initial commit
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
/* ============================================================================
|
||||
ROUTE COPY - Events Calendar / Applications and Opportunities
|
||||
----------------------------------------------------------------------------
|
||||
The message catalogue in ~/i18n holds UI chrome that every page shares
|
||||
(actions, filters, form labels, statuses). Page prose belongs to the page,
|
||||
and lives here.
|
||||
|
||||
Every string below is taken from the approved draft,
|
||||
docs/SHARPER_Website_Draft_Section_EN_AR.docx - section 4 "Events Calendar"
|
||||
and section 8 "Applications and Opportunities" - in both languages. Where
|
||||
the draft marks a value "TBD" (network eligibility criteria, the response
|
||||
time in weeks, the project email, activity figures) NOTHING is invented: the
|
||||
sentence either omits it or the value comes from the CMS.
|
||||
|
||||
Parity is enforced by the type: `RouteCopy` is derived from the English
|
||||
object, so a key added on one side and forgotten on the other is a compile
|
||||
error rather than a blank paragraph in production.
|
||||
========================================================================= */
|
||||
|
||||
import type { Locale } from "~/i18n";
|
||||
|
||||
const EN = {
|
||||
events: {
|
||||
/** Draft 4, EN headline. */
|
||||
heading: "Upcoming Events",
|
||||
/** Draft 4, EN standfirst. */
|
||||
intro:
|
||||
"Register for a SUQ dialogue, join a training, or track what’s coming next across Libya.",
|
||||
metaDescription:
|
||||
"Dialogues, trainings and Civil Society Law Sessions across Libya. Register for a SUQ dialogue, join a training, or track what’s coming next.",
|
||||
listLabel: "Events",
|
||||
statusFilterLabel: "Status",
|
||||
emptyHint:
|
||||
"Dialogues, trainings and sessions are published here as soon as they are scheduled.",
|
||||
unavailable:
|
||||
"The events calendar cannot be loaded right now. Please try again in a moment.",
|
||||
formatHybrid: "In person and online",
|
||||
},
|
||||
eventDetail: {
|
||||
backToList: "Back to the events calendar",
|
||||
aboutHeading: "About this event",
|
||||
detailsHeading: "Event details",
|
||||
registrationDeadlineLabel: "Registration deadline",
|
||||
registerHeading: "Register for this event",
|
||||
waitlistHeading: "Join the waitlist",
|
||||
formIntro:
|
||||
"We use these details only to manage your place and to send joining information.",
|
||||
registrationClosed: "Registration for this event is closed.",
|
||||
/** `event.registrationStatus === "not-required"`. */
|
||||
registrationNotRequired: "No registration required",
|
||||
registrationNotRequiredNote:
|
||||
"This event is open to all - no registration is needed.",
|
||||
registrationFull:
|
||||
"This event is fully booked. Join the waitlist and we will contact you if a place becomes available.",
|
||||
eventCompleted: "This event has already taken place.",
|
||||
joinOnlineLabel: "Joining link",
|
||||
addToCalendarHint:
|
||||
"Downloads an .ics file you can open in any calendar application.",
|
||||
timeZoneNote: "All times are shown in Libya time (UTC+2).",
|
||||
/**
|
||||
* `event-registration.consentGdpr` is REQUIRED in the CMS, so the reader
|
||||
* has to be asked in plain words rather than opted in silently.
|
||||
*/
|
||||
consentLabel:
|
||||
"I agree that SHARP-ER may store the details above to manage my registration for this event.",
|
||||
consentHint:
|
||||
"We keep them only for as long as the event needs, and never share them with anyone outside the project. You can ask us to delete them at any time.",
|
||||
/** Draft 4, EN confirmation screen, reused for the waitlist variant. */
|
||||
waitlistConfirmationTitle: "You’re on the waitlist.",
|
||||
waitlistConfirmationBody:
|
||||
"Thank you — {event} on {date} is fully booked, so we have added you to the waitlist. We will email you if a place becomes available.",
|
||||
},
|
||||
opportunities: {
|
||||
/** Draft 8, EN headline. */
|
||||
heading: "Get Involved",
|
||||
/** Draft 8, EN standfirst. */
|
||||
intro:
|
||||
"Whether you’re a civil society organisation or an individual, here’s how to take part in SHARP-ER.",
|
||||
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",
|
||||
pathwayCallsTitle: "Apply for a Call for Expression of Interest",
|
||||
pathwayCallsBody:
|
||||
"Open calls for CSO sub-projects and Policy-to-Rights (P2R) initiatives.",
|
||||
pathwayNetworkTitle: "Join the SHARP Network",
|
||||
pathwayNetworkBody:
|
||||
"Become part of the civil society network behind SHARP-ER: regional meetings, peer learning, and joint events.",
|
||||
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.",
|
||||
},
|
||||
/** `opportunity.opportunityType`, verbatim, with the draft's own wording. */
|
||||
opportunityTypes: {
|
||||
"call-eoi": "Call for Expression of Interest",
|
||||
"network-membership": "Network membership",
|
||||
training: "Training",
|
||||
grant: "Grant",
|
||||
other: "Opportunity",
|
||||
},
|
||||
opportunityDetail: {
|
||||
backToList: "Back to applications and opportunities",
|
||||
aboutHeading: "About this opportunity",
|
||||
detailsHeading: "Call details",
|
||||
howToApplyHeading: "How to apply",
|
||||
attachmentsHeading: "Documents",
|
||||
applyHeading: "Apply",
|
||||
statusLabel: "Status",
|
||||
typeLabel: "Type",
|
||||
opensLabel: "Opens",
|
||||
deadlineLabel: "Deadline",
|
||||
formIntro:
|
||||
"Tell us briefly why you are applying. Our team reviews every application.",
|
||||
messageHint:
|
||||
"Briefly describe your organisation and why you are applying.",
|
||||
closedNote: "This call is closed. It stays published for reference.",
|
||||
upcomingNote: "This call opens on {date}.",
|
||||
externalApplication:
|
||||
"Applications for this call are handled on an external site.",
|
||||
/**
|
||||
* Draft 8 marks the response time "[X weeks] - TBD" and the CMS has no
|
||||
* field for it, so no number is ever named.
|
||||
*/
|
||||
applicationReceivedBody:
|
||||
"Thank you for your application. Our team will review it and follow up by email.",
|
||||
},
|
||||
form: {
|
||||
/** Honeypot label - never seen by a human, read by a bot. */
|
||||
honeypotLabel: "Leave this field empty",
|
||||
submissionFailed:
|
||||
"Your details could not be submitted. Please try again in a moment.",
|
||||
},
|
||||
};
|
||||
|
||||
export type RouteCopy = typeof EN;
|
||||
|
||||
const AR: RouteCopy = {
|
||||
events: {
|
||||
heading: "الفعاليات القادمة",
|
||||
intro:
|
||||
"سجّل في حوار SUQ، أو انضم إلى تدريب، أو تابع ما هو قادم في مختلف أنحاء ليبيا.",
|
||||
metaDescription:
|
||||
"حوارات وتدريبات وجلسات قانون المجتمع المدني في مختلف أنحاء ليبيا. سجّل في حوار SUQ، أو انضم إلى تدريب، أو تابع ما هو قادم.",
|
||||
listLabel: "الفعاليات",
|
||||
statusFilterLabel: "الحالة",
|
||||
emptyHint: "تُنشر هنا الحوارات والتدريبات والجلسات فور تحديد مواعيدها.",
|
||||
unavailable:
|
||||
"تعذّر تحميل تقويم الفعاليات في الوقت الحالي. يرجى المحاولة مرة أخرى بعد قليل.",
|
||||
formatHybrid: "حضوري وعبر الإنترنت",
|
||||
},
|
||||
eventDetail: {
|
||||
backToList: "العودة إلى تقويم الفعاليات",
|
||||
aboutHeading: "عن الفعالية",
|
||||
detailsHeading: "تفاصيل الفعالية",
|
||||
registrationDeadlineLabel: "الموعد النهائي للتسجيل",
|
||||
registerHeading: "سجّل في هذه الفعالية",
|
||||
waitlistHeading: "انضم إلى قائمة الانتظار",
|
||||
formIntro:
|
||||
"نستخدم هذه البيانات فقط لإدارة مكانك وإرسال تفاصيل الانضمام.",
|
||||
registrationClosed: "أُغلق التسجيل في هذه الفعالية.",
|
||||
registrationNotRequired: "لا يلزم التسجيل",
|
||||
registrationNotRequiredNote:
|
||||
"هذه الفعالية مفتوحة للجميع، ولا حاجة إلى التسجيل.",
|
||||
registrationFull:
|
||||
"اكتمل العدد في هذه الفعالية. انضم إلى قائمة الانتظار وسنتواصل معك في حال توفّر مكان.",
|
||||
eventCompleted: "انتهت هذه الفعالية.",
|
||||
joinOnlineLabel: "رابط الانضمام",
|
||||
addToCalendarHint: "يُنزّل ملف .ics يمكن فتحه في أي تطبيق تقويم.",
|
||||
timeZoneNote: "جميع الأوقات معروضة بتوقيت ليبيا (UTC+2).",
|
||||
consentLabel:
|
||||
"أوافق على أن يحتفظ مشروع SHARP-ER بالبيانات أعلاه لإدارة تسجيلي في هذه الفعالية.",
|
||||
consentHint:
|
||||
"نحتفظ بها فقط للمدة التي تتطلبها الفعالية، ولا نشاركها مع أي جهة خارج المشروع. ويمكنك أن تطلب حذفها في أي وقت.",
|
||||
waitlistConfirmationTitle: "تمت إضافتك إلى قائمة الانتظار.",
|
||||
waitlistConfirmationBody:
|
||||
"شكرًا لك — اكتمل العدد في {event} بتاريخ {date}، لذلك أضفناك إلى قائمة الانتظار. سنراسلك عبر البريد الإلكتروني في حال توفّر مكان.",
|
||||
},
|
||||
opportunities: {
|
||||
heading: "شارك معنا",
|
||||
intro:
|
||||
"سواء كنتم منظمة مجتمع مدني أو فردًا، إليكم كيفية المشاركة في SHARP-ER.",
|
||||
metaDescription:
|
||||
"دعوات للتعبير عن الاهتمام، وعضوية شبكة SHARP، وجلسات قادمة — كيف تشارك منظمات المجتمع المدني والأفراد في SHARP-ER.",
|
||||
pathwaysHeading: "طرق المشاركة",
|
||||
pathwayCallsTitle: "التقديم على دعوة للتعبير عن الاهتمام",
|
||||
pathwayCallsBody:
|
||||
"دعوات مفتوحة للمشاريع الفرعية لمنظمات المجتمع المدني ومبادرات «من السياسة إلى الحقوق» (P2R).",
|
||||
pathwayNetworkTitle: "انضم إلى شبكة SHARP",
|
||||
pathwayNetworkBody:
|
||||
"كن جزءًا من شبكة المجتمع المدني وراء SHARP-ER: اجتماعات إقليمية، وتعلّم بين الأقران، وفعاليات مشتركة.",
|
||||
pathwaySessionsTitle: "سجّل في حوار SUQ أو تدريب",
|
||||
pathwaySessionsBody:
|
||||
"شارك في حوار قادم من نوع «ابحث، افهم، اسأل» أو في تدريب على تيسير الحوار.",
|
||||
listHeading: "الدعوات الحالية",
|
||||
listLabel: "الدعوات والفرص",
|
||||
statusFilterLabel: "الحالة",
|
||||
empty: "لا توجد دعوات أو فرص لعرضها حتى الآن.",
|
||||
emptyHint: "تُنشر هنا دعوات التعبير عن الاهتمام فور فتحها.",
|
||||
unavailable:
|
||||
"تعذّر تحميل الدعوات والفرص في الوقت الحالي. يرجى المحاولة مرة أخرى بعد قليل.",
|
||||
},
|
||||
opportunityTypes: {
|
||||
"call-eoi": "دعوة للتعبير عن الاهتمام",
|
||||
"network-membership": "عضوية الشبكة",
|
||||
training: "تدريب",
|
||||
grant: "منحة",
|
||||
other: "فرصة",
|
||||
},
|
||||
opportunityDetail: {
|
||||
backToList: "العودة إلى طلبات وفرص المشاركة",
|
||||
aboutHeading: "عن هذه الفرصة",
|
||||
detailsHeading: "تفاصيل الدعوة",
|
||||
howToApplyHeading: "كيفية التقديم",
|
||||
attachmentsHeading: "المستندات",
|
||||
applyHeading: "قدّم طلبك",
|
||||
statusLabel: "الحالة",
|
||||
typeLabel: "النوع",
|
||||
opensLabel: "تاريخ الفتح",
|
||||
deadlineLabel: "الموعد النهائي",
|
||||
formIntro: "أخبرنا باختصار عن سبب تقديمك. يراجع فريقنا كل طلب.",
|
||||
messageHint: "صف باختصار منظمتك وسبب تقديمك.",
|
||||
closedNote: "أُغلقت هذه الدعوة، وتبقى منشورة للاطّلاع.",
|
||||
upcomingNote: "تُفتح هذه الدعوة بتاريخ {date}.",
|
||||
externalApplication: "يتم التقديم على هذه الدعوة عبر موقع خارجي.",
|
||||
applicationReceivedBody:
|
||||
"شكرًا لتقديم طلبك. سيراجعه فريقنا ويتواصل معك عبر البريد الإلكتروني.",
|
||||
},
|
||||
form: {
|
||||
honeypotLabel: "اترك هذا الحقل فارغًا",
|
||||
submissionFailed:
|
||||
"تعذّر إرسال بياناتك. يرجى المحاولة مرة أخرى بعد قليل.",
|
||||
},
|
||||
};
|
||||
|
||||
const COPY: Readonly<Record<Locale, RouteCopy>> = { en: EN, ar: AR };
|
||||
|
||||
/** The route copy for a locale. Falls back to English, never to "". */
|
||||
export function getRouteCopy(locale: Locale): RouteCopy {
|
||||
return COPY[locale] ?? EN;
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/* ============================================================================
|
||||
EVENTS - Strapi access. SERVER ONLY.
|
||||
----------------------------------------------------------------------------
|
||||
Maps the CMS entity onto the plain view model in ./events.ts, so the route
|
||||
component never sees a Strapi shape and the loader payload stays small and
|
||||
serialisable. The fault-tolerant fetch helpers come from ../_shared/cms, so
|
||||
this section degrades exactly like Resources, Knowledge Hub and News do.
|
||||
|
||||
CONTENT TYPE api::event.event (collection, i18n enabled, draft & publish)
|
||||
The field names below are copied from
|
||||
apps/cms/src/api/event/content-types/event/schema.json - they are the
|
||||
contract, and getting one wrong is a 400 from Strapi, not a blank field.
|
||||
|
||||
slug uid, required, targets `title`
|
||||
title string, required
|
||||
description text (max 500) - the card excerpt
|
||||
body richtext - the detail page body
|
||||
eventType enum suq-dialogue | training | law-session |
|
||||
network-meeting | launch | other
|
||||
startDateTime datetime, required
|
||||
endDateTime datetime
|
||||
timezone string, default "Africa/Tripoli"
|
||||
format enum in-person | online | hybrid
|
||||
venue string - "Bab Al-Bahr Hotel". There is NO
|
||||
`city` field.
|
||||
onlineUrl string - joining link for online / hybrid
|
||||
region RELATION -> api::region.region (populate it; a
|
||||
region is an entry, not a string)
|
||||
registrationStatus enum open | closed | full | waitlist | not-required
|
||||
capacity integer
|
||||
waitlistEnabled boolean
|
||||
registrationDeadline datetime
|
||||
recapUrl string - "Read recap" for a past event
|
||||
coverImage media (single image)
|
||||
|
||||
There is no external "registrationUrl" on this type: registration is either
|
||||
handled by the on-site form or it is not open.
|
||||
|
||||
WHY THE WHOLE SET IS FETCHED
|
||||
Upcoming / Ongoing / Completed are derived from the dates (see ./events.ts)
|
||||
and cannot be expressed as a Strapi filter without duplicating the rule in
|
||||
two places, where it would drift. The calendar of a two-year project is
|
||||
small, so one request brings it back and the filtering, sorting and paging
|
||||
happen in memory. EVENTS_FETCH_LIMIT is the ceiling; raise it or move to
|
||||
date-range queries if the project ever outgrows it.
|
||||
========================================================================= */
|
||||
|
||||
import type { Locale } from "~/i18n";
|
||||
import type { StrapiEntity, StrapiMedia } from "~/lib/strapi";
|
||||
|
||||
import {
|
||||
collectLocalizedSlugs,
|
||||
LOCALIZATION_POPULATE,
|
||||
type CmsLocalizable,
|
||||
type LocalizedSlugs,
|
||||
} from "../_shared/alternates";
|
||||
import {
|
||||
fetchBySlug,
|
||||
fetchCollection,
|
||||
toImage,
|
||||
type CmsRichText,
|
||||
type CmsTaxonomy,
|
||||
} from "../_shared/cms";
|
||||
import {
|
||||
deriveTimeStatus,
|
||||
EVENT_REGISTRATION_STATUSES,
|
||||
isDateOnly,
|
||||
type EventCardData,
|
||||
type EventDetailData,
|
||||
type EventFormat,
|
||||
type EventRegistrationStatus,
|
||||
} from "./events";
|
||||
|
||||
export const EVENTS_RESOURCE = "events";
|
||||
|
||||
/** Hard ceiling on one listing request. */
|
||||
export const EVENTS_FETCH_LIMIT = 200;
|
||||
|
||||
interface StrapiEvent extends StrapiEntity, CmsLocalizable {
|
||||
readonly slug: string;
|
||||
readonly title: string;
|
||||
readonly description?: string | null;
|
||||
readonly body?: CmsRichText;
|
||||
readonly startDateTime: string;
|
||||
readonly endDateTime?: string | null;
|
||||
readonly format?: EventFormat | null;
|
||||
readonly venue?: string | null;
|
||||
readonly region?: CmsTaxonomy | null;
|
||||
readonly onlineUrl?: string | null;
|
||||
readonly registrationStatus?: EventRegistrationStatus | null;
|
||||
readonly registrationDeadline?: string | null;
|
||||
readonly waitlistEnabled?: boolean | null;
|
||||
readonly recapUrl?: string | null;
|
||||
readonly coverImage?: StrapiMedia | null;
|
||||
}
|
||||
|
||||
const FORMATS: readonly EventFormat[] = ["in-person", "online", "hybrid"];
|
||||
|
||||
/** What one listing request populates. Named so the two queries cannot drift. */
|
||||
const LIST_POPULATE = {
|
||||
coverImage: {
|
||||
fields: ["url", "alternativeText", "width", "height", "formats"],
|
||||
},
|
||||
region: { fields: ["name", "slug"] },
|
||||
} as const;
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
MAPPING
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
/** Trimmed, or undefined - so an empty CMS string never renders as a gap. */
|
||||
function text(value: string | null | undefined): string | undefined {
|
||||
const trimmed = typeof value === "string" ? value.trim() : "";
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function oneOf<T extends string>(
|
||||
value: unknown,
|
||||
allowed: readonly T[],
|
||||
): T | undefined {
|
||||
return typeof value === "string" && (allowed as readonly string[]).includes(value)
|
||||
? (value as T)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function toCard(entry: StrapiEvent, now: Date): EventCardData {
|
||||
const start = entry.startDateTime;
|
||||
const end = text(entry.endDateTime);
|
||||
const allDay = isDateOnly(start);
|
||||
|
||||
return {
|
||||
slug: entry.slug,
|
||||
title: entry.title,
|
||||
summary: text(entry.description),
|
||||
start,
|
||||
end,
|
||||
allDay,
|
||||
format: oneOf(entry.format, FORMATS),
|
||||
place: text(entry.venue),
|
||||
region: text(entry.region?.name),
|
||||
/* The CMS default is "not-required", not "open": an event nobody has
|
||||
opened registration for must not invite one. */
|
||||
registrationStatus:
|
||||
oneOf(entry.registrationStatus, EVENT_REGISTRATION_STATUSES) ??
|
||||
"not-required",
|
||||
timeStatus: deriveTimeStatus({ start, end, allDay }, now),
|
||||
recapUrl: text(entry.recapUrl),
|
||||
image: toImage(entry.coverImage, "medium"),
|
||||
};
|
||||
}
|
||||
|
||||
function toDetail(entry: StrapiEvent, now: Date): EventDetailData {
|
||||
return {
|
||||
...toCard(entry, now),
|
||||
documentId: entry.documentId,
|
||||
description: entry.body ?? undefined,
|
||||
onlineUrl: text(entry.onlineUrl),
|
||||
registrationDeadline: text(entry.registrationDeadline),
|
||||
venue: text(entry.venue),
|
||||
waitlistEnabled: entry.waitlistEnabled === true,
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
QUERIES
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
export interface EventListResult {
|
||||
readonly events: EventCardData[];
|
||||
/**
|
||||
* False when the collection could not be read at all. The listing then
|
||||
* renders its empty state with a notice rather than a 500 - an unreachable
|
||||
* calendar is not a broken page, and the rest of the site still works.
|
||||
*/
|
||||
readonly available: boolean;
|
||||
}
|
||||
|
||||
/** Every published event for a locale, mapped against a single "now". */
|
||||
export async function fetchEvents(
|
||||
locale: Locale,
|
||||
now: Date = new Date(),
|
||||
): Promise<EventListResult> {
|
||||
const result = await fetchCollection<StrapiEvent>(EVENTS_RESOURCE, {
|
||||
locale,
|
||||
sort: ["startDateTime:asc"],
|
||||
populate: LIST_POPULATE,
|
||||
pagination: { page: 1, pageSize: EVENTS_FETCH_LIMIT },
|
||||
});
|
||||
|
||||
return {
|
||||
events: result.entries
|
||||
.filter((entry) => Boolean(entry?.slug && entry.startDateTime))
|
||||
.map((entry) => toCard(entry, now)),
|
||||
available: result.available,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One event by slug, or null when the CMS has no such entry.
|
||||
*
|
||||
* A detail page does not degrade the way the listing does: a missing entry is
|
||||
* a genuine 404, and a CMS outage propagates as a 500 rather than telling a
|
||||
* crawler that a real event does not exist.
|
||||
*/
|
||||
export interface EventDetailResult {
|
||||
readonly event: EventDetailData;
|
||||
/** The slug of this event in every language the CMS holds it in. */
|
||||
readonly alternates: LocalizedSlugs;
|
||||
}
|
||||
|
||||
export async function fetchEvent(
|
||||
slug: string,
|
||||
locale: Locale,
|
||||
now: Date = new Date(),
|
||||
): Promise<EventDetailResult | null> {
|
||||
const entry = await fetchBySlug<StrapiEvent>(EVENTS_RESOURCE, slug, {
|
||||
locale,
|
||||
populate: {
|
||||
...LIST_POPULATE,
|
||||
// The Arabic translation has its own slug - see ../_shared/alternates.ts.
|
||||
localizations: LOCALIZATION_POPULATE,
|
||||
},
|
||||
});
|
||||
|
||||
if (!entry) return null;
|
||||
|
||||
return {
|
||||
event: toDetail(entry, now),
|
||||
alternates: collectLocalizedSlugs(locale, entry.slug, entry.localizations),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
/* ============================================================================
|
||||
EVENTS - view model, status derivation, formatting
|
||||
----------------------------------------------------------------------------
|
||||
PURE. No server import, so the same helpers run in the loader and in the
|
||||
component. Everything that talks to Strapi lives in ./events.server.ts.
|
||||
|
||||
The three calendar states the draft asks for (Upcoming / Ongoing /
|
||||
Completed, draft section 4) are DERIVED from the dates rather than stored,
|
||||
so an editor cannot forget to move an event to "Completed" the morning
|
||||
after it happened. The registration state (Open / Closed / Full) is a real
|
||||
editorial decision and does come from the CMS.
|
||||
========================================================================= */
|
||||
|
||||
import {
|
||||
formatDate,
|
||||
formatDateRange,
|
||||
translate,
|
||||
type Locale,
|
||||
type TranslationKey,
|
||||
} from "~/i18n";
|
||||
|
||||
import type { CmsImage, CmsRichText } from "../_shared/cms";
|
||||
import { getRouteCopy } from "./copy";
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
TIME ZONE
|
||||
The project is nationwide Libya, which sits on UTC+2 all year (no DST since
|
||||
2013). Pinning the zone keeps the server-rendered string and the hydrated
|
||||
one identical whatever the reader's device is set to, and shows the local
|
||||
time of the room the event is held in.
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
export const EVENT_TIME_ZONE = "Africa/Tripoli";
|
||||
const EVENT_UTC_OFFSET = "+02:00";
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
TYPES
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
/** How an event is attended. Mirrors the CMS enumeration. */
|
||||
export type EventFormat = "in-person" | "online" | "hybrid";
|
||||
|
||||
/**
|
||||
* Editorial registration state. The five values are the CMS enumeration
|
||||
* `event.registrationStatus`, verbatim - draft 4 names only the first three,
|
||||
* but an editor can also mark a full event as taking a waitlist, or say that
|
||||
* an event needs no registration at all.
|
||||
*/
|
||||
export type EventRegistrationStatus =
|
||||
| "open"
|
||||
| "closed"
|
||||
| "full"
|
||||
| "waitlist"
|
||||
| "not-required";
|
||||
|
||||
export const EVENT_REGISTRATION_STATUSES = [
|
||||
"open",
|
||||
"closed",
|
||||
"full",
|
||||
"waitlist",
|
||||
"not-required",
|
||||
] as const satisfies readonly EventRegistrationStatus[];
|
||||
|
||||
/** Derived from the dates, never stored. */
|
||||
export type EventTimeStatus = "upcoming" | "ongoing" | "completed";
|
||||
|
||||
/** The filter tabs: All - Upcoming - Ongoing - Completed (draft 4). */
|
||||
export type EventStatusFilter = "all" | EventTimeStatus;
|
||||
|
||||
export const EVENT_STATUS_FILTERS: readonly EventStatusFilter[] = [
|
||||
"all",
|
||||
"upcoming",
|
||||
"ongoing",
|
||||
"completed",
|
||||
];
|
||||
|
||||
export function isEventStatusFilter(value: unknown): value is EventStatusFilter {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(EVENT_STATUS_FILTERS as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
/** What a card needs. Plain and serialisable - it crosses the loader boundary. */
|
||||
export interface EventCardData {
|
||||
readonly slug: string;
|
||||
readonly title: string;
|
||||
readonly summary?: string;
|
||||
/** ISO 8601. Date-only when `allDay`. */
|
||||
readonly start: string;
|
||||
readonly end?: string;
|
||||
readonly allDay: boolean;
|
||||
readonly format?: EventFormat;
|
||||
/** The venue. Absent for an online-only event - the CMS has no `city`. */
|
||||
readonly place?: string;
|
||||
/** Name of the related region entry, not a free-text string. */
|
||||
readonly region?: string;
|
||||
readonly registrationStatus: EventRegistrationStatus;
|
||||
readonly timeStatus: EventTimeStatus;
|
||||
/** Set on a past event that has a write-up - drives the "Read recap" label. */
|
||||
readonly recapUrl?: string;
|
||||
readonly image?: CmsImage;
|
||||
}
|
||||
|
||||
/** Everything the detail page renders. */
|
||||
export interface EventDetailData extends EventCardData {
|
||||
readonly documentId: string;
|
||||
/** The long body. There is no external registration portal field in the CMS. */
|
||||
readonly description?: CmsRichText;
|
||||
readonly onlineUrl?: string;
|
||||
readonly registrationDeadline?: string;
|
||||
readonly venue?: string;
|
||||
/** Editorial: the CMS offers a waitlist once the event is full. */
|
||||
readonly waitlistEnabled: boolean;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
TIME
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/** True for a CMS `date` field ("2026-03-12"), false for a `datetime`. */
|
||||
export function isDateOnly(value: string | null | undefined): boolean {
|
||||
return typeof value === "string" && DATE_ONLY.test(value.trim());
|
||||
}
|
||||
|
||||
/** "2026-03-12" for an instant, read in Libyan local time. */
|
||||
function libyanCalendarDay(value: Date): string {
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: EVENT_TIME_ZONE,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
/** The last instant of the Libyan calendar day that contains `value`. */
|
||||
function endOfLibyanDay(value: Date): Date {
|
||||
return new Date(
|
||||
`${libyanCalendarDay(value)}T23:59:59.999${EVENT_UTC_OFFSET}`,
|
||||
);
|
||||
}
|
||||
|
||||
interface EventInterval {
|
||||
readonly start: string;
|
||||
readonly end?: string;
|
||||
readonly allDay: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* When an event is over.
|
||||
* - explicit `end` on a timed event -> that instant
|
||||
* - all-day (with or without `end`) -> the end of the last day
|
||||
* - timed event with no `end` -> the end of its own day, so an
|
||||
* evening dialogue reads "Ongoing" for the rest of that day and
|
||||
* "Completed" from midnight, rather than the instant it starts.
|
||||
*/
|
||||
export function eventEndsAt(event: EventInterval): Date | null {
|
||||
const start = new Date(event.start);
|
||||
if (Number.isNaN(start.getTime())) return null;
|
||||
|
||||
if (event.allDay) {
|
||||
const last = event.end ? new Date(event.end) : start;
|
||||
return endOfLibyanDay(Number.isNaN(last.getTime()) ? start : last);
|
||||
}
|
||||
|
||||
if (event.end) {
|
||||
const end = new Date(event.end);
|
||||
if (!Number.isNaN(end.getTime())) return end;
|
||||
}
|
||||
|
||||
return endOfLibyanDay(start);
|
||||
}
|
||||
|
||||
/** Upcoming / Ongoing / Completed for an instant in time. */
|
||||
export function deriveTimeStatus(
|
||||
event: EventInterval,
|
||||
now: Date = new Date(),
|
||||
): EventTimeStatus {
|
||||
const start = new Date(event.start);
|
||||
if (Number.isNaN(start.getTime())) return "upcoming";
|
||||
|
||||
const end = eventEndsAt(event) ?? start;
|
||||
const at = now.getTime();
|
||||
|
||||
if (at < start.getTime()) return "upcoming";
|
||||
return at <= end.getTime() ? "ongoing" : "completed";
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
FORMATTING
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* "12 March 2026, 14:00" / "12–14 March 2026" for an all-day range.
|
||||
* `withTimeZone` adds the zone abbreviation - use it on the detail page,
|
||||
* where the reader is deciding whether they can attend.
|
||||
*/
|
||||
export function formatEventWhen(
|
||||
event: EventInterval,
|
||||
locale: Locale,
|
||||
options: { readonly withTimeZone?: boolean } = {},
|
||||
): string {
|
||||
if (event.allDay) {
|
||||
// A date-only value parses as UTC midnight, so it is read back in UTC to
|
||||
// land on the day the editor typed.
|
||||
return formatDateRange(event.start, event.end ?? null, locale, {
|
||||
dateStyle: "long",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
}
|
||||
|
||||
// `dateStyle`/`timeStyle` cannot be combined with `timeZoneName`, so the
|
||||
// components are spelled out.
|
||||
const parts: Intl.DateTimeFormatOptions = {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: EVENT_TIME_ZONE,
|
||||
...(options.withTimeZone ? { timeZoneName: "short" } : {}),
|
||||
};
|
||||
|
||||
if (event.end && !isDateOnly(event.end)) {
|
||||
return formatDateRange(event.start, event.end, locale, parts);
|
||||
}
|
||||
|
||||
return formatDate(event.start, locale, parts);
|
||||
}
|
||||
|
||||
/** The short date used in a confirmation sentence ("… on 12 March 2026"). */
|
||||
export function formatEventDay(
|
||||
event: EventInterval,
|
||||
locale: Locale,
|
||||
): string {
|
||||
return formatDate(event.start, locale, {
|
||||
dateStyle: "long",
|
||||
timeZone: event.allDay ? "UTC" : EVENT_TIME_ZONE,
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
LIST OPERATIONS
|
||||
The calendar of a two-year project is small enough to sort, filter and
|
||||
paginate in memory, which keeps the derived Upcoming/Ongoing/Completed
|
||||
states honest - Strapi cannot express them without duplicating the rule in
|
||||
a query. `EVENTS_FETCH_LIMIT` in ./events.server.ts is the ceiling.
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
function startTime(event: EventCardData): number {
|
||||
const value = new Date(event.start).getTime();
|
||||
return Number.isNaN(value) ? 0 : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upcoming and ongoing events first, soonest at the top; completed ones after
|
||||
* them, most recent first. Filtering to a single tab keeps that tab's order.
|
||||
*/
|
||||
export function sortEvents(
|
||||
events: readonly EventCardData[],
|
||||
): EventCardData[] {
|
||||
return [...events].sort((a, b) => {
|
||||
const aPast = a.timeStatus === "completed";
|
||||
const bPast = b.timeStatus === "completed";
|
||||
if (aPast !== bPast) return aPast ? 1 : -1;
|
||||
return aPast
|
||||
? startTime(b) - startTime(a)
|
||||
: startTime(a) - startTime(b);
|
||||
});
|
||||
}
|
||||
|
||||
export interface EventFilters {
|
||||
readonly status: EventStatusFilter;
|
||||
readonly region: string | null;
|
||||
}
|
||||
|
||||
export function filterEvents(
|
||||
events: readonly EventCardData[],
|
||||
filters: EventFilters,
|
||||
): EventCardData[] {
|
||||
return events.filter((event) => {
|
||||
if (filters.status !== "all" && event.timeStatus !== filters.status) {
|
||||
return false;
|
||||
}
|
||||
if (filters.region && event.region !== filters.region) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** Every region that actually has an event, in the reader's collation order. */
|
||||
export function collectRegions(
|
||||
events: readonly EventCardData[],
|
||||
locale: Locale,
|
||||
): string[] {
|
||||
const regions = new Set<string>();
|
||||
for (const event of events) {
|
||||
if (event.region) regions.add(event.region);
|
||||
}
|
||||
return [...regions].sort((a, b) => a.localeCompare(b, locale));
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
REGISTRATION
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* True when the on-site form should be shown at all. "not-required" means the
|
||||
* reader simply turns up, so there is nothing to submit; "closed" speaks for
|
||||
* itself; "full" and "waitlist" still take names for the waiting list.
|
||||
*/
|
||||
export function isRegistrationOpen(event: EventCardData): boolean {
|
||||
return (
|
||||
event.timeStatus !== "completed" &&
|
||||
(event.registrationStatus === "open" ||
|
||||
event.registrationStatus === "full" ||
|
||||
event.registrationStatus === "waitlist")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The label of the card's call to action. Draft section 4 names all four:
|
||||
* "Register", "Apply", "Read recap" and, for an event whose registration has
|
||||
* not opened or has closed, plain "View details".
|
||||
*/
|
||||
export function eventActionKey(event: EventCardData): TranslationKey {
|
||||
if (event.timeStatus === "completed") {
|
||||
return event.recapUrl ? "actions.readRecap" : "actions.viewDetails";
|
||||
}
|
||||
|
||||
if (
|
||||
event.registrationStatus === "full" ||
|
||||
event.registrationStatus === "waitlist"
|
||||
) {
|
||||
return "events.joinWaitlist";
|
||||
}
|
||||
if (
|
||||
event.registrationStatus === "closed" ||
|
||||
event.registrationStatus === "not-required"
|
||||
) {
|
||||
return "actions.viewDetails";
|
||||
}
|
||||
return "actions.register";
|
||||
}
|
||||
|
||||
/** Catalogue key for the Upcoming / Ongoing / Completed badge. */
|
||||
export function eventTimeStatusKey(status: EventTimeStatus): TranslationKey {
|
||||
return status === "upcoming"
|
||||
? "filters.upcoming"
|
||||
: status === "ongoing"
|
||||
? "filters.ongoing"
|
||||
: "filters.completed";
|
||||
}
|
||||
|
||||
/**
|
||||
* The registration badge label.
|
||||
*
|
||||
* Not a catalogue KEY on its own: the shared catalogue names Open / Closed /
|
||||
* Full, and `event.registrationStatus` has two states beyond those, so
|
||||
* "not-required" comes from this section's copy in ./copy.ts and "waitlist"
|
||||
* reads "Full" - which is what a reader needs to know about the places left.
|
||||
*/
|
||||
export function eventRegistrationLabel(
|
||||
status: EventRegistrationStatus,
|
||||
locale: Locale,
|
||||
): string {
|
||||
if (status === "not-required") {
|
||||
return getRouteCopy(locale).eventDetail.registrationNotRequired;
|
||||
}
|
||||
|
||||
const key: TranslationKey =
|
||||
status === "open"
|
||||
? "events.statusOpen"
|
||||
: status === "closed"
|
||||
? "events.statusClosed"
|
||||
: "events.statusFull";
|
||||
|
||||
return translate(locale, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a submission joins the waitlist rather than taking a place.
|
||||
*
|
||||
* KNOWN SERVER-SIDE LIMITATION: the CMS middleware
|
||||
* (apps/cms/src/api/event-registration/middlewares/sanitize-submission.ts)
|
||||
* hard-sets `registrationState: "confirmed"` on every create and the field is
|
||||
* not in the client whitelist, so this intent CANNOT be transmitted. The page
|
||||
* tells the reader the truth about the EVENT (it is full, this is a waitlist
|
||||
* request); the stored row still says "confirmed" until that middleware is
|
||||
* fixed, and staff triage it. Nothing here fakes a waitlist flag the API
|
||||
* would reject.
|
||||
*/
|
||||
export function isWaitlisted(event: EventCardData): boolean {
|
||||
return (
|
||||
event.registrationStatus === "full" ||
|
||||
event.registrationStatus === "waitlist"
|
||||
);
|
||||
}
|
||||
|
||||
/** Days until an event starts - used only to decide "registers soon" copy. */
|
||||
export function daysUntil(event: EventInterval, now: Date = new Date()): number {
|
||||
const start = new Date(event.start).getTime();
|
||||
if (Number.isNaN(start)) return 0;
|
||||
return Math.ceil((start - now.getTime()) / MS_PER_DAY);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/* ============================================================================
|
||||
FORM PARSING AND VALIDATION - pure
|
||||
----------------------------------------------------------------------------
|
||||
Actions validate, components render. The action returns error CODES, never
|
||||
sentences, so the same result is correct on /en and /ar and the message
|
||||
catalogue stays the only place a wording lives.
|
||||
|
||||
Both forms work without JavaScript: they are plain <Form method="post">
|
||||
submissions, so validation has to be complete on the server. There is no
|
||||
client mirror of these rules to drift out of sync.
|
||||
========================================================================= */
|
||||
|
||||
import type { TranslationKey } from "~/i18n";
|
||||
|
||||
/**
|
||||
* The failures a reader can act on. Codes, not sentences.
|
||||
*
|
||||
* `consent` is its own code because "This field is required." is the wrong
|
||||
* sentence for an unticked checkbox: an unticked box is a decision, not an
|
||||
* omission, so it gets its own wording.
|
||||
*/
|
||||
export type FieldErrorCode = "required" | "email" | "consent";
|
||||
|
||||
export type FieldErrors = Readonly<Record<string, FieldErrorCode>>;
|
||||
|
||||
/**
|
||||
* Message catalogue key for each code - TOTAL over `FieldErrorCode`, so a new
|
||||
* code cannot be added without giving it a sentence in both languages. The
|
||||
* catalogue is the only place a wording lives (see the header), which is why
|
||||
* `consent` resolves here and not out of this section's own copy.
|
||||
*/
|
||||
export const FIELD_ERROR_KEYS: Readonly<
|
||||
Record<FieldErrorCode, TranslationKey>
|
||||
> = {
|
||||
required: "form.errorRequired",
|
||||
email: "form.errorEmail",
|
||||
consent: "form.errorConsent",
|
||||
};
|
||||
|
||||
/**
|
||||
* A field no human fills in. A bot that fills every input gets a 200 and an
|
||||
* acknowledgement, and nothing is written to the CMS.
|
||||
*/
|
||||
export const HONEYPOT_FIELD = "website";
|
||||
|
||||
/* Deliberately permissive: one @, no spaces, a dot in the domain. Anything
|
||||
stricter rejects valid addresses, and only a delivered email proves one. */
|
||||
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
|
||||
|
||||
/** Upper bounds, applied by truncation rather than by an error message. */
|
||||
const LIMITS = {
|
||||
name: 120,
|
||||
organisation: 160,
|
||||
email: 254,
|
||||
phone: 40,
|
||||
message: 4000,
|
||||
} as const;
|
||||
|
||||
function field(form: FormData, name: string, limit: number): string {
|
||||
const value = form.get(name);
|
||||
return typeof value === "string" ? value.trim().slice(0, limit) : "";
|
||||
}
|
||||
|
||||
export function isHoneypotFilled(form: FormData): boolean {
|
||||
const value = form.get(HONEYPOT_FIELD);
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
EVENT REGISTRATION - draft 4
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
export interface RegistrationValues {
|
||||
readonly fullName: string;
|
||||
readonly email: string;
|
||||
readonly organisation: string;
|
||||
readonly phone: string;
|
||||
/**
|
||||
* `event-registration.consentGdpr` is REQUIRED in the CMS: a submission
|
||||
* without it is rejected with a 400, so the checkbox is a real, labelled
|
||||
* control on the form and a real validation rule here - never a hidden
|
||||
* input that consents on the reader's behalf.
|
||||
*/
|
||||
readonly consentGdpr: boolean;
|
||||
}
|
||||
|
||||
/** The name of the consent checkbox, shared by the form and the validator. */
|
||||
export const CONSENT_FIELD = "consentGdpr";
|
||||
|
||||
/** A checkbox is either absent or carries its value ("on" by default). */
|
||||
function checkbox(form: FormData, name: string): boolean {
|
||||
const value = form.get(name);
|
||||
return typeof value === "string" && value.length > 0 && value !== "false";
|
||||
}
|
||||
|
||||
export interface Validated<T> {
|
||||
readonly values: T;
|
||||
readonly errors: FieldErrors;
|
||||
readonly valid: boolean;
|
||||
}
|
||||
|
||||
export function validateRegistration(
|
||||
form: FormData,
|
||||
): Validated<RegistrationValues> {
|
||||
const values: RegistrationValues = {
|
||||
fullName: field(form, "fullName", LIMITS.name),
|
||||
email: field(form, "email", LIMITS.email),
|
||||
organisation: field(form, "organisation", LIMITS.organisation),
|
||||
phone: field(form, "phone", LIMITS.phone),
|
||||
consentGdpr: checkbox(form, CONSENT_FIELD),
|
||||
};
|
||||
|
||||
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.consentGdpr) errors[CONSENT_FIELD] = "consent";
|
||||
|
||||
return { values, errors, valid: Object.keys(errors).length === 0 };
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
OPPORTUNITY APPLICATION - draft 8
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
export interface ApplicationValues {
|
||||
readonly fullName: string;
|
||||
readonly email: string;
|
||||
readonly organisation: string;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
export function validateApplication(
|
||||
form: FormData,
|
||||
): Validated<ApplicationValues> {
|
||||
const values: ApplicationValues = {
|
||||
fullName: field(form, "fullName", LIMITS.name),
|
||||
email: field(form, "email", LIMITS.email),
|
||||
organisation: field(form, "organisation", LIMITS.organisation),
|
||||
message: field(form, "message", 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.message) errors.message = "required";
|
||||
|
||||
return { values, errors, valid: Object.keys(errors).length === 0 };
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/* ============================================================================
|
||||
iCALENDAR (RFC 5545)
|
||||
----------------------------------------------------------------------------
|
||||
Builds the file behind "Add to calendar". Pure, dependency-free and written
|
||||
against the spec rather than against one client's tolerance:
|
||||
|
||||
* CRLF line endings, and content lines folded at 75 OCTETS - folded on
|
||||
character boundaries, so an Arabic title (2 bytes per letter) is never
|
||||
cut in half;
|
||||
* TEXT values escaped for \ ; , and newlines;
|
||||
* timed events written as UTC (`...Z`), all-day events as VALUE=DATE with
|
||||
a DTEND on the day AFTER the last day, which is what "exclusive end"
|
||||
means in the spec and what every calendar client expects;
|
||||
* a stable UID, so re-importing an updated file replaces the entry
|
||||
instead of duplicating it.
|
||||
|
||||
No ORGANIZER property: it requires a real mailto: address, and the project
|
||||
inbox is still marked TBD in the draft copy. An invented one would end up in
|
||||
readers' calendars.
|
||||
========================================================================= */
|
||||
|
||||
const CRLF = "\r\n";
|
||||
|
||||
/** RFC 5545 3.1: a content line is at most 75 octets, excluding the CRLF. */
|
||||
const MAX_OCTETS = 75;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
export type IcsStatus = "CONFIRMED" | "TENTATIVE" | "CANCELLED";
|
||||
|
||||
export interface IcsEvent {
|
||||
/** Globally unique and STABLE for this event. */
|
||||
readonly uid: string;
|
||||
/** ISO 8601. */
|
||||
readonly start: string;
|
||||
readonly end?: string | undefined;
|
||||
/** Write the all-day form (VALUE=DATE) instead of a timed one. */
|
||||
readonly allDay?: boolean;
|
||||
readonly summary: string;
|
||||
readonly description?: string | undefined;
|
||||
readonly location?: string | undefined;
|
||||
readonly url?: string | undefined;
|
||||
readonly status?: IcsStatus;
|
||||
/** DTSTAMP. Defaults to now; pass a fixed value in a test. */
|
||||
readonly stamp?: Date;
|
||||
}
|
||||
|
||||
export interface IcsCalendarOptions {
|
||||
/** PRODID. Identifies the software that wrote the file. */
|
||||
readonly prodId?: string;
|
||||
/** X-WR-CALNAME, shown by some clients when a file is imported. */
|
||||
readonly name?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_PROD_ID = "-//SHARP-ER//Events Calendar//EN";
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
VALUE ENCODING
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Control characters a TEXT value may not carry. TAB is legal and the two
|
||||
* newline characters are escaped before this runs, so neither is in the class.
|
||||
*/
|
||||
const CONTROL_CHARS =
|
||||
/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
|
||||
|
||||
/** RFC 5545 3.3.11 TEXT escaping, plus control characters dropped. */
|
||||
export function escapeIcsText(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/;/g, "\\;")
|
||||
.replace(/,/g, "\\,")
|
||||
.replace(/\r\n|\r|\n/g, "\\n")
|
||||
.replace(CONTROL_CHARS, "");
|
||||
}
|
||||
|
||||
/** "20260312T140000Z" */
|
||||
function toUtcStamp(date: Date): string {
|
||||
return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
|
||||
}
|
||||
|
||||
/** "20260312" - the calendar day of a date-only value, read in UTC. */
|
||||
function toDateValue(date: Date): string {
|
||||
return date.toISOString().slice(0, 10).replace(/-/g, "");
|
||||
}
|
||||
|
||||
function addDays(date: Date, days: number): Date {
|
||||
return new Date(date.getTime() + days * 86_400_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds one content line to 75 octets. Continuation lines begin with a single
|
||||
* space, which the parser strips again.
|
||||
*/
|
||||
export function foldIcsLine(line: string): string {
|
||||
if (encoder.encode(line).length <= MAX_OCTETS) return line;
|
||||
|
||||
const parts: string[] = [];
|
||||
let current = "";
|
||||
let octets = 0;
|
||||
|
||||
// Iterating the string yields whole code points, so a multi-byte character
|
||||
// is never split across two lines.
|
||||
for (const char of line) {
|
||||
const size = encoder.encode(char).length;
|
||||
if (octets + size > MAX_OCTETS) {
|
||||
parts.push(current);
|
||||
current = " ";
|
||||
octets = 1;
|
||||
}
|
||||
current += char;
|
||||
octets += size;
|
||||
}
|
||||
|
||||
parts.push(current);
|
||||
return parts.join(CRLF);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
BUILDER
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
function property(name: string, value: string): string {
|
||||
return foldIcsLine(`${name}:${value}`);
|
||||
}
|
||||
|
||||
function eventLines(event: IcsEvent): string[] {
|
||||
const start = new Date(event.start);
|
||||
if (Number.isNaN(start.getTime())) return [];
|
||||
|
||||
const parsedEnd = event.end ? new Date(event.end) : undefined;
|
||||
const end =
|
||||
parsedEnd && !Number.isNaN(parsedEnd.getTime()) ? parsedEnd : undefined;
|
||||
|
||||
const lines: string[] = ["BEGIN:VEVENT"];
|
||||
lines.push(property("UID", escapeIcsText(event.uid)));
|
||||
lines.push(property("DTSTAMP", toUtcStamp(event.stamp ?? new Date())));
|
||||
|
||||
if (event.allDay) {
|
||||
lines.push(`DTSTART;VALUE=DATE:${toDateValue(start)}`);
|
||||
// DTEND is exclusive: the day after the last day of the event.
|
||||
lines.push(`DTEND;VALUE=DATE:${toDateValue(addDays(end ?? start, 1))}`);
|
||||
} else {
|
||||
lines.push(property("DTSTART", toUtcStamp(start)));
|
||||
if (end && end.getTime() > start.getTime()) {
|
||||
lines.push(property("DTEND", toUtcStamp(end)));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(property("SUMMARY", escapeIcsText(event.summary)));
|
||||
|
||||
if (event.description) {
|
||||
lines.push(property("DESCRIPTION", escapeIcsText(event.description)));
|
||||
}
|
||||
if (event.location) {
|
||||
lines.push(property("LOCATION", escapeIcsText(event.location)));
|
||||
}
|
||||
if (event.url) {
|
||||
// A URI value is not TEXT and must not be escaped.
|
||||
lines.push(property("URL", event.url));
|
||||
}
|
||||
|
||||
lines.push(property("STATUS", event.status ?? "CONFIRMED"));
|
||||
lines.push("END:VEVENT");
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** A complete VCALENDAR document, CRLF-terminated as the spec requires. */
|
||||
export function buildIcsCalendar(
|
||||
events: readonly IcsEvent[],
|
||||
options: IcsCalendarOptions = {},
|
||||
): string {
|
||||
const lines: string[] = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
property("PRODID", options.prodId ?? DEFAULT_PROD_ID),
|
||||
"CALSCALE:GREGORIAN",
|
||||
"METHOD:PUBLISH",
|
||||
];
|
||||
|
||||
if (options.name) {
|
||||
lines.push(property("X-WR-CALNAME", escapeIcsText(options.name)));
|
||||
}
|
||||
|
||||
for (const event of events) lines.push(...eventLines(event));
|
||||
|
||||
lines.push("END:VCALENDAR");
|
||||
|
||||
return `${lines.join(CRLF)}${CRLF}`;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
RESPONSE
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
/** ASCII-safe file name, with the UTF-8 original kept in `filename*`. */
|
||||
function contentDisposition(filename: string): string {
|
||||
const ascii = filename.replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
const fallback = ascii.length > 4 ? ascii : "event.ics";
|
||||
|
||||
return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(
|
||||
filename,
|
||||
)}`;
|
||||
}
|
||||
|
||||
/** The whole HTTP response for a .ics download. */
|
||||
export function icsResponse(body: string, filename: string): Response {
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "text/calendar; charset=utf-8",
|
||||
"content-disposition": contentDisposition(filename),
|
||||
// The file changes whenever the event does, and it is cheap to rebuild.
|
||||
"cache-control": "public, max-age=0, must-revalidate",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/* ============================================================================
|
||||
OPPORTUNITIES - Strapi access. SERVER ONLY.
|
||||
----------------------------------------------------------------------------
|
||||
CONTENT TYPE api::opportunity.opportunity
|
||||
(collection, i18n enabled, draft & publish)
|
||||
Field names copied from
|
||||
apps/cms/src/api/opportunity/content-types/opportunity/schema.json.
|
||||
|
||||
slug uid, required, targets `title`
|
||||
title string, required
|
||||
summary text (max 500) - the card excerpt
|
||||
body richtext
|
||||
eligibility richtext - draft §8 marks the network
|
||||
criteria "TBD"; the page shows
|
||||
this block only when it is filled
|
||||
howToApply richtext
|
||||
opportunityType enum call-eoi | network-membership | training |
|
||||
grant | other
|
||||
opportunityStatus enum upcoming | open | closed (required, default
|
||||
"upcoming" - see deriveOpportunityStatus)
|
||||
openDate date - before it, the call is "Upcoming"
|
||||
deadline datetime - inclusive; after it, "Closed"
|
||||
externalApplicationUrl string - an EXTERNAL portal; when set,
|
||||
the on-site form is not rendered
|
||||
attachments media (multiple) - ToR, guidelines, annexes
|
||||
coverImage media (single image)
|
||||
|
||||
There is NO `applicationEmail` and NO `responseWeeks` on this type: the
|
||||
draft marks the response time TBD and the CMS never grew a field for it, so
|
||||
the acknowledgement never names a number.
|
||||
|
||||
Where the applications collection is written: ./applications.server.ts.
|
||||
========================================================================= */
|
||||
|
||||
import type { Locale } from "~/i18n";
|
||||
import type { StrapiEntity, StrapiMedia } from "~/lib/strapi";
|
||||
|
||||
import {
|
||||
collectLocalizedSlugs,
|
||||
LOCALIZATION_POPULATE,
|
||||
type CmsLocalizable,
|
||||
type LocalizedSlugs,
|
||||
} from "../_shared/alternates";
|
||||
import {
|
||||
fetchBySlug,
|
||||
fetchCollection,
|
||||
toFile,
|
||||
toImage,
|
||||
type CmsFile,
|
||||
type CmsRichText,
|
||||
} from "../_shared/cms";
|
||||
import {
|
||||
deriveOpportunityStatus,
|
||||
OPPORTUNITY_TYPES,
|
||||
type OpportunityCardData,
|
||||
type OpportunityDetailData,
|
||||
type OpportunityStatus,
|
||||
type OpportunityType,
|
||||
} from "./opportunities";
|
||||
|
||||
export const OPPORTUNITIES_RESOURCE = "opportunities";
|
||||
|
||||
/** Hard ceiling on one listing request. */
|
||||
export const OPPORTUNITIES_FETCH_LIMIT = 100;
|
||||
|
||||
interface StrapiOpportunity extends StrapiEntity, CmsLocalizable {
|
||||
readonly slug: string;
|
||||
readonly title: string;
|
||||
readonly summary?: string | null;
|
||||
readonly body?: CmsRichText;
|
||||
readonly eligibility?: CmsRichText;
|
||||
readonly howToApply?: CmsRichText;
|
||||
readonly opportunityType?: OpportunityType | null;
|
||||
readonly opportunityStatus?: OpportunityStatus | null;
|
||||
readonly openDate?: string | null;
|
||||
readonly deadline?: string | null;
|
||||
readonly externalApplicationUrl?: string | null;
|
||||
readonly attachments?: readonly StrapiMedia[] | null;
|
||||
readonly coverImage?: StrapiMedia | null;
|
||||
}
|
||||
|
||||
const STATUSES: readonly OpportunityStatus[] = ["open", "closed", "upcoming"];
|
||||
|
||||
const LIST_POPULATE = {
|
||||
coverImage: {
|
||||
fields: ["url", "alternativeText", "width", "height", "formats"],
|
||||
},
|
||||
} as const;
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
MAPPING
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
function text(value: string | null | undefined): string | undefined {
|
||||
const trimmed = typeof value === "string" ? value.trim() : "";
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function oneOf<T extends string>(
|
||||
value: unknown,
|
||||
allowed: readonly T[],
|
||||
): T | undefined {
|
||||
return typeof value === "string" && (allowed as readonly string[]).includes(value)
|
||||
? (value as T)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function toCard(entry: StrapiOpportunity, now: Date): OpportunityCardData {
|
||||
return {
|
||||
slug: entry.slug,
|
||||
title: entry.title,
|
||||
summary: text(entry.summary),
|
||||
status: deriveOpportunityStatus(
|
||||
{
|
||||
openDate: text(entry.openDate),
|
||||
deadline: text(entry.deadline),
|
||||
status: oneOf(entry.opportunityStatus, STATUSES),
|
||||
},
|
||||
now,
|
||||
),
|
||||
openDate: text(entry.openDate),
|
||||
deadline: text(entry.deadline),
|
||||
opportunityType: oneOf(entry.opportunityType, OPPORTUNITY_TYPES),
|
||||
image: toImage(entry.coverImage, "medium"),
|
||||
};
|
||||
}
|
||||
|
||||
function toDetail(entry: StrapiOpportunity, now: Date): OpportunityDetailData {
|
||||
const attachments = (entry.attachments ?? [])
|
||||
.map((media) => toFile(media))
|
||||
.filter((file): file is CmsFile => file !== undefined);
|
||||
|
||||
return {
|
||||
...toCard(entry, now),
|
||||
documentId: entry.documentId,
|
||||
description: entry.body ?? undefined,
|
||||
eligibility: entry.eligibility ?? undefined,
|
||||
howToApply: entry.howToApply ?? undefined,
|
||||
externalApplicationUrl: text(entry.externalApplicationUrl),
|
||||
attachments,
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
QUERIES
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
export interface OpportunityListResult {
|
||||
readonly opportunities: OpportunityCardData[];
|
||||
/** False when the collection could not be read at all. */
|
||||
readonly available: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every published opportunity for a locale. Open / Upcoming / Closed is
|
||||
* derived from the dates (see ./opportunities.ts), so - as with the events
|
||||
* calendar - the set is fetched once and filtered in memory rather than
|
||||
* duplicating that rule in a Strapi query.
|
||||
*/
|
||||
export async function fetchOpportunities(
|
||||
locale: Locale,
|
||||
now: Date = new Date(),
|
||||
): Promise<OpportunityListResult> {
|
||||
const result = await fetchCollection<StrapiOpportunity>(
|
||||
OPPORTUNITIES_RESOURCE,
|
||||
{
|
||||
locale,
|
||||
sort: ["deadline:asc", "title:asc"],
|
||||
populate: LIST_POPULATE,
|
||||
pagination: { page: 1, pageSize: OPPORTUNITIES_FETCH_LIMIT },
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
opportunities: result.entries
|
||||
.filter((entry) => Boolean(entry?.slug && entry.title))
|
||||
.map((entry) => toCard(entry, now)),
|
||||
available: result.available,
|
||||
};
|
||||
}
|
||||
|
||||
export interface OpportunityDetailResult {
|
||||
readonly opportunity: OpportunityDetailData;
|
||||
readonly alternates: LocalizedSlugs;
|
||||
}
|
||||
|
||||
/** One opportunity by slug, or null when the CMS has no such entry. */
|
||||
export async function fetchOpportunity(
|
||||
slug: string,
|
||||
locale: Locale,
|
||||
now: Date = new Date(),
|
||||
): Promise<OpportunityDetailResult | null> {
|
||||
const entry = await fetchBySlug<StrapiOpportunity>(
|
||||
OPPORTUNITIES_RESOURCE,
|
||||
slug,
|
||||
{
|
||||
locale,
|
||||
populate: {
|
||||
...LIST_POPULATE,
|
||||
attachments: { fields: ["url", "name", "ext", "mime", "size"] },
|
||||
localizations: LOCALIZATION_POPULATE,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!entry) return null;
|
||||
|
||||
return {
|
||||
opportunity: toDetail(entry, now),
|
||||
alternates: collectLocalizedSlugs(locale, entry.slug, entry.localizations),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/* ============================================================================
|
||||
OPPORTUNITIES - view model, status derivation
|
||||
----------------------------------------------------------------------------
|
||||
PURE. Strapi access lives in ./opportunities.server.ts.
|
||||
|
||||
Draft section 8 asks for a status tag of Open / Closed, and the listing
|
||||
additionally distinguishes a call that has been announced but has not opened
|
||||
yet. The status is derived from `openDate` / `deadline` so it cannot go
|
||||
stale; `opportunityStatus` in the CMS is required and defaults to
|
||||
"upcoming", so it is used as an editorial CLOSE and as the fallback when a
|
||||
call carries no dates at all - never as a blanket override, which would let
|
||||
a forgotten default hide an open call.
|
||||
========================================================================= */
|
||||
|
||||
import {
|
||||
formatDate,
|
||||
formatFileSize,
|
||||
type Locale,
|
||||
type TranslationKey,
|
||||
} from "~/i18n";
|
||||
|
||||
import type { CmsFile, CmsImage, CmsRichText } from "../_shared/cms";
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
TYPES
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
export type OpportunityStatus = "open" | "closed" | "upcoming";
|
||||
|
||||
/** The filter tabs: All - Open - Upcoming - Closed. */
|
||||
export type OpportunityStatusFilter = "all" | OpportunityStatus;
|
||||
|
||||
export const OPPORTUNITY_STATUS_FILTERS: readonly OpportunityStatusFilter[] = [
|
||||
"all",
|
||||
"open",
|
||||
"upcoming",
|
||||
"closed",
|
||||
];
|
||||
|
||||
export function isOpportunityStatusFilter(
|
||||
value: unknown,
|
||||
): value is OpportunityStatusFilter {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(OPPORTUNITY_STATUS_FILTERS as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `opportunity.opportunityType` in the CMS, verbatim. Only
|
||||
* `network-membership` carries behaviour - it is what the "Join the SHARP
|
||||
* Network" card links to when the CMS holds such an entry.
|
||||
*/
|
||||
export type OpportunityType =
|
||||
| "call-eoi"
|
||||
| "network-membership"
|
||||
| "training"
|
||||
| "grant"
|
||||
| "other";
|
||||
|
||||
export const OPPORTUNITY_TYPES = [
|
||||
"call-eoi",
|
||||
"network-membership",
|
||||
"training",
|
||||
"grant",
|
||||
"other",
|
||||
] as const satisfies readonly OpportunityType[];
|
||||
|
||||
export interface OpportunityCardData {
|
||||
readonly slug: string;
|
||||
readonly title: string;
|
||||
readonly summary?: string;
|
||||
readonly status: OpportunityStatus;
|
||||
/** ISO date the call opens - `openDate` in the CMS. */
|
||||
readonly openDate?: string;
|
||||
/** ISO date applications close. */
|
||||
readonly deadline?: string;
|
||||
readonly opportunityType?: OpportunityType;
|
||||
readonly image?: CmsImage;
|
||||
}
|
||||
|
||||
export interface OpportunityDetailData extends OpportunityCardData {
|
||||
readonly documentId: string;
|
||||
readonly description?: CmsRichText;
|
||||
readonly eligibility?: CmsRichText;
|
||||
readonly howToApply?: CmsRichText;
|
||||
/** An external application portal. When set, the on-site form is not shown. */
|
||||
readonly externalApplicationUrl?: string;
|
||||
/** ToR, guidelines, annexes - `attachments` in the CMS. */
|
||||
readonly attachments: readonly CmsFile[];
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
STATUS
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
export interface OpportunityWindow {
|
||||
readonly openDate?: string | null;
|
||||
readonly deadline?: string | null;
|
||||
/** `opportunity.opportunityStatus`. Required in the CMS, default "upcoming". */
|
||||
readonly status?: OpportunityStatus | null;
|
||||
}
|
||||
|
||||
/** A deadline is inclusive: a call closes at the end of its closing day. */
|
||||
function endOfDay(value: string): number {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return Number.NaN;
|
||||
date.setUTCHours(23, 59, 59, 999);
|
||||
return date.getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Precedence, in order:
|
||||
* 1. an editorial "closed" - a call can be suspended or closed early, and
|
||||
* that decision must win over a deadline that has not passed yet;
|
||||
* 2. the dates, when the entry carries any - they cannot go stale;
|
||||
* 3. whatever `opportunityStatus` says, for a call with no dates at all.
|
||||
*/
|
||||
export function deriveOpportunityStatus(
|
||||
entry: OpportunityWindow,
|
||||
now: Date = new Date(),
|
||||
): OpportunityStatus {
|
||||
if (entry.status === "closed") return "closed";
|
||||
|
||||
const at = now.getTime();
|
||||
let dated = false;
|
||||
|
||||
if (entry.openDate) {
|
||||
const opens = new Date(entry.openDate).getTime();
|
||||
if (!Number.isNaN(opens)) {
|
||||
dated = true;
|
||||
if (at < opens) return "upcoming";
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.deadline) {
|
||||
const closes = endOfDay(entry.deadline);
|
||||
if (!Number.isNaN(closes)) {
|
||||
dated = true;
|
||||
if (at > closes) return "closed";
|
||||
}
|
||||
}
|
||||
|
||||
return dated ? "open" : (entry.status ?? "open");
|
||||
}
|
||||
|
||||
/** Catalogue key for the Open / Upcoming / Closed badge. */
|
||||
export function opportunityStatusKey(
|
||||
status: OpportunityStatus,
|
||||
): TranslationKey {
|
||||
return status === "open"
|
||||
? "opportunities.statusOpen"
|
||||
: status === "upcoming"
|
||||
? "filters.upcoming"
|
||||
: "opportunities.statusClosed";
|
||||
}
|
||||
|
||||
/** Label of the card's call to action - only an open call invites an apply. */
|
||||
export function opportunityActionKey(
|
||||
status: OpportunityStatus,
|
||||
): TranslationKey {
|
||||
return status === "open" ? "actions.apply" : "actions.viewDetails";
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
LIST OPERATIONS
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
function deadlineTime(item: OpportunityCardData): number {
|
||||
const value = item.deadline ? new Date(item.deadline).getTime() : Number.NaN;
|
||||
return Number.isNaN(value) ? Number.POSITIVE_INFINITY : value;
|
||||
}
|
||||
|
||||
const STATUS_ORDER: Readonly<Record<OpportunityStatus, number>> = {
|
||||
open: 0,
|
||||
upcoming: 1,
|
||||
closed: 2,
|
||||
};
|
||||
|
||||
/** Open calls first with the nearest deadline at the top, closed ones last. */
|
||||
export function sortOpportunities(
|
||||
items: readonly OpportunityCardData[],
|
||||
): OpportunityCardData[] {
|
||||
return [...items].sort((a, b) => {
|
||||
const byStatus = STATUS_ORDER[a.status] - STATUS_ORDER[b.status];
|
||||
if (byStatus !== 0) return byStatus;
|
||||
|
||||
return a.status === "closed"
|
||||
? deadlineTime(b) - deadlineTime(a)
|
||||
: deadlineTime(a) - deadlineTime(b);
|
||||
});
|
||||
}
|
||||
|
||||
export function filterOpportunities(
|
||||
items: readonly OpportunityCardData[],
|
||||
status: OpportunityStatusFilter,
|
||||
): OpportunityCardData[] {
|
||||
if (status === "all") return [...items];
|
||||
return items.filter((item) => item.status === status);
|
||||
}
|
||||
|
||||
/** The CMS entry the "Join the SHARP Network" card should link to, if any. */
|
||||
export function findNetworkOpportunity(
|
||||
items: readonly OpportunityCardData[],
|
||||
): OpportunityCardData | undefined {
|
||||
return items.find((item) => item.opportunityType === "network-membership");
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
FORMATTING
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
/** "12 March 2026" - a deadline is a calendar day, never a time of day. */
|
||||
export function formatOpportunityDate(
|
||||
value: string | null | undefined,
|
||||
locale: Locale,
|
||||
): string {
|
||||
return formatDate(value, locale, { dateStyle: "long", timeZone: "UTC" });
|
||||
}
|
||||
|
||||
/** "PDF · 1.2 MB", or just the type when the upload records no size. */
|
||||
export function formatAttachmentMeta(file: CmsFile, locale: Locale): string {
|
||||
const size =
|
||||
file.sizeBytes > 0 ? formatFileSize(file.sizeBytes, locale) : undefined;
|
||||
|
||||
return [file.kind || undefined, size].filter(Boolean).join(" · ");
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/* ============================================================================
|
||||
ROOT LOADER DATA, READ FROM `meta`
|
||||
----------------------------------------------------------------------------
|
||||
`buildMeta` needs the locale, the pathname and the site origin, and all
|
||||
three are resolved once per request by the root loader. A `meta` function
|
||||
reaches them through its `matches`.
|
||||
|
||||
React Router 8 names that property `loaderData` on every match (see
|
||||
`CreateMetaArgs` in react-router/lib/types/route-module-annotations.d.ts and
|
||||
the `Meta` component that builds the array). Version 7 called it `data`, and
|
||||
plenty of examples still do, so both are read here.
|
||||
|
||||
Defensive rather than indexed: `matches[0]` has no data at all while the
|
||||
root loader is in flight or on an error render, and a `meta` that throws
|
||||
takes the whole document down with it.
|
||||
========================================================================= */
|
||||
|
||||
import { DEFAULT_LOCALE, isLocale, localizedPath, type Locale } from "~/i18n";
|
||||
|
||||
export interface RootSeo {
|
||||
readonly locale: Locale;
|
||||
/** Pathname INCLUDING the locale prefix. */
|
||||
readonly pathname: string;
|
||||
/** Site origin without a trailing slash. */
|
||||
readonly siteUrl: string;
|
||||
}
|
||||
|
||||
/** Matches the default in ~/lib/env.server, for the case root data is absent. */
|
||||
const FALLBACK_SITE_URL = "https://sharper.ly";
|
||||
|
||||
function readMatchData(match: unknown): Record<string, unknown> | undefined {
|
||||
if (!match || typeof match !== "object") return undefined;
|
||||
|
||||
const { id, loaderData, data } = match as {
|
||||
id?: unknown;
|
||||
loaderData?: unknown;
|
||||
data?: unknown;
|
||||
};
|
||||
if (id !== "root") return undefined;
|
||||
|
||||
const payload = loaderData ?? data;
|
||||
return payload && typeof payload === "object"
|
||||
? (payload as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** The root loader's `{ locale, pathname, siteUrl }`, with safe fallbacks. */
|
||||
export function readRootSeo(matches: readonly unknown[]): RootSeo {
|
||||
for (const match of matches) {
|
||||
const record = readMatchData(match);
|
||||
if (!record) continue;
|
||||
|
||||
const locale = isLocale(record.locale) ? record.locale : DEFAULT_LOCALE;
|
||||
|
||||
return {
|
||||
locale,
|
||||
pathname:
|
||||
typeof record.pathname === "string"
|
||||
? record.pathname
|
||||
: localizedPath("/", locale),
|
||||
siteUrl:
|
||||
typeof record.siteUrl === "string" ? record.siteUrl : FALLBACK_SITE_URL,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
locale: DEFAULT_LOCALE,
|
||||
pathname: localizedPath("/", DEFAULT_LOCALE),
|
||||
siteUrl: FALLBACK_SITE_URL,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/* ============================================================================
|
||||
STRAPI WRITE - SERVER ONLY
|
||||
----------------------------------------------------------------------------
|
||||
~/lib/strapi.ts is a read client: the site renders from the CMS and never
|
||||
mutates it. The two public forms in this section are the exception - an
|
||||
event registration and an application to a call - so the POST lives here,
|
||||
next to the routes that need it, and reuses that module's error type so a
|
||||
caller handles one failure shape.
|
||||
|
||||
The `.server` suffix keeps it out of the browser bundle; it is imported by
|
||||
`action` only.
|
||||
========================================================================= */
|
||||
|
||||
import { getServerEnv } from "~/lib/env.server";
|
||||
import { StrapiError } from "~/lib/strapi";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||
|
||||
export interface StrapiWriteInit {
|
||||
readonly timeoutMs?: number;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
interface StrapiErrorEnvelope {
|
||||
readonly error?: {
|
||||
readonly status?: number;
|
||||
readonly name?: string;
|
||||
readonly message?: string;
|
||||
readonly details?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /api/{resource}` with Strapi's `{ data }` envelope, returning the
|
||||
* created entity. Every failure - transport, timeout, 4xx, 5xx - arrives as a
|
||||
* StrapiError, so the action can answer with one localised message instead of
|
||||
* leaking the CMS's own wording to the reader.
|
||||
*/
|
||||
export async function createStrapiEntry<T>(
|
||||
resource: string,
|
||||
data: Readonly<Record<string, unknown>>,
|
||||
init: StrapiWriteInit = {},
|
||||
): Promise<T> {
|
||||
const env = getServerEnv();
|
||||
const url = `${env.STRAPI_URL}/api/${resource.replace(/^\/+/, "")}`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
accept: "application/json",
|
||||
};
|
||||
if (env.STRAPI_TOKEN) headers.authorization = `Bearer ${env.STRAPI_TOKEN}`;
|
||||
|
||||
const timeoutMs = init.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||||
const signal = init.signal
|
||||
? AbortSignal.any([init.signal, timeoutSignal])
|
||||
: timeoutSignal;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ data }),
|
||||
signal,
|
||||
});
|
||||
} catch (cause) {
|
||||
const timedOut = cause instanceof Error && cause.name === "TimeoutError";
|
||||
throw new StrapiError({
|
||||
message: timedOut
|
||||
? `Strapi write timed out after ${timeoutMs}ms: ${url}`
|
||||
: `Strapi write failed: ${url}`,
|
||||
status: 0,
|
||||
url,
|
||||
cause,
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let body: StrapiErrorEnvelope["error"];
|
||||
try {
|
||||
body = ((await response.json()) as StrapiErrorEnvelope).error;
|
||||
} catch {
|
||||
body = undefined;
|
||||
}
|
||||
|
||||
throw new StrapiError({
|
||||
message:
|
||||
body?.message ??
|
||||
`Strapi responded ${response.status} ${response.statusText} for ${url}`,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
url,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
if (response.status === 204) return undefined as T;
|
||||
|
||||
try {
|
||||
const payload = (await response.json()) as { data?: T };
|
||||
return payload.data as T;
|
||||
} catch (cause) {
|
||||
throw new StrapiError({
|
||||
message: `Strapi returned a non-JSON body for ${url}`,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
url,
|
||||
cause,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/* ============================================================================
|
||||
FORM SUBMISSIONS -> STRAPI. SERVER ONLY.
|
||||
----------------------------------------------------------------------------
|
||||
The two public forms of this section write into their own collections. Both
|
||||
are ordinary <Form method="post"> actions, so they work with scripting off.
|
||||
|
||||
THE PAYLOAD IS THE WHITELIST, NOTHING MORE
|
||||
Each collection has a `sanitize-submission` middleware in the CMS that
|
||||
copies out a fixed set of fields and DROPS everything else, then sets the
|
||||
workflow state and the timestamps server-side. Sending a field that is not
|
||||
on the list is not merely ignored - a required field that is missing is a
|
||||
400 and the reader loses what they typed. So the two payloads below are
|
||||
exactly the whitelists, read from:
|
||||
|
||||
apps/cms/src/api/event-registration/middlewares/sanitize-submission.ts
|
||||
event · fullName · email · phone · organisation · role ·
|
||||
dietaryOrAccess · consentGdpr
|
||||
|
||||
apps/cms/src/api/application/middlewares/sanitize-submission.ts
|
||||
opportunity · applicantName · email · phone · organisation ·
|
||||
organisationType · answers
|
||||
|
||||
`attachments` exists on the `application` content type but is NOT on that
|
||||
whitelist and must never be sent: it is a media relation, so its value is a
|
||||
list of ids of files already in the Media Library, and an unauthenticated
|
||||
caller who could set it would bind arbitrary uploads onto a row reviewers
|
||||
open. A public upload flow needs its own authenticated endpoint first.
|
||||
|
||||
There is no `language` field on either type ("locale" is reserved by the
|
||||
i18n plugin and `language` was never modelled), and no `waitlist` boolean.
|
||||
|
||||
THE ENDPOINTS
|
||||
The URL is the content type's PLURAL name:
|
||||
api::event-registration.event-registration -> /api/event-registrations
|
||||
api::application.application -> /api/applications
|
||||
(not "opportunity-applications", which does not exist and 404s).
|
||||
|
||||
WAITLIST IS DERIVED SERVER-SIDE, NEVER SENT
|
||||
`registrationState` is not on the registration whitelist, and it is not an
|
||||
omission to work around: the middleware works it out from the event itself -
|
||||
places taken vs `capacity`, plus `registrationStatus` / `waitlistEnabled` -
|
||||
and answers `confirmed`, `waitlisted`, or a 400 when the event is full with
|
||||
no waitlist to offer. That decision needs a count no client can be trusted
|
||||
to report, so this module sends only what the registrant actually supplied
|
||||
and lets the CMS decide what it is worth.
|
||||
|
||||
PERMISSIONS
|
||||
`create` on both collections is granted to the Public role; `find` /
|
||||
`findOne` are NOT, or anyone could read everyone else's contact details.
|
||||
========================================================================= */
|
||||
|
||||
import type { ApplicationValues, RegistrationValues } from "./forms";
|
||||
import { createStrapiEntry } from "./strapi-write.server";
|
||||
|
||||
export const EVENT_REGISTRATIONS_RESOURCE = "event-registrations";
|
||||
export const APPLICATIONS_RESOURCE = "applications";
|
||||
|
||||
/** Strapi rejects "" on an email field; absent is the honest value. */
|
||||
function optional(value: string): string | undefined {
|
||||
return value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
export interface RegistrationSubmission {
|
||||
readonly values: RegistrationValues;
|
||||
/** documentId of the event being registered for. */
|
||||
readonly eventId: string;
|
||||
}
|
||||
|
||||
export async function submitRegistration(
|
||||
input: RegistrationSubmission,
|
||||
): Promise<void> {
|
||||
await createStrapiEntry(EVENT_REGISTRATIONS_RESOURCE, {
|
||||
event: input.eventId,
|
||||
fullName: input.values.fullName,
|
||||
email: input.values.email,
|
||||
phone: optional(input.values.phone),
|
||||
organisation: optional(input.values.organisation),
|
||||
// Required in the schema: without it every POST is a 400.
|
||||
consentGdpr: input.values.consentGdpr,
|
||||
});
|
||||
}
|
||||
|
||||
export interface ApplicationSubmission {
|
||||
readonly values: ApplicationValues;
|
||||
/** documentId of the opportunity being applied to. */
|
||||
readonly opportunityId: string;
|
||||
}
|
||||
|
||||
/** The key the free-text answer is filed under inside `answers` (json). */
|
||||
export const APPLICATION_MESSAGE_KEY = "message";
|
||||
|
||||
export async function submitApplication(
|
||||
input: ApplicationSubmission,
|
||||
): Promise<void> {
|
||||
await createStrapiEntry(APPLICATIONS_RESOURCE, {
|
||||
opportunity: input.opportunityId,
|
||||
// `fullName` is the registration's field name; an application's is
|
||||
// `applicantName`.
|
||||
applicantName: input.values.fullName,
|
||||
email: input.values.email,
|
||||
organisation: optional(input.values.organisation),
|
||||
// `answers` is a json field - the form's one free-text question goes in
|
||||
// it, keyed, so more questions can be added without another migration.
|
||||
answers: { [APPLICATION_MESSAGE_KEY]: input.values.message },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
/* ============================================================================
|
||||
EVENTS AND OPPORTUNITIES - the pieces these two sections add
|
||||
----------------------------------------------------------------------------
|
||||
Everything generic already exists and is imported, not rebuilt:
|
||||
|
||||
~/components/ui/Button Button, ArrowIcon
|
||||
~/components/ui/Card CardShell, CardMedia, CardLink, CardActionHint
|
||||
- the primitives the card family exports "for
|
||||
composing new card types"
|
||||
../_shared/listing-ui PageShell, PageHeader, EmptyState, TabLinks,
|
||||
Pagination, MetaList, MetaLine, BackLink
|
||||
|
||||
What is genuinely new here is the status badge the draft asks for on every
|
||||
event and every call ("Open / Closed / Full", "Upcoming / Ongoing /
|
||||
Completed"), the two cards that carry the field set of draft sections 4 and
|
||||
8, and the notice panel a detail page uses to explain a closed registration.
|
||||
|
||||
RULES: tier-2 semantic tokens only, logical properties only, type from the
|
||||
published roles. Nothing here knows about a hex value or a direction.
|
||||
========================================================================= */
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import {
|
||||
CardActionHint,
|
||||
CardLink,
|
||||
CardMedia,
|
||||
CardShell,
|
||||
} from "~/components/ui/Card";
|
||||
import type { CardElement, CardTitleElement } from "~/components/ui/Card";
|
||||
import { toIsoDate, useTranslation } from "~/i18n";
|
||||
|
||||
import { MetaLine } from "../_shared/listing-ui";
|
||||
import {
|
||||
eventActionKey,
|
||||
eventRegistrationLabel,
|
||||
eventTimeStatusKey,
|
||||
formatEventWhen,
|
||||
type EventCardData,
|
||||
} from "./events";
|
||||
import { getRouteCopy } from "./copy";
|
||||
import {
|
||||
formatOpportunityDate,
|
||||
opportunityActionKey,
|
||||
opportunityStatusKey,
|
||||
type OpportunityCardData,
|
||||
} from "./opportunities";
|
||||
|
||||
function cx(...parts: Array<string | false | null | undefined>): string {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
STATUS BADGE
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
/** Accent = live and joinable, neutral = informational, danger = full. */
|
||||
export type BadgeTone = "accent" | "neutral" | "danger";
|
||||
|
||||
const BADGE_TONE: Readonly<Record<BadgeTone, string>> = {
|
||||
accent: "border-border-accent bg-surface-accent-subtle text-text-accent",
|
||||
neutral: "border-border-subtle bg-surface-sunken text-text-tertiary",
|
||||
danger: "border-border-danger bg-surface-danger-subtle text-text-danger",
|
||||
};
|
||||
|
||||
/**
|
||||
* Colour is never the only carrier: the badge always spells the state out, so
|
||||
* it survives a monochrome print and a colour-blind reader.
|
||||
*/
|
||||
export function StatusBadge({
|
||||
tone = "neutral",
|
||||
children,
|
||||
}: {
|
||||
tone?: BadgeTone;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cx(
|
||||
"inline-flex items-center gap-2xs rounded-pill border px-xs py-2xs label-md",
|
||||
BADGE_TONE[tone],
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** A row of badges above a card title or a detail heading. */
|
||||
export function BadgeRow({ children }: { children: ReactNode }) {
|
||||
return <div className="flex flex-wrap items-center gap-xs">{children}</div>;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
NOTICE
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
export type NoticeTone = "info" | "accent" | "danger";
|
||||
|
||||
const NOTICE_TONE: Readonly<Record<NoticeTone, string>> = {
|
||||
info: "border-border-default bg-surface-sunken text-text-secondary",
|
||||
accent: "border-border-accent bg-surface-accent-subtle text-text-primary",
|
||||
danger: "border-border-danger bg-surface-danger-subtle text-text-primary",
|
||||
};
|
||||
|
||||
/**
|
||||
* The panel that explains a state: registration closed, the call is not open
|
||||
* yet, the CMS could not be reached. `role` is left to the caller - a notice
|
||||
* that appears in response to a submission needs "status", one that is part of
|
||||
* the page does not.
|
||||
*/
|
||||
export function Notice({
|
||||
tone = "info",
|
||||
title,
|
||||
children,
|
||||
role,
|
||||
}: {
|
||||
tone?: NoticeTone;
|
||||
title?: string;
|
||||
children?: ReactNode;
|
||||
role?: "status" | "alert";
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role={role}
|
||||
className={cx(
|
||||
"flex flex-col gap-xs rounded-card border p-lg",
|
||||
NOTICE_TONE[tone],
|
||||
)}
|
||||
>
|
||||
{title ? <p className="body-md-bold text-text-primary">{title}</p> : null}
|
||||
{children ? <div className="body-md">{children}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
EVENT CARD - draft section 4
|
||||
title - date and time - format/location - registration status - CTA
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
export function EventCard({
|
||||
event,
|
||||
href,
|
||||
as = "article",
|
||||
titleAs = "h3",
|
||||
className,
|
||||
}: {
|
||||
event: EventCardData;
|
||||
href: string;
|
||||
as?: CardElement;
|
||||
titleAs?: CardTitleElement;
|
||||
className?: string;
|
||||
}) {
|
||||
const { locale, t } = useTranslation();
|
||||
const copy = getRouteCopy(locale);
|
||||
const Title = titleAs;
|
||||
|
||||
const when = formatEventWhen(event, locale);
|
||||
const formatLabel =
|
||||
event.format === "online"
|
||||
? t("events.online")
|
||||
: event.format === "hybrid"
|
||||
? copy.events.formatHybrid
|
||||
: event.format === "in-person"
|
||||
? t("events.inPerson")
|
||||
: undefined;
|
||||
|
||||
const isPast = event.timeStatus === "completed";
|
||||
|
||||
return (
|
||||
<CardShell
|
||||
as={as}
|
||||
linked
|
||||
className={cx("flex-col overflow-hidden", className)}
|
||||
>
|
||||
{event.image ? (
|
||||
<CardMedia
|
||||
image={{ src: event.image.src, alt: event.image.alt }}
|
||||
className="aspect-[9/5] w-full"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-md p-lg">
|
||||
<div className="flex flex-col gap-xs">
|
||||
<BadgeRow>
|
||||
<StatusBadge tone={isPast ? "neutral" : "accent"}>
|
||||
{t(eventTimeStatusKey(event.timeStatus))}
|
||||
</StatusBadge>
|
||||
|
||||
{/* A finished event's registration state is history, not news. */}
|
||||
{isPast ? null : (
|
||||
<StatusBadge
|
||||
tone={
|
||||
event.registrationStatus === "full"
|
||||
? "danger"
|
||||
: event.registrationStatus === "closed"
|
||||
? "neutral"
|
||||
: "accent"
|
||||
}
|
||||
>
|
||||
{eventRegistrationLabel(event.registrationStatus, locale)}
|
||||
</StatusBadge>
|
||||
)}
|
||||
</BadgeRow>
|
||||
|
||||
<Title className="sh-card-title body-lg-bold text-text-primary">
|
||||
<CardLink href={href} className="sh-card-link">
|
||||
{event.title}
|
||||
</CardLink>
|
||||
</Title>
|
||||
|
||||
<p className="body-sm-bold text-text-secondary">
|
||||
<time dateTime={toIsoDate(event.start)}>{when}</time>
|
||||
</p>
|
||||
|
||||
<MetaLine items={[formatLabel, event.place, event.region].filter(
|
||||
(item): item is string => Boolean(item),
|
||||
)} />
|
||||
|
||||
{event.summary ? (
|
||||
<p className="body-sm text-text-secondary line-clamp-3">
|
||||
{event.summary}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<CardActionHint label={t(eventActionKey(event))} />
|
||||
</div>
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
OPPORTUNITY CARD - draft section 8
|
||||
title - status tag - deadline - CTA
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
export function OpportunityCard({
|
||||
opportunity,
|
||||
href,
|
||||
as = "article",
|
||||
titleAs = "h3",
|
||||
className,
|
||||
}: {
|
||||
opportunity: OpportunityCardData;
|
||||
href: string;
|
||||
as?: CardElement;
|
||||
titleAs?: CardTitleElement;
|
||||
className?: string;
|
||||
}) {
|
||||
const { locale, t } = useTranslation();
|
||||
const Title = titleAs;
|
||||
|
||||
const isOpen = opportunity.status === "open";
|
||||
|
||||
/* An open or closed call is defined by its deadline; a call that has not
|
||||
opened yet is defined by its opening date. */
|
||||
const dateLine =
|
||||
opportunity.status === "upcoming" && opportunity.openDate
|
||||
? formatOpportunityDate(opportunity.openDate, locale)
|
||||
: opportunity.deadline
|
||||
? t("opportunities.deadline", {
|
||||
date: formatOpportunityDate(opportunity.deadline, locale),
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const dateValue =
|
||||
opportunity.status === "upcoming" && opportunity.openDate
|
||||
? opportunity.openDate
|
||||
: opportunity.deadline;
|
||||
|
||||
return (
|
||||
<CardShell
|
||||
as={as}
|
||||
linked
|
||||
className={cx("flex-col overflow-hidden", className)}
|
||||
>
|
||||
{opportunity.image ? (
|
||||
<CardMedia
|
||||
image={{ src: opportunity.image.src, alt: opportunity.image.alt }}
|
||||
className="aspect-[9/5] w-full"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-md p-lg">
|
||||
<div className="flex flex-col gap-xs">
|
||||
<BadgeRow>
|
||||
<StatusBadge tone={isOpen ? "accent" : "neutral"}>
|
||||
{t(opportunityStatusKey(opportunity.status))}
|
||||
</StatusBadge>
|
||||
{opportunity.opportunityType ? (
|
||||
<StatusBadge>
|
||||
{getRouteCopy(locale).opportunityTypes[
|
||||
opportunity.opportunityType
|
||||
]}
|
||||
</StatusBadge>
|
||||
) : null}
|
||||
</BadgeRow>
|
||||
|
||||
<Title className="sh-card-title body-lg-bold text-text-primary">
|
||||
<CardLink href={href} className="sh-card-link">
|
||||
{opportunity.title}
|
||||
</CardLink>
|
||||
</Title>
|
||||
|
||||
{dateLine ? (
|
||||
<p className="body-sm-bold text-text-secondary">
|
||||
<time dateTime={toIsoDate(dateValue)}>{dateLine}</time>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{opportunity.summary ? (
|
||||
<p className="body-sm text-text-secondary line-clamp-3">
|
||||
{opportunity.summary}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<CardActionHint label={t(opportunityActionKey(opportunity.status))} />
|
||||
</div>
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
DEFINITION GRID - the "Event details" block
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
export interface DetailItem {
|
||||
readonly label: string;
|
||||
readonly value: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A description list rather than a table: the pairs are a key/value set, they
|
||||
* reflow to one column on a phone, and a screen reader announces the label
|
||||
* with the value.
|
||||
*/
|
||||
export function DetailList({
|
||||
heading,
|
||||
items,
|
||||
}: {
|
||||
heading: string;
|
||||
items: readonly DetailItem[];
|
||||
}) {
|
||||
const present = items.filter((item) => item.value !== undefined && item.value !== null);
|
||||
if (present.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={heading}
|
||||
className="flex flex-col gap-md rounded-card border border-border-subtle bg-surface-raised p-lg"
|
||||
>
|
||||
<h2 className="label-md uppercase text-text-tertiary">{heading}</h2>
|
||||
<dl className="grid gap-md sm:grid-cols-2">
|
||||
{present.map((item) => (
|
||||
<div key={item.label} className="flex flex-col gap-2xs">
|
||||
<dt className="body-sm text-text-tertiary">{item.label}</dt>
|
||||
<dd className="body-md-bold text-text-primary">{item.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
CONSENT CHECKBOX
|
||||
----------------------------------------------------------------------------
|
||||
`event-registration.consentGdpr` is a REQUIRED boolean in the CMS, so the
|
||||
reader has to be asked - out loud, in a real <label>, with the reason next
|
||||
to it. ~/components/ui has no Checkbox (this is the only one in the site),
|
||||
so the control is composed here from the same tier-2 tokens the rest of the
|
||||
section uses; promote it to ~/components/ui when a second form needs one.
|
||||
|
||||
Accessibility: the label wraps nothing - it is associated by `htmlFor`, so
|
||||
the hint and the error can sit outside it and still be announced, via
|
||||
`aria-describedby`. `aria-invalid` marks the failure for a screen reader,
|
||||
and the message is text, never colour alone.
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
export function ConsentCheckbox({
|
||||
name,
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
defaultChecked = false,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
defaultChecked?: boolean;
|
||||
}) {
|
||||
const hintId = hint ? `${name}-hint` : undefined;
|
||||
const errorId = error ? `${name}-error` : undefined;
|
||||
const describedBy = [hintId, errorId].filter(Boolean).join(" ") || undefined;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2xs">
|
||||
<div className="flex items-start gap-sm">
|
||||
<input
|
||||
id={name}
|
||||
name={name}
|
||||
type="checkbox"
|
||||
value="true"
|
||||
defaultChecked={defaultChecked}
|
||||
required
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={describedBy}
|
||||
className="mt-2xs size-md shrink-0 rounded-[var(--radius-control)] border border-border-strong accent-[var(--interactive-primary-bg)]"
|
||||
/>
|
||||
<label htmlFor={name} className="body-sm text-text-primary">
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{hint ? (
|
||||
<p id={hintId} className="body-sm text-text-tertiary ps-lg">
|
||||
{hint}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p id={errorId} className="body-sm text-text-danger ps-lg">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user