diff --git a/packages/nextjs/components/ScheduleCalendar.tsx b/packages/nextjs/components/ScheduleCalendar.tsx index 99baa80..e3a3a0f 100644 --- a/packages/nextjs/components/ScheduleCalendar.tsx +++ b/packages/nextjs/components/ScheduleCalendar.tsx @@ -11,7 +11,9 @@ import { getSessionPosition, getSessionsForDay, sessionTypeColors, + sessions, } from "~~/app/sessions"; +import { addAllSessionsToCalendar } from "~~/utils/calendar"; export const ScheduleCalendar = () => { const [selectedSession, setSelectedSession] = useState(null); @@ -30,6 +32,10 @@ export const ScheduleCalendar = () => { setSelectedSession(null); }; + const handleAddAllToCalendar = () => { + addAllSessionsToCalendar(sessions); + }; + return ( // wrapping div with overflow-x-auto
@@ -118,6 +124,15 @@ export const ScheduleCalendar = () => {
+
+ +
+ diff --git a/packages/nextjs/components/SessionModal.tsx b/packages/nextjs/components/SessionModal.tsx index 4ed0df0..0744938 100644 --- a/packages/nextjs/components/SessionModal.tsx +++ b/packages/nextjs/components/SessionModal.tsx @@ -2,6 +2,7 @@ import Image from "next/image"; import { Session, formatTo12Hour } from "~~/app/sessions"; +import { addSessionToCalendar, getGoogleCalendarUrl } from "~~/utils/calendar"; interface SessionModalProps { session: Session | null; @@ -12,6 +13,31 @@ interface SessionModalProps { export const SessionModal = ({ session, isOpen, onClose }: SessionModalProps) => { if (!session) return null; + const handleGoogleCalendar = () => { + try { + const url = getGoogleCalendarUrl(session); + if (!url) { + alert("Unable to generate Google Calendar link. Please try the ICS download option."); + return; + } + + const newWindow = window.open(url, "_blank"); + if (!newWindow || newWindow.closed || typeof newWindow.closed === "undefined") { + // Popup was blocked + alert( + "Popup blocked! Please allow popups for this site, or copy this link:\n\n" + url.substring(0, 100) + "...", + ); + } + } catch (error) { + console.error("Failed to open Google Calendar:", error); + alert("Unable to open Google Calendar. Please try the ICS download option."); + } + }; + + const handleICSDownload = () => { + addSessionToCalendar(session); + }; + // Solid lighter versions of the session colors const lightColors = { workshop: "#B8D4F7", // light blue @@ -24,8 +50,11 @@ export const SessionModal = ({ session, isOpen, onClose }: SessionModalProps) => }; return ( -
-
+
+

{session.title}

@@ -60,12 +89,30 @@ export const SessionModal = ({ session, isOpen, onClose }: SessionModalProps) =>
-

{session.description}

+

{session.description}

{session.link && ( - + {session.link.text} )} + +
+ +
+ + or + +
diff --git a/packages/nextjs/utils/calendar.ts b/packages/nextjs/utils/calendar.ts new file mode 100644 index 0000000..1205342 --- /dev/null +++ b/packages/nextjs/utils/calendar.ts @@ -0,0 +1,215 @@ +import { Session } from "~~/app/sessions"; + +/** + * Formats date and time for iCalendar with timezone + * @param date - Date string in format YYYY-MM-DD + * @param time - Time string in format HH:MM + * @returns Formatted date string for iCalendar (YYYYMMDDTHHMMSS) + */ +const formatDateTimeForICS = (date: string, time: string): string => { + const [year, month, day] = date.split("-"); + const [hours, minutes] = time.split(":"); + + // Return local time format (not UTC) - timezone will be specified separately + return `${year}${month}${day}T${hours}${minutes}00`; +}; + +/** + * Escapes special characters for iCalendar format + */ +const escapeICalText = (text: string): string => { + return text.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n"); +}; + +/** + * Builds event data for iCalendar format + */ +const buildEventData = (session: Session) => { + const speakers = session.speaker?.map(s => s.name).join(", ") || ""; + const speakerText = speakers ? `\n\nSpeaker(s): ${speakers}` : ""; + + const description = escapeICalText(session.description + speakerText); + const title = escapeICalText(session.title); + const location = "Devconnect main venue - Workshop space (Yellow Pavilion)"; + + // Generate unique ID for the event + const uid = `${session.date}-${session.startTime}-${session.title.replace(/\s+/g, "-")}@devconnect.buidlguidl.com`; + + return { description, title, location, uid }; +}; + +/** + * Generates an iCalendar (.ics) file content for a single session + */ +export const generateSessionICS = (session: Session): string => { + const startDateTime = formatDateTimeForICS(session.date, session.startTime); + const endDateTime = formatDateTimeForICS(session.date, session.endTime); + const { description, title, location, uid } = buildEventData(session); + + const icsContent = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//BuidlGuidl//Devconnect Builder Bootcamp//EN", + "CALSCALE:GREGORIAN", + "METHOD:PUBLISH", + "BEGIN:VTIMEZONE", + "TZID:America/Argentina/Buenos_Aires", + "BEGIN:STANDARD", + "DTSTART:19700101T000000", + "TZOFFSETFROM:-0300", + "TZOFFSETTO:-0300", + "END:STANDARD", + "END:VTIMEZONE", + "BEGIN:VEVENT", + `UID:${uid}`, + `DTSTART;TZID=America/Argentina/Buenos_Aires:${startDateTime}`, + `DTEND;TZID=America/Argentina/Buenos_Aires:${endDateTime}`, + `SUMMARY:${title}`, + `DESCRIPTION:${description}`, + `LOCATION:${location}`, + "STATUS:CONFIRMED", + "SEQUENCE:0", + "END:VEVENT", + "END:VCALENDAR", + ].join("\r\n"); + + return icsContent; +}; + +/** + * Generates an iCalendar (.ics) file content for all sessions + */ +export const generateAllSessionsICS = (sessions: Session[]): string => { + const events = sessions.map(session => { + const startDateTime = formatDateTimeForICS(session.date, session.startTime); + const endDateTime = formatDateTimeForICS(session.date, session.endTime); + const { description, title, location, uid } = buildEventData(session); + + return [ + "BEGIN:VEVENT", + `UID:${uid}`, + `DTSTART;TZID=America/Argentina/Buenos_Aires:${startDateTime}`, + `DTEND;TZID=America/Argentina/Buenos_Aires:${endDateTime}`, + `SUMMARY:${title}`, + `DESCRIPTION:${description}`, + `LOCATION:${location}`, + "STATUS:CONFIRMED", + "SEQUENCE:0", + "END:VEVENT", + ].join("\r\n"); + }); + + const icsContent = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//BuidlGuidl//Devconnect Builder Bootcamp//EN", + "CALSCALE:GREGORIAN", + "METHOD:PUBLISH", + "X-WR-CALNAME:BuidlGuidl Builder Bootcamp @ Devconnect", + "X-WR-TIMEZONE:America/Argentina/Buenos_Aires", + "BEGIN:VTIMEZONE", + "TZID:America/Argentina/Buenos_Aires", + "BEGIN:STANDARD", + "DTSTART:19700101T000000", + "TZOFFSETFROM:-0300", + "TZOFFSETTO:-0300", + "END:STANDARD", + "END:VTIMEZONE", + ...events, + "END:VCALENDAR", + ].join("\r\n"); + + return icsContent; +}; + +/** + * Opens ICS file directly (better UX - no download required) + */ +export const openICS = (icsContent: string, filename: string): void => { + try { + // Use data URI to open calendar app directly without downloading + const dataUri = `data:text/calendar;charset=utf-8,${encodeURIComponent(icsContent)}`; + const link = document.createElement("a"); + link.href = dataUri; + link.download = filename; + document.body.appendChild(link); + + try { + link.click(); + } finally { + // Always clean up DOM element, even if click fails + document.body.removeChild(link); + } + } catch (error) { + console.error("Failed to download calendar file:", error); + alert("Unable to download calendar file. Please try again or use Google Calendar option."); + } +}; + +/** + * Adds a single session to calendar + */ +export const addSessionToCalendar = (session: Session): void => { + try { + if (!session || !session.title || !session.date || !session.startTime || !session.endTime) { + throw new Error("Invalid session data"); + } + + const icsContent = generateSessionICS(session); + const filename = `${session.title.replace(/\s+/g, "-")}.ics`; + openICS(icsContent, filename); + } catch (error) { + console.error("Failed to add session to calendar:", error); + alert("Unable to add event to calendar. Please try again."); + } +}; + +/** + * Adds all sessions to calendar + */ +export const addAllSessionsToCalendar = (sessions: Session[]): void => { + try { + if (!sessions || sessions.length === 0) { + throw new Error("No sessions to add"); + } + + const icsContent = generateAllSessionsICS(sessions); + openICS(icsContent, "BuidlGuidl-Devconnect-All-Sessions.ics"); + } catch (error) { + console.error("Failed to add all sessions to calendar:", error); + alert("Unable to add events to calendar. Please try again."); + } +}; + +/** + * Generates Google Calendar URL for a session + * Note: Times are in Argentina timezone (America/Argentina/Buenos_Aires) + */ +export const getGoogleCalendarUrl = (session: Session): string => { + try { + if (!session || !session.title || !session.date || !session.startTime || !session.endTime) { + throw new Error("Invalid session data"); + } + + // Google Calendar dates format: YYYYMMDDTHHmmss + // We use the raw date/time values which represent Argentina local time + const startDateTime = formatDateTimeForICS(session.date, session.startTime); + const endDateTime = formatDateTimeForICS(session.date, session.endTime); + + const speakers = session.speaker?.map(s => s.name).join(", ") || ""; + const speakerText = speakers ? `\n\nSpeaker(s): ${speakers}` : ""; + // Note: For Google Calendar, we don't escape - we URL encode instead + const details = encodeURIComponent(session.description + speakerText); + const location = encodeURIComponent("Devconnect main venue - Workshop space (Yellow Pavilion)"); + const title = encodeURIComponent(session.title); + + const dates = `${startDateTime}/${endDateTime}`; + + // ctz parameter tells Google Calendar the timezone for the event + return `https://calendar.google.com/calendar/render?action=TEMPLATE&text=${title}&dates=${dates}&details=${details}&location=${location}&ctz=America/Argentina/Buenos_Aires`; + } catch (error) { + console.error("Failed to generate Google Calendar URL:", error); + // Return a fallback URL or empty string + return ""; + } +};