From d9f8ae6042dcda9b6be3bfdf47cf2423f2d0e4b0 Mon Sep 17 00:00:00 2001 From: Steven Le Date: Fri, 1 May 2026 08:48:51 -0700 Subject: [PATCH] make event HC clearer, refactor shared functions, remove unused code --- src/Pages/Events/CalendarView.js | 180 +++--------- .../Events/CreateEventFormQuestionBlock.js | 2 - src/Pages/Events/CreateEventPage.js | 142 ++-------- src/Pages/Events/EditEventPage.js | 140 ++-------- src/Pages/Events/EventAttendeesDashboard.js | 2 +- src/Pages/Events/EventIcons.js | 108 +++++++ src/Pages/Events/Events.js | 263 +----------------- src/Pages/Events/EventsRegistration.js | 49 +--- src/Pages/Events/eventUtils.js | 58 ++++ src/Pages/Events/useEventQuestions.js | 119 ++++++++ 10 files changed, 384 insertions(+), 679 deletions(-) create mode 100644 src/Pages/Events/EventIcons.js create mode 100644 src/Pages/Events/eventUtils.js create mode 100644 src/Pages/Events/useEventQuestions.js diff --git a/src/Pages/Events/CalendarView.js b/src/Pages/Events/CalendarView.js index ef977b6ec..90a89720b 100644 --- a/src/Pages/Events/CalendarView.js +++ b/src/Pages/Events/CalendarView.js @@ -1,9 +1,17 @@ -import React, { useState, useMemo, useEffect, useRef } from 'react'; +import { useState, useMemo, useEffect, useRef } from 'react'; import { Link } from 'react-router-dom'; import { getEventAttendanceSummary, getMyEventRegistrationState, joinWaitlistForSCEvent } from '../../APIFunctions/SCEvents'; import { membershipState } from '../../Enums'; - -// ─── tiny helpers ──────────────────────────────────────────────────────────── +import { calculateEventCapacity, getApiErrorMessage, toDateKey } from './eventUtils'; +import { + ChevronLeft, + ChevronRight, + CalendarIcon, + PinIcon, + ClockIcon, + XIcon, + PlusIcon +} from './EventIcons'; const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const MONTHS = [ @@ -13,13 +21,6 @@ const MONTHS = [ const currentYear = new Date().getFullYear(); const YEAR_RANGE = Array.from({ length: 10 }, (_, i) => currentYear + i); -function toDateKey(date) { - const y = date.getFullYear(); - const m = String(date.getMonth() + 1).padStart(2, '0'); - const d = String(date.getDate()).padStart(2, '0'); - return `${y}-${m}-${d}`; -} - function eventDateKey(event) { if (!event.date) return null; if (/^\d{4}-\d{2}-\d{2}$/.test(event.date)) return event.date; @@ -159,12 +160,8 @@ function getBadgeText(event, isAdminView) { return ''; } -function getRegistrationStatus(event) { - return event?.registration_status || 'none'; -} - function getRegistrationCta(event) { - const registrationStatus = getRegistrationStatus(event); + const registrationStatus = event?.registration_status || 'none'; switch (registrationStatus) { case 'registered': @@ -223,76 +220,6 @@ function canUserManageEvent(event, user) { return false; } -// ─── icons ──────────────────────────────────────────────────────────────────── - -function ChevronLeft() { - return ( - - ); -} - -function ChevronRight() { - return ( - - ); -} - -function CalendarIcon() { - return ( - - ); -} - -function PinIcon() { - return ( - - ); -} - -function ClockIcon() { - return ( - - ); -} - -function XIcon() { - return ( - - ); -} - -function PlusIcon() { - return ( - - ); -} - // ─── Event popup ────────────────────────────────────────────────────────────── function EventPopup({ event, onClose, isAdminView, user }) { @@ -311,14 +238,8 @@ function EventPopup({ event, onClose, isAdminView, user }) { const [isCheckingRegistration, setIsCheckingRegistration] = useState(false); const eventId = event?.id || event?._id; const authToken = user?.token; - 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; + const { maxAttendees, hasCapacityLimit, remainingSpots, isFull } = calculateEventCapacity(event, attendeeCount); const registrationCta = getRegistrationCta(event); - const isFull = hasCapacityLimit && typeof remainingSpots === 'number' && remainingSpots <= 0; const shouldShowWaitlistJoin = !canManageEvent && event.status === 'published' && @@ -416,33 +337,24 @@ function EventPopup({ event, onClose, isAdminView, user }) { setWaitlistSubmitting(false); if (response.error) { - let msg = ''; - const data = response.responseData; - - if (data && typeof data === 'object' && data.error) { - msg = String(data.error); - } else if (typeof data === 'string' && data.trim()) { - msg = data.trim(); - } - - setWaitlistError(msg || 'Failed to join waitlist.'); + setWaitlistError(getApiErrorMessage(response, { fallback: 'Failed to join waitlist.' })); return; } setWaitlistMessage('Joined waitlist successfully.'); } return ( -
+
-
+
@@ -451,7 +363,7 @@ function EventPopup({ event, onClose, isAdminView, user }) {
-
+
{event.date && (
@@ -508,18 +420,18 @@ function EventPopup({ event, onClose, isAdminView, user }) { )}
-
+
{event.max_attendees > 0 && ( -

- {event.max_attendees} spot{event.max_attendees !== 1 ? 's' : ''} available - {event.waitlist_enabled && ( +

+ {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 @@ -528,13 +440,13 @@ function EventPopup({ event, onClose, isAdminView, user }) { )} {hasCapacityLimit && !canManageEvent && attendanceLoading && ( -

+

Loading live spots...

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

+

Unable to load live spots

)} @@ -602,11 +514,11 @@ function EventPopup({ event, onClose, isAdminView, user }) { )} {waitlistError && ( -

{waitlistError}

+

{waitlistError}

)} {waitlistMessage && ( -

{waitlistMessage}

+

{waitlistMessage}

)}
@@ -636,7 +548,7 @@ function EventRow({ event, onSelect, isAdminView }) { title={event.name} > -
+
{timeLabel && ( {timeLabel} @@ -688,7 +600,7 @@ function CalCell({ date, isCurrentMonth, isToday, events, onSelectEvent, isAdmin isToday ? 'bg-cyan-500/[0.12]' : 'hover:bg-slate-700/35', ].join(' ')} > -
+
+
No events scheduled this month.
); @@ -749,7 +661,7 @@ function MobileMonthAgenda({ monthEvents, onSelectEvent, isAdminView }) { }, []); return ( -
+
{groupedEvents.map(({ dateKey, events }) => { const [year, month, day] = dateKey.split('-'); const date = new Date(Number(year), Number(month) - 1, Number(day)); @@ -786,8 +698,8 @@ function MobileMonthAgenda({ monthEvents, onSelectEvent, isAdminView }) { colors.border, ].join(' ')} > -
-
+
+
{event.name || 'Untitled Event'}
@@ -796,7 +708,7 @@ function MobileMonthAgenda({ monthEvents, onSelectEvent, isAdminView }) {
-
+
{event.location || '\u00A0'}
@@ -899,7 +811,7 @@ export default function CalendarView({ )}
-
+
@@ -916,8 +828,8 @@ export default function CalendarView({ ))} -
- +
+
@@ -937,8 +849,8 @@ export default function CalendarView({ ))} -
- +
+
@@ -962,7 +874,7 @@ export default function CalendarView({ @@ -971,21 +883,21 @@ export default function CalendarView({
-
+
{isAdminView ? ( -
+
{[ { label: 'Published', dot: 'bg-cyan-300' }, { label: 'Private', dot: 'bg-violet-300' }, @@ -1023,7 +935,7 @@ export default function CalendarView({ ))}
) : ( -
+
{[ { label: 'Open', dot: 'bg-cyan-300' }, { label: 'Members only', dot: 'bg-violet-300' }, diff --git a/src/Pages/Events/CreateEventFormQuestionBlock.js b/src/Pages/Events/CreateEventFormQuestionBlock.js index 808b27b63..1b12fb90e 100644 --- a/src/Pages/Events/CreateEventFormQuestionBlock.js +++ b/src/Pages/Events/CreateEventFormQuestionBlock.js @@ -1,6 +1,4 @@ /* eslint-disable camelcase -- SCEvents registration question shape uses snake_case */ -import React from 'react'; - export default function CreateEventFormQuestionBlock({ question, index, diff --git a/src/Pages/Events/CreateEventPage.js b/src/Pages/Events/CreateEventPage.js index 790340f99..a8b9027fe 100644 --- a/src/Pages/Events/CreateEventPage.js +++ b/src/Pages/Events/CreateEventPage.js @@ -1,24 +1,16 @@ /* eslint-disable camelcase -- mirrors SCEvents JSON field names in state and payloads */ -import React, { useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { Link, useHistory } from 'react-router-dom'; import { useSCE } from '../../Components/context/SceContext'; import { createSCEvent } from '../../APIFunctions/SCEvents'; import CreateEventFormQuestionBlock from './CreateEventFormQuestionBlock'; import { membershipState } from '../../Enums'; +import { useEventQuestions, toApiRegistrationForm } from './useEventQuestions'; +import { getApiErrorMessage } from './eventUtils'; /** Matches SCEvents `max_attendees` when there is no cap. */ const UNLIMITED_ATTENDEES = -1; -function newQuestionTemplate() { - return { - id: crypto.randomUUID(), - type: 'textbox', - question: '', - required: false, - answer_details: { max_chars: 200 }, - }; -} - function defaultQuestions() { return [ { @@ -45,28 +37,6 @@ function defaultQuestions() { ]; } -function toApiRegistrationForm(questions) { - return questions.map((q) => { - const base = { - id: q.id, - type: q.type, - question: q.question, - required: !!q.required, - }; - if (q.type === 'textbox' && q.answer_details?.max_chars) { - base.answer_details = { max_chars: q.answer_details.max_chars }; - } - if ( - (q.type === 'multiple_choice' || q.type === 'dropdown' || q.type === 'checkbox') && - q.answer_options && - q.answer_options.length - ) { - base.answer_options = q.answer_options; - } - return base; - }); -} - export default function CreateEventPage() { const { user } = useSCE(); const history = useHistory(); @@ -91,7 +61,18 @@ export default function CreateEventPage() { const [maxAttendees, setMaxAttendees] = useState(UNLIMITED_ATTENDEES); const [waitlistEnabled, setWaitlistEnabled] = useState(false); const [waitlistSize, setWaitlistSize] = useState(10); - const [questions, setQuestions] = useState(defaultQuestions); + + const { + questions, + addQuestion, + removeQuestion, + updateQuestion, + updateQuestionType, + updateAnswerOption, + addAnswerOption, + removeAnswerOption + } = useEventQuestions(defaultQuestions()); + const [submitError, setSubmitError] = useState(''); const [submitting, setSubmitting] = useState(false); @@ -99,76 +80,6 @@ export default function CreateEventPage() { const adminId = useMemo(() => (user?._id != null ? String(user._id) : ''), [user]); - function addQuestion() { - setQuestions((prev) => [...prev, newQuestionTemplate()]); - } - - function removeQuestion(id) { - setQuestions((prev) => prev.filter((q) => q.id !== id)); - } - - function updateQuestion(id, field, value) { - setQuestions((prev) => - prev.map((q) => (q.id === id ? { ...q, [field]: value } : q)), - ); - } - - function updateQuestionType(id, newType) { - setQuestions((prev) => - prev.map((q) => { - if (q.id !== id) return q; - const base = { - id: q.id, - type: newType, - question: q.question, - required: q.required, - }; - if (newType === 'textbox') { - return { ...base, answer_details: { max_chars: 200 } }; - } - if (newType === 'multiple_choice' || newType === 'dropdown' || newType === 'checkbox') { - return { ...base, answer_options: ['Option 1', 'Option 2'] }; - } - return base; - }), - ); - } - - function updateAnswerOption(questionId, optionIndex, value) { - setQuestions((prev) => - prev.map((q) => { - if (q.id !== questionId) return q; - const next = [...(q.answer_options || [])]; - next[optionIndex] = value; - return { ...q, answer_options: next }; - }), - ); - } - - function addAnswerOption(questionId) { - setQuestions((prev) => - prev.map((q) => { - if (q.id !== questionId) return q; - return { - ...q, - answer_options: [...(q.answer_options || []), 'New option'], - }; - }), - ); - } - - function removeAnswerOption(questionId, optionIndex) { - setQuestions((prev) => - prev.map((q) => { - if (q.id !== questionId) return q; - return { - ...q, - answer_options: (q.answer_options || []).filter((_, i) => i !== optionIndex), - }; - }), - ); - } - async function handleCreateEvent() { setSubmitError(''); if (!eventName.trim()) { @@ -185,6 +96,7 @@ export default function CreateEventPage() { } if (maxAttendees !== UNLIMITED_ATTENDEES && (maxAttendees === '' || maxAttendees <= 0)) { setSubmitError('Please enter a valid max attendees, or check "No limit".'); + return; } if (waitlistEnabled && (!waitlistSize || Number(waitlistSize) <= 0)) { setSubmitError('Please enter a valid waitlist size.'); @@ -216,24 +128,10 @@ export default function CreateEventPage() { setSubmitting(false); if (result.error) { - let msg = ''; - const data = result.responseData; - if (data && typeof data === 'object' && data.error) { - msg = String(data.error); - } else if (typeof data === 'string' && data.trim()) { - msg = data.trim(); - } - if (!msg && result.statusCode) { - msg = `HTTP ${result.statusCode}`; - } - if (result.networkError) { - msg = - (msg || 'Network error') + - '. Is the SCEvents API running (e.g. Docker on port 8002)?'; - } else if (!msg) { - msg = 'SCEvents returned an error.'; - } - setSubmitError(msg); + setSubmitError(getApiErrorMessage(result, { + fallback: 'SCEvents returned an error.', + networkHint: 'Is the SCEvents API running (e.g. Docker on port 8002)?', + })); return; } diff --git a/src/Pages/Events/EditEventPage.js b/src/Pages/Events/EditEventPage.js index ff8c9881a..fbdd609af 100644 --- a/src/Pages/Events/EditEventPage.js +++ b/src/Pages/Events/EditEventPage.js @@ -1,46 +1,16 @@ /* eslint-disable camelcase -- mirrors SCEvents JSON field names in state and payloads */ -import React, { useMemo, useState, useEffect } from 'react'; +import { useMemo, useState, useEffect } from 'react'; import { Link, useHistory, useParams } from 'react-router-dom'; import { useSCE } from '../../Components/context/SceContext'; import { getEventByID, updateSCEvent } from '../../APIFunctions/SCEvents'; import CreateEventFormQuestionBlock from './CreateEventFormQuestionBlock'; import { membershipState } from '../../Enums'; +import { toApiRegistrationForm, useEventQuestions } from './useEventQuestions'; +import { getApiErrorMessage } from './eventUtils'; /** Matches SCEvents `max_attendees` when there is no cap. */ const UNLIMITED_ATTENDEES = -1; -function newQuestionTemplate() { - return { - id: crypto.randomUUID(), - type: 'textbox', - question: '', - required: false, - answer_details: { max_chars: 200 }, - }; -} - -function toApiRegistrationForm(questions) { - return questions.map((q) => { - const base = { - id: q.id, - type: q.type, - question: q.question, - required: !!q.required, - }; - if (q.type === 'textbox' && q.answer_details?.max_chars) { - base.answer_details = { max_chars: q.answer_details.max_chars }; - } - if ( - (q.type === 'multiple_choice' || q.type === 'dropdown') && - q.answer_options && - q.answer_options.length - ) { - base.answer_options = q.answer_options; - } - return base; - }); -} - export default function EditEventPage() { const { id } = useParams(); const { user } = useSCE(); @@ -60,8 +30,18 @@ export default function EditEventPage() { const [maxAttendees, setMaxAttendees] = useState(UNLIMITED_ATTENDEES); const [waitlistEnabled, setWaitlistEnabled] = useState(false); const [waitlistSize, setWaitlistSize] = useState(10); - const [questions, setQuestions] = useState([]); const [eventAdmins, setEventAdmins] = useState([]); + const { + questions, + setQuestions, + addQuestion, + removeQuestion, + updateQuestion, + updateQuestionType, + updateAnswerOption, + addAnswerOption, + removeAnswerOption + } = useEventQuestions([]); const [submitError, setSubmitError] = useState(''); const [submitting, setSubmitting] = useState(false); @@ -106,76 +86,6 @@ export default function EditEventPage() { loadEvent(); }, [id]); - function addQuestion() { - setQuestions((prev) => [...prev, newQuestionTemplate()]); - } - - function removeQuestion(qId) { - setQuestions((prev) => prev.filter((q) => q.id !== qId)); - } - - function updateQuestion(qId, field, value) { - setQuestions((prev) => - prev.map((q) => (q.id === qId ? { ...q, [field]: value } : q)), - ); - } - - function updateQuestionType(qId, newType) { - setQuestions((prev) => - prev.map((q) => { - if (q.id !== qId) return q; - const base = { - id: q.id, - type: newType, - question: q.question, - required: q.required, - }; - if (newType === 'textbox') { - return { ...base, answer_details: { max_chars: 200 } }; - } - if (newType === 'multiple_choice' || newType === 'dropdown') { - return { ...base, answer_options: ['Option 1', 'Option 2'] }; - } - return base; - }), - ); - } - - function updateAnswerOption(questionId, optionIndex, value) { - setQuestions((prev) => - prev.map((q) => { - if (q.id !== questionId) return q; - const next = [...(q.answer_options || [])]; - next[optionIndex] = value; - return { ...q, answer_options: next }; - }), - ); - } - - function addAnswerOption(questionId) { - setQuestions((prev) => - prev.map((q) => { - if (q.id !== questionId) return q; - return { - ...q, - answer_options: [...(q.answer_options || []), 'New option'], - }; - }), - ); - } - - function removeAnswerOption(questionId, optionIndex) { - setQuestions((prev) => - prev.map((q) => { - if (q.id !== questionId) return q; - return { - ...q, - answer_options: (q.answer_options || []).filter((_, i) => i !== optionIndex), - }; - }), - ); - } - async function handleUpdateEvent() { setSubmitError(''); if (!eventName.trim()) { @@ -215,24 +125,10 @@ export default function EditEventPage() { setSubmitting(false); if (result.error) { - let msg = ''; - const data = result.responseData; - if (data && typeof data === 'object' && data.error) { - msg = String(data.error); - } else if (typeof data === 'string' && data.trim()) { - msg = data.trim(); - } - if (!msg && result.statusCode) { - msg = `HTTP ${result.statusCode}`; - } - if (result.networkError) { - msg = - (msg || 'Network error') + - '. Is the SCEvents API running (e.g. Docker on port 8002)?'; - } else if (!msg) { - msg = 'SCEvents returned an error.'; - } - setSubmitError(msg); + setSubmitError(getApiErrorMessage(result, { + fallback: 'SCEvents returned an error.', + networkHint: 'Is the SCEvents API running (e.g. Docker on port 8002)?', + })); return; } diff --git a/src/Pages/Events/EventAttendeesDashboard.js b/src/Pages/Events/EventAttendeesDashboard.js index e5b7e13ca..ff578afe1 100644 --- a/src/Pages/Events/EventAttendeesDashboard.js +++ b/src/Pages/Events/EventAttendeesDashboard.js @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Link, Redirect, useParams } from 'react-router-dom'; import { getEventRegistrationByRequestId, getEventRegistrations } from '../../APIFunctions/SCEvents'; import { useSCE } from '../../Components/context/SceContext'; diff --git a/src/Pages/Events/EventIcons.js b/src/Pages/Events/EventIcons.js new file mode 100644 index 000000000..ca7a167cc --- /dev/null +++ b/src/Pages/Events/EventIcons.js @@ -0,0 +1,108 @@ +export function ChevronLeft() { + return ( + + ); +} + +export function ChevronRight() { + return ( + + ); +} + +export function CalendarIcon() { + return ( + + ); +} + +export function PinIcon() { + return ( + + ); +} + +export function ClockIcon() { + return ( + + ); +} + +export function XIcon() { + return ( + + ); +} + +export function PlusIcon() { + return ( + + ); +} + +export function ArrowLeftIcon() { + return ( + + ); +} + +export function PeopleIcon() { + return ( + + + + ); +} + +export function EditIcon() { + return ( + + ); +} diff --git a/src/Pages/Events/Events.js b/src/Pages/Events/Events.js index 945de1bcc..12948aa3f 100644 --- a/src/Pages/Events/Events.js +++ b/src/Pages/Events/Events.js @@ -1,147 +1,13 @@ -import React, { useEffect, useState } from 'react'; -import { Link } from 'react-router-dom'; -import { getAllSCEvents, getEventAttendanceSummary } from '../../APIFunctions/SCEvents'; +import { useEffect, useState } from 'react'; +import { getAllSCEvents } from '../../APIFunctions/SCEvents'; import { useSCE } from '../../Components/context/SceContext'; import { membershipState } from '../../Enums'; import CalendarView from './CalendarView'; - -function CalendarIcon() { - return ( - - ); -} - -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 formatDateParam(date) { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; -} +import { toDateKey } from './eventUtils'; function canUserSeeEvent(event, user) { const userId = user?._id != null ? String(user._id) : ''; - const userAccess = getUserAccessLevel(user); + const userAccess = user?.accessLevel ?? membershipState.NON_MEMBER; const isGlobalAdmin = userAccess >= membershipState.ADMIN; const isEventAdmin = Array.isArray(event.admins) && userId @@ -169,118 +35,6 @@ function canUserSeeEvent(event, user) { return false; } -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(); const [events, setEvents] = useState([]); @@ -291,9 +45,8 @@ export default function EventsPage() { return new Date(today.getFullYear(), today.getMonth(), 1); }); - const canCreateEvent = user?.accessLevel >= membershipState.OFFICER; + const isAdminView = user?.accessLevel >= membershipState.OFFICER; const visibleEvents = events.filter((event) => canUserSeeEvent(event, user)); - const isAdminView = canCreateEvent; const pageContainerClass = isAdminView ? 'relative h-dvh overflow-hidden bg-gradient-to-r from-gray-800 to-gray-600 text-white' : 'relative h-[calc(100dvh-4rem)] overflow-hidden bg-gradient-to-r from-gray-800 to-gray-600 text-white'; @@ -307,8 +60,8 @@ export default function EventsPage() { setHasError(false); const token = window.localStorage.getItem('jwtToken'); - const startDate = formatDateParam(new Date(cursor.getFullYear(), cursor.getMonth(), 1)); - const endDate = formatDateParam(new Date(cursor.getFullYear(), cursor.getMonth() + 1, 0)); + const startDate = toDateKey(new Date(cursor.getFullYear(), cursor.getMonth(), 1)); + const endDate = toDateKey(new Date(cursor.getFullYear(), cursor.getMonth() + 1, 0)); const response = await getAllSCEvents(token, { startDate, endDate }); if (!response.error) { @@ -350,7 +103,7 @@ export default function EventsPage() { events={visibleEvents} isAdminView={isAdminView} user={user} - canCreateEvent={canCreateEvent} + canCreateEvent={isAdminView} cursor={cursor} setCursor={setCursor} /> diff --git a/src/Pages/Events/EventsRegistration.js b/src/Pages/Events/EventsRegistration.js index 90a31d816..75d3a438f 100644 --- a/src/Pages/Events/EventsRegistration.js +++ b/src/Pages/Events/EventsRegistration.js @@ -1,27 +1,10 @@ /* eslint-disable camelcase -- mirrors SCEvents JSON field names in state and payloads */ -import React, { useState, useEffect } from 'react'; +import { useState, useEffect } from 'react'; import { useParams, useHistory } from 'react-router-dom'; import { useSCE } from '../../Components/context/SceContext'; import { getEventByID, getEventAttendanceSummary, registerForEvent } from '../../APIFunctions/SCEvents'; - -function ArrowLeftIcon() { - return ( - - ); -} - -function getRegistrationStatus(event) { - return event?.registration_status || 'none'; -} +import { calculateEventCapacity, getApiErrorMessage } from './eventUtils'; +import { ArrowLeftIcon } from './EventIcons'; function StatusPanel({ title, message, borderClass, textClass, onBack }) { return ( @@ -64,7 +47,7 @@ export default function EventRegistration() { const [submitting, setSubmitting] = useState(false); const [attendeeCount, setAttendeeCount] = useState(null); const [attendanceLoading, setAttendanceLoading] = useState(false); - const registrationStatus = getRegistrationStatus(event); + const registrationStatus = event?.registration_status || 'none'; const goBackToEvents = () => history.push('/events'); useEffect(() => { @@ -166,27 +149,12 @@ export default function EventRegistration() { setSubmitting(false); if (result.error) { - let msg = ''; - const data = result.responseData; - - if (data && typeof data === 'object' && data.error) { - msg = String(data.error); - } else if (typeof data === 'string' && data.trim()) { - msg = data.trim(); - } + let msg = getApiErrorMessage(result, { fallback: 'Failed to submit registration.' }); if (msg.toLowerCase().includes('registration is closed')) { msg = 'This event is no longer accepting sign-ups, but more events are on the way 👀'; } - if (!msg && result.statusCode) { - msg = `HTTP ${result.statusCode}`; - } - - if (!msg) { - msg = 'Failed to submit registration.'; - } - setSubmitError(msg); return; } @@ -280,12 +248,7 @@ 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; + const { remainingSpots, hasCapacityLimit } = calculateEventCapacity(event, attendeeCount); return (
diff --git a/src/Pages/Events/eventUtils.js b/src/Pages/Events/eventUtils.js new file mode 100644 index 000000000..e41b4fe5a --- /dev/null +++ b/src/Pages/Events/eventUtils.js @@ -0,0 +1,58 @@ +export function calculateEventCapacity(event, attendeeCount) { + 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; + + const isFull = hasCapacityLimit && typeof remainingSpots === 'number' && remainingSpots <= 0; + + return { + maxAttendees, + hasCapacityLimit, + remainingSpots, + isFull + }; +} + +export function toDateKey(date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +export function getApiErrorMessage(result, options = {}) { + const { + fallback = 'Request failed.', + networkHint = '', + } = options; + + let msg = ''; + const data = result?.responseData; + + if (data && typeof data === 'object') { + if (data.error) { + msg = String(data.error); + } else if (data.message) { + msg = String(data.message); + } + } else if (typeof data === 'string' && data.trim()) { + msg = data.trim(); + } + + if (!msg && result?.statusCode) { + msg = `HTTP ${result.statusCode}`; + } + + if (result?.networkError) { + msg = msg || 'Network error'; + if (networkHint) { + msg = `${msg}. ${networkHint}`; + } + } + + return msg || fallback; +} diff --git a/src/Pages/Events/useEventQuestions.js b/src/Pages/Events/useEventQuestions.js new file mode 100644 index 000000000..cc42075e9 --- /dev/null +++ b/src/Pages/Events/useEventQuestions.js @@ -0,0 +1,119 @@ +/* eslint-disable camelcase -- SCEvents registration question shape uses snake_case */ +import { useState } from 'react'; + +export function newQuestionTemplate() { + return { + id: crypto.randomUUID(), + type: 'textbox', + question: '', + required: false, + answer_details: { max_chars: 200 }, + }; +} + +export function toApiRegistrationForm(questions) { + return questions.map((q) => { + const base = { + id: q.id, + type: q.type, + question: q.question, + required: !!q.required, + }; + + if (q.type === 'textbox' && q.answer_details?.max_chars) { + base.answer_details = { max_chars: q.answer_details.max_chars }; + } + + if (['multiple_choice', 'dropdown', 'checkbox'].includes(q.type)) { + base.answer_options = Array.isArray(q.answer_options) + ? q.answer_options.map((opt) => String(opt).trim()).filter(Boolean) + : []; + } + + return base; + }); +} + +export function useEventQuestions(initialQuestions = []) { + const [questions, setQuestions] = useState(initialQuestions); + + function addQuestion() { + setQuestions((prev) => [...prev, newQuestionTemplate()]); + } + + function removeQuestion(id) { + setQuestions((prev) => prev.filter((q) => q.id !== id)); + } + + function updateQuestion(id, field, value) { + setQuestions((prev) => + prev.map((q) => (q.id === id ? { ...q, [field]: value } : q)) + ); + } + + function updateQuestionType(id, newType) { + setQuestions((prev) => + prev.map((q) => { + if (q.id !== id) return q; + + const updated = { ...q, type: newType }; + + if (newType === 'textbox') { + updated.answer_details = { max_chars: 200 }; + delete updated.answer_options; + } else if (['multiple_choice', 'dropdown', 'checkbox'].includes(newType)) { + if (!updated.answer_options) { + updated.answer_options = ['Option 1', 'Option 2']; + } + delete updated.answer_details; + } + return updated; + }) + ); + } + + function updateAnswerOption(questionId, optionIndex, value) { + setQuestions((prev) => + prev.map((q) => { + if (q.id !== questionId) return q; + const newOptions = [...(q.answer_options || [])]; + newOptions[optionIndex] = value; + return { ...q, answer_options: newOptions }; + }) + ); + } + + function addAnswerOption(questionId) { + setQuestions((prev) => + prev.map((q) => { + if (q.id !== questionId) return q; + const newOptions = [...(q.answer_options || [])]; + newOptions.push(`Option ${newOptions.length + 1}`); + return { ...q, answer_options: newOptions }; + }) + ); + } + + function removeAnswerOption(questionId, optionIndex) { + setQuestions((prev) => + prev.map((q) => { + if (q.id !== questionId) return q; + const newOptions = [...(q.answer_options || [])]; + newOptions.splice(optionIndex, 1); + return { ...q, answer_options: newOptions }; + }) + ); + } + + return { + questions, + setQuestions, + addQuestion, + removeQuestion, + updateQuestion, + updateQuestionType, + updateAnswerOption, + addAnswerOption, + removeAnswerOption + }; +}