diff --git a/src/Pages/Events/Calendar/CalendarCell.js b/src/Pages/Events/Calendar/CalendarCell.js new file mode 100644 index 000000000..f2c45e345 --- /dev/null +++ b/src/Pages/Events/Calendar/CalendarCell.js @@ -0,0 +1,57 @@ +import { sortEventsForDay } from './calendarUtils'; +import { EventRow } from './EventRow'; + +export function CalendarCell({ date, isCurrentMonth, isToday, events, onSelectEvent, isAdminView }) { + const sortedEvents = sortEventsForDay(events); + const mobileRows = sortedEvents.slice(0, 2); + const desktopOnlyRows = sortedEvents.slice(2, 4); + const hiddenCount = Math.max(sortedEvents.length - 4, 0); + + return ( +
+
+ + {date.getDate()} + +
+ +
+ {mobileRows.map((ev) => ( + + ))} + + {desktopOnlyRows.map((ev) => ( +
+ +
+ ))} + + {hiddenCount > 0 && ( + + +{hiddenCount} more + + )} +
+
+ ); +} diff --git a/src/Pages/Events/Calendar/CalendarGrid.js b/src/Pages/Events/Calendar/CalendarGrid.js new file mode 100644 index 000000000..c402641ce --- /dev/null +++ b/src/Pages/Events/Calendar/CalendarGrid.js @@ -0,0 +1,21 @@ +import { DayLabels } from './DayLabels'; +import { CalendarCell } from './CalendarCell'; + +export function CalendarGrid({ cells, onSelectEvent, isAdminView }) { + return ( +
+ + +
+ {cells.map((cell) => ( + + ))} +
+
+ ); +} diff --git a/src/Pages/Events/Calendar/CalendarHeader.js b/src/Pages/Events/Calendar/CalendarHeader.js new file mode 100644 index 000000000..e76a7f7df --- /dev/null +++ b/src/Pages/Events/Calendar/CalendarHeader.js @@ -0,0 +1,92 @@ +import { Link } from 'react-router-dom'; +import { ChevronLeft, ChevronRight, PlusIcon } from '../EventIcons'; +import { MONTHS, YEAR_RANGE } from './calendarConstants'; + +export function CalendarHeader({ + month, + year, + monthEventCount, + canCreateEvent, + onMonthChange, + onYearChange, + onTodayClick, + onPreviousMonth, + onNextMonth, +}) { + return ( +
+
+
+
+ +
+ +
+ +
+ + +
+ +

+ {monthEventCount} event{monthEventCount !== 1 ? 's' : ''} this month +

+
+ +
+ {canCreateEvent && ( + + + + )} + + + + +
+
+ ); +} diff --git a/src/Pages/Events/Calendar/CalendarLegend.js b/src/Pages/Events/Calendar/CalendarLegend.js new file mode 100644 index 000000000..e657529ea --- /dev/null +++ b/src/Pages/Events/Calendar/CalendarLegend.js @@ -0,0 +1,26 @@ +export function CalendarLegend({ isAdminView }) { + const legendItems = isAdminView + ? [ + { label: 'Scheduled', dot: 'bg-blue-300' }, + { label: 'Published', dot: 'bg-cyan-300' }, + { label: 'Private', dot: 'bg-violet-300' }, + { label: 'Draft', dot: 'bg-amber-300' }, + { label: 'Closed', dot: 'bg-rose-300' }, + ] + : [ + { label: 'Open', dot: 'bg-cyan-300' }, + { label: 'Members only', dot: 'bg-violet-300' }, + { label: 'Closed', dot: 'bg-rose-300' }, + ]; + + return ( +
+ {legendItems.map(({ label, dot }) => ( + + + {label} + + ))} +
+ ); +} diff --git a/src/Pages/Events/Calendar/CalendarView.js b/src/Pages/Events/Calendar/CalendarView.js new file mode 100644 index 000000000..6e4e54d71 --- /dev/null +++ b/src/Pages/Events/Calendar/CalendarView.js @@ -0,0 +1,123 @@ +import { useState, useMemo } from 'react'; +import { toDateKey } from '../eventUtils'; +import { eventDateKey, sortEventsForDay } from './calendarUtils'; +import { EventPopup } from './EventPopup'; +import { CalendarHeader } from './CalendarHeader'; +import { CalendarGrid } from './CalendarGrid'; +import { MobileMonthAgenda } from './MobileMonthAgenda'; +import { CalendarLegend } from './CalendarLegend'; + +export default function CalendarView({ + events, + isAdminView = false, + user, + canCreateEvent = false, + cursor, + setCursor, +}) { + const today = useMemo(() => new Date(), []); + const [selectedEvent, setSelectedEvent] = useState(null); + + const eventsByDate = useMemo(() => { + const map = {}; + events.forEach((ev) => { + const key = eventDateKey(ev); + if (!key) return; + if (!map[key]) map[key] = []; + map[key].push(ev); + }); + Object.keys(map).forEach((key) => { + map[key] = sortEventsForDay(map[key]); + }); + return map; + }, [events]); + + const year = cursor.getFullYear(); + const month = cursor.getMonth(); + + function handleMonthChange(e) { + const nextMonth = Number(e.target.value); + setCursor(new Date(year, nextMonth, 1)); + } + + function handleYearChange(e) { + const nextYear = Number(e.target.value); + setCursor(new Date(nextYear, month, 1)); + } + + const firstDayOfMonth = new Date(year, month, 1).getDay(); + const daysInMonth = new Date(year, month + 1, 0).getDate(); + const totalCells = Math.ceil((firstDayOfMonth + daysInMonth) / 7) * 7; + const todayKey = toDateKey(today); + + const cells = Array.from({ length: totalCells }, (_, i) => { + const dayOffset = i - firstDayOfMonth; + const date = new Date(year, month, dayOffset + 1); + const key = toDateKey(date); + return { + date, + key, + isCurrentMonth: date.getMonth() === month, + isToday: key === todayKey, + events: eventsByDate[key] || [], + }; + }); + + const monthEventCount = cells + .filter((c) => c.isCurrentMonth) + .reduce((sum, c) => sum + c.events.length, 0); + + const monthEvents = useMemo(() => { + return cells + .filter((c) => c.isCurrentMonth && c.events.length > 0) + .flatMap((c) => + c.events.map((event) => ({ + event, + dateKey: c.key, + })) + ); + }, [cells]); + + return ( + <> + {selectedEvent && ( + setSelectedEvent(null)} + isAdminView={isAdminView} + user={user} + /> + )} + +
+ setCursor(new Date(today.getFullYear(), today.getMonth(), 1))} + onPreviousMonth={() => setCursor(new Date(year, month - 1, 1))} + onNextMonth={() => setCursor(new Date(year, month + 1, 1))} + /> + +
+ +
+ + + + +
+ + ); +} diff --git a/src/Pages/Events/Calendar/DayLabels.js b/src/Pages/Events/Calendar/DayLabels.js new file mode 100644 index 000000000..d09ced7a8 --- /dev/null +++ b/src/Pages/Events/Calendar/DayLabels.js @@ -0,0 +1,16 @@ +import { DAYS } from './calendarConstants'; + +export function DayLabels() { + return ( +
+ {DAYS.map((d) => ( +
+ {d} +
+ ))} +
+ ); +} diff --git a/src/Pages/Events/Calendar/EventPopup.js b/src/Pages/Events/Calendar/EventPopup.js new file mode 100644 index 000000000..ec6a97338 --- /dev/null +++ b/src/Pages/Events/Calendar/EventPopup.js @@ -0,0 +1,342 @@ +import { useEffect, useRef, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { + getEventAttendanceSummary, + getMyEventRegistrationState, + joinWaitlistForSCEvent, +} from '../../../APIFunctions/SCEvents'; +import { calculateEventCapacity, getApiErrorMessage } from '../eventUtils'; +import { + CalendarIcon, + PinIcon, + ClockIcon, + XIcon, + ScheduleIcon, +} from '../EventIcons'; +import { + canUserManageEvent, + formatDate, + formatTime, + getBadgeText, + getRegistrationCta, + pillColors, +} from './calendarUtils'; + +export function EventPopup({ event, onClose, isAdminView, user }) { + const popupRef = useRef(null); + const colors = pillColors(event, isAdminView); + const badgeText = getBadgeText(event, isAdminView); + const userId = user?._id != null ? String(user._id) : ''; + const [attendeeCount, setAttendeeCount] = useState(null); + const [attendanceLoading, setAttendanceLoading] = useState(false); + const [attendanceLoaded, setAttendanceLoaded] = useState(false); + const [waitlistSubmitting, setWaitlistSubmitting] = useState(false); + const [waitlistMessage, setWaitlistMessage] = useState(''); + const [waitlistError, setWaitlistError] = useState(''); + const canManageEvent = canUserManageEvent(event, user); + const [hasRegistered, setHasRegistered] = useState(false); + const [isCheckingRegistration, setIsCheckingRegistration] = useState(false); + const eventId = event?.id || event?._id; + const authToken = user?.token; + const { maxAttendees, hasCapacityLimit, remainingSpots, isFull } = calculateEventCapacity(event, attendeeCount); + const registrationCta = getRegistrationCta(event, isAdminView); + const shouldShowWaitlistJoin = + !canManageEvent && + event.status === 'published' && + !registrationCta.disabled && + isFull && + !!event.waitlist_enabled; + + useEffect(() => { + function onKey(e) { + if (e.key === 'Escape') onClose(); + } + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [onClose]); + + useEffect(() => { + async function fetchMyRegistrationState() { + if (!userId || canManageEvent || !user?.token || event.status !== 'published') { + setHasRegistered(false); + setIsCheckingRegistration(false); + return; + } + + setIsCheckingRegistration(true); + const eventID = event?.id || event?._id; + const response = await getMyEventRegistrationState(eventID, user.token); + if (!response.error) { + setHasRegistered(Boolean(response.responseData?.registered)); + } else { + setHasRegistered(false); + } + setIsCheckingRegistration(false); + } + + fetchMyRegistrationState(); + }, [canManageEvent, event, user?.token, userId]); + + useEffect(() => { + function onClickOutside(e) { + if (popupRef.current && !popupRef.current.contains(e.target)) { + onClose(); + } + } + const timer = setTimeout(() => document.addEventListener('mousedown', onClickOutside), 0); + return () => { + clearTimeout(timer); + document.removeEventListener('mousedown', onClickOutside); + }; + }, [onClose]); + + useEffect(() => { + let isCurrent = true; + setAttendeeCount(null); + setAttendanceLoaded(false); + setAttendanceLoading(false); + + async function fetchAttendanceSummary() { + if (!eventId || !authToken) { + return; + } + setAttendanceLoading(true); + const response = await getEventAttendanceSummary(eventId, authToken); + if (isCurrent && !response.error && typeof response.responseData?.attendee_count === 'number') { + setAttendeeCount(response.responseData.attendee_count); + } + if (isCurrent) { + setAttendanceLoaded(true); + setAttendanceLoading(false); + } + } + + fetchAttendanceSummary(); + + return () => { + isCurrent = false; + }; + }, [eventId, authToken]); + + async function handleJoinWaitlist() { + setWaitlistError(''); + setWaitlistMessage(''); + + if (!authToken) { + setWaitlistError('You must be logged in to join the waitlist.'); + return; + } + + if (!eventId) { + setWaitlistError('Missing event id.'); + return; + } + + setWaitlistSubmitting(true); + const response = await joinWaitlistForSCEvent(eventId, authToken); + setWaitlistSubmitting(false); + + if (response.error) { + setWaitlistError(getApiErrorMessage(response, { fallback: 'Failed to join waitlist.' })); + return; + } + + setWaitlistMessage('Joined waitlist successfully.'); + } + return ( +
+
+
+ +
+
+ + + {event.category || event.type || 'Event'} + +
+ +
+ +
+

+ {event.name || 'Untitled Event'} +

+ +
+ {badgeText && ( + + {badgeText} + + )} +
+
+ +
+ {event.date && ( +
+ + {formatDate(event.date)} +
+ )} + + {event.time && ( +
+ + {formatTime(event.time)} +
+ )} + + {event.location && ( +
+ + {event.location} +
+ )} + + {event.description && ( +

+ {event.description} +

+ )} +
+ +
+ {event.max_attendees > 0 && ( +

+ {event.max_attendees} total spot{event.max_attendees !== 1 ? 's' : ''} + {canManageEvent && event.waitlist_enabled && ( + · waitlist available + )} +

+ )} + + {hasCapacityLimit && !canManageEvent && typeof remainingSpots === 'number' && ( +

+ {remainingSpots} spot{remainingSpots !== 1 ? 's' : ''} left + {event.waitlist_enabled && ( + · waitlist available + )} +

+ )} + + {hasCapacityLimit && !canManageEvent && attendanceLoading && ( +

+ Loading live spots... +

+ )} + + {hasCapacityLimit && !canManageEvent && attendanceLoaded && typeof remainingSpots !== 'number' && ( +

+ Unable to load live spots +

+ )} + {canManageEvent && ( +
+ + View attendees + + + Edit event + +
+ )} + + {!canManageEvent && event.status === 'closed' && ( +
+ Registration closed +
+ )} + + {!canManageEvent && event.status === 'draft' && ( +
+ Not yet published +
+ )} + + {!canManageEvent && event.status === 'published' && registrationCta.disabled && ( +
+ {registrationCta.label} +
+ )} + + {shouldShowWaitlistJoin && ( + + )} + + {!shouldShowWaitlistJoin && !canManageEvent && event.status === 'published' && !registrationCta.disabled && ( + + {registrationCta.label} + + )} + + {waitlistError && ( +

{waitlistError}

+ )} + + {waitlistMessage && ( +

{waitlistMessage}

+ )} + + {isAdminView && event.publish_date && ( +
+ + + Publish date: {new Date(event.publish_date).toLocaleString()} + +
+ )} + + {isAdminView && event.published_at && ( +
+ Published at: {new Date(event.published_at).toLocaleString()} +
+ )} +
+
+
+ ); +} diff --git a/src/Pages/Events/Calendar/EventRow.js b/src/Pages/Events/Calendar/EventRow.js new file mode 100644 index 000000000..cb4637bdf --- /dev/null +++ b/src/Pages/Events/Calendar/EventRow.js @@ -0,0 +1,40 @@ +import { formatTime, getBadgeText, pillColors } from './calendarUtils'; + +export function EventRow({ event, onSelect, isAdminView }) { + const colors = pillColors(event, isAdminView); + const badgeText = getBadgeText(event, isAdminView); + const timeLabel = formatTime(event.time); + + return ( + + ); +} diff --git a/src/Pages/Events/Calendar/MobileMonthAgenda.js b/src/Pages/Events/Calendar/MobileMonthAgenda.js new file mode 100644 index 000000000..acb167d6f --- /dev/null +++ b/src/Pages/Events/Calendar/MobileMonthAgenda.js @@ -0,0 +1,86 @@ +import { formatTime, getBadgeText, pillColors } from './calendarUtils'; + +export function MobileMonthAgenda({ monthEvents, onSelectEvent, isAdminView }) { + if (monthEvents.length === 0) { + return ( +
+ No events scheduled this month. +
+ ); + } + + const groupedEvents = monthEvents.reduce((groups, item) => { + const lastGroup = groups[groups.length - 1]; + if (!lastGroup || lastGroup.dateKey !== item.dateKey) { + groups.push({ dateKey: item.dateKey, events: [item.event] }); + return groups; + } + lastGroup.events.push(item.event); + return groups; + }, []); + + return ( +
+ {groupedEvents.map(({ dateKey, events }) => { + const [year, month, day] = dateKey.split('-'); + const date = new Date(Number(year), Number(month) - 1, Number(day)); + const weekday = date.toLocaleDateString('en-US', { weekday: 'short' }).toUpperCase(); + const dayOfMonth = String(date.getDate()); + + return ( +
+
+
+ {weekday} +
+
+ {dayOfMonth} +
+
+ +
+ {events.map((event) => { + const colors = pillColors(event, isAdminView); + const badgeText = getBadgeText(event, isAdminView); + const timeLabel = formatTime(event.time) || 'All day'; + + return ( + + ); + })} +
+
+ ); + })} +
+ ); +} diff --git a/src/Pages/Events/Calendar/calendarConstants.js b/src/Pages/Events/Calendar/calendarConstants.js new file mode 100644 index 000000000..a3be0f6c6 --- /dev/null +++ b/src/Pages/Events/Calendar/calendarConstants.js @@ -0,0 +1,9 @@ +export const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + +export const MONTHS = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December', +]; + +const currentYear = new Date().getFullYear(); +export const YEAR_RANGE = Array.from({ length: 10 }, (_, i) => currentYear + i); diff --git a/src/Pages/Events/Calendar/calendarUtils.js b/src/Pages/Events/Calendar/calendarUtils.js new file mode 100644 index 000000000..57b9361cf --- /dev/null +++ b/src/Pages/Events/Calendar/calendarUtils.js @@ -0,0 +1,230 @@ +import { membershipState } from '../../../Enums'; +import { toDateKey } from '../eventUtils'; + +export function eventDateKey(event) { + if (!event.date) return null; + if (/^\d{4}-\d{2}-\d{2}$/.test(event.date)) return event.date; + const d = new Date(event.date); + if (isNaN(d)) return null; + return toDateKey(d); +} + +export function toTimeSortValue(event) { + if (!event?.time) return Number.POSITIVE_INFINITY; + const [hour, minute] = String(event.time).split(':').map(Number); + if (Number.isNaN(hour) || Number.isNaN(minute)) return Number.POSITIVE_INFINITY; + return hour * 60 + minute; +} + +export function sortEventsForDay(events) { + return [...events].sort((a, b) => { + const timeDelta = toTimeSortValue(a) - toTimeSortValue(b); + if (timeDelta !== 0) return timeDelta; + return String(a.name || '').localeCompare(String(b.name || '')); + }); +} + +export function formatDate(dateStr) { + if (!dateStr) return ''; + const d = new Date(`${dateStr}T12:00:00`); + if (isNaN(d)) return dateStr; + return d.toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric', + year: 'numeric', + }); +} + +export function formatTime(timeStr) { + if (!timeStr) return ''; + const [hours, minutes] = String(timeStr).split(':'); + if (hours == null || minutes == null) return timeStr; + + const h = Number(hours); + const m = Number(minutes); + if (Number.isNaN(h) || Number.isNaN(m)) return timeStr; + + return new Date(2000, 0, 1, h, m).toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + }); +} + +export function isScheduledEvent(event) { + if (event.status !== 'draft' || !event.publish_date) return false; + const publishDate = new Date(event.publish_date); + return !isNaN(publishDate) && publishDate > new Date(); +} + +export function pillColors(event, isAdminView) { + const status = event.status || 'draft'; + const visibility = event.visibility || 'public'; + + if (isAdminView) { + if (isScheduledEvent(event)) { + return { + bg: 'bg-blue-500/12', + text: 'text-blue-200', + border: 'border-blue-400/40', + dot: 'bg-blue-300', + accent: 'text-blue-300', + }; + } + + if (status === 'draft') { + return { + bg: 'bg-amber-500/12', + text: 'text-amber-200', + border: 'border-amber-400/40', + dot: 'bg-amber-300', + accent: 'text-amber-300', + }; + } + + if (status === 'closed') { + return { + bg: 'bg-rose-500/12', + text: 'text-rose-200', + border: 'border-rose-400/40', + dot: 'bg-rose-300', + accent: 'text-rose-300', + }; + } + + if (visibility === 'private') { + return { + bg: 'bg-violet-500/12', + text: 'text-violet-200', + border: 'border-violet-400/40', + dot: 'bg-violet-300', + accent: 'text-violet-300', + }; + } + + return { + bg: 'bg-cyan-500/12', + text: 'text-cyan-100', + border: 'border-cyan-400/40', + dot: 'bg-cyan-300', + accent: 'text-cyan-300', + }; + } + + if (status === 'closed') { + return { + bg: 'bg-rose-500/16', + text: 'text-rose-200', + border: 'border-rose-400/30', + dot: 'bg-rose-300', + accent: 'text-rose-300', + }; + } + + if (visibility === 'private') { + return { + bg: 'bg-violet-500/16', + text: 'text-violet-200', + border: 'border-violet-400/30', + dot: 'bg-violet-300', + accent: 'text-violet-300', + }; + } + + return { + bg: 'bg-cyan-500/16', + text: 'text-cyan-100', + border: 'border-cyan-400/30', + dot: 'bg-cyan-300', + accent: 'text-cyan-300', + }; +} + +export function getBadgeText(event, isAdminView) { + const status = event.status || 'draft'; + const visibility = event.visibility || 'public'; + + if (isAdminView) { + if (isScheduledEvent(event)) return 'Scheduled'; + if (status === 'draft') return 'Draft'; + if (status === 'closed') return 'Closed'; + if (visibility === 'private') return 'Private'; + return 'Published'; + } + + if (status === 'closed') return 'Closed'; + if (visibility === 'private') return 'Members only'; + return ''; +} + +export function getRegistrationCta(event, isAdminView) { + const registrationStatus = event?.registration_status || 'none'; + + switch (registrationStatus) { + case 'registered': + return { + label: 'Registered', + disabled: true, + className: 'border border-emerald-400/30 bg-emerald-500/10 text-emerald-200', + }; + case 'pending': + return { + label: 'Pending', + disabled: true, + className: 'border border-amber-400/30 bg-amber-500/10 text-amber-200', + }; + case 'waitlisted': + return { + label: 'On waitlist', + disabled: true, + className: 'border border-violet-400/30 bg-violet-500/10 text-violet-200', + }; + case 'rejected': + if (isAdminView) { + return { + label: 'Register', + disabled: false, + className: '', + }; + } + return { + label: 'Unavailable', + disabled: true, + className: 'border border-slate-500/40 bg-slate-700/40 text-slate-300', + }; + default: + return { + label: 'Register', + disabled: false, + className: '', + }; + } +} + +/** + * Checks if a user has permission to manage an event. + * @param {Object} event The event object. + * @param {Object} user The user object. + * @returns {boolean} True if the user can manage the event. + */ +export function canUserManageEvent(event, user) { + const userId = user?._id != null ? String(user._id) : ''; + const access = user?.accessLevel ?? 0; + const eventAdmins = Array.isArray(event.admins) ? event.admins.map((id) => String(id)) : []; + + // 1. If user is explicitly listed as an admin for this event + if (eventAdmins.length > 0 && userId && eventAdmins.includes(userId)) { + return true; + } + + if (event.all_org_admins_can_edit && access >= membershipState.OFFICER) { + return true; + } + + // 2. If the event has no admins, allow users with level 3 (ADMIN) or higher to manage it + if (eventAdmins.length === 0 && access >= membershipState.ADMIN) { + return true; + } + + return false; +} diff --git a/src/Pages/Events/CalendarView.js b/src/Pages/Events/CalendarView.js deleted file mode 100644 index d63d1dec0..000000000 --- a/src/Pages/Events/CalendarView.js +++ /dev/null @@ -1,1000 +0,0 @@ -import { useState, useMemo, useEffect, useRef } from 'react'; -import { Link } from 'react-router-dom'; -import { getEventAttendanceSummary, getMyEventRegistrationState, joinWaitlistForSCEvent } from '../../APIFunctions/SCEvents'; -import { membershipState } from '../../Enums'; -import { calculateEventCapacity, getApiErrorMessage, toDateKey } from './eventUtils'; -import { - ChevronLeft, - ChevronRight, - CalendarIcon, - PinIcon, - ClockIcon, - XIcon, - PlusIcon, - ScheduleIcon, -} from './EventIcons'; - -const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; -const MONTHS = [ - 'January', 'February', 'March', 'April', 'May', 'June', - 'July', 'August', 'September', 'October', 'November', 'December', -]; -const currentYear = new Date().getFullYear(); -const YEAR_RANGE = Array.from({ length: 10 }, (_, i) => currentYear + i); - -function eventDateKey(event) { - if (!event.date) return null; - if (/^\d{4}-\d{2}-\d{2}$/.test(event.date)) return event.date; - const d = new Date(event.date); - if (isNaN(d)) return null; - return toDateKey(d); -} - -function toTimeSortValue(event) { - if (!event?.time) return Number.POSITIVE_INFINITY; - const [hour, minute] = String(event.time).split(':').map(Number); - if (Number.isNaN(hour) || Number.isNaN(minute)) return Number.POSITIVE_INFINITY; - return hour * 60 + minute; -} - -function sortEventsForDay(events) { - return [...events].sort((a, b) => { - const timeDelta = toTimeSortValue(a) - toTimeSortValue(b); - if (timeDelta !== 0) return timeDelta; - return String(a.name || '').localeCompare(String(b.name || '')); - }); -} - -function formatDate(dateStr) { - if (!dateStr) return ''; - const d = new Date(`${dateStr}T12:00:00`); - if (isNaN(d)) return dateStr; - return d.toLocaleDateString('en-US', { - weekday: 'long', - month: 'long', - day: 'numeric', - year: 'numeric', - }); -} - -function formatTime(timeStr) { - if (!timeStr) return ''; - const [hours, minutes] = String(timeStr).split(':'); - if (hours == null || minutes == null) return timeStr; - - const h = Number(hours); - const m = Number(minutes); - if (Number.isNaN(h) || Number.isNaN(m)) return timeStr; - - return new Date(2000, 0, 1, h, m).toLocaleTimeString('en-US', { - hour: 'numeric', - minute: '2-digit', - }); -} - -function isScheduledEvent(event) { - if (event.status !== 'draft' || !event.publish_date) return false; - const publishDate = new Date(event.publish_date); - return !isNaN(publishDate) && publishDate > new Date(); -} - -function pillColors(event, isAdminView) { - const status = event.status || 'draft'; - const visibility = event.visibility || 'public'; - - if (isAdminView) { - if (isScheduledEvent(event)) { - return { - bg: 'bg-blue-500/12', - text: 'text-blue-200', - border: 'border-blue-400/40', - dot: 'bg-blue-300', - accent: 'text-blue-300', - }; - } - - if (status === 'draft') { - return { - bg: 'bg-amber-500/12', - text: 'text-amber-200', - border: 'border-amber-400/40', - dot: 'bg-amber-300', - accent: 'text-amber-300', - }; - } - - if (status === 'closed') { - return { - bg: 'bg-rose-500/12', - text: 'text-rose-200', - border: 'border-rose-400/40', - dot: 'bg-rose-300', - accent: 'text-rose-300', - }; - } - - if (visibility === 'private') { - return { - bg: 'bg-violet-500/12', - text: 'text-violet-200', - border: 'border-violet-400/40', - dot: 'bg-violet-300', - accent: 'text-violet-300', - }; - } - - return { - bg: 'bg-cyan-500/12', - text: 'text-cyan-100', - border: 'border-cyan-400/40', - dot: 'bg-cyan-300', - accent: 'text-cyan-300', - }; - } - - if (status === 'closed') { - return { - bg: 'bg-rose-500/16', - text: 'text-rose-200', - border: 'border-rose-400/30', - dot: 'bg-rose-300', - accent: 'text-rose-300', - }; - } - - if (visibility === 'private') { - return { - bg: 'bg-violet-500/16', - text: 'text-violet-200', - border: 'border-violet-400/30', - dot: 'bg-violet-300', - accent: 'text-violet-300', - }; - } - - return { - bg: 'bg-cyan-500/16', - text: 'text-cyan-100', - border: 'border-cyan-400/30', - dot: 'bg-cyan-300', - accent: 'text-cyan-300', - }; -} - -function getBadgeText(event, isAdminView) { - const status = event.status || 'draft'; - const visibility = event.visibility || 'public'; - - if (isAdminView) { - if (isScheduledEvent(event)) return 'Scheduled'; - if (status === 'draft') return 'Draft'; - if (status === 'closed') return 'Closed'; - if (visibility === 'private') return 'Private'; - return 'Published'; - } - - if (status === 'closed') return 'Closed'; - if (visibility === 'private') return 'Members only'; - return ''; -} - -function getRegistrationCta(event, isAdminView) { - const registrationStatus = event?.registration_status || 'none'; - - switch (registrationStatus) { - case 'registered': - return { - label: 'Registered', - disabled: true, - className: 'border border-emerald-400/30 bg-emerald-500/10 text-emerald-200', - }; - case 'pending': - return { - label: 'Pending', - disabled: true, - className: 'border border-amber-400/30 bg-amber-500/10 text-amber-200', - }; - case 'waitlisted': - return { - label: 'On waitlist', - disabled: true, - className: 'border border-violet-400/30 bg-violet-500/10 text-violet-200', - }; - case 'rejected': - if (isAdminView) { - return { - label: 'Register', - disabled: false, - className: '', - }; - } - return { - label: 'Unavailable', - disabled: true, - className: 'border border-slate-500/40 bg-slate-700/40 text-slate-300', - }; - default: - return { - label: 'Register', - disabled: false, - className: '', - }; - } -} - -/** - * Checks if a user has permission to manage an event. - * @param {Object} event The event object. - * @param {Object} user The user object. - * @returns {boolean} True if the user can manage the event. - */ -function canUserManageEvent(event, user) { - const userId = user?._id != null ? String(user._id) : ''; - const access = user?.accessLevel ?? 0; - const eventAdmins = Array.isArray(event.admins) ? event.admins.map((id) => String(id)) : []; - - // 1. If user is explicitly listed as an admin for this event - if (eventAdmins.length > 0 && userId && eventAdmins.includes(userId)) { - return true; - } - - if (event.all_org_admins_can_edit && access >= membershipState.OFFICER) { - return true; - } - - // 2. If the event has no admins, allow users with level 3 (ADMIN) or higher to manage it - if (eventAdmins.length === 0 && access >= membershipState.ADMIN) { - return true; - } - - return false; -} - -// ─── Event popup ────────────────────────────────────────────────────────────── - -function EventPopup({ event, onClose, isAdminView, user }) { - const popupRef = useRef(null); - const colors = pillColors(event, isAdminView); - const badgeText = getBadgeText(event, isAdminView); - const userId = user?._id != null ? String(user._id) : ''; - const [attendeeCount, setAttendeeCount] = useState(null); - const [attendanceLoading, setAttendanceLoading] = useState(false); - const [attendanceLoaded, setAttendanceLoaded] = useState(false); - const [waitlistSubmitting, setWaitlistSubmitting] = useState(false); - const [waitlistMessage, setWaitlistMessage] = useState(''); - const [waitlistError, setWaitlistError] = useState(''); - const canManageEvent = canUserManageEvent(event, user); - const [hasRegistered, setHasRegistered] = useState(false); - const [isCheckingRegistration, setIsCheckingRegistration] = useState(false); - const eventId = event?.id || event?._id; - const authToken = user?.token; - const { maxAttendees, hasCapacityLimit, remainingSpots, isFull } = calculateEventCapacity(event, attendeeCount); - const registrationCta = getRegistrationCta(event, isAdminView); - const shouldShowWaitlistJoin = - !canManageEvent && - event.status === 'published' && - !registrationCta.disabled && - isFull && - !!event.waitlist_enabled; - - useEffect(() => { - function onKey(e) { - if (e.key === 'Escape') onClose(); - } - document.addEventListener('keydown', onKey); - return () => document.removeEventListener('keydown', onKey); - }, [onClose]); - - useEffect(() => { - async function fetchMyRegistrationState() { - if (!userId || canManageEvent || !user?.token || event.status !== 'published') { - setHasRegistered(false); - setIsCheckingRegistration(false); - return; - } - - setIsCheckingRegistration(true); - const eventID = event?.id || event?._id; - const response = await getMyEventRegistrationState(eventID, user.token); - if (!response.error) { - setHasRegistered(Boolean(response.responseData?.registered)); - } else { - setHasRegistered(false); - } - setIsCheckingRegistration(false); - } - - fetchMyRegistrationState(); - }, [canManageEvent, event, user?.token, userId]); - - useEffect(() => { - function onClickOutside(e) { - if (popupRef.current && !popupRef.current.contains(e.target)) { - onClose(); - } - } - const timer = setTimeout(() => document.addEventListener('mousedown', onClickOutside), 0); - return () => { - clearTimeout(timer); - document.removeEventListener('mousedown', onClickOutside); - }; - }, [onClose]); - - useEffect(() => { - let isCurrent = true; - setAttendeeCount(null); - setAttendanceLoaded(false); - setAttendanceLoading(false); - - async function fetchAttendanceSummary() { - if (!eventId || !authToken) { - return; - } - setAttendanceLoading(true); - const response = await getEventAttendanceSummary(eventId, authToken); - if (isCurrent && !response.error && typeof response.responseData?.attendee_count === 'number') { - setAttendeeCount(response.responseData.attendee_count); - } - if (isCurrent) { - setAttendanceLoaded(true); - setAttendanceLoading(false); - } - } - - fetchAttendanceSummary(); - - return () => { - isCurrent = false; - }; - }, [eventId, authToken]); - - async function handleJoinWaitlist() { - setWaitlistError(''); - setWaitlistMessage(''); - - if (!authToken) { - setWaitlistError('You must be logged in to join the waitlist.'); - return; - } - - if (!eventId) { - setWaitlistError('Missing event id.'); - return; - } - - setWaitlistSubmitting(true); - const response = await joinWaitlistForSCEvent(eventId, authToken); - setWaitlistSubmitting(false); - - if (response.error) { - setWaitlistError(getApiErrorMessage(response, { fallback: 'Failed to join waitlist.' })); - return; - } - - setWaitlistMessage('Joined waitlist successfully.'); - } - return ( -
-
-
- -
-
- - - {event.category || event.type || 'Event'} - -
- -
- -
-

- {event.name || 'Untitled Event'} -

- -
- {badgeText && ( - - {badgeText} - - )} -
-
- -
- {event.date && ( -
- - {formatDate(event.date)} -
- )} - - {event.time && ( -
- - {formatTime(event.time)} -
- )} - - {event.location && ( -
- - {event.location} -
- )} - - {event.description && ( -

- {event.description} -

- )} -
- -
- {event.max_attendees > 0 && ( -

- {event.max_attendees} total spot{event.max_attendees !== 1 ? 's' : ''} - {canManageEvent && event.waitlist_enabled && ( - · waitlist available - )} -

- )} - - {hasCapacityLimit && !canManageEvent && typeof remainingSpots === 'number' && ( -

- {remainingSpots} spot{remainingSpots !== 1 ? 's' : ''} left - {event.waitlist_enabled && ( - · waitlist available - )} -

- )} - - {hasCapacityLimit && !canManageEvent && attendanceLoading && ( -

- Loading live spots... -

- )} - - {hasCapacityLimit && !canManageEvent && attendanceLoaded && typeof remainingSpots !== 'number' && ( -

- Unable to load live spots -

- )} - {canManageEvent && ( -
- - View attendees - - - Edit event - -
- )} - - {!canManageEvent && event.status === 'closed' && ( -
- Registration closed -
- )} - - {!canManageEvent && event.status === 'draft' && ( -
- Not yet published -
- )} - - {!canManageEvent && event.status === 'published' && registrationCta.disabled && ( -
- {registrationCta.label} -
- )} - - {shouldShowWaitlistJoin && ( - - )} - - {!shouldShowWaitlistJoin && !canManageEvent && event.status === 'published' && !registrationCta.disabled && ( - - {registrationCta.label} - - )} - - {waitlistError && ( -

{waitlistError}

- )} - - {waitlistMessage && ( -

{waitlistMessage}

- )} - - {isAdminView && event.publish_date && ( -
- - - Publish date: {new Date(event.publish_date).toLocaleString()} - -
- )} - - {isAdminView && event.published_at && ( -
- Published at: {new Date(event.published_at).toLocaleString()} -
- )} -
-
-
- ); -} - -// ─── Event pill ─────────────────────────────────────────────────────────────── - -function EventRow({ event, onSelect, isAdminView }) { - const colors = pillColors(event, isAdminView); - const badgeText = getBadgeText(event, isAdminView); - const timeLabel = formatTime(event.time); - - return ( - - ); -} - -// ─── Day labels ─────────────────────────────────────────────────────────────── - -function DayLabels() { - return ( -
- {DAYS.map((d) => ( -
- {d} -
- ))} -
- ); -} - -// ─── Calendar cell ──────────────────────────────────────────────────────────── - -function CalCell({ date, isCurrentMonth, isToday, events, onSelectEvent, isAdminView }) { - const sortedEvents = sortEventsForDay(events); - const mobileRows = sortedEvents.slice(0, 2); - const desktopOnlyRows = sortedEvents.slice(2, 4); - const hiddenCount = Math.max(sortedEvents.length - 4, 0); - - return ( -
-
- - {date.getDate()} - -
- -
- {mobileRows.map((ev) => ( - - ))} - - {desktopOnlyRows.map((ev) => ( -
- -
- ))} - - {hiddenCount > 0 && ( - - +{hiddenCount} more - - )} -
-
- ); -} - -function MobileMonthAgenda({ monthEvents, onSelectEvent, isAdminView }) { - if (monthEvents.length === 0) { - return ( -
- No events scheduled this month. -
- ); - } - - const groupedEvents = monthEvents.reduce((groups, item) => { - const lastGroup = groups[groups.length - 1]; - if (!lastGroup || lastGroup.dateKey !== item.dateKey) { - groups.push({ dateKey: item.dateKey, events: [item.event] }); - return groups; - } - lastGroup.events.push(item.event); - return groups; - }, []); - - return ( -
- {groupedEvents.map(({ dateKey, events }) => { - const [year, month, day] = dateKey.split('-'); - const date = new Date(Number(year), Number(month) - 1, Number(day)); - const weekday = date.toLocaleDateString('en-US', { weekday: 'short' }).toUpperCase(); - const dayOfMonth = String(date.getDate()); - - return ( -
-
-
- {weekday} -
-
- {dayOfMonth} -
-
- -
- {events.map((event) => { - const colors = pillColors(event, isAdminView); - const badgeText = getBadgeText(event, isAdminView); - const timeLabel = formatTime(event.time) || 'All day'; - - return ( - - ); - })} -
-
- ); - })} -
- ); -} - -// ─── Main export ────────────────────────────────────────────────────────────── - -export default function CalendarView({ - events, - isAdminView = false, - user, - canCreateEvent = false, - cursor, - setCursor, -}) { - const today = useMemo(() => new Date(), []); - const [selectedEvent, setSelectedEvent] = useState(null); - - const eventsByDate = useMemo(() => { - const map = {}; - events.forEach((ev) => { - const key = eventDateKey(ev); - if (!key) return; - if (!map[key]) map[key] = []; - map[key].push(ev); - }); - Object.keys(map).forEach((key) => { - map[key] = sortEventsForDay(map[key]); - }); - return map; - }, [events]); - - const year = cursor.getFullYear(); - const month = cursor.getMonth(); - - function handleMonthChange(e) { - const nextMonth = Number(e.target.value); - setCursor(new Date(year, nextMonth, 1)); - } - - function handleYearChange(e) { - const nextYear = Number(e.target.value); - setCursor(new Date(nextYear, month, 1)); - } - - const firstDayOfMonth = new Date(year, month, 1).getDay(); - const daysInMonth = new Date(year, month + 1, 0).getDate(); - const totalCells = Math.ceil((firstDayOfMonth + daysInMonth) / 7) * 7; - const todayKey = toDateKey(today); - - const cells = Array.from({ length: totalCells }, (_, i) => { - const dayOffset = i - firstDayOfMonth; - const date = new Date(year, month, dayOffset + 1); - const key = toDateKey(date); - return { - date, - key, - isCurrentMonth: date.getMonth() === month, - isToday: key === todayKey, - events: eventsByDate[key] || [], - }; - }); - - const monthEventCount = cells - .filter((c) => c.isCurrentMonth) - .reduce((sum, c) => sum + c.events.length, 0); - - const monthEvents = useMemo(() => { - return cells - .filter((c) => c.isCurrentMonth && c.events.length > 0) - .flatMap((c) => - c.events.map((event) => ({ - event, - dateKey: c.key, - })) - ); - }, [cells]); - - return ( - <> - {selectedEvent && ( - setSelectedEvent(null)} - isAdminView={isAdminView} - user={user} - /> - )} - -
-
-
-
-
- - -
- - - -
-
- -
- - -
- - - -
-
- - -
- -

- {monthEventCount} event{monthEventCount !== 1 ? 's' : ''} this month -

-
- -
- {canCreateEvent && ( - - - - )} - - - -
-
- -
- -
- -
- - -
- {cells.map((cell) => ( - - ))} -
-
- - {isAdminView ? ( -
- {[ - { label: 'Scheduled', dot: 'bg-blue-300' }, - { label: 'Published', dot: 'bg-cyan-300' }, - { label: 'Private', dot: 'bg-violet-300' }, - { label: 'Draft', dot: 'bg-amber-300' }, - { label: 'Closed', dot: 'bg-rose-300' }, - ].map(({ label, dot }) => ( - - - {label} - - ))} -
- ) : ( -
- {[ - { label: 'Open', dot: 'bg-cyan-300' }, - { label: 'Members only', dot: 'bg-violet-300' }, - { label: 'Closed', dot: 'bg-rose-300' }, - ].map(({ label, dot }) => ( - - - {label} - - ))} -
- )} -
- - ); -} diff --git a/src/Pages/Events/Events.js b/src/Pages/Events/Events.js index b69f75830..a1fc2f072 100644 --- a/src/Pages/Events/Events.js +++ b/src/Pages/Events/Events.js @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { getAllSCEvents } from '../../APIFunctions/SCEvents'; import { useSCE } from '../../Components/context/SceContext'; import { membershipState } from '../../Enums'; -import CalendarView from './CalendarView'; +import CalendarView from './Calendar/CalendarView'; import { toDateKey } from './eventUtils'; function canUserSeeEvent(event, user) {