feat(events): added "Add to Google Calendar" button; and clarify .ics download
This commit is contained in:
@@ -67,7 +67,8 @@
|
|||||||
"applyToJoin": "قدّم طلب الانضمام",
|
"applyToJoin": "قدّم طلب الانضمام",
|
||||||
"viewUpcomingSessions": "اطّلع على الجلسات القادمة",
|
"viewUpcomingSessions": "اطّلع على الجلسات القادمة",
|
||||||
"submitYourUpdate": "شاركونا مستجداتكم",
|
"submitYourUpdate": "شاركونا مستجداتكم",
|
||||||
"addToCalendar": "أضف إلى التقويم",
|
"addToGoogleCalendar": "أضف إلى تقويم Google",
|
||||||
|
"downloadIcsFile": "تنزيل ملف .ics",
|
||||||
"tryAgain": "حاول مرة أخرى"
|
"tryAgain": "حاول مرة أخرى"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
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
|
||||||
Registered in app/routes.ts as "eventsDetail", a SIBLING of the listing route
|
Registered in app/routes.ts as "eventsDetail", a SIBLING of the listing 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 |
Reference in New Issue
Block a user