diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 264675d23..63b978996 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import React, { useEffect } from 'react'; import { BrowserRouter as Router } from 'react-router-dom'; import { ToastProvider } from './context/toastContext'; import AuthManager from './components/AuthManager/AuthManager'; +import { ErrorModalProvider } from './context/errorModal'; import './styles/App.css'; import { setAuthToken } from './util/axios'; @@ -18,9 +19,11 @@ const App = () => { return ( - - - + + + + + ); }; diff --git a/frontend/src/components/AuthManager/AuthManager.tsx b/frontend/src/components/AuthManager/AuthManager.tsx index 817f0057a..e05d9904e 100644 --- a/frontend/src/components/AuthManager/AuthManager.tsx +++ b/frontend/src/components/AuthManager/AuthManager.tsx @@ -27,6 +27,7 @@ import { createPortal } from 'react-dom'; import CryptoJS from 'crypto-js'; import axios, { setAuthToken } from '../../util/axios'; import UnregisteredUserPage from '../Onboarding/UnregisteredUserPage'; +import { useErrorModal, formatErrorMessage } from '../../context/errorModal'; const secretKey = `${import.meta.env.VITE_ENCRYPTION_KEY!}`; @@ -65,6 +66,7 @@ const AuthManager = () => { setUnregisteredUser(null); logout(); }; + const { showError } = useErrorModal(); useEffect(() => { const token = jwtValue(); @@ -213,6 +215,10 @@ const AuthManager = () => { } } catch (error) { console.error('Error decrypting JWT:', error); + showError( + `Error decrypting JWT: ${formatErrorMessage(error)}`, + 'Authentication Error' + ); } return ''; } @@ -255,7 +261,7 @@ const AuthManager = () => { setAuthToken(''); setSignedIn(false); setRefreshUser(() => () => {}); - window.location.href = `${process.env.VITE_SERVER_URL}/api/sso/logout`; + window.location.href = `${import.meta.env.VITE_SERVER_URL}/api/sso/logout`; } function createRefresh(userId: string, userType: string, token: string) { diff --git a/frontend/src/components/ConfirmationToast/confirmationtoast.module.css b/frontend/src/components/ConfirmationToast/confirmationtoast.module.css index 9c874ff31..c36db2862 100644 --- a/frontend/src/components/ConfirmationToast/confirmationtoast.module.css +++ b/frontend/src/components/ConfirmationToast/confirmationtoast.module.css @@ -7,7 +7,7 @@ padding: 0.75rem 1rem; box-shadow: 4px 6px 30px 5px rgba(0, 0, 0, 0.15); border-radius: 0.625rem; - z-index: 999; + z-index: 2000; display: flex; align-items: center; } diff --git a/frontend/src/components/EmployeeCards/EmployeeCards.tsx b/frontend/src/components/EmployeeCards/EmployeeCards.tsx index 026029e79..e1e02fc7f 100644 --- a/frontend/src/components/EmployeeCards/EmployeeCards.tsx +++ b/frontend/src/components/EmployeeCards/EmployeeCards.tsx @@ -12,7 +12,6 @@ const formatPhone = (phoneNumber: string | undefined) => { const secondPart = phoneNumber.substring(6, 10); return `${areaCode}-${firstPart}-${secondPart}`; } else { - console.error('Undefined PhoneNumber'); return ''; } }; diff --git a/frontend/src/components/EmployeeModal/EmployeeModal.tsx b/frontend/src/components/EmployeeModal/EmployeeModal.tsx index d8c0c5480..d8cd1fc39 100644 --- a/frontend/src/components/EmployeeModal/EmployeeModal.tsx +++ b/frontend/src/components/EmployeeModal/EmployeeModal.tsx @@ -12,6 +12,7 @@ import styles from './employeemodal.module.css'; import { useEmployees } from '../../context/EmployeesContext'; import { useToast, ToastStatus } from '../../context/toastContext'; import axios from '../../util/axios'; +import { useErrorModal, formatErrorMessage } from '../../context/errorModal'; import { extractNetIdFromEmail } from 'util/userUtils'; type AdminData = { @@ -77,6 +78,7 @@ const EmployeeModal = ({ setIsOpen, }: EmployeeModalProps) => { const { showToast } = useToast(); + const { showError } = useErrorModal(); const { updateAdminInfo, updateDriverInfo, @@ -158,6 +160,10 @@ const EmployeeModal = ({ }); } catch (error) { console.error('Error uploading photo:', error); + showError( + `Error uploading photo: ${formatErrorMessage(error)}`, + 'Employees Error' + ); throw new Error('Failed to upload employee photo. Please try again.'); } } @@ -183,7 +189,12 @@ const EmployeeModal = ({ break; case '/api/admins': // Use optimistic create from context - await createAdmin(extractAdminData(employeeData)); + await createAdmin(extractAdminData(employeeData)).catch((error) => { + showError( + `Failed to create admin: ${formatErrorMessage(error)}`, + 'Employees Error' + ); + }); res = employeeData; // The context will handle server response and ID assignment break; default: @@ -234,9 +245,19 @@ const EmployeeModal = ({ async function deleteEmployee(id: string, endpoint: string): Promise { // Use optimistic delete from context if (endpoint === '/api/admins') { - await deleteAdmin(id); + await deleteAdmin(id).catch((error) => { + showError( + `Failed to delete admin: ${formatErrorMessage(error)}`, + 'Employees Error' + ); + }); } else if (endpoint === '/api/drivers') { - await deleteDriver(id); + await deleteDriver(id).catch((error) => { + showError( + `Failed to delete driver: ${formatErrorMessage(error)}`, + 'Employees Error' + ); + }); } } @@ -263,42 +284,98 @@ const EmployeeModal = ({ // If no employee exists, create one using a primary role. if (!currentId || currentId === '') { if (hasAdmin) { - employeeData.id = ( - await createEmployee(employeeData, '/api/admins') - ).id; - showToast( - `Created a new employee with the admin role`, - ToastStatus.SUCCESS - ); + try { + employeeData.id = ( + await createEmployee(employeeData, '/api/admins') + ).id; + showToast( + `Created a new employee with the admin role`, + ToastStatus.SUCCESS + ); + } catch (error) { + showError( + `Failed to create admin: ${formatErrorMessage(error)}`, + 'Employees Error' + ); + } } if (hasDriver) { - employeeData.id = ( - await createEmployee(employeeData, '/api/drivers') - ).id; - showToast( - `Created a new employee with the driver role`, - ToastStatus.SUCCESS - ); + try { + employeeData.id = ( + await createEmployee(employeeData, '/api/drivers') + ).id; + showToast( + `Created a new employee with the driver role`, + ToastStatus.SUCCESS + ); + } catch (error) { + showError( + `Failed to create driver: ${formatErrorMessage(error)}`, + 'Employees Error' + ); + } } } else { if (hasAdmin) { if (employeeData.admin) { - await updateEmployee(employeeData, '/api/admins'); + try { + await updateEmployee(employeeData, '/api/admins'); + } catch (error) { + showError( + `Failed to update admin: ${formatErrorMessage(error)}`, + 'Employees Error' + ); + } } else { - await createEmployee(employeeData, '/api/admins'); + try { + await createEmployee(employeeData, '/api/admins'); + } catch (error) { + showError( + `Failed to create admin: ${formatErrorMessage(error)}`, + 'Employees Error' + ); + } } } else if (employeeData.admin) { - await deleteEmployee(employeeData.id, '/api/admins'); + try { + await deleteEmployee(employeeData.id, '/api/admins'); + } catch (error) { + showError( + `Failed to delete admin: ${formatErrorMessage(error)}`, + 'Employees Error' + ); + } } if (hasDriver) { if (employeeData.driver) { - await updateEmployee(employeeData, '/api/drivers'); + try { + await updateEmployee(employeeData, '/api/drivers'); + } catch (error) { + showError( + `Failed to update driver: ${formatErrorMessage(error)}`, + 'Employees Error' + ); + } } else { - await createEmployee(employeeData, '/api/drivers'); + try { + await createEmployee(employeeData, '/api/drivers'); + } catch (error) { + showError( + `Failed to create driver: ${formatErrorMessage(error)}`, + 'Employees Error' + ); + } } } else if (employeeData.driver) { - await deleteEmployee(employeeData.id, '/api/drivers'); + try { + await deleteEmployee(employeeData.id, '/api/drivers'); + } catch (error) { + showError( + `Failed to delete driver: ${formatErrorMessage(error)}`, + 'Employees Error' + ); + } } } const id = employeeData.id; @@ -365,11 +442,20 @@ const EmployeeModal = ({ : 'Drivers'; try { setIsUploadingImage(true); - await uploadEmployeePhoto(id, targetTable, imageBase64); + await uploadEmployeePhoto(id, targetTable, imageBase64).catch( + (error) => { + showError( + `Failed to upload photo: ${formatErrorMessage(error)}`, + 'Employees Error' + ); + } + ); } catch (uploadError) { - showToast( - 'Employee created but photo upload failed. You can try uploading the photo again later.', - ToastStatus.ERROR + showError( + `Employee created but photo upload failed: ${formatErrorMessage( + uploadError + )}. You can try uploading the photo again later.`, + 'Employees Error' ); // Don't throw here - we want the employee creation to succeed even if photo upload fails } finally { @@ -378,9 +464,12 @@ const EmployeeModal = ({ } // Note: No need to manually refresh - optimistic updates handle this automatically - showToast(`Employee information processed`, ToastStatus.SUCCESS); + showToast('Employee information processed', ToastStatus.SUCCESS); } catch (error) { - showToast('An error occurred: ', ToastStatus.ERROR); + showError( + `An error occurred while saving employee: ${formatErrorMessage(error)}`, + 'Employees Error' + ); } finally { closeModal(); } diff --git a/frontend/src/components/ExportButton/ExportButton.tsx b/frontend/src/components/ExportButton/ExportButton.tsx index d94715399..bf1e9fd79 100644 --- a/frontend/src/components/ExportButton/ExportButton.tsx +++ b/frontend/src/components/ExportButton/ExportButton.tsx @@ -5,6 +5,8 @@ import { Button } from '../FormElements/FormElements'; import styles from './exportButton.module.css'; import { ToastStatus, useToast } from '../../context/toastContext'; import axios from '../../util/axios'; +import { formatErrorMessage } from '../../context/errorModal'; +import { useErrorModal } from '../../context/errorModal'; type clickHandler = { toastMsg: string; @@ -21,6 +23,7 @@ const ExportButton = ({ }: clickHandler) => { const [downloadData, setDownloadData] = useState(''); const { showToast } = useToast(); + const { showError } = useErrorModal(); const csvLink = useRef< CSVLink & HTMLAnchorElement & { link: HTMLAnchorElement } >(null); @@ -42,7 +45,13 @@ const ExportButton = ({ csvLink.current.link.click(); } }) - .then(() => showToast(toastMsg, ToastStatus.SUCCESS)); + .then(() => showToast(toastMsg, ToastStatus.SUCCESS)) + .catch((error) => { + showError( + `Failed to download data: ${formatErrorMessage(error)}`, + 'Export Error' + ); + }); }; return ( diff --git a/frontend/src/components/Locations/LocationFormModal.tsx b/frontend/src/components/Locations/LocationFormModal.tsx index fbd85638d..28012f151 100644 --- a/frontend/src/components/Locations/LocationFormModal.tsx +++ b/frontend/src/components/Locations/LocationFormModal.tsx @@ -24,6 +24,8 @@ import { Tag } from 'types'; import { LocationType } from '@carriage-web/shared/types/location'; import styles from './locations.module.css'; import LocationImagesUpload, { LocationImage } from './LocationImagesUpload'; +import { useErrorModal, formatErrorMessage } from '../../context/errorModal'; +import { useToast, ToastStatus } from '../../context/toastContext'; const CAMPUS_OPTIONS = [ { value: Tag.NORTH, label: 'North Campus' }, @@ -68,6 +70,8 @@ export const LocationFormModal: React.FC = ({ const [loadingAddr, setLoadingAddr] = useState(false); const [error, setError] = useState(null); const [locationImages, setLocationImages] = useState([]); + const { showError } = useErrorModal(); + const { showToast } = useToast(); useEffect(() => { if (!open) return; @@ -99,6 +103,10 @@ export const LocationFormModal: React.FC = ({ } catch (e) { setError("Couldn't retrieve address for this location"); console.error(e); + showError( + `Couldn't retrieve address for this location: ${formatErrorMessage(e)}`, + 'Locations Error' + ); } finally { setLoadingAddr(false); } @@ -115,6 +123,10 @@ export const LocationFormModal: React.FC = ({ } catch (e) { setError("Couldn't find coordinates for this address"); console.error(e); + showError( + `Couldn't find coordinates for this address: ${formatErrorMessage(e)}`, + 'Locations Error' + ); } finally { setLoadingAddr(false); } @@ -148,11 +160,18 @@ export const LocationFormModal: React.FC = ({ imagesList: locationImages, }; onSubmit(updatedLocation); + showToast('Location saved successfully', ToastStatus.SUCCESS); onClose(); }; return ( - + {mode === 'add' ? 'Add New Location' : 'Edit Location'} diff --git a/frontend/src/components/Locations/LocationsContent.tsx b/frontend/src/components/Locations/LocationsContent.tsx index 2b3bc0dd4..2ecff48cc 100644 --- a/frontend/src/components/Locations/LocationsContent.tsx +++ b/frontend/src/components/Locations/LocationsContent.tsx @@ -28,10 +28,14 @@ const LocationsContent: React.FC = ({ setFilteredLocations(locations); }, [locations]); - const uniqueTags = useMemo( - () => Array.from(new Set(locations.map((location) => location.tag))), - [locations] - ); + const uniqueTags = useMemo(() => { + const tags = locations + .map((location) => location.tag) + .filter( + (tag): tag is string => typeof tag === 'string' && tag.length > 0 + ); + return Array.from(new Set(tags)); + }, [locations]); const handleFilterApply = (filteredItems: LocationType[]) => { setFilteredLocations(filteredItems); diff --git a/frontend/src/components/Locations/PlacesSearch.tsx b/frontend/src/components/Locations/PlacesSearch.tsx index 7cf7ce41a..1e95b0616 100644 --- a/frontend/src/components/Locations/PlacesSearch.tsx +++ b/frontend/src/components/Locations/PlacesSearch.tsx @@ -2,6 +2,7 @@ import React, { useState, useCallback } from 'react'; import { TextField, Paper, CircularProgress } from '@mui/material'; import { useMap, Map } from '@vis.gl/react-google-maps'; import styles from './locations.module.css'; +import { useErrorModal, formatErrorMessage } from '../../context/errorModal'; interface PlacesSearchProps { onAddressSelect: (address: string, lat: number, lng: number) => void; @@ -18,6 +19,7 @@ const PlacesSearch = ({ const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); const map = useMap(); + const { showError } = useErrorModal(); const searchPlace = useCallback( async (query: string) => { @@ -54,6 +56,10 @@ const PlacesSearch = ({ setIsLoading(false); setError('Error searching for address'); setResults([]); + showError( + `Error searching for address: ${formatErrorMessage(error)}`, + 'Address Search Error' + ); } }, [map] diff --git a/frontend/src/components/Modal/RiderModal.tsx b/frontend/src/components/Modal/RiderModal.tsx index 1dac33a73..0aa1f39da 100644 --- a/frontend/src/components/Modal/RiderModal.tsx +++ b/frontend/src/components/Modal/RiderModal.tsx @@ -9,6 +9,7 @@ import { edit, trash, trashbig, red_trash } from '../../icons/other/index'; import AuthContext from '../../context/auth'; import { ToastStatus, useToast } from '../../context/toastContext'; import axios from '../../util/axios'; +import { useErrorModal, formatErrorMessage } from '../../context/errorModal'; type RiderModalProps = { existingRider?: RiderType; @@ -28,7 +29,7 @@ const RiderModal = ({ const [isSubmitted, setIsSubmitted] = useState(false); const { showToast } = useToast(); const { refreshRiders } = useRiders(); - + const { showError } = useErrorModal(); const closeModal = () => setIsOpen(false); const saveDataThen = (next: () => void) => (data: ObjectType) => { @@ -44,19 +45,23 @@ const RiderModal = ({ useEffect(() => { if (isSubmitted) { const method = existingRider ? axios.put : axios.post; - method( - `/api/riders/${!existingRider ? '' : existingRider.id}`, - formData - ).then(() => { - refreshRiders(); - showToast( - `The student has been ${!existingRider ? 'added' : 'edited'}`, - ToastStatus.SUCCESS - ); - if (isRiderWeb) { - refreshUser(); - } - }); + method(`/api/riders/${!existingRider ? '' : existingRider.id}`, formData) + .then(() => { + refreshRiders(); + showToast( + `The student has been ${!existingRider ? 'added' : 'edited'}`, + ToastStatus.SUCCESS + ); + if (isRiderWeb) { + refreshUser(); + } + }) + .catch((error) => { + showError( + `Failed to save student: ${formatErrorMessage(error)}`, + 'Students Error' + ); + }); setIsSubmitted(false); } }, [ diff --git a/frontend/src/components/Modal/modal.module.css b/frontend/src/components/Modal/modal.module.css index bb46985ea..200229255 100644 --- a/frontend/src/components/Modal/modal.module.css +++ b/frontend/src/components/Modal/modal.module.css @@ -6,7 +6,7 @@ left: 0; height: 100%; width: 100%; - z-index: 1000; + z-index: 10000; } .modal { @@ -17,7 +17,7 @@ background-color: white; padding: 2rem 2.25rem; border-radius: 1rem; - z-index: 1010; + z-index: 10010; } .title { diff --git a/frontend/src/components/OptimisticDemo/OptimisticDemo.tsx b/frontend/src/components/OptimisticDemo/OptimisticDemo.tsx index 9884d1bfe..87d9eea8e 100644 --- a/frontend/src/components/OptimisticDemo/OptimisticDemo.tsx +++ b/frontend/src/components/OptimisticDemo/OptimisticDemo.tsx @@ -1,9 +1,11 @@ import React from 'react'; import { useRiders } from '../../context/RidersContext'; +import { useErrorModal, formatErrorMessage } from '../../context/errorModal'; const OptimisticDemo: React.FC = () => { const { riders, isOptimistic, pendingOperations, updateRiderActive, error } = useRiders(); + const { showError } = useErrorModal(); const handleDemoToggle = async () => { if (riders.length > 0) { @@ -12,6 +14,7 @@ const OptimisticDemo: React.FC = () => { await updateRiderActive(firstRider.id, !firstRider.active); } catch (err) { console.error('Demo failed:', err); + showError(`Demo failed: ${formatErrorMessage(err)}`, 'Demo Error'); } } }; diff --git a/frontend/src/components/RequestRideModal/CreateOrEditRideModal.tsx b/frontend/src/components/RequestRideModal/CreateOrEditRideModal.tsx index 2b62a8919..393e6a04a 100644 --- a/frontend/src/components/RequestRideModal/CreateOrEditRideModal.tsx +++ b/frontend/src/components/RequestRideModal/CreateOrEditRideModal.tsx @@ -11,6 +11,7 @@ import RequestRideInfo from './RequestRideInfo'; import { RideModalType } from './types'; import { format_date } from '../../util/index'; import axios from '../../util/axios'; +import { useToast, ToastStatus } from '../../context/toastContext'; type CreateOrEditRideModalProps = { isOpen: boolean; @@ -53,6 +54,7 @@ const CreateOrEditRideModal = ({ const methods = useForm({ defaultValues }); const { id } = useContext(AuthContext); + const { showToast } = useToast(); const closeModal = () => { methods.clearErrors(); @@ -98,8 +100,9 @@ const CreateOrEditRideModal = ({ let rideData: ObjectType; if (recurring || whenRepeat) { // For now, block recurring rides as they're not fully implemented - alert( - 'Recurring rides are not yet supported. Please create a single ride instead.' + showToast( + 'Recurring rides are not yet supported. Please create a single ride instead.', + ToastStatus.ERROR ); return; } else { diff --git a/frontend/src/components/ResponsiveRideCard.tsx b/frontend/src/components/ResponsiveRideCard.tsx index 1bfacb87f..292f006cf 100644 --- a/frontend/src/components/ResponsiveRideCard.tsx +++ b/frontend/src/components/ResponsiveRideCard.tsx @@ -1,6 +1,7 @@ import React, { FC, ReactNode, useState } from 'react'; import { SchedulingState, Status, Tag } from '../types'; import { RideType } from '@carriage-web/shared/types/ride'; +import { LocationType } from '@carriage-web/shared/types/location'; import { BadgeRounded, FlagRounded, @@ -51,25 +52,45 @@ const renderFormattedTime = (time: Date): ReactNode => { ); }; +/** Both endpoints must be finite numbers so Map defaultCenter and markers never receive NaN. */ +const hasValidMapCoords = (loc: LocationType): boolean => { + const lat = Number(loc.lat); + const lng = Number(loc.lng); + return Number.isFinite(lat) && Number.isFinite(lng); +}; + const ResponsiveRideCard: FC = ({ ride, handleEdit, }) => { const [expanded, setExpanded] = useState(false); - // Check if either location is a custom location (with no valid coordinates) + // Custom tag / placeholder zeros, or missing non-finite coords — skip map to avoid Google Maps errors const hasCustomLocation = () => { const isPickupCustom = ride.startLocation.tag === Tag.CUSTOM || - ride.startLocation.lat === 0 || - ride.startLocation.lng === 0; + Number(ride.startLocation.lat) === 0 || + Number(ride.startLocation.lng) === 0; const isDropoffCustom = ride.endLocation.tag === Tag.CUSTOM || - ride.endLocation.lat === 0 || - ride.endLocation.lng === 0; + Number(ride.endLocation.lat) === 0 || + Number(ride.endLocation.lng) === 0; return isPickupCustom || isDropoffCustom; }; + const canShowRouteMap = + hasValidMapCoords(ride.startLocation) && + hasValidMapCoords(ride.endLocation) && + !hasCustomLocation(); + + const coordsInvalidForMap = + !hasValidMapCoords(ride.startLocation) || + !hasValidMapCoords(ride.endLocation); + + const mapPlaceholderSubtitle = coordsInvalidForMap + ? 'Map not available — location coordinates are missing or invalid.' + : 'Map not available for custom locations'; + return ( @@ -162,7 +183,7 @@ const ResponsiveRideCard: FC = ({ {/* expanded location view */} {expanded && ( - {hasCustomLocation() ? ( + {!canShowRouteMap ? ( = ({ marginBottom: '8px', }} > - 📍 Custom Location + 📍{' '} + {coordsInvalidForMap + ? 'Map unavailable' + : 'Custom Location'} - Map not available for custom locations + {mapPlaceholderSubtitle} @@ -196,18 +220,24 @@ const ResponsiveRideCard: FC = ({ = ({ = ({ const { showToast } = useToast(); const { refreshRides, updateRideStatus } = useRides(); const { curDate } = useDate(); + const { showError } = useErrorModal(); const [updateStatusOpen, setUpdateStatusOpen] = useState(false); const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false); @@ -84,7 +86,10 @@ const RideActions: React.FC = ({ await refreshRides(); } catch (error) { console.error('Failed to update status:', error); - showToast('Failed to update ride status', ToastStatus.ERROR); + showError( + `Failed to update status: ${formatErrorMessage(error)}`, + 'Rides Error' + ); } finally { setUpdating(false); } @@ -120,7 +125,10 @@ const RideActions: React.FC = ({ showToast('Ride Cancelled', ToastStatus.SUCCESS); } catch (error) { console.error('Failed to cancel ride:', error); - showToast('Failed to cancel ride', ToastStatus.ERROR); + showError( + `Failed to cancel ride: ${formatErrorMessage(error)}`, + 'Rides Error' + ); } }; @@ -137,7 +145,7 @@ const RideActions: React.FC = ({ const handleSave = async () => { setSaving(true); try { - const success = await saveChanges(); + const [success, errMessage] = await saveChanges(); if (success) { const message = isNewRide(ride) ? 'Ride created successfully' @@ -156,16 +164,20 @@ const RideActions: React.FC = ({ } } else { const message = isNewRide(ride) - ? 'Failed to create ride' - : 'Failed to save ride'; - showToast(message, ToastStatus.ERROR); + ? 'Failed to create ride due to the following errors: ' + + errMessage + + '.' + : 'Failed to save ride due to the following errors: ' + + errMessage + + '.'; + showError(message, 'Rides Error'); } } catch (error) { console.error('Error saving ride:', error); const message = isNewRide(ride) - ? 'Failed to create ride' - : 'Failed to save ride'; - showToast(message, ToastStatus.ERROR); + ? 'Failed to create ride: ' + formatErrorMessage(error) + : 'Failed to save ride: ' + formatErrorMessage(error); + showError(message, 'Rides Error'); } finally { setSaving(false); } diff --git a/frontend/src/components/RideDetails/RideDetailsComponent.tsx b/frontend/src/components/RideDetails/RideDetailsComponent.tsx index dbe0c7fa2..07f5786d1 100644 --- a/frontend/src/components/RideDetails/RideDetailsComponent.tsx +++ b/frontend/src/components/RideDetails/RideDetailsComponent.tsx @@ -114,6 +114,7 @@ const RideDetailsComponent: React.FC = ({ onClose={onClose} maxWidth="md" fullScreen={isMobile} + disableEnforceFocus PaperProps={{ className: isMobile ? styles.modalMobile : styles.modal, }} diff --git a/frontend/src/components/RideDetails/RideEditContext.tsx b/frontend/src/components/RideDetails/RideEditContext.tsx index 9c54ba7c2..95a38a1ae 100644 --- a/frontend/src/components/RideDetails/RideEditContext.tsx +++ b/frontend/src/components/RideDetails/RideEditContext.tsx @@ -10,13 +10,11 @@ import { SchedulingState } from '../../types'; import { RideType } from '@carriage-web/shared/types/ride'; import axios from '../../util/axios'; import { canEditRide, UserRole } from '../../util/rideValidation'; -import { - isNewRide, - hasRideChanges, - getRideChanges, -} from '../../util/modelFixtures'; +import { isNewRide, hasRideChanges } from '../../util/modelFixtures'; import { validateRideTimes } from './TimeValidation'; import { useRides } from '../../context/RidesContext'; +import { useToast } from '../../context/toastContext'; +import { useErrorModal } from '../../context/errorModal'; interface RideEditContextType { isEditing: boolean; @@ -25,7 +23,7 @@ interface RideEditContextType { startEditing: () => void; stopEditing: () => void; updateRideField: (field: keyof RideType, value: any) => void; - saveChanges: () => Promise; + saveChanges: () => Promise<[boolean, string]>; hasChanges: boolean; canEdit: boolean; userRole: UserRole; @@ -51,6 +49,8 @@ export const RideEditProvider: React.FC = ({ initialEditingState = false, }) => { const { updateRideInfo } = useRides(); + const { showToast } = useToast(); + const { showError } = useErrorModal(); // For new rides, automatically start in editing mode const shouldStartEditing = initialEditingState || isNewRide(ride); @@ -143,9 +143,9 @@ export const RideEditProvider: React.FC = ({ return hasChangesResult; }, [editedRide, originalRide]); - const saveChanges = useCallback(async (): Promise => { + const saveChanges = useCallback(async (): Promise<[boolean, string]> => { if (!editedRide) { - return false; + return [false, 'No ride to save']; } // Validate ride times @@ -156,13 +156,19 @@ export const RideEditProvider: React.FC = ({ { allowPastTimes: !isNewRide(editedRide), maxDurationHours: 24, - minDurationMinutes: 5, + minDurationMinutes: 1, } ); if (!timeValidation.isValid) { console.error('Time validation failed:', timeValidation.errors); - return false; + const errMessages = timeValidation.errors + .map((err) => err.message) + .join(', '); + const firstErr = + timeValidation.errors[0]?.message || 'Invalid time values'; + showError(errMessages || firstErr, 'Ride Edit Error'); + return [false, errMessages || firstErr]; } } @@ -176,7 +182,11 @@ export const RideEditProvider: React.FC = ({ !editedRide.endTime ) { console.error('Missing required fields for new ride'); - return false; + showError( + 'Please select pickup, dropoff, start time, and end time.', + 'Ride Edit Error' + ); + return [false, 'Missing required fields for new ride']; } try { @@ -202,22 +212,25 @@ export const RideEditProvider: React.FC = ({ } stopEditing(); - return true; - } catch (error) { + return [true, '']; + } catch (error: any) { console.error('Failed to create new ride:', error); - return false; + const msg = + error?.response?.data?.message || + error?.response?.data?.err || + 'Failed to create ride.'; + showError(msg, 'Rides Error'); + return [false, msg]; } } else { // Existing ride update logic if (!originalRide || !hasChanges()) { - return false; + return [false, 'No changes to save']; } try { - // Prepare the update payload - only include changed fields (like the previous implementation) const updatePayload: any = {}; - // Only include changed fields - include riders for single rider updates const fieldsToCheck: (keyof RideType)[] = [ 'startTime', 'endTime', @@ -244,19 +257,21 @@ export const RideEditProvider: React.FC = ({ updatePayload.$REMOVE = ['driver']; } - // Use optimistic update from RidesContext await updateRideInfo(ride.id, updatePayload); - - // Update the context with the optimistically updated ride if (onRideUpdated) { onRideUpdated({ ...editedRide } as RideType); } stopEditing(); - return true; - } catch (error) { + return [true, '']; + } catch (error: any) { console.error('Failed to save ride changes:', error); - return false; + const msg = + error?.response?.data?.message || + error?.response?.data?.err || + 'Failed to save changes.'; + showError(msg, 'Rides Error'); + return [false, msg]; } } }, [ @@ -267,6 +282,7 @@ export const RideEditProvider: React.FC = ({ onRideUpdated, stopEditing, updateRideInfo, + showToast, ]); const contextValue: RideEditContextType = { diff --git a/frontend/src/components/RideDetails/RideLocations.tsx b/frontend/src/components/RideDetails/RideLocations.tsx index 0ee6c4aaa..87ed71938 100644 --- a/frontend/src/components/RideDetails/RideLocations.tsx +++ b/frontend/src/components/RideDetails/RideLocations.tsx @@ -28,6 +28,7 @@ import { useLocations } from '../../context/LocationsContext'; import { SearchableType } from '../../utils/searchConfig'; import SearchPopup from './SearchPopup'; import styles from './RideLocations.module.css'; +import { useErrorModal, formatErrorMessage } from '../../context/errorModal'; interface RideLocationsProps { // No props needed - gets ride from context @@ -221,6 +222,7 @@ const RideMap: React.FC = ({ duration: string; } | null>(null); const mapsLibrary = useMapsLibrary('routes'); + const { showError } = useErrorModal(); // Check if either location is a custom location const hasCustomLocation = useMemo(() => { @@ -309,6 +311,10 @@ const RideMap: React.FC = ({ } } catch (error) { console.error('Error fetching route:', error); + showError( + `Error fetching route: ${formatErrorMessage(error)}`, + 'Maps Error' + ); if (polylineRef.current) { polylineRef.current.setMap(null); polylineRef.current = null; diff --git a/frontend/src/components/RideDetails/RideOverview.tsx b/frontend/src/components/RideDetails/RideOverview.tsx index 44a9c44e7..98a67a9b1 100644 --- a/frontend/src/components/RideDetails/RideOverview.tsx +++ b/frontend/src/components/RideDetails/RideOverview.tsx @@ -35,6 +35,8 @@ import RiderList from './RiderList'; import { isNewRide } from '../../util/modelFixtures'; import { validateRideTimes } from './TimeValidation'; import styles from './RideOverview.module.css'; +import { useToast, ToastStatus } from '../../context/toastContext'; +import { useErrorModal, formatErrorMessage } from '../../context/errorModal'; interface RideOverviewProps { userRole: 'rider' | 'driver' | 'admin'; @@ -201,7 +203,8 @@ const RideOverview: React.FC = ({ userRole }) => { const ride = editedRide!; const temporalType = getTemporalType(ride); const showRecurrence = userRole !== 'driver'; // Hide recurrence for drivers - + const { showToast } = useToast(); + const { showError } = useErrorModal(); const formatDateTime = (dateTimeString: string) => { const date = new Date(dateTimeString); return { @@ -236,15 +239,23 @@ const RideOverview: React.FC = ({ userRole }) => { const currentEndTime = dayjs(ride.endTime); // Ensure end time is after start time - if ( - updatedStartTime.isAfter(currentEndTime) || - updatedStartTime.isSame(currentEndTime) - ) { - const newEndTime = updatedStartTime.add(30, 'minute'); - updateRideField('endTime', newEndTime.toISOString()); + try { + if ( + updatedStartTime.isAfter(currentEndTime) || + updatedStartTime.isSame(currentEndTime) + ) { + const newEndTime = updatedStartTime.add(30, 'minute'); + updateRideField('endTime', newEndTime.toISOString()); + } + + updateRideField('startTime', updatedStartTime.toISOString()); + } catch (error) { + console.error('Failed to update ride time:', error); + showError( + `Error updating ride time: ${formatErrorMessage(error)}`, + 'Ride Edit Error' + ); } - - updateRideField('startTime', updatedStartTime.toISOString()); }; const handleStartTimeChange = (newTime: Dayjs | null) => { @@ -390,7 +401,7 @@ const RideOverview: React.FC = ({ userRole }) => { { allowPastTimes, maxDurationHours: 24, - minDurationMinutes: 5, + minDurationMinutes: 1, } ); @@ -450,20 +461,7 @@ const RideOverview: React.FC = ({ userRole }) => { }} /> {/* Start time specific error - directly below start time field */} - {startTimePastError && ( - - {startTimePastError.message} - - )} + {/* Field-level error messaging removed; errors surface on Save */} @@ -501,7 +499,6 @@ const RideOverview: React.FC = ({ userRole }) => { }, }} /> - {/* End time specific errors - directly below end time field */} {(endTimeBeforeStartError || durationError) && ( = ({ userRole }) => { color="textSecondary" sx={{ fontStyle: 'italic' }} > - Note: Full recurrence functionality coming soon + Full recurrence functionality coming soon > )} diff --git a/frontend/src/components/RideDetails/RidePeople.tsx b/frontend/src/components/RideDetails/RidePeople.tsx index 8900cf57a..c6bd2256a 100644 --- a/frontend/src/components/RideDetails/RidePeople.tsx +++ b/frontend/src/components/RideDetails/RidePeople.tsx @@ -23,6 +23,7 @@ import RiderList from './RiderList'; import SearchPopup from './SearchPopup'; import { SearchableType } from '../../utils/searchConfig'; import styles from './RidePeople.module.css'; +import { useErrorModal, formatErrorMessage } from '../../context/errorModal'; interface RidePeopleProps { userRole: 'rider' | 'driver' | 'admin'; @@ -113,6 +114,7 @@ const PersonCard: React.FC = ({ const RidePeople: React.FC = ({ userRole }) => { const { editedRide, isEditing, updateRideField } = useRideEdit(); const { getAvailableRiders } = useRides(); + const { showError } = useErrorModal(); const { drivers: employeesDrivers, loading: employeesLoading, @@ -189,6 +191,10 @@ const RidePeople: React.FC = ({ userRole }) => { console.error('Failed to fetch riders:', error); setRidersError('Failed to load riders'); setRiders([]); + showError( + `Failed to fetch riders: ${formatErrorMessage(error)}`, + 'Riders Error' + ); } finally { setLoadingRiders(false); } diff --git a/frontend/src/components/RideDetails/TimeValidation.tsx b/frontend/src/components/RideDetails/TimeValidation.tsx index e81925f87..19298ec5d 100644 --- a/frontend/src/components/RideDetails/TimeValidation.tsx +++ b/frontend/src/components/RideDetails/TimeValidation.tsx @@ -6,7 +6,9 @@ export interface TimeValidationError { | 'end_time_before_start' | 'same_time' | 'too_long_duration' - | 'invalid_time'; + | 'invalid_time' + | 'weekend_occurrence' + | 'scheduling_deadline_passed'; message: string; } @@ -27,7 +29,7 @@ export const validateRideTimes = ( const { allowPastTimes = false, maxDurationHours = 24, - minDurationMinutes = 5, + minDurationMinutes = 1, } = options; const errors: TimeValidationError[] = []; @@ -78,6 +80,54 @@ export const validateRideTimes = ( }); } + // Check weekends + if ( + start.day() === 0 || + start.day() === 6 || + end.day() === 0 || + end.day() === 6 + ) { + errors.push({ + type: 'weekend_occurrence', + message: 'Ride cannot occur on a weekend', + }); + } + + // Check that rides must be scheduled between 7:45am and 10:00 pm + if (start.isBefore(start.hour(7).minute(45)) || end.isAfter(end.hour(22))) { + errors.push({ + type: 'invalid_time', + message: 'Ride must be scheduled between 7:45am and 10:00 pm', + }); + } + + // Check that ride times are scheduled in five-minute intervals + if (start.minute() % 5 !== 0 || end.minute() % 5 !== 0) { + errors.push({ + type: 'invalid_time', + message: 'Start and end time must be in five-minute intervals', + }); + } + + // Check that rides must be scheduled by 10am the previous business day + let previousBusinessDay = start.subtract(1, 'day'); + while (previousBusinessDay.day() === 0 || previousBusinessDay.day() === 6) { + previousBusinessDay = previousBusinessDay.subtract(1, 'day'); + } + const deadline = previousBusinessDay + .set('hour', 10) + .set('minute', 0) + .set('second', 0); + + if (now.isAfter(deadline)) { + errors.push({ + type: 'scheduling_deadline_passed', + message: `Ride must be scheduled by 10am on previous day (${previousBusinessDay.format( + 'dddd' + )} ${previousBusinessDay.format('MM/DD/YYYY')})`, + }); + } + return { isValid: errors.length === 0, errors, diff --git a/frontend/src/components/RiderComponents/RequestRideDialog.tsx b/frontend/src/components/RiderComponents/RequestRideDialog.tsx index 600992148..87a773629 100644 --- a/frontend/src/components/RiderComponents/RequestRideDialog.tsx +++ b/frontend/src/components/RiderComponents/RequestRideDialog.tsx @@ -27,6 +27,7 @@ import { TimePicker, } from '@mui/x-date-pickers'; import { APIProvider } from '@vis.gl/react-google-maps'; +import dayjs from 'dayjs'; import RequestRideMap from './RequestRideMap'; import styles from './requestridedialog.module.css'; import { Tag } from 'types'; @@ -34,6 +35,11 @@ import { RideType } from '@carriage-web/shared/types/ride'; import { LocationType } from '@carriage-web/shared/types/location'; import RequestRidePlacesSearch from './RequestRidePlacesSearch'; import axios from '../../util/axios'; +import { useLocations } from '../../context/LocationsContext'; +import { useToast, ToastStatus } from '../../context/toastContext'; +import { formatErrorMessage } from '../../context/errorModal'; +import { validateRideTimes } from 'components/RideDetails/TimeValidation'; +import { useErrorModal } from '../../context/errorModal'; type RepeatOption = 'none' | 'daily' | 'weekly' | 'custom'; @@ -52,7 +58,7 @@ type SelectionState = 'pickup' | 'dropoff' | 'complete'; interface RequestRideDialogProps { open: boolean; onClose: () => void; - onSubmit: (data: FormData) => void; + onSubmit: (data: FormData) => Promise | boolean | void; supportedLocations: LocationType[]; ride?: RideType; } @@ -112,6 +118,9 @@ const RequestRideDialog: React.FC = ({ //official locations with other added const supportLocsWithOther = [Other, ...supportedLocations]; + const { locations } = useLocations(); + const { showToast } = useToast(); + const { showError } = useErrorModal(); const [formData, setFormData] = useState({ pickupLocation: null, dropoffLocation: null, @@ -502,6 +511,7 @@ const RequestRideDialog: React.FC = ({ try { let finalPickup = formData.pickupLocation; let finalDropoff = formData.dropoffLocation; + let result: boolean | void = false; // Handle custom pickup - create actual Location in DB if (customPickup) { @@ -523,21 +533,58 @@ const RequestRideDialog: React.FC = ({ finalDropoff = await createCustomLocation(customDropoffName); } - onSubmit({ - ...formData, - pickupLocation: finalPickup, - dropoffLocation: finalDropoff, - }); + const datetime = dayjs(formData.time) + .set('date', formData.date!.getDate()) + .set('month', formData.date!.getMonth()) + .set('year', formData.date!.getFullYear()); + + const timeValidation = validateRideTimes( + datetime, // start time + datetime.add(30, 'minute'), // end time + { + allowPastTimes: false, + maxDurationHours: 24, + minDurationMinutes: 5, + } + ); - // Reset state - setCustomPickup(false); - setCustomDropoff(false); - setCustomPickupName(''); - setCustomDropoffName(''); - onClose(); - } catch (err) { - console.error('Error submitting ride:', err); - alert('Failed to submit ride. Please try again.'); + if (!timeValidation.isValid) { + console.error('Time validation failed:', timeValidation.errors); + const errMessages = timeValidation.errors + .map((err) => err.message) + .join(', '); + const firstErr = + timeValidation.errors[0]?.message || 'Invalid time values'; + showError( + 'Could not create ride due to following time validation issues: ' + + (errMessages || firstErr), + 'Time Validation Error' + ); + result = false; + } else { + result = await onSubmit({ + ...formData, + pickupLocation: finalPickup, + dropoffLocation: finalDropoff, + }); + } + if (result !== false) { + // Reset state + setCustomPickup(false); + setCustomDropoff(false); + setCustomPickupName(''); + setCustomDropoffName(''); + onClose(); + showToast('Changes saved successfully', ToastStatus.SUCCESS); + } else { + showToast('Failed to save changes', ToastStatus.ERROR); + } + } catch (e) { + console.error('Error submitting ride:', e); + showToast( + 'Failed to save changes: ' + formatErrorMessage(e), + ToastStatus.ERROR + ); } }; @@ -583,7 +630,14 @@ const RequestRideDialog: React.FC = ({ : null; return ( - + {!ride ? 'Request a Ride' : 'Edit Ride'} = ({ onDropoffSelect, }) => { const map = useMap(); + const { showError } = useErrorModal(); const polylineRef = useRef(null); const clusterer = useRef(null); const markers = useRef>({}); @@ -112,6 +114,10 @@ const RequestRideMap: React.FC = ({ } } catch (error) { console.error('Error fetching route:', error); + showError( + `Error fetching route: ${formatErrorMessage(error)}`, + 'Maps Error' + ); if (polylineRef.current) { polylineRef.current.setMap(null); polylineRef.current = null; diff --git a/frontend/src/components/RiderComponents/RequestRidePlacesSearch.tsx b/frontend/src/components/RiderComponents/RequestRidePlacesSearch.tsx index f55761c68..9b0e0d333 100644 --- a/frontend/src/components/RiderComponents/RequestRidePlacesSearch.tsx +++ b/frontend/src/components/RiderComponents/RequestRidePlacesSearch.tsx @@ -1,6 +1,7 @@ import React, { useState, useCallback, useEffect, useRef } from 'react'; import { TextField, Paper, CircularProgress } from '@mui/material'; import styles from './requestridedialog.module.css'; +import { useErrorModal, formatErrorMessage } from '../../context/errorModal'; interface RequestRidePlacesSearchProps { onAddressSelect: (address: string, lat: number, lng: number) => void; @@ -29,6 +30,7 @@ const RequestRidePlacesSearch: React.FC = ({ }); } }, []); + const { showError } = useErrorModal(); const searchPlace = useCallback(async (query: string) => { console.log('Searching for:', query); @@ -76,6 +78,10 @@ const RequestRidePlacesSearch: React.FC = ({ setIsLoading(false); setError('Error searching for address'); setResults([]); + showError( + `Error searching for address: ${formatErrorMessage(error)}`, + 'Address Search Error' + ); } }, []); diff --git a/frontend/src/components/UserDetail/ActionsCard.tsx b/frontend/src/components/UserDetail/ActionsCard.tsx index dbdc461ea..fade63dc6 100644 --- a/frontend/src/components/UserDetail/ActionsCard.tsx +++ b/frontend/src/components/UserDetail/ActionsCard.tsx @@ -13,6 +13,7 @@ import Toast from '../ConfirmationToast/ConfirmationToast'; import { useRiders } from '../../context/RidersContext'; import { ToastStatus, useToast } from '../../context/toastContext'; import styles from './UserDetailCards.module.css'; +import { useErrorModal, formatErrorMessage } from '../../context/errorModal'; interface ActionsCardProps { user: Employee | RiderType; @@ -33,6 +34,7 @@ const ActionsCard: React.FC = ({ const { updateRiderActive } = useRiders(); const { toastType } = useToast(); + const { showError } = useErrorModal(); // Auto-dismiss toast after 3 seconds useEffect(() => { @@ -93,6 +95,10 @@ const ActionsCard: React.FC = ({ // triggers the useUserDetailData hook to update the local user state } catch (error) { console.error('Error updating rider status:', error); + showError( + `Error updating rider status: ${formatErrorMessage(error)}`, + 'Rider Error' + ); setToastMessage( `Failed to ${newActiveStatus ? 'activate' : 'deactivate'} rider.` ); diff --git a/frontend/src/components/UserDetail/hooks/useUserDetailData.ts b/frontend/src/components/UserDetail/hooks/useUserDetailData.ts index 46e41e6b9..7d27b4a8c 100644 --- a/frontend/src/components/UserDetail/hooks/useUserDetailData.ts +++ b/frontend/src/components/UserDetail/hooks/useUserDetailData.ts @@ -7,6 +7,10 @@ import { DriverType } from '@carriage-web/shared/types/driver'; import { useRiders } from '../../../context/RidersContext'; import { useEmployees } from '../../../context/EmployeesContext'; import axios from '../../../util/axios'; +import { + showGlobalError, + formatErrorMessage, +} from '../../../context/errorModal'; interface UserDetailData { user: Employee | RiderType | null; @@ -151,6 +155,10 @@ const useUserDetailData = ( } catch (err) { console.error('Error fetching employee data:', err); setError('Failed to fetch employee data'); + showGlobalError( + `Failed to fetch employee data: ${formatErrorMessage(err)}`, + 'Employees Error' + ); setLoading(false); } } @@ -194,6 +202,10 @@ const useUserDetailData = ( } catch (err) { setError('Failed to fetch rider data'); console.error('Error fetching rider data:', err); + showGlobalError( + `Failed to fetch rider data: ${formatErrorMessage(err)}`, + 'Riders Error' + ); setLoading(false); } }; diff --git a/frontend/src/context/EmployeesContext.tsx b/frontend/src/context/EmployeesContext.tsx index 12558fce0..98b828a63 100644 --- a/frontend/src/context/EmployeesContext.tsx +++ b/frontend/src/context/EmployeesContext.tsx @@ -3,6 +3,7 @@ import { Employee } from '../types'; import { AdminType } from '@carriage-web/shared/types/admin'; import { DriverType } from '@carriage-web/shared/types/driver'; import axios from '../util/axios'; +import { useErrorModal, formatErrorMessage } from './errorModal'; type employeesState = { drivers: Array; @@ -72,6 +73,7 @@ export const EmployeesProvider = ({ children }: EmployeesProviderProps) => { const [admins, setAdmins] = useState>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const { showError } = useErrorModal(); const refreshDrivers = useCallback(async () => { try { @@ -85,6 +87,10 @@ export const EmployeesProvider = ({ children }: EmployeesProviderProps) => { } catch (error) { console.error('Failed to fetch drivers:', error); setError(error as Error); + showError( + `Failed to fetch drivers: ${formatErrorMessage(error)}`, + 'Employees Error' + ); } }, []); @@ -100,6 +106,10 @@ export const EmployeesProvider = ({ children }: EmployeesProviderProps) => { } catch (error) { console.error('Failed to fetch admins:', error); setError(error as Error); + showError( + `Failed to fetch admins: ${formatErrorMessage(error)}`, + 'Employees Error' + ); } }, []); @@ -132,6 +142,10 @@ export const EmployeesProvider = ({ children }: EmployeesProviderProps) => { console.error('Failed to update driver info:', error); setDrivers(originalDrivers); setError(error as Error); + showError( + `Failed to update driver info: ${formatErrorMessage(error)}`, + 'Employees Error' + ); throw error; } }, @@ -163,6 +177,10 @@ export const EmployeesProvider = ({ children }: EmployeesProviderProps) => { console.error('Failed to create driver:', error); setDrivers((prevDrivers) => prevDrivers.filter((d) => d.id !== tempId)); setError(error as Error); + showError( + `Failed to create driver: ${formatErrorMessage(error)}`, + 'Employees Error' + ); throw error; } }, []); @@ -183,6 +201,10 @@ export const EmployeesProvider = ({ children }: EmployeesProviderProps) => { console.error('Failed to delete driver:', error); setDrivers(originalDrivers); setError(error as Error); + showError( + `Failed to delete driver: ${formatErrorMessage(error)}`, + 'Employees Error' + ); throw error; } }, @@ -218,6 +240,10 @@ export const EmployeesProvider = ({ children }: EmployeesProviderProps) => { console.error('Failed to update admin info:', error); setAdmins(originalAdmins); setError(error as Error); + showError( + `Failed to update admin info: ${formatErrorMessage(error)}`, + 'Employees Error' + ); throw error; } }, @@ -247,6 +273,10 @@ export const EmployeesProvider = ({ children }: EmployeesProviderProps) => { console.error('Failed to create admin:', error); setAdmins((prevAdmins) => prevAdmins.filter((a) => a.id !== tempId)); setError(error as Error); + showError( + `Failed to create admin: ${formatErrorMessage(error)}`, + 'Employees Error' + ); throw error; } }, []); @@ -267,6 +297,10 @@ export const EmployeesProvider = ({ children }: EmployeesProviderProps) => { console.error('Failed to delete admin:', error); setAdmins(originalAdmins); setError(error as Error); + showError( + `Failed to delete admin: ${formatErrorMessage(error)}`, + 'Employees Error' + ); throw error; } }, diff --git a/frontend/src/context/LocationsContext.tsx b/frontend/src/context/LocationsContext.tsx index 450c5fb8f..f0af5c452 100644 --- a/frontend/src/context/LocationsContext.tsx +++ b/frontend/src/context/LocationsContext.tsx @@ -33,7 +33,9 @@ export const LocationsProvider = ({ children }: locationsProviderProps) => { const filtered = locationsData.filter((loc) => loc.tag !== Tag.CUSTOM); filtered.sort((a: LocationType, b: LocationType) => { - return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1; + const nameA = (a.name ?? '').toLowerCase(); + const nameB = (b.name ?? '').toLowerCase(); + return nameA.localeCompare(nameB); }); componentMounted.current && setLocations(filtered); diff --git a/frontend/src/context/RidersContext.tsx b/frontend/src/context/RidersContext.tsx index c1ef0291f..7202b18b5 100644 --- a/frontend/src/context/RidersContext.tsx +++ b/frontend/src/context/RidersContext.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useState, useRef } from 'react'; import { RiderType } from '@carriage-web/shared/types/rider'; import axios from '../util/axios'; +import { useErrorModal, formatErrorMessage } from './errorModal'; type ridersState = { riders: Array; @@ -45,6 +46,7 @@ export const RidersProvider = ({ children }: RidersProviderProps) => { const componentMounted = useRef(true); const [riders, setRiders] = useState>([]); const [loading, setLoading] = useState(true); + const { showError } = useErrorModal(); const refreshRiders = useCallback(async () => { setLoading(true); @@ -64,6 +66,10 @@ export const RidersProvider = ({ children }: RidersProviderProps) => { } } catch (error) { console.error('Failed to fetch riders:', error); + showError( + `Failed to fetch riders: ${formatErrorMessage(error)}`, + 'Riders Error' + ); } finally { if (componentMounted.current) { setLoading(false); @@ -87,6 +93,10 @@ export const RidersProvider = ({ children }: RidersProviderProps) => { // Rollback on error console.error('Failed to update rider active status:', error); await refreshRiders(); // Refresh to get server state + showError( + `Failed to update rider active status: ${formatErrorMessage(error)}`, + 'Riders Error' + ); throw error; } }, @@ -118,6 +128,10 @@ export const RidersProvider = ({ children }: RidersProviderProps) => { // Rollback on error console.error('Failed to update rider info:', error); setRiders(originalRiders); + showError( + `Failed to update rider info: ${formatErrorMessage(error)}`, + 'Riders Error' + ); throw error; } }, @@ -144,6 +158,10 @@ export const RidersProvider = ({ children }: RidersProviderProps) => { // Rollback on error console.error('Failed to create rider:', error); setRiders((prevRiders) => prevRiders.filter((r) => r.id !== tempId)); + showError( + `Failed to create rider: ${formatErrorMessage(error)}`, + 'Riders Error' + ); throw error; } }, []); @@ -163,6 +181,10 @@ export const RidersProvider = ({ children }: RidersProviderProps) => { // Rollback on error console.error('Failed to delete rider:', error); setRiders(originalRiders); + showError( + `Failed to delete rider: ${formatErrorMessage(error)}`, + 'Riders Error' + ); throw error; } }, diff --git a/frontend/src/context/RidesContext.tsx b/frontend/src/context/RidesContext.tsx index 20b5720b0..ce02bfe99 100644 --- a/frontend/src/context/RidesContext.tsx +++ b/frontend/src/context/RidesContext.tsx @@ -5,6 +5,7 @@ import { RiderType } from '@carriage-web/shared/types/rider'; import { useDate } from './date'; import { format_date } from '../util/index'; import axios from '../util/axios'; +import { useErrorModal, formatErrorMessage } from './errorModal'; type ridesState = { unscheduledRides: RideType[]; @@ -76,6 +77,7 @@ export const RidesProvider = ({ children }: RidesProviderProps) => { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const { curDate } = useDate(); + const { showError } = useErrorModal(); const refreshRides = useCallback(async () => { const formattedDate = format_date(curDate); @@ -110,6 +112,10 @@ export const RidesProvider = ({ children }: RidesProviderProps) => { } catch (error) { console.error('Error refreshing rides:', error); setError(error as Error); + showError( + `Error refreshing rides: ${formatErrorMessage(error)}`, + 'Rides Error' + ); } finally { setLoading(false); } @@ -244,6 +250,10 @@ export const RidesProvider = ({ children }: RidesProviderProps) => { updateRideInLists(rideId, () => originalRide); } setError(error as Error); + showError( + `Failed to update ride status: ${formatErrorMessage(error)}`, + 'Rides Error' + ); throw error; } }, @@ -292,6 +302,10 @@ export const RidesProvider = ({ children }: RidesProviderProps) => { ); } setError(error as Error); + showError( + `Failed to update ride scheduling: ${formatErrorMessage(error)}`, + 'Rides Error' + ); throw error; } }, @@ -321,6 +335,10 @@ export const RidesProvider = ({ children }: RidesProviderProps) => { updateRideInLists(rideId, () => originalRide); } setError(error as Error); + showError( + `Failed to assign driver: ${formatErrorMessage(error)}`, + 'Rides Error' + ); throw error; } }, @@ -341,7 +359,9 @@ export const RidesProvider = ({ children }: RidesProviderProps) => { // We'll just use it for the optimistic update logic } catch (error) { console.error('Failed to fetch ride from server:', error); - throw new Error('Ride not found'); + const msg = 'Ride not found'; + showError(`${msg}: ${formatErrorMessage(error)}`, 'Rides Error'); + throw new Error(msg); } } @@ -405,6 +425,10 @@ export const RidesProvider = ({ children }: RidesProviderProps) => { ); } setError(error as Error); + showError( + `Failed to update ride info: ${formatErrorMessage(error)}`, + 'Rides Error' + ); throw error; } }, @@ -447,6 +471,10 @@ export const RidesProvider = ({ children }: RidesProviderProps) => { setUnscheduledRides((prev) => prev.filter((r) => r.id !== tempId)); } setError(error as Error); + showError( + `Failed to create ride: ${formatErrorMessage(error)}`, + 'Rides Error' + ); throw error; } }, []); @@ -486,6 +514,10 @@ export const RidesProvider = ({ children }: RidesProviderProps) => { } } setError(error as Error); + showError( + `Failed to delete ride: ${formatErrorMessage(error)}`, + 'Rides Error' + ); throw error; } }, @@ -528,6 +560,10 @@ export const RidesProvider = ({ children }: RidesProviderProps) => { } } setError(error as Error); + showError( + `Failed to cancel ride: ${formatErrorMessage(error)}`, + 'Rides Error' + ); throw error; } }, @@ -587,6 +623,10 @@ export const RidesProvider = ({ children }: RidesProviderProps) => { } catch (error) { console.error('Failed to get available riders:', error); setError(error as Error); + showError( + `Failed to get available riders: ${formatErrorMessage(error)}`, + 'Rides Error' + ); throw error; } }, diff --git a/frontend/src/context/errorModal.tsx b/frontend/src/context/errorModal.tsx new file mode 100644 index 000000000..dd54f9d48 --- /dev/null +++ b/frontend/src/context/errorModal.tsx @@ -0,0 +1,99 @@ +import React, { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; +import Modal from '../components/Modal/Modal'; + +type ErrorModalState = { + isOpen: boolean; + title?: string; + message?: React.ReactNode; +}; + +type ErrorModalContextValue = { + showError: (message: React.ReactNode, title?: string) => void; + hideError: () => void; +}; + +const ErrorModalContext = createContext( + undefined +); + +let externalShowError: + | ((message: React.ReactNode, title?: string) => void) + | null = null; + +export const showGlobalError = (message: React.ReactNode, title?: string) => { + if (externalShowError) { + externalShowError(message, title); + } +}; + +export const useErrorModal = (): ErrorModalContextValue => { + const ctx = useContext(ErrorModalContext); + if (!ctx) { + throw new Error('useErrorModal must be used within an ErrorModalProvider'); + } + return ctx; +}; + +type ProviderProps = { + children: React.ReactNode; + defaultTitle?: string; +}; + +export const ErrorModalProvider = ({ + children, + defaultTitle = 'Something went wrong', +}: ProviderProps) => { + const [state, setState] = useState({ isOpen: false }); + + const hideError = useCallback(() => { + setState({ isOpen: false }); + }, []); + + const showError = useCallback( + (message: React.ReactNode, title?: string) => { + setState({ isOpen: true, title: title || defaultTitle, message }); + }, + [defaultTitle] + ); + + // Expose for non-React modules + externalShowError = showError; + + const value = useMemo( + () => ({ showError, hideError }), + [showError, hideError] + ); + + return ( + + {children} + + {state.message} + + + ); +}; + +export const formatErrorMessage = (err: unknown): string => { + if (typeof err === 'string') return err; + if (err && typeof err === 'object') { + const anyErr: any = err as any; + if (anyErr?.response?.data?.message) return anyErr.response.data.message; + if (anyErr?.message) return anyErr.message as string; + } + try { + return JSON.stringify(err); + } catch { + return 'An unexpected error occurred.'; + } +}; diff --git a/frontend/src/hooks/useOptimisticRiders.ts b/frontend/src/hooks/useOptimisticRiders.ts index c34e4a563..2a2c0e697 100644 --- a/frontend/src/hooks/useOptimisticRiders.ts +++ b/frontend/src/hooks/useOptimisticRiders.ts @@ -5,6 +5,7 @@ import { OptimisticUpdateOptions, } from './useOptimisticUpdate'; import axios from '../util/axios'; +import { showGlobalError, formatErrorMessage } from '../context/errorModal'; export interface RiderOperations { updateRiderActive: (riderId: string, active: boolean) => Promise; @@ -230,6 +231,10 @@ export function useOptimisticRiders(initialRiders: RiderType[]) { optimisticState.updateServerData(serverRiders); } catch (error) { console.error('Failed to refresh riders from server:', error); + showGlobalError( + `Failed to refresh riders from server: ${formatErrorMessage(error)}`, + 'Riders Error' + ); throw error; } }, [optimisticState]); diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 04acdf1f1..fdb48b036 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; import App from './App'; +import { showGlobalError } from './context/errorModal'; import * as serviceWorker from './serviceWorker'; const container = document.getElementById('root'); @@ -9,6 +10,10 @@ if (container) { root.render(); } else { console.error('Failed to find the root element'); + showGlobalError( + 'Failed to initialize the app. Please refresh the page.', + 'Initialization Error' + ); } serviceWorker.register(); diff --git a/frontend/src/pages/Driver/Rides.tsx b/frontend/src/pages/Driver/Rides.tsx index 731650cd3..81ecb6642 100644 --- a/frontend/src/pages/Driver/Rides.tsx +++ b/frontend/src/pages/Driver/Rides.tsx @@ -30,6 +30,8 @@ import NoRidesView from '../../components/NoRidesView/NoRidesView'; import ContactInfoModal from '../../components/ContactInfoModal/ContactInfoModal'; import { APIProvider, useMapsLibrary } from '@vis.gl/react-google-maps'; import UpdateStatusModal from '../../components/UpdateStatusModal/UpdateStatusModal'; +import { useToast, ToastStatus } from '../../context/toastContext'; +import { useErrorModal, formatErrorMessage } from '../../context/errorModal'; const getStatusColor = ( status: Status @@ -406,6 +408,8 @@ const Rides = () => { useRides(); const { curDate } = useDate(); const authContext = useContext(AuthContext); + const { showToast } = useToast(); + const { showError } = useErrorModal(); const [updating, setUpdating] = useState(false); const [currentRideId, setCurrentRideId] = useState(null); const [allDriverRides, setAllDriverRides] = useState([]); @@ -421,6 +425,10 @@ const Rides = () => { setAllDriverRides(rides); } catch (error) { console.error('Failed to fetch driver rides:', error); + showError( + `Failed to fetch driver rides: ${formatErrorMessage(error)}`, + 'Rides Error' + ); } finally { setLoadingRides(false); } @@ -500,7 +508,7 @@ const Rides = () => { const errorMessage = error.response?.data?.message || 'Could not update ride status. Please try again.'; - alert(`Error: ${errorMessage}`); + showError(`Error: ${errorMessage}`, 'Rides Error'); } finally { setUpdating(false); } diff --git a/frontend/src/pages/Rider/Schedule.tsx b/frontend/src/pages/Rider/Schedule.tsx index 88a5adb51..70ea29238 100644 --- a/frontend/src/pages/Rider/Schedule.tsx +++ b/frontend/src/pages/Rider/Schedule.tsx @@ -22,6 +22,8 @@ import buttonStyles from '../../styles/button.module.css'; import { NavigateBefore, NavigateNext } from '@mui/icons-material'; import { DatePicker, LocalizationProvider } from '@mui/x-date-pickers'; import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns'; +import { useToast, ToastStatus } from '../../context/toastContext'; +import { useErrorModal, formatErrorMessage } from '../../context/errorModal'; type DayRideCollection = [string, RideType[]][]; @@ -70,11 +72,13 @@ const partitionRides = (rides: RideType[]): DayRideCollection => { const Schedule: React.FC = () => { const { user, id } = useContext(AuthContext); + const { showToast } = useToast(); const { locations } = useLocations(); const { refreshRides, refreshRidesByUser } = useRides(); const [isDialogOpen, setIsDialogOpen] = useState(false); const [allRiderRides, setAllRiderRides] = useState([]); const [loadingRides, setLoadingRides] = useState(false); + const { showError } = useErrorModal(); const [editingRide, setEditingRide] = useState(null); @@ -158,17 +162,18 @@ const Schedule: React.FC = () => { // For now, block any recurring rides if (formData.repeatType !== 'none') { - alert( - 'Recurring rides are not yet supported. Please create a single ride.' + showToast( + 'Recurring rides are not yet supported. Please create a single ride.', + ToastStatus.ERROR ); - return; + return false; } try { // Build ISO datetimes if (!formData.date || !formData.time) { - alert('Please select both date and time.'); - return; + showToast('Please select both date and time.', ToastStatus.ERROR); + return false; } const dateStr = formData.date.toISOString().split('T')[0]; @@ -179,7 +184,7 @@ const Schedule: React.FC = () => { new Date(startISO).getTime() + 30 * 60 * 1000 ).toISOString(); - await axios.post('/api/rides', { + const result = await axios.post('/api/rides', { // Send location IDs (matching Admin flow) startLocation: formData.pickupLocation.id, endLocation: formData.dropoffLocation.id, @@ -191,13 +196,17 @@ const Schedule: React.FC = () => { schedulingState: 'unscheduled', }); + console.log('Result:', result); + // Refresh rides after successful creation await refreshRides(); console.log('Ride created successfully'); fetchRiderRides(); - } catch (error) { + } catch (error: any) { console.error('Failed to create ride:', error); - alert('Failed to create ride. Please try again.'); + const msg = error?.response?.data?.err || 'Please try again.'; + showError('Failed to create ride: ' + msg, 'Rides Error'); + return false; } }; diff --git a/frontend/src/serviceWorker.ts b/frontend/src/serviceWorker.ts index 7bb86f784..11030be62 100644 --- a/frontend/src/serviceWorker.ts +++ b/frontend/src/serviceWorker.ts @@ -2,6 +2,7 @@ // register() is not called by default. import axios from './util/axios'; +import { showGlobalError, formatErrorMessage } from './context/errorModal'; // This lets the app load faster on subsequent visits in production, and gives // it offline capabilities. However, it also means that developers (and users) @@ -105,6 +106,12 @@ function registerValidSW(swUrl: string, config?: Config) { }) .catch((error) => { console.error('Error during service worker registration:', error); + showGlobalError( + `Failed to register service worker: ${formatErrorMessage( + error + )}. Some features may not be available offline.`, + 'Service Worker Error' + ); }); } @@ -145,6 +152,10 @@ export function unregister() { }) .catch((error) => { console.error(error.message); + showGlobalError( + `Failed to unregister service worker: ${formatErrorMessage(error)}`, + 'Service Worker Error' + ); }); } } diff --git a/frontend/src/util/rideValidation.ts b/frontend/src/util/rideValidation.ts index 567332aae..71b22a46a 100644 --- a/frontend/src/util/rideValidation.ts +++ b/frontend/src/util/rideValidation.ts @@ -53,6 +53,19 @@ export function isRideCompleted(ride: RideType): boolean { * @returns True if the user can edit the ride */ export function canEditRide(ride: RideType, userRole: UserRole): boolean { + // Past or completed rides cannot be edited + if (isRidePast(ride) || isRideCompleted(ride) || isRideActive(ride)) { + return false; + } + + // Time-based check for past/active ride + const now = new Date(); + const rideStartTime = new Date(ride.startTime); + + if (now > rideStartTime) { + return false; + } + // Drivers cannot edit rides if (userRole === 'driver') { return false; diff --git a/server/src/router/ride.ts b/server/src/router/ride.ts index 6579dc0a6..b9d29baef 100644 --- a/server/src/router/ride.ts +++ b/server/src/router/ride.ts @@ -14,6 +14,7 @@ import { } from '@carriage-web/shared/types/ride'; import { LocationType } from '@carriage-web/shared/types/location'; import { validateUser, daysUntilWeekday } from '../util'; +import { Rider } from '../models/rider'; import { DriverType } from '@carriage-web/shared/types/driver'; import { RiderType } from '@carriage-web/shared/types/rider'; import { notify } from '../util/notification'; @@ -23,6 +24,53 @@ import { UserType } from '../models/subscription'; const router = express.Router(); const tableName = 'Rides'; +/** + * Ensure that all riders attached to a ride are still eligible based on their endDate. + * - A rider must be active (if the flag exists) + * - The ride's start date (in America/New_York) must be on or before the rider's endDate + */ +async function ensureRidersEligibleForRide( + riderIds: string[], + rideStartTimeIso: string +) { + if (!riderIds.length) return; + + const uniqueIds = Array.from(new Set(riderIds)); + const keys = uniqueIds.map((id) => ({ id })); + + const docs = (await Rider.batchGet(keys)) as any[]; + if (!docs || !docs.length) { + throw new Error('No riders found for this ride.'); + } + + const rideDateNy = moment + .tz(rideStartTimeIso, 'America/New_York') + .format('YYYY-MM-DD'); + + for (const doc of docs) { + if (!doc) continue; + const riderJson = doc.toJSON ? doc.toJSON() : doc; + const { id, email, active, endDate } = riderJson as { + id: string; + email: string; + active?: boolean; + endDate?: string; + }; + + if (active === false) { + throw new Error( + `Rider with email ${email} is not active and cannot be scheduled for rides.` + ); + } + + if (endDate && endDate < rideDateNy) { + throw new Error( + `Rider with email ${email} has an end date of ${endDate} and cannot be scheduled for a ride on ${rideDateNy}. Please contact admin to extend your end date` + ); + } + } +} + // Debug endpoint to get current user's JWT token router.get('/debug/token', validateUser('User'), (req, res) => { const token = req.headers.authorization?.replace('Bearer ', ''); @@ -303,7 +351,7 @@ router.get('/diagnose', async (_req, res) => { }); // Create a new ride -router.post('/', validateUser('User'), (req, res) => { +router.post('/', validateUser('User'), async (req, res) => { const { body } = req; const { startLocation, @@ -355,6 +403,33 @@ router.post('/', validateUser('User'), (req, res) => { return; } + // Enforce rider end dates: all riders on this ride must still be eligible. + // Accept both legacy single rider and new riders array formats. + const riderIdsForValidation: string[] = []; + if (body.riders && Array.isArray(body.riders) && body.riders.length > 0) { + body.riders.forEach((r: any) => { + if (typeof r === 'string') riderIdsForValidation.push(r); + else if (r && typeof r.id === 'string') riderIdsForValidation.push(r.id); + }); + } else if (body.rider) { + if (typeof body.rider === 'string') { + riderIdsForValidation.push(body.rider); + } else if (body.rider.id) { + riderIdsForValidation.push(body.rider.id); + } + } + + try { + await ensureRidersEligibleForRide(riderIdsForValidation, body.startTime); + } catch (error: any) { + res.status(400).send({ + err: + error?.message || + 'One or more riders are not eligible to be scheduled for this ride.', + }); + return; + } + // Determine scheduling state based on driver assignment const hasDriver = body.driver ? true : false; const schedulingState = @@ -444,7 +519,7 @@ router.put('/:id', validateUser('User'), (req, res) => { } //Check if id matches or user is admin - db.getById(res, Ride, id, tableName, (ride: RideType) => { + db.getById(res, Ride, id, tableName, async (ride: RideType) => { const { riders, driver } = ride; const userIsRider = riders && riders.some((rider) => rider.id === res.locals.user.id); @@ -454,6 +529,47 @@ router.put('/:id', validateUser('User'), (req, res) => { userIsRider || (driver && res.locals.user.id === driver.id) ) { + // Before applying the update, ensure all riders will still be eligible + // at the (possibly updated) startTime. + const updatedStartTime = body.startTime || ride.startTime; + + const riderIdsForValidation: string[] = []; + if (body.riders && Array.isArray(body.riders)) { + body.riders.forEach((r: any) => { + if (typeof r === 'string') riderIdsForValidation.push(r); + else if (r && typeof r.id === 'string') + riderIdsForValidation.push(r.id); + }); + } else if (body.rider) { + if (typeof body.rider === 'string') { + riderIdsForValidation.push(body.rider); + } else if (body.rider.id) { + riderIdsForValidation.push(body.rider.id); + } + } else if (riders && Array.isArray(riders)) { + riders.forEach((r: any) => { + if (!r) return; + if (typeof r === 'string') riderIdsForValidation.push(r); + else if (r.id) riderIdsForValidation.push(r.id); + }); + } else if ((ride as any).rider && (ride as any).rider.id) { + riderIdsForValidation.push((ride as any).rider.id); + } + + try { + await ensureRidersEligibleForRide( + riderIdsForValidation, + updatedStartTime + ); + } catch (error: any) { + res.status(400).send({ + err: + error?.message || + 'One or more riders are not eligible to be scheduled for this ride.', + }); + return; + } + db.update(res, Ride, { id }, body, tableName, async (doc) => { const ride = doc; const { userType } = res.locals.user; diff --git a/server/tests/driver.test.ts b/server/tests/driver.test.ts index c43980f50..3823de7b7 100644 --- a/server/tests/driver.test.ts +++ b/server/tests/driver.test.ts @@ -36,7 +36,7 @@ const testRider: Omit = { accessibility: [Accessibility.CRUTCHES, Accessibility.ASSISTANT], description: '', joinDate: '2023-03-09', - endDate: '2024-03-09', + endDate: '2099-03-09', address: '36 Colonial Ln, Ithaca, NY 14850', favoriteLocations: ['Test-Location 1'], organization: Organization.REDRUNNER, @@ -53,7 +53,7 @@ const testStatRider: RiderType = { accessibility: [Accessibility.ASSISTANT], description: '', joinDate: '2023-03-09', - endDate: '2024-03-09', + endDate: '2099-03-09', address: '36 Colonial Ln, Ithaca, NY 14850', favoriteLocations: ['1'], organization: Organization.REDRUNNER, diff --git a/server/tests/rider.test.ts b/server/tests/rider.test.ts index 9d6b9238f..503d05263 100644 --- a/server/tests/rider.test.ts +++ b/server/tests/rider.test.ts @@ -93,7 +93,7 @@ const testRiders = [ accessibility: 'Crutches', description: '', joinDate: '2023-03-09', - endDate: '2024-03-09', + endDate: '2099-03-09', address: '36 Colonial Ln, Ithaca, NY 14850', favoriteLocations: ['1'], organization: Organization.REDRUNNER, @@ -110,7 +110,7 @@ const testRiders = [ accessibility: 'Crutches', description: 'needs help', joinDate: '2023-03-09', - endDate: '2024-03-09', + endDate: '2099-03-09', address: '37 Colonial Ln, Ithaca, NY 14850', favoriteLocations: ['2'], organization: Organization.CULIFT,
- Map not available for custom locations + {mapPlaceholderSubtitle}