feat(events): added "Add to Google Calendar" button; and clarify .ics download

This commit is contained in:
2026-10-02 12:49:11 +02:00
parent 5310272715
commit a5c531be00
6 changed files with 221 additions and 8 deletions
+2 -1
View File
@@ -67,7 +67,8 @@
"applyToJoin": "قدّم طلب الانضمام", "applyToJoin": "قدّم طلب الانضمام",
"viewUpcomingSessions": "اطّلع على الجلسات القادمة", "viewUpcomingSessions": "اطّلع على الجلسات القادمة",
"submitYourUpdate": "شاركونا مستجداتكم", "submitYourUpdate": "شاركونا مستجداتكم",
"addToCalendar": "أضف إلى التقويم", "addToGoogleCalendar": "أضف إلى تقويم Google",
"downloadIcsFile": "تنزيل ملف .ics",
"tryAgain": "حاول مرة أخرى" "tryAgain": "حاول مرة أخرى"
}, },
"filters": { "filters": {
+2 -1
View File
@@ -67,7 +67,8 @@
"applyToJoin": "Apply to join", "applyToJoin": "Apply to join",
"viewUpcomingSessions": "View upcoming sessions", "viewUpcomingSessions": "View upcoming sessions",
"submitYourUpdate": "Submit your update", "submitYourUpdate": "Submit your update",
"addToCalendar": "Add to calendar", "addToGoogleCalendar": "Add to Google Calendar",
"downloadIcsFile": "Download .ics file",
"tryAgain": "Try again" "tryAgain": "Try again"
}, },
"filters": { "filters": {
@@ -65,7 +65,8 @@ const EN = {
eventCompleted: "This event has already taken place.", eventCompleted: "This event has already taken place.",
joinOnlineLabel: "Joining link", joinOnlineLabel: "Joining link",
addToCalendarHint: 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).", timeZoneNote: "All times are shown in Libya time (UTC+2).",
/** /**
* `event-registration.consentGdpr` is REQUIRED in the CMS, so the reader * `event-registration.consentGdpr` is REQUIRED in the CMS, so the reader
@@ -259,7 +260,9 @@ const AR: RouteCopy = {
"اكتمل العدد في هذه الفعالية. انضم إلى قائمة الانتظار وسنتواصل معك في حال توفّر مكان.", "اكتمل العدد في هذه الفعالية. انضم إلى قائمة الانتظار وسنتواصل معك في حال توفّر مكان.",
eventCompleted: "انتهت هذه الفعالية.", eventCompleted: "انتهت هذه الفعالية.",
joinOnlineLabel: "رابط الانضمام", joinOnlineLabel: "رابط الانضمام",
addToCalendarHint: "يُنزّل ملف .ics يمكن فتحه في أي تطبيق تقويم.", addToCalendarHint:
"ملف .ics متوافق مع تطبيق التقويم على أجهزة Apple ومع تطبيقات " +
"التقويم الأخرى.",
timeZoneNote: "جميع الأوقات معروضة بتوقيت ليبيا (UTC+2).", timeZoneNote: "جميع الأوقات معروضة بتوقيت ليبيا (UTC+2).",
consentLabel: consentLabel:
"أوافق على أن يحتفظ مشروع SHARP-ER بالبيانات أعلاه لإدارة تسجيلي في هذه الفعالية.", "أوافق على أن يحتفظ مشروع SHARP-ER بالبيانات أعلاه لإدارة تسجيلي في هذه الفعالية.",
@@ -1,7 +1,7 @@
/* ============================================================================ /* ============================================================================
iCALENDAR (RFC 5545) 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: against the spec rather than against one client's tolerance:
* CRLF line endings, and content lines folded at 75 OCTETS - folded on * CRLF line endings, and content lines folded at 75 OCTETS - folded on
+62 -2
View File
@@ -13,7 +13,7 @@
refuses to submit without it. It is never a hidden input: consent that the refuses to submit without it. It is never a hidden input: consent that the
reader did not give is not consent. reader did not give is not consent.
"Add to calendar" points at the sibling resource route, The .ics download points at the sibling resource route,
/{locale}/events-calendar/{slug}.ics, which streams a real RFC 5545 file. /{locale}/events-calendar/{slug}.ics, which streams a real RFC 5545 file.
ROUTE ROUTE
@@ -62,6 +62,7 @@ import {
formatEventWhen, formatEventWhen,
isRegistrationOpen, isRegistrationOpen,
isWaitlisted, isWaitlisted,
type EventDetailData,
} from "./_events-opportunities/events"; } from "./_events-opportunities/events";
import { fetchEvent } from "./_events-opportunities/events.server"; import { fetchEvent } from "./_events-opportunities/events.server";
import { import {
@@ -98,6 +99,47 @@ import { RichText } from "./_shared/RichText";
*/ */
export const handle = SELF_ALTERNATES_HANDLE; 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) { export async function loader({ params }: Route.LoaderArgs) {
const locale = requireLocale(params.lang); const locale = requireLocale(params.lang);
const slug = params.slug ?? ""; const slug = params.slug ?? "";
@@ -429,6 +471,24 @@ export default function EventDetail() {
<DetailList heading={copy.eventDetail.detailsHeading} items={details} /> <DetailList heading={copy.eventDetail.detailsHeading} items={details} />
<div className="flex flex-col gap-xs"> <div className="flex flex-col gap-xs">
<Button
as="a"
href={buildGoogleCalendarHref(event)}
target="_blank"
rel="noopener noreferrer"
variant="secondary"
iconStart={
<img
src="/assets/icons/google-calendar.svg"
alt=""
aria-hidden="true"
height={20}
/>
}
fullWidth
>
{t("actions.addToGoogleCalendar")}
</Button>
<Button <Button
as="a" as="a"
href={`${detailPath}.ics`} href={`${detailPath}.ics`}
@@ -436,7 +496,7 @@ export default function EventDetail() {
variant="secondary" variant="secondary"
fullWidth fullWidth
> >
{t("actions.addToCalendar")} {t("actions.downloadIcsFile")}
</Button> </Button>
<p className="body-sm text-text-tertiary"> <p className="body-sm text-text-tertiary">
{copy.eventDetail.addToCalendarHint} {copy.eventDetail.addToCalendarHint}
@@ -0,0 +1,148 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="800"
height="859.0954"
fill="none"
version="1.1"
id="svg10"
sodipodi:docname="Calendar2026.svg"
inkscape:version="1.4.2 (f4327f4, 2025-05-13)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview10"
pagecolor="#505050"
bordercolor="#ffffff"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
inkscape:deskcolor="#505050"
inkscape:zoom="4.3177083"
inkscape:cx="76.313631"
inkscape:cy="87.893848"
inkscape:window-width="1920"
inkscape:window-height="1009"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="svg10" />
<path
fill="#bbe2ff"
d="M 64.743253,150.86554 C 64.743253,67.543758 132.28701,0 215.60879,0 h 368.78242 c 83.32178,0 150.86554,67.543758 150.86554,150.86554 v 159.24695 c 0,83.32178 -67.54376,150.86554 -150.86554,150.86554 H 215.60879 c -83.32178,0 -150.865537,-67.54376 -150.865537,-150.86554 z"
id="path1"
style="stroke-width:5.23839" />
<path
fill="#3c90ff"
d="M 1.1859076,216.8273 C -9.5475474,135.25514 53.952176,62.86064 136.22104,62.86064 h 527.55792 c 82.2741,0 145.76859,72.3945 135.03513,153.96666 l -32.12702,244.15073 32.12702,244.15072 c 10.73346,81.57216 -52.76103,153.96666 -135.03513,153.96666 H 136.22104 c -82.274102,0 -145.7685874,-72.3945 -135.0351324,-153.96666 L 33.312933,460.97803 Z"
id="path2"
style="stroke-width:5.23839" />
<mask
id="a"
width="154"
height="152"
x="19"
y="20"
maskUnits="userSpaceOnUse">
<path
fill="#3c90ff"
d="M 19.867,49.392 C 17.818,33.82 29.94,20 45.645,20 h 100.71 c 15.706,0 27.827,13.82 25.778,29.392 L 166,96 l 6.133,46.608 C 174.182,158.18 162.061,172 146.355,172 H 45.645 C 29.939,172 17.818,158.18 19.867,142.608 L 26,96 Z"
id="path3" />
</mask>
<g
mask="url(#a)"
id="g4"
transform="matrix(5.2383867,0,0,5.2383867,-102.88512,-41.907093)">
<path
fill="url(#b)"
d="M 0,0 H 166 V 76 H 0 Z"
transform="matrix(1,0,0,-1,13,172)"
id="path4"
style="fill:url(#b)" />
</g>
<mask
id="c"
width="154"
height="152"
x="19"
y="20"
maskUnits="userSpaceOnUse">
<path
fill="#3186ff"
d="M 19.867,49.392 C 17.818,33.82 29.94,20 45.645,20 h 100.71 c 15.706,0 27.827,13.82 25.778,29.392 L 166,96 l 6.133,46.608 C 174.182,158.18 162.061,172 146.355,172 H 45.645 C 29.939,172 17.818,158.18 19.867,142.608 L 26,96 Z"
id="path5" />
</mask>
<g
mask="url(#c)"
id="g6"
transform="matrix(5.2383867,0,0,5.2383867,-102.88512,-41.907093)">
<path
fill="url(#d)"
d="M 32,27.2 C 32,16.596 40.596,8 51.2,8 h 89.6 C 151.404,8 160,16.596 160,27.2 V 96 H 32 Z"
filter="url(#e)"
id="path6"
style="fill:url(#d)" />
</g>
<path
fill="#ffffff"
d="m 291.84303,656.55843 q -32.90754,0 -56.45409,-10.70202 -23.54655,-10.70203 -39.86412,-28.62779 -16.05566,-18.19815 -22.74508,-35.58436 -6.68942,-17.3862 -5.34839,-21.13689 a 10.84346,10.84346 0 0 1 5.34839,-5.88794 l 29.70165,-11.77066 q 3.74021,-1.8701 7.4909,-0.53431 3.74021,1.06863 8.82668,12.30497 5.35363,11.23634 14.98178,23.8137 a 74.908929,74.908929 0 0 0 23.54655,19.52871 q 13.65124,6.95658 33.70902,6.95658 32.37323,0 51.37286,-18.72724 19.26155,-18.72723 19.26155,-47.62217 0,-31.3046 -20.33542,-48.16173 Q 321.00513,473.283 287.55803,473.283 H 259.4698 a 9.9529347,9.9529347 0 0 1 -6.95657,-2.67158 q -2.67158,-2.94397 -2.67682,-6.68942 v -28.62778 q 0,-4.01785 2.67158,-6.68942 a 9.5338637,9.5338637 0 0 1 6.96181,-2.94398 h 24.34279 q 29.96881,0 48.16172,-16.32281 18.19292,-16.32281 18.19292,-42.27378 0,-25.67857 -16.32281,-41.46707 -16.32282,-15.7885 -44.94536,-15.7885 -16.05566,0 -27.82631,5.35364 a 60.241447,60.241447 0 0 0 -20.33542,14.98178 118.91138,118.91138 0 0 0 -14.71463,19.8011 q -6.14986,10.16771 -9.90055,11.23634 -3.7402,0.80148 -7.22373,-1.33579 l -28.09347,-13.64599 q -3.47829,-1.87535 -4.54692,-5.88795 -1.06863,-4.0126 6.42226,-18.72723 7.75805,-14.98179 23.54131,-30.50313 a 110.00612,110.00612 0 0 1 36.92539,-24.08086 q 21.13689,-8.56476 49.23036,-8.55953 52.17433,0 82.67222,27.55392 30.50312,27.29199 30.50312,72.24259 0,31.03744 -14.98178,53.77728 -14.71987,22.7346 -41.73947,32.11131 v 1.06863 q 32.64039,9.62815 51.36762,35.31196 18.99963,25.42189 18.99439,60.73386 0,50.57138 -35.3172,82.94461 -35.30673,32.37323 -92.03846,32.37323 z m 268.46732,-6.1551 q -4.54692,0 -8.03045,-3.47829 a 11.78637,11.78637 0 0 1 -3.20589,-8.29237 V 341.11326 l -60.19954,43.34241 q -3.21637,2.40966 -7.49613,1.60819 a 10.267238,10.267238 0 0 1 -6.41702,-4.0126 l -17.39145,-24.62042 a 10.372006,10.372006 0 0 1 -1.87534,-7.4909 q 0.80147,-4.27452 4.27976,-6.68418 l 106.75308,-76.25519 q 1.34103,-1.06863 2.94398,-1.60295 1.60818,-0.80147 3.74544,-0.80147 h 22.47792 q 4.54692,0 7.22374,3.21113 2.94397,2.9335 2.94397,7.49089 v 363.3345 q 0,4.81932 -3.47829,8.29237 a 10.476773,10.476773 0 0 1 -8.03045,3.47829 z"
id="path7"
style="stroke-width:5.23839" />
<defs
id="defs10">
<linearGradient
id="b"
x1="83"
x2="83"
y1="76"
gradientUnits="userSpaceOnUse">
<stop
stop-color="#4fa0ff"
id="stop7" />
<stop
offset="1"
stop-color="#3186ff"
id="stop8" />
</linearGradient>
<linearGradient
id="d"
x1="89.059998"
x2="89.059998"
y1="21.75"
y2="96.389999"
gradientUnits="userSpaceOnUse">
<stop
stop-color="#a9a8ff"
id="stop9" />
<stop
offset=".8"
stop-color="#3c90ff"
id="stop10" />
</linearGradient>
<filter
id="e"
width="152"
height="112"
x="20"
y="-4"
color-interpolation-filters="sRGB"
filterUnits="userSpaceOnUse">
<feFlood
flood-opacity="0"
result="BackgroundImageFix"
id="feFlood10" />
<feBlend
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
id="feBlend10"
mode="normal" />
<feGaussianBlur
result="effect1_foregroundBlur_37330_7673"
stdDeviation="6"
id="feGaussianBlur10" />
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 6.5 KiB