diff --git a/apps/web/app/i18n/messages/ar.json b/apps/web/app/i18n/messages/ar.json index 3418d85..5d24f56 100644 --- a/apps/web/app/i18n/messages/ar.json +++ b/apps/web/app/i18n/messages/ar.json @@ -67,7 +67,8 @@ "applyToJoin": "قدّم طلب الانضمام", "viewUpcomingSessions": "اطّلع على الجلسات القادمة", "submitYourUpdate": "شاركونا مستجداتكم", - "addToCalendar": "أضف إلى التقويم", + "addToGoogleCalendar": "أضف إلى تقويم Google", + "downloadIcsFile": "تنزيل ملف .ics", "tryAgain": "حاول مرة أخرى" }, "filters": { diff --git a/apps/web/app/i18n/messages/en.json b/apps/web/app/i18n/messages/en.json index 375295e..8adee14 100644 --- a/apps/web/app/i18n/messages/en.json +++ b/apps/web/app/i18n/messages/en.json @@ -67,7 +67,8 @@ "applyToJoin": "Apply to join", "viewUpcomingSessions": "View upcoming sessions", "submitYourUpdate": "Submit your update", - "addToCalendar": "Add to calendar", + "addToGoogleCalendar": "Add to Google Calendar", + "downloadIcsFile": "Download .ics file", "tryAgain": "Try again" }, "filters": { diff --git a/apps/web/app/routes/_events-opportunities/copy.ts b/apps/web/app/routes/_events-opportunities/copy.ts index 87a98cc..30c4408 100644 --- a/apps/web/app/routes/_events-opportunities/copy.ts +++ b/apps/web/app/routes/_events-opportunities/copy.ts @@ -65,7 +65,8 @@ const EN = { eventCompleted: "This event has already taken place.", joinOnlineLabel: "Joining link", addToCalendarHint: - "Downloads an .ics file you can open in any calendar application.", + "The .ics file is compatible with Calendar on Apple devices and " + + "other calendar apps.", timeZoneNote: "All times are shown in Libya time (UTC+2).", /** * `event-registration.consentGdpr` is REQUIRED in the CMS, so the reader @@ -259,7 +260,9 @@ const AR: RouteCopy = { "اكتمل العدد في هذه الفعالية. انضم إلى قائمة الانتظار وسنتواصل معك في حال توفّر مكان.", eventCompleted: "انتهت هذه الفعالية.", joinOnlineLabel: "رابط الانضمام", - addToCalendarHint: "يُنزّل ملف .ics يمكن فتحه في أي تطبيق تقويم.", + addToCalendarHint: + "ملف .ics متوافق مع تطبيق التقويم على أجهزة Apple ومع تطبيقات " + + "التقويم الأخرى.", timeZoneNote: "جميع الأوقات معروضة بتوقيت ليبيا (UTC+2).", consentLabel: "أوافق على أن يحتفظ مشروع SHARP-ER بالبيانات أعلاه لإدارة تسجيلي في هذه الفعالية.", diff --git a/apps/web/app/routes/_events-opportunities/ics.ts b/apps/web/app/routes/_events-opportunities/ics.ts index 919af85..17a4649 100644 --- a/apps/web/app/routes/_events-opportunities/ics.ts +++ b/apps/web/app/routes/_events-opportunities/ics.ts @@ -1,7 +1,7 @@ /* ============================================================================ iCALENDAR (RFC 5545) ---------------------------------------------------------------------------- - Builds the file behind "Add to calendar". Pure, dependency-free and written + Builds the file behind "Download .ics file". 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 diff --git a/apps/web/app/routes/events-calendar.$slug.tsx b/apps/web/app/routes/events-calendar.$slug.tsx index a2a5e07..81fd126 100644 --- a/apps/web/app/routes/events-calendar.$slug.tsx +++ b/apps/web/app/routes/events-calendar.$slug.tsx @@ -13,8 +13,8 @@ refuses to submit without it. It is never a hidden input: consent that the reader did not give is not consent. - "Add to calendar" points at the sibling resource route, - /{locale}/events-calendar/{slug}.ics, which streams a real RFC 5545 file. + The .ics download points at the sibling resource route, + /{locale}/events-calendar/{slug}.ics, which streams a real RFC 5545 file. ROUTE Registered in app/routes.ts as "eventsDetail", a SIBLING of the listing route @@ -62,6 +62,7 @@ import { formatEventWhen, isRegistrationOpen, isWaitlisted, + type EventDetailData, } from "./_events-opportunities/events"; import { fetchEvent } from "./_events-opportunities/events.server"; import { @@ -98,6 +99,47 @@ import { RichText } from "./_shared/RichText"; */ export const handle = SELF_ALTERNATES_HANDLE; +function addCalendarDay(date: Date): Date { + const next = new Date(date); + next.setUTCDate(next.getUTCDate() + 1); + return next; +} + +function toGoogleCalendarDate(date: Date): string { + return date.toISOString().slice(0, 10).replace(/-/g, ""); +} + +function toGoogleCalendarDateTime(date: Date): string { + return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, ""); +} + +function buildGoogleCalendarHref(event: EventDetailData): string { + const start = new Date(event.start); + const parsedEnd = event.end ? new Date(event.end) : undefined; + const end = + parsedEnd && !Number.isNaN(parsedEnd.getTime()) ? parsedEnd : undefined; + const dates = event.allDay + ? `${toGoogleCalendarDate(start)}/${toGoogleCalendarDate( + addCalendarDay(end ?? start), + )}` + : `${toGoogleCalendarDateTime(start)}/${toGoogleCalendarDateTime( + end && end.getTime() > start.getTime() ? end : start, + )}`; + + const params = new URLSearchParams({ + action: "TEMPLATE", + text: event.title, + dates, + }); + const details = [event.summary, event.onlineUrl].filter(Boolean).join("\n\n"); + const location = event.place ?? event.venue; + + if (details) params.set("details", details); + if (location) params.set("location", location); + + return `https://calendar.google.com/calendar/render?${params.toString()}`; +} + export async function loader({ params }: Route.LoaderArgs) { const locale = requireLocale(params.lang); const slug = params.slug ?? ""; @@ -429,6 +471,24 @@ export default function EventDetail() {
+

{copy.eventDetail.addToCalendarHint} diff --git a/apps/web/public/assets/icons/google-calendar.svg b/apps/web/public/assets/icons/google-calendar.svg new file mode 100644 index 0000000..011cf5e --- /dev/null +++ b/apps/web/public/assets/icons/google-calendar.svg @@ -0,0 +1,148 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +