diff --git a/src/APIFunctions/SCEvents.js b/src/APIFunctions/SCEvents.js index 6a387fb3c..c14d6bc46 100644 --- a/src/APIFunctions/SCEvents.js +++ b/src/APIFunctions/SCEvents.js @@ -33,6 +33,26 @@ export async function getEventByID(id) { return status; } +export async function getEventAttendanceSummary(id, token) { + const status = new ApiResponse(); + try { + const res = await fetch(`${SCEVENTS_API_URL}/events/${id}/attendance`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + const result = await res.json(); + status.responseData = result; + if (!res.ok) { + status.error = true; + } + } catch (err) { + status.error = true; + status.responseData = err; + } + return status; +} + export async function createSCEvent(token, eventBody) { const status = new ApiResponse(); try { diff --git a/src/Pages/Events/CalendarView.js b/src/Pages/Events/CalendarView.js index 26f2d2a68..a3e04b4b4 100644 --- a/src/Pages/Events/CalendarView.js +++ b/src/Pages/Events/CalendarView.js @@ -1,5 +1,6 @@ import React, { useState, useMemo, useEffect, useRef } from 'react'; import { Link } from 'react-router-dom'; +import { getEventAttendanceSummary } from '../../APIFunctions/SCEvents'; // ─── tiny helpers ──────────────────────────────────────────────────────────── @@ -218,11 +219,22 @@ function EventPopup({ event, onClose, isAdminView, user }) { const popupRef = useRef(null); const colors = pillColors(event, isAdminView); const badgeText = getBadgeText(event, isAdminView); + const eventId = event.id || event._id; + const authToken = user?.token || window.localStorage.getItem('jwtToken'); const userId = user?._id != null ? String(user._id) : ''; + const [attendeeCount, setAttendeeCount] = useState(null); + const [attendanceLoading, setAttendanceLoading] = useState(false); + const [attendanceLoaded, setAttendanceLoaded] = useState(false); const canEditEvent = Array.isArray(event.admins) && userId && event.admins.includes(userId); + const maxAttendees = Number(event.max_attendees); + const hasCapacityLimit = Number.isFinite(maxAttendees) && maxAttendees > 0; + const remainingSpots = + hasCapacityLimit && typeof attendeeCount === 'number' + ? Math.max(maxAttendees - attendeeCount, 0) + : null; useEffect(() => { function onKey(e) { @@ -245,11 +257,39 @@ function EventPopup({ event, onClose, isAdminView, user }) { }; }, [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]); + return ( -
+
- {event.max_attendees > 0 && ( + {hasCapacityLimit && canEditEvent && typeof attendeeCount === 'number' && (

- {event.max_attendees} spot{event.max_attendees !== 1 ? 's' : ''} available + {attendeeCount} {attendeeCount === 1 ? 'person' : 'people'} registered {event.waitlist_enabled && ( · waitlist enabled )}

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

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

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

+ Loading live spots... +

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

+ Unable to load live spots +

+ )} + {canEditEvent && ( + + + + ); +} + +function PinIcon() { + return ( + + ); +} + +function PeopleIcon() { + return ( + + ); +} + +function PlusIcon() { + return ( + + ); +} + +function EditIcon() { + return ( + + ); +} function getUserAccessLevel(user) { return user?.accessLevel ?? membershipState.NON_MEMBER; } +function formatEventDate(date) { + if (!date) { + return ''; + } + + const [year, month, day] = date.split('-').map(Number); + if (!year || !month || !day) { + return date; + } + + return new Date(year, month - 1, day).toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + }); +} + +function formatEventTime(time) { + if (!time) { + return ''; + } + + const [hour, minute] = time.split(':').map(Number); + if (Number.isNaN(hour) || Number.isNaN(minute)) { + return time; + } + + return new Date(2000, 0, 1, hour, minute).toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); +} + function canUserSeeEvent(event, user) { const userId = user?._id != null ? String(user._id) : ''; const userAccess = getUserAccessLevel(user); @@ -42,7 +163,117 @@ function canUserSeeEvent(event, user) { return false; } -// ─── page ───────────────────────────────────────────────────────────────────── +function EventCard({ event, user }) { + const [attendeeCount, setAttendeeCount] = useState(0); + const userAccess = getUserAccessLevel(user); + const isEventAdmin = + Array.isArray(event.admins) && user?._id + ? event.admins.includes(String(user._id)) + : false; + const canViewAttendance = + isEventAdmin || + ((!Array.isArray(event.admins) || event.admins.length === 0) && + userAccess >= membershipState.ADMIN); + + useEffect(() => { + let isCurrent = true; + setAttendeeCount(0); + + async function fetchAttendanceSummary() { + const response = await getEventAttendanceSummary(event.id, user?.token); + if (isCurrent && !response.error && typeof response.responseData?.attendee_count === 'number') { + setAttendeeCount(response.responseData.attendee_count); + } + } + + if (canViewAttendance && event.id && user?.token) { + fetchAttendanceSummary(); + } + + return () => { + isCurrent = false; + }; + }, [canViewAttendance, event.id, user?.token]); + + return ( +
+
+

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

+ {isEventAdmin && ( + + + + )} +
+ +
+ {event.status === 'draft' && ( + + Draft + + )} + {event.visibility === 'private' && ( + + Private + + )} +
+ +
+ {(event.date || event.time) && ( +
+ + + + {[formatEventDate(event.date), formatEventTime(event.time)].filter(Boolean).join(' · ')} +
+ )} + + {event.location && ( +
+ + + + {event.location} +
+ )} + + {canViewAttendance && ( +
+ + + + + {attendeeCount} + {' people going'} + +
+ )} +
+ + {event.description && ( +

+ {event.description} +

+ )} + +
+ + Register + +
+
+ ); +} export default function EventsPage() { const { user } = useSCE(); diff --git a/src/Pages/Events/EventsRegistation.js b/src/Pages/Events/EventsRegistation.js index 4a2ac88b1..8348f9743 100644 --- a/src/Pages/Events/EventsRegistation.js +++ b/src/Pages/Events/EventsRegistation.js @@ -3,7 +3,7 @@ import React, { useState, useEffect } from 'react'; import { useParams, useHistory, Redirect } from 'react-router-dom'; import config from '../../config/config.json'; import { useSCE } from '../../Components/context/SceContext'; -import { getEventByID, registerForSCEvent } from '../../APIFunctions/SCEvents'; +import { getEventByID, getEventAttendanceSummary, registerForSCEvent } from '../../APIFunctions/SCEvents'; function ArrowLeftIcon() { return ( @@ -30,7 +30,10 @@ export default function EventRegistration() { const [isLoading, setIsLoading] = useState(true); const [hasError, setHasError] = useState(false); const [submitError, setSubmitError] = useState(''); + const [submitSuccess, setSubmitSuccess] = useState(''); const [submitting, setSubmitting] = useState(false); + const [attendeeCount, setAttendeeCount] = useState(null); + const [attendanceLoading, setAttendanceLoading] = useState(false); useEffect(() => { if (!isSCEventsEnabled) { @@ -57,6 +60,34 @@ export default function EventRegistration() { fetchEvent(); }, [id, isSCEventsEnabled]); + useEffect(() => { + if (!isSCEventsEnabled || !id) { + return; + } + let isCurrent = true; + const token = window.localStorage.getItem('jwtToken'); + if (!token) { + return; + } + + async function fetchAttendance() { + setAttendanceLoading(true); + const response = await getEventAttendanceSummary(id, token); + if (isCurrent && !response.error && typeof response.responseData?.attendee_count === 'number') { + setAttendeeCount(response.responseData.attendee_count); + } + if (isCurrent) { + setAttendanceLoading(false); + } + } + + fetchAttendance(); + + return () => { + isCurrent = false; + }; + }, [id, isSCEventsEnabled]); + if (!isSCEventsEnabled) { return ; } @@ -77,6 +108,7 @@ export default function EventRegistration() { const handleSubmit = async (e) => { e.preventDefault(); setSubmitError(''); + setSubmitSuccess(''); const token = window.localStorage.getItem('jwtToken'); if (!token) { @@ -132,8 +164,10 @@ export default function EventRegistration() { setSubmitError(msg); return; } - - history.push('/events'); + setSubmitSuccess('Registration request sent successfully.'); + setTimeout(() => { + history.push('/events'); + }, 1200); }; if (isLoading) { @@ -189,6 +223,13 @@ export default function EventRegistration() { ); } + const maxAttendees = Number(event.max_attendees); + const hasCapacityLimit = Number.isFinite(maxAttendees) && maxAttendees > 0; + const remainingSpots = + hasCapacityLimit && typeof attendeeCount === 'number' + ? Math.max(maxAttendees - attendeeCount, 0) + : null; + return (
{/* Background Blurs */} @@ -212,6 +253,16 @@ export default function EventRegistration() { {event.name} + {hasCapacityLimit && ( +
+ {typeof remainingSpots === 'number' + ? `${remainingSpots} spot${remainingSpots !== 1 ? 's' : ''} left` + : attendanceLoading + ? 'Loading live spots...' + : 'Unable to load live spots right now.'} +
+ )} +
{(event.registration_form || []).map((field) => ( // eslint-disable-line camelcase @@ -297,10 +348,16 @@ export default function EventRegistration() {
)} + {submitSuccess && ( +
+ {submitSuccess} +
+ )} +