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": "قدّم طلب الانضمام",
"viewUpcomingSessions": "اطّلع على الجلسات القادمة",
"submitYourUpdate": "شاركونا مستجداتكم",
"addToCalendar": "أضف إلى التقويم",
"addToGoogleCalendar": "أضف إلى تقويم Google",
"downloadIcsFile": "تنزيل ملف .ics",
"tryAgain": "حاول مرة أخرى"
},
"filters": {
+2 -1
View File
@@ -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": {
@@ -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 بالبيانات أعلاه لإدارة تسجيلي في هذه الفعالية.",
@@ -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
+62 -2
View File
@@ -13,7 +13,7 @@
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,
The .ics download points at the sibling resource route,
/{locale}/events-calendar/{slug}.ics, which streams a real RFC 5545 file.
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() {
<DetailList heading={copy.eventDetail.detailsHeading} items={details} />
<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
as="a"
href={`${detailPath}.ics`}
@@ -436,7 +496,7 @@ export default function EventDetail() {
variant="secondary"
fullWidth
>
{t("actions.addToCalendar")}
{t("actions.downloadIcsFile")}
</Button>
<p className="body-sm text-text-tertiary">
{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