Rebuild the site to the Claude Design drafts
Carries the 11 page drafts plus the shared SiteNav and SiteFooter onto the project's own design system: tier-2 tokens, the published type roles, logical properties throughout, and both locales. Surfaces - The page stands on #0B0B0A with #1D1D1B bands lifted off it, as the drafts do; the body previously painted the band colour, so every band was a step too light. - A card is drawn one step off whatever it stands on (#1D1D1B on the ground, #141413 inside a band). Components paint --surface-card and a band re-points it for its subtree, so a card can never come out the same colour as its band. Shell - Nav: translucent ground with a blur, chevrons on group triggers, an accent rule under the current section, the drafts' compact dropdown card, and an EN | AR toggle in place of the globe menu. - Footer: the drafts' four-column grid, one-line brand text, Facebook as a bordered button, the EU lockup, the implemented-by row and the funding disclaimer. Pages - Home: hero with counting figures, launch band, three areas of change, newsroom, FAQ accordion and the consortium band. - The nine interior pages rebuilt and then audited section by section against their drafts. - Applications: the three-step application form now posts real submissions to the Strapi `application` type, validated server-side and working without JavaScript. Motion - Every animation in the drafts, as classes in _page-kit/motion.css: staggered entrances, scroll-driven reveals, the drifting washes (each page carries its own geometry), the sheen, the counting figures and the accordion. The drafts' coloured hover glow is deliberately not shipped. CMS - New `faq` collection type behind the home page's accordion. - `role` added to the consortium member component so an editor can supply the chip and the deliverable separately. Content - No invented figure, date, name or commitment ships: the five-working-day reply, the placeholder email and Facebook handle, the sample news, events, publications and FAQs, the invented application rules and the TBD counters are all withheld pending real content. Figures that survived (3 consortium partners, 4 areas of work, 16 human security sub-projects, Dec 2025 - Dec 2027) are verified against docs/ and the bundled draft copy. Verified: pnpm typecheck (web + CMS) and pnpm --filter web build pass; all 20 page/locale URLs render 200 through the built SSR handler, including with the CMS unreachable; no raw hex or physical-direction utility in components or routes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,16 @@ import type { Locale } from "~/i18n";
|
||||
|
||||
const EN = {
|
||||
events: {
|
||||
/* The kicker above an event title. "other" is intentionally empty: an
|
||||
event the CMS did not classify simply gets no kicker. */
|
||||
eventTypes: {
|
||||
"suq-dialogue": "SUQ Dialogue",
|
||||
training: "Training",
|
||||
"law-session": "Law Session",
|
||||
"network-meeting": "Network Meeting",
|
||||
launch: "Launch",
|
||||
other: "",
|
||||
},
|
||||
/** Draft 4, EN headline. */
|
||||
heading: "Upcoming Events",
|
||||
/** Draft 4, EN standfirst. */
|
||||
@@ -79,6 +89,9 @@ const EN = {
|
||||
metaDescription:
|
||||
"Calls for expression of interest, SHARP network membership and upcoming sessions — how civil society organisations and individuals take part in SHARP-ER.",
|
||||
pathwaysHeading: "Ways to take part",
|
||||
/* The draft has no call list; this single line keeps the open calls
|
||||
reachable in-site now that the listing band is gone. */
|
||||
currentCallsLabel: "Open calls:",
|
||||
pathwayCallsTitle: "Apply for a Call for Expression of Interest",
|
||||
pathwayCallsBody:
|
||||
"Open calls for CSO sub-projects and Policy-to-Rights (P2R) initiatives.",
|
||||
@@ -88,14 +101,81 @@ const EN = {
|
||||
pathwaySessionsTitle: "Register for a SUQ Dialogue or Training",
|
||||
pathwaySessionsBody:
|
||||
"Take part in an upcoming Search, Understand, Question dialogue or a dialogue-facilitation training.",
|
||||
listHeading: "Current calls",
|
||||
listLabel: "Calls and opportunities",
|
||||
statusFilterLabel: "Status",
|
||||
empty: "There are no calls or opportunities to show yet.",
|
||||
emptyHint:
|
||||
"Calls for expression of interest are published here as soon as they open.",
|
||||
unavailable:
|
||||
"Calls and opportunities cannot be loaded right now. Please try again in a moment.",
|
||||
},
|
||||
/* -------------------------------------------------------------------------
|
||||
THE APPLICATION FORM - the redesign draft's #apply band
|
||||
----------------------------------------------------------------------------
|
||||
THREE OF THE DRAFT'S SENTENCES ARE NOT REPRODUCED, on the client's own
|
||||
instruction and under CLAUDE.md rule 5:
|
||||
|
||||
* "PDF or DOCX, up to 10 MB" - not the rule this project runs. The CMS
|
||||
ceiling is UPLOAD_MAX_FILE_SIZE (50 MB by default) over the media types
|
||||
listed in apps/cms/config/plugins.ts, and in any case nothing is
|
||||
uploaded here (see `documentsNote`), so no limit is quoted at all.
|
||||
* "an automatic acknowledgment with your reference number" - there is no
|
||||
reference-number field anywhere in the CMS, so no number is promised.
|
||||
The acknowledgement itself IS modelled, as
|
||||
`application.acknowledgementSentAt`, so it is the only half that
|
||||
survives.
|
||||
* "follows up within four weeks" - that response time appears in no
|
||||
approved document; §8 of the draft marks it "[X weeks] - TBD". The
|
||||
follow-up is stated without a period attached, exactly as the per-call
|
||||
acknowledgement already does.
|
||||
---------------------------------------------------------------------- */
|
||||
apply: {
|
||||
/** The draft's "Step {n} of 3" kicker over the form. */
|
||||
stepOf: "Step {current} of {total}",
|
||||
/** Accessible name of the progress list. */
|
||||
stepsLabel: "Application progress",
|
||||
stepEligibility: "Eligibility",
|
||||
stepApplication: "Application",
|
||||
stepReview: "Review",
|
||||
/** Announced on the step the reader is on. */
|
||||
stepCurrent: "current step",
|
||||
stepDone: "done",
|
||||
/** The standfirst beside the form. */
|
||||
standfirst:
|
||||
"Submissions are acknowledged automatically by email. The consortium reviews complete applications and follows up by email.",
|
||||
formLabel: "Application form",
|
||||
fieldsetLegend: "Your details",
|
||||
routeLabel: "What are you applying for?",
|
||||
/* The short names the draft gives the three routes - the long ones head
|
||||
the pathway cards above and are too long for a select. */
|
||||
routeCalls: "Call for Expression of Interest",
|
||||
routeNetwork: "SHARP Network membership",
|
||||
routeSessions: "SUQ dialogue or training",
|
||||
organisationLabel: "Organisation name",
|
||||
organisationPlaceholder: "Registered legal name",
|
||||
contactLabel: "Contact person",
|
||||
contactPlaceholder: "Full name",
|
||||
emailLabel: "Email",
|
||||
emailPlaceholder: "name@organisation.ly",
|
||||
regionLabel: "Region of operation",
|
||||
regionPlaceholder: "City or municipality",
|
||||
yearsLabel: "Years of operating history",
|
||||
yearsPlaceholder: "e.g. 4",
|
||||
summaryLabel: "Summary of your proposal",
|
||||
summaryHint: "Briefly describe your organisation and why you are applying.",
|
||||
documentsTitle: "Supporting documents",
|
||||
/**
|
||||
* The draft draws a file input here. There is nowhere for the file to go:
|
||||
* `attachments` is deliberately off the public whitelist in
|
||||
* apps/cms/src/api/application/middlewares/sanitize-submission.ts, and the
|
||||
* Public role cannot upload. A control that accepted a document and then
|
||||
* dropped it would be worse than none, so the panel says plainly what the
|
||||
* form does with documents - and quotes no size limit, because it accepts
|
||||
* no files at all.
|
||||
*/
|
||||
documentsNote:
|
||||
"Documents are not uploaded through this form. Describe any supporting material in the summary above.",
|
||||
/** `application.acknowledgementSentAt` - modelled, so it can be said. */
|
||||
acknowledgement: "Your submission is acknowledged automatically by email.",
|
||||
submit: "Submit application",
|
||||
errorSummaryTitle: "Please check the form",
|
||||
receivedTitle: "Application received",
|
||||
receivedBody:
|
||||
"Thank you for applying to {route}. An acknowledgement is on its way to the email address you gave us, and our team will review your application and follow up by email.",
|
||||
submitAnother: "Submit another",
|
||||
},
|
||||
/** `opportunity.opportunityType`, verbatim, with the draft's own wording. */
|
||||
opportunityTypes: {
|
||||
@@ -143,6 +223,14 @@ export type RouteCopy = typeof EN;
|
||||
|
||||
const AR: RouteCopy = {
|
||||
events: {
|
||||
eventTypes: {
|
||||
"suq-dialogue": "حوار سوق",
|
||||
training: "تدريب",
|
||||
"law-session": "جلسة قانونية",
|
||||
"network-meeting": "اجتماع الشبكة",
|
||||
launch: "إطلاق",
|
||||
other: "",
|
||||
},
|
||||
heading: "الفعاليات القادمة",
|
||||
intro:
|
||||
"سجّل في حوار SUQ، أو انضم إلى تدريب، أو تابع ما هو قادم في مختلف أنحاء ليبيا.",
|
||||
@@ -189,6 +277,7 @@ const AR: RouteCopy = {
|
||||
metaDescription:
|
||||
"دعوات للتعبير عن الاهتمام، وعضوية شبكة SHARP، وجلسات قادمة — كيف تشارك منظمات المجتمع المدني والأفراد في SHARP-ER.",
|
||||
pathwaysHeading: "طرق المشاركة",
|
||||
currentCallsLabel: "الدعوات المفتوحة:",
|
||||
pathwayCallsTitle: "التقديم على دعوة للتعبير عن الاهتمام",
|
||||
pathwayCallsBody:
|
||||
"دعوات مفتوحة للمشاريع الفرعية لمنظمات المجتمع المدني ومبادرات «من السياسة إلى الحقوق» (P2R).",
|
||||
@@ -198,13 +287,45 @@ const AR: RouteCopy = {
|
||||
pathwaySessionsTitle: "سجّل في حوار SUQ أو تدريب",
|
||||
pathwaySessionsBody:
|
||||
"شارك في حوار قادم من نوع «ابحث، افهم، اسأل» أو في تدريب على تيسير الحوار.",
|
||||
listHeading: "الدعوات الحالية",
|
||||
listLabel: "الدعوات والفرص",
|
||||
statusFilterLabel: "الحالة",
|
||||
empty: "لا توجد دعوات أو فرص لعرضها حتى الآن.",
|
||||
emptyHint: "تُنشر هنا دعوات التعبير عن الاهتمام فور فتحها.",
|
||||
unavailable:
|
||||
"تعذّر تحميل الدعوات والفرص في الوقت الحالي. يرجى المحاولة مرة أخرى بعد قليل.",
|
||||
},
|
||||
apply: {
|
||||
stepOf: "الخطوة {current} من {total}",
|
||||
stepsLabel: "مراحل تقديم الطلب",
|
||||
stepEligibility: "الأهلية",
|
||||
stepApplication: "الطلب",
|
||||
stepReview: "المراجعة",
|
||||
stepCurrent: "الخطوة الحالية",
|
||||
stepDone: "مكتملة",
|
||||
standfirst:
|
||||
"يصلك إشعار تلقائي باستلام طلبك عبر البريد الإلكتروني. ويراجع الائتلاف الطلبات المكتملة ويتواصل معك عبر البريد الإلكتروني.",
|
||||
formLabel: "نموذج التقديم",
|
||||
fieldsetLegend: "بياناتك",
|
||||
routeLabel: "ما الذي تقدّم عليه؟",
|
||||
routeCalls: "دعوة للتعبير عن الاهتمام",
|
||||
routeNetwork: "عضوية شبكة SHARP",
|
||||
routeSessions: "حوار SUQ أو تدريب",
|
||||
organisationLabel: "اسم المنظمة",
|
||||
organisationPlaceholder: "الاسم القانوني المسجَّل",
|
||||
contactLabel: "الشخص المسؤول",
|
||||
contactPlaceholder: "الاسم الكامل",
|
||||
emailLabel: "البريد الإلكتروني",
|
||||
emailPlaceholder: "name@organisation.ly",
|
||||
regionLabel: "نطاق العمل",
|
||||
regionPlaceholder: "المدينة أو البلدية",
|
||||
yearsLabel: "عدد سنوات العمل",
|
||||
yearsPlaceholder: "مثال: 4",
|
||||
summaryLabel: "ملخّص المقترح",
|
||||
summaryHint: "صف باختصار منظمتك وسبب تقديمك.",
|
||||
documentsTitle: "المستندات الداعمة",
|
||||
documentsNote:
|
||||
"لا تُرفَق المستندات عبر هذا النموذج. صف أي مواد داعمة في الملخّص أعلاه.",
|
||||
acknowledgement: "يصلك إشعار تلقائي باستلام طلبك عبر البريد الإلكتروني.",
|
||||
submit: "إرسال الطلب",
|
||||
errorSummaryTitle: "يرجى مراجعة النموذج",
|
||||
receivedTitle: "تم استلام الطلب",
|
||||
receivedBody:
|
||||
"شكرًا لتقديمك على {route}. سيصلك إشعار بالاستلام على البريد الإلكتروني الذي زوّدتنا به، وسيراجع فريقنا طلبك ويتواصل معك عبر البريد الإلكتروني.",
|
||||
submitAnother: "تقديم طلب آخر",
|
||||
},
|
||||
opportunityTypes: {
|
||||
"call-eoi": "دعوة للتعبير عن الاهتمام",
|
||||
|
||||
@@ -69,6 +69,8 @@ import {
|
||||
type EventDetailData,
|
||||
type EventFormat,
|
||||
type EventRegistrationStatus,
|
||||
EVENT_TYPES,
|
||||
type EventType,
|
||||
} from "./events";
|
||||
|
||||
export const EVENTS_RESOURCE = "events";
|
||||
@@ -83,6 +85,7 @@ interface StrapiEvent extends StrapiEntity, CmsLocalizable {
|
||||
readonly body?: CmsRichText;
|
||||
readonly startDateTime: string;
|
||||
readonly endDateTime?: string | null;
|
||||
readonly eventType?: EventType | null;
|
||||
readonly format?: EventFormat | null;
|
||||
readonly venue?: string | null;
|
||||
readonly region?: CmsTaxonomy | null;
|
||||
@@ -135,6 +138,7 @@ function toCard(entry: StrapiEvent, now: Date): EventCardData {
|
||||
start,
|
||||
end,
|
||||
allDay,
|
||||
eventType: oneOf(entry.eventType, EVENT_TYPES),
|
||||
format: oneOf(entry.format, FORMATS),
|
||||
place: text(entry.venue),
|
||||
region: text(entry.region?.name),
|
||||
|
||||
@@ -53,6 +53,22 @@ export type EventRegistrationStatus =
|
||||
| "waitlist"
|
||||
| "not-required";
|
||||
|
||||
/**
|
||||
* api::event.eventType - what kind of session this is. The redesign prints it
|
||||
* as a kicker above the title ("SUQ Dialogue", "Training"), so the label map
|
||||
* lives with the rest of the route copy, in both locales.
|
||||
*/
|
||||
export const EVENT_TYPES = [
|
||||
"suq-dialogue",
|
||||
"training",
|
||||
"law-session",
|
||||
"network-meeting",
|
||||
"launch",
|
||||
"other",
|
||||
] as const;
|
||||
|
||||
export type EventType = (typeof EVENT_TYPES)[number];
|
||||
|
||||
export const EVENT_REGISTRATION_STATUSES = [
|
||||
"open",
|
||||
"closed",
|
||||
@@ -85,6 +101,8 @@ export function isEventStatusFilter(value: unknown): value is EventStatusFilter
|
||||
export interface EventCardData {
|
||||
readonly slug: string;
|
||||
readonly title: string;
|
||||
/** Kicker above the title. Absent when the CMS leaves the type unset. */
|
||||
readonly eventType?: EventType;
|
||||
readonly summary?: string;
|
||||
/** ISO 8601. Date-only when `allDay`. */
|
||||
readonly start: string;
|
||||
|
||||
@@ -54,6 +54,10 @@ const LIMITS = {
|
||||
email: 254,
|
||||
phone: 40,
|
||||
message: 4000,
|
||||
/** Free text - "Tripoli", "Sabha and the south". Not a taxonomy. */
|
||||
region: 160,
|
||||
/** Free text on purpose: "4", "under a year", "since 2016" are all answers. */
|
||||
years: 40,
|
||||
} as const;
|
||||
|
||||
function field(form: FormData, name: string, limit: number): string {
|
||||
@@ -150,3 +154,103 @@ export function validateApplication(
|
||||
|
||||
return { values, errors, valid: Object.keys(errors).length === 0 };
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
GET INVOLVED - the application form on the section's own page
|
||||
----------------------------------------------------------------------------
|
||||
The redesign draft puts a single application form under the three pathway
|
||||
cards, with five fields and a proposal summary. It is the same contract as
|
||||
the per-call form above, plus the two answers the draft asks for (region of
|
||||
operation, years of operating history) and the pathway being applied under.
|
||||
|
||||
WHAT IS REQUIRED, AND WHY EXACTLY THOSE THREE
|
||||
`application.applicantName` and `application.email` are `required: true` in
|
||||
the CMS, so a submission without them is a 400 that costs the applicant
|
||||
everything they typed - they are validated here first. The summary is
|
||||
required because an application with no case in it cannot be reviewed.
|
||||
|
||||
Everything else is optional BY DESIGN, not by omission: the page invites
|
||||
"a civil society organisation or an individual", so an organisation name, a
|
||||
region and a number of years of operating history cannot be demanded
|
||||
without shutting the individuals out of the form that invites them.
|
||||
|
||||
`yearsOperating` is a STRING, deliberately. "4", "under a year" and "since
|
||||
2016" are all true answers to the draft's question, and a number input would
|
||||
reject two of them; it reaches the CMS inside `answers`, where a reviewer
|
||||
reads it, and nothing computes on it.
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* The three ways in, as the draft names them. The value is carried in the
|
||||
* query string (`?route=`) so a pathway card can open the form already set to
|
||||
* its own route, and it is submitted as a real `<select>` so the reader can
|
||||
* correct it - and so the choice still travels with scripting off.
|
||||
*/
|
||||
export type ApplicationRoute = "calls" | "network" | "sessions";
|
||||
|
||||
export const APPLICATION_ROUTES = [
|
||||
"calls",
|
||||
"network",
|
||||
"sessions",
|
||||
] as const satisfies readonly ApplicationRoute[];
|
||||
|
||||
export function isApplicationRoute(value: unknown): value is ApplicationRoute {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(APPLICATION_ROUTES as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
export interface PathwayApplicationValues {
|
||||
readonly route: ApplicationRoute;
|
||||
readonly organisation: string;
|
||||
readonly fullName: string;
|
||||
readonly email: string;
|
||||
readonly region: string;
|
||||
readonly yearsOperating: string;
|
||||
readonly summary: string;
|
||||
}
|
||||
|
||||
/** The field name of the pathway select, shared by the form and the parser. */
|
||||
export const ROUTE_FIELD = "route";
|
||||
|
||||
export function validatePathwayApplication(
|
||||
form: FormData,
|
||||
fallbackRoute: ApplicationRoute = "calls",
|
||||
): Validated<PathwayApplicationValues> {
|
||||
const submittedRoute = form.get(ROUTE_FIELD);
|
||||
|
||||
const values: PathwayApplicationValues = {
|
||||
// An unknown value is not an error the reader can act on - it cannot be
|
||||
// produced by the select - so it falls back rather than blocking a submit.
|
||||
route: isApplicationRoute(submittedRoute) ? submittedRoute : fallbackRoute,
|
||||
organisation: field(form, "organisation", LIMITS.organisation),
|
||||
fullName: field(form, "fullName", LIMITS.name),
|
||||
email: field(form, "email", LIMITS.email),
|
||||
region: field(form, "region", LIMITS.region),
|
||||
yearsOperating: field(form, "yearsOperating", LIMITS.years),
|
||||
summary: field(form, "summary", LIMITS.message),
|
||||
};
|
||||
|
||||
const errors: Record<string, FieldErrorCode> = {};
|
||||
|
||||
if (!values.fullName) errors.fullName = "required";
|
||||
if (!values.email) errors.email = "required";
|
||||
else if (!EMAIL.test(values.email)) errors.email = "email";
|
||||
if (!values.summary) errors.summary = "required";
|
||||
|
||||
return { values, errors, valid: Object.keys(errors).length === 0 };
|
||||
}
|
||||
|
||||
/** The form's fields in reading order - the error summary lists them in it. */
|
||||
export const PATHWAY_APPLICATION_FIELDS = [
|
||||
"organisation",
|
||||
"fullName",
|
||||
"email",
|
||||
"region",
|
||||
"yearsOperating",
|
||||
"summary",
|
||||
] as const;
|
||||
|
||||
export type PathwayApplicationField =
|
||||
(typeof PATHWAY_APPLICATION_FIELDS)[number];
|
||||
|
||||
@@ -49,7 +49,11 @@
|
||||
`findOne` are NOT, or anyone could read everyone else's contact details.
|
||||
========================================================================= */
|
||||
|
||||
import type { ApplicationValues, RegistrationValues } from "./forms";
|
||||
import type {
|
||||
ApplicationValues,
|
||||
PathwayApplicationValues,
|
||||
RegistrationValues,
|
||||
} from "./forms";
|
||||
import { createStrapiEntry } from "./strapi-write.server";
|
||||
|
||||
export const EVENT_REGISTRATIONS_RESOURCE = "event-registrations";
|
||||
@@ -104,3 +108,73 @@ export async function submitApplication(
|
||||
answers: { [APPLICATION_MESSAGE_KEY]: input.values.message },
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
GET INVOLVED - the section page's own application
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* The keys the Get Involved form files inside `answers` (json).
|
||||
*
|
||||
* They are named here rather than spelled at the call site because a reviewer
|
||||
* reads them in the admin panel: renaming one silently orphans every answer
|
||||
* already stored under the old key.
|
||||
*
|
||||
* `message` is deliberately the SAME key the per-call form writes
|
||||
* (APPLICATION_MESSAGE_KEY above), so the free-text case for applying reads
|
||||
* out of one place whichever form it arrived through.
|
||||
*/
|
||||
export const APPLICATION_ANSWER_KEYS = {
|
||||
/** Which of the three pathways the applicant chose. */
|
||||
route: "route",
|
||||
region: "regionOfOperation",
|
||||
yearsOperating: "yearsOperating",
|
||||
summary: APPLICATION_MESSAGE_KEY,
|
||||
} as const;
|
||||
|
||||
export interface PathwayApplicationSubmission {
|
||||
readonly values: PathwayApplicationValues;
|
||||
/**
|
||||
* documentId of the opportunity this application belongs to, when one is
|
||||
* known. The Get Involved form is NOT tied to a single call - it is the
|
||||
* section's general way in - so it is usually absent, and `opportunity` is
|
||||
* optional on the content type precisely for that case. It is never guessed
|
||||
* from "the nearest open call": an application filed against a call the
|
||||
* applicant never named is a fabricated relation.
|
||||
*/
|
||||
readonly opportunityId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a Get Involved application. The payload is the CMS whitelist and
|
||||
* nothing more (see the header): the two extra answers the draft asks for ride
|
||||
* inside `answers`, which is on that whitelist, so they need no migration.
|
||||
*
|
||||
* `attachments` is NOT sent, and the form does not offer an upload. See the
|
||||
* header of this file and the `sanitize-submission` middleware in the CMS for
|
||||
* why a public caller cannot supply one.
|
||||
*/
|
||||
export async function submitPathwayApplication(
|
||||
input: PathwayApplicationSubmission,
|
||||
): Promise<void> {
|
||||
const { values } = input;
|
||||
|
||||
await createStrapiEntry(APPLICATIONS_RESOURCE, {
|
||||
...(input.opportunityId ? { opportunity: input.opportunityId } : {}),
|
||||
applicantName: values.fullName,
|
||||
email: values.email,
|
||||
organisation: optional(values.organisation),
|
||||
answers: {
|
||||
[APPLICATION_ANSWER_KEYS.route]: values.route,
|
||||
// Only the answers actually given: an empty string in the admin panel
|
||||
// reads as "answered, with nothing", which is not what happened.
|
||||
...(values.region
|
||||
? { [APPLICATION_ANSWER_KEYS.region]: values.region }
|
||||
: {}),
|
||||
...(values.yearsOperating
|
||||
? { [APPLICATION_ANSWER_KEYS.yearsOperating]: values.yearsOperating }
|
||||
: {}),
|
||||
[APPLICATION_ANSWER_KEYS.summary]: values.summary,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/* ============================================================================
|
||||
PAGE KIT - FAQ accordion
|
||||
----------------------------------------------------------------------------
|
||||
The disclosure list the home page's FAQ band draws. One question is open at
|
||||
a time, as the design draft has it, and the first one is open on arrival so
|
||||
the band never reads as a row of empty bars.
|
||||
|
||||
PATTERN: a plain disclosure - a <button aria-expanded> that controls the
|
||||
panel right after it. Deliberately NOT an ARIA accordion widget with arrow
|
||||
key roving: there is one tab stop per question, which is what a keyboard
|
||||
user expects from a list of links-like controls, and it matches the
|
||||
disclosure the navigation already ships (DesktopNav, LanguageMenu).
|
||||
|
||||
The answer is CMS richtext (markdown), so it renders through <RichText />
|
||||
rather than as a raw string.
|
||||
|
||||
RULES: tier-2 tokens only, logical properties only, and the open/close
|
||||
motion is a class from motion.css so this module carries no keyframes.
|
||||
========================================================================= */
|
||||
|
||||
import { useId, useState } from "react";
|
||||
|
||||
import { RichText } from "./rich-text";
|
||||
import { cx } from "./sections";
|
||||
|
||||
import "./motion.css";
|
||||
|
||||
export interface AccordionItem {
|
||||
readonly id: string;
|
||||
readonly question: string;
|
||||
/** Markdown, as authored in the CMS richtext field. */
|
||||
readonly answer: string;
|
||||
}
|
||||
|
||||
export interface AccordionProps {
|
||||
items: readonly AccordionItem[];
|
||||
/**
|
||||
* Index of the question open on first paint, or null for an all-closed
|
||||
* list. The server renders this same state, so nothing shifts on hydration.
|
||||
*/
|
||||
defaultOpenIndex?: number | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** The +/- mark. The upright bar collapses as the question opens. */
|
||||
function ToggleMark({ open }: { open: boolean }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cx(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-pill",
|
||||
"border border-[var(--border-default)] text-[var(--text-accent)]",
|
||||
"transition-colors duration-[var(--motion-duration-base)]",
|
||||
open && "border-[var(--border-accent)] bg-[var(--surface-accent-subtle)]",
|
||||
)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||
<path d="M1 7h12" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<path
|
||||
d="M7 1v12"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
className={cx(
|
||||
"origin-center transition-transform duration-[var(--motion-duration-base)]",
|
||||
open ? "scale-y-0" : "scale-y-100",
|
||||
)}
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Accordion({ items, defaultOpenIndex = 0, className }: AccordionProps) {
|
||||
const baseId = useId();
|
||||
const initial =
|
||||
defaultOpenIndex === null ? null : (items[defaultOpenIndex]?.id ?? null);
|
||||
const [openId, setOpenId] = useState<string | null>(initial);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={cx("flex flex-col border-t border-[var(--border-default)]", className)}>
|
||||
{items.map((item) => {
|
||||
const open = item.id === openId;
|
||||
const buttonId = `${baseId}-${item.id}-question`;
|
||||
const panelId = `${baseId}-${item.id}-answer`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cx(
|
||||
"border-b border-[var(--border-default)]",
|
||||
"transition-colors duration-[var(--motion-duration-base)]",
|
||||
"hover:bg-[var(--surface-muted)]",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
id={buttonId}
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
onClick={() => setOpenId(open ? null : item.id)}
|
||||
className={cx(
|
||||
"flex w-full cursor-pointer items-center justify-between gap-lg",
|
||||
"bg-transparent py-lg text-start",
|
||||
"body-lg-bold text-[var(--text-primary)]",
|
||||
"transition-colors duration-[var(--motion-duration-base)]",
|
||||
"hover:text-[var(--text-accent)]",
|
||||
)}
|
||||
>
|
||||
<span>{item.question}</span>
|
||||
<ToggleMark open={open} />
|
||||
</button>
|
||||
|
||||
{/* Kept out of the tree entirely while closed, so a screen reader
|
||||
never reaches an answer whose question reads as collapsed. */}
|
||||
{open ? (
|
||||
<div
|
||||
id={panelId}
|
||||
role="region"
|
||||
aria-labelledby={buttonId}
|
||||
className="sh-answer pb-lg pe-3xl body-md text-[var(--text-secondary)]"
|
||||
>
|
||||
<RichText markdown={item.answer} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -103,6 +103,8 @@ export interface CmsPartner extends StrapiEntity {
|
||||
export interface CmsConsortiumMember {
|
||||
readonly id?: number;
|
||||
readonly partner?: CmsPartner | null;
|
||||
/** Short role chip - "Lead implementing partner", "Consortium partner". */
|
||||
readonly role?: string | null;
|
||||
readonly roleDescription?: string | null;
|
||||
}
|
||||
|
||||
@@ -145,6 +147,27 @@ export function isRichTextBlock(block: CmsBlock): block is CmsRichTextBlock {
|
||||
return block.__component === "blocks.rich-text";
|
||||
}
|
||||
|
||||
/** api::news-article.news-article - the card shapes the home page needs. */
|
||||
export interface CmsNewsArticle extends StrapiEntity {
|
||||
readonly title: string;
|
||||
readonly slug: string;
|
||||
readonly excerpt?: string | null;
|
||||
readonly category: "sharper-news" | "from-the-network";
|
||||
/** Not localised in the CMS: one date for both languages. */
|
||||
readonly publishDate: string;
|
||||
readonly coverImage?: StrapiMedia | null;
|
||||
readonly authorOrganisation?: Pick<CmsPartner, "name" | "slug"> | null;
|
||||
readonly featured?: boolean | null;
|
||||
}
|
||||
|
||||
/** api::faq.faq - one question of the home page's FAQ accordion. */
|
||||
export interface CmsFaq extends StrapiEntity {
|
||||
readonly question: string;
|
||||
/** Markdown from the CMS richtext editor. */
|
||||
readonly answer: string;
|
||||
readonly order?: number | null;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
SINGLE TYPES
|
||||
------------------------------------------------------------------------ */
|
||||
@@ -154,6 +177,8 @@ export interface CmsHomePage extends StrapiEntity {
|
||||
readonly hero?: CmsHero | null;
|
||||
readonly stats?: readonly CmsStat[] | null;
|
||||
readonly blocks?: readonly CmsBlock[] | null;
|
||||
/** Curated newsroom picks; the page falls back to the latest articles. */
|
||||
readonly featuredNews?: readonly CmsNewsArticle[] | null;
|
||||
readonly partnersHeadline?: string | null;
|
||||
readonly partners?: readonly CmsPartner[] | null;
|
||||
readonly seo?: CmsSeo | null;
|
||||
|
||||
@@ -44,10 +44,16 @@ export interface TitledCopy {
|
||||
readonly description: string;
|
||||
}
|
||||
|
||||
/** One consortium member and the role the draft assigns it. */
|
||||
/**
|
||||
* One consortium member, split the way the redesign draws it: a role chip, the
|
||||
* member's name, then what it delivers. Both halves are the approved draft's
|
||||
* own wording - section 1 writes each member as
|
||||
* "<name> - <role> - <deliverable>".
|
||||
*/
|
||||
export interface ConsortiumCopy {
|
||||
readonly name: string;
|
||||
readonly role: string;
|
||||
readonly description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,47 +65,105 @@ export type ProseRun = string | { readonly em: string };
|
||||
|
||||
export interface HomeCopy {
|
||||
readonly heroHeadline: string;
|
||||
readonly statsHeading: string;
|
||||
readonly heroLead: string;
|
||||
readonly heroCtaPrimary: string;
|
||||
readonly heroCtaSecondary: string;
|
||||
/**
|
||||
* The figures band. Bundled so the band is never empty, and overridden by
|
||||
* `home-page.stats` the moment an editor writes one. Every figure here is
|
||||
* verifiable: three consortium members and the Dec 2025 - Dec 2027 run come
|
||||
* from the project brief, the four areas of work are the What We Do
|
||||
* workstreams, and the sixteen human security sub-projects are the approved
|
||||
* EN/AR draft's own wording.
|
||||
*/
|
||||
readonly stats: readonly { readonly value: string; readonly label: string }[];
|
||||
readonly launchEyebrow: string;
|
||||
readonly launchHeading: string;
|
||||
/** One paragraph, as the design draws it - not the full three-part story. */
|
||||
readonly launchLead: readonly ProseRun[];
|
||||
readonly mechanismsHeading: string;
|
||||
readonly mechanisms: readonly string[];
|
||||
readonly partnersHeading: string;
|
||||
readonly launchLead: string;
|
||||
readonly workHeading: string;
|
||||
readonly workLinkLabel: string;
|
||||
readonly newsEyebrow: string;
|
||||
readonly newsHeading: string;
|
||||
readonly newsEmpty: string;
|
||||
readonly faqEyebrow: string;
|
||||
readonly faqHeading: string;
|
||||
readonly faqLead: string;
|
||||
readonly faqEmpty: string;
|
||||
readonly faqCtaLabel: string;
|
||||
readonly consortiumHeading: string;
|
||||
/**
|
||||
* The same three members the About page lists, split the way the redesign
|
||||
* draws them: a role chip, the name, then what they deliver.
|
||||
*/
|
||||
readonly consortium: readonly {
|
||||
readonly role: string;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
}[];
|
||||
readonly networkLabel: string;
|
||||
readonly partnersEmpty: string;
|
||||
}
|
||||
|
||||
export interface AboutCopy {
|
||||
/** Draft eyebrow over the hero headline - "About the project". */
|
||||
readonly heroEyebrow: string;
|
||||
readonly heroHeadline: string;
|
||||
readonly lead: string;
|
||||
readonly body: string;
|
||||
/**
|
||||
* The four-cell band under the hero. These are project FACTS, not figures:
|
||||
* the run, the coverage, the lead partner and the funder, each one written
|
||||
* out in the approved draft's first section. Nothing here is counted or
|
||||
* estimated - the draft marks every statistic "TBD", so none is published.
|
||||
*/
|
||||
readonly facts: readonly { readonly label: string; readonly value: string }[];
|
||||
readonly bodyHeading: string;
|
||||
/**
|
||||
* The approved body prose, one entry per paragraph. The draft sets it as two
|
||||
* paragraphs beside the focus-area column, which a single string cannot
|
||||
* express.
|
||||
*/
|
||||
readonly body: readonly string[];
|
||||
readonly consortiumHeading: string;
|
||||
readonly consortium: readonly ConsortiumCopy[];
|
||||
readonly pillarsHeading: string;
|
||||
readonly pillars: readonly TitledCopy[];
|
||||
readonly legacyEyebrow: string;
|
||||
readonly legacyHeading: string;
|
||||
readonly legacyBody: string;
|
||||
readonly legacyLinkLabel: string;
|
||||
readonly fundingNote: string;
|
||||
}
|
||||
|
||||
export interface WhatWeDoCopy {
|
||||
readonly heroHeadline: string;
|
||||
readonly lead: string;
|
||||
/**
|
||||
* The draft's own heading over the areas grid. It restates the approved
|
||||
* "SHARP-ER is organised around four connected areas of work" line, which is
|
||||
* why the draft prints no separate intro paragraph under it and neither does
|
||||
* the page.
|
||||
*/
|
||||
readonly workstreamsHeading: string;
|
||||
readonly workstreamsIntro: string;
|
||||
readonly workstreams: readonly TitledCopy[];
|
||||
readonly valuesLabel: string;
|
||||
readonly values: readonly string[];
|
||||
/** Not drawn - it names the principles block for the document outline. */
|
||||
readonly principlesHeading: string;
|
||||
readonly principles: string;
|
||||
/**
|
||||
* The redesign draws the four principles as tiles. These are the four
|
||||
* clauses of the approved "Our approach rests on four principles…" sentence
|
||||
* and nothing else: the draft gives each tile an explanatory line, none of
|
||||
* which is in any approved document, so the tiles carry the clause alone.
|
||||
*/
|
||||
readonly principleTitles: readonly string[];
|
||||
readonly activitiesCtaHeading: string;
|
||||
}
|
||||
|
||||
export interface ContactCopy {
|
||||
readonly heroHeadline: string;
|
||||
readonly lead: string;
|
||||
/** Not drawn - it names the form for the document outline. */
|
||||
readonly formHeading: string;
|
||||
readonly formIntro: string;
|
||||
/** Not drawn - it names the channels column for the document outline. */
|
||||
readonly detailsHeading: string;
|
||||
readonly generalEnquiries: string;
|
||||
readonly websiteLabel: string;
|
||||
@@ -113,6 +177,8 @@ export interface ContactCopy {
|
||||
readonly errorTooLong: string;
|
||||
readonly honeypotLabel: string;
|
||||
readonly organisationHint: string;
|
||||
/** Link text for the project's Facebook page. */
|
||||
readonly facebookLabel: string;
|
||||
}
|
||||
|
||||
export interface PageCopy {
|
||||
@@ -128,50 +194,89 @@ export interface PageCopy {
|
||||
|
||||
const EN: PageCopy = {
|
||||
home: {
|
||||
/* The frame's wordmark above the headline carries the project name, so
|
||||
the headline itself does not repeat it (figma I253:4800;228:2609). */
|
||||
heroHeadline: "Project Officially Launches in Libya",
|
||||
statsHeading: "SHARP-ER in numbers",
|
||||
launchHeading: "The Journey of SHARP-ER Launch",
|
||||
launchLead: [
|
||||
"The SHARP-ER project officially launched on ",
|
||||
{ em: "Tuesday, June 30" },
|
||||
", joining representatives from the ",
|
||||
{ em: "European Union Delegation" },
|
||||
", key ",
|
||||
{ em: "Libyan Ministries" },
|
||||
", and ",
|
||||
{ em: "local civil society partners" },
|
||||
". The event marked the launch of the project’s official website and the Call for Expression of Interest (EOI).",
|
||||
heroHeadline: "SHARP-ER Project Officially Launches in Libya",
|
||||
heroLead:
|
||||
"Shared Action on Rule of Law Progress — Exit Route: a nationwide initiative supporting Libya’s civil society, local institutions, and communities in advancing justice, accountability, and good governance.",
|
||||
heroCtaPrimary: "Expression of Interest",
|
||||
heroCtaSecondary: "About the project",
|
||||
stats: [
|
||||
{ value: "3", label: "Consortium partners" },
|
||||
{ value: "4", label: "Areas of work" },
|
||||
{ value: "16", label: "Human security sub-projects" },
|
||||
{ value: "25 mo", label: "Dec 2025 – Dec 2027" },
|
||||
],
|
||||
mechanismsHeading: "What the launch set in motion",
|
||||
mechanisms: [
|
||||
"Building local capacity and skills",
|
||||
"Fostering cross-sector partnerships",
|
||||
"Supporting municipal and community-led initiatives",
|
||||
launchEyebrow: "30 June · Launch event",
|
||||
launchHeading: "An important start for the project",
|
||||
launchLead:
|
||||
"The launch brought together representatives from the European Union Delegation, the Ministry of Youth, the Ministry of Local Governance, the Ministry of Women’s Affairs, civil society organisations, partners, and stakeholders from across Libya. The event marked the opening of the Expression of Interest and the launch of this website.",
|
||||
workHeading: "Three areas of change",
|
||||
workLinkLabel: "What we do",
|
||||
newsEyebrow: "Newsroom",
|
||||
newsHeading: "News and Updates",
|
||||
newsEmpty: "News and updates will appear here as they are published.",
|
||||
faqEyebrow: "Frequently asked",
|
||||
faqHeading: "Questions about the project",
|
||||
/* The draft also promises a reply "within five working days". That
|
||||
commitment is in no approved document, so it is not published here. */
|
||||
faqLead: "Eligibility, timelines and how to take part.",
|
||||
faqEmpty: "Answers to common questions will be published here.",
|
||||
faqCtaLabel: "More in the Knowledge Hub",
|
||||
consortiumHeading: "The consortium",
|
||||
consortium: [
|
||||
{
|
||||
role: "Lead implementing partner",
|
||||
name: "Acted",
|
||||
description:
|
||||
"Overall project management and civil society sub-projects.",
|
||||
},
|
||||
{
|
||||
role: "Consortium partner",
|
||||
name: "United Efforts Consultancy (UEC)",
|
||||
description: "Network strengthening, dialogue, and peer-learning.",
|
||||
},
|
||||
{
|
||||
role: "Consortium partner",
|
||||
name: "Stichting Bureau Ensemble (SBE)",
|
||||
description: "Research, perception data, and knowledge publications.",
|
||||
},
|
||||
],
|
||||
partnersHeading: "Meet Our Partners",
|
||||
networkLabel: "Civil society network partners",
|
||||
partnersEmpty:
|
||||
"Partner organisations will be listed here as they join the SHARP-ER network.",
|
||||
},
|
||||
|
||||
about: {
|
||||
heroEyebrow: "About the project",
|
||||
heroHeadline: "Strengthening the Rule of Law, Together",
|
||||
lead: "SHARP-ER (Shared Action on Rule of Law Progress – Exit Route) is a nationwide initiative supporting Libya’s civil society, local institutions, and communities in advancing justice, accountability, and good governance.",
|
||||
body: "Running from December 2025 to December 2027, SHARP-ER is implemented by Acted, in consortium with United Efforts Consultancy (UEC) and Stichting Bureau Ensemble (SBE), and funded by the European Union. The project works nationwide across Libya, partnering with civil society organisations, municipalities, and community leaders to strengthen access to justice, build the capacity of civil society to hold institutions accountable, and improve local governance and service delivery.",
|
||||
facts: [
|
||||
{ label: "Duration", value: "December 2025 – December 2027" },
|
||||
{ label: "Coverage", value: "Nationwide, Libya" },
|
||||
{ label: "Lead partner", value: "Acted" },
|
||||
{ label: "Funded by", value: "The European Union" },
|
||||
],
|
||||
bodyHeading: "A nationwide project, delivered locally",
|
||||
body: [
|
||||
"Running from December 2025 to December 2027, SHARP-ER is implemented by Acted, in consortium with United Efforts Consultancy (UEC) and Stichting Bureau Ensemble (SBE), and funded by the European Union.",
|
||||
"The project works nationwide across Libya, partnering with civil society organisations, municipalities, and community leaders to strengthen access to justice, build the capacity of civil society to hold institutions accountable, and improve local governance and service delivery.",
|
||||
],
|
||||
consortiumHeading: "The consortium",
|
||||
consortium: [
|
||||
{
|
||||
name: "Acted",
|
||||
role: "Lead implementing partner — overall project management and civil society sub-projects",
|
||||
role: "Lead implementing partner",
|
||||
description:
|
||||
"Overall project management and civil society sub-projects.",
|
||||
},
|
||||
{
|
||||
name: "United Efforts Consultancy (UEC)",
|
||||
role: "Network strengthening, dialogue, and peer-learning",
|
||||
role: "Consortium partner",
|
||||
description: "Network strengthening, dialogue, and peer-learning.",
|
||||
},
|
||||
{
|
||||
name: "Stichting Bureau Ensemble (SBE)",
|
||||
role: "Research, perception data, and knowledge publications",
|
||||
role: "Consortium partner",
|
||||
description: "Research, perception data, and knowledge publications.",
|
||||
},
|
||||
],
|
||||
pillarsHeading: "Where we focus",
|
||||
@@ -192,18 +297,17 @@ const EN: PageCopy = {
|
||||
"Enhancing collaboration between municipalities and local actors to improve service delivery",
|
||||
},
|
||||
],
|
||||
legacyHeading: "Building on SHARP I and II",
|
||||
legacyEyebrow: "Building on SHARP",
|
||||
legacyHeading: "Carrying the SHARP I and II network into a new phase",
|
||||
legacyBody:
|
||||
"SHARP-ER builds on the foundation of SHARP I and II (2019–2024), carrying its network of civil society partners and its dialogue practice into a new phase focused on sustainability and exit.",
|
||||
legacyLinkLabel: "Read about the SHARP legacy",
|
||||
fundingNote: "Funded by the European Union.",
|
||||
},
|
||||
|
||||
whatWeDo: {
|
||||
heroHeadline: "A Participatory Approach to Justice and Governance",
|
||||
lead: "We work directly with civil society, municipalities, and communities — not around them.",
|
||||
workstreamsHeading: "Our areas of work",
|
||||
workstreamsIntro: "SHARP-ER is organised around four connected areas of work:",
|
||||
workstreamsHeading: "Four connected areas of work",
|
||||
workstreams: [
|
||||
{
|
||||
title: "Capacity Building",
|
||||
@@ -235,16 +339,21 @@ const EN: PageCopy = {
|
||||
"Responsibility & Accountability",
|
||||
],
|
||||
principlesHeading: "How we work",
|
||||
principles:
|
||||
"Our approach rests on four principles: working with local actors, promoting sustainable — not one-off — solutions, connecting civil society with institutions, and supporting gradual, realistic institutional reform.",
|
||||
principleTitles: [
|
||||
"Working with local actors",
|
||||
"Sustainable, not one-off, solutions",
|
||||
"Connecting civil society with institutions",
|
||||
"Gradual, realistic institutional reform",
|
||||
],
|
||||
activitiesCtaHeading:
|
||||
"See how this work is taking shape across the network",
|
||||
},
|
||||
|
||||
contact: {
|
||||
facebookLabel: "Follow us on Facebook",
|
||||
heroHeadline: "Get in Touch",
|
||||
lead: "Questions about SHARP-ER, a partnership, or a specific activity? Reach out.",
|
||||
formHeading: "Send us a message",
|
||||
formIntro:
|
||||
"Fill in the form and the SHARP-ER team will come back to you by email.",
|
||||
detailsHeading: "Contact details",
|
||||
generalEnquiries: "General enquiries",
|
||||
websiteLabel: "Website",
|
||||
@@ -274,49 +383,90 @@ const EN: PageCopy = {
|
||||
|
||||
const AR: PageCopy = {
|
||||
home: {
|
||||
/* (ar-draft) matches the English frame: the wordmark names the project. */
|
||||
heroHeadline: "انطلاق المشروع رسميًا في ليبيا",
|
||||
statsHeading: "SHARP-ER بالأرقام",
|
||||
launchHeading: "مسيرة إطلاق مشروع SHARP-ER",
|
||||
launchLead: [
|
||||
"انطلقت رسميًا فعاليات مشروع SHARP-ER يوم ",
|
||||
{ em: "الثلاثاء 30 يونيو" },
|
||||
"، بمشاركة ممثلين عن ",
|
||||
{ em: "بعثة الاتحاد الأوروبي" },
|
||||
"، وعدد من ",
|
||||
{ em: "الوزارات الليبية" },
|
||||
"، إلى جانب ",
|
||||
{ em: "شركاء المجتمع المدني المحليين" },
|
||||
". وشهد الحدث إطلاق الموقع الإلكتروني الرسمي للمشروع والدعوة للتعبير عن الاهتمام (EOI).",
|
||||
heroHeadline: "انطلاق مشروع SHARP-ER رسميًا في ليبيا",
|
||||
heroLead:
|
||||
"العمل المشترك لتعزيز سيادة القانون – مسار الخروج: مبادرة وطنية تدعم المجتمع المدني والمؤسسات المحلية والمجتمعات في ليبيا في تعزيز العدالة والمساءلة والحوكمة الرشيدة.",
|
||||
heroCtaPrimary: "الدعوة للتعبير عن الاهتمام",
|
||||
heroCtaSecondary: "عن المشروع",
|
||||
/* Western digits, as the rest of the Arabic draft writes its numbers
|
||||
("ديسمبر 2025", "SHARP I and II (2019–2024)"). */
|
||||
stats: [
|
||||
{ value: "3", label: "شركاء الائتلاف" },
|
||||
{ value: "4", label: "مجالات العمل" },
|
||||
{ value: "16", label: "مشروعًا فرعيًا للأمن الإنساني" },
|
||||
{ value: "25 شهرًا", label: "ديسمبر 2025 – ديسمبر 2027" },
|
||||
],
|
||||
mechanismsHeading: "ما الذي أطلقه هذا الحدث",
|
||||
mechanisms: [
|
||||
"بناء القدرات والمهارات المحلية",
|
||||
"تعزيز الشراكات بين القطاعات",
|
||||
"دعم المبادرات البلدية والمبادرات المجتمعية",
|
||||
launchEyebrow: "30 يونيو · فعالية الإطلاق",
|
||||
launchHeading: "بداية مهمة للمشروع",
|
||||
launchLead:
|
||||
"جمعت فعالية الإطلاق ممثلين عن بعثة الاتحاد الأوروبي، ووزارة الشباب، ووزارة الحكم المحلي، ووزارة شؤون المرأة، ومنظمات المجتمع المدني، والشركاء، وأصحاب المصلحة من مختلف أنحاء ليبيا. وشهد الحدث افتتاح الدعوة للتعبير عن الاهتمام وإطلاق هذا الموقع الإلكتروني.",
|
||||
workHeading: "ثلاثة مجالات للتغيير",
|
||||
workLinkLabel: "ماذا نفعل",
|
||||
newsEyebrow: "غرفة الأخبار",
|
||||
newsHeading: "الأخبار والمستجدات",
|
||||
newsEmpty: "ستظهر الأخبار والمستجدات هنا فور نشرها.",
|
||||
faqEyebrow: "الأسئلة الشائعة",
|
||||
faqHeading: "أسئلة حول المشروع",
|
||||
faqLead: "شروط الأهلية والجداول الزمنية وكيفية المشاركة.",
|
||||
faqEmpty: "ستُنشر إجابات الأسئلة الشائعة هنا قريبًا.",
|
||||
faqCtaLabel: "المزيد في مركز المعرفة",
|
||||
consortiumHeading: "الائتلاف المنفّذ",
|
||||
consortium: [
|
||||
{
|
||||
role: "الشريك المنفّذ الرئيسي",
|
||||
name: "Acted",
|
||||
description: "الإدارة العامة للمشروع والمشاريع الفرعية لمنظمات المجتمع المدني.",
|
||||
},
|
||||
{
|
||||
role: "شريك في الائتلاف",
|
||||
name: "United Efforts Consultancy (UEC)",
|
||||
description: "تعزيز الشبكة، والحوار، والتعلّم بين الأقران.",
|
||||
},
|
||||
{
|
||||
role: "شريك في الائتلاف",
|
||||
name: "Stichting Bureau Ensemble (SBE)",
|
||||
description: "البحث، وبيانات الاستطلاع، والمنشورات المعرفية.",
|
||||
},
|
||||
],
|
||||
partnersHeading: "تعرّف على شركائنا",
|
||||
networkLabel: "شركاء شبكة المجتمع المدني",
|
||||
partnersEmpty:
|
||||
"ستُدرج المنظمات الشريكة هنا فور انضمامها إلى شبكة SHARP-ER.",
|
||||
},
|
||||
|
||||
about: {
|
||||
heroEyebrow: "عن المشروع",
|
||||
heroHeadline: "نحو تعزيز سيادة القانون، معًا",
|
||||
lead: "SHARP-ER (العمل المشترك لتعزيز سيادة القانون – مسار الخروج) هو مبادرة وطنية تدعم المجتمع المدني والمؤسسات المحلية والمجتمعات الليبية في تعزيز العدالة والمساءلة والحوكمة الرشيدة.",
|
||||
body: "يمتد المشروع من ديسمبر 2025 إلى ديسمبر 2027، ويُنفَّذ من قِبل منظمة Acted بالشراكة مع United Efforts Consultancy (UEC) وStichting Bureau Ensemble (SBE)، وبتمويل من الاتحاد الأوروبي. يعمل المشروع على مستوى ليبيا بأكملها، بالتعاون مع منظمات المجتمع المدني والبلديات والقيادات المجتمعية، من أجل تحسين الوصول إلى العدالة، وبناء قدرات المجتمع المدني على مساءلة المؤسسات، وتحسين الحوكمة المحلية وتقديم الخدمات.",
|
||||
/* Western digits, as the Arabic column of the approved draft writes its
|
||||
own dates ("ديسمبر 2025", "(2019–2024)"). */
|
||||
facts: [
|
||||
{ label: "مدة المشروع", value: "ديسمبر 2025 – ديسمبر 2027" },
|
||||
{ label: "نطاق العمل", value: "على مستوى ليبيا بأكملها" },
|
||||
{ label: "الشريك المنفّذ الرئيسي", value: "Acted" },
|
||||
{ label: "بتمويل من", value: "الاتحاد الأوروبي" },
|
||||
],
|
||||
bodyHeading: "مشروع وطني يُنفَّذ محليًا",
|
||||
body: [
|
||||
"يمتد المشروع من ديسمبر 2025 إلى ديسمبر 2027، ويُنفَّذ من قِبل منظمة Acted بالشراكة مع United Efforts Consultancy (UEC) وStichting Bureau Ensemble (SBE)، وبتمويل من الاتحاد الأوروبي.",
|
||||
"يعمل المشروع على مستوى ليبيا بأكملها، بالتعاون مع منظمات المجتمع المدني والبلديات والقيادات المجتمعية، من أجل تحسين الوصول إلى العدالة، وبناء قدرات المجتمع المدني على مساءلة المؤسسات، وتحسين الحوكمة المحلية وتقديم الخدمات.",
|
||||
],
|
||||
consortiumHeading: "الائتلاف المنفّذ",
|
||||
consortium: [
|
||||
{
|
||||
name: "Acted",
|
||||
role: "الشريك المنفّذ الرئيسي — الإدارة العامة للمشروع والمشاريع الفرعية لمنظمات المجتمع المدني",
|
||||
role: "الشريك المنفّذ الرئيسي",
|
||||
description:
|
||||
"الإدارة العامة للمشروع والمشاريع الفرعية لمنظمات المجتمع المدني.",
|
||||
},
|
||||
{
|
||||
name: "United Efforts Consultancy (UEC)",
|
||||
role: "تعزيز الشبكة، والحوار، والتعلّم بين الأقران",
|
||||
role: "شريك في الائتلاف",
|
||||
description: "تعزيز الشبكة، والحوار، والتعلّم بين الأقران.",
|
||||
},
|
||||
{
|
||||
name: "Stichting Bureau Ensemble (SBE)",
|
||||
role: "البحث، وبيانات الاستطلاع، والمنشورات المعرفية",
|
||||
role: "شريك في الائتلاف",
|
||||
description: "البحث، وبيانات الاستطلاع، والمنشورات المعرفية.",
|
||||
},
|
||||
],
|
||||
pillarsHeading: "محاور تركيزنا",
|
||||
@@ -337,18 +487,21 @@ const AR: PageCopy = {
|
||||
"تعزيز التعاون بين البلديات والجهات الفاعلة المحلية لتحسين تقديم الخدمات",
|
||||
},
|
||||
],
|
||||
legacyHeading: "استكمالًا لمشروعي SHARP الأول والثاني",
|
||||
legacyEyebrow: "استكمالًا لمشروع SHARP",
|
||||
legacyHeading:
|
||||
"حمل شبكة SHARP الأول والثاني إلى مرحلة جديدة",
|
||||
legacyBody:
|
||||
"يبني SHARP-ER على الأساس الذي أرساه مشروعا SHARP الأول والثاني (2019–2024)، حاملًا معه شبكة الشركاء من المجتمع المدني وممارسة الحوار إلى مرحلة جديدة تركّز على الاستدامة ومسار الخروج.",
|
||||
legacyLinkLabel: "اطّلع على إرث مشروع SHARP",
|
||||
fundingNote: "بتمويل من الاتحاد الأوروبي.",
|
||||
},
|
||||
|
||||
whatWeDo: {
|
||||
heroHeadline: "نهج تشاركي في العدالة والحوكمة",
|
||||
lead: "نعمل مباشرة مع المجتمع المدني والبلديات والمجتمعات المحلية، لا من حولها.",
|
||||
workstreamsHeading: "محاور عملنا",
|
||||
workstreamsIntro: "يقوم عمل SHARP-ER على أربعة محاور مترابطة:",
|
||||
/* The approved Arabic column writes the same line as a sentence -
|
||||
"يقوم عمل SHARP-ER على أربعة محاور مترابطة" - which the draft's frame
|
||||
sets as the heading itself. */
|
||||
workstreamsHeading: "أربعة محاور عمل مترابطة",
|
||||
workstreams: [
|
||||
{
|
||||
title: "بناء القدرات",
|
||||
@@ -380,15 +533,20 @@ const AR: PageCopy = {
|
||||
"المساءلة والمسؤولية",
|
||||
],
|
||||
principlesHeading: "كيف نعمل",
|
||||
principles:
|
||||
"يسترشد عملنا بأربعة مبادئ: العمل مع الفاعلين المحليين، وتعزيز الحلول المستدامة لا الآنية، والربط بين المجتمع المدني والمؤسسات، ودعم إصلاح مؤسسي تدريجي وواقعي.",
|
||||
principleTitles: [
|
||||
"العمل مع الفاعلين المحليين",
|
||||
"حلول مستدامة لا آنية",
|
||||
"الربط بين المجتمع المدني والمؤسسات",
|
||||
"إصلاح مؤسسي تدريجي وواقعي",
|
||||
],
|
||||
activitiesCtaHeading: "اطّلع على كيفية تجسّد هذا العمل عبر الشبكة",
|
||||
},
|
||||
|
||||
contact: {
|
||||
facebookLabel: "تابعونا على فيسبوك",
|
||||
heroHeadline: "تواصل معنا",
|
||||
lead: "لديك استفسار حول SHARP-ER، أو شراكة محتملة، أو نشاط معيّن؟ راسلنا.",
|
||||
formHeading: "أرسل لنا رسالة",
|
||||
formIntro: "املأ النموذج وسيعاود فريق SHARP-ER التواصل معك عبر البريد الإلكتروني.",
|
||||
detailsHeading: "بيانات التواصل",
|
||||
generalEnquiries: "للاستفسارات العامة",
|
||||
websiteLabel: "الموقع الإلكتروني",
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/* ============================================================================
|
||||
HERO WORDMARK SUFFIX - the "-ER" after "SHARP" on the home hero
|
||||
----------------------------------------------------------------------------
|
||||
Not drawn in Figma: a project request, so the hero finishes on the full
|
||||
name. "SHARP" is on screen from the first paint; once the page has settled,
|
||||
"-ER" slides out from behind the P while it fades in.
|
||||
|
||||
HOW "BEHIND THE P" WORKS: the suffix sits in its own box whose inline-start
|
||||
edge is exactly the P's right edge, and that box clips. The mark starts a
|
||||
full box-width back - entirely inside the P's side of the clip, so nothing
|
||||
overlaps "SHARP" - and travels forward to rest. The opacity reaches 1 before
|
||||
the travel ends, so the glyphs read as solid for the last, slowest part of
|
||||
the decelerating slide.
|
||||
|
||||
The translate is physical (translateX) on purpose: the lockup row is pinned
|
||||
to dir="ltr" because a Latin wordmark never mirrors, so the P is always to
|
||||
the left of the suffix, in Arabic too.
|
||||
|
||||
Pure CSS, so it runs from the server-rendered HTML without waiting for
|
||||
hydration, and the `both` fill keeps the suffix hidden through the delay
|
||||
instead of flashing in first. It replays whenever the hero mounts again,
|
||||
e.g. on a client-side navigation back to the home page.
|
||||
|
||||
REDUCED MOTION: app.css shortens every animation to 0.01ms but leaves the
|
||||
delay alone, which would hold the suffix hidden for the delay and then pop
|
||||
it in. Here the animation is removed outright, so "SHARP-ER" is simply there
|
||||
from the start.
|
||||
========================================================================= */
|
||||
|
||||
@layer theme, base, components, utilities;
|
||||
|
||||
@layer components {
|
||||
.sh-hero-suffix {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sh-hero-suffix__mark {
|
||||
animation: sh-hero-suffix-in var(--motion-duration-reveal)
|
||||
var(--motion-ease-entrance) var(--motion-delay-reveal) both;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sh-hero-suffix__mark {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sh-hero-suffix-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
60% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/* ============================================================================
|
||||
HOME MOTION - the reveals the redesigned home page runs
|
||||
----------------------------------------------------------------------------
|
||||
Every animation in the Claude Design draft ("SHARP-ER Home v2 Dark"), carried
|
||||
over as classes so the page modules stay free of inline keyframes and raw
|
||||
values. Durations and easings are the motion tokens from semantic.css; the
|
||||
few long, decorative loops are expressed as multiples of them rather than as
|
||||
bare seconds, so retiming the system retimes the page.
|
||||
|
||||
TWO KINDS OF MOTION LIVE HERE
|
||||
* entrance - time based, runs once from the server-rendered HTML
|
||||
(.sh-rise, .sh-bar-grow), staggered with .sh-rise--2..5
|
||||
* scroll - scroll-driven (`animation-timeline`), so a band reveals as
|
||||
it enters the viewport and the top progress bar tracks the
|
||||
document
|
||||
|
||||
WHY THE @supports GUARD: scroll-driven timelines ship in Chromium only. The
|
||||
from-state (opacity 0) is applied ONLY where `animation-timeline: view()` is
|
||||
understood, so Safari and Firefox render every band at rest, fully visible,
|
||||
instead of holding an invisible element forever.
|
||||
|
||||
REDUCED MOTION: app.css already collapses every animation to 0.01ms. The
|
||||
scroll block is additionally gated on `prefers-reduced-motion: no-preference`
|
||||
so a reduced-motion visitor never gets a timeline-driven opacity ramp at all.
|
||||
|
||||
DIRECTION: `transform-origin` and `linear-gradient()` have no logical
|
||||
keywords, so the progress bar and the brand stripe carry an explicit RTL
|
||||
override. These are the only physical directions in the file, and they exist
|
||||
because the property itself has no logical form - every box edge elsewhere is
|
||||
still ps-/pe-/start-/end-.
|
||||
========================================================================= */
|
||||
|
||||
@layer theme, base, components, utilities;
|
||||
|
||||
@layer components {
|
||||
/* -------------------------------------------------------------------------
|
||||
ENTRANCE - hero content, first paint
|
||||
---------------------------------------------------------------------- */
|
||||
.sh-rise {
|
||||
animation: sh-rise var(--motion-duration-reveal) var(--motion-ease-entrance)
|
||||
var(--sh-rise-delay, 0ms) both;
|
||||
}
|
||||
|
||||
/* The hero's cascade. The step is the system's fast duration, so the whole
|
||||
stagger retimes with the token instead of drifting on hard-coded ms. */
|
||||
.sh-rise--2 {
|
||||
--sh-rise-delay: calc(var(--motion-duration-fast) * 0.75);
|
||||
}
|
||||
|
||||
.sh-rise--3 {
|
||||
--sh-rise-delay: calc(var(--motion-duration-fast) * 1.5);
|
||||
}
|
||||
|
||||
.sh-rise--4 {
|
||||
--sh-rise-delay: calc(var(--motion-duration-fast) * 2.25);
|
||||
}
|
||||
|
||||
.sh-rise--5 {
|
||||
--sh-rise-delay: calc(var(--motion-duration-fast) * 3);
|
||||
}
|
||||
|
||||
/* The brand stripe under the hero picture, drawn on from the inline start. */
|
||||
.sh-bar-grow {
|
||||
transform-origin: left center;
|
||||
animation: sh-bar-grow var(--motion-duration-reveal)
|
||||
var(--motion-ease-entrance) calc(var(--motion-duration-fast) * 4) both;
|
||||
}
|
||||
|
||||
[dir="rtl"] .sh-bar-grow {
|
||||
transform-origin: right center;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
AMBIENT LOOPS - decorative, never carrying meaning
|
||||
---------------------------------------------------------------------- */
|
||||
|
||||
/* The two soft washes behind the hero. Long, slow drifts: 22.5x and 30x the
|
||||
reveal duration (18s / 24s at the shipped token). */
|
||||
.sh-glow {
|
||||
animation: sh-glow calc(var(--motion-duration-reveal) * 22.5) ease-in-out
|
||||
infinite;
|
||||
}
|
||||
|
||||
/* A highlight travelling across the hero picture. */
|
||||
.sh-sheen {
|
||||
background-size: 240% 100%;
|
||||
animation: sh-sheen calc(var(--motion-duration-reveal) * 8) linear infinite;
|
||||
}
|
||||
|
||||
/* The live dot in the hero status pill. */
|
||||
.sh-pulse {
|
||||
animation: sh-pulse calc(var(--motion-duration-reveal) * 2.75) ease-in-out
|
||||
infinite;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
INTERACTION
|
||||
---------------------------------------------------------------------- */
|
||||
|
||||
/* Tiles that rise a little under the pointer. No shadow: the draft's hover
|
||||
glow is deliberately left out. */
|
||||
.sh-lift {
|
||||
transition:
|
||||
transform var(--motion-duration-base) var(--motion-ease-standard),
|
||||
border-color var(--motion-duration-base) var(--motion-ease-standard),
|
||||
background-color var(--motion-duration-base) var(--motion-ease-standard);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.sh-lift:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
|
||||
/* An FAQ answer opening under its question. */
|
||||
.sh-answer {
|
||||
animation: sh-answer var(--motion-duration-base) var(--motion-ease-entrance)
|
||||
both;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
SCROLL DRIVEN - Chromium today, inert and harmless elsewhere
|
||||
---------------------------------------------------------------------- */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
@supports (animation-timeline: view()) {
|
||||
.sh-reveal {
|
||||
animation: sh-rise linear both;
|
||||
animation-timeline: view();
|
||||
animation-range: entry 0% entry 70%;
|
||||
}
|
||||
|
||||
/* Cards and pictures: the same reveal with a touch of scale. */
|
||||
.sh-reveal--in {
|
||||
animation-name: sh-reveal-in;
|
||||
animation-range: entry 0% entry 80%;
|
||||
}
|
||||
}
|
||||
|
||||
@supports (animation-timeline: scroll()) {
|
||||
/* The reading-progress rule across the top of the page. It is painted
|
||||
ONLY inside this @supports block: without a scroll timeline the bar
|
||||
would sit there permanently at full width, which is a rule the design
|
||||
never asked for. */
|
||||
.sh-progress {
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
var(--surface-accent),
|
||||
var(--text-accent) 55%,
|
||||
var(--surface-danger)
|
||||
);
|
||||
transform-origin: left center;
|
||||
animation: sh-bar-grow linear both;
|
||||
animation-timeline: scroll();
|
||||
}
|
||||
|
||||
[dir="rtl"] .sh-progress {
|
||||
background-image: linear-gradient(
|
||||
to left,
|
||||
var(--surface-accent),
|
||||
var(--text-accent) 55%,
|
||||
var(--surface-danger)
|
||||
);
|
||||
transform-origin: right center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sh-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(26px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sh-reveal-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.985);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sh-bar-grow {
|
||||
from {
|
||||
transform: scaleX(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sh-glow {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.5;
|
||||
transform: translate3d(-4%, 0, 0) scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.85;
|
||||
transform: translate3d(6%, -3%, 0) scale(1.12);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sh-sheen {
|
||||
from {
|
||||
background-position: -160% 0;
|
||||
}
|
||||
|
||||
to {
|
||||
background-position: 260% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sh-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
transform: scale(0.72);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sh-answer {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import "./hero.css";
|
||||
import "./motion.css";
|
||||
|
||||
export function cx(...parts: Array<string | false | null | undefined>): string {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
@@ -50,9 +53,16 @@ export interface PageSectionProps {
|
||||
/** Flip this section (and only this section) to the other surface. */
|
||||
surface?: "dark" | "light";
|
||||
className?: string;
|
||||
/**
|
||||
* Paint this section as one of the drafts' lighter #1D1D1B bands. Cards
|
||||
* inside it then draw themselves a step darker, as the drafts do.
|
||||
*/
|
||||
band?: boolean;
|
||||
/** Content is placed directly in the section, without the centred column. */
|
||||
bleed?: boolean;
|
||||
"aria-labelledby"?: string;
|
||||
/** For a band whose heading is not on the page - a figures row, a logo wall. */
|
||||
"aria-label"?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,6 +73,7 @@ export function PageSection({
|
||||
children,
|
||||
id,
|
||||
surface,
|
||||
band = false,
|
||||
className,
|
||||
bleed = false,
|
||||
...rest
|
||||
@@ -71,7 +82,7 @@ export function PageSection({
|
||||
<section
|
||||
id={id}
|
||||
{...(surface ? { "data-surface": surface } : {})}
|
||||
className={cx("py-3xl md:py-4xl", className)}
|
||||
className={cx("py-3xl md:py-4xl", band && "sh-band", className)}
|
||||
{...rest}
|
||||
>
|
||||
{bleed ? children : <Container>{children}</Container>}
|
||||
@@ -169,8 +180,49 @@ export interface PageHeroWordmark {
|
||||
/** Intrinsic pixels of the asset, for the aspect ratio the browser reserves. */
|
||||
width: number;
|
||||
height: number;
|
||||
/** Drawn width in CSS pixels. Figma 253:4800 draws the hero lockup at 419. */
|
||||
/** Drawn width in CSS pixels. Figma 253:4800 draws the hero lockup at 406. */
|
||||
drawnWidth: number;
|
||||
/**
|
||||
* A second mark drawn flush after this one at the same scale, faded in once
|
||||
* the page has rendered, sliding out from behind it - the home hero's
|
||||
* "-ER". Always decorative.
|
||||
*/
|
||||
suffix?: PageHeroWordmarkSuffix;
|
||||
}
|
||||
|
||||
export interface PageHeroWordmarkSuffix {
|
||||
src: string;
|
||||
/** Intrinsic pixels of the asset, for the aspect ratio the browser reserves. */
|
||||
width: number;
|
||||
height: number;
|
||||
/** Drawn width in CSS pixels when the lockup is at full size. */
|
||||
drawnWidth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One soft radial wash drifting behind a hero. Every draft positions its own
|
||||
* differently - which edge it hangs from, how far in, how big and how slowly
|
||||
* it drifts - so the geometry travels with the page rather than being baked
|
||||
* into the hero.
|
||||
*
|
||||
* `offset` is measured from the INLINE start or end, so the wash mirrors in
|
||||
* Arabic along with the rest of the page.
|
||||
*/
|
||||
export interface HeroWash {
|
||||
/** Offset from the hero's top edge in px; negative pulls it above. */
|
||||
top: number;
|
||||
/** Which inline edge the wash hangs from. */
|
||||
from: "start" | "end";
|
||||
/** How far in from that edge - the draft's own value, e.g. "8%" or "-160px". */
|
||||
offset: string;
|
||||
width: number;
|
||||
height: number;
|
||||
/** Which brand wash. The drafts use green everywhere and red once, on home. */
|
||||
tone?: "accent" | "danger";
|
||||
/** Seconds one drift cycle takes; the drafts vary this from 18s to 24s. */
|
||||
durationSeconds?: number;
|
||||
/** Drift the other way round, as the home hero's red wash does. */
|
||||
reverse?: boolean;
|
||||
}
|
||||
|
||||
export interface PageHeroMedia {
|
||||
@@ -195,17 +247,125 @@ export interface PageHeroProps {
|
||||
lead?: string;
|
||||
/**
|
||||
* Brand lockup drawn above the headline. Only the home hero carries one
|
||||
* (figma 253:4800, a 419x58 wordmark 48px above the headline row).
|
||||
* (figma 253:4800, a 406x86 wordmark 48px above the headline row).
|
||||
*/
|
||||
wordmark?: PageHeroWordmark;
|
||||
/**
|
||||
* Funding attribution, drawn 24px directly above the wordmark (figma
|
||||
* 478:15951). Only the home hero carries one; every other page relies on the
|
||||
* footer for EU visibility.
|
||||
*/
|
||||
funderBadge?: ReactNode;
|
||||
/**
|
||||
* A short status line drawn as a pill above the headline - the redesign's
|
||||
* "Now live" chip. Only rendered when the caller passes one, so it can never
|
||||
* announce a state the CMS has not authored.
|
||||
*/
|
||||
statusPill?: ReactNode;
|
||||
/** Buttons, rendered inline-end of the headline on wide viewports. */
|
||||
actions?: ReactNode;
|
||||
/**
|
||||
* Put the lead on its own row, opposite the buttons, instead of under the
|
||||
* headline. The home draft lays the hero out this way - headline across the
|
||||
* top, then the lead and the CTA pair sharing a row, baseline aligned. Every
|
||||
* interior hero stacks eyebrow / headline / lead in one column.
|
||||
*/
|
||||
leadWithActions?: boolean;
|
||||
/**
|
||||
* The washes drifting behind this hero, in the page's own geometry.
|
||||
* Decorative and opt-in: omit it and the hero is flat.
|
||||
*/
|
||||
ambient?: readonly HeroWash[];
|
||||
/**
|
||||
* Tone of the lead. The drafts set it quiet almost everywhere, but What We
|
||||
* Do prints it in the primary tone, a step up from the body colour.
|
||||
*/
|
||||
leadTone?: "secondary" | "primary";
|
||||
/**
|
||||
* Stagger the hero's own content in on first paint. Opt-in for the same
|
||||
* reason: only the home hero is designed as an arrival moment.
|
||||
*/
|
||||
reveal?: boolean;
|
||||
/** Wide picture below the headline row, as in Figma 253:4800. */
|
||||
media?: PageHeroMedia;
|
||||
/**
|
||||
* Content closing the hero band, under the picture and its brand stripe -
|
||||
* the redesign puts the figures row here so it sits on the hero's own
|
||||
* surface rather than opening a band of its own.
|
||||
*/
|
||||
below?: ReactNode;
|
||||
mediaTreatment?: PageHeroMediaTreatment;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The lockup above the hero headline. Its size is a graphic dimension, not a
|
||||
* spacing value - set the way Brand.tsx and the footer size their lockups, with
|
||||
* the intrinsic box on each <img> so the browser reserves the right height.
|
||||
*
|
||||
* With a `suffix`, the two marks share one row. The row is pinned to
|
||||
* `dir="ltr"` because a Latin wordmark never mirrors (its position on the page
|
||||
* still follows the page's direction), and each mark takes the share of the
|
||||
* row its drawn width gives it, so the pair shrinks together on a narrow screen
|
||||
* and stays at one scale at every width.
|
||||
*/
|
||||
function HeroWordmark({ wordmark }: { wordmark: PageHeroWordmark }) {
|
||||
const { suffix } = wordmark;
|
||||
|
||||
if (!suffix) {
|
||||
return (
|
||||
<img
|
||||
src={wordmark.src}
|
||||
alt={wordmark.alt}
|
||||
width={wordmark.width}
|
||||
height={wordmark.height}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
style={{ maxWidth: `${wordmark.drawnWidth}px` }}
|
||||
className="block h-auto w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
dir="ltr"
|
||||
style={{ maxWidth: `${wordmark.drawnWidth + suffix.drawnWidth}px` }}
|
||||
className="flex w-full items-start"
|
||||
>
|
||||
<img
|
||||
src={wordmark.src}
|
||||
alt={wordmark.alt}
|
||||
width={wordmark.width}
|
||||
height={wordmark.height}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
style={{ flex: `${wordmark.drawnWidth} 1 0%` }}
|
||||
className="block h-auto min-w-0"
|
||||
/>
|
||||
{/* The clipping box starts at the P's right edge, so the mark inside it
|
||||
can slide out from behind the P (hero.css). */}
|
||||
<span
|
||||
style={{ flex: `${suffix.drawnWidth} 1 0%` }}
|
||||
className="sh-hero-suffix block min-w-0"
|
||||
>
|
||||
<img
|
||||
src={suffix.src}
|
||||
alt=""
|
||||
width={suffix.width}
|
||||
height={suffix.height}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
className="sh-hero-suffix__mark block h-auto w-full"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The page hero - Figma "Hero header" (253:4800): headline on the inline-start
|
||||
* side, the call-to-action group on the inline-end side, and a full-width
|
||||
@@ -217,54 +377,158 @@ export function PageHero({
|
||||
eyebrow,
|
||||
lead,
|
||||
wordmark,
|
||||
funderBadge,
|
||||
statusPill,
|
||||
actions,
|
||||
leadWithActions = false,
|
||||
ambient,
|
||||
leadTone = "secondary",
|
||||
reveal = false,
|
||||
media,
|
||||
mediaTreatment = "rounded",
|
||||
below,
|
||||
className,
|
||||
}: PageHeroProps) {
|
||||
/** Only paid for when the caller asks for the arrival treatment. */
|
||||
const rise = (step?: 2 | 3 | 4 | 5) =>
|
||||
reveal ? cx("sh-rise", step && `sh-rise--${step}`) : undefined;
|
||||
|
||||
const washes = ambient ?? [];
|
||||
|
||||
return (
|
||||
<section className={cx("pt-2xl md:pt-3xl", className)}>
|
||||
<section
|
||||
className={cx(
|
||||
"pt-[var(--layout-hero-top)] pb-[var(--layout-hero-bottom)]",
|
||||
washes.length > 0 && "relative isolate overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{washes.length > 0 ? (
|
||||
/* Flat washes of the brand colours, drifting. Purely decorative:
|
||||
behind the content, never catching the pointer, and hidden from
|
||||
assistive technology. The geometry is the page's own; only the
|
||||
drift itself is shared (motion.css). */
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10">
|
||||
{washes.map((wash, index) => (
|
||||
<span
|
||||
key={`${wash.from}-${wash.offset}-${index}`}
|
||||
className={cx(
|
||||
"sh-glow absolute rounded-pill",
|
||||
wash.tone === "danger"
|
||||
? "bg-[radial-gradient(closest-side,var(--surface-danger-subtle),transparent)]"
|
||||
: "bg-[radial-gradient(closest-side,var(--surface-accent-subtle),transparent)]",
|
||||
)}
|
||||
style={{
|
||||
top: `${wash.top}px`,
|
||||
width: `${wash.width}px`,
|
||||
height: `${wash.height}px`,
|
||||
...(wash.from === "end"
|
||||
? { insetInlineEnd: wash.offset }
|
||||
: { insetInlineStart: wash.offset }),
|
||||
...(wash.durationSeconds
|
||||
? { animationDuration: `${wash.durationSeconds}s` }
|
||||
: {}),
|
||||
...(wash.reverse ? { animationDirection: "reverse" } : {}),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Container className="flex flex-col gap-2xl">
|
||||
{wordmark ? (
|
||||
/* A graphic dimension, not a spacing value - sized the way Brand.tsx
|
||||
and the footer size their lockups, with the intrinsic box on the
|
||||
element so the browser reserves the right height. */
|
||||
<img
|
||||
src={wordmark.src}
|
||||
alt={wordmark.alt}
|
||||
width={wordmark.width}
|
||||
height={wordmark.height}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
style={{ maxWidth: `${wordmark.drawnWidth}px` }}
|
||||
className="block h-auto w-full"
|
||||
/>
|
||||
{funderBadge || wordmark ? (
|
||||
/* Figma groups the two (478:16183): the badge, then 24px, then the
|
||||
lockup. The group as a whole keeps the 48px the Container's gap
|
||||
already provides above the headline row. */
|
||||
<div className={cx("flex flex-col gap-lg", rise())}>
|
||||
{funderBadge}
|
||||
|
||||
{wordmark ? <HeroWordmark wordmark={wordmark} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-lg lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="flex max-w-[46rem] flex-col gap-sm">
|
||||
{eyebrow ? (
|
||||
<p className="label-md uppercase text-[var(--text-accent)]">{eyebrow}</p>
|
||||
{statusPill ? <div className={rise(2)}>{statusPill}</div> : null}
|
||||
|
||||
{leadWithActions ? (
|
||||
<>
|
||||
{/* Headline across the top ... */}
|
||||
<div className={cx("flex max-w-[43.75rem] flex-col gap-sm", rise(3))}>
|
||||
{eyebrow ? (
|
||||
<p className="label-md uppercase text-[var(--text-accent)]">{eyebrow}</p>
|
||||
) : null}
|
||||
|
||||
<h1 className="heading-h1 text-[var(--text-primary)] md:display-sm">
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* ... then the lead and the buttons sharing a row, sitting on the
|
||||
same baseline, as the home draft draws them. */}
|
||||
{lead || actions ? (
|
||||
<div
|
||||
className={cx(
|
||||
"flex flex-col gap-xl lg:flex-row lg:items-end lg:justify-between",
|
||||
rise(4),
|
||||
)}
|
||||
>
|
||||
{lead ? (
|
||||
<p
|
||||
className={cx(
|
||||
"body-lg max-w-[40rem]",
|
||||
leadTone === "primary"
|
||||
? "text-[var(--text-primary)]"
|
||||
: "text-[var(--text-secondary)]",
|
||||
)}
|
||||
>
|
||||
{lead}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{actions ? (
|
||||
<div className="flex flex-wrap items-center gap-lg lg:justify-end">
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col gap-lg lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className={cx("flex max-w-[46rem] flex-col gap-sm", rise(3))}>
|
||||
{eyebrow ? (
|
||||
<p className="label-md uppercase text-[var(--text-accent)]">{eyebrow}</p>
|
||||
) : null}
|
||||
|
||||
<h1 className="heading-h1 text-[var(--text-primary)] md:display-sm">
|
||||
{title}
|
||||
</h1>
|
||||
<h1 className="heading-h1 text-[var(--text-primary)] md:display-sm">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
{lead ? (
|
||||
<p className="body-lg max-w-[var(--layout-prose-max)] text-[var(--text-secondary)]">
|
||||
{lead}
|
||||
</p>
|
||||
{lead ? (
|
||||
<p
|
||||
className={cx(
|
||||
"body-lg max-w-[var(--layout-prose-max)]",
|
||||
leadTone === "primary"
|
||||
? "text-[var(--text-primary)]"
|
||||
: "text-[var(--text-secondary)]",
|
||||
)}
|
||||
>
|
||||
{lead}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{actions ? (
|
||||
<div
|
||||
className={cx(
|
||||
"flex flex-wrap items-center gap-lg lg:justify-end",
|
||||
rise(4),
|
||||
)}
|
||||
>
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{actions ? (
|
||||
<div className="flex flex-wrap items-center gap-lg lg:justify-end">
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{media ? (
|
||||
mediaTreatment === "banner" ? (
|
||||
@@ -273,23 +537,54 @@ export function PageHero({
|
||||
kept at 16:9 on a phone, where a 2.9:1 strip would be a sliver.
|
||||
The two washes are flat fills in the frame, not gradients: a
|
||||
mint tint over a shade, both painted on the photograph. */
|
||||
<div className="relative w-full overflow-hidden">
|
||||
<img
|
||||
src={media.src}
|
||||
alt={media.alt}
|
||||
{...(media.width ? { width: media.width } : {})}
|
||||
{...(media.height ? { height: media.height } : {})}
|
||||
fetchPriority="high"
|
||||
className="aspect-[16/9] w-full object-cover md:aspect-[1244/425]"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 bg-[var(--overlay-picture-shade)]"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 bg-[var(--overlay-picture-tint)]"
|
||||
/>
|
||||
<div className={cx("w-full", rise(5))}>
|
||||
<div className="relative w-full overflow-hidden">
|
||||
<img
|
||||
src={media.src}
|
||||
alt={media.alt}
|
||||
{...(media.width ? { width: media.width } : {})}
|
||||
{...(media.height ? { height: media.height } : {})}
|
||||
fetchPriority="high"
|
||||
className="aspect-[16/9] w-full object-cover md:aspect-[1244/415]"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 bg-[var(--overlay-picture-shade)]"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 bg-[var(--overlay-picture-tint)]"
|
||||
/>
|
||||
{/* A highlight travelling across the picture - the draft's
|
||||
`sheen`. Decorative, and it rides on top of both washes. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="sh-sheen pointer-events-none absolute inset-0 bg-[linear-gradient(100deg,transparent_35%,var(--overlay-veil)_50%,transparent_65%)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* The colour band (478:16360): three blocks flush under the
|
||||
picture, 6px tall. Figma lays out 390 / 495 / 363 inside a
|
||||
1244-wide clipping frame, so the last block loses 4px and the
|
||||
split that is actually drawn is 390 : 495 : 359 - carried here
|
||||
as flex-grow ratios so it holds at every width instead of only
|
||||
at 1244. The three fills are `background/accent`,
|
||||
`background/inverse` and `background/danger`, which is why the
|
||||
middle block reads white on the dark hero and near-black on a
|
||||
light surface.
|
||||
|
||||
Purely decorative, so it is hidden from assistive technology;
|
||||
as an ordinary flex row it mirrors in Arabic along with the
|
||||
rest of the page. */}
|
||||
<div aria-hidden="true" className="sh-bar-grow flex h-[6px] w-full">
|
||||
<span className="basis-0 grow-[390] bg-[var(--surface-accent)]" />
|
||||
{/* The middle block is the raised dark, not the inverse:
|
||||
the redesign drops pure white from the page entirely, so
|
||||
the stripe reads green / dark / red on the near-black
|
||||
band instead of green / white / red. */}
|
||||
<span className="basis-0 grow-[495] bg-[var(--surface-raised)]" />
|
||||
<span className="basis-0 grow-[359] bg-[var(--surface-danger)]" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
@@ -302,6 +597,8 @@ export function PageHero({
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{below}
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
@@ -316,6 +613,11 @@ export interface InfoPanelProps {
|
||||
children?: ReactNode;
|
||||
/** Small line above the title - a number, a partner name, a role. */
|
||||
eyebrow?: string;
|
||||
/**
|
||||
* How the eyebrow is drawn. "chip" is the redesign's filled role tag on the
|
||||
* subtle accent surface; "plain" is the bare accent label inner pages use.
|
||||
*/
|
||||
eyebrowVariant?: "plain" | "chip";
|
||||
titleAs?: "h2" | "h3" | "h4";
|
||||
className?: string;
|
||||
}
|
||||
@@ -329,6 +631,7 @@ export function InfoPanel({
|
||||
title,
|
||||
children,
|
||||
eyebrow,
|
||||
eyebrowVariant = "plain",
|
||||
titleAs = "h3",
|
||||
className,
|
||||
}: InfoPanelProps) {
|
||||
@@ -337,14 +640,22 @@ export function InfoPanel({
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"flex h-full flex-col gap-xs rounded-card border border-[var(--border-subtle)] bg-[var(--surface-raised)] p-lg",
|
||||
"flex h-full flex-col gap-xs rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)] p-lg",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{eyebrow ? (
|
||||
<p className="label-md uppercase text-[var(--text-accent)]">{eyebrow}</p>
|
||||
<p
|
||||
className={cx(
|
||||
"label-md uppercase text-[var(--text-accent)]",
|
||||
eyebrowVariant === "chip" &&
|
||||
"self-start rounded-control bg-[var(--surface-accent-subtle)] px-xs py-2xs",
|
||||
)}
|
||||
>
|
||||
{eyebrow}
|
||||
</p>
|
||||
) : null}
|
||||
<Title className="body-lg-bold text-[var(--text-primary)]">{title}</Title>
|
||||
<Title className="heading-h3 text-[var(--text-primary)]">{title}</Title>
|
||||
{children ? (
|
||||
<div className="body-md text-[var(--text-secondary)]">{children}</div>
|
||||
) : null}
|
||||
@@ -389,23 +700,48 @@ export function EmptyState({ message, title, className, children }: EmptyStatePr
|
||||
export interface ValueChipsProps {
|
||||
values: readonly string[];
|
||||
label?: string;
|
||||
/** The redesign centres the row on What We Do; inner pages read start. */
|
||||
align?: "start" | "center";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** The "What guides us" row: Dialogue · Community Leadership · Learning · … */
|
||||
export function ValueChips({ values, label, className }: ValueChipsProps) {
|
||||
export function ValueChips({
|
||||
values,
|
||||
label,
|
||||
align = "start",
|
||||
className,
|
||||
}: ValueChipsProps) {
|
||||
if (values.length === 0) return null;
|
||||
|
||||
const centred = align === "center";
|
||||
|
||||
return (
|
||||
<div className={cx("flex flex-col gap-sm", className)}>
|
||||
<div
|
||||
className={cx(
|
||||
"flex flex-col gap-sm",
|
||||
centred && "items-center text-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Accent, like every other eyebrow in the system (SectionHeader). */}
|
||||
{label ? (
|
||||
<p className="label-md uppercase text-[var(--text-tertiary)]">{label}</p>
|
||||
<p className="label-md uppercase text-[var(--text-accent)]">{label}</p>
|
||||
) : null}
|
||||
<ul className="flex flex-wrap gap-sm">
|
||||
<ul className={cx("flex flex-wrap gap-sm", centred && "justify-center")}>
|
||||
{values.map((value) => (
|
||||
<li
|
||||
key={value}
|
||||
className="rounded-pill border border-[var(--border-accent)] bg-[var(--surface-accent-subtle)] px-md py-2xs body-sm-bold text-[var(--text-primary)]"
|
||||
/* The drafts rest a chip as a plain outline on the band and only
|
||||
fill it under the pointer, where it also takes the 3px lift
|
||||
every other control has. */
|
||||
className={cx(
|
||||
"rounded-pill border border-[var(--border-default)] bg-transparent",
|
||||
"px-md py-2xs body-sm-bold text-[var(--text-primary)]",
|
||||
"transition duration-[var(--motion-duration-base)]",
|
||||
"hover:-translate-y-[3px] hover:border-[var(--border-accent)]",
|
||||
"hover:bg-[var(--surface-accent-subtle)]",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
/* ============================================================================
|
||||
MONTH GRID - the events calendar's "Month" view
|
||||
----------------------------------------------------------------------------
|
||||
PURE and client-safe: no Strapi, no `~/lib/env.server`, no React. The same
|
||||
helpers run in the loader (which decides which month to render) and in the
|
||||
component (which draws it), which is what keeps the URL and the grid in
|
||||
step.
|
||||
|
||||
The redesign draft ships a list/month switch for the calendar, and the
|
||||
approved review note asks for exactly that ("Events as a calendar/list
|
||||
toggle plus an individual event page"). The month is a URL parameter -
|
||||
?view=month&month=2026-03 - so a particular month is a shareable link and
|
||||
the arrows are ordinary <a>s that work with scripting off.
|
||||
|
||||
NOTHING HERE INVENTS A DATE. The grid is built from the entries the caller
|
||||
passes in, which come from Strapi; an empty CMS produces an empty month,
|
||||
never a sample event.
|
||||
|
||||
TIME ZONE: every calendar day is resolved in the zone the caller names
|
||||
(the calendar uses Libya time), so an evening event lands on the day it is
|
||||
actually held rather than on whatever day it is in the reader's browser.
|
||||
The arithmetic itself is done on "YYYY-MM-DD" strings through UTC, which has
|
||||
no daylight saving to fall through.
|
||||
========================================================================= */
|
||||
|
||||
/** "YYYY-MM". */
|
||||
export type CalendarMonthKey = string;
|
||||
|
||||
/** "YYYY-MM-DD". */
|
||||
export type CalendarDayKey = string;
|
||||
|
||||
const MONTH_PATTERN = /^(\d{4})-(0[1-9]|1[0-2])$/;
|
||||
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/** Anything that can sit in a day cell. `href` is already localised. */
|
||||
export interface CalendarEntry {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly href: string;
|
||||
/** ISO 8601 instant, or a date-only value when `allDay`. */
|
||||
readonly start: string;
|
||||
readonly end?: string;
|
||||
readonly allDay: boolean;
|
||||
}
|
||||
|
||||
export interface CalendarDay {
|
||||
readonly key: CalendarDayKey;
|
||||
/** 1-31. Padding cells from the neighbouring months keep their own number. */
|
||||
readonly dayNumber: number;
|
||||
readonly inMonth: boolean;
|
||||
readonly isToday: boolean;
|
||||
readonly entries: readonly CalendarEntry[];
|
||||
}
|
||||
|
||||
export interface CalendarMonth {
|
||||
readonly month: CalendarMonthKey;
|
||||
/** First day of the month, for formatting the month's own name. */
|
||||
readonly firstDay: CalendarDayKey;
|
||||
readonly previous: CalendarMonthKey;
|
||||
readonly next: CalendarMonthKey;
|
||||
/**
|
||||
* Weekday numbers in display order, 0 = Sunday, so the component can label
|
||||
* the header row with `Intl` instead of a hard-coded English list.
|
||||
*/
|
||||
readonly weekdays: readonly number[];
|
||||
readonly days: readonly CalendarDay[];
|
||||
/** How many of `entries` actually landed in this month. */
|
||||
readonly entryCount: number;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
PARSING AND ARITHMETIC
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
/** "2026-03" from a query parameter, or undefined when it is not one. */
|
||||
export function parseMonthKey(value: string | null | undefined): CalendarMonthKey | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
return MONTH_PATTERN.test(trimmed) ? trimmed : undefined;
|
||||
}
|
||||
|
||||
/** The calendar day an instant falls on, read in `timeZone`. */
|
||||
export function calendarDayIn(
|
||||
value: Date | string,
|
||||
timeZone: string,
|
||||
): CalendarDayKey | undefined {
|
||||
if (typeof value === "string" && DAY_PATTERN.test(value.trim())) {
|
||||
// A CMS `date` field is already a calendar day - re-reading it in a zone
|
||||
// would shift it by the offset.
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return undefined;
|
||||
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
/** The month a calendar day belongs to. */
|
||||
export function monthKeyOf(day: CalendarDayKey): CalendarMonthKey {
|
||||
return day.slice(0, 7);
|
||||
}
|
||||
|
||||
function utcOf(day: CalendarDayKey): number {
|
||||
return Date.parse(`${day}T00:00:00Z`);
|
||||
}
|
||||
|
||||
function dayKeyOf(time: number): CalendarDayKey {
|
||||
return new Date(time).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** "2026-03" plus `delta` months, rolling the year over. */
|
||||
export function shiftMonth(
|
||||
month: CalendarMonthKey,
|
||||
delta: number,
|
||||
): CalendarMonthKey {
|
||||
const match = MONTH_PATTERN.exec(month);
|
||||
if (!match) return month;
|
||||
|
||||
const index = Number(match[1]) * 12 + (Number(match[2]) - 1) + delta;
|
||||
const year = Math.floor(index / 12);
|
||||
const zeroBased = index - year * 12;
|
||||
|
||||
return `${String(year).padStart(4, "0")}-${String(zeroBased + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
THE GRID
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
export interface BuildCalendarOptions {
|
||||
readonly month: CalendarMonthKey;
|
||||
readonly entries: readonly CalendarEntry[];
|
||||
readonly timeZone: string;
|
||||
/** 0 = Sunday … 6 = Saturday. The column the header row starts on. */
|
||||
readonly weekStartsOn: number;
|
||||
/** "Today" as an instant - passed in so the loader and the grid agree. */
|
||||
readonly now: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Six weeks of cells, always: a fixed grid never reflows as the reader pages
|
||||
* through the year, and the padding cells carry their real dates so a run of
|
||||
* events across a month boundary stays readable.
|
||||
*/
|
||||
export function buildCalendarMonth({
|
||||
month,
|
||||
entries,
|
||||
timeZone,
|
||||
weekStartsOn,
|
||||
now,
|
||||
}: BuildCalendarOptions): CalendarMonth {
|
||||
const first = `${month}-01`;
|
||||
const firstTime = utcOf(first);
|
||||
const today = calendarDayIn(now, timeZone);
|
||||
|
||||
/* Every day each entry occupies, so a two-day workshop appears twice. */
|
||||
const byDay = new Map<CalendarDayKey, CalendarEntry[]>();
|
||||
for (const entry of entries) {
|
||||
const from = calendarDayIn(entry.start, timeZone);
|
||||
if (!from) continue;
|
||||
|
||||
const to = entry.end ? (calendarDayIn(entry.end, timeZone) ?? from) : from;
|
||||
const start = utcOf(from);
|
||||
const end = Math.max(start, utcOf(to));
|
||||
// A month is 31 cells; a mis-entered end date cannot be allowed to spin.
|
||||
const last = Math.min(end, start + 62 * MS_PER_DAY);
|
||||
|
||||
for (let time = start; time <= last; time += MS_PER_DAY) {
|
||||
const key = dayKeyOf(time);
|
||||
const bucket = byDay.get(key);
|
||||
if (bucket) bucket.push(entry);
|
||||
else byDay.set(key, [entry]);
|
||||
}
|
||||
}
|
||||
|
||||
/* Back up from the first of the month to the column the week starts on. */
|
||||
const firstWeekday = new Date(firstTime).getUTCDay();
|
||||
const lead = (firstWeekday - weekStartsOn + 7) % 7;
|
||||
const gridStart = firstTime - lead * MS_PER_DAY;
|
||||
|
||||
const days: CalendarDay[] = [];
|
||||
let entryCount = 0;
|
||||
|
||||
for (let cell = 0; cell < 42; cell += 1) {
|
||||
const time = gridStart + cell * MS_PER_DAY;
|
||||
const key = dayKeyOf(time);
|
||||
const cellEntries = byDay.get(key) ?? [];
|
||||
const inMonth = monthKeyOf(key) === month;
|
||||
if (inMonth) entryCount += cellEntries.length;
|
||||
|
||||
days.push({
|
||||
key,
|
||||
dayNumber: new Date(time).getUTCDate(),
|
||||
inMonth,
|
||||
isToday: key === today,
|
||||
entries: cellEntries,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
month,
|
||||
firstDay: first,
|
||||
previous: shiftMonth(month, -1),
|
||||
next: shiftMonth(month, 1),
|
||||
weekdays: Array.from({ length: 7 }, (_, index) => (weekStartsOn + index) % 7),
|
||||
days,
|
||||
entryCount,
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
LABELS
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* The day the reader's week starts on. Libya's week starts on Saturday, and
|
||||
* an Arabic calendar that opened on Monday would read wrong; English keeps the
|
||||
* Monday-first grid the draft draws.
|
||||
*/
|
||||
export function weekStartFor(locale: string): number {
|
||||
return locale === "ar" ? 6 : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Mon", "Tue", … in the page's language. Built from a reference week that
|
||||
* happens to start on a Sunday (2024-01-07 was one), so no weekday name is
|
||||
* ever hard-coded in either language.
|
||||
*/
|
||||
export function weekdayLabel(
|
||||
weekday: number,
|
||||
intlLocale: string,
|
||||
width: "short" | "narrow" | "long" = "short",
|
||||
): string {
|
||||
const reference = new Date(Date.UTC(2024, 0, 7 + weekday));
|
||||
return new Intl.DateTimeFormat(intlLocale, {
|
||||
weekday: width,
|
||||
timeZone: "UTC",
|
||||
}).format(reference);
|
||||
}
|
||||
@@ -478,3 +478,32 @@ export async function fetchBySlug<T>(
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How many entries match a filter, without pulling any of them back.
|
||||
*
|
||||
* Used by the knowledge hub, where each type card carries the number of items
|
||||
* actually published under it. The figure is the CMS's own count - the
|
||||
* redesign draft's "12 modules · EN / AR" was sample text and is not shipped -
|
||||
* and an unreachable collection counts as nothing rather than taking the page
|
||||
* down.
|
||||
*/
|
||||
export async function fetchCount(
|
||||
resource: string,
|
||||
locale: string,
|
||||
filters: Record<string, unknown>,
|
||||
): Promise<number> {
|
||||
try {
|
||||
const result = await getMany<{ readonly id?: number }>(resource, {
|
||||
locale,
|
||||
filters,
|
||||
fields: ["id"],
|
||||
pagination: { page: 1, pageSize: 1, withCount: true },
|
||||
});
|
||||
return result.pagination?.total ?? result.data.length;
|
||||
} catch (error) {
|
||||
if (!isStrapiError(error)) throw error;
|
||||
warn(resource, error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,9 +27,12 @@ import { ArrowIcon, Button } from "~/components/ui/Button";
|
||||
import { Input } from "~/components/ui/Input";
|
||||
import type { Translator } from "~/i18n";
|
||||
|
||||
import { Container, PageHero, type HeroWash } from "../_page-kit/sections";
|
||||
import type { PaginationModel, SelectOption } from "./listing";
|
||||
import { withParam } from "./listing";
|
||||
|
||||
import "../_page-kit/motion.css";
|
||||
|
||||
function cx(...parts: Array<string | false | null | undefined>): string {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
@@ -59,6 +62,57 @@ export function PageShell({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The redesigned listing hero (2026 drafts): an accent eyebrow over the
|
||||
* headline, the standfirst, the two slow washes drifting behind it, the
|
||||
* staggered entrance, and a rule drawn on under the whole band.
|
||||
*
|
||||
* It is `PageHero` from the page kit, not a second hero: the same component
|
||||
* the home page uses, with `ambient` and `reveal` turned on, so every page on
|
||||
* the site arrives the same way and there is one hero to maintain.
|
||||
*
|
||||
* `children` is the slot under the headline row - the knowledge hub puts its
|
||||
* search box there; `actions` is the page kit's own inline-end slot.
|
||||
*/
|
||||
export function ListingHero({
|
||||
heading,
|
||||
intro,
|
||||
eyebrow,
|
||||
actions,
|
||||
ambient,
|
||||
children,
|
||||
}: {
|
||||
heading: string;
|
||||
intro?: string;
|
||||
eyebrow?: string;
|
||||
actions?: ReactNode;
|
||||
/** This page's own drifting wash - every draft positions its own. */
|
||||
ambient?: readonly HeroWash[];
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2xl">
|
||||
<PageHero
|
||||
title={heading}
|
||||
{...(eyebrow ? { eyebrow } : {})}
|
||||
{...(intro ? { lead: intro } : {})}
|
||||
{...(actions ? { actions } : {})}
|
||||
{...(ambient ? { ambient } : {})}
|
||||
reveal
|
||||
/>
|
||||
|
||||
{children ? <Container>{children}</Container> : null}
|
||||
|
||||
{/* The draft closes the hero with a hairline. Drawn on from the inline
|
||||
start rather than simply appearing - the `barGrow` of the drafts. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="sh-bar-grow h-px w-full bg-[var(--border-subtle)]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
heading,
|
||||
intro,
|
||||
@@ -261,8 +315,11 @@ export function EmptyState({
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
// The drafts draw the empty panel as a dashed outline on whatever band
|
||||
// it sits on - no fill of its own, so it is correct on the #0B0B0A
|
||||
// ground and inside a #1D1D1B band alike.
|
||||
"flex flex-col items-start gap-md rounded-card border border-dashed",
|
||||
"border-border-default bg-surface-sunken p-2xl",
|
||||
"border-border-default p-2xl",
|
||||
)}
|
||||
>
|
||||
<p className="heading-h3 text-text-primary">{title}</p>
|
||||
@@ -524,3 +581,454 @@ export function MetaLine({ items }: { items: readonly string[] }) {
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
PILL FILTERS
|
||||
----------------------------------------------------------------------------
|
||||
The 2026 drafts replace the underlined tab strip with a row of pills on the
|
||||
calendar, the calls list and the activities index: a dot marks the pill in
|
||||
force and the rest are outlined. They are LINKS, exactly as the tab strip
|
||||
was - each combination of filters is its own URL, so it survives a share, a
|
||||
bookmark, the back button and a crawler that only follows links.
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
export interface FilterPill {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly href: string;
|
||||
readonly isCurrent: boolean;
|
||||
}
|
||||
|
||||
export function FilterPills({
|
||||
label,
|
||||
pills,
|
||||
shape = "pill",
|
||||
hideLabel = false,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
pills: readonly FilterPill[];
|
||||
/**
|
||||
* The drafts draw two shapes on the same row: the filters themselves are
|
||||
* fully rounded, while the calendar's List / Month switch is a pair of
|
||||
* square controls. Same markup, same behaviour, one radius token apart.
|
||||
*/
|
||||
shape?: "pill" | "control";
|
||||
/** The view switch carries no visible caption in the draft. */
|
||||
hideLabel?: boolean;
|
||||
/** Anything that belongs on the same row - a region select, a view switch. */
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const radius = shape === "pill" ? "rounded-pill" : "rounded-[var(--radius-control)]";
|
||||
|
||||
return (
|
||||
<nav aria-label={label} className="flex flex-wrap items-center gap-sm">
|
||||
<p
|
||||
className={cx(
|
||||
"label-md uppercase text-text-tertiary",
|
||||
hideLabel && "sr-only",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
|
||||
<ul className="flex flex-wrap items-center gap-xs">
|
||||
{pills.map((pill) => (
|
||||
<li key={pill.id}>
|
||||
<Link
|
||||
to={pill.href}
|
||||
prefetch="intent"
|
||||
aria-current={pill.isCurrent ? "page" : undefined}
|
||||
className={cx(
|
||||
"inline-flex items-center gap-xs border px-md py-xs",
|
||||
radius,
|
||||
"body-sm-bold transition-colors",
|
||||
"duration-[var(--motion-duration-fast)] ease-[var(--motion-ease-standard)]",
|
||||
pill.isCurrent
|
||||
? "border-border-accent bg-surface-accent-subtle text-text-primary"
|
||||
: "border-border-default text-text-secondary hover:border-border-accent hover:text-text-primary",
|
||||
)}
|
||||
>
|
||||
{/* Decorative: `aria-current` already says which pill is on. */}
|
||||
{pill.isCurrent ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="size-[7px] shrink-0 rounded-pill bg-[var(--surface-accent)]"
|
||||
/>
|
||||
) : null}
|
||||
{pill.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{children}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single `<select>` that navigates on change, styled as one of the pills
|
||||
* beside it. A real `<form method="get">`, so it still applies with scripting
|
||||
* off; `keep` carries the filters that are not this one, because a GET submit
|
||||
* replaces the whole query string.
|
||||
*/
|
||||
export function PillSelect({
|
||||
name,
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
allLabel,
|
||||
applyLabel,
|
||||
action,
|
||||
keep,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
value: string;
|
||||
options: readonly SelectOption[];
|
||||
allLabel: string;
|
||||
/** Label of the fallback submit - shown because a <select> cannot submit. */
|
||||
applyLabel: string;
|
||||
action: string;
|
||||
/** Other query parameters to preserve, as [name, value] pairs. */
|
||||
keep?: readonly (readonly [string, string])[];
|
||||
}) {
|
||||
const submit = useSubmit();
|
||||
|
||||
return (
|
||||
<Form
|
||||
method="get"
|
||||
action={action}
|
||||
aria-label={label}
|
||||
onChange={(event) => {
|
||||
if (event.target instanceof HTMLSelectElement) {
|
||||
submit(event.currentTarget, { method: "get" });
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-xs"
|
||||
>
|
||||
{(keep ?? []).map(([key, kept]) => (
|
||||
<input key={key} type="hidden" name={key} value={kept} />
|
||||
))}
|
||||
|
||||
<label className="sr-only" htmlFor={`pill-${name}`}>
|
||||
{label}
|
||||
</label>
|
||||
<select
|
||||
id={`pill-${name}`}
|
||||
name={name}
|
||||
defaultValue={value}
|
||||
className={cx(
|
||||
"body-sm-bold rounded-pill border border-border-default bg-surface-raised",
|
||||
"px-md py-xs text-text-secondary transition-colors",
|
||||
"duration-[var(--motion-duration-fast)] ease-[var(--motion-ease-standard)]",
|
||||
"hover:border-border-accent hover:text-text-primary",
|
||||
)}
|
||||
>
|
||||
<option value="">{allLabel}</option>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Kept for the case where scripting is off - the same bargain the
|
||||
filter bar strikes. A <select> alone cannot submit its form. */}
|
||||
<Button type="submit" variant="ghost" size="sm">
|
||||
{applyLabel}
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
FACET SIDEBAR - the resources listing
|
||||
----------------------------------------------------------------------------
|
||||
The draft draws the resources filters as checkbox groups in a sticky rail.
|
||||
They are rendered as LINKS with a checkbox-shaped marker: one click sets the
|
||||
facet, clicking the active option clears it, and the result is still one
|
||||
URL per view. A real <input type="checkbox"> would promise multi-select,
|
||||
which the Strapi query behind this listing does not offer.
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
export interface FacetOption {
|
||||
readonly value: string;
|
||||
readonly label: string;
|
||||
readonly href: string;
|
||||
readonly isCurrent: boolean;
|
||||
}
|
||||
|
||||
export function FacetGroup({
|
||||
heading,
|
||||
options,
|
||||
}: {
|
||||
heading: string;
|
||||
options: readonly FacetOption[];
|
||||
}) {
|
||||
if (options.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-sm border-t border-border-subtle pt-md">
|
||||
<p className="label-md uppercase text-text-tertiary">{heading}</p>
|
||||
<ul className="flex flex-col gap-2xs">
|
||||
{options.map((option) => (
|
||||
<li key={option.value}>
|
||||
<Link
|
||||
to={option.href}
|
||||
prefetch="intent"
|
||||
aria-current={option.isCurrent ? "true" : undefined}
|
||||
className={cx(
|
||||
"flex items-start gap-sm py-2xs text-start body-sm-bold",
|
||||
"transition-colors duration-[var(--motion-duration-fast)]",
|
||||
"ease-[var(--motion-ease-standard)]",
|
||||
option.isCurrent
|
||||
? "text-text-primary"
|
||||
: "text-text-secondary hover:text-text-primary",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cx(
|
||||
"mt-2xs flex size-[0.875rem] shrink-0 items-center justify-center",
|
||||
"rounded-[var(--radius-control)] border",
|
||||
option.isCurrent
|
||||
? "border-border-accent"
|
||||
: "border-border-default",
|
||||
)}
|
||||
>
|
||||
{option.isCurrent ? (
|
||||
<span className="size-[7px] bg-[var(--surface-accent)]" />
|
||||
) : null}
|
||||
</span>
|
||||
<span>{option.label}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The rail the facet groups sit in - sticky from the large breakpoint up. */
|
||||
export function FacetRail({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<aside
|
||||
aria-label={label}
|
||||
className={cx(
|
||||
"flex min-w-0 flex-col gap-lg lg:sticky lg:top-[var(--layout-sticky-top)]",
|
||||
"lg:max-w-[20rem] lg:self-start",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
SEARCH
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* A standalone search box - the hub's hero field and the resources rail both
|
||||
* use it. `keep` preserves the filters that are not the query, so searching
|
||||
* inside a filtered view does not silently clear the filters.
|
||||
*/
|
||||
export function SearchForm({
|
||||
action,
|
||||
label,
|
||||
placeholder,
|
||||
defaultValue,
|
||||
submitLabel,
|
||||
keep,
|
||||
name = "q",
|
||||
className,
|
||||
}: {
|
||||
action: string;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
defaultValue: string;
|
||||
submitLabel: string;
|
||||
keep?: readonly (readonly [string, string])[];
|
||||
name?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Form
|
||||
method="get"
|
||||
action={action}
|
||||
role="search"
|
||||
aria-label={label}
|
||||
className={cx("flex flex-wrap items-end gap-sm", className)}
|
||||
>
|
||||
{(keep ?? []).map(([key, value]) => (
|
||||
<input key={key} type="hidden" name={key} value={value} />
|
||||
))}
|
||||
|
||||
<Input
|
||||
type="search"
|
||||
name={name}
|
||||
label={label}
|
||||
placeholder={placeholder}
|
||||
defaultValue={defaultValue}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
|
||||
<Button type="submit" variant="primary" size="md">
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
CALLOUT BAND
|
||||
----------------------------------------------------------------------------
|
||||
The wide panel the drafts close a page with: a raised surface, an accent
|
||||
rule down the inline-start edge, a headline and one action. Used by the
|
||||
newsroom ("Are you a partner organisation?") and by the knowledge hub's
|
||||
repository note.
|
||||
|
||||
It is NOT Card.CTA: that card is a filled green field for a primary action,
|
||||
and the drafts draw this one as a quiet panel on the page surface.
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
export function CalloutBand({
|
||||
eyebrow,
|
||||
headline,
|
||||
body,
|
||||
action,
|
||||
headlineAs = "h2",
|
||||
}: {
|
||||
headline: string;
|
||||
action: { readonly to: string; readonly label: string };
|
||||
eyebrow?: string;
|
||||
body?: string;
|
||||
headlineAs?: "h2" | "h3";
|
||||
}) {
|
||||
const Headline = headlineAs;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cx(
|
||||
"sh-reveal flex flex-col gap-lg rounded-card border border-border-subtle",
|
||||
"border-s-4 border-s-[var(--border-accent)] bg-surface-raised p-xl",
|
||||
"md:flex-row md:items-center md:justify-between",
|
||||
)}
|
||||
>
|
||||
<div className="flex max-w-[var(--layout-prose-max)] flex-col gap-xs">
|
||||
{eyebrow ? (
|
||||
<p className="label-md uppercase text-text-accent">{eyebrow}</p>
|
||||
) : null}
|
||||
<Headline className="heading-h3 text-text-primary">{headline}</Headline>
|
||||
{body ? <p className="body-md text-text-secondary">{body}</p> : null}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
as={Link}
|
||||
to={action.to}
|
||||
variant="primary"
|
||||
size="md"
|
||||
iconEnd={<ArrowIcon />}
|
||||
className="shrink-0"
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
FORM CONTROLS
|
||||
----------------------------------------------------------------------------
|
||||
`FilterSelect` above is a GET filter: it has no error state and its id is
|
||||
namespaced for the filter bar. A select that is part of a POSTed form needs
|
||||
both, so it gets its own control here rather than a second set of props on
|
||||
the filter one.
|
||||
|
||||
It is native on purpose, for the same reasons `FilterSelect` is: correct in
|
||||
every browser with a keyboard and a screen reader, and usable before - and
|
||||
without - hydration. The visual treatment is the field treatment from
|
||||
~/components/ui/Input, spent through the same tier-2 tokens.
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
export function FormSelect({
|
||||
id,
|
||||
name,
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
defaultValue,
|
||||
options,
|
||||
required = false,
|
||||
className,
|
||||
}: {
|
||||
id: string;
|
||||
name: string;
|
||||
label: string;
|
||||
options: readonly SelectOption[];
|
||||
hint?: string;
|
||||
error?: string;
|
||||
defaultValue?: string;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const hintId = `${id}-hint`;
|
||||
const errorId = `${id}-error`;
|
||||
const describedBy =
|
||||
[hint ? hintId : null, error ? errorId : null].filter(Boolean).join(" ") ||
|
||||
undefined;
|
||||
|
||||
return (
|
||||
<div className={cx("sh-field flex min-w-0 flex-col gap-xs", className)}>
|
||||
<label className="sh-field__label label-md" htmlFor={id}>
|
||||
{label}
|
||||
{required ? (
|
||||
<span className="sh-field__required ms-2xs" aria-hidden="true">
|
||||
*
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
|
||||
<select
|
||||
id={id}
|
||||
name={name}
|
||||
defaultValue={defaultValue}
|
||||
required={required}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={describedBy}
|
||||
className={cx(
|
||||
"body-md w-full rounded-[var(--radius-control)] border bg-field-bg",
|
||||
"border-field-border px-md py-sm text-[var(--field-fg)]",
|
||||
"transition-colors duration-[var(--motion-duration-fast)]",
|
||||
"ease-[var(--motion-ease-standard)] hover:border-[var(--field-border-hover)]",
|
||||
)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{hint ? (
|
||||
<p id={hintId} className="sh-field__hint body-sm">
|
||||
{hint}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p id={errorId} role="alert" className="sh-field__error body-sm">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -227,3 +227,197 @@ export const NEWS_DETAIL_COPY: Localised<Pick<DetailCopy, "backToListing" | "abo
|
||||
aboutThis: "عن هذا المستجد",
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
LISTING CHROME - the redesign's page furniture
|
||||
----------------------------------------------------------------------------
|
||||
The strings the six listing pages gained with the 2026 redesign drafts:
|
||||
hero eyebrows, the pill and facet labels, the calendar's view switch, the
|
||||
knowledge hub's FAQ band and its repository callout.
|
||||
|
||||
EVERYTHING HERE IS FURNITURE, NOT CONTENT. Not one figure, date, name or
|
||||
commitment from the drafts is carried over: the drafts' sample entries,
|
||||
their "12 modules", their "Closes 15 August 2026", their "within four
|
||||
weeks" and their TBD counters are all absent by design (CLAUDE.md rule 5).
|
||||
The two places where a draft's sentence IS reproduced are traceable to an
|
||||
approved source and noted inline.
|
||||
|
||||
`ListingChrome` is derived from the English object, so a key added on one
|
||||
side and forgotten on the other is a compile error, not a blank label.
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
const LISTING_CHROME_EN = {
|
||||
news: {
|
||||
/** Draft eyebrow over the §5 headline. */
|
||||
eyebrow: "Newsroom",
|
||||
/** The draft's lead card reads "Read the full update". */
|
||||
readFull: "Read the full update",
|
||||
/** Visually hidden heading over the lead article. */
|
||||
featuredLabel: "Latest update",
|
||||
/** Visually hidden heading over the rest of the grid. */
|
||||
moreLabel: "More updates",
|
||||
},
|
||||
events: {
|
||||
eyebrow: "Events calendar",
|
||||
/** Label of the list / month switch. */
|
||||
viewLabel: "View",
|
||||
viewList: "List",
|
||||
viewMonth: "Month",
|
||||
/** Accessible name of the month grid. */
|
||||
calendarLabel: "Events by month",
|
||||
previousMonth: "Previous month",
|
||||
nextMonth: "Next month",
|
||||
monthEmpty: "No events are scheduled in this month.",
|
||||
/** Announced before the day number of a cell that carries events. */
|
||||
dayLabel: "Events on {date}",
|
||||
},
|
||||
applications: {
|
||||
eyebrow: "Applications and opportunities",
|
||||
/**
|
||||
* The draft's three-step application form IS built (see
|
||||
* ../applications-and-opportunities.tsx), but its attachment limit, its
|
||||
* reference number and its "within four weeks" are not: none of the three
|
||||
* is true of this project, so the page states none of them. The wording
|
||||
* that replaced them lives in ../_events-opportunities/copy.ts under
|
||||
* `apply`, annotated key by key.
|
||||
*/
|
||||
pathwayAllLabel: "Ways to take part",
|
||||
},
|
||||
resources: {
|
||||
eyebrow: "Resources and publications",
|
||||
/** Accessible name of the filter sidebar. */
|
||||
facetsLabel: "Refine these results",
|
||||
searchPlaceholder: "Title, author, keyword",
|
||||
/** `resource.resourceLanguage`, spelled out for the card's language chip. */
|
||||
languageEn: "English",
|
||||
languageAr: "Arabic",
|
||||
languageBilingual: "English and Arabic",
|
||||
},
|
||||
knowledge: {
|
||||
eyebrow: "Knowledge hub",
|
||||
searchPlaceholder: "Search the hub — topic, module, or keyword",
|
||||
/**
|
||||
* The count under a type card. The number always comes from Strapi, and
|
||||
* the line is dropped entirely at zero rather than reading "0 published".
|
||||
*/
|
||||
itemCount: "{count} published",
|
||||
browseLabel: "Browse by type",
|
||||
faqEyebrow: "Plain-language basics",
|
||||
faqHeading: "Frequently asked questions",
|
||||
/** Expanded from the approved §7 line for the FAQ entry of the hub. */
|
||||
faqIntro:
|
||||
"Plain-language answers on rule of law, governance, and civic participation basics.",
|
||||
faqEmpty: "Answers are being prepared and will appear here.",
|
||||
repositoryEyebrow: "Long-term repository",
|
||||
/**
|
||||
* ToR §2 and §3.3: the site "acts as a long-term repository for knowledge
|
||||
* and resource sharing linked to SHARP-ER activities and outputs" "with
|
||||
* version control, DOI/handle readiness, and searchable metadata". The
|
||||
* draft's stronger claim - that every item already is versioned and kept
|
||||
* beyond the project's end - is not made here.
|
||||
*/
|
||||
repositoryHeadline:
|
||||
"A long-term repository for knowledge and resources linked to SHARP-ER activities and outputs, with version control and searchable metadata.",
|
||||
repositoryAction: "Browse publications",
|
||||
},
|
||||
activities: {
|
||||
partnerFilterLabel: "Filter by partner",
|
||||
allPartners: "All partners",
|
||||
/** Visually hidden heading over the two onward cards. */
|
||||
nextStepsLabel: "Where to go next",
|
||||
nextEventsEyebrow: "Next",
|
||||
nextApplicationsEyebrow: "Get involved",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type ListingChrome = {
|
||||
readonly [Section in keyof typeof LISTING_CHROME_EN]: {
|
||||
readonly [Key in keyof (typeof LISTING_CHROME_EN)[Section]]: string;
|
||||
};
|
||||
};
|
||||
|
||||
const LISTING_CHROME_AR: ListingChrome = {
|
||||
news: {
|
||||
eyebrow: "غرفة الأخبار",
|
||||
readFull: "اقرأ المستجد كاملًا",
|
||||
featuredLabel: "أحدث مستجد",
|
||||
moreLabel: "مستجدات أخرى",
|
||||
},
|
||||
events: {
|
||||
eyebrow: "التقويم الزمني للفعاليات",
|
||||
viewLabel: "طريقة العرض",
|
||||
viewList: "قائمة",
|
||||
viewMonth: "شهر",
|
||||
calendarLabel: "الفعاليات حسب الشهر",
|
||||
previousMonth: "الشهر السابق",
|
||||
nextMonth: "الشهر التالي",
|
||||
monthEmpty: "لا توجد فعاليات مجدولة في هذا الشهر.",
|
||||
dayLabel: "فعاليات يوم {date}",
|
||||
},
|
||||
applications: {
|
||||
eyebrow: "طلبات وفرص المشاركة",
|
||||
pathwayAllLabel: "طرق المشاركة",
|
||||
},
|
||||
resources: {
|
||||
eyebrow: "الموارد والمنشورات",
|
||||
facetsLabel: "تصفية النتائج",
|
||||
searchPlaceholder: "العنوان أو الكاتب أو كلمة مفتاحية",
|
||||
languageEn: "الإنجليزية",
|
||||
languageAr: "العربية",
|
||||
languageBilingual: "الإنجليزية والعربية",
|
||||
},
|
||||
knowledge: {
|
||||
eyebrow: "مركز المعرفة",
|
||||
searchPlaceholder: "ابحث في المركز: موضوع أو وحدة أو كلمة مفتاحية",
|
||||
itemCount: "عدد المواد: {count}",
|
||||
browseLabel: "التصفح حسب النوع",
|
||||
faqEyebrow: "أساسيات بلغة مبسّطة",
|
||||
faqHeading: "الأسئلة الشائعة",
|
||||
faqIntro:
|
||||
"إجابات بلغة مبسّطة حول أساسيات سيادة القانون والحوكمة والمشاركة المدنية.",
|
||||
faqEmpty: "يجري إعداد الإجابات وستظهر هنا.",
|
||||
repositoryEyebrow: "مستودع طويل الأمد",
|
||||
repositoryHeadline:
|
||||
"مستودع طويل الأمد للمعرفة والموارد المرتبطة بأنشطة SHARP-ER ومخرجاته، مع ضبط الإصدارات وبيانات وصفية قابلة للبحث.",
|
||||
repositoryAction: "تصفّح المنشورات",
|
||||
},
|
||||
activities: {
|
||||
partnerFilterLabel: "التصفية حسب الشريك",
|
||||
allPartners: "جميع الشركاء",
|
||||
nextStepsLabel: "إلى أين بعد ذلك",
|
||||
nextEventsEyebrow: "التالي",
|
||||
nextApplicationsEyebrow: "شارك معنا",
|
||||
},
|
||||
};
|
||||
|
||||
const LISTING_CHROME: Localised<ListingChrome> = {
|
||||
en: LISTING_CHROME_EN,
|
||||
ar: LISTING_CHROME_AR,
|
||||
};
|
||||
|
||||
/** The listing chrome for a locale. Never returns "". */
|
||||
export function getListingChrome(locale: Locale): ListingChrome {
|
||||
return LISTING_CHROME[locale] ?? LISTING_CHROME_EN;
|
||||
}
|
||||
|
||||
/**
|
||||
* `resource.resourceLanguage` spelled out. Returns undefined for a value the
|
||||
* enumeration does not carry, so the chip is dropped rather than invented.
|
||||
*/
|
||||
export function resourceLanguageLabel(
|
||||
value: string | null | undefined,
|
||||
locale: Locale,
|
||||
): string | undefined {
|
||||
const copy = getListingChrome(locale).resources;
|
||||
|
||||
switch (value) {
|
||||
case "en":
|
||||
return copy.languageEn;
|
||||
case "ar":
|
||||
return copy.languageAr;
|
||||
case "bilingual":
|
||||
return copy.languageBilingual;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
+230
-90
@@ -1,18 +1,43 @@
|
||||
/* ============================================================================
|
||||
ABOUT - /en/about and /ar/about
|
||||
----------------------------------------------------------------------------
|
||||
Section 1 of docs/SHARPER_Website_Draft_Section_EN_AR.docx: who SHARP-ER is,
|
||||
the three consortium members and their roles, the three focus areas, and the
|
||||
SHARP I / II legacy the project builds on.
|
||||
The redesigned About page ("About", Claude Design draft), carried onto the
|
||||
project's own design system. Five bands, alternating the two dark surfaces
|
||||
the way the draft alternates #0B0B0A and #1D1D1B:
|
||||
|
||||
The draft copy is the fallback; every block here is overridden field by
|
||||
field by api::about-page.about-page as soon as an editor fills it in.
|
||||
Hero eyebrow, headline and lead on the sunken surface, with the
|
||||
two drifting washes behind it.
|
||||
Facts a four-cell seam grid: the run, the coverage, the lead
|
||||
partner and the funder.
|
||||
Nationwide the approved body prose beside the three focus areas.
|
||||
Consortium the three members as role / name / deliverable panels, under
|
||||
a header row that carries the EU funding line.
|
||||
Legacy the SHARP I and II panel with the accent start-edge, and the
|
||||
call to action into What We Do.
|
||||
|
||||
WHAT THE DRAFT SHIPS THAT THIS PAGE DOES NOT
|
||||
--------------------------------------------
|
||||
* Nothing was cut for sourcing: every fact, role and sentence the draft
|
||||
prints is in section 1 of docs/SHARPER_Website_Draft_Section_EN_AR.docx or
|
||||
in the project brief. What the draft does NOT print - a figure of any kind
|
||||
- stays absent, because the draft marks every statistic "TBD".
|
||||
* The draft's coloured hover glow (a green box-shadow under a hovered card)
|
||||
is deliberately dropped; the tiles only rise.
|
||||
|
||||
Every block is the FALLBACK: api::about-page.about-page overrides it field
|
||||
by field as soon as an editor fills it in.
|
||||
|
||||
MOTION: classes from _page-kit/motion.css - the staggered hero entrance, the
|
||||
drifting washes, the scroll-driven band reveals and the hover lift.
|
||||
|
||||
There is no Arabic frame in the draft, so /ar is this same DOM under
|
||||
`dir="rtl"`: every inline edge below is logical (ps-/pe-/start-/end-, or an
|
||||
explicit `border-inline-start-*`), never left/right.
|
||||
========================================================================= */
|
||||
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { ArrowIcon, Button } from "~/components/ui/Button";
|
||||
import { Card } from "~/components/ui/Card";
|
||||
import { createTranslator, sectionPath } from "~/i18n";
|
||||
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
|
||||
|
||||
@@ -29,10 +54,11 @@ import {
|
||||
InfoPanel,
|
||||
PageHero,
|
||||
PageSection,
|
||||
Prose,
|
||||
SectionHeader,
|
||||
} from "./_page-kit/sections";
|
||||
|
||||
import "./_page-kit/motion.css";
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const locale = routeLocale(params);
|
||||
|
||||
@@ -69,7 +95,10 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||
.map((member) => ({
|
||||
id: member.partner?.documentId ?? String(member.id ?? member.partner?.id),
|
||||
name: member.partner?.name ?? "",
|
||||
role: member.roleDescription ?? member.partner?.description ?? "",
|
||||
/* The chip and the line under the name are separate fields, so a
|
||||
CMS-authored member reads exactly like the bundled copy. */
|
||||
role: member.role ?? null,
|
||||
description: member.roleDescription ?? member.partner?.description ?? "",
|
||||
website: member.partner?.website ?? null,
|
||||
})),
|
||||
legacy: {
|
||||
@@ -115,76 +144,179 @@ export function meta({ loaderData, matches }: Route.MetaArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The accent start-edge the draft paints down the left of its closing panel.
|
||||
* Written as arbitrary properties rather than `border-s-4`: the edge needs its
|
||||
* own colour as well as its own width, and `border-inline-start-*` is the only
|
||||
* spelling that mirrors in Arabic without an `rtl:` override.
|
||||
*/
|
||||
const ACCENT_EDGE =
|
||||
"[border-inline-start-color:var(--border-accent)] [border-inline-start-width:4px]";
|
||||
|
||||
/** One member of the consortium, however it reached the page. */
|
||||
interface ConsortiumMember {
|
||||
id: string;
|
||||
name: string;
|
||||
/** The role chip - `consortium.role` in the CMS, or the bundled copy's. */
|
||||
role: string | null;
|
||||
description: string;
|
||||
website: string | null;
|
||||
}
|
||||
|
||||
export default function About({ loaderData }: Route.ComponentProps) {
|
||||
const { locale, hero, body, richTextBlocks, legacy } = loaderData;
|
||||
const t = createTranslator(locale);
|
||||
const copy = pageCopy(locale).about;
|
||||
const whatWeDoCopy = pageCopy(locale).whatWeDo;
|
||||
|
||||
const pillars = loaderData.pillars.length > 0 ? loaderData.pillars : copy.pillars;
|
||||
const consortium =
|
||||
|
||||
const consortium: readonly ConsortiumMember[] =
|
||||
loaderData.consortium.length > 0
|
||||
? loaderData.consortium
|
||||
? loaderData.consortium.map((member) => ({
|
||||
id: member.id,
|
||||
name: member.name,
|
||||
role: member.role,
|
||||
description: member.description,
|
||||
website: member.website,
|
||||
}))
|
||||
: copy.consortium.map((member) => ({
|
||||
id: member.name,
|
||||
name: member.name,
|
||||
role: member.role,
|
||||
description: member.description,
|
||||
website: null,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHero
|
||||
className="border-b border-[var(--border-subtle)] bg-[var(--surface-sunken)] [--layout-hero-bottom:clamp(3rem,6vw,4.5rem)]"
|
||||
title={hero.headline ?? copy.heroHeadline}
|
||||
eyebrow={hero.eyebrow ?? t("nav.sections.aboutAndStrategy")}
|
||||
eyebrow={hero.eyebrow ?? copy.heroEyebrow}
|
||||
lead={hero.subheading ?? copy.lead}
|
||||
ambient={[{ top: -260, from: "start", offset: "6%", width: 760, height: 600, durationSeconds: 20 }]}
|
||||
reveal
|
||||
{...(hero.media ? { media: hero.media } : {})}
|
||||
/>
|
||||
|
||||
<PageSection className="pt-0 md:pt-0">
|
||||
<Prose>
|
||||
{body ? (
|
||||
<RichText markdown={body} />
|
||||
) : (
|
||||
<p className="body-md text-[var(--text-secondary)]">{copy.body}</p>
|
||||
)}
|
||||
|
||||
{richTextBlocks.map((block) => (
|
||||
<RichText key={block.id} markdown={block.body} />
|
||||
{/* THE FACTS BAND - four cells divided by a one-pixel seam, which is the
|
||||
band's own background showing between them. Facts, not figures: the
|
||||
run, the coverage, the lead partner and the funder, each written out
|
||||
in the approved draft. */}
|
||||
<PageSection className="border-b border-[var(--border-subtle)] bg-[var(--surface-sunken)] pt-0 pb-0 md:pt-0 md:pb-0">
|
||||
<dl className="grid grid-cols-1 gap-[1px] bg-[var(--border-subtle)] sm:grid-cols-2 lg:grid-cols-4">
|
||||
{copy.facts.map((fact) => (
|
||||
<div
|
||||
key={fact.label}
|
||||
className="sh-reveal flex flex-col gap-2xs bg-[var(--surface-sunken)] p-lg transition-colors duration-[var(--motion-duration-base)] hover:bg-[var(--surface-raised)]"
|
||||
>
|
||||
<dt className="label-md uppercase text-[var(--text-tertiary)]">
|
||||
{fact.label}
|
||||
</dt>
|
||||
<dd className="body-lg-bold text-[var(--text-primary)]">
|
||||
{fact.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<p className="body-sm-bold text-[var(--text-accent)]">{copy.fundingNote}</p>
|
||||
</Prose>
|
||||
</dl>
|
||||
</PageSection>
|
||||
|
||||
{/* WHERE WE FOCUS */}
|
||||
<PageSection aria-labelledby="about-pillars-heading">
|
||||
<div className="flex flex-col gap-2xl">
|
||||
<SectionHeader id="about-pillars-heading" title={copy.pillarsHeading} />
|
||||
{/* A NATIONWIDE PROJECT, DELIVERED LOCALLY - the approved body prose on
|
||||
the inline-start side, the three focus areas stacked beside it. The
|
||||
band is the raised dark the draft paints (#1d1d1b) and has to say so:
|
||||
the document ground is now the sunken near-black. */}
|
||||
<PageSection
|
||||
aria-labelledby="about-body-heading"
|
||||
band
|
||||
>
|
||||
<div className="grid grid-cols-1 items-start gap-2xl lg:grid-cols-2 lg:gap-4xl">
|
||||
<div className="sh-reveal flex flex-col gap-md">
|
||||
<SectionHeader id="about-body-heading" title={copy.bodyHeading} />
|
||||
|
||||
<ul className="grid list-none grid-cols-1 gap-lg md:grid-cols-3">
|
||||
{pillars.map((pillar) => (
|
||||
<li key={pillar.title}>
|
||||
<InfoPanel title={pillar.title}>{pillar.description}</InfoPanel>
|
||||
</li>
|
||||
{body ? (
|
||||
<RichText markdown={body} />
|
||||
) : (
|
||||
/* Two paragraphs, as the draft sets them - the run and the
|
||||
consortium first, then what the project does. */
|
||||
copy.body.map((paragraph) => (
|
||||
<p
|
||||
key={paragraph}
|
||||
className="body-md text-[var(--text-secondary)]"
|
||||
>
|
||||
{paragraph}
|
||||
</p>
|
||||
))
|
||||
)}
|
||||
|
||||
{richTextBlocks.map((block) => (
|
||||
<RichText key={block.id} markdown={block.body} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-md">
|
||||
{/* The draft prints no heading over this column; the heading exists
|
||||
for the document outline and for screen readers. */}
|
||||
<h3 id="about-pillars-heading" className="sr-only">
|
||||
{copy.pillarsHeading}
|
||||
</h3>
|
||||
|
||||
<ul
|
||||
aria-labelledby="about-pillars-heading"
|
||||
className="flex list-none flex-col gap-md"
|
||||
>
|
||||
{pillars.map((pillar) => (
|
||||
<li key={pillar.title}>
|
||||
<InfoPanel
|
||||
className="sh-lift sh-reveal sh-reveal--in hover:border-[var(--border-accent)]"
|
||||
title={pillar.title}
|
||||
titleAs="h4"
|
||||
>
|
||||
{pillar.description}
|
||||
</InfoPanel>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</PageSection>
|
||||
|
||||
{/* THE CONSORTIUM */}
|
||||
<PageSection aria-labelledby="about-consortium-heading">
|
||||
{/* The draft draws no rule between the raised band above and this one -
|
||||
the change of surface is the seam - so this band carries none. */}
|
||||
<PageSection
|
||||
aria-labelledby="about-consortium-heading"
|
||||
className="bg-[var(--surface-sunken)]"
|
||||
>
|
||||
<div className="flex flex-col gap-2xl">
|
||||
<SectionHeader
|
||||
id="about-consortium-heading"
|
||||
title={copy.consortiumHeading}
|
||||
/>
|
||||
<div className="sh-reveal flex flex-wrap items-end justify-between gap-lg border-b border-[var(--border-default)] pb-lg">
|
||||
<SectionHeader
|
||||
id="about-consortium-heading"
|
||||
title={copy.consortiumHeading}
|
||||
/>
|
||||
|
||||
{/* EU visibility, as the draft draws it beside this heading. The
|
||||
emblem itself is carried by the navigation and the footer. */}
|
||||
<p className="label-md uppercase text-[var(--text-tertiary)]">
|
||||
{t("site.euStatement")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="grid list-none grid-cols-1 gap-lg md:grid-cols-3">
|
||||
{consortium.map((member) => (
|
||||
<li key={member.id}>
|
||||
<InfoPanel title={member.name}>
|
||||
<span className="block">{member.role}</span>
|
||||
<InfoPanel
|
||||
/* On the sunken band the draft lifts these cards to the
|
||||
raised dark it uses for a page band (#1d1d1b), one step
|
||||
lighter than the panel shade InfoPanel defaults to. The
|
||||
`!` is what makes the override beat the component's own
|
||||
`bg-*` utility, which sorts after this one. */
|
||||
className="sh-lift sh-reveal sh-reveal--in hover:border-[var(--border-accent)]"
|
||||
title={member.name}
|
||||
{...(member.role
|
||||
? { eyebrow: member.role, eyebrowVariant: "chip" as const }
|
||||
: {})}
|
||||
>
|
||||
<span className="block">{member.description}</span>
|
||||
{member.website ? (
|
||||
<a
|
||||
href={member.website}
|
||||
@@ -202,15 +334,21 @@ export default function About({ loaderData }: Route.ComponentProps) {
|
||||
</div>
|
||||
</PageSection>
|
||||
|
||||
{/* SHARP I AND II */}
|
||||
<PageSection aria-labelledby="about-legacy-heading">
|
||||
<div className="flex flex-col gap-lg">
|
||||
<SectionHeader
|
||||
id="about-legacy-heading"
|
||||
title={legacy.headline ?? copy.legacyHeading}
|
||||
/>
|
||||
{/* SHARP I AND II, AND THE WAY ON TO WHAT WE DO */}
|
||||
<PageSection
|
||||
aria-labelledby="about-legacy-heading"
|
||||
className="bg-[var(--surface-sunken)] pt-0 md:pt-0"
|
||||
>
|
||||
<div
|
||||
className={`sh-reveal flex flex-wrap items-center justify-between gap-xl rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)] p-lg md:p-xl ${ACCENT_EDGE}`}
|
||||
>
|
||||
<div className="flex max-w-[44rem] flex-col gap-sm">
|
||||
<SectionHeader
|
||||
id="about-legacy-heading"
|
||||
eyebrow={copy.legacyEyebrow}
|
||||
title={legacy.headline ?? copy.legacyHeading}
|
||||
/>
|
||||
|
||||
<Prose>
|
||||
{legacy.body ? (
|
||||
<RichText markdown={legacy.body} />
|
||||
) : (
|
||||
@@ -218,49 +356,51 @@ export default function About({ loaderData }: Route.ComponentProps) {
|
||||
{copy.legacyBody}
|
||||
</p>
|
||||
)}
|
||||
</Prose>
|
||||
</div>
|
||||
|
||||
{/* The draft marks the legacy page as "[link: …]" with no target yet,
|
||||
so the button appears only once the CMS supplies a real one. */}
|
||||
{legacy.link ? (
|
||||
legacy.link.isExternal ? (
|
||||
<Button
|
||||
as="a"
|
||||
href={legacy.link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="secondary"
|
||||
iconEnd={<ArrowIcon />}
|
||||
className="self-start"
|
||||
>
|
||||
{legacy.link.label}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
as={Link}
|
||||
to={legacy.link.href}
|
||||
prefetch="intent"
|
||||
variant="secondary"
|
||||
iconEnd={<ArrowIcon />}
|
||||
className="self-start"
|
||||
>
|
||||
{legacy.link.label}
|
||||
</Button>
|
||||
)
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-md">
|
||||
<Button
|
||||
as={Link}
|
||||
to={sectionPath("whatWeDo", locale)}
|
||||
prefetch="intent"
|
||||
size="lg"
|
||||
iconEnd={<ArrowIcon />}
|
||||
>
|
||||
{t("actions.learnWhatWeDo")}
|
||||
</Button>
|
||||
|
||||
{/* The draft marks the legacy page as "[link: …]" with no target
|
||||
yet, so this second button appears only once the CMS gives it
|
||||
a real one. */}
|
||||
{legacy.link ? (
|
||||
legacy.link.isExternal ? (
|
||||
<Button
|
||||
as="a"
|
||||
href={legacy.link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
iconEnd={<ArrowIcon />}
|
||||
>
|
||||
{legacy.link.label}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
as={Link}
|
||||
to={legacy.link.href}
|
||||
prefetch="intent"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
iconEnd={<ArrowIcon />}
|
||||
>
|
||||
{legacy.link.label}
|
||||
</Button>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</PageSection>
|
||||
|
||||
<PageSection>
|
||||
<Card.CTA
|
||||
headline={whatWeDoCopy.heroHeadline}
|
||||
body={whatWeDoCopy.lead}
|
||||
action={{
|
||||
href: sectionPath("whatWeDo", locale),
|
||||
label: t("actions.learnWhatWeDo"),
|
||||
}}
|
||||
/>
|
||||
</PageSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,29 @@
|
||||
URL /en/activities-and-initiatives · /ar/activities-and-initiatives
|
||||
Route id "activities" (see app/routes.ts)
|
||||
|
||||
The page is organised by the five activity streams of the approved draft
|
||||
copy (docs/SHARPER_Website_Draft_Section_EN_AR.docx, section 3): the stream
|
||||
headings, descriptions and implementing partners are project structure and
|
||||
live in ./activities.content, while the activities filed under each stream
|
||||
come from Strapi.
|
||||
Laid out to the 2026 redesign draft ("Activities-and-Initiatives"): an
|
||||
ambient hero, a "filter by partner" pill row, and the five activity streams
|
||||
as a numbered disclosure list - each row carrying its implementing partner
|
||||
and opening onto the stream's description and the activities filed under it.
|
||||
|
||||
The five streams are the approved draft copy
|
||||
(docs/SHARPER_Website_Draft_Section_EN_AR.docx, section 3): their headings,
|
||||
descriptions and implementing partners are project structure and live in
|
||||
./activities.content, while the activities themselves come from Strapi.
|
||||
|
||||
THE DISCLOSURE IS A NATIVE <details>, NOT THE PAGE KIT'S <Accordion>.
|
||||
That component renders a markdown answer and nothing else; these panels
|
||||
carry a grid of links to the activities in the stream, and those links have
|
||||
to stay in the served HTML whether the panel is open or shut, or the only
|
||||
route a crawler has to an activity page disappears. `<details>` keeps them
|
||||
there and needs no JavaScript to work.
|
||||
|
||||
THE FIGURES BAND IS NOT BUILT. The draft draws three counters reading "TBD"
|
||||
over a note saying the numbers must be confirmed before publication; the
|
||||
approved copy says the same ("[XX+] activities completed · [XX+] CSOs
|
||||
engaged · [X] regions covered - placeholder only"). Neither the figures nor
|
||||
the placeholder is rendered (CLAUDE.md rule 5), and there is no
|
||||
`activities-page` single type in the CMS to read a confirmed figure from.
|
||||
|
||||
THE FIELD NAMES BELOW ARE THE CMS'S OWN
|
||||
apps/cms/src/api/activity/content-types/activity/schema.json:
|
||||
@@ -16,15 +34,10 @@
|
||||
`cover`), `implementingPartners` and `regions` as RELATIONS, and NO dates
|
||||
or `location` string. Sorting or filtering on a field the content type does
|
||||
not have is a 400 from Strapi, so nothing here is guessed.
|
||||
|
||||
The CMS is empty today, so the empty state is what actually renders: the
|
||||
five streams still describe the work, and one localised panel explains that
|
||||
individual activities are published as they are completed.
|
||||
========================================================================= */
|
||||
|
||||
import { Link } from "react-router";
|
||||
import { useSearchParams } from "react-router";
|
||||
|
||||
import { ArrowIcon, Button } from "~/components/ui/Button";
|
||||
import { Card } from "~/components/ui/Card";
|
||||
import { getMessages, sectionPath, useTranslation } from "~/i18n";
|
||||
import { DEFAULT_LOCALE, isLocale, type Locale } from "~/i18n/config";
|
||||
@@ -39,6 +52,7 @@ import {
|
||||
} from "~/lib/strapi";
|
||||
|
||||
import {
|
||||
ACTIVITY_STREAMS,
|
||||
ACTIVITY_STREAM_KEYS,
|
||||
activitiesIndexPath,
|
||||
activityPath,
|
||||
@@ -49,11 +63,17 @@ import {
|
||||
readRootMeta,
|
||||
streamAnchorId,
|
||||
toActivityStreamKey,
|
||||
type ActivityStreamKey,
|
||||
type ActivitySummary,
|
||||
} from "./activities.content";
|
||||
import { getRouteCopy } from "./_events-opportunities/copy";
|
||||
import { FilterPills, ListingHero, PageShell } from "./_shared/listing-ui";
|
||||
import { getListingChrome } from "./_shared/page-copy";
|
||||
|
||||
import type { Route } from "./+types/activities-and-initiatives";
|
||||
|
||||
import "./_page-kit/motion.css";
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
CMS SHAPES
|
||||
------------------------------------------------------------------------ */
|
||||
@@ -109,6 +129,42 @@ function namesOf(entries: readonly NamedEntity[] | null | undefined): string | n
|
||||
/** How many activities one request pulls. Well above the project's volume. */
|
||||
const ACTIVITY_PAGE_SIZE = 100;
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
THE PARTNER FILTER
|
||||
----------------------------------------------------------------------------
|
||||
The draft's pill row. The three consortium members are proper names, not
|
||||
translatable copy - they are written the same way in the English and the
|
||||
Arabic draft - and which stream each one implements is read off the approved
|
||||
stream copy rather than restated here, so the filter cannot drift from the
|
||||
line the panel shows.
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
const PARTNER_FILTERS = ["acted", "uec", "sbe"] as const;
|
||||
type PartnerFilter = (typeof PARTNER_FILTERS)[number];
|
||||
|
||||
const PARTNER_LABELS: Readonly<Record<PartnerFilter, string>> = {
|
||||
acted: "Acted",
|
||||
uec: "UEC",
|
||||
sbe: "SBE",
|
||||
};
|
||||
|
||||
function isPartnerFilter(value: unknown): value is PartnerFilter {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(PARTNER_FILTERS as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
/** True when the stream's approved partner line names this organisation. */
|
||||
function streamHasPartner(
|
||||
stream: ActivityStreamKey,
|
||||
partner: PartnerFilter,
|
||||
): boolean {
|
||||
return ACTIVITY_STREAMS[DEFAULT_LOCALE][stream].partners
|
||||
.toLowerCase()
|
||||
.includes(partner);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
LOADER
|
||||
------------------------------------------------------------------------ */
|
||||
@@ -154,17 +210,6 @@ function toSummary(entity: ActivityEntity): ActivitySummary {
|
||||
};
|
||||
}
|
||||
|
||||
/* DRAFT COPY NOTE - the source document ends section 3 with
|
||||
"[XX+] activities completed · [XX+] CSOs engaged · [X] regions covered -
|
||||
placeholder only: these figures are marked "TBD" in the official fact
|
||||
sheet and must not be published until confirmed."
|
||||
Nothing is hard-coded here and no placeholder is rendered. The figures
|
||||
strip has also been removed rather than left pointing at an
|
||||
`activities-page` single type that DOES NOT EXIST in the CMS (the single
|
||||
types are about-page, contact-page, home-page, site-settings and
|
||||
what-we-do-page): a request to /api/activities-page is a 404 on every page
|
||||
load. Give the section a home in Strapi first, then bring the strip back. */
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
// The ":lang" layout has already validated the segment; the guard is here so
|
||||
// the locale is a `Locale` by inference rather than by assertion.
|
||||
@@ -229,119 +274,245 @@ export default function ActivitiesAndInitiatives({
|
||||
}: Route.ComponentProps) {
|
||||
const { activities } = loaderData;
|
||||
const { locale, t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const copy = getIndexCopy(locale);
|
||||
const chrome = getListingChrome(locale).activities;
|
||||
const routeCopy = getRouteCopy(locale);
|
||||
const grouped = groupActivitiesByStream(activities);
|
||||
const hasActivities = activities.length > 0;
|
||||
|
||||
const pathname = activitiesIndexPath(locale);
|
||||
const requested = searchParams.get("partner");
|
||||
const partner = isPartnerFilter(requested) ? requested : null;
|
||||
|
||||
const streams = ACTIVITY_STREAM_KEYS.filter(
|
||||
(stream) => partner === null || streamHasPartner(stream, partner),
|
||||
);
|
||||
|
||||
const pills = [
|
||||
{
|
||||
id: "all",
|
||||
label: chrome.allPartners,
|
||||
href: pathname,
|
||||
isCurrent: partner === null,
|
||||
},
|
||||
...PARTNER_FILTERS.map((value) => ({
|
||||
id: value,
|
||||
label: PARTNER_LABELS[value],
|
||||
href: `${pathname}?partner=${value}`,
|
||||
isCurrent: partner === value,
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[var(--layout-content-max)] flex-col gap-[var(--layout-section-gap)] px-[var(--layout-gutter)] py-4xl">
|
||||
<header className="flex max-w-[var(--layout-prose-max)] flex-col gap-md">
|
||||
<p className="label-md text-text-accent">{copy.eyebrow}</p>
|
||||
<h1 className="display-sm text-text-primary">{copy.headline}</h1>
|
||||
<p className="body-lg text-text-secondary">{copy.lede}</p>
|
||||
</header>
|
||||
<>
|
||||
<ListingHero
|
||||
eyebrow={copy.eyebrow}
|
||||
heading={copy.headline}
|
||||
intro={copy.lede}
|
||||
ambient={[{ top: -250, from: "start", offset: "12%", width: 740, height: 580, durationSeconds: 21 }]}
|
||||
/>
|
||||
|
||||
<nav aria-label={copy.streamsNavLabel}>
|
||||
<h2 className="sr-only">{copy.streamsNavLabel}</h2>
|
||||
<ul className="flex flex-wrap gap-sm">
|
||||
{ACTIVITY_STREAM_KEYS.map((stream) => (
|
||||
<li key={stream}>
|
||||
<a
|
||||
href={`#${streamAnchorId(stream)}`}
|
||||
className="body-sm-bold inline-flex rounded-pill border border-border-subtle px-md py-xs text-text-secondary transition-colors duration-[var(--motion-duration-fast)] ease-[var(--motion-ease-standard)] hover:border-border-strong hover:text-text-primary"
|
||||
>
|
||||
{getStreamCopy(stream, locale).title}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
<PageShell>
|
||||
{/* The draft reveals the filter row as it enters, like every other
|
||||
band on the page. */}
|
||||
<div className="sh-reveal">
|
||||
<FilterPills label={chrome.partnerFilterLabel} pills={pills} />
|
||||
</div>
|
||||
|
||||
{hasActivities ? null : (
|
||||
<section
|
||||
aria-labelledby="activities-empty"
|
||||
className="flex flex-col items-start gap-md rounded-card border border-border-subtle bg-surface-raised p-xl"
|
||||
>
|
||||
<h2 id="activities-empty" className="body-lg-bold text-text-primary">
|
||||
{copy.emptyTitle}
|
||||
</h2>
|
||||
<p className="body-md max-w-[var(--layout-prose-max)] text-text-secondary">
|
||||
{copy.emptyBody}
|
||||
</p>
|
||||
<Button
|
||||
as={Link}
|
||||
to={sectionPath("events", locale)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
iconEnd={<ArrowIcon />}
|
||||
>
|
||||
{t("nav.sections.events")}
|
||||
</Button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{ACTIVITY_STREAM_KEYS.map((stream) => {
|
||||
const streamCopy = getStreamCopy(stream, locale);
|
||||
const items = grouped[stream];
|
||||
const anchor = streamAnchorId(stream);
|
||||
|
||||
return (
|
||||
{hasActivities ? null : (
|
||||
<section
|
||||
key={stream}
|
||||
id={anchor}
|
||||
aria-labelledby={`${anchor}-title`}
|
||||
className="flex scroll-mt-4xl flex-col gap-lg"
|
||||
aria-labelledby="activities-empty"
|
||||
className="flex flex-col items-start gap-md rounded-card border border-dashed border-border-default p-xl"
|
||||
>
|
||||
<div className="flex max-w-[var(--layout-prose-max)] flex-col gap-xs">
|
||||
<h2 id={`${anchor}-title`} className="heading-h2 text-text-primary">
|
||||
{streamCopy.title}
|
||||
</h2>
|
||||
<p className="body-md text-text-secondary">
|
||||
{streamCopy.description}
|
||||
</p>
|
||||
<p className="body-sm text-text-tertiary">
|
||||
<span className="body-sm-bold text-text-secondary">
|
||||
{streamCopy.partnerLabel}:
|
||||
</span>{" "}
|
||||
{streamCopy.partners}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{items.length > 0 ? (
|
||||
<>
|
||||
<h3 className="sr-only">{copy.streamListLabel}</h3>
|
||||
<ul className="grid list-none grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((activity) => (
|
||||
<Card.Content
|
||||
as="li"
|
||||
key={activity.id}
|
||||
titleAs="h4"
|
||||
href={activityPath(activity.slug, locale)}
|
||||
title={activity.title}
|
||||
eyebrow={activityEyebrow(activity)}
|
||||
excerpt={activity.summary ?? undefined}
|
||||
actionLabel={t("actions.viewDetails")}
|
||||
image={
|
||||
activity.imageUrl
|
||||
? { src: activity.imageUrl, alt: activity.imageAlt }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : hasActivities ? (
|
||||
<p className="body-sm text-text-tertiary">{copy.streamEmpty}</p>
|
||||
) : null}
|
||||
<h2 id="activities-empty" className="body-lg-bold text-text-primary">
|
||||
{copy.emptyTitle}
|
||||
</h2>
|
||||
<p className="body-md max-w-[var(--layout-prose-max)] text-text-secondary">
|
||||
{copy.emptyBody}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section aria-label={copy.streamsNavLabel} className="flex flex-col">
|
||||
<h2 className="sr-only">{copy.streamsNavLabel}</h2>
|
||||
|
||||
<div className="border-t border-border-default">
|
||||
{streams.map((stream, index) => (
|
||||
<StreamDisclosure
|
||||
key={stream}
|
||||
stream={stream}
|
||||
ordinal={index + 1}
|
||||
open={index === 0}
|
||||
activities={grouped[stream]}
|
||||
hasActivities={hasActivities}
|
||||
locale={locale}
|
||||
viewDetailsLabel={t("actions.viewDetails")}
|
||||
streamEmptyLabel={copy.streamEmpty}
|
||||
streamListLabel={copy.streamListLabel}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Where to go next - the draft's two closing cards. Both blurbs are
|
||||
the approved standfirsts of sections 4 and 8, not new copy. */}
|
||||
<nav aria-label={chrome.nextStepsLabel}>
|
||||
<h2 className="sr-only">{chrome.nextStepsLabel}</h2>
|
||||
<ul className="grid gap-lg sm:grid-cols-2">
|
||||
<Card.Content
|
||||
as="li"
|
||||
titleAs="h3"
|
||||
className="sh-reveal sh-reveal--in"
|
||||
href={sectionPath("events", locale)}
|
||||
eyebrow={chrome.nextEventsEyebrow}
|
||||
title={routeCopy.events.heading}
|
||||
excerpt={routeCopy.events.intro}
|
||||
actionLabel={t("actions.viewUpcomingSessions")}
|
||||
/>
|
||||
<Card.Content
|
||||
as="li"
|
||||
titleAs="h3"
|
||||
className="sh-reveal sh-reveal--in"
|
||||
href={sectionPath("applications", locale)}
|
||||
eyebrow={chrome.nextApplicationsEyebrow}
|
||||
title={routeCopy.opportunities.heading}
|
||||
excerpt={routeCopy.opportunities.intro}
|
||||
actionLabel={t("actions.seeCurrentCalls")}
|
||||
/>
|
||||
</ul>
|
||||
</nav>
|
||||
</PageShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The card kicker: where the activity runs. An activity carries no dates. */
|
||||
function activityEyebrow(activity: ActivitySummary): string | undefined {
|
||||
return activity.location ?? undefined;
|
||||
/* ---------------------------------------------------------------------------
|
||||
ONE STREAM
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
/** The +/- mark, drawn the way the page kit's accordion draws it. */
|
||||
function ToggleMark() {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={[
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-pill border",
|
||||
"border-border-default text-text-accent transition-colors",
|
||||
"duration-[var(--motion-duration-base)]",
|
||||
"group-open:border-border-accent group-open:bg-surface-accent-subtle",
|
||||
].join(" ")}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||
<path d="M1 7h12" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<path
|
||||
d="M7 1v12"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
className="origin-center transition-transform duration-[var(--motion-duration-base)] group-open:scale-y-0"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamDisclosure({
|
||||
stream,
|
||||
ordinal,
|
||||
open,
|
||||
activities,
|
||||
hasActivities,
|
||||
locale,
|
||||
viewDetailsLabel,
|
||||
streamEmptyLabel,
|
||||
streamListLabel,
|
||||
}: {
|
||||
stream: ActivityStreamKey;
|
||||
ordinal: number;
|
||||
open: boolean;
|
||||
activities: readonly ActivitySummary[];
|
||||
hasActivities: boolean;
|
||||
locale: Locale;
|
||||
viewDetailsLabel: string;
|
||||
streamEmptyLabel: string;
|
||||
streamListLabel: string;
|
||||
}) {
|
||||
const copy = getStreamCopy(stream, locale);
|
||||
const anchor = streamAnchorId(stream);
|
||||
const number = new Intl.NumberFormat(`${locale}-u-nu-latn`, {
|
||||
minimumIntegerDigits: 2,
|
||||
useGrouping: false,
|
||||
}).format(ordinal);
|
||||
|
||||
return (
|
||||
<details
|
||||
id={anchor}
|
||||
open={open}
|
||||
className="sh-reveal sh-lift group scroll-mt-2xl border-b border-border-default hover:bg-surface-raised"
|
||||
>
|
||||
<summary
|
||||
className={[
|
||||
"flex cursor-pointer list-none items-center gap-lg py-lg",
|
||||
"text-start text-text-primary transition-colors",
|
||||
"duration-[var(--motion-duration-base)] hover:text-text-accent",
|
||||
"[&::-webkit-details-marker]:hidden",
|
||||
].join(" ")}
|
||||
>
|
||||
<span aria-hidden="true" className="label-md min-w-8 text-text-tertiary">
|
||||
{number}
|
||||
</span>
|
||||
|
||||
<h3 className="flex-1 heading-h3">{copy.title}</h3>
|
||||
|
||||
<span className="hidden shrink-0 rounded-[var(--radius-control)] bg-surface-accent-subtle px-xs py-2xs label-md uppercase text-text-accent sm:inline-block">
|
||||
{copy.partners}
|
||||
</span>
|
||||
|
||||
<ToggleMark />
|
||||
</summary>
|
||||
|
||||
{/* The animation is attached by the `open` state, so it plays when the
|
||||
reader opens the row rather than once on first paint. */}
|
||||
<div className="flex flex-col gap-lg pb-xl ps-2xl group-open:[animation:sh-answer_var(--motion-duration-base)_var(--motion-ease-entrance)_both]">
|
||||
<div className="flex max-w-[var(--layout-prose-max)] flex-col gap-xs">
|
||||
<p className="body-md text-text-secondary">{copy.description}</p>
|
||||
<p className="body-sm text-text-tertiary">
|
||||
<span className="body-sm-bold text-text-secondary">
|
||||
{copy.partnerLabel}:
|
||||
</span>{" "}
|
||||
{copy.partners}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{activities.length > 0 ? (
|
||||
<>
|
||||
<h4 className="sr-only">{streamListLabel}</h4>
|
||||
<ul className="grid list-none grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3">
|
||||
{activities.map((activity) => (
|
||||
<Card.Content
|
||||
as="li"
|
||||
key={activity.id}
|
||||
titleAs="h4"
|
||||
href={activityPath(activity.slug, locale)}
|
||||
title={activity.title}
|
||||
eyebrow={activity.location ?? undefined}
|
||||
excerpt={activity.summary ?? undefined}
|
||||
actionLabel={viewDetailsLabel}
|
||||
image={
|
||||
activity.imageUrl
|
||||
? { src: activity.imageUrl, alt: activity.imageAlt }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : hasActivities ? (
|
||||
<p className="body-sm text-text-tertiary">{streamEmptyLabel}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+202
-111
@@ -13,18 +13,40 @@
|
||||
|
||||
Every message the reader can see is localised: field labels and the generic
|
||||
errors come from the i18n catalogue, the page prose from the approved draft.
|
||||
|
||||
THE REDESIGN
|
||||
------------
|
||||
The Claude Design draft ("Contact") lays the page out as two columns on the
|
||||
sunken surface: the form in a raised panel, and beside it the channels, the
|
||||
consortium disclosure list and the EU funding note. Motion is the classes in
|
||||
_page-kit/motion.css - the hero entrance and washes, the band reveals, the
|
||||
panel that rises when the message is sent, and the answer that opens under a
|
||||
question. The draft's coloured hover glow is deliberately dropped.
|
||||
|
||||
WHAT THE DRAFT SHIPS THAT THIS PAGE DOES NOT
|
||||
* "The project team replies within five working days." No approved
|
||||
document makes that commitment, so it is not published (CLAUDE.md 5).
|
||||
* "info@sharper.ly" and "Facebook - SHARP-ER Libya". The approved draft
|
||||
writes both as placeholders ("[project email - e.g. ...]", "[Facebook
|
||||
page link placeholder]"), so the address and the social links come from
|
||||
the CMS and the page says plainly that they are still to be published.
|
||||
* The three consortium addresses, which the draft fills with "Contact
|
||||
details TBD" - the disclosure list renders only what the CMS holds.
|
||||
========================================================================= */
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { SITE_FACEBOOK_URL } from "~/lib/site-links";
|
||||
import { data, Form, Link, useLocation, useNavigation } from "react-router";
|
||||
|
||||
import { ArrowIcon, Button } from "~/components/ui/Button";
|
||||
import { EuBadge } from "~/components/ui/EuBadge";
|
||||
import { Input, Textarea } from "~/components/ui/Input";
|
||||
import { createTranslator, sectionPath, type Locale } from "~/i18n";
|
||||
import { getServerEnv } from "~/lib/env.server";
|
||||
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
|
||||
|
||||
import type { Route } from "./+types/contact";
|
||||
import { Accordion, type AccordionItem } from "./_page-kit/accordion";
|
||||
import type { CmsContactPage } from "./_page-kit/cms-types";
|
||||
import { list, loadSingle, toImage } from "./_page-kit/cms.server";
|
||||
import { pageCopy } from "./_page-kit/copy";
|
||||
@@ -32,13 +54,13 @@ import { routeLocale } from "./_page-kit/locale";
|
||||
import { RichText } from "./_page-kit/rich-text";
|
||||
import {
|
||||
EmptyState,
|
||||
InfoPanel,
|
||||
PageHero,
|
||||
PageSection,
|
||||
Prose,
|
||||
SectionHeader,
|
||||
} from "./_page-kit/sections";
|
||||
|
||||
import "./_page-kit/motion.css";
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
LOADER
|
||||
------------------------------------------------------------------------ */
|
||||
@@ -283,6 +305,51 @@ export async function action({ params, request }: Route.ActionArgs) {
|
||||
PAGE
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
/** See the note on the same constant in about.tsx. */
|
||||
const ACCENT_EDGE =
|
||||
"[border-inline-start-color:var(--border-accent)] [border-inline-start-width:4px]";
|
||||
|
||||
/** The tick the draft draws in a filled circle over the "message sent" panel. */
|
||||
function SentMark() {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex size-12 shrink-0 items-center justify-center rounded-pill bg-[var(--surface-accent)] text-[var(--text-on-accent)]"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M5 13l4.5 4.5L19 7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** One label-and-value row of the channels list. */
|
||||
function Channel({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2xs border-b border-[var(--border-subtle)] pb-md">
|
||||
<dt className="label-md uppercase text-[var(--text-tertiary)]">{label}</dt>
|
||||
{/* The draft sets the value itself in the page's primary tone, a step
|
||||
above the label. */}
|
||||
<dd className="body-lg-bold text-[var(--text-primary)]">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const LINK_CLASS =
|
||||
"text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]";
|
||||
|
||||
export default function Contact({
|
||||
loaderData,
|
||||
actionData,
|
||||
@@ -292,6 +359,13 @@ export default function Contact({
|
||||
const t = createTranslator(locale);
|
||||
const copy = pageCopy(locale).contact;
|
||||
|
||||
/* The CMS wins; until an editor fills the row, the project's own confirmed
|
||||
channel stands in (lib/site-links.ts). */
|
||||
const channels =
|
||||
socialLinks.length > 0
|
||||
? socialLinks
|
||||
: [{ label: copy.facebookLabel, href: SITE_FACEBOOK_URL, isExternal: true }];
|
||||
|
||||
const navigation = useNavigation();
|
||||
const { pathname } = useLocation();
|
||||
const isSubmitting = navigation.state === "submitting";
|
||||
@@ -305,6 +379,32 @@ export default function Contact({
|
||||
(message): message is string => typeof message === "string",
|
||||
);
|
||||
|
||||
/* The draft draws the consortium addresses as a disclosure list, which the
|
||||
approved draft also calls for ("optional accordion"). The answers are
|
||||
Markdown because that is what <Accordion /> renders; every value in them
|
||||
comes from the CMS, and the whole list is replaced by the localised empty
|
||||
state while the addresses are still "TBD". */
|
||||
const consortiumItems: readonly AccordionItem[] = consortiumContacts.map(
|
||||
(contact) => {
|
||||
const place = [contact.city, contact.country].filter(Boolean).join(", ");
|
||||
|
||||
return {
|
||||
id: contact.id,
|
||||
question: contact.organisation,
|
||||
answer: [
|
||||
contact.addressLine,
|
||||
place === "" ? null : place,
|
||||
contact.email ? `[${contact.email}](mailto:${contact.email})` : null,
|
||||
contact.phone
|
||||
? `[${contact.phone}](tel:${contact.phone.replace(/\s+/g, "")})`
|
||||
: null,
|
||||
]
|
||||
.filter((line): line is string => typeof line === "string")
|
||||
.join("\n\n"),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
/* A failed submit re-renders the page with the summary at the top of the
|
||||
form. Screen-reader users are left wherever they were, and keyboard users
|
||||
below the fold, so focus is moved onto the summary itself - it is
|
||||
@@ -320,32 +420,47 @@ export default function Contact({
|
||||
return (
|
||||
<>
|
||||
<PageHero
|
||||
className="border-b border-[var(--border-subtle)] bg-[var(--surface-sunken)] [--layout-hero-bottom:clamp(2.5rem,5vw,3.75rem)]"
|
||||
title={hero.headline ?? copy.heroHeadline}
|
||||
eyebrow={hero.eyebrow ?? t("nav.sections.engagementAndOpportunities")}
|
||||
eyebrow={hero.eyebrow ?? t("nav.sections.contact")}
|
||||
lead={hero.subheading ?? copy.lead}
|
||||
ambient={[{ top: -250, from: "start", offset: "6%", width: 720, height: 560, durationSeconds: 21 }]}
|
||||
reveal
|
||||
{...(hero.media ? { media: hero.media } : {})}
|
||||
/>
|
||||
|
||||
<PageSection className="pt-0 md:pt-0">
|
||||
<div className="grid grid-cols-1 gap-2xl lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
|
||||
<PageSection className="bg-[var(--surface-sunken)]">
|
||||
{/* Two equal columns, as the draft sets them - the form beside the
|
||||
channels, each starting at the top of the band. */}
|
||||
<div className="grid grid-cols-1 items-start gap-2xl lg:grid-cols-2 lg:gap-4xl">
|
||||
{/* ---------------------------------------------------------------
|
||||
THE FORM
|
||||
THE FORM - one raised panel on the sunken band, as the draft has
|
||||
it. The mechanism underneath is unchanged: a real HTML form post
|
||||
that the server validates.
|
||||
--------------------------------------------------------------- */}
|
||||
<div className="flex flex-col gap-lg">
|
||||
<SectionHeader
|
||||
id="contact-form-heading"
|
||||
title={copy.formHeading}
|
||||
lead={copy.formIntro}
|
||||
/>
|
||||
<div className="sh-reveal sh-reveal--in flex flex-col gap-lg rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)] p-lg md:p-xl">
|
||||
{/* The draft opens the panel straight onto the fields; the heading
|
||||
is what labels the form in the outline and for a screen
|
||||
reader. */}
|
||||
<h2 id="contact-form-heading" className="sr-only">
|
||||
{copy.formHeading}
|
||||
</h2>
|
||||
|
||||
{isSuccess ? (
|
||||
/* The draft swaps the fields for this acknowledgement inside the
|
||||
same panel - no box of its own. */
|
||||
<div
|
||||
className="flex flex-col items-start gap-md rounded-card border border-[var(--border-accent)] bg-[var(--surface-accent-subtle)] p-lg"
|
||||
className="sh-rise flex flex-col items-start gap-md"
|
||||
role="status"
|
||||
>
|
||||
<p className="body-lg-bold text-[var(--text-primary)]">
|
||||
<SentMark />
|
||||
|
||||
<p className="heading-h3 text-[var(--text-primary)]">
|
||||
{t("form.successTitle")}
|
||||
</p>
|
||||
{/* The draft promises a reply "within five working days"; no
|
||||
approved document does, so the generic acknowledgement from
|
||||
the message catalogue stands instead. */}
|
||||
<p className="body-md text-[var(--text-secondary)]">
|
||||
{t("form.successBody")}
|
||||
</p>
|
||||
@@ -405,7 +520,9 @@ export default function Contact({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-lg md:grid-cols-2">
|
||||
{/* One column at every width - the draft stacks the four
|
||||
fields above the message. */}
|
||||
<div className="flex flex-col gap-lg">
|
||||
<Input
|
||||
name="name"
|
||||
id={fieldDomId("name")}
|
||||
@@ -474,128 +591,102 @@ export default function Contact({
|
||||
</div>
|
||||
|
||||
{/* ---------------------------------------------------------------
|
||||
CONTACT DETAILS
|
||||
THE CHANNELS COLUMN
|
||||
--------------------------------------------------------------- */}
|
||||
<div className="flex flex-col gap-lg">
|
||||
<SectionHeader id="contact-details-heading" title={copy.detailsHeading} />
|
||||
<div className="flex flex-col gap-xl">
|
||||
<div className="sh-reveal flex flex-col gap-md">
|
||||
{/* The draft opens this column straight onto the channel rows;
|
||||
the heading is here for the outline only. */}
|
||||
<h2 id="contact-details-heading" className="sr-only">
|
||||
{copy.detailsHeading}
|
||||
</h2>
|
||||
|
||||
{intro ? (
|
||||
<Prose>
|
||||
<RichText markdown={intro} />
|
||||
</Prose>
|
||||
) : null}
|
||||
{intro ? (
|
||||
<Prose>
|
||||
<RichText markdown={intro} />
|
||||
</Prose>
|
||||
) : null}
|
||||
|
||||
<dl className="flex flex-col gap-md rounded-card border border-[var(--border-subtle)] bg-[var(--surface-raised)] p-lg">
|
||||
<div className="flex flex-col gap-2xs">
|
||||
<dt className="label-md uppercase text-[var(--text-tertiary)]">
|
||||
{copy.generalEnquiries}
|
||||
</dt>
|
||||
<dd className="body-md text-[var(--text-secondary)]">
|
||||
<dl className="flex flex-col gap-md">
|
||||
<Channel label={copy.generalEnquiries}>
|
||||
{generalEmail ? (
|
||||
<a
|
||||
href={`mailto:${generalEmail}`}
|
||||
className="text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
|
||||
>
|
||||
<a href={`mailto:${generalEmail}`} className={LINK_CLASS}>
|
||||
{generalEmail}
|
||||
</a>
|
||||
) : (
|
||||
copy.contactPending
|
||||
/* The approved draft writes the address as a placeholder,
|
||||
so nothing stands in for it. A sentence is not a channel
|
||||
value, so it keeps the quieter body tone. */
|
||||
<span className="body-md text-[var(--text-secondary)]">
|
||||
{copy.contactPending}
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</Channel>
|
||||
|
||||
<div className="flex flex-col gap-2xs">
|
||||
<dt className="label-md uppercase text-[var(--text-tertiary)]">
|
||||
{copy.websiteLabel}
|
||||
</dt>
|
||||
<dd className="body-md">
|
||||
<a
|
||||
href={copy.websiteHref}
|
||||
className="text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
|
||||
>
|
||||
<Channel label={copy.websiteLabel}>
|
||||
<a href={copy.websiteHref} className={LINK_CLASS}>
|
||||
{loaderData.websiteLabel ?? copy.websiteText}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
</Channel>
|
||||
|
||||
{socialLinks.length > 0 ? (
|
||||
<div className="flex flex-col gap-2xs">
|
||||
<dt className="label-md uppercase text-[var(--text-tertiary)]">
|
||||
{t("site.followUs")}
|
||||
</dt>
|
||||
<dd>
|
||||
<ul className="flex flex-wrap gap-md body-md">
|
||||
{socialLinks.map((link) => (
|
||||
{/* The CMS owns this row; until an editor fills it, the
|
||||
project's own confirmed Facebook page stands in. */}
|
||||
{channels.length > 0 ? (
|
||||
<Channel label={t("site.followUs")}>
|
||||
<ul className="flex flex-wrap gap-md">
|
||||
{channels.map((link) => (
|
||||
<li key={link.href}>
|
||||
<a
|
||||
href={link.href}
|
||||
{...(link.isExternal
|
||||
? { target: "_blank", rel: "noopener noreferrer" }
|
||||
: {})}
|
||||
className="text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
</Channel>
|
||||
) : null}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* CONSORTIUM CONTACTS - "TBD" in the draft, so the CMS owns them.
|
||||
The draft labels the list with an accent eyebrow rather than a
|
||||
section heading, so the heading takes the label role. */}
|
||||
<div className="sh-reveal flex flex-col gap-sm">
|
||||
<h2
|
||||
id="contact-consortium-heading"
|
||||
className="label-md uppercase text-[var(--text-accent)]"
|
||||
>
|
||||
{copy.consortiumHeading}
|
||||
</h2>
|
||||
|
||||
{consortiumItems.length > 0 ? (
|
||||
<Accordion items={consortiumItems} defaultOpenIndex={null} />
|
||||
) : (
|
||||
<EmptyState message={copy.consortiumEmpty} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* EU VISIBILITY - the funding note the draft prints at the foot of
|
||||
this column. The emblem and the statement come from the shared
|
||||
badge, so the wording and the artwork stay contractual. */}
|
||||
<div
|
||||
className={`sh-reveal flex items-center gap-md rounded-card border border-[var(--border-subtle)] bg-[var(--surface-raised)] p-md ${ACCENT_EDGE}`}
|
||||
>
|
||||
<EuBadge
|
||||
size="sm"
|
||||
statement={t("site.euStatement")}
|
||||
locale={locale}
|
||||
surface="dark"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageSection>
|
||||
|
||||
{/* CONSORTIUM CONTACTS - "TBD" in the draft, so the CMS owns them. */}
|
||||
<PageSection aria-labelledby="contact-consortium-heading">
|
||||
<div className="flex flex-col gap-lg">
|
||||
<SectionHeader
|
||||
id="contact-consortium-heading"
|
||||
title={copy.consortiumHeading}
|
||||
/>
|
||||
|
||||
{consortiumContacts.length > 0 ? (
|
||||
<ul className="grid list-none grid-cols-1 gap-lg md:grid-cols-3">
|
||||
{consortiumContacts.map((contact) => (
|
||||
<li key={contact.id}>
|
||||
<InfoPanel title={contact.organisation}>
|
||||
<address className="flex flex-col gap-2xs not-italic">
|
||||
{contact.addressLine ? <span>{contact.addressLine}</span> : null}
|
||||
{contact.city || contact.country ? (
|
||||
<span>
|
||||
{[contact.city, contact.country]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
</span>
|
||||
) : null}
|
||||
{contact.email ? (
|
||||
<a
|
||||
href={`mailto:${contact.email}`}
|
||||
className="text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
|
||||
>
|
||||
{contact.email}
|
||||
</a>
|
||||
) : null}
|
||||
{contact.phone ? (
|
||||
<a
|
||||
href={`tel:${contact.phone.replace(/\s+/g, "")}`}
|
||||
dir="ltr"
|
||||
className="text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
|
||||
>
|
||||
{contact.phone}
|
||||
</a>
|
||||
) : null}
|
||||
</address>
|
||||
</InfoPanel>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<EmptyState message={copy.consortiumEmpty} />
|
||||
)}
|
||||
</div>
|
||||
</PageSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,49 +1,84 @@
|
||||
/* ============================================================================
|
||||
EVENTS CALENDAR - /{locale}/events-calendar
|
||||
----------------------------------------------------------------------------
|
||||
Draft section 4. The listing the draft describes:
|
||||
Draft section 4, laid out to the 2026 redesign draft ("Events-Calendar"):
|
||||
|
||||
filter tabs All - Upcoming - Ongoing - Completed, plus a region filter
|
||||
card fields title - date and time - format/location - registration
|
||||
status - call to action (Register / Apply / Read recap)
|
||||
filter row All · Upcoming · Ongoing · Completed as pills, a region
|
||||
select beside them, and a List / Month view switch
|
||||
list view one wide row per event: a date chip, the title, where and
|
||||
when, the registration state, and the call to action
|
||||
month view a seven-column grid with the events of one month in it
|
||||
|
||||
Every filter is a real URL (?status=®ion=&page=), so a filtered calendar
|
||||
is shareable, survives the back button and is reachable by a crawler that
|
||||
only follows links. There is no client-side filtering to fall out of step
|
||||
with the server, and the page works with scripting off.
|
||||
Every control is a real URL (?status=®ion=&view=&month=&page=), so a
|
||||
filtered calendar is shareable, survives the back button and is reachable by
|
||||
a crawler that only follows links. There is no client-side filtering to fall
|
||||
out of step with the server, and the page works with scripting off.
|
||||
|
||||
Upcoming / Ongoing / Completed are DERIVED from the dates rather than stored
|
||||
(see ./_events-opportunities/events.ts), so nothing goes stale the morning
|
||||
after an event.
|
||||
|
||||
EMPTY BY DESIGN: the CMS is empty today, so the state that actually renders
|
||||
is the localised "no events yet" panel - and if Strapi cannot be reached at
|
||||
all, the page still renders, with a notice, instead of a 500.
|
||||
NOT ONE DATE ON THIS PAGE IS WRITTEN HERE. The draft's five sample events -
|
||||
the Tripoli SUQ dialogue on 4 March, the Benghazi network meeting, "March
|
||||
2026" - were mockup content. The grid and the list are built from Strapi
|
||||
entries; with an empty CMS the localised empty state renders instead, and if
|
||||
Strapi cannot be reached at all the page still renders, with a notice,
|
||||
rather than a 500.
|
||||
|
||||
WHAT THE DRAFT SHIPS THAT THIS PAGE DOES NOT
|
||||
* the "You're registered." confirmation panel - registration happens on
|
||||
the event's own page, which already renders that approved confirmation;
|
||||
putting it on the listing would mean faking a submission;
|
||||
* "Add to calendar · iCal / Google" on the month header - the .ics route
|
||||
is per event (events-calendar.$slug.ics.ts) and there is no whole-
|
||||
calendar feed to offer, so the action lives on each event instead;
|
||||
* the event-type kicker ("SUQ Dialogue", "Training"): `event.eventType`
|
||||
exists in Strapi but is not carried on the card view model, which lives
|
||||
in a module this change does not own.
|
||||
========================================================================= */
|
||||
|
||||
import { Link, useSearchParams } from "react-router";
|
||||
|
||||
import { Button } from "~/components/ui/Button";
|
||||
import { ArrowIcon, Button } from "~/components/ui/Button";
|
||||
import { CardLink, CardShell } from "~/components/ui/Card";
|
||||
import {
|
||||
intlLocale,
|
||||
localizedPath,
|
||||
sectionPath,
|
||||
toIsoDate,
|
||||
translate,
|
||||
useTranslation,
|
||||
type Locale,
|
||||
} from "~/i18n";
|
||||
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
|
||||
|
||||
import type { Route } from "./+types/events-calendar";
|
||||
import {
|
||||
collectRegions,
|
||||
EVENT_STATUS_FILTERS,
|
||||
eventActionKey,
|
||||
eventRegistrationLabel,
|
||||
eventTimeStatusKey,
|
||||
EVENT_STATUS_FILTERS,
|
||||
EVENT_TIME_ZONE,
|
||||
filterEvents,
|
||||
formatEventWhen,
|
||||
sortEvents,
|
||||
type EventCardData,
|
||||
} from "./_events-opportunities/events";
|
||||
import { fetchEvents } from "./_events-opportunities/events.server";
|
||||
import { getRouteCopy } from "./_events-opportunities/copy";
|
||||
import { readRootSeo } from "./_events-opportunities/root-seo";
|
||||
import { EventCard, Notice } from "./_events-opportunities/ui";
|
||||
import { Notice } from "./_events-opportunities/ui";
|
||||
import {
|
||||
buildCalendarMonth,
|
||||
calendarDayIn,
|
||||
monthKeyOf,
|
||||
parseMonthKey,
|
||||
weekdayLabel,
|
||||
weekStartFor,
|
||||
type CalendarDay,
|
||||
type CalendarMonth,
|
||||
} from "./_shared/calendar";
|
||||
import {
|
||||
buildPagination,
|
||||
PAGE_SIZE,
|
||||
@@ -54,16 +89,25 @@ import {
|
||||
} from "./_shared/listing";
|
||||
import {
|
||||
EmptyState,
|
||||
PageHeader,
|
||||
FilterPills,
|
||||
ListingHero,
|
||||
MetaLine,
|
||||
PageShell,
|
||||
Pagination,
|
||||
PillSelect,
|
||||
ResultSummary,
|
||||
TabLinks,
|
||||
} from "./_shared/listing-ui";
|
||||
import { getListingChrome } from "./_shared/page-copy";
|
||||
|
||||
import "./_page-kit/motion.css";
|
||||
|
||||
/** Longest region value we will echo back into a filter. */
|
||||
const MAX_REGION_LENGTH = 80;
|
||||
|
||||
/** The two ways the calendar can be read. */
|
||||
const VIEWS = ["list", "month"] as const;
|
||||
type CalendarView = (typeof VIEWS)[number];
|
||||
|
||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
const locale = requireLocale(params.lang);
|
||||
const url = new URL(request.url);
|
||||
@@ -75,6 +119,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
EVENT_STATUS_FILTERS,
|
||||
"all",
|
||||
);
|
||||
const view = parseEnum(url.searchParams, "view", VIEWS, "list");
|
||||
|
||||
const { events, available } = await fetchEvents(locale, now);
|
||||
|
||||
@@ -98,6 +143,33 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
);
|
||||
const start = (pagination.page - 1) * PAGE_SIZE;
|
||||
|
||||
/* Which month the grid opens on: the one the reader asked for, else the
|
||||
month of the next event in the filtered set, else the current month.
|
||||
Derived, never a literal - the draft's "March 2026" was sample content. */
|
||||
const requestedMonth = parseMonthKey(url.searchParams.get("month"));
|
||||
const firstDay = matching[0]
|
||||
? calendarDayIn(matching[0].start, EVENT_TIME_ZONE)
|
||||
: undefined;
|
||||
const today = calendarDayIn(now, EVENT_TIME_ZONE);
|
||||
const month =
|
||||
requestedMonth ??
|
||||
(firstDay ? monthKeyOf(firstDay) : monthKeyOf(today ?? "1970-01-01"));
|
||||
|
||||
const calendar = buildCalendarMonth({
|
||||
month,
|
||||
entries: matching.map((event) => ({
|
||||
id: event.slug,
|
||||
title: event.title,
|
||||
href: `${sectionPath("events", locale)}/${encodeURIComponent(event.slug)}`,
|
||||
start: event.start,
|
||||
...(event.end ? { end: event.end } : {}),
|
||||
allDay: event.allDay,
|
||||
})),
|
||||
timeZone: EVENT_TIME_ZONE,
|
||||
weekStartsOn: weekStartFor(locale),
|
||||
now,
|
||||
});
|
||||
|
||||
return {
|
||||
locale,
|
||||
events: matching.slice(start, start + PAGE_SIZE),
|
||||
@@ -105,6 +177,8 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
status,
|
||||
region,
|
||||
regions,
|
||||
view,
|
||||
calendar,
|
||||
available,
|
||||
/** Distinguishes "the CMS is empty" from "these filters match nothing". */
|
||||
hasEvents: events.length > 0,
|
||||
@@ -122,9 +196,14 @@ export function meta({ location, matches }: Route.MetaArgs) {
|
||||
siteUrl: root.siteUrl,
|
||||
title: translate(root.locale, "nav.sections.events"),
|
||||
description: copy.events.metaDescription,
|
||||
// A filtered or paginated view is the same content in another order.
|
||||
// A filtered, paginated or re-viewed calendar is the same content in
|
||||
// another order.
|
||||
noIndex:
|
||||
search.has("status") || search.has("region") || search.has("page"),
|
||||
search.has("status") ||
|
||||
search.has("region") ||
|
||||
search.has("page") ||
|
||||
search.has("view") ||
|
||||
search.has("month"),
|
||||
jsonLd: buildBreadcrumbJsonLd(root.siteUrl, [
|
||||
{
|
||||
name: translate(root.locale, "nav.sections.home"),
|
||||
@@ -139,115 +218,435 @@ export function meta({ location, matches }: Route.MetaArgs) {
|
||||
}
|
||||
|
||||
export default function EventsCalendar({ loaderData }: Route.ComponentProps) {
|
||||
const { events, pagination, status, region, regions, available, hasEvents } =
|
||||
loaderData;
|
||||
const {
|
||||
events,
|
||||
pagination,
|
||||
status,
|
||||
region,
|
||||
regions,
|
||||
view,
|
||||
calendar,
|
||||
available,
|
||||
hasEvents,
|
||||
} = loaderData;
|
||||
const { locale, t } = useTranslation();
|
||||
const copy = getRouteCopy(locale);
|
||||
const chrome = getListingChrome(locale).events;
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const pathname = sectionPath("events", locale);
|
||||
const href = (key: string, value: string | undefined) =>
|
||||
`${pathname}${withParam(searchParams, key, value)}`;
|
||||
|
||||
const tabs = EVENT_STATUS_FILTERS.map((value) => ({
|
||||
const statusPills = EVENT_STATUS_FILTERS.map((value) => ({
|
||||
id: value,
|
||||
label: value === "all" ? t("filters.all") : t(eventTimeStatusKey(value)),
|
||||
href: href("status", value === "all" ? undefined : value),
|
||||
isCurrent: status === value,
|
||||
}));
|
||||
|
||||
/* Paging and the month on show are properties of one view, so switching
|
||||
view keeps the filters and starts the new view at its own beginning. */
|
||||
const viewParams = new URLSearchParams();
|
||||
if (status !== "all") viewParams.set("status", status);
|
||||
if (region) viewParams.set("region", region);
|
||||
|
||||
const viewPills = VIEWS.map((value) => ({
|
||||
id: value,
|
||||
label: value === "list" ? chrome.viewList : chrome.viewMonth,
|
||||
href: `${pathname}${withParam(
|
||||
viewParams,
|
||||
"view",
|
||||
value === "list" ? undefined : value,
|
||||
)}`,
|
||||
isCurrent: view === value,
|
||||
}));
|
||||
|
||||
/* What the region form has to carry across a GET submit - the query string
|
||||
is replaced wholesale, so anything not in the form is lost. */
|
||||
const keep: (readonly [string, string])[] = [
|
||||
...(status === "all" ? [] : [["status", status] as const]),
|
||||
...(view === "list" ? [] : [["view", view] as const]),
|
||||
];
|
||||
|
||||
const isFiltered = status !== "all" || region !== null;
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader heading={copy.events.heading} intro={copy.events.intro} />
|
||||
<>
|
||||
<ListingHero
|
||||
eyebrow={chrome.eyebrow}
|
||||
heading={copy.events.heading}
|
||||
intro={copy.events.intro}
|
||||
ambient={[{ top: -250, from: "end", offset: "4%", width: 720, height: 560, durationSeconds: 20 }]}
|
||||
/>
|
||||
|
||||
{available ? null : (
|
||||
<Notice tone="info" role="status">
|
||||
{copy.events.unavailable}
|
||||
</Notice>
|
||||
)}
|
||||
<PageShell>
|
||||
{available ? null : (
|
||||
<Notice tone="info" role="status">
|
||||
{copy.events.unavailable}
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-lg">
|
||||
<TabLinks label={copy.events.statusFilterLabel} tabs={tabs} />
|
||||
<div className="flex flex-col gap-lg">
|
||||
<div className="flex flex-wrap items-center justify-between gap-lg border-b border-border-subtle pb-lg">
|
||||
<FilterPills
|
||||
label={copy.events.statusFilterLabel}
|
||||
pills={statusPills}
|
||||
>
|
||||
{regions.length > 0 ? (
|
||||
<PillSelect
|
||||
name="region"
|
||||
label={t("filters.region")}
|
||||
value={region ?? ""}
|
||||
options={regions.map((value) => ({ value, label: value }))}
|
||||
allLabel={t("filters.all")}
|
||||
applyLabel={t("filters.apply")}
|
||||
action={pathname}
|
||||
keep={keep}
|
||||
/>
|
||||
) : null}
|
||||
</FilterPills>
|
||||
|
||||
{regions.length > 0 ? (
|
||||
<nav aria-label={t("filters.region")} className="flex flex-col gap-xs">
|
||||
<p className="label-md uppercase text-text-tertiary">
|
||||
{t("filters.region")}
|
||||
</p>
|
||||
<ul className="flex flex-wrap items-center gap-xs">
|
||||
<li>
|
||||
<Button
|
||||
as={Link}
|
||||
to={href("region", undefined)}
|
||||
prefetch="intent"
|
||||
variant={region === null ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
aria-current={region === null ? "true" : undefined}
|
||||
>
|
||||
{t("filters.all")}
|
||||
</Button>
|
||||
</li>
|
||||
{regions.map((value) => (
|
||||
<li key={value}>
|
||||
<Button
|
||||
as={Link}
|
||||
to={href("region", value)}
|
||||
prefetch="intent"
|
||||
variant={region === value ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
aria-current={region === value ? "true" : undefined}
|
||||
>
|
||||
{value}
|
||||
</Button>
|
||||
</li>
|
||||
{/* The draft draws the List / Month switch as a pair of square
|
||||
controls with no caption, beside the rounded filter pills. */}
|
||||
<FilterPills
|
||||
label={chrome.viewLabel}
|
||||
pills={viewPills}
|
||||
shape="control"
|
||||
hideLabel
|
||||
/>
|
||||
</div>
|
||||
|
||||
{view === "list" ? <ResultSummary pagination={pagination} t={t} /> : null}
|
||||
</div>
|
||||
|
||||
{view === "month" ? (
|
||||
<MonthView
|
||||
calendar={calendar}
|
||||
locale={locale}
|
||||
emptyMessage={chrome.monthEmpty}
|
||||
label={chrome.calendarLabel}
|
||||
previousLabel={chrome.previousMonth}
|
||||
nextLabel={chrome.nextMonth}
|
||||
hrefFor={(month) => href("month", month)}
|
||||
/>
|
||||
) : events.length > 0 ? (
|
||||
<>
|
||||
{/* The list is the page's content, so it gets a heading in the
|
||||
outline even though the hero already names the page. */}
|
||||
<h2 className="sr-only">{copy.events.listLabel}</h2>
|
||||
<ul
|
||||
aria-label={copy.events.listLabel}
|
||||
className="flex flex-col gap-md"
|
||||
>
|
||||
{events.map((event) => (
|
||||
<EventRow
|
||||
key={event.slug}
|
||||
event={event}
|
||||
href={`${pathname}/${encodeURIComponent(event.slug)}`}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
<ResultSummary pagination={pagination} t={t} />
|
||||
</div>
|
||||
|
||||
{events.length > 0 ? (
|
||||
<>
|
||||
<ul
|
||||
aria-label={copy.events.listLabel}
|
||||
className="grid gap-lg sm:grid-cols-2 lg:grid-cols-3"
|
||||
<Pagination
|
||||
model={pagination}
|
||||
pathname={pathname}
|
||||
params={searchParams}
|
||||
t={t}
|
||||
/>
|
||||
</>
|
||||
) : hasEvents && isFiltered ? (
|
||||
<EmptyState
|
||||
title={t("filters.noResults")}
|
||||
body={t("filters.noResultsHint")}
|
||||
>
|
||||
{events.map((event) => (
|
||||
<EventCard
|
||||
key={event.slug}
|
||||
as="li"
|
||||
event={event}
|
||||
href={`${pathname}/${encodeURIComponent(event.slug)}`}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Pagination
|
||||
model={pagination}
|
||||
pathname={pathname}
|
||||
params={searchParams}
|
||||
t={t}
|
||||
/>
|
||||
</>
|
||||
) : hasEvents && isFiltered ? (
|
||||
<EmptyState
|
||||
title={t("filters.noResults")}
|
||||
body={t("filters.noResultsHint")}
|
||||
>
|
||||
<Button as={Link} to={pathname} variant="secondary" size="sm">
|
||||
{t("filters.clear")}
|
||||
</Button>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<EmptyState
|
||||
title={t("events.noEvents")}
|
||||
body={copy.events.emptyHint}
|
||||
/>
|
||||
)}
|
||||
</PageShell>
|
||||
<Button as={Link} to={pathname} variant="secondary" size="sm">
|
||||
{t("filters.clear")}
|
||||
</Button>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<EmptyState title={t("events.noEvents")} body={copy.events.emptyHint} />
|
||||
)}
|
||||
</PageShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
ONE EVENT, AS THE DRAFT DRAWS IT
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
/** "10:00–14:00" in Libya time, or nothing for an all-day event. */
|
||||
function formatClock(event: EventCardData, locale: Locale): string | undefined {
|
||||
if (event.allDay) return undefined;
|
||||
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: EVENT_TIME_ZONE,
|
||||
};
|
||||
const formatter = new Intl.DateTimeFormat(intlLocale(locale), options);
|
||||
|
||||
const start = new Date(event.start);
|
||||
if (Number.isNaN(start.getTime())) return undefined;
|
||||
|
||||
const end = event.end ? new Date(event.end) : null;
|
||||
if (end && !Number.isNaN(end.getTime()) && end.getTime() !== start.getTime()) {
|
||||
return formatter.formatRange(start, end);
|
||||
}
|
||||
return formatter.format(start);
|
||||
}
|
||||
|
||||
/** The date block on the inline-start edge: month over the day number. */
|
||||
function DateChip({ event, locale }: { event: EventCardData; locale: Locale }) {
|
||||
const timeZone = event.allDay ? "UTC" : EVENT_TIME_ZONE;
|
||||
const date = new Date(event.start);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
|
||||
const part = (options: Intl.DateTimeFormatOptions) =>
|
||||
new Intl.DateTimeFormat(intlLocale(locale), { ...options, timeZone }).format(
|
||||
date,
|
||||
);
|
||||
|
||||
return (
|
||||
/* The draft draws a 76x82 block holding the month over the day and
|
||||
nothing else - the full date, year included, is on the line under the
|
||||
title, so repeating it here only crowds the block. */
|
||||
<div className="flex h-[5.125rem] w-[4.75rem] shrink-0 flex-col items-center justify-center gap-2xs rounded-card border border-border-subtle bg-surface-sunken">
|
||||
<span className="label-md uppercase text-text-accent">
|
||||
{part({ month: "short" })}
|
||||
</span>
|
||||
<span className="heading-h2 text-text-primary">
|
||||
{part({ day: "numeric" })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventRow({ event, href }: { event: EventCardData; href: string }) {
|
||||
const { locale, t } = useTranslation();
|
||||
const copy = getRouteCopy(locale);
|
||||
|
||||
const isPast = event.timeStatus === "completed";
|
||||
const isLive = !isPast && event.registrationStatus === "open";
|
||||
|
||||
const formatLabel =
|
||||
event.format === "online"
|
||||
? t("events.online")
|
||||
: event.format === "hybrid"
|
||||
? copy.events.formatHybrid
|
||||
: event.format === "in-person"
|
||||
? t("events.inPerson")
|
||||
: undefined;
|
||||
|
||||
const clock = formatClock(event, locale);
|
||||
|
||||
/* The draft's kicker above the title. Empty for an event the CMS left
|
||||
unclassified ("other"), in which case only the time status shows. */
|
||||
const typeLabel = event.eventType
|
||||
? copy.events.eventTypes[event.eventType]
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<CardShell
|
||||
as="li"
|
||||
linked
|
||||
className="sh-reveal sh-reveal--in flex-col gap-lg p-lg md:flex-row md:items-center"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-lg">
|
||||
<DateChip event={event} locale={locale} />
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-xs">
|
||||
<p className="label-md uppercase text-text-tertiary">
|
||||
{typeLabel ? (
|
||||
<>
|
||||
<span className="text-text-accent">{typeLabel}</span>
|
||||
<span aria-hidden="true"> · </span>
|
||||
</>
|
||||
) : null}
|
||||
{t(eventTimeStatusKey(event.timeStatus))}
|
||||
</p>
|
||||
|
||||
<h3 className="sh-card-title body-lg-bold text-text-primary">
|
||||
<CardLink href={href} className="sh-card-link">
|
||||
{event.title}
|
||||
</CardLink>
|
||||
</h3>
|
||||
|
||||
<p className="body-sm-bold text-text-secondary">
|
||||
<time dateTime={toIsoDate(event.start)}>
|
||||
{formatEventWhen(event, locale)}
|
||||
</time>
|
||||
</p>
|
||||
|
||||
<MetaLine
|
||||
items={[formatLabel, event.place, event.region, clock].filter(
|
||||
(item): item is string => Boolean(item),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-md md:justify-end">
|
||||
{/* A finished event's registration state is history, not news. */}
|
||||
{isPast ? null : (
|
||||
<span className="inline-flex items-center gap-xs rounded-pill border border-border-default px-md py-2xs label-md uppercase text-text-primary">
|
||||
{isLive ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="sh-pulse size-[7px] shrink-0 rounded-pill bg-[var(--surface-accent)]"
|
||||
/>
|
||||
) : null}
|
||||
{eventRegistrationLabel(event.registrationStatus, locale)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* The draft ends every row with the filled accent action - Register,
|
||||
Apply, Join waitlist, Read recap - not a text hint. `sh-card-above`
|
||||
lifts it over the stretched title link so it stays its own target,
|
||||
and the label comes from the event's own registration state. */}
|
||||
<Button
|
||||
as={Link}
|
||||
to={href}
|
||||
prefetch="intent"
|
||||
variant="primary"
|
||||
size="md"
|
||||
iconEnd={<ArrowIcon />}
|
||||
className="sh-card-above shrink-0"
|
||||
>
|
||||
{t(eventActionKey(event))}
|
||||
</Button>
|
||||
</div>
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
THE MONTH GRID
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
function MonthView({
|
||||
calendar,
|
||||
locale,
|
||||
label,
|
||||
emptyMessage,
|
||||
previousLabel,
|
||||
nextLabel,
|
||||
hrefFor,
|
||||
}: {
|
||||
calendar: CalendarMonth;
|
||||
locale: Locale;
|
||||
label: string;
|
||||
emptyMessage: string;
|
||||
previousLabel: string;
|
||||
nextLabel: string;
|
||||
hrefFor: (month: string) => string;
|
||||
}) {
|
||||
const monthName = new Intl.DateTimeFormat(intlLocale(locale), {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
}).format(new Date(`${calendar.firstDay}T00:00:00Z`));
|
||||
|
||||
return (
|
||||
<section aria-label={label} className="flex flex-col gap-lg">
|
||||
<div className="flex flex-wrap items-center justify-between gap-md">
|
||||
<h2 className="heading-h3 text-text-primary">{monthName}</h2>
|
||||
|
||||
<div className="flex items-center gap-xs">
|
||||
<Button
|
||||
as={Link}
|
||||
to={hrefFor(calendar.previous)}
|
||||
prefetch="intent"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
iconStart={<ArrowIcon direction="backward" />}
|
||||
>
|
||||
{previousLabel}
|
||||
</Button>
|
||||
<Button
|
||||
as={Link}
|
||||
to={hrefFor(calendar.next)}
|
||||
prefetch="intent"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
iconEnd={<ArrowIcon />}
|
||||
>
|
||||
{nextLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* The hairlines are the grid's own gap showing through, which is how the
|
||||
draft draws it - one border round the whole block, one pixel between
|
||||
the cells. */}
|
||||
<div className="grid grid-cols-7 gap-px overflow-hidden rounded-card border border-border-subtle bg-[var(--border-subtle)]">
|
||||
{calendar.weekdays.map((weekday) => (
|
||||
<div
|
||||
key={weekday}
|
||||
className="bg-surface-raised p-sm text-center label-md uppercase text-text-tertiary"
|
||||
>
|
||||
<span aria-hidden="true">
|
||||
{weekdayLabel(weekday, intlLocale(locale), "narrow")}
|
||||
</span>
|
||||
<span className="sr-only">
|
||||
{weekdayLabel(weekday, intlLocale(locale), "long")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{calendar.days.map((day) => (
|
||||
<MonthCell key={day.key} day={day} locale={locale} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{calendar.entryCount === 0 ? (
|
||||
<p className="body-md text-text-secondary" role="status">
|
||||
{emptyMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function MonthCell({ day, locale }: { day: CalendarDay; locale: Locale }) {
|
||||
const dayNumber = new Intl.NumberFormat(intlLocale(locale), {
|
||||
useGrouping: false,
|
||||
}).format(day.dayNumber);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"sh-lift flex min-h-[6rem] flex-col gap-2xs p-xs",
|
||||
day.inMonth ? "bg-[var(--surface-page)]" : "bg-surface-sunken",
|
||||
"hover:bg-surface-muted",
|
||||
].join(" ")}
|
||||
>
|
||||
<p
|
||||
className={[
|
||||
"label-md",
|
||||
day.isToday ? "text-text-accent" : "text-text-tertiary",
|
||||
day.inMonth ? "" : "opacity-60",
|
||||
].join(" ")}
|
||||
>
|
||||
<time dateTime={day.key}>{dayNumber}</time>
|
||||
</p>
|
||||
|
||||
{day.entries.map((entry) => (
|
||||
<Link
|
||||
key={`${day.key}-${entry.id}`}
|
||||
to={entry.href}
|
||||
prefetch="intent"
|
||||
className={[
|
||||
"rounded-[var(--radius-control)] border-s-2 border-s-[var(--border-accent)]",
|
||||
"bg-surface-accent-subtle px-2xs py-2xs body-sm-bold text-text-primary",
|
||||
"transition-colors duration-[var(--motion-duration-fast)]",
|
||||
"ease-[var(--motion-ease-standard)] hover:text-text-accent",
|
||||
].join(" ")}
|
||||
>
|
||||
{entry.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+530
-231
@@ -1,38 +1,56 @@
|
||||
/* ============================================================================
|
||||
HOME - /en and /ar
|
||||
----------------------------------------------------------------------------
|
||||
Figma frame 253:4799 ("Home-en"). The frame is four bands:
|
||||
The redesigned home page (Claude Design "Home", second drop), carried onto
|
||||
the project's own design system. Six bands, alternating the two dark
|
||||
surfaces the way the draft alternates #0B0B0A and #1D1D1B:
|
||||
|
||||
Hero header (253:4800) the SHARP-ER lockup, the headline, the CTA
|
||||
pair and the wide washed picture. The frame's
|
||||
subheading node is hidden, so the hero carries
|
||||
no lead unless the CMS supplies one.
|
||||
About the Programme 253:4835, hidden="true" in the frame. It is
|
||||
the only place figures appear, so the band
|
||||
renders ONLY once the CMS has real numbers -
|
||||
there is no placeholder standing in for it.
|
||||
Offical launch (361:10853) "The Journey of SHARP-ER Launch": one centred
|
||||
lead with four bold runs, a three-up photo
|
||||
strip that bleeds past the page and is
|
||||
clipped, and three plain centred captions.
|
||||
Partners (393:14754) "Meet Our Partners", start-aligned, over a
|
||||
grid of logo-and-name tiles.
|
||||
Footer owned by the app shell, not by this module
|
||||
Hero EU badge, an optional status pill, the headline, the lead,
|
||||
the CTA pair, the wide picture, the brand stripe and the
|
||||
figures row. Two slow washes drift behind it.
|
||||
Launch "An important start for the project": the launch lead and
|
||||
three pictures from the CMS gallery.
|
||||
Work "Three areas of change" - the three approved pillars, with
|
||||
a link through to What We Do.
|
||||
Newsroom three cards from the newsroom, newest first.
|
||||
FAQs a sticky heading column beside the accordion.
|
||||
Consortium the three members, then the civil society network logos.
|
||||
|
||||
There is no Arabic frame in the file - only the navigation and the footer
|
||||
are drawn in Arabic - so /ar is this same DOM under `dir="rtl"`, which is
|
||||
why every inline edge below is logical (ps-/pe-/start-/end-) and never
|
||||
left/right.
|
||||
WHAT THE DRAFT SHIPS THAT THIS PAGE DOES NOT
|
||||
--------------------------------------------
|
||||
* The draft's FAQ column promises a reply "within five working days". That
|
||||
commitment appears in no approved document, so it is not published
|
||||
(CLAUDE.md rule 5). Everything else in the draft is verifiable: the four
|
||||
figures, the ministries at the launch and the three pillars are all in
|
||||
docs/ or in the bundled draft copy.
|
||||
* Every list - news, questions, partner logos, launch pictures - is CMS
|
||||
data, never the draft's sample entries. Each band shows its localised
|
||||
empty state instead of inventing content.
|
||||
* The draft's own navigation and footer are ignored: the site ships
|
||||
<Navigation /> and <Footer /> from the app shell.
|
||||
|
||||
Copy is the approved draft in both languages; the pictures, the figures and
|
||||
the partner list come from Strapi and degrade to a localised empty state
|
||||
while it is empty.
|
||||
MOTION: every animation in the draft is here, as classes from
|
||||
_page-kit/motion.css, plus the figures counting up on arrival. The one
|
||||
effect deliberately dropped is the coloured glow behind a hovered button or
|
||||
card.
|
||||
|
||||
There is no Arabic frame, so /ar is this same DOM under `dir="rtl"`, which
|
||||
is why every inline edge below is logical (ps-/pe-/start-/end-).
|
||||
========================================================================= */
|
||||
|
||||
import { useEffect, useLayoutEffect, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { ArrowIcon, Button } from "~/components/ui/Button";
|
||||
import { createTranslator, getMessages, sectionPath } from "~/i18n";
|
||||
import { Card } from "~/components/ui/Card";
|
||||
import { EuBadge } from "~/components/ui/EuBadge";
|
||||
import {
|
||||
createTranslator,
|
||||
formatDate,
|
||||
getMessages,
|
||||
sectionPath,
|
||||
type Locale,
|
||||
} from "~/i18n";
|
||||
import {
|
||||
buildMeta,
|
||||
buildOrganizationJsonLd,
|
||||
@@ -41,9 +59,12 @@ import {
|
||||
import { safeHref } from "~/lib/safe-href";
|
||||
|
||||
import type { Route } from "./+types/home";
|
||||
import { Accordion } from "./_page-kit/accordion";
|
||||
import {
|
||||
isImageGalleryBlock,
|
||||
type CmsFaq,
|
||||
type CmsHomePage,
|
||||
type CmsNewsArticle,
|
||||
type CmsPartner,
|
||||
} from "./_page-kit/cms-types";
|
||||
import {
|
||||
@@ -58,30 +79,50 @@ import { pageCopy } from "./_page-kit/copy";
|
||||
import { routeLocale } from "./_page-kit/locale";
|
||||
import {
|
||||
Container,
|
||||
cx,
|
||||
EmptyState,
|
||||
PageHero,
|
||||
PageSection,
|
||||
SectionHeader,
|
||||
StatStrip,
|
||||
} from "./_page-kit/sections";
|
||||
|
||||
/** Partner logos and CSO tiles - a wide but finite grid. */
|
||||
const PARTNER_PAGE_SIZE = 36;
|
||||
import "./_page-kit/motion.css";
|
||||
|
||||
/**
|
||||
* The mark the hero draws above the headline (figma 253:4800). The frame paints
|
||||
* "SHARP" only - 272.58 x 57.65 of ink - not the full SHARP-ER lockup the
|
||||
* navigation carries, so this is the SHARP wordmark on its own, as vector so it
|
||||
* stays crisp at any size. `alt` is empty because the navigation's brand link
|
||||
* already carries the project's accessible name.
|
||||
*/
|
||||
const HERO_WORDMARK = {
|
||||
src: "/assets/sharp-wordmark-inverse.svg",
|
||||
alt: "",
|
||||
width: 272,
|
||||
height: 57,
|
||||
drawnWidth: 273,
|
||||
} as const;
|
||||
/** The network logo wall - a wide but finite grid. */
|
||||
const PARTNER_PAGE_SIZE = 36;
|
||||
/** The draft's newsroom band is a straight three-up row. */
|
||||
const NEWS_PAGE_SIZE = 3;
|
||||
/** More than a dozen questions is a page of its own, not a home page band. */
|
||||
const FAQ_PAGE_SIZE = 12;
|
||||
/** The draft's launch band shows three pictures. */
|
||||
const LAUNCH_SHOTS = 3;
|
||||
|
||||
/** A newsroom card, already localised and safe to serialise to the client. */
|
||||
interface NewsCard {
|
||||
id: string;
|
||||
title: string;
|
||||
href: string;
|
||||
excerpt: string | null;
|
||||
dateLabel: string | null;
|
||||
organisation: string | null;
|
||||
image: { src: string; alt: string } | null;
|
||||
}
|
||||
|
||||
function toNewsCard(article: CmsNewsArticle, locale: Locale): NewsCard {
|
||||
const image = toImage(article.coverImage, "small");
|
||||
|
||||
return {
|
||||
id: article.documentId,
|
||||
title: article.title,
|
||||
href: `${sectionPath("news", locale)}/${article.slug}`,
|
||||
excerpt: article.excerpt ?? null,
|
||||
/* The CMS holds one date for both languages; the reader's locale decides
|
||||
how it is written out. */
|
||||
dateLabel: article.publishDate ? formatDate(article.publishDate, locale) : null,
|
||||
organisation: article.authorOrganisation?.name ?? null,
|
||||
image: image ? { src: image.src, alt: image.alt } : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const locale = routeLocale(params);
|
||||
@@ -93,18 +134,19 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||
stats: true,
|
||||
// Strapi 5 populates a dynamic zone per component with `on`.
|
||||
blocks: { on: { "blocks.image-gallery": { populate: { images: true } } } },
|
||||
featuredNews: { populate: { coverImage: true, authorOrganisation: true } },
|
||||
partners: { populate: { logo: true, logoDark: true } },
|
||||
seo: { populate: { ogImage: true } },
|
||||
},
|
||||
});
|
||||
|
||||
/* The curated relation wins; otherwise show every partner the CMS knows, so
|
||||
the grid fills in as soon as partners exist - before anyone remembers to
|
||||
the wall fills in as soon as partners exist - before anyone remembers to
|
||||
curate the home page itself. */
|
||||
const curated = list(page?.partners);
|
||||
const curatedPartners = list(page?.partners);
|
||||
const partners =
|
||||
curated.length > 0
|
||||
? curated
|
||||
curatedPartners.length > 0
|
||||
? curatedPartners
|
||||
: await loadMany<CmsPartner>("partners", {
|
||||
locale,
|
||||
sort: ["name:asc"],
|
||||
@@ -113,6 +155,25 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||
pagination: { pageSize: PARTNER_PAGE_SIZE },
|
||||
});
|
||||
|
||||
/* Same rule for the newsroom: an editor's picks, else simply the newest
|
||||
articles, so the band is never empty while the newsroom has entries. */
|
||||
const curatedNews = list(page?.featuredNews);
|
||||
const news =
|
||||
curatedNews.length > 0
|
||||
? curatedNews
|
||||
: await loadMany<CmsNewsArticle>("news-articles", {
|
||||
locale,
|
||||
sort: ["publishDate:desc", "title:asc"],
|
||||
populate: { coverImage: true, authorOrganisation: true },
|
||||
pagination: { pageSize: NEWS_PAGE_SIZE },
|
||||
});
|
||||
|
||||
const faqs = await loadMany<CmsFaq>("faqs", {
|
||||
locale,
|
||||
sort: ["order:asc", "createdAt:asc"],
|
||||
pagination: { pageSize: FAQ_PAGE_SIZE },
|
||||
});
|
||||
|
||||
const gallery = list(page?.blocks)
|
||||
.filter(isImageGalleryBlock)
|
||||
.flatMap((block) => list(block.images))
|
||||
@@ -132,13 +193,20 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||
variant: cta.variant ?? "primary",
|
||||
})),
|
||||
},
|
||||
/* Editor-owned figures win; the bundled four stand in until then. */
|
||||
stats: list(page?.stats).map((stat) => ({
|
||||
value: stat.value,
|
||||
label: stat.label,
|
||||
})),
|
||||
gallery,
|
||||
gallery: gallery.slice(0, LAUNCH_SHOTS),
|
||||
partnersHeadline: page?.partnersHeadline ?? null,
|
||||
partners: partners.map(toPartner),
|
||||
news: news.slice(0, NEWS_PAGE_SIZE).map((article) => toNewsCard(article, locale)),
|
||||
faqs: faqs.map((faq) => ({
|
||||
id: faq.documentId,
|
||||
question: faq.question,
|
||||
answer: faq.answer,
|
||||
})),
|
||||
seo: {
|
||||
title: page?.seo?.metaTitle ?? null,
|
||||
description: page?.seo?.metaDescription ?? null,
|
||||
@@ -171,6 +239,10 @@ export function meta({ loaderData, matches }: Route.MetaArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
SMALL PIECES
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
interface HeroCtaProps {
|
||||
href: string;
|
||||
label: string;
|
||||
@@ -178,12 +250,13 @@ interface HeroCtaProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* A hero call to action. Figma paints the secondary one as a white fill with a
|
||||
* black stroke and a dark label, which is the secondary variant on the LIGHT
|
||||
* surface - so the surface is flipped on the control itself. It has to be the
|
||||
* control and not a wrapper: `[data-surface]` paints a background in the base
|
||||
* layer, and a wrapping element would draw a square white box behind the
|
||||
* button's rounded corners.
|
||||
* A hero call to action.
|
||||
*
|
||||
* Both controls are the project's Button, which now carries the Claude Design
|
||||
* drafts' own treatment: the accent fill for the primary, the outline ring for
|
||||
* the secondary, and a 3px lift under the pointer. The secondary is no longer
|
||||
* flipped onto the light surface - the drafts draw it as an outline on the
|
||||
* dark band, not as a white fill.
|
||||
*/
|
||||
function HeroCta({ href, label, variant }: HeroCtaProps) {
|
||||
return (
|
||||
@@ -193,7 +266,6 @@ function HeroCta({ href, label, variant }: HeroCtaProps) {
|
||||
prefetch="intent"
|
||||
size="lg"
|
||||
variant={variant}
|
||||
{...(variant === "secondary" ? { "data-surface": "light" } : {})}
|
||||
iconEnd={variant === "primary" ? <ArrowIcon /> : undefined}
|
||||
>
|
||||
{label}
|
||||
@@ -202,70 +274,160 @@ function HeroCta({ href, label, variant }: HeroCtaProps) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The three-up strip under the launch lead (figma 361:11115). The row is drawn
|
||||
* 1849px wide against a 1440px page, so it deliberately overflows the page and
|
||||
* is clipped by the band - the widths below are the designed proportions of the
|
||||
* 1440 artboard (500 / 729 / 500) carried over as viewport fractions, which
|
||||
* reproduces the overhang at any width. The centre picture is the emphasised
|
||||
* one; the flanks sit at half strength and are dropped on small screens, where
|
||||
* three clipped pictures would be unreadable.
|
||||
* The hero's status line. The draft hard-codes "Now live · Expression of
|
||||
* Interest open"; here the words are the CMS hero eyebrow, so the pill only
|
||||
* ever announces a state an editor has actually written - and the hero simply
|
||||
* has no pill until then. The dot pulses; the text does not move.
|
||||
*/
|
||||
function LaunchStrip({ images }: { images: readonly ResolvedImage[] }) {
|
||||
const first = images[0];
|
||||
if (!first) return null;
|
||||
function StatusPill({ label }: { label: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-xs rounded-pill border border-[var(--border-subtle)] bg-[var(--surface-raised)] py-2xs ps-sm pe-md">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="sh-pulse size-[7px] shrink-0 rounded-pill bg-[var(--surface-accent)]"
|
||||
/>
|
||||
<span className="label-md uppercase text-[var(--text-secondary)]">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* The flanks only exist as a trio. With one or two pictures the emphasised
|
||||
one stays centred and the row simply has no wings, rather than throwing
|
||||
the centre off-axis or hiding an uploaded picture below `lg`. */
|
||||
const complete = images.length >= 3;
|
||||
const centre = complete ? images[1]! : first;
|
||||
const flanks = complete
|
||||
? { start: images[0]!, end: images[2]! }
|
||||
: { start: undefined, end: undefined };
|
||||
/* `useLayoutEffect` would warn during SSR, and the count only exists in a
|
||||
browser, so the effect is picked per environment. */
|
||||
const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
||||
|
||||
/**
|
||||
* A figure that counts up to its value on arrival, as the draft does.
|
||||
*
|
||||
* The server renders the FINAL value, so a reader without JavaScript - and a
|
||||
* search engine - sees the real figure. In a browser the count is reset before
|
||||
* the first paint (layout effect, not a plain effect), so the number never
|
||||
* flashes its end state first. A value the CMS wrote as words rather than
|
||||
* digits is simply printed.
|
||||
*/
|
||||
function CountUpValue({ value }: { value: string }) {
|
||||
const parsed = /^(\d[\d,]*)([\s\S]*)$/.exec(value);
|
||||
const target = parsed ? Number(parsed[1]!.replace(/,/g, "")) : null;
|
||||
const suffix = parsed ? parsed[2]! : "";
|
||||
const [shown, setShown] = useState<number | null>(target);
|
||||
|
||||
useBrowserLayoutEffect(() => {
|
||||
if (target === null) return;
|
||||
/* app.css collapses animation, but this one is script-driven, so the
|
||||
preference has to be honoured here as well. */
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
|
||||
let frame = 0;
|
||||
const started = performance.now();
|
||||
const duration = 1500;
|
||||
|
||||
setShown(0);
|
||||
|
||||
const tick = (now: number) => {
|
||||
const progress = Math.min(1, (now - started) / duration);
|
||||
/* Ease out cubic - the draft's own curve. */
|
||||
setShown(Math.round(target * (1 - Math.pow(1 - progress, 3))));
|
||||
if (progress < 1) frame = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
frame = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [target]);
|
||||
|
||||
if (target === null) return <>{value}</>;
|
||||
|
||||
return (
|
||||
<ul className="flex list-none items-center justify-center gap-3xl">
|
||||
{flanks.start ? (
|
||||
<li className="hidden shrink-0 lg:block">
|
||||
<img
|
||||
src={flanks.start.src}
|
||||
alt={flanks.start.alt}
|
||||
loading="lazy"
|
||||
className="aspect-[500/268] w-[34.7vw] object-cover opacity-50"
|
||||
/>
|
||||
</li>
|
||||
) : null}
|
||||
<>
|
||||
{shown}
|
||||
{suffix}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
<li className="shrink-0">
|
||||
<img
|
||||
src={centre.src}
|
||||
alt={centre.alt}
|
||||
loading="lazy"
|
||||
className="aspect-[729/374] w-[86vw] object-cover lg:w-[50.6vw]"
|
||||
/>
|
||||
</li>
|
||||
/**
|
||||
* The figures row that closes the hero: four cells divided by hairlines. The
|
||||
* grid's own background paints the 1px gaps, which is how the draft draws the
|
||||
* dividers without a border on each cell.
|
||||
*/
|
||||
function StatsRow({ stats }: { stats: readonly { value: string; label: string }[] }) {
|
||||
if (stats.length === 0) return null;
|
||||
|
||||
{flanks.end ? (
|
||||
<li className="hidden shrink-0 lg:block">
|
||||
<img
|
||||
src={flanks.end.src}
|
||||
alt={flanks.end.alt}
|
||||
loading="lazy"
|
||||
className="aspect-[500/267] w-[34.7vw] object-cover opacity-50"
|
||||
/>
|
||||
return (
|
||||
<dl className="grid grid-cols-1 gap-px border-y border-[var(--border-subtle)] bg-[var(--border-subtle)] sm:grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((stat) => (
|
||||
/* Source order is term-then-description, as the spec requires; the
|
||||
reversed column paints the figure above its label without repeating
|
||||
the label for a screen reader. */
|
||||
<div
|
||||
key={`${stat.value}-${stat.label}`}
|
||||
className="flex flex-col-reverse gap-2xs bg-[var(--surface-sunken)] px-lg py-xl text-start transition-colors duration-[var(--motion-duration-base)] hover:bg-[var(--surface-raised)]"
|
||||
>
|
||||
<dt className="label-md uppercase text-[var(--text-tertiary)]">{stat.label}</dt>
|
||||
<dd className="heading-h1 tabular-nums text-[var(--text-primary)]">
|
||||
<CountUpValue value={stat.value} />
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The launch band's three pictures. The CMS alt text doubles as the visible
|
||||
* caption - so the picture is described once, in the caption, and the <img>
|
||||
* itself is marked decorative rather than having a screen reader read the same
|
||||
* sentence twice.
|
||||
*/
|
||||
function LaunchShots({ images }: { images: readonly ResolvedImage[] }) {
|
||||
if (images.length === 0) return null;
|
||||
|
||||
return (
|
||||
<ul className="grid list-none grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3">
|
||||
{images.map((image) => (
|
||||
<li key={image.src} className="sh-reveal">
|
||||
<figure className="flex flex-col gap-xs">
|
||||
<img
|
||||
src={image.src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="sh-lift h-[15rem] w-full rounded-control border border-[var(--border-subtle)] object-cover hover:border-[var(--border-accent)]"
|
||||
/>
|
||||
{image.alt ? (
|
||||
<figcaption className="body-sm-bold text-[var(--text-secondary)]">
|
||||
{image.alt}
|
||||
</figcaption>
|
||||
) : null}
|
||||
</figure>
|
||||
</li>
|
||||
) : null}
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** The "see the whole section" link the draft puts opposite a band heading. */
|
||||
function BandLink({ to, label }: { to: string; label: string }) {
|
||||
return (
|
||||
<Button as={Link} to={to} prefetch="intent" variant="ghost" iconEnd={<ArrowIcon />}>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
THE PAGE
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
export default function Home({ loaderData }: Route.ComponentProps) {
|
||||
const { locale, hero, stats, gallery, partners } = loaderData;
|
||||
const { locale, hero, stats, gallery, partners, news, faqs } = loaderData;
|
||||
const t = createTranslator(locale);
|
||||
const copy = pageCopy(locale).home;
|
||||
/* The three pillars are the About page's approved copy - one source, so the
|
||||
two pages can never drift apart. */
|
||||
const pillars = pageCopy(locale).about.pillars;
|
||||
|
||||
const headline = hero.headline ?? copy.heroHeadline;
|
||||
const partnersHeadline = loaderData.partnersHeadline ?? copy.partnersHeading;
|
||||
const lead = hero.subheading ?? copy.heroLead;
|
||||
const figures = stats.length > 0 ? stats : copy.stats;
|
||||
const consortiumHeading = loaderData.partnersHeadline ?? copy.consortiumHeading;
|
||||
|
||||
/* The frame draws one accent CTA and one white one. Editor-supplied CTAs go
|
||||
through the same component as the fallback pair, so a `secondary` CTA
|
||||
@@ -279,165 +441,302 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
||||
}))
|
||||
: [
|
||||
{
|
||||
href: sectionPath("about", locale),
|
||||
label: t("actions.discoverSharper"),
|
||||
href: sectionPath("applications", locale),
|
||||
label: copy.heroCtaPrimary,
|
||||
variant: "primary",
|
||||
},
|
||||
{
|
||||
href: sectionPath("whatWeDo", locale),
|
||||
label: t("actions.exploreOurWork"),
|
||||
href: sectionPath("about", locale),
|
||||
label: copy.heroCtaSecondary,
|
||||
variant: "secondary",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* `lead` is passed only when the CMS supplies a subheading: the frame's
|
||||
own subheading node is hidden, so nothing stands under the headline
|
||||
unless an editor deliberately writes one. */}
|
||||
<PageHero
|
||||
title={headline}
|
||||
{...(hero.eyebrow ? { eyebrow: hero.eyebrow } : {})}
|
||||
{...(hero.subheading ? { lead: hero.subheading } : {})}
|
||||
wordmark={HERO_WORDMARK}
|
||||
{...(hero.media ? { media: hero.media } : {})}
|
||||
mediaTreatment="banner"
|
||||
actions={
|
||||
heroCtas.map((cta) => (
|
||||
<HeroCta key={`${cta.href}-${cta.label}`} {...cta} />
|
||||
))
|
||||
}
|
||||
{/* The reading-progress rule. Decorative, pointer-transparent, and
|
||||
painted only where scroll timelines exist (motion.css). */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="sh-progress pointer-events-none fixed inset-x-0 top-0 z-[var(--layer-dropdown)] h-[3px]"
|
||||
/>
|
||||
|
||||
{/* FIGURES - 253:4835 is hidden in the frame and every figure in the
|
||||
draft is marked TBD, so the band exists only once the CMS has real
|
||||
numbers. Nothing is rendered in its place. */}
|
||||
{stats.length > 0 ? (
|
||||
<PageSection aria-labelledby="home-stats-heading">
|
||||
<div className="flex flex-col gap-3xl">
|
||||
<SectionHeader
|
||||
id="home-stats-heading"
|
||||
title={copy.statsHeading}
|
||||
size="display"
|
||||
/>
|
||||
<StatStrip stats={stats} />
|
||||
</div>
|
||||
</PageSection>
|
||||
) : null}
|
||||
<PageHero
|
||||
className="bg-[var(--surface-sunken)] [--layout-hero-top:clamp(2.5rem,6vw,4.5rem)] [--layout-hero-bottom:var(--spacing-2xl)]"
|
||||
title={headline}
|
||||
lead={lead}
|
||||
leadWithActions
|
||||
{...(hero.eyebrow ? { statusPill: <StatusPill label={hero.eyebrow} /> } : {})}
|
||||
ambient={[
|
||||
{ top: -220, from: "start", offset: "8%", width: 780, height: 620, durationSeconds: 18 },
|
||||
{
|
||||
top: 120,
|
||||
from: "end",
|
||||
offset: "-160px",
|
||||
width: 620,
|
||||
height: 520,
|
||||
tone: "danger",
|
||||
durationSeconds: 24,
|
||||
reverse: true,
|
||||
},
|
||||
]}
|
||||
reveal
|
||||
/* EU visibility sits at the top of the hero (the draft's badge). The
|
||||
emblem is the dark-surface export because the hero sits on the
|
||||
page's own dark surface. `emblemAlt` is left at its empty default:
|
||||
the statement is real text beside the emblem, so naming the image
|
||||
would read the attribution out twice. */
|
||||
funderBadge={
|
||||
<EuBadge
|
||||
size="sm"
|
||||
statement={t("site.euStatement")}
|
||||
locale={locale}
|
||||
surface="dark"
|
||||
className="self-start"
|
||||
/>
|
||||
}
|
||||
{...(hero.media ? { media: hero.media } : {})}
|
||||
mediaTreatment="banner"
|
||||
actions={heroCtas.map((cta) => (
|
||||
<HeroCta key={`${cta.href}-${cta.label}`} {...cta} />
|
||||
))}
|
||||
below={<StatsRow stats={figures} />}
|
||||
/>
|
||||
|
||||
{/* THE LAUNCH STORY - the band is `bleed` so the photo strip can run past
|
||||
the content column, and it clips what runs past the page. */}
|
||||
{/* THE LAUNCH */}
|
||||
<PageSection
|
||||
aria-labelledby="home-launch-heading"
|
||||
bleed
|
||||
className="overflow-hidden"
|
||||
band
|
||||
>
|
||||
<div className="flex flex-col gap-3xl">
|
||||
<Container>
|
||||
<div className="flex flex-col items-center gap-lg text-center">
|
||||
<SectionHeader
|
||||
id="home-launch-heading"
|
||||
title={copy.launchHeading}
|
||||
size="display"
|
||||
align="center"
|
||||
/>
|
||||
<div className="sh-reveal mx-auto flex max-w-[55rem] flex-col items-center gap-lg text-center">
|
||||
<SectionHeader
|
||||
id="home-launch-heading"
|
||||
eyebrow={copy.launchEyebrow}
|
||||
title={copy.launchHeading}
|
||||
size="display"
|
||||
align="center"
|
||||
/>
|
||||
|
||||
{/* One paragraph with four bold runs, as the frame sets it. The
|
||||
designed 20px has no published type role, so it takes the
|
||||
nearest one, Body/Large - whose 28px leading is the frame's. */}
|
||||
<p className="body-lg text-[var(--text-primary)]">
|
||||
{copy.launchLead.map((run, index) =>
|
||||
typeof run === "string" ? (
|
||||
<span key={index}>{run}</span>
|
||||
) : (
|
||||
<strong key={index} className="body-lg-bold">
|
||||
{run.em}
|
||||
</strong>
|
||||
),
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</Container>
|
||||
<p className="body-lg text-[var(--text-secondary)]">{copy.launchLead}</p>
|
||||
</div>
|
||||
|
||||
{gallery.length > 0 ? <LaunchStrip images={gallery} /> : null}
|
||||
|
||||
<Container>
|
||||
{/* Figma prints these three captions with no heading of their own;
|
||||
the heading exists for the outline and for screen readers. */}
|
||||
<h3 id="home-mechanisms-heading" className="sr-only">
|
||||
{copy.mechanismsHeading}
|
||||
</h3>
|
||||
|
||||
<ul
|
||||
aria-labelledby="home-mechanisms-heading"
|
||||
className="grid list-none grid-cols-1 gap-lg text-center sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
{copy.mechanisms.map((mechanism) => (
|
||||
<li
|
||||
key={mechanism}
|
||||
className="mx-auto max-w-[21.875rem] heading-h2 text-[var(--text-primary)]"
|
||||
>
|
||||
{mechanism}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Container>
|
||||
<LaunchShots images={gallery} />
|
||||
</div>
|
||||
</PageSection>
|
||||
|
||||
{/* PARTNERS */}
|
||||
<PageSection aria-labelledby="home-partners-heading">
|
||||
<div className="flex flex-col gap-3xl">
|
||||
<SectionHeader
|
||||
id="home-partners-heading"
|
||||
title={partnersHeadline}
|
||||
size="display"
|
||||
/>
|
||||
{/* THREE AREAS OF CHANGE */}
|
||||
<PageSection
|
||||
aria-labelledby="home-work-heading"
|
||||
className="bg-[var(--surface-sunken)]"
|
||||
>
|
||||
<div className="flex flex-col gap-2xl">
|
||||
<div className="sh-reveal flex flex-wrap items-end justify-between gap-lg border-b border-[var(--border-default)] pb-lg">
|
||||
<SectionHeader
|
||||
id="home-work-heading"
|
||||
title={copy.workHeading}
|
||||
size="display"
|
||||
/>
|
||||
|
||||
{partners.length > 0 ? (
|
||||
<ul className="grid list-none grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3 lg:gap-x-4xl">
|
||||
{partners.map((partner) => (
|
||||
/* The tile pads its inline start only, as the frame draws it,
|
||||
and sits on the sunken surface - darker than the page, which
|
||||
is the way round the design has it. */
|
||||
<li
|
||||
key={partner.id}
|
||||
className="flex min-h-[7.125rem] items-center gap-md rounded-control bg-[var(--surface-sunken)] ps-md py-sm"
|
||||
>
|
||||
{partner.logo ? (
|
||||
<img
|
||||
src={partner.logo.src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="size-[4.375rem] shrink-0 object-contain"
|
||||
/>
|
||||
) : null}
|
||||
<BandLink to={sectionPath("whatWeDo", locale)} label={copy.workLinkLabel} />
|
||||
</div>
|
||||
|
||||
{/* An editor-supplied URL: `safeHref` refuses anything
|
||||
that is not http/https/mailto/tel, and the plain-text
|
||||
branch below already renders the partner's name. */}
|
||||
{safeHref(partner.website) ? (
|
||||
<a
|
||||
href={safeHref(partner.website)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="body-lg-bold text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
|
||||
>
|
||||
{partner.name}
|
||||
</a>
|
||||
) : (
|
||||
<span className="body-lg-bold text-[var(--text-primary)]">
|
||||
{partner.name}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
<ul className="grid list-none grid-cols-1 gap-lg md:grid-cols-3">
|
||||
{pillars.map((pillar) => (
|
||||
<li
|
||||
key={pillar.title}
|
||||
className="sh-lift sh-reveal sh-reveal--in flex h-full flex-col gap-sm rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)] p-xl hover:border-[var(--border-accent)]"
|
||||
>
|
||||
<h3 className="heading-h3 text-[var(--text-primary)]">{pillar.title}</h3>
|
||||
<p className="body-md text-[var(--text-secondary)]">
|
||||
{pillar.description}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</PageSection>
|
||||
|
||||
{/* NEWSROOM */}
|
||||
<PageSection
|
||||
aria-labelledby="home-news-heading"
|
||||
band
|
||||
>
|
||||
<div className="flex flex-col gap-2xl">
|
||||
<div className="sh-reveal flex flex-wrap items-end justify-between gap-lg border-b border-[var(--border-default)] pb-lg">
|
||||
<SectionHeader
|
||||
id="home-news-heading"
|
||||
eyebrow={copy.newsEyebrow}
|
||||
title={copy.newsHeading}
|
||||
size="display"
|
||||
/>
|
||||
|
||||
<BandLink to={sectionPath("news", locale)} label={t("actions.viewAll")} />
|
||||
</div>
|
||||
|
||||
{news.length > 0 ? (
|
||||
<ul className="grid list-none grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3">
|
||||
{news.map((item) => (
|
||||
<Card.Content
|
||||
key={item.id}
|
||||
as="li"
|
||||
className="sh-reveal sh-reveal--in"
|
||||
href={item.href}
|
||||
title={item.title}
|
||||
actionLabel={t("actions.readMore")}
|
||||
{...(item.organisation
|
||||
? { eyebrow: t("news.postedBy", { organisation: item.organisation }) }
|
||||
: item.dateLabel
|
||||
? { eyebrow: item.dateLabel }
|
||||
: {})}
|
||||
{...(item.organisation && item.dateLabel
|
||||
? { badge: item.dateLabel }
|
||||
: {})}
|
||||
{...(item.excerpt ? { excerpt: item.excerpt } : {})}
|
||||
{...(item.image ? { image: item.image } : {})}
|
||||
titleAs="h3"
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<EmptyState message={copy.partnersEmpty} />
|
||||
<EmptyState message={copy.newsEmpty} />
|
||||
)}
|
||||
</div>
|
||||
</PageSection>
|
||||
|
||||
{/* FAQs - the heading column stays with the reader while the answers
|
||||
scroll beside it, as the draft has it. */}
|
||||
<PageSection
|
||||
aria-labelledby="home-faq-heading"
|
||||
className="bg-[var(--surface-sunken)]"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-2xl lg:grid-cols-2 lg:gap-4xl">
|
||||
<div className="sh-reveal flex flex-col gap-lg lg:sticky lg:top-[var(--layout-sticky-top)] lg:self-start">
|
||||
<SectionHeader
|
||||
id="home-faq-heading"
|
||||
eyebrow={copy.faqEyebrow}
|
||||
title={copy.faqHeading}
|
||||
lead={copy.faqLead}
|
||||
size="display"
|
||||
/>
|
||||
|
||||
<Button
|
||||
as={Link}
|
||||
to={sectionPath("knowledgeHub", locale)}
|
||||
prefetch="intent"
|
||||
size="lg"
|
||||
className="self-start"
|
||||
iconEnd={<ArrowIcon />}
|
||||
>
|
||||
{copy.faqCtaLabel}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{faqs.length > 0 ? (
|
||||
<Accordion items={faqs} className="sh-reveal" />
|
||||
) : (
|
||||
<EmptyState message={copy.faqEmpty} />
|
||||
)}
|
||||
</div>
|
||||
</PageSection>
|
||||
|
||||
{/* THE CONSORTIUM, then the network it works with */}
|
||||
<PageSection
|
||||
aria-labelledby="home-consortium-heading"
|
||||
band
|
||||
>
|
||||
<div className="flex flex-col gap-2xl">
|
||||
<div className="sh-reveal flex flex-wrap items-end justify-between gap-lg">
|
||||
<SectionHeader
|
||||
id="home-consortium-heading"
|
||||
title={consortiumHeading}
|
||||
size="display"
|
||||
/>
|
||||
|
||||
<p className="label-md uppercase text-[var(--text-tertiary)]">
|
||||
{t("site.euStatement")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="grid list-none grid-cols-1 gap-lg md:grid-cols-3">
|
||||
{copy.consortium.map((member) => (
|
||||
<li
|
||||
key={member.name}
|
||||
className="sh-lift sh-reveal sh-reveal--in flex h-full flex-col items-start gap-sm rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)] p-xl hover:border-[var(--border-accent)]"
|
||||
>
|
||||
<span className="rounded-control bg-[var(--surface-accent-subtle)] px-xs py-2xs label-md uppercase text-[var(--text-accent)]">
|
||||
{member.role}
|
||||
</span>
|
||||
<h3 className="heading-h3 text-[var(--text-primary)]">{member.name}</h3>
|
||||
<p className="body-md text-[var(--text-secondary)]">
|
||||
{member.description}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="flex flex-col gap-md">
|
||||
<h3 className="label-md uppercase text-[var(--text-tertiary)]">
|
||||
{copy.networkLabel}
|
||||
</h3>
|
||||
|
||||
{partners.length > 0 ? (
|
||||
<ul className="grid list-none grid-cols-2 gap-md sm:grid-cols-3 lg:grid-cols-4">
|
||||
{partners.map((partner) => {
|
||||
const href = safeHref(partner.website);
|
||||
|
||||
/* The logo wall is a grid of equal tiles; the name is the
|
||||
tile's text when there is no logo to draw, and always its
|
||||
accessible name. */
|
||||
const inner = (
|
||||
<>
|
||||
{partner.logo ? (
|
||||
<img
|
||||
src={partner.logo.src}
|
||||
alt={partner.name}
|
||||
loading="lazy"
|
||||
className="max-h-[3.5rem] w-auto max-w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="body-sm-bold text-center text-[var(--text-secondary)]">
|
||||
{partner.name}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<li
|
||||
key={partner.id}
|
||||
className={cx(
|
||||
"sh-reveal sh-reveal--in flex h-[5.5rem] items-center justify-center",
|
||||
"rounded-control border border-[var(--border-subtle)] bg-[var(--surface-card)] p-md",
|
||||
"transition-colors duration-[var(--motion-duration-base)]",
|
||||
"hover:border-[var(--border-accent)]",
|
||||
)}
|
||||
>
|
||||
{href ? (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex h-full w-full items-center justify-center"
|
||||
>
|
||||
{inner}
|
||||
</a>
|
||||
) : (
|
||||
inner
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<EmptyState message={copy.partnersEmpty} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,17 @@
|
||||
/{locale}/knowledge-hub
|
||||
|
||||
§7 of docs/SHARPER_Website_Draft_Section_EN_AR.docx: training modules,
|
||||
toolkits, Civil Society Law Session guides, case studies and FAQs. Browsing
|
||||
is by type, by tag and by free-text search, all three held in the query
|
||||
string so a browse is a shareable, crawlable URL:
|
||||
toolkits, Civil Society Law Session guides, case studies and FAQs, laid out
|
||||
to the 2026 redesign draft ("Knowledge-Hub"):
|
||||
|
||||
hero the search box the ToR asks for, in the headline band
|
||||
collections the five kinds of material as numbered tiles, each carrying
|
||||
the number of items published under it
|
||||
FAQs a sticky heading column beside the accordion
|
||||
repository the closing note on long-term storage
|
||||
|
||||
Browsing is by type, by tag and by free-text search, all three held in the
|
||||
query string so a browse is a shareable, crawlable URL:
|
||||
?type=case-study&topic=access-to-justice&q=municipality
|
||||
|
||||
The ?type= values are the `knowledge-item.itemType` enumeration in the CMS,
|
||||
@@ -16,17 +24,30 @@
|
||||
listing and the news archive filter on - so a tag means the same thing
|
||||
everywhere on the site instead of drifting per section.
|
||||
|
||||
While the hub is empty the four type cards still describe what will land in
|
||||
it, using the draft's own one-line descriptions; nothing is invented and no
|
||||
count is shown.
|
||||
WHAT COMES FROM WHERE
|
||||
* the five type names and their one-line descriptions are approved draft
|
||||
copy and live in ./_shared/page-copy;
|
||||
* the COUNT on each tile is Strapi's own count. The draft's "12 modules ·
|
||||
EN / AR", "6 guides", "9 case studies" were sample text and are not
|
||||
shipped;
|
||||
* the FAQ band reads api::faq.faq. The draft writes out five questions and
|
||||
answers - on the rule of law, SUQ, eligibility, P2R and reuse - and not
|
||||
one of them appears in an approved document, so none of them is
|
||||
published here. Until an editor writes them the band shows its localised
|
||||
"being prepared" line;
|
||||
* the repository note is the ToR's own wording (§2 and §3.3), not the
|
||||
draft's stronger claim that every item already is versioned and kept
|
||||
beyond the project's end.
|
||||
========================================================================= */
|
||||
|
||||
import { Link, useLocation } from "react-router";
|
||||
|
||||
import { Button } from "~/components/ui/Button";
|
||||
import { Card } from "~/components/ui/Card";
|
||||
import { Card, CardActionHint, CardLink, CardShell } from "~/components/ui/Card";
|
||||
import {
|
||||
formatDate,
|
||||
formatNumber,
|
||||
interpolate,
|
||||
sectionPath,
|
||||
useTranslation,
|
||||
type Locale,
|
||||
@@ -34,10 +55,15 @@ import {
|
||||
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
|
||||
|
||||
import type { Route } from "./+types/knowledge-hub";
|
||||
import { Accordion } from "./_page-kit/accordion";
|
||||
import type { CmsFaq } from "./_page-kit/cms-types";
|
||||
import { loadMany } from "./_page-kit/cms.server";
|
||||
import { Container } from "./_page-kit/sections";
|
||||
import {
|
||||
CMS,
|
||||
KNOWLEDGE_TYPES,
|
||||
fetchCollection,
|
||||
fetchCount,
|
||||
fetchFilterOptions,
|
||||
toImage,
|
||||
type CmsImage,
|
||||
@@ -56,23 +82,30 @@ import {
|
||||
withParam,
|
||||
} from "./_shared/listing";
|
||||
import {
|
||||
CalloutBand,
|
||||
EmptyState,
|
||||
FilterBar,
|
||||
FilterSearch,
|
||||
FilterSelect,
|
||||
PageHeader,
|
||||
FilterPills,
|
||||
ListingHero,
|
||||
PageShell,
|
||||
Pagination,
|
||||
PillSelect,
|
||||
ResultSummary,
|
||||
SearchForm,
|
||||
} from "./_shared/listing-ui";
|
||||
import {
|
||||
KNOWLEDGE_COPY,
|
||||
KNOWLEDGE_TYPE_BLURBS,
|
||||
KNOWLEDGE_TYPE_LABELS,
|
||||
getListingChrome,
|
||||
} from "./_shared/page-copy";
|
||||
|
||||
import "./_page-kit/motion.css";
|
||||
|
||||
const DATE_FIELD = "publicationDate";
|
||||
|
||||
/** More than a dozen questions is a page of its own, not one band. */
|
||||
const FAQ_PAGE_SIZE = 12;
|
||||
|
||||
/** The label for one `itemType`, or the raw value if a new one is added. */
|
||||
function typeLabel(value: string, locale: Locale): string {
|
||||
return KNOWLEDGE_TYPE_LABELS[locale][value] ?? value;
|
||||
@@ -111,7 +144,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const [listing, topicOptions] = await Promise.all([
|
||||
const [listing, topicOptions, typeCounts, faqs] = await Promise.all([
|
||||
fetchCollection<KnowledgeEntry>(CMS.knowledgeItems, {
|
||||
locale,
|
||||
filters,
|
||||
@@ -125,6 +158,18 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
pagination: { page, pageSize: PAGE_SIZE, withCount: true },
|
||||
}),
|
||||
fetchFilterOptions(CMS.topics, locale),
|
||||
/* One count per type card. Strapi's own figure, so a card can never
|
||||
promise material that has not been published. */
|
||||
Promise.all(
|
||||
KNOWLEDGE_TYPES.map((value) =>
|
||||
fetchCount(CMS.knowledgeItems, locale, { itemType: { $eq: value } }),
|
||||
),
|
||||
),
|
||||
loadMany<CmsFaq>("faqs", {
|
||||
locale,
|
||||
sort: ["order:asc", "createdAt:asc"],
|
||||
pagination: { pageSize: FAQ_PAGE_SIZE },
|
||||
}),
|
||||
]);
|
||||
|
||||
const entries: KnowledgeListItem[] = listing.entries.map((entry) =>
|
||||
@@ -147,12 +192,20 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
})),
|
||||
topics: topicOptions,
|
||||
},
|
||||
/** The five type cards, with the draft's descriptions. */
|
||||
browse: KNOWLEDGE_TYPES.map((value) => ({
|
||||
/** The five type cards, with the draft's descriptions and a real count. */
|
||||
browse: KNOWLEDGE_TYPES.map((value, index) => ({
|
||||
value,
|
||||
label: typeLabel(value, locale),
|
||||
blurb: KNOWLEDGE_TYPE_BLURBS[locale][value] ?? "",
|
||||
count: typeCounts[index] ?? 0,
|
||||
})),
|
||||
faqs: faqs
|
||||
.filter((faq) => Boolean(faq?.question && faq?.answer))
|
||||
.map((faq) => ({
|
||||
id: faq.documentId,
|
||||
question: faq.question,
|
||||
answer: faq.answer,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -203,107 +256,243 @@ export function meta({ matches, loaderData }: Route.MetaArgs) {
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
export default function KnowledgeHub({ loaderData }: Route.ComponentProps) {
|
||||
const { entries, pagination, filters, facets, browse } = loaderData;
|
||||
const { entries, pagination, filters, facets, browse, faqs } = loaderData;
|
||||
const { t, locale } = useTranslation();
|
||||
const { pathname, search } = useLocation();
|
||||
const params = new URLSearchParams(search);
|
||||
|
||||
const copy = KNOWLEDGE_COPY[locale];
|
||||
const chrome = getListingChrome(locale).knowledge;
|
||||
const isFiltered = Boolean(filters.type || filters.topic || filters.q);
|
||||
|
||||
const typePills = [
|
||||
{
|
||||
id: "all",
|
||||
label: t("filters.all"),
|
||||
href: `${pathname}${withParam(params, "type", undefined)}`,
|
||||
isCurrent: filters.type === "",
|
||||
},
|
||||
...facets.types.map((option) => ({
|
||||
id: option.value,
|
||||
label: option.label,
|
||||
href: `${pathname}${withParam(params, "type", option.value)}`,
|
||||
isCurrent: filters.type === option.value,
|
||||
})),
|
||||
];
|
||||
|
||||
const keep = [...params.entries()]
|
||||
.filter(([key]) => key !== "q" && key !== "page")
|
||||
.map(([key, value]) => [key, value] as const);
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader heading={copy.heading} intro={copy.intro} />
|
||||
|
||||
{/* The map of the hub. Hidden once a filter is on, where it would just
|
||||
repeat the control the reader has already used. */}
|
||||
{!isFiltered ? (
|
||||
<nav aria-label={t("nav.sections.knowledgeHub")}>
|
||||
<ul className="grid gap-lg sm:grid-cols-2 lg:grid-cols-3">
|
||||
{browse.map((item) => (
|
||||
<Card.Content
|
||||
key={item.value}
|
||||
as="li"
|
||||
titleAs="h2"
|
||||
href={`${pathname}${withParam(new URLSearchParams(), "type", item.value)}`}
|
||||
title={item.label}
|
||||
excerpt={item.blurb}
|
||||
actionLabel={t("actions.browseHub")}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
<section className="flex flex-col gap-lg" aria-label={t("filters.heading")}>
|
||||
<FilterBar
|
||||
legend={t("filters.heading")}
|
||||
<>
|
||||
<ListingHero
|
||||
eyebrow={chrome.eyebrow}
|
||||
heading={copy.heading}
|
||||
intro={copy.intro}
|
||||
ambient={[{ top: -250, from: "start", offset: "10%", width: 740, height: 570, durationSeconds: 21 }]}
|
||||
>
|
||||
<SearchForm
|
||||
action={pathname}
|
||||
applyLabel={t("filters.apply")}
|
||||
clearLabel={t("filters.clear")}
|
||||
clearHref={pathname}
|
||||
showClear={isFiltered}
|
||||
>
|
||||
<FilterSearch
|
||||
label={t("search.label")}
|
||||
placeholder={t("search.placeholder")}
|
||||
defaultValue={filters.q}
|
||||
/>
|
||||
<FilterSelect
|
||||
name="type"
|
||||
label={t("filters.category")}
|
||||
value={filters.type}
|
||||
options={facets.types}
|
||||
allLabel={t("filters.all")}
|
||||
/>
|
||||
<FilterSelect
|
||||
name="topic"
|
||||
label={t("filters.topic")}
|
||||
value={filters.topic}
|
||||
options={facets.topics}
|
||||
allLabel={t("filters.all")}
|
||||
/>
|
||||
</FilterBar>
|
||||
label={t("search.label")}
|
||||
placeholder={chrome.searchPlaceholder}
|
||||
defaultValue={filters.q}
|
||||
submitLabel={t("search.submit")}
|
||||
keep={keep}
|
||||
className="max-w-[var(--layout-prose-max)]"
|
||||
/>
|
||||
</ListingHero>
|
||||
|
||||
{filters.q ? (
|
||||
<p className="body-md text-text-secondary">
|
||||
{t("search.resultsFor", { query: filters.q })}
|
||||
</p>
|
||||
<PageShell>
|
||||
{/* The map of the hub. Hidden once a filter is on, where it would just
|
||||
repeat the control the reader has already used. */}
|
||||
{!isFiltered ? (
|
||||
<nav aria-label={chrome.browseLabel} className="flex flex-col gap-lg">
|
||||
<h2 className="sr-only">{chrome.browseLabel}</h2>
|
||||
<ul className="grid gap-lg sm:grid-cols-2 lg:grid-cols-3">
|
||||
{browse.map((item, index) => (
|
||||
<CollectionTile
|
||||
key={item.value}
|
||||
index={index}
|
||||
label={item.label}
|
||||
blurb={item.blurb}
|
||||
count={item.count}
|
||||
countTemplate={chrome.itemCount}
|
||||
actionLabel={t("actions.browseHub")}
|
||||
href={`${pathname}${withParam(new URLSearchParams(), "type", item.value)}`}
|
||||
locale={locale}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
<ResultSummary pagination={pagination} t={t} />
|
||||
<section className="flex flex-col gap-lg" aria-label={t("filters.heading")}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-lg border-b border-border-subtle pb-lg">
|
||||
<FilterPills label={t("filters.category")} pills={typePills} />
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<EmptyState
|
||||
title={isFiltered ? t("filters.noResults") : copy.emptyTitle}
|
||||
body={isFiltered ? t("filters.noResultsHint") : copy.emptyBody}
|
||||
>
|
||||
{isFiltered ? (
|
||||
<Button as={Link} to={pathname} variant="secondary" size="sm">
|
||||
{t("filters.clear")}
|
||||
</Button>
|
||||
) : null}
|
||||
</EmptyState>
|
||||
) : (
|
||||
<ul className="grid gap-lg sm:grid-cols-2 lg:grid-cols-3">
|
||||
{entries.map((entry) => (
|
||||
<Card.Content
|
||||
key={entry.id}
|
||||
as="li"
|
||||
href={`${sectionPath("knowledgeHub", locale)}/${entry.slug}`}
|
||||
title={entry.title}
|
||||
actionLabel={t("actions.readMore")}
|
||||
{...(entry.typeLabel ? { eyebrow: entry.typeLabel } : {})}
|
||||
{...(entry.summary ? { excerpt: entry.summary } : {})}
|
||||
{...(entry.dateLabel ? { badge: entry.dateLabel } : {})}
|
||||
{...(entry.image ? { image: entry.image } : {})}
|
||||
{facets.topics.length > 0 ? (
|
||||
<PillSelect
|
||||
name="topic"
|
||||
label={t("filters.topic")}
|
||||
value={filters.topic}
|
||||
options={facets.topics}
|
||||
allLabel={t("filters.all")}
|
||||
applyLabel={t("filters.apply")}
|
||||
action={pathname}
|
||||
keep={[
|
||||
...(filters.type ? [["type", filters.type] as const] : []),
|
||||
...(filters.q ? [["q", filters.q] as const] : []),
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Pagination model={pagination} pathname={pathname} params={params} t={t} />
|
||||
{filters.q ? (
|
||||
<p className="body-md text-text-secondary">
|
||||
{t("search.resultsFor", { query: filters.q })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<ResultSummary pagination={pagination} t={t} />
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<EmptyState
|
||||
title={isFiltered ? t("filters.noResults") : copy.emptyTitle}
|
||||
body={isFiltered ? t("filters.noResultsHint") : copy.emptyBody}
|
||||
>
|
||||
{isFiltered ? (
|
||||
<Button as={Link} to={pathname} variant="secondary" size="sm">
|
||||
{t("filters.clear")}
|
||||
</Button>
|
||||
) : null}
|
||||
</EmptyState>
|
||||
) : (
|
||||
<ul className="grid gap-lg sm:grid-cols-2 lg:grid-cols-3">
|
||||
{entries.map((entry) => (
|
||||
<Card.Content
|
||||
key={entry.id}
|
||||
as="li"
|
||||
className="sh-reveal sh-reveal--in"
|
||||
href={`${sectionPath("knowledgeHub", locale)}/${entry.slug}`}
|
||||
title={entry.title}
|
||||
actionLabel={t("actions.readMore")}
|
||||
{...(entry.typeLabel ? { eyebrow: entry.typeLabel } : {})}
|
||||
{...(entry.summary ? { excerpt: entry.summary } : {})}
|
||||
{...(entry.dateLabel ? { badge: entry.dateLabel } : {})}
|
||||
{...(entry.image ? { image: entry.image } : {})}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<Pagination model={pagination} pathname={pathname} params={params} t={t} />
|
||||
</section>
|
||||
</PageShell>
|
||||
|
||||
{/* The FAQ band. The draft lifts it off the #0B0B0A ground with
|
||||
#1D1D1B, so the surface is painted rather than inherited. */}
|
||||
<section
|
||||
aria-labelledby="hub-faqs"
|
||||
className="sh-band py-3xl md:py-4xl"
|
||||
>
|
||||
<Container>
|
||||
<div className="grid gap-2xl lg:grid-cols-[minmax(0,22rem)_minmax(0,1fr)] lg:items-start">
|
||||
<div className="sh-reveal flex flex-col gap-sm lg:sticky lg:top-[var(--layout-sticky-top)]">
|
||||
<p className="label-md uppercase text-text-accent">
|
||||
{chrome.faqEyebrow}
|
||||
</p>
|
||||
<h2 id="hub-faqs" className="heading-h2 text-text-primary">
|
||||
{chrome.faqHeading}
|
||||
</h2>
|
||||
<p className="body-md text-text-secondary">{chrome.faqIntro}</p>
|
||||
</div>
|
||||
|
||||
{faqs.length > 0 ? (
|
||||
<Accordion items={faqs} />
|
||||
) : (
|
||||
<EmptyState title={chrome.faqHeading} body={chrome.faqEmpty} />
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
</PageShell>
|
||||
|
||||
<PageShell>
|
||||
<CalloutBand
|
||||
eyebrow={chrome.repositoryEyebrow}
|
||||
headline={chrome.repositoryHeadline}
|
||||
action={{
|
||||
to: sectionPath("resources", locale),
|
||||
label: chrome.repositoryAction,
|
||||
}}
|
||||
/>
|
||||
</PageShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
ONE COLLECTION TILE
|
||||
----------------------------------------------------------------------------
|
||||
The draft numbers the tiles and closes each with a count and an arrow. The
|
||||
number is ordinal decoration, so it is hidden from assistive technology -
|
||||
the tile's accessible name is its title.
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
function CollectionTile({
|
||||
index,
|
||||
label,
|
||||
blurb,
|
||||
count,
|
||||
countTemplate,
|
||||
actionLabel,
|
||||
href,
|
||||
locale,
|
||||
}: {
|
||||
index: number;
|
||||
label: string;
|
||||
blurb: string;
|
||||
count: number;
|
||||
countTemplate: string;
|
||||
actionLabel: string;
|
||||
href: string;
|
||||
locale: Locale;
|
||||
}) {
|
||||
const ordinal = formatNumber(index + 1, locale, {
|
||||
minimumIntegerDigits: 2,
|
||||
useGrouping: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<CardShell
|
||||
as="li"
|
||||
linked
|
||||
className="sh-reveal sh-reveal--in flex-col gap-md p-lg"
|
||||
>
|
||||
<p aria-hidden="true" className="heading-h2 text-text-accent">
|
||||
{ordinal}
|
||||
</p>
|
||||
|
||||
<h3 className="sh-card-title body-lg-bold text-text-primary">
|
||||
<CardLink href={href} className="sh-card-link">
|
||||
{label}
|
||||
</CardLink>
|
||||
</h3>
|
||||
|
||||
{blurb ? <p className="body-sm text-text-secondary">{blurb}</p> : null}
|
||||
|
||||
<div className="mt-auto flex items-center justify-between gap-sm border-t border-border-subtle pt-sm">
|
||||
{/* Nothing published yet means no count - a row of zeroes would read
|
||||
as a fault rather than as an honest figure. */}
|
||||
{count > 0 ? (
|
||||
<p className="label-md uppercase text-text-tertiary">
|
||||
{interpolate(countTemplate, { count: formatNumber(count, locale) })}
|
||||
</p>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<CardActionHint label={actionLabel} />
|
||||
</div>
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
----------------------------------------------------------------------------
|
||||
/{locale}/news-and-updates
|
||||
|
||||
§5 of docs/SHARPER_Website_Draft_Section_EN_AR.docx. Two streams, exactly as
|
||||
the draft describes them:
|
||||
§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 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
|
||||
@@ -18,13 +19,30 @@
|
||||
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 { Card } from "~/components/ui/Card";
|
||||
import { formatDate, sectionPath, useTranslation, type Locale } from "~/i18n";
|
||||
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";
|
||||
@@ -32,6 +50,7 @@ import {
|
||||
CMS,
|
||||
NEWS_CATEGORIES,
|
||||
fetchCollection,
|
||||
taxonomyNames,
|
||||
toImage,
|
||||
type CmsImage,
|
||||
type NewsCategory,
|
||||
@@ -48,15 +67,18 @@ import {
|
||||
withParam,
|
||||
} from "./_shared/listing";
|
||||
import {
|
||||
CalloutBand,
|
||||
EmptyState,
|
||||
PageHeader,
|
||||
ListingHero,
|
||||
PageShell,
|
||||
Pagination,
|
||||
ResultSummary,
|
||||
TabLinks,
|
||||
type TabLink,
|
||||
} from "./_shared/listing-ui";
|
||||
import { NEWS_COPY } from "./_shared/page-copy";
|
||||
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";
|
||||
@@ -70,7 +92,10 @@ interface NewsListItem {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -95,6 +120,9 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
},
|
||||
// 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 },
|
||||
});
|
||||
@@ -112,9 +140,10 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
}
|
||||
|
||||
function toListItem(entry: NewsEntry, locale: Locale): NewsListItem {
|
||||
const image = toImage(entry.coverImage, "small");
|
||||
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,
|
||||
@@ -122,7 +151,8 @@ function toListItem(entry: NewsEntry, locale: Locale): NewsListItem {
|
||||
title: entry.title,
|
||||
...(entry.excerpt ? { excerpt: entry.excerpt } : {}),
|
||||
...(organisation ? { organisation } : {}),
|
||||
...(date ? { dateLabel: formatDate(date, locale) } : {}),
|
||||
...(date ? { date, dateLabel: formatDate(date, locale) } : {}),
|
||||
...(tag ? { tag } : {}),
|
||||
...(image ? { image } : {}),
|
||||
};
|
||||
}
|
||||
@@ -166,6 +196,7 @@ export default function NewsAndUpdates({ loaderData }: Route.ComponentProps) {
|
||||
const params = new URLSearchParams(search);
|
||||
|
||||
const copy = NEWS_COPY[locale];
|
||||
const chrome = getListingChrome(locale).news;
|
||||
const empty = new URLSearchParams();
|
||||
|
||||
const tabs: TabLink[] = [
|
||||
@@ -183,71 +214,227 @@ export default function NewsAndUpdates({ loaderData }: Route.ComponentProps) {
|
||||
},
|
||||
];
|
||||
|
||||
/* 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 (
|
||||
<PageShell>
|
||||
<PageHeader heading={copy.heading} intro={copy.intro} />
|
||||
|
||||
<section className="flex flex-col gap-lg" aria-label={copy.heading}>
|
||||
<TabLinks label={t("nav.sections.news")} tabs={tabs} />
|
||||
|
||||
<p className="body-md max-w-[var(--layout-prose-max)] text-text-secondary">
|
||||
{tab === "from-the-network" ? copy.networkBlurb : copy.projectBlurb}
|
||||
</p>
|
||||
|
||||
<ResultSummary pagination={pagination} t={t} />
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<EmptyState title={copy.emptyTitle} body={copy.emptyBody}>
|
||||
{tab === "from-the-network" ? (
|
||||
<Button
|
||||
as={Link}
|
||||
to={sectionPath("contact", locale)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
{t("actions.submitYourUpdate")}
|
||||
</Button>
|
||||
) : null}
|
||||
</EmptyState>
|
||||
) : (
|
||||
<ul className="grid gap-lg sm:grid-cols-2 lg:grid-cols-3">
|
||||
{entries.map((entry) => (
|
||||
<Card.Content
|
||||
key={entry.id}
|
||||
as="li"
|
||||
href={`${sectionPath("news", locale)}/${entry.slug}`}
|
||||
title={entry.title}
|
||||
actionLabel={t("actions.readMore")}
|
||||
eyebrow={
|
||||
entry.organisation
|
||||
? t("news.postedBy", { organisation: entry.organisation })
|
||||
: entry.dateLabel
|
||||
}
|
||||
{...(entry.excerpt ? { excerpt: entry.excerpt } : {})}
|
||||
{...(entry.dateLabel && entry.organisation
|
||||
? { badge: entry.dateLabel }
|
||||
: {})}
|
||||
{...(entry.image ? { image: entry.image } : {})}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<Pagination model={pagination} pathname={pathname} params={params} t={t} />
|
||||
</section>
|
||||
|
||||
{/* "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. */}
|
||||
<Card.CTA
|
||||
eyebrow={t("news.tabNetwork")}
|
||||
headline={t("news.partnerPrompt")}
|
||||
body={copy.networkBlurb}
|
||||
action={{
|
||||
href: sectionPath("contact", locale),
|
||||
label: t("actions.submitYourUpdate"),
|
||||
}}
|
||||
<>
|
||||
<ListingHero
|
||||
eyebrow={chrome.eyebrow}
|
||||
heading={copy.heading}
|
||||
intro={copy.intro}
|
||||
ambient={[{ top: -250, from: "start", offset: "8%", width: 720, height: 560, durationSeconds: 20 }]}
|
||||
/>
|
||||
</PageShell>
|
||||
|
||||
<PageShell>
|
||||
<section className="flex flex-col gap-lg" aria-label={copy.heading}>
|
||||
<TabLinks label={t("nav.sections.news")} tabs={tabs} />
|
||||
|
||||
<p className="body-md max-w-[var(--layout-prose-max)] text-text-secondary">
|
||||
{tab === "from-the-network" ? copy.networkBlurb : copy.projectBlurb}
|
||||
</p>
|
||||
|
||||
<ResultSummary pagination={pagination} t={t} />
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<EmptyState title={copy.emptyTitle} body={copy.emptyBody}>
|
||||
{tab === "from-the-network" ? (
|
||||
<Button
|
||||
as={Link}
|
||||
to={sectionPath("contact", locale)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
{t("actions.submitYourUpdate")}
|
||||
</Button>
|
||||
) : null}
|
||||
</EmptyState>
|
||||
) : (
|
||||
<>
|
||||
{featured ? (
|
||||
<>
|
||||
<h2 className="sr-only">{chrome.featuredLabel}</h2>
|
||||
<FeaturedNewsCard
|
||||
entry={featured}
|
||||
href={hrefOf(featured)}
|
||||
actionLabel={chrome.readFull}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{grid.length > 0 ? (
|
||||
<>
|
||||
<h2 className="sr-only">{chrome.moreLabel}</h2>
|
||||
<ul className="grid gap-lg sm:grid-cols-2 lg:grid-cols-3">
|
||||
{grid.map((entry) => (
|
||||
<NewsCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
href={hrefOf(entry)}
|
||||
creditLabel={
|
||||
entry.organisation
|
||||
? t("news.postedBy", {
|
||||
organisation: entry.organisation,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Pagination model={pagination} pathname={pathname} params={params} t={t} />
|
||||
</section>
|
||||
|
||||
{/* "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. */}
|
||||
<CalloutBand
|
||||
eyebrow={t("news.tabNetwork")}
|
||||
headline={t("news.partnerPrompt")}
|
||||
body={copy.networkBlurb}
|
||||
action={{
|
||||
to: sectionPath("contact", locale),
|
||||
label: t("actions.submitYourUpdate"),
|
||||
}}
|
||||
/>
|
||||
</PageShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
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 (
|
||||
<div className="flex flex-wrap items-center gap-sm">
|
||||
{entry.tag ? (
|
||||
<span className="rounded-[var(--radius-control)] bg-surface-accent-subtle px-xs py-2xs label-md uppercase text-text-accent">
|
||||
{entry.tag}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{entry.dateLabel ? (
|
||||
<time
|
||||
dateTime={toIsoDate(entry.date)}
|
||||
className="label-md text-text-tertiary"
|
||||
>
|
||||
{entry.dateLabel}
|
||||
</time>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The wide lead card: picture beside the story, stacked on a phone. */
|
||||
function FeaturedNewsCard({
|
||||
entry,
|
||||
href,
|
||||
actionLabel,
|
||||
}: {
|
||||
entry: NewsListItem;
|
||||
href: string;
|
||||
actionLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<CardShell
|
||||
linked
|
||||
className="sh-reveal sh-reveal--in flex-col overflow-hidden md:flex-row"
|
||||
>
|
||||
{entry.image ? (
|
||||
<div className="relative md:w-1/2 md:shrink-0">
|
||||
<CardMedia image={entry.image} className="aspect-[16/10] w-full md:h-full" />
|
||||
{/* The highlight travelling across the picture - the draft's sheen. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="sh-sheen pointer-events-none absolute inset-0 bg-[linear-gradient(100deg,transparent_35%,var(--overlay-veil)_50%,transparent_65%)]"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-1 flex-col justify-center gap-md p-xl">
|
||||
<NewsMetaRow entry={entry} />
|
||||
|
||||
<h3 className="sh-card-title heading-h3 text-text-primary">
|
||||
<CardLink href={href} className="sh-card-link">
|
||||
{entry.title}
|
||||
</CardLink>
|
||||
</h3>
|
||||
|
||||
{entry.excerpt ? (
|
||||
<p className="body-md text-text-secondary">{entry.excerpt}</p>
|
||||
) : null}
|
||||
|
||||
<CardActionHint label={actionLabel} />
|
||||
</div>
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
/** One card of the three-up grid. */
|
||||
function NewsCard({
|
||||
entry,
|
||||
href,
|
||||
creditLabel,
|
||||
}: {
|
||||
entry: NewsListItem;
|
||||
href: string;
|
||||
creditLabel?: string;
|
||||
}) {
|
||||
return (
|
||||
<CardShell
|
||||
as="li"
|
||||
linked
|
||||
className="sh-reveal sh-reveal--in flex-col overflow-hidden"
|
||||
>
|
||||
{entry.image ? (
|
||||
<CardMedia image={entry.image} className="aspect-[9/5] w-full" />
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-1 flex-col gap-md p-lg">
|
||||
<div className="flex flex-col gap-xs">
|
||||
<NewsMetaRow entry={entry} />
|
||||
|
||||
<h3 className="sh-card-title body-lg-bold text-text-primary">
|
||||
<CardLink href={href} className="sh-card-link">
|
||||
{entry.title}
|
||||
</CardLink>
|
||||
</h3>
|
||||
|
||||
{entry.excerpt ? (
|
||||
<p className="body-sm text-text-secondary line-clamp-3">
|
||||
{entry.excerpt}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 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 ? (
|
||||
<p className="mt-auto label-md uppercase text-text-tertiary">
|
||||
{creditLabel}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,23 @@
|
||||
----------------------------------------------------------------------------
|
||||
/{locale}/resources-and-publications
|
||||
|
||||
§6 of docs/SHARPER_Website_Draft_Section_EN_AR.docx, with the four filters
|
||||
the ToR mandates - topic, date, author, partner organisation - plus the
|
||||
type tabs the draft lists and a sort order.
|
||||
§6 of docs/SHARPER_Website_Draft_Section_EN_AR.docx with the four filters
|
||||
the ToR mandates - topic, date, author, partner organisation - plus the type
|
||||
categories the draft lists, a sort order and a free-text search.
|
||||
|
||||
Laid out to the 2026 redesign draft ("Resources-and-Publications"): a sticky
|
||||
facet rail on the inline-start side, and a column of wide result rows, each
|
||||
with its format chip, its metadata line, its language chip and a direct
|
||||
download.
|
||||
|
||||
THE FACETS ARE LINKS, NOT CHECKBOXES
|
||||
The draft draws them as checkboxes, which promises multi-select; the Strapi
|
||||
query behind this listing takes one value per facet, so each option is a
|
||||
link that sets the facet and clicking the active one clears it. Every view
|
||||
is therefore still an ordinary URL - ?topic=access-to-justice&year=2026 -
|
||||
that can be shared, bookmarked, walked back and followed by a crawler, and
|
||||
it renders identically on the server and the client because nothing about it
|
||||
is component state.
|
||||
|
||||
THE AUTHOR FILTER IS NOT A TAXONOMY
|
||||
`resource.authors` is a plain STRING field in the CMS - there is no author
|
||||
@@ -13,17 +27,15 @@
|
||||
distinct values the published resources actually carry, which is also why
|
||||
the filter offers nothing at all while the CMS is empty instead of 404ing.
|
||||
|
||||
FILTER STATE LIVES IN THE URL
|
||||
Every control is a field of one `<form method="get">`, so a filtered view is
|
||||
an ordinary link: ?topic=access-to-justice&year=2026&sort=oldest. It can be
|
||||
shared, bookmarked, walked back with the browser's Back button and followed
|
||||
by a crawler, and it renders identically on the server and the client
|
||||
because nothing about it is component state.
|
||||
NOTHING THE DRAFT LISTS IS PUBLISHED HERE. Its six sample publications -
|
||||
the "Nationwide Perception Study", the "SHARP I and II Closing Report",
|
||||
their page counts and their file sizes - were mockup content. Every row is
|
||||
a Strapi entry; an empty CMS renders the localised empty state.
|
||||
|
||||
The canonical URL and the en/ar/x-default hreflang set are emitted for this
|
||||
route by the app shell (root.tsx `Layout`) from the pathname, which is why
|
||||
`buildMeta` is called WITHOUT `alternates` here: a leaf `meta` replaces its
|
||||
parents' wholesale, and passing it would emit each link twice. Every filter
|
||||
route by the app shell (root.tsx `Layout`), which is why `buildMeta` is
|
||||
called WITHOUT `alternates` here: a leaf `meta` replaces its parents'
|
||||
wholesale, and passing it would emit each link twice. Every filter
|
||||
combination therefore canonicalises to the unfiltered listing, which is the
|
||||
behaviour we want from a facet.
|
||||
========================================================================= */
|
||||
@@ -34,7 +46,6 @@ import {
|
||||
CardActionHint,
|
||||
CardEyebrow,
|
||||
CardLink,
|
||||
CardMedia,
|
||||
CardShell,
|
||||
} from "~/components/ui/Card";
|
||||
import { Button } from "~/components/ui/Button";
|
||||
@@ -42,6 +53,7 @@ import {
|
||||
formatDate,
|
||||
formatFileSize,
|
||||
sectionPath,
|
||||
toIsoDate,
|
||||
translate,
|
||||
useTranslation,
|
||||
type Locale,
|
||||
@@ -59,9 +71,7 @@ import {
|
||||
fetchYearOptions,
|
||||
taxonomyNames,
|
||||
toFile,
|
||||
toImage,
|
||||
type CmsFile,
|
||||
type CmsImage,
|
||||
type ResourceEntry,
|
||||
type ResourceType,
|
||||
} from "./_shared/cms";
|
||||
@@ -73,6 +83,7 @@ import {
|
||||
parseExactText,
|
||||
parseOptionalEnum,
|
||||
parsePage,
|
||||
parseQuery,
|
||||
parseSlug,
|
||||
parseSort,
|
||||
parseYear,
|
||||
@@ -80,6 +91,7 @@ import {
|
||||
SORT_ORDERS,
|
||||
requireLocale,
|
||||
sortFor,
|
||||
withParam,
|
||||
yearFilter,
|
||||
yearOptions,
|
||||
type SelectOption,
|
||||
@@ -87,15 +99,23 @@ import {
|
||||
} from "./_shared/listing";
|
||||
import {
|
||||
EmptyState,
|
||||
FilterBar,
|
||||
FilterSelect,
|
||||
FacetGroup,
|
||||
FacetRail,
|
||||
ListingHero,
|
||||
MetaLine,
|
||||
PageHeader,
|
||||
PageShell,
|
||||
Pagination,
|
||||
ResultSummary,
|
||||
SearchForm,
|
||||
type FacetOption,
|
||||
} from "./_shared/listing-ui";
|
||||
import { RESOURCES_COPY } from "./_shared/page-copy";
|
||||
import {
|
||||
RESOURCES_COPY,
|
||||
getListingChrome,
|
||||
resourceLanguageLabel,
|
||||
} from "./_shared/page-copy";
|
||||
|
||||
import "./_page-kit/motion.css";
|
||||
|
||||
/**
|
||||
* The four publication kinds, as `resource.resourceType` publishes them,
|
||||
@@ -132,9 +152,10 @@ interface ResourceListItem {
|
||||
readonly authors?: string;
|
||||
readonly partners: readonly string[];
|
||||
readonly topics: readonly string[];
|
||||
readonly image?: CmsImage;
|
||||
readonly file?: CmsFile;
|
||||
readonly fileLabel?: string;
|
||||
readonly fileKind?: string;
|
||||
readonly fileSizeLabel?: string;
|
||||
readonly languageLabel?: string;
|
||||
readonly externalUrl?: string;
|
||||
}
|
||||
|
||||
@@ -153,6 +174,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
const partner = parseSlug(search, "partner");
|
||||
const year = parseYear(search);
|
||||
const sort = parseSort(search);
|
||||
const query = parseQuery(search);
|
||||
|
||||
const filters = compactFilters({
|
||||
resourceType: type ? { $eq: type } : undefined,
|
||||
@@ -161,6 +183,16 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
authors: author ? { $eq: author } : undefined,
|
||||
partnerOrganisations: partner ? { slug: { $eq: partner } } : undefined,
|
||||
[DATE_FIELD]: year ? yearFilter(year) : undefined,
|
||||
/* The draft's "Title, author, keyword" box. The body of a document is not
|
||||
searched - it is a file, not a field - so the three things a reader can
|
||||
actually see on the card are what the query hits. */
|
||||
$or: query
|
||||
? [
|
||||
{ title: { $containsi: query } },
|
||||
{ description: { $containsi: query } },
|
||||
{ authors: { $containsi: query } },
|
||||
]
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// One round trip per facet, all in flight together: the listing itself, the
|
||||
@@ -172,9 +204,8 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
filters,
|
||||
sort: sortFor(DATE_FIELD, sort),
|
||||
populate: {
|
||||
thumbnail: {
|
||||
fields: ["url", "alternativeText", "width", "height", "formats"],
|
||||
},
|
||||
// No thumbnail: the listing row draws the format chip instead of a
|
||||
// cover, so the image is fetched by the detail route, not here.
|
||||
file: { fields: ["url", "name", "ext", "mime", "size"] },
|
||||
partnerOrganisations: { fields: ["name", "slug"] },
|
||||
topics: { fields: ["name", "slug"] },
|
||||
@@ -202,6 +233,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
partner: partner ?? "",
|
||||
year: year === undefined ? "" : String(year),
|
||||
sort,
|
||||
q: query ?? "",
|
||||
},
|
||||
facets: {
|
||||
types: enumOptions(RESOURCE_TYPES, TYPE_LABEL, locale),
|
||||
@@ -216,9 +248,9 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
|
||||
function toListItem(entry: ResourceEntry, locale: Locale): ResourceListItem {
|
||||
const file = toFile(entry.file);
|
||||
const image = toImage(entry.thumbnail, "small");
|
||||
const date = entry.publicationDate ?? entry.publishedAt ?? undefined;
|
||||
const authors = entry.authors?.trim();
|
||||
const language = resourceLanguageLabel(entry.resourceLanguage, locale);
|
||||
|
||||
return {
|
||||
id: entry.documentId,
|
||||
@@ -232,13 +264,14 @@ function toListItem(entry: ResourceEntry, locale: Locale): ResourceListItem {
|
||||
...(authors ? { authors } : {}),
|
||||
partners: taxonomyNames(entry.partnerOrganisations),
|
||||
topics: taxonomyNames(entry.topics),
|
||||
...(image ? { image } : {}),
|
||||
...(file
|
||||
? {
|
||||
file,
|
||||
fileLabel: `${file.kind} · ${formatFileSize(file.sizeBytes, locale)}`,
|
||||
fileKind: file.kind,
|
||||
fileSizeLabel: formatFileSize(file.sizeBytes, locale),
|
||||
}
|
||||
: {}),
|
||||
...(language ? { languageLabel: language } : {}),
|
||||
...(entry.externalUrl ? { externalUrl: entry.externalUrl } : {}),
|
||||
};
|
||||
}
|
||||
@@ -281,108 +314,176 @@ export default function ResourcesAndPublications({
|
||||
const params = new URLSearchParams(search);
|
||||
|
||||
const copy = RESOURCES_COPY[locale];
|
||||
const chrome = getListingChrome(locale).resources;
|
||||
|
||||
const sortLabel =
|
||||
facets.sorts.find((option) => option.value === filters.sort)?.label ?? "";
|
||||
|
||||
const isFiltered =
|
||||
Boolean(filters.type) ||
|
||||
Boolean(filters.topic) ||
|
||||
Boolean(filters.author) ||
|
||||
Boolean(filters.partner) ||
|
||||
Boolean(filters.year);
|
||||
Boolean(filters.year) ||
|
||||
Boolean(filters.q);
|
||||
|
||||
/** One facet option: sets the value, or clears it when it is already on. */
|
||||
const facetOptions = (
|
||||
key: string,
|
||||
current: string,
|
||||
options: readonly SelectOption[],
|
||||
): FacetOption[] =>
|
||||
options.map((option) => {
|
||||
const isCurrent = current === option.value;
|
||||
return {
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
href: `${pathname}${withParam(
|
||||
params,
|
||||
key,
|
||||
isCurrent ? undefined : option.value,
|
||||
)}`,
|
||||
isCurrent,
|
||||
};
|
||||
});
|
||||
|
||||
/* Everything except the search term, so submitting the box keeps the facets
|
||||
the reader has already set. */
|
||||
const keep = [...params.entries()]
|
||||
.filter(([key]) => key !== "q" && key !== "page")
|
||||
.map(([key, value]) => [key, value] as const);
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader heading={copy.heading} intro={copy.intro} />
|
||||
<>
|
||||
<ListingHero
|
||||
eyebrow={chrome.eyebrow}
|
||||
heading={copy.heading}
|
||||
intro={copy.intro}
|
||||
ambient={[{ top: -250, from: "end", offset: "6%", width: 720, height: 560, durationSeconds: 22 }]}
|
||||
/>
|
||||
|
||||
<section className="flex flex-col gap-lg" aria-label={copy.heading}>
|
||||
<FilterBar
|
||||
legend={t("filters.heading")}
|
||||
action={pathname}
|
||||
applyLabel={t("filters.apply")}
|
||||
clearLabel={t("filters.clear")}
|
||||
clearHref={pathname}
|
||||
showClear={isFiltered || filters.sort !== "newest"}
|
||||
>
|
||||
<FilterSelect
|
||||
name="type"
|
||||
label={t("filters.category")}
|
||||
value={filters.type}
|
||||
options={facets.types}
|
||||
allLabel={t("filters.all")}
|
||||
/>
|
||||
<FilterSelect
|
||||
name="topic"
|
||||
label={t("filters.topic")}
|
||||
value={filters.topic}
|
||||
options={facets.topics}
|
||||
allLabel={t("filters.all")}
|
||||
/>
|
||||
<FilterSelect
|
||||
name="author"
|
||||
label={t("filters.author")}
|
||||
value={filters.author}
|
||||
options={facets.authors}
|
||||
allLabel={t("filters.all")}
|
||||
/>
|
||||
<FilterSelect
|
||||
name="partner"
|
||||
label={t("filters.partner")}
|
||||
value={filters.partner}
|
||||
options={facets.partners}
|
||||
allLabel={t("filters.all")}
|
||||
/>
|
||||
<FilterSelect
|
||||
name="year"
|
||||
label={t("filters.date")}
|
||||
value={filters.year}
|
||||
options={facets.years}
|
||||
allLabel={t("filters.all")}
|
||||
/>
|
||||
{/* No "All" option: sorting always has a value. */}
|
||||
<FilterSelect
|
||||
name="sort"
|
||||
label={t("filters.sortBy")}
|
||||
value={filters.sort}
|
||||
options={facets.sorts}
|
||||
/>
|
||||
</FilterBar>
|
||||
<PageShell>
|
||||
<div className="grid gap-2xl lg:grid-cols-[minmax(0,17rem)_minmax(0,1fr)]">
|
||||
<FacetRail label={chrome.facetsLabel}>
|
||||
<SearchForm
|
||||
action={pathname}
|
||||
label={t("search.label")}
|
||||
placeholder={chrome.searchPlaceholder}
|
||||
defaultValue={filters.q}
|
||||
submitLabel={t("search.submit")}
|
||||
keep={keep}
|
||||
className="flex-col items-stretch"
|
||||
/>
|
||||
|
||||
<ResultSummary pagination={pagination} t={t} />
|
||||
<FacetGroup
|
||||
heading={t("filters.category")}
|
||||
options={facetOptions("type", filters.type, facets.types)}
|
||||
/>
|
||||
<FacetGroup
|
||||
heading={t("filters.topic")}
|
||||
options={facetOptions("topic", filters.topic, facets.topics)}
|
||||
/>
|
||||
<FacetGroup
|
||||
heading={t("filters.author")}
|
||||
options={facetOptions("author", filters.author, facets.authors)}
|
||||
/>
|
||||
<FacetGroup
|
||||
heading={t("filters.partner")}
|
||||
options={facetOptions("partner", filters.partner, facets.partners)}
|
||||
/>
|
||||
<FacetGroup
|
||||
heading={t("filters.date")}
|
||||
options={facetOptions("year", filters.year, facets.years)}
|
||||
/>
|
||||
{/* Sorting always has a value, so its options never clear. */}
|
||||
<FacetGroup
|
||||
heading={t("filters.sortBy")}
|
||||
options={facets.sorts.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
href: `${pathname}${withParam(params, "sort", option.value)}`,
|
||||
isCurrent: filters.sort === option.value,
|
||||
}))}
|
||||
/>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<EmptyState
|
||||
title={isFiltered ? t("filters.noResults") : copy.emptyTitle}
|
||||
body={isFiltered ? t("filters.noResultsHint") : copy.emptyBody}
|
||||
>
|
||||
{isFiltered ? (
|
||||
<Button as={Link} to={pathname} variant="secondary" size="sm">
|
||||
{isFiltered || filters.sort !== "newest" ? (
|
||||
<Button
|
||||
as={Link}
|
||||
to={pathname}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="self-start"
|
||||
>
|
||||
{t("filters.clear")}
|
||||
</Button>
|
||||
) : null}
|
||||
</EmptyState>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-lg">
|
||||
{entries.map((entry) => (
|
||||
<ResourceRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
href={`${sectionPath("resources", locale)}/${entry.slug}`}
|
||||
readLabel={t("actions.viewDetails")}
|
||||
downloadLabel={t("actions.download")}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</FacetRail>
|
||||
|
||||
<Pagination model={pagination} pathname={pathname} params={params} t={t} />
|
||||
</section>
|
||||
</PageShell>
|
||||
<section className="flex min-w-0 flex-col gap-lg" aria-label={copy.heading}>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-md border-b border-border-subtle pb-md">
|
||||
<div className="flex flex-wrap items-baseline gap-md">
|
||||
<ResultSummary pagination={pagination} t={t} />
|
||||
|
||||
{filters.q ? (
|
||||
<p className="body-sm text-text-secondary">
|
||||
{t("search.resultsFor", { query: filters.q })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* "Sorted by date · newest first" in the draft. The order in
|
||||
force is read back from the query string, never asserted. */}
|
||||
<p className="label-md uppercase text-text-tertiary">
|
||||
{t("filters.sortBy")}
|
||||
<span aria-hidden="true"> · </span>
|
||||
{sortLabel}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<EmptyState
|
||||
title={isFiltered ? t("filters.noResults") : copy.emptyTitle}
|
||||
body={isFiltered ? t("filters.noResultsHint") : copy.emptyBody}
|
||||
>
|
||||
{isFiltered ? (
|
||||
<Button as={Link} to={pathname} variant="secondary" size="sm">
|
||||
{t("filters.clear")}
|
||||
</Button>
|
||||
) : null}
|
||||
</EmptyState>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-md">
|
||||
{entries.map((entry) => (
|
||||
<ResourceRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
href={`${sectionPath("resources", locale)}/${entry.slug}`}
|
||||
readLabel={t("actions.viewDetails")}
|
||||
downloadLabel={t("actions.download")}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<Pagination
|
||||
model={pagination}
|
||||
pathname={pathname}
|
||||
params={params}
|
||||
t={t}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</PageShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
ONE RESULT
|
||||
Composed from the exported card primitives rather than Card.Horizontal:
|
||||
a publication carries a metadata line (date · author · partner · PDF · size)
|
||||
and a direct download, neither of which that card has a slot for.
|
||||
a publication carries a metadata line (date · author · partner), a format
|
||||
chip, a language chip and a direct download, none of which that card has a
|
||||
slot for.
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
function ResourceRow({
|
||||
@@ -399,40 +500,67 @@ function ResourceRow({
|
||||
const downloadHref = entry.file?.url ?? entry.externalUrl;
|
||||
|
||||
return (
|
||||
<CardShell as="li" linked className="flex-col overflow-hidden sm:flex-row">
|
||||
{entry.image ? (
|
||||
<CardMedia
|
||||
image={entry.image}
|
||||
className="aspect-[6/5] w-full sm:aspect-auto sm:w-60 sm:shrink-0"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-1 flex-col gap-md p-lg">
|
||||
<div className="flex flex-col gap-xs">
|
||||
{entry.typeLabel ? <CardEyebrow>{entry.typeLabel}</CardEyebrow> : null}
|
||||
|
||||
<h2 className="sh-card-title body-lg-bold text-text-primary">
|
||||
<CardLink href={href} className="sh-card-link">
|
||||
{entry.title}
|
||||
</CardLink>
|
||||
</h2>
|
||||
|
||||
<MetaLine
|
||||
items={[
|
||||
entry.dateLabel ?? "",
|
||||
entry.authors ?? "",
|
||||
entry.partners.join(", "),
|
||||
entry.fileLabel ?? "",
|
||||
]}
|
||||
/>
|
||||
|
||||
{entry.summary ? (
|
||||
<p className="body-sm text-text-secondary line-clamp-3">{entry.summary}</p>
|
||||
<CardShell
|
||||
as="li"
|
||||
linked
|
||||
className="sh-reveal sh-reveal--in flex-col overflow-hidden sm:flex-row"
|
||||
>
|
||||
{/* No thumbnail: the draft anchors the row on the format chip below and
|
||||
keeps every result the same height, whether or not the CMS entry
|
||||
carries a cover. The thumbnail still renders on the detail page. */}
|
||||
<div className="flex flex-1 flex-col gap-md p-lg lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex min-w-0 gap-md">
|
||||
{/* The draft's document chip. Only drawn for a file we actually
|
||||
hold - an externally hosted document has no format to name. */}
|
||||
{entry.fileKind ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex h-14 w-11 shrink-0 items-center justify-center rounded-[var(--radius-control)] border border-border-default bg-surface-sunken label-md text-text-accent"
|
||||
>
|
||||
{entry.fileKind}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-xs">
|
||||
{entry.typeLabel ? <CardEyebrow>{entry.typeLabel}</CardEyebrow> : null}
|
||||
|
||||
<h2 className="sh-card-title body-lg-bold text-text-primary">
|
||||
<CardLink href={href} className="sh-card-link">
|
||||
{entry.title}
|
||||
</CardLink>
|
||||
</h2>
|
||||
|
||||
<MetaLine
|
||||
items={[
|
||||
entry.authors ?? "",
|
||||
entry.dateLabel ?? "",
|
||||
entry.fileSizeLabel ?? "",
|
||||
entry.partners.join(", "),
|
||||
]}
|
||||
/>
|
||||
|
||||
{entry.date ? (
|
||||
<time dateTime={toIsoDate(entry.date)} className="sr-only">
|
||||
{entry.dateLabel}
|
||||
</time>
|
||||
) : null}
|
||||
|
||||
{entry.summary ? (
|
||||
<p className="body-sm text-text-secondary line-clamp-3">
|
||||
{entry.summary}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<CardActionHint label={readLabel} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-md">
|
||||
<CardActionHint label={readLabel} />
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-md">
|
||||
{entry.languageLabel ? (
|
||||
<span className="rounded-pill border border-border-default px-md py-2xs label-md uppercase text-text-primary">
|
||||
{entry.languageLabel}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{downloadHref ? (
|
||||
// `sh-card-above` lifts it over the stretched title link, so the
|
||||
@@ -440,7 +568,7 @@ function ResourceRow({
|
||||
<Button
|
||||
as="a"
|
||||
href={downloadHref}
|
||||
variant="secondary"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="sh-card-above"
|
||||
{...(entry.file
|
||||
|
||||
@@ -1,15 +1,36 @@
|
||||
/* ============================================================================
|
||||
WHAT WE DO - /en/what-we-do and /ar/what-we-do
|
||||
----------------------------------------------------------------------------
|
||||
Section 2 of docs/SHARPER_Website_Draft_Section_EN_AR.docx: the participatory
|
||||
approach, the four connected areas of work, the values that guide them and
|
||||
the four principles the approach rests on.
|
||||
The redesigned What We Do page ("What-We-Do", Claude Design draft), carried
|
||||
onto the project's own design system. Four bands:
|
||||
|
||||
Hero eyebrow, headline and the one-line lead, on the sunken
|
||||
surface with the two drifting washes behind it.
|
||||
Areas the four connected areas of work as a seam grid, each cell
|
||||
led by its numeral.
|
||||
Guidance the five values as a centred chip row, then the four
|
||||
principles as bar-topped tiles.
|
||||
Activities the closing panel with the accent start-edge.
|
||||
|
||||
WHAT THE DRAFT SHIPS THAT THIS PAGE DOES NOT
|
||||
--------------------------------------------
|
||||
* The draft gives each principle an explanatory line ("Local organisations
|
||||
lead the design and delivery of activities", "Solutions are built to
|
||||
outlast the project's own timeline", "Institutional change is supported
|
||||
step by step, not imposed"). None of those sentences is in an approved
|
||||
document, so the tiles carry only the clause the approved "How we work"
|
||||
sentence states, which is what the draft's own titles carry too.
|
||||
* The draft's coloured hover glow is deliberately dropped.
|
||||
|
||||
Overridden field by field by api::what-we-do-page.what-we-do-page; the draft
|
||||
copy below is what renders until an editor fills that in.
|
||||
|
||||
Arabic is this same DOM under `dir="rtl"`, so every inline edge is logical.
|
||||
========================================================================= */
|
||||
|
||||
import { Card } from "~/components/ui/Card";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { ArrowIcon, Button } from "~/components/ui/Button";
|
||||
import { createTranslator, formatNumber, sectionPath } from "~/i18n";
|
||||
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
|
||||
|
||||
@@ -25,7 +46,6 @@ import { pageCopy } from "./_page-kit/copy";
|
||||
import { routeLocale } from "./_page-kit/locale";
|
||||
import { RichText } from "./_page-kit/rich-text";
|
||||
import {
|
||||
InfoPanel,
|
||||
PageHero,
|
||||
PageSection,
|
||||
Prose,
|
||||
@@ -33,6 +53,8 @@ import {
|
||||
ValueChips,
|
||||
} from "./_page-kit/sections";
|
||||
|
||||
import "./_page-kit/motion.css";
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const locale = routeLocale(params);
|
||||
|
||||
@@ -104,6 +126,10 @@ export function meta({ loaderData, matches }: Route.MetaArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
/** See the note on the same constant in about.tsx. */
|
||||
const ACCENT_EDGE =
|
||||
"[border-inline-start-color:var(--border-accent)] [border-inline-start-width:4px]";
|
||||
|
||||
export default function WhatWeDo({ loaderData }: Route.ComponentProps) {
|
||||
const { locale, hero, intro, richTextBlocks } = loaderData;
|
||||
const t = createTranslator(locale);
|
||||
@@ -117,25 +143,40 @@ export default function WhatWeDo({ loaderData }: Route.ComponentProps) {
|
||||
const values =
|
||||
loaderData.guidingValues.length > 0 ? loaderData.guidingValues : copy.values;
|
||||
|
||||
/* An editor's principles carry a line of their own; the bundled fallback is
|
||||
the four clauses of the approved sentence and nothing more. */
|
||||
const principles =
|
||||
loaderData.principles.length > 0
|
||||
? loaderData.principles
|
||||
: copy.principleTitles.map((title) => ({ title, description: "" }));
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHero
|
||||
className="border-b border-[var(--border-subtle)] bg-[var(--surface-sunken)] [--layout-hero-bottom:clamp(2.75rem,6vw,4.25rem)]"
|
||||
title={hero.headline ?? copy.heroHeadline}
|
||||
eyebrow={hero.eyebrow ?? t("nav.sections.aboutAndStrategy")}
|
||||
eyebrow={hero.eyebrow ?? t("nav.sections.whatWeDo")}
|
||||
lead={hero.subheading ?? copy.lead}
|
||||
ambient={[{ top: -240, from: "end", offset: "-120px", width: 700, height: 560, durationSeconds: 22 }]}
|
||||
leadTone="primary"
|
||||
reveal
|
||||
{...(hero.media ? { media: hero.media } : {})}
|
||||
/>
|
||||
|
||||
{/* THE FOUR AREAS OF WORK */}
|
||||
{/* THE FOUR AREAS OF WORK - one seam grid, the cells raised out of the
|
||||
band, each led by its numeral (localised, so the Arabic page numbers
|
||||
its cells in its own conventions). */}
|
||||
<PageSection
|
||||
aria-labelledby="wwd-workstreams-heading"
|
||||
className="pt-0 md:pt-0"
|
||||
className="bg-[var(--surface-sunken)]"
|
||||
>
|
||||
<div className="flex flex-col gap-2xl">
|
||||
{/* The draft prints the heading alone - the approved intro sentence
|
||||
IS this heading - so no lead is drawn under it. */}
|
||||
<SectionHeader
|
||||
className="sh-reveal max-w-[var(--layout-prose-max)]"
|
||||
id="wwd-workstreams-heading"
|
||||
title={copy.workstreamsHeading}
|
||||
lead={intro ? undefined : copy.workstreamsIntro}
|
||||
/>
|
||||
|
||||
{intro ? (
|
||||
@@ -144,68 +185,115 @@ export default function WhatWeDo({ loaderData }: Route.ComponentProps) {
|
||||
</Prose>
|
||||
) : null}
|
||||
|
||||
<ul className="grid list-none grid-cols-1 gap-lg md:grid-cols-2">
|
||||
<ul className="grid list-none grid-cols-1 gap-[1px] overflow-hidden rounded-card border border-[var(--border-subtle)] bg-[var(--border-subtle)] sm:grid-cols-2 lg:grid-cols-4">
|
||||
{workstreams.map((workstream, index) => (
|
||||
<li key={workstream.title}>
|
||||
<InfoPanel
|
||||
title={workstream.title}
|
||||
eyebrow={formatNumber(index + 1, locale, {
|
||||
minimumIntegerDigits: 2,
|
||||
})}
|
||||
<li
|
||||
key={workstream.title}
|
||||
className="sh-reveal flex min-h-[16.25rem] flex-col gap-sm bg-[var(--surface-card)] p-lg transition-colors duration-[var(--motion-duration-base)] hover:bg-[var(--surface-muted)]"
|
||||
>
|
||||
<p
|
||||
aria-hidden="true"
|
||||
className="heading-h2 text-[var(--text-accent)]"
|
||||
>
|
||||
{formatNumber(index + 1, locale, { minimumIntegerDigits: 2 })}
|
||||
</p>
|
||||
{/* The draft sets an area title a step above body - the same
|
||||
size it gives the consortium names on About. */}
|
||||
<h3 className="heading-h3 text-[var(--text-primary)]">
|
||||
{workstream.title}
|
||||
</h3>
|
||||
<p className="body-md text-[var(--text-secondary)]">
|
||||
{workstream.description}
|
||||
</InfoPanel>
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<ValueChips values={values} label={copy.valuesLabel} />
|
||||
</div>
|
||||
</PageSection>
|
||||
|
||||
{/* HOW WE WORK */}
|
||||
<PageSection aria-labelledby="wwd-principles-heading">
|
||||
<div className="flex flex-col gap-lg">
|
||||
<SectionHeader
|
||||
id="wwd-principles-heading"
|
||||
title={copy.principlesHeading}
|
||||
{/* WHAT GUIDES US, AND HOW WE WORK - the one band the draft raises off
|
||||
the near-black ground, so it names its surface explicitly. */}
|
||||
<PageSection
|
||||
aria-labelledby="wwd-principles-heading"
|
||||
band
|
||||
>
|
||||
<div className="flex flex-col gap-2xl">
|
||||
{/* The draft's chips rest as a plain outline and only take the accent
|
||||
fill under the pointer; the shared component rests filled, so the
|
||||
resting and hover paints are restated here until it carries the
|
||||
draft's own treatment. */}
|
||||
<ValueChips
|
||||
className="sh-reveal"
|
||||
align="center"
|
||||
values={values}
|
||||
label={copy.valuesLabel}
|
||||
/>
|
||||
|
||||
{loaderData.principles.length > 0 ? (
|
||||
<ul className="grid list-none grid-cols-1 gap-lg md:grid-cols-2">
|
||||
{loaderData.principles.map((principle) => (
|
||||
<li key={principle.title}>
|
||||
<InfoPanel title={principle.title}>
|
||||
{principle.description}
|
||||
</InfoPanel>
|
||||
<div className="flex flex-col gap-2xl border-t border-[var(--border-default)] pt-2xl">
|
||||
{/* The draft draws the four principles straight under the rule,
|
||||
with no heading over them; the heading stays in the outline. */}
|
||||
<h2 id="wwd-principles-heading" className="sr-only">
|
||||
{copy.principlesHeading}
|
||||
</h2>
|
||||
|
||||
<ul className="grid list-none grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-4">
|
||||
{principles.map((principle) => (
|
||||
<li
|
||||
key={principle.title}
|
||||
className="sh-reveal flex flex-col gap-sm"
|
||||
>
|
||||
{/* The short accent rule the draft draws over each tile,
|
||||
drawn on from the inline start. Decorative. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="sh-bar-grow block h-[3px] w-[44px] bg-[var(--surface-accent)]"
|
||||
/>
|
||||
<h3 className="body-md-bold text-[var(--text-primary)]">
|
||||
{principle.title}
|
||||
</h3>
|
||||
{principle.description ? (
|
||||
<p className="body-sm text-[var(--text-secondary)]">
|
||||
{principle.description}
|
||||
</p>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<Prose>
|
||||
<p className="body-md text-[var(--text-secondary)]">
|
||||
{copy.principles}
|
||||
</p>
|
||||
</Prose>
|
||||
)}
|
||||
|
||||
{richTextBlocks.map((block) => (
|
||||
<Prose key={block.id}>
|
||||
<RichText markdown={block.body} />
|
||||
</Prose>
|
||||
))}
|
||||
{richTextBlocks.map((block) => (
|
||||
<Prose key={block.id}>
|
||||
<RichText markdown={block.body} />
|
||||
</Prose>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PageSection>
|
||||
|
||||
<PageSection>
|
||||
<Card.CTA
|
||||
headline={t("nav.sections.activities")}
|
||||
body={copy.lead}
|
||||
action={{
|
||||
href: sectionPath("activities", locale),
|
||||
label: t("actions.seeActivities"),
|
||||
}}
|
||||
/>
|
||||
{/* THE WAY ON TO THE ACTIVITIES */}
|
||||
<PageSection
|
||||
aria-labelledby="wwd-activities-heading"
|
||||
className="bg-[var(--surface-sunken)]"
|
||||
>
|
||||
<div
|
||||
className={`sh-reveal flex flex-wrap items-center justify-between gap-xl rounded-card border border-[var(--border-subtle)] bg-[var(--surface-raised)] p-lg md:p-xl ${ACCENT_EDGE}`}
|
||||
>
|
||||
<h2
|
||||
id="wwd-activities-heading"
|
||||
className="heading-h2 max-w-[39rem] text-[var(--text-primary)]"
|
||||
>
|
||||
{copy.activitiesCtaHeading}
|
||||
</h2>
|
||||
|
||||
<Button
|
||||
as={Link}
|
||||
to={sectionPath("activities", locale)}
|
||||
prefetch="intent"
|
||||
size="lg"
|
||||
iconEnd={<ArrowIcon />}
|
||||
>
|
||||
{t("actions.seeActivities")}
|
||||
</Button>
|
||||
</div>
|
||||
</PageSection>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user