diff --git a/app/App.tsx b/app/App.tsx index 602a4f43d..9264c4bb4 100644 --- a/app/App.tsx +++ b/app/App.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react"; // Todo: Once GA sunsets the UA analytics tracking come July 2023, we can remove the "react-ga" // package and all references to it: // https://support.google.com/analytics/answer/12938611#zippy=%2Cin-this-article -import ReactGA_4 from "react-ga4"; +import TagManager from "react-gtm-module"; import { Helmet } from "react-helmet-async"; import { useLocation } from "react-router-dom"; import { UserLocation, getLocation, websiteConfig, AppProvider } from "./utils"; @@ -18,6 +18,8 @@ import { AroundRadius } from "algoliasearch"; const { siteUrl, title } = websiteConfig; export const OUTER_CONTAINER_ID = "outer-container"; +TagManager.initialize({ gtmId: config.GOOGLE_ANALYTICS_GA4_ID }); + export const App = () => { const location = useLocation(); const [userLocation, setUserLocation] = useState(null); @@ -31,23 +33,6 @@ export const App = () => { setUserLocation(userLocation); setAroundLatLng(`${userLocation.coords.lat},${userLocation.coords.lng}`); }); - - if (config.GOOGLE_ANALYTICS_GA4_ID) { - ReactGA_4.initialize(config.GOOGLE_ANALYTICS_GA4_ID); - } - - setTimeout(() => { - // We call setTimeout here to give our views time to update the document - // title beforeGA sends its page view event - // TODO: This hack is old. Let's figure out if it is still necessary or - // there is a different modern approach - // (see: https://stackoverflow.com/questions/2497200/how-to-listen-for-changes-to-the-title-element/29540461#29540461) - const page = location.pathname + location.search; - ReactGA_4.send({ - hitType: "pageview", - page, - }); - }, 500); }, [location, setAroundLatLng]); if (!userLocation) { diff --git a/app/components/ui/Calendar/EventCalendar.module.scss b/app/components/ui/Calendar/EventCalendar.module.scss index 5731200eb..2c4253e46 100644 --- a/app/components/ui/Calendar/EventCalendar.module.scss +++ b/app/components/ui/Calendar/EventCalendar.module.scss @@ -401,9 +401,7 @@ justify-content: space-between; padding: 1rem 0.5rem; margin-bottom: 1rem; - background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); - border-radius: 12px; - border: 1px solid #dee2e6; + border-bottom: 1px solid #dee2e6; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); @media (min-width: 769px) { @@ -415,8 +413,8 @@ background: white; border: 2px solid #007bff; border-radius: 50%; - width: 44px; - height: 44px; + width: 36px; + height: 36px; display: flex; align-items: center; justify-content: center; @@ -786,7 +784,8 @@ } &:focus { - outline: none; + outline: 2px solid rgba(0, 123, 255, 0.3); + outline-offset: 4px; border-radius: 50%; } &:active { @@ -1052,3 +1051,9 @@ color: white; } } + +.categoryInstructions { + margin-top: 8px; + margin-bottom: 24px; + text-align: center; +} diff --git a/app/components/ui/Calendar/EventCalendar.tsx b/app/components/ui/Calendar/EventCalendar.tsx index d625c3cef..525487151 100644 --- a/app/components/ui/Calendar/EventCalendar.tsx +++ b/app/components/ui/Calendar/EventCalendar.tsx @@ -1,196 +1,58 @@ import React, { useMemo, useState } from "react"; -import { Calendar, momentLocalizer, Event, Views } from "react-big-calendar"; +import { Calendar, momentLocalizer, Views } from "react-big-calendar"; import moment from "moment"; -import DOMPurify from "dompurify"; -import { SFGovEvent } from "hooks/SFGovAPI"; +import { useAllSFGovEvents } from "hooks/SFGovAPI"; import { Loader } from "../Loader"; +import { CategoryFilters } from "./components/CategoryFilters"; +import { MobileAgenda } from "./components/MobileAgenda"; +import { EventSlideout } from "./components/EventSlideout"; +import { CustomToolbar } from "./components/CustomToolbar"; +import { EventCalendarProps, CalendarEvent } from "./types"; +import { useEventProcessing, useEventTransformation } from "./hooks"; +import { + getDarkerColor, + getCategoryColor, + formatMobileDateHeader, +} from "./utils"; +import { CATEGORY_COLORS } from "./constants"; import styles from "./EventCalendar.module.scss"; import "react-big-calendar/lib/css/react-big-calendar.css"; -import { - ChevronLeftIcon, - ChevronRightIcon, - XMarkIcon, -} from "@heroicons/react/16/solid"; // Setup the localizer const localizer = momentLocalizer(moment); -// Utility function to safely sanitize HTML content -const sanitizeHtml = (html: string): string => { - if (!html) return html; - - // Configure DOMPurify to allow only safe HTML elements and attributes - const cleanHtml = DOMPurify.sanitize(html, { - ALLOWED_TAGS: ["p", "br", "strong", "em", "u", "span", "div", "a"], - ALLOWED_ATTR: ["href", "target", "rel"], - ALLOWED_URI_REGEXP: /^https?:\/\//, // Only allow http/https links - }); - - return cleanHtml; -}; - -// Color palette for categories -const CATEGORY_COLORS = [ - "#E3F2FD", // Light Blue - "#E8F5E8", // Light Green - "#F3E5F5", // Light Purple - "#FFF3E0", // Light Orange - "#FCE4EC", // Light Pink - "#FFFDE7", // Light Yellow - "#E0F7FA", // Light Cyan - "#F0F4FF", // Light Periwinkle - "#F5F5F5", // Light Gray -]; - -// Function to get color for a category -const getCategoryColor = (index: number): string => { - return CATEGORY_COLORS[index % CATEGORY_COLORS.length]; -}; - -// Function to get darker version of color for text/borders -const getDarkerColor = (color: string): string => { - // Simple function to darken hex colors - const hex = color.replace("#", ""); - const r = Math.max(0, parseInt(hex.substr(0, 2), 16) - 40); - const g = Math.max(0, parseInt(hex.substr(2, 2), 16) - 40); - const b = Math.max(0, parseInt(hex.substr(4, 2), 16) - 40); - return `#${r.toString(16).padStart(2, "0")}${g - .toString(16) - .padStart(2, "0")}${b.toString(16).padStart(2, "0")}`; -}; - -// Helper function to parse days_of_week string and check if a date matches -const shouldEventOccurOnDay = ( - daysOfWeek: string | undefined, - date: Date -): boolean => { - if (!daysOfWeek || daysOfWeek.trim() === "") { - // No days specified, assume every day - return true; - } - - // Map of day abbreviations to JavaScript day numbers (0 = Sunday, 6 = Saturday) - const dayMap: { [key: string]: number } = { - Su: 0, - M: 1, - T: 2, - W: 3, - Th: 4, - F: 5, - Sa: 6, - }; - - const dayOfWeek = date.getDay(); - - // Handle range format like "M-F" (Monday through Friday) - if (daysOfWeek.includes("-")) { - const [start, end] = daysOfWeek.split("-"); - const startDay = dayMap[start.trim()]; - const endDay = dayMap[end.trim()]; - - if (startDay !== undefined && endDay !== undefined) { - // Handle wrap-around (e.g., F-M would be Friday through Monday) - if (startDay <= endDay) { - return dayOfWeek >= startDay && dayOfWeek <= endDay; - } else { - return dayOfWeek >= startDay || dayOfWeek <= endDay; - } - } - } - - // Handle comma-separated format like "T,W,F,Sa" - const days = daysOfWeek.split(",").map((d) => d.trim()); - return days.some((day) => dayMap[day] === dayOfWeek); -}; - -// Helper function to ensure URLs have proper protocol -const ensureHttpsProtocol = (url: string): string => { - if (!url || url.trim() === "") return url; - - const trimmedUrl = url.trim(); - - // Security: Block potentially dangerous protocols - const dangerousProtocols = ["javascript", "data", "vbscript", "file", "ftp"]; - const lowerUrl = trimmedUrl.toLowerCase(); - if ( - dangerousProtocols.some((protocol) => lowerUrl.startsWith(`${protocol}:`)) - ) { - return ""; // Return empty string for dangerous protocols - } - - // If it already has a safe protocol, return as is - if (trimmedUrl.startsWith("http://") || trimmedUrl.startsWith("https://")) { - return trimmedUrl; - } - - // If it's a relative path, return as-is - if ( - trimmedUrl.startsWith("/") || - trimmedUrl.startsWith("./") || - trimmedUrl.startsWith("../") - ) { - return trimmedUrl; - } - - // Try to parse as a valid URL; if it fails, prepend https:// and try again - try { - // Try parsing as is (may throw if missing protocol) - new URL(trimmedUrl); - // If no error, but no protocol, add https:// - return `https://${trimmedUrl}`; - } catch { - try { - // Try parsing with https:// prepended - const testUrl = new URL(`https://${trimmedUrl}`); - - // Additional security: Ensure the URL has a valid hostname - if (testUrl.hostname && testUrl.hostname !== "localhost") { - return `https://${trimmedUrl}`; - } - - // If hostname is suspicious, return original - return trimmedUrl; - } catch { - // If still invalid, return original - return trimmedUrl; - } - } -}; -interface CalendarEvent extends Event { - id: string; - pageLink: string; - description: string; - location?: string; - originalEvent: SFGovEvent; -} - -interface EventCalendarProps { - events: SFGovEvent[] | null; - onEventSelect?: (event: CalendarEvent) => void; -} - -interface CategoryFilter { - category: string; - enabled: boolean; - color: string; -} - export const EventCalendar: React.FC = ({ - events, onEventSelect, }) => { - // State for slideout menu + const { data: events, isLoading: eventsAreLoading } = useAllSFGovEvents(); + const [slideoutOpen, setSlideoutOpen] = useState(false); const [selectedEvent, setSelectedEvent] = useState( null ); - // State for showing multiple events from a day const [dayEvents, setDayEvents] = useState([]); const [selectedDate, setSelectedDate] = useState(null); // State for mobile agenda navigation const [currentMobileDate, setCurrentMobileDate] = useState(new Date()); + // Process events and categories + const { + availableCategories, + categoryFilters, + enabledCategories, + categoryColorMap, + toggleCategory, + } = useEventProcessing(events); + + // Transform events for calendar + const calendarEvents = useEventTransformation( + events, + categoryFilters, + enabledCategories, + currentMobileDate + ); + // Navigation functions for mobile agenda view const navigateToPreviousDay = () => { setCurrentMobileDate((prev) => { @@ -208,85 +70,6 @@ export const EventCalendar: React.FC = ({ }); }; - const formatMobileDateHeader = (date: Date): string => { - return date.toLocaleDateString("en-US", { - weekday: "long", - month: "long", - day: "numeric", - }); - }; - - // Extract unique categories from events and create filter state - const availableCategories = useMemo(() => { - if (!events) return []; - - const uniqueCategories = Array.from( - new Set(events.map((event) => event.category).filter(Boolean)) - ).sort(); - - return uniqueCategories; - }, [events]); - - // State for category filters - all categories enabled by default - const [categoryFilters, setCategoryFilters] = useState(() => - availableCategories.map((category, index) => ({ - category, - enabled: true, - color: getCategoryColor(index), - })) - ); - - // Update category filters when available categories change - React.useEffect(() => { - setCategoryFilters((prev) => { - const existingCategories = new Set(prev.map((f) => f.category)); - const newFilters = [...prev]; - - // Add new categories that weren't present before - availableCategories.forEach((category, index) => { - if (!existingCategories.has(category)) { - newFilters.push({ - category, - enabled: true, - color: getCategoryColor(availableCategories.indexOf(category)), - }); - } - }); - - // Remove categories that no longer exist - return newFilters.filter((filter) => - availableCategories.includes(filter.category) - ); - }); - }, [availableCategories]); - - // Get enabled category names - const enabledCategories = useMemo( - () => - new Set(categoryFilters.filter((f) => f.enabled).map((f) => f.category)), - [categoryFilters] - ); - - // Toggle category filter - const toggleCategory = (category: string) => { - setCategoryFilters((prev) => - prev.map((filter) => - filter.category === category - ? { ...filter, enabled: !filter.enabled } - : filter - ) - ); - }; - - // Get category color mapping - const categoryColorMap = useMemo(() => { - const map = new Map(); - categoryFilters.forEach((filter) => { - map.set(filter.category, filter.color); - }); - return map; - }, [categoryFilters]); - // Responsive views and default view for mobile const calendarViews = useMemo(() => { if (typeof window !== "undefined" && window.innerWidth <= 768) { @@ -310,144 +93,6 @@ export const EventCalendar: React.FC = ({ return 800; }, []); - const calendarEvents = useMemo(() => { - if (!events) return []; - - const transformedEvents: CalendarEvent[] = []; - - events - .filter((event) => { - // Filter out events without start date - if (!event.event_start_date) return false; - - // Filter by category if category filters are active - if ( - categoryFilters.length > 0 && - !enabledCategories.has(event.category) - ) { - return false; - } - - return true; - }) - .forEach((event) => { - const startDateOnly = new Date(event.event_start_date); - const endDateOnly = event.event_end_date - ? new Date(event.event_end_date) - : new Date(event.event_start_date); - - // Check if this is a recurring event (has both start/end dates AND start/end times) - const isRecurringEvent = - event.event_end_date && - event.start_time && - event.end_time && - startDateOnly.toDateString() !== endDateOnly.toDateString(); - - if (isRecurringEvent) { - // Create individual events for each day between start and end dates - const currentDate = new Date(startDateOnly); - - while (currentDate <= endDateOnly) { - // Check if event should occur on this day of the week - if (shouldEventOccurOnDay(event.days_of_week, currentDate)) { - // Parse times for this specific day - const [startHours, startMinutes, startSeconds = 0] = - event.start_time.split(":").map(Number); - const [endHours, endMinutes, endSeconds = 0] = event.end_time - .split(":") - .map(Number); - - // Create start datetime for this day - const dayStartDate = new Date(currentDate); - dayStartDate.setHours(startHours, startMinutes, startSeconds); - - // Create end datetime for this day - const dayEndDate = new Date(currentDate); - dayEndDate.setHours(endHours, endMinutes, endSeconds); - - // Add individual event for this day - transformedEvents.push({ - id: `${event.id}-${currentDate.toISOString().split("T")[0]}`, // Unique ID per day - title: event.event_name, - start: dayStartDate, - end: dayEndDate, - pageLink: ensureHttpsProtocol(event.more_info || ""), - description: event.event_description || "", - location: event.site_location_name || "", - allDay: false, // These are timed events - originalEvent: event, - }); - } - - // Move to next day - currentDate.setDate(currentDate.getDate() + 1); - } - } else { - // Handle as single event (existing logic) - const startDate = new Date(event.event_start_date); - if (event.start_time) { - const [hours, minutes, seconds] = event.start_time - .split(":") - .map(Number); - startDate.setHours(hours, minutes, seconds || 0); - } - - let endDate: Date; - const isAllDayEvent = !event.start_time && !event.end_time; - - if (event.event_end_date) { - endDate = new Date(event.event_end_date); - if (event.end_time) { - const [hours, minutes, seconds] = event.end_time - .split(":") - .map(Number); - endDate.setHours(hours, minutes, seconds || 0); - } else if (event.start_time) { - // If there's a start time but no end time, default to 1 hour duration - endDate = new Date(startDate); - endDate.setHours(endDate.getHours() + 1); - } else { - // No specific time, treat as all day event - end date should be same day - endDate.setHours(23, 59, 59, 999); - } - } else { - // If no end date, assume same day as start - endDate = new Date(startDate); - if (event.start_time && !event.end_time) { - // If there's a start time but no end time, default to 1 hour duration - endDate.setHours(endDate.getHours() + 1); - } else if (isAllDayEvent) { - // All day event - end should be same day - endDate.setHours(23, 59, 59, 999); - } - } - - transformedEvents.push({ - id: event.id, - title: event.event_name, - start: startDate, - end: endDate, - pageLink: ensureHttpsProtocol(event.more_info || ""), - description: event.event_description || "", - location: event.site_location_name || "", - allDay: isAllDayEvent, - originalEvent: event, - }); - } - }); - - // Filter events for mobile agenda view to show only current day - const isMobile = typeof window !== "undefined" && window.innerWidth <= 768; - if (isMobile) { - const currentDateStr = currentMobileDate.toDateString(); - return transformedEvents.filter((event) => { - return event.start && event.start.toDateString() === currentDateStr; - }); - } - - return transformedEvents; - }, [events, categoryFilters.length, enabledCategories, currentMobileDate]); - // Group events by start time for mobile agenda view const groupedEventsByTime = useMemo(() => { const isMobile = typeof window !== "undefined" && window.innerWidth <= 768; @@ -499,7 +144,7 @@ export const EventCalendar: React.FC = ({ setSelectedDate(null); setSlideoutOpen(true); - // Still call the optional onEventSelect prop if provided + // Call parent callback if provided if (onEventSelect) { onEventSelect(event); } @@ -520,37 +165,32 @@ export const EventCalendar: React.FC = ({ setSelectedDate(null); }; - // Handle escape key to close slideout - React.useEffect(() => { - const handleEscape = (e: KeyboardEvent) => { - if (e.key === "Escape" && slideoutOpen) { - closeSlideout(); - } - }; - - if (slideoutOpen) { - document.addEventListener("keydown", handleEscape); - // Prevent body scroll when slideout is open - document.body.style.overflow = "hidden"; + const handleSlideoutEventSelect = (event: CalendarEvent) => { + // When viewing an event from the day events list, show its details + setSelectedEvent(event); + setDayEvents([]); + setSelectedDate(null); + }; - // Focus management - focus the close button when slideout opens - setTimeout(() => { - const closeBtn = document.querySelector( - ".close-slideout-btn" - ) as HTMLElement; - closeBtn?.focus(); - }, 100); + const eventStyleGetter = (event: CalendarEvent) => { + // Get category color with improved fallback + let categoryColor = categoryColorMap.get(event.originalEvent.category); + + // If color not found in map, calculate it directly to prevent blue flash + if (!categoryColor && event.originalEvent.category) { + const categories = Array.from( + new Set(events?.map((e) => e.category).filter(Boolean) || []) + ).sort(); + const categoryIndex = categories.indexOf(event.originalEvent.category); + categoryColor = + categoryIndex >= 0 + ? getCategoryColor(categoryIndex) + : CATEGORY_COLORS[0]; + } else if (!categoryColor) { + // Final fallback - use first color from palette instead of blue + categoryColor = CATEGORY_COLORS[0]; } - return () => { - document.removeEventListener("keydown", handleEscape); - document.body.style.overflow = "unset"; - }; - }, [slideoutOpen]); - - const eventStyleGetter = (event: CalendarEvent) => { - const categoryColor = - categoryColorMap.get(event.originalEvent.category) || "#007bff"; const darkerColor = getDarkerColor(categoryColor); return { @@ -567,199 +207,39 @@ export const EventCalendar: React.FC = ({ }; }; - // Components object for react-big-calendar - const calendarComponents = useMemo(() => { - // Custom toolbar component for balanced layout - const CustomToolbar = ({ - date, - onNavigate, - label, - }: { - date: Date; - onNavigate: (action: "PREV" | "NEXT" | "TODAY") => void; - label: string; - }) => { - return ( -
-
- -
-
-

{label}

-
-
- - -
-
- ); - }; - - // Custom event component for agenda view - const AgendaEvent = ({ event }: { event: CalendarEvent }) => { - const categoryColor = - categoryColorMap.get(event.originalEvent.category) || "#007bff"; - - return ( -
- - {event.originalEvent.category} - -
{event.title}
- {event.location && ( -
πŸ“ {event.location}
- )} -
- ); - }; - - const isMobile = typeof window !== "undefined" && window.innerWidth <= 768; - - if (isMobile) { - return { - agenda: { - event: AgendaEvent, - }, - }; - } - - // Desktop components with custom toolbar - return { - toolbar: CustomToolbar, - }; - }, [categoryColorMap]); + // Loading states + if (!events || eventsAreLoading) { + return ; + } - if (!events) { + // Show loader if category colors aren't ready to prevent blue flash + if ( + events && + availableCategories.length > 0 && + categoryFilters.length === 0 + ) { return ; } + const isMobile = typeof window !== "undefined" && window.innerWidth <= 768; + return (
- {/* Category Filters */} - {availableCategories.length > 0 && ( -
-

Filter by Category

-
- {categoryFilters.map(({ category, enabled, color }) => ( - - ))} -
-
- )} - - {/* Mobile Date Navigation Header */} - {typeof window !== "undefined" && window.innerWidth <= 768 && ( -
- -

- {formatMobileDateHeader(currentMobileDate)} -

- -
- )} - - {/* Custom Mobile Agenda View */} - {typeof window !== "undefined" && window.innerWidth <= 768 ? ( -
- {Object.keys(groupedEventsByTime).length === 0 ? ( -
-

No events scheduled for this day.

-
- ) : ( - Object.entries(groupedEventsByTime).map(([timeKey, events]) => ( -
-
- {timeKey} -
-
- {events.map((event) => { - const categoryColor = - categoryColorMap.get(event.originalEvent.category) || - "#007bff"; - return ( - - ); - })} -
-
- )) - )} -
+ + + {isMobile ? ( + ) : ( = ({ eventPropGetter={eventStyleGetter} views={calendarViews} defaultView={defaultView} - components={calendarComponents} - date={ - typeof window !== "undefined" && window.innerWidth <= 768 - ? currentMobileDate - : undefined - } - onNavigate={(date) => { - if (typeof window !== "undefined" && window.innerWidth <= 768) { - setCurrentMobileDate(date); - } + components={{ + toolbar: CustomToolbar, }} - toolbar={ - typeof window !== "undefined" && window.innerWidth <= 768 - ? false - : true - } tooltipAccessor={(event: CalendarEvent) => - `${event.title}${event.location ? ` - ${event.location}` : ""}` + `${event.title} - ${event.originalEvent.category}` } /> )} {/* Event Details Slideout */} - {slideoutOpen && (selectedEvent || dayEvents.length > 0) && ( - <> - {/* Backdrop */} -
e.key === "Escape" && closeSlideout()} - role="button" - tabIndex={0} - aria-label="Close event details" - /> - - {/* Slideout Panel */} -
-
-

- {selectedEvent - ? selectedEvent.title - : selectedDate - ? `Events for ${selectedDate.toLocaleDateString()}` - : "Event Details"} -

- -
- -
- {/* Single Event Details */} - {selectedEvent && ( - <> -
-
- {selectedEvent.originalEvent.category} -
-
- - {selectedEvent.location && ( -
- πŸ“ Location: - {selectedEvent.location} -
- )} - -
- πŸ“… Date & Time: - - {selectedEvent.start && - selectedEvent.start.toLocaleDateString()}{" "} - at{" "} - {selectedEvent.allDay - ? "All Day" - : selectedEvent.start?.toLocaleTimeString()} - {!selectedEvent.allDay && - selectedEvent.end && - ` - ${selectedEvent.end.toLocaleTimeString()}`} - -
- - {selectedEvent.description && ( -
- πŸ“ Description: - -
- )} - - {selectedEvent.originalEvent.org_name && ( -
- 🏒 Organization: - {selectedEvent.originalEvent.org_name} -
- )} - - {selectedEvent.originalEvent.site_address && ( -
- πŸ—ΊοΈ Address: - {selectedEvent.originalEvent.site_address} -
- )} - - {selectedEvent.originalEvent.site_phone && ( -
- πŸ“ž Phone: - {selectedEvent.originalEvent.site_phone} -
- )} - - {selectedEvent.originalEvent.site_email && ( - - )} - - {selectedEvent.originalEvent.fee !== undefined && ( -
- πŸ’° Fee: - - {selectedEvent.originalEvent.fee ? "Yes" : "Free"} - -
- )} - - {selectedEvent.originalEvent.age_group_eligibility_tags && ( -
- πŸ‘₯ Age Group: - - {selectedEvent.originalEvent.age_group_eligibility_tags} - -
- )} - - )} - - {/* Multiple Events for a Day */} - {dayEvents.length > 0 && ( -
-

- {dayEvents.length} event{dayEvents.length > 1 ? "s" : ""} on - this day -

- - {dayEvents.map((event) => ( -
-
-
-

{event.title}

-
- -
-
- - {event.allDay - ? "All Day" - : `${event.start?.toLocaleTimeString()} ${ - event.end - ? `- ${event.end.toLocaleTimeString()}` - : "" - }`} - - {event.location && ( - - πŸ“ {event.location} - - )} -
- -
- - {event.originalEvent.category} - -
- - {event.description && ( -

120 - ? `${event.description.substring(0, 120)}...` - : event.description - ), - }} - /> - )} - -

- - {event.pageLink && ( - - )} -
-
-
- ))} -
- )} -
- -
- {selectedEvent && selectedEvent.pageLink && ( - - )} - -
-
- - )} +
); }; diff --git a/app/components/ui/Calendar/components/CategoryFilters.tsx b/app/components/ui/Calendar/components/CategoryFilters.tsx new file mode 100644 index 000000000..864aa290c --- /dev/null +++ b/app/components/ui/Calendar/components/CategoryFilters.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { CategoryFiltersProps } from "../types"; +import { getDarkerColor } from "../utils"; +import styles from "../EventCalendar.module.scss"; + +export const CategoryFilters: React.FC = ({ + availableCategories, + categoryFilters, + onToggleCategory, +}) => { + if (availableCategories.length === 0) { + return null; + } + + return ( +
+

Filter by Category

+

+ All categories are selected by default. Click a category to deselect it. +

+
+ {categoryFilters.map(({ category, enabled, color }) => ( + + ))} +
+
+ ); +}; diff --git a/app/components/ui/Calendar/components/CustomToolbar.tsx b/app/components/ui/Calendar/components/CustomToolbar.tsx new file mode 100644 index 000000000..bc96c1b8f --- /dev/null +++ b/app/components/ui/Calendar/components/CustomToolbar.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { ToolbarProps } from "react-big-calendar"; +import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/outline"; +import { CalendarEvent } from "../types"; +import styles from "../EventCalendar.module.scss"; + +/** + * Custom toolbar for the event calendar with chevrons and centered date + * Matches the original EventCalendar toolbar layout with left/center/right sections + */ +export const CustomToolbar: React.FC> = ({ + date, + onNavigate, + label, +}) => { + return ( +
+
+ +
+
+

{label}

+
+
+ + +
+
+ ); +}; diff --git a/app/components/ui/Calendar/components/EventSlideout.tsx b/app/components/ui/Calendar/components/EventSlideout.tsx new file mode 100644 index 000000000..beeead6ad --- /dev/null +++ b/app/components/ui/Calendar/components/EventSlideout.tsx @@ -0,0 +1,306 @@ +import React, { useEffect, useLayoutEffect, useRef } from "react"; +import { XMarkIcon } from "@heroicons/react/16/solid"; +import { EventSlideoutProps } from "../types"; +import { sanitizeHtml } from "../utils"; +import styles from "../EventCalendar.module.scss"; + +export const EventSlideout: React.FC = ({ + isOpen, + onClose, + selectedEvent, + dayEvents, + selectedDate, + categoryColorMap, + onEventSelect, +}) => { + const closeButtonRef = useRef(null); + + // Handle escape key to close slideout + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape" && isOpen) { + onClose(); + } + }; + + if (isOpen) { + document.addEventListener("keydown", handleEscape); + // Prevent body scroll when slideout is open + document.body.style.overflow = "hidden"; + } + + return () => { + document.removeEventListener("keydown", handleEscape); + document.body.style.overflow = "unset"; + }; + }, [isOpen, onClose]); + + // Focus management - focus the close button when slideout opens + useLayoutEffect(() => { + if (isOpen && closeButtonRef.current) { + closeButtonRef.current.focus(); + } + }, [isOpen]); + + if (!isOpen || (!selectedEvent && dayEvents.length === 0)) { + return null; + } + + return ( + <> + {/* Backdrop */} +
e.key === "Escape" && onClose()} + role="button" + tabIndex={0} + aria-label="Close event details" + /> + + {/* Slideout Panel */} +
+
+

+ {selectedEvent + ? selectedEvent.title + : selectedDate + ? `Events for ${selectedDate.toLocaleDateString()}` + : "Event Details"} +

+ +
+ +
+ {/* Single Event Details */} + {selectedEvent && ( + <> +
+
+ {selectedEvent.originalEvent.category} +
+
+ + {selectedEvent.location && ( +
+ πŸ“ Location: + {selectedEvent.location} +
+ )} + +
+ πŸ“… Date & Time: + + {selectedEvent.start && + selectedEvent.start.toLocaleDateString()}{" "} + at{" "} + {selectedEvent.allDay + ? "All Day" + : selectedEvent.start?.toLocaleTimeString()} + {!selectedEvent.allDay && + selectedEvent.end && + ` - ${selectedEvent.end.toLocaleTimeString()}`} + +
+ + {selectedEvent.description && ( +
+ πŸ“ Description: + +
+ )} + + {selectedEvent.originalEvent.org_name && ( +
+ 🏒 Organization: + {selectedEvent.originalEvent.org_name} +
+ )} + + {selectedEvent.originalEvent.site_address && ( +
+ πŸ—ΊοΈ Address: + {selectedEvent.originalEvent.site_address} +
+ )} + + {selectedEvent.originalEvent.site_phone && ( +
+ πŸ“ž Phone: + {selectedEvent.originalEvent.site_phone} +
+ )} + + {selectedEvent.originalEvent.site_email && ( + + )} + + {selectedEvent.originalEvent.fee !== undefined && ( +
+ πŸ’° Fee: + + {selectedEvent.originalEvent.fee ? "Yes" : "Free"} + +
+ )} + + {selectedEvent.originalEvent.age_group_eligibility_tags && ( +
+ πŸ‘₯ Age Group: + + {selectedEvent.originalEvent.age_group_eligibility_tags} + +
+ )} + + )} + + {/* Multiple Events for a Day */} + {dayEvents.length > 0 && ( +
+

+ {dayEvents.length} event{dayEvents.length > 1 ? "s" : ""} on + this day +

+ + {dayEvents.map((event) => ( +
+
+
+

{event.title}

+
+ +
+
+ + {event.allDay + ? "All Day" + : `${event.start?.toLocaleTimeString()} ${ + event.end + ? `- ${event.end.toLocaleTimeString()}` + : "" + }`} + + {event.location && ( + + πŸ“ {event.location} + + )} +
+ +
+ + {event.originalEvent.category} + +
+ + {event.description && ( +

120 + ? `${event.description.substring(0, 120)}...` + : event.description + ), + }} + /> + )} + +

+ + {event.pageLink && ( + + )} +
+
+
+ ))} +
+ )} +
+ +
+ {selectedEvent && selectedEvent.pageLink && ( + + )} + +
+
+ + ); +}; diff --git a/app/components/ui/Calendar/components/MobileAgenda.tsx b/app/components/ui/Calendar/components/MobileAgenda.tsx new file mode 100644 index 000000000..ade1d086a --- /dev/null +++ b/app/components/ui/Calendar/components/MobileAgenda.tsx @@ -0,0 +1,102 @@ +import React from "react"; +import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/16/solid"; +import { MobileAgendaProps, CalendarEvent } from "../types"; +import { CATEGORY_COLORS } from "../constants"; +import styles from "../EventCalendar.module.scss"; + +export const MobileAgenda: React.FC = ({ + currentDate, + groupedEvents, + onNavigatePrevious, + onNavigateNext, + onEventSelect, + formatDateHeader, +}) => { + return ( +
+ {/* Mobile Navigation Header */} +
+ +

+ {formatDateHeader(currentDate)} +

+ +
+ + {/* Events Content */} + {Object.keys(groupedEvents).length === 0 ? ( +
+

No events scheduled for this day.

+
+ ) : ( + Object.entries(groupedEvents).map(([timeKey, events]) => ( +
+
+ {timeKey} +
+
+ {events.map((event) => ( + + ))} +
+
+ )) + )} +
+ ); +}; + +interface AgendaEventChipProps { + event: CalendarEvent; + onSelect: (event: CalendarEvent) => void; +} + +const AgendaEventChip: React.FC = ({ + event, + onSelect, +}) => { + // Use first color from palette as fallback + const categoryColor = CATEGORY_COLORS[0]; + + return ( + + ); +}; diff --git a/app/components/ui/Calendar/constants.ts b/app/components/ui/Calendar/constants.ts new file mode 100644 index 000000000..3f0fca95c --- /dev/null +++ b/app/components/ui/Calendar/constants.ts @@ -0,0 +1,23 @@ +// Color palette for categories +export const CATEGORY_COLORS = [ + "#E3F2FD", // Light Blue + "#E8F5E8", // Light Green + "#F3E5F5", // Light Purple + "#FFF3E0", // Light Orange + "#FCE4EC", // Light Pink + "#FFFDE7", // Light Yellow + "#E0F7FA", // Light Cyan + "#F0F4FF", // Light Periwinkle + "#F5F5F5", // Light Gray +]; + +// Map of day abbreviations to JavaScript day numbers (0 = Sunday, 6 = Saturday) +export const DAY_MAP: { [key: string]: number } = { + Su: 0, + M: 1, + T: 2, + W: 3, + Th: 4, + F: 5, + Sa: 6, +}; diff --git a/app/components/ui/Calendar/hooks.ts b/app/components/ui/Calendar/hooks.ts new file mode 100644 index 000000000..f119954aa --- /dev/null +++ b/app/components/ui/Calendar/hooks.ts @@ -0,0 +1,246 @@ +import { useMemo, useLayoutEffect, useState, useEffect } from "react"; +import { SFGovEvent } from "hooks/SFGovAPI"; +import { CalendarEvent, CategoryFilter } from "./types"; +import { + extractUniqueCategories, + createCategoryColorMap, + getCategoryColor, + shouldEventOccurOnDay, + ensureHttpsProtocol, +} from "./utils"; + +export const useEventProcessing = (events: SFGovEvent[] | null) => { + // Extract unique categories from events + const availableCategories = useMemo( + () => extractUniqueCategories(events), + [events] + ); + + // State for category filters - initialize empty to prevent flash + const [categoryFilters, setCategoryFilters] = useState([]); + + // Use useLayoutEffect to set category filters before paint to prevent flashing + useLayoutEffect(() => { + if (availableCategories.length > 0) { + setCategoryFilters( + availableCategories.map((category, index) => ({ + category, + enabled: true, + color: getCategoryColor(index), + })) + ); + } + }, [availableCategories]); + + // Update category filters when available categories change + useEffect(() => { + setCategoryFilters((prev) => { + const existingCategories = new Set(prev.map((f) => f.category)); + const newFilters = [...prev]; + + // Add new categories that weren't present before + availableCategories.forEach((category, index) => { + if (!existingCategories.has(category)) { + newFilters.push({ + category, + enabled: true, + color: getCategoryColor(availableCategories.indexOf(category)), + }); + } + }); + + // Remove categories that no longer exist + return newFilters.filter((filter) => + availableCategories.includes(filter.category) + ); + }); + }, [availableCategories]); + + // Get enabled category names + const enabledCategories = useMemo( + () => + new Set(categoryFilters.filter((f) => f.enabled).map((f) => f.category)), + [categoryFilters] + ); + + // Get category color mapping - use pre-calculated mapping to prevent flashing + const categoryColorMap = useMemo(() => { + // First try to get from categoryFilters if available + if (categoryFilters.length > 0) { + const map = new Map(); + categoryFilters.forEach((filter) => { + map.set(filter.category, filter.color); + }); + return map; + } + + // Fallback to direct calculation to prevent blue flash + return createCategoryColorMap(events); + }, [categoryFilters, events]); + + // Toggle category filter + const toggleCategory = (category: string) => { + setCategoryFilters((prev) => + prev.map((filter) => + filter.category === category + ? { ...filter, enabled: !filter.enabled } + : filter + ) + ); + }; + + return { + availableCategories, + categoryFilters, + enabledCategories, + categoryColorMap, + toggleCategory, + }; +}; + +export const useEventTransformation = ( + events: SFGovEvent[] | null, + categoryFilters: CategoryFilter[], + enabledCategories: Set, + currentMobileDate: Date +) => { + const calendarEvents = useMemo(() => { + if (!events) return []; + + const transformedEvents: CalendarEvent[] = []; + + events + .filter((event) => { + // Filter out events without start date + if (!event.event_start_date) return false; + + // Filter by category if category filters are active + if ( + categoryFilters.length > 0 && + !enabledCategories.has(event.category) + ) { + return false; + } + + return true; + }) + .forEach((event) => { + const startDateOnly = new Date(event.event_start_date); + const endDateOnly = event.event_end_date + ? new Date(event.event_end_date) + : new Date(event.event_start_date); + + // Check if this is a recurring event (has both start/end dates AND start/end times) + const isRecurringEvent = + event.event_end_date && + event.start_time && + event.end_time && + startDateOnly.toDateString() !== endDateOnly.toDateString(); + + if (isRecurringEvent) { + // Create individual events for each day between start and end dates + const currentDate = new Date(startDateOnly); + + while (currentDate <= endDateOnly) { + // Check if event should occur on this day of the week + if (shouldEventOccurOnDay(event.days_of_week, currentDate)) { + // Parse times for this specific day + const [startHours, startMinutes, startSeconds = 0] = + event.start_time.split(":").map(Number); + const [endHours, endMinutes, endSeconds = 0] = event.end_time + .split(":") + .map(Number); + + // Create start datetime for this day + const dayStartDate = new Date(currentDate); + dayStartDate.setHours(startHours, startMinutes, startSeconds); + + // Create end datetime for this day + const dayEndDate = new Date(currentDate); + dayEndDate.setHours(endHours, endMinutes, endSeconds); + + // Add individual event for this day + transformedEvents.push({ + id: `${event.id}-${currentDate.toISOString().split("T")[0]}`, // Unique ID per day + title: event.event_name, + start: dayStartDate, + end: dayEndDate, + pageLink: ensureHttpsProtocol(event.more_info || ""), + description: event.event_description || "", + location: event.site_location_name || "", + allDay: false, // These are timed events + originalEvent: event, + }); + } + + // Move to next day + currentDate.setDate(currentDate.getDate() + 1); + } + } else { + // Handle as single event (existing logic) + const startDate = new Date(event.event_start_date); + if (event.start_time) { + const [hours, minutes, seconds] = event.start_time + .split(":") + .map(Number); + startDate.setHours(hours, minutes, seconds || 0); + } + + let endDate: Date; + const isAllDayEvent = !event.start_time && !event.end_time; + + if (event.event_end_date) { + endDate = new Date(event.event_end_date); + if (event.end_time) { + const [hours, minutes, seconds] = event.end_time + .split(":") + .map(Number); + endDate.setHours(hours, minutes, seconds || 0); + } else if (event.start_time) { + // If there's a start time but no end time, default to 1 hour duration + endDate = new Date(startDate); + endDate.setHours(endDate.getHours() + 1); + } else { + // No specific time, treat as all day event - end date should be same day + endDate.setHours(23, 59, 59, 999); + } + } else { + // If no end date, assume same day as start + endDate = new Date(startDate); + if (event.start_time && !event.end_time) { + // If there's a start time but no end time, default to 1 hour duration + endDate.setHours(endDate.getHours() + 1); + } else if (isAllDayEvent) { + // All day event - end should be same day + endDate.setHours(23, 59, 59, 999); + } + } + + transformedEvents.push({ + id: event.id, + title: event.event_name, + start: startDate, + end: endDate, + pageLink: ensureHttpsProtocol(event.more_info || ""), + description: event.event_description || "", + location: event.site_location_name || "", + allDay: isAllDayEvent, + originalEvent: event, + }); + } + }); + + // Filter events for mobile agenda view to show only current day + const isMobile = typeof window !== "undefined" && window.innerWidth <= 768; + if (isMobile) { + const currentDateStr = currentMobileDate.toDateString(); + return transformedEvents.filter((event) => { + return event.start && event.start.toDateString() === currentDateStr; + }); + } + + return transformedEvents; + }, [events, categoryFilters.length, enabledCategories, currentMobileDate]); + + return calendarEvents; +}; diff --git a/app/components/ui/Calendar/index.ts b/app/components/ui/Calendar/index.ts new file mode 100644 index 000000000..8aadd2bab --- /dev/null +++ b/app/components/ui/Calendar/index.ts @@ -0,0 +1,36 @@ +// Main component +export { EventCalendar } from "./EventCalendar"; + +// Sub-components +export { CategoryFilters } from "./components/CategoryFilters"; +export { MobileAgenda } from "./components/MobileAgenda"; +export { EventSlideout } from "./components/EventSlideout"; +export { CustomToolbar } from "./components/CustomToolbar"; + +// Types +export type { + CalendarEvent, + EventCalendarProps, + CategoryFilter, + CategoryFiltersProps, + MobileAgendaProps, + EventSlideoutProps, +} from "./types"; + +// Utilities +export { + sanitizeHtml, + getCategoryColor, + getDarkerColor, + createCategoryColorMap, + shouldEventOccurOnDay, + ensureHttpsProtocol, + formatMobileDateHeader, + extractUniqueCategories, +} from "./utils"; + +// Constants +export { CATEGORY_COLORS, DAY_MAP } from "./constants"; + +// Hooks +export { useEventProcessing, useEventTransformation } from "./hooks"; diff --git a/app/components/ui/Calendar/types.ts b/app/components/ui/Calendar/types.ts new file mode 100644 index 000000000..714faa385 --- /dev/null +++ b/app/components/ui/Calendar/types.ts @@ -0,0 +1,45 @@ +import { Event } from "react-big-calendar"; +import { SFGovEvent } from "hooks/SFGovAPI"; + +export interface CalendarEvent extends Event { + id: string; + pageLink: string; + description: string; + location?: string; + originalEvent: SFGovEvent; +} + +export interface EventCalendarProps { + onEventSelect?: (event: CalendarEvent) => void; +} + +export interface CategoryFilter { + category: string; + enabled: boolean; + color: string; +} + +export interface CategoryFiltersProps { + availableCategories: string[]; + categoryFilters: CategoryFilter[]; + onToggleCategory: (category: string) => void; +} + +export interface MobileAgendaProps { + currentDate: Date; + groupedEvents: { [timeKey: string]: CalendarEvent[] }; + onNavigatePrevious: () => void; + onNavigateNext: () => void; + onEventSelect: (event: CalendarEvent) => void; + formatDateHeader: (date: Date) => string; +} + +export interface EventSlideoutProps { + isOpen: boolean; + onClose: () => void; + selectedEvent: CalendarEvent | null; + dayEvents: CalendarEvent[]; + selectedDate: Date | null; + categoryColorMap: Map; + onEventSelect: (event: CalendarEvent) => void; +} diff --git a/app/components/ui/Calendar/utils.ts b/app/components/ui/Calendar/utils.ts new file mode 100644 index 000000000..f85c1a872 --- /dev/null +++ b/app/components/ui/Calendar/utils.ts @@ -0,0 +1,120 @@ +import DOMPurify from "dompurify"; +import { SFGovEvent } from "hooks/SFGovAPI"; +import { CATEGORY_COLORS, DAY_MAP } from "./constants"; + +// Utility function to safely sanitize HTML content +export const sanitizeHtml = (html: string): string => { + if (!html) return html; + + // Configure DOMPurify to allow only safe HTML elements and attributes + const cleanHtml = DOMPurify.sanitize(html, { + ALLOWED_TAGS: ["p", "br", "strong", "em", "u", "span", "div", "a"], + ALLOWED_ATTR: ["href", "target", "rel"], + ALLOWED_URI_REGEXP: /^https?:\/\//, // Only allow http/https links + }); + + return cleanHtml; +}; + +// Function to get color for a category +export const getCategoryColor = (index: number): string => { + return CATEGORY_COLORS[index % CATEGORY_COLORS.length]; +}; + +// Function to get darker version of color for text/borders +export const getDarkerColor = (color: string): string => { + // Simple function to darken hex colors + const hex = color.replace("#", ""); + const r = Math.max(0, parseInt(hex.substring(0, 2), 16) - 40); + const g = Math.max(0, parseInt(hex.substring(2, 4), 16) - 40); + const b = Math.max(0, parseInt(hex.substring(4, 6), 16) - 40); + return `#${r.toString(16).padStart(2, "0")}${g + .toString(16) + .padStart(2, "0")}${b.toString(16).padStart(2, "0")}`; +}; + +// Helper function to create category color mapping synchronously +export const createCategoryColorMap = ( + events: SFGovEvent[] | null +): Map => { + const colorMap = new Map(); + + if (!events) return colorMap; + + const uniqueCategories = Array.from( + new Set(events.map((event) => event.category).filter(Boolean)) + ).sort(); + + uniqueCategories.forEach((category, index) => { + colorMap.set(category, getCategoryColor(index)); + }); + + return colorMap; +}; + +// Helper function to parse days_of_week string and check if a date matches +export const shouldEventOccurOnDay = ( + daysOfWeek: string | undefined, + date: Date +): boolean => { + if (!daysOfWeek || daysOfWeek.trim() === "") { + // No days specified, assume every day + return true; + } + + const dayOfWeek = date.getDay(); + + // Handle range format like "M-F" (Monday through Friday) + if (daysOfWeek.includes("-")) { + const [start, end] = daysOfWeek.split("-"); + const startDay = DAY_MAP[start.trim()]; + const endDay = DAY_MAP[end.trim()]; + + if (startDay !== undefined && endDay !== undefined) { + // Handle wrap-around (e.g., F-M would be Friday through Monday) + if (startDay <= endDay) { + return dayOfWeek >= startDay && dayOfWeek <= endDay; + } else { + return dayOfWeek >= startDay || dayOfWeek <= endDay; + } + } + } + + // Handle comma-separated format like "T,W,F,Sa" + const days = daysOfWeek.split(",").map((d) => d.trim()); + return days.some((day) => DAY_MAP[day] === dayOfWeek); +}; + +// Utility function to ensure HTTPS protocol +export const ensureHttpsProtocol = (url: string): string => { + if (!url) return ""; + if (url.startsWith("http://")) { + return url.replace("http://", "https://"); + } + if (!url.startsWith("https://") && !url.startsWith("http://")) { + return `https://${url}`; + } + return url; +}; + +// Helper function to format mobile date header +export const formatMobileDateHeader = (date: Date): string => { + return date.toLocaleDateString("en-US", { + weekday: "long", + month: "long", + day: "numeric", + }); +}; + +// Helper function to extract unique categories from events +export const extractUniqueCategories = ( + events: SFGovEvent[] | null +): string[] => { + if (!events) return []; + + const uniqueCategories = Array.from( + new Set(events.map((event) => event.category).filter(Boolean)) + ).sort(); + + return uniqueCategories; +}; diff --git a/app/components/ui/ErrorBoundary/ErrorBoundary.module.scss b/app/components/ui/ErrorBoundary/ErrorBoundary.module.scss new file mode 100644 index 000000000..1d03eb635 --- /dev/null +++ b/app/components/ui/ErrorBoundary/ErrorBoundary.module.scss @@ -0,0 +1,50 @@ +.errorContainer { + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; + padding: 2rem; + background-color: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 8px; + margin: 1rem 0; +} + +.errorContent { + text-align: center; + max-width: 400px; +} + +.errorTitle { + color: #dc3545; + font-size: 1.25rem; + font-weight: 600; + margin-bottom: 0.75rem; +} + +.errorMessage { + color: #6c757d; + font-size: 0.95rem; + line-height: 1.5; + margin-bottom: 1.5rem; +} + +.retryButton { + background-color: #007bff; + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 4px; + font-size: 0.9rem; + cursor: pointer; + transition: background-color 0.2s; + + &:hover { + background-color: #0056b3; + } + + &:focus { + outline: 2px solid #0056b3; + outline-offset: 2px; + } +} diff --git a/app/components/ui/ErrorBoundary/ErrorBoundary.tsx b/app/components/ui/ErrorBoundary/ErrorBoundary.tsx new file mode 100644 index 000000000..651b1a8ae --- /dev/null +++ b/app/components/ui/ErrorBoundary/ErrorBoundary.tsx @@ -0,0 +1,60 @@ +import React, { Component, ReactNode } from "react"; +import styles from "./ErrorBoundary.module.scss"; + +interface Props { + children: ReactNode; + fallback?: ReactNode; + sectionName?: string; +} + +interface State { + hasError: boolean; + error?: Error; +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + // TODO: Implement logging service + // console.error(`ErrorBoundary caught an error in ${this.props.sectionName}:`, error, errorInfo); + } + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( +
+
+

+ {this.props.sectionName + ? `Error loading ${this.props.sectionName}` + : "Something went wrong"} +

+

+ This section couldn't be loaded. Please try refreshing the page. +

+ +
+
+ ); + } + + return this.props.children; + } +} diff --git a/app/components/ui/ErrorBoundary/index.ts b/app/components/ui/ErrorBoundary/index.ts new file mode 100644 index 000000000..8dd268992 --- /dev/null +++ b/app/components/ui/ErrorBoundary/index.ts @@ -0,0 +1 @@ +export { ErrorBoundary } from "./ErrorBoundary"; diff --git a/app/components/ui/PageNotFound.tsx b/app/components/ui/PageNotFound.tsx index ee21499a3..3819323fd 100644 --- a/app/components/ui/PageNotFound.tsx +++ b/app/components/ui/PageNotFound.tsx @@ -2,16 +2,78 @@ import React from "react"; import { Button } from "./inline/Button/Button"; import styles from "./PageNotFound.module.scss"; -const PageNotFound = () => { +export enum NotFoundType { + ORGANIZATION_INACTIVE = "organization_inactive", + SERVICE_INACTIVE = "service_inactive", + ORGANIZATION_NOT_FOUND = "organization_not_found", + SERVICE_NOT_FOUND = "service_not_found", + EVENT_NOT_FOUND = "event_not_found", + PAGE_NOT_FOUND = "page_not_found", +} + +interface NotFoundContent { + header: string; + body: string; + ctaText: string; + ctaLink: string; +} + +const notFoundConfig: Record = { + [NotFoundType.ORGANIZATION_INACTIVE]: { + header: "Resource not active", + body: "This organization is currently inactive. It may become active again at a future date, or be permanently discontinued.", + ctaText: "Browse organizations", + ctaLink: "/search", + }, + [NotFoundType.SERVICE_INACTIVE]: { + header: "Resource not active", + body: "This service is currently inactive. It may become active again at a future date, or be permanently discontinued.", + ctaText: "Browse services", + ctaLink: "/search", + }, + [NotFoundType.ORGANIZATION_NOT_FOUND]: { + header: "Resource does not exist", + body: "This organization cannot be found.", + ctaText: "Browse organizations", + ctaLink: "/search", + }, + [NotFoundType.SERVICE_NOT_FOUND]: { + header: "Resource does not exist", + body: "This service cannot be found.", + ctaText: "Browse services", + ctaLink: "/search", + }, + [NotFoundType.EVENT_NOT_FOUND]: { + header: "Event details not found", + body: "This event cannot be found.", + ctaText: "Browse events", + ctaLink: "/", + }, + [NotFoundType.PAGE_NOT_FOUND]: { + header: "Page not found", + body: "This page cannot be found. It may have been moved or deleted.", + ctaText: "Go to homepage", + ctaLink: "/", + }, +}; + +interface PageNotFoundProps { + type?: NotFoundType; + isInactive?: boolean; +} + +const PageNotFound: React.FC = ({ + type = NotFoundType.PAGE_NOT_FOUND, +}) => { + // Handle legacy isInactive prop for backward compatibility + const config = notFoundConfig[type]; + return (
-

Page not found

-

- We’re sorry, but the page you’re looking for can’t be found. The URL may - be misspelled or the page you’re looking for is no longer available. -

-
); diff --git a/app/index.html b/app/index.html index 0c4161e57..ecea53fdc 100644 --- a/app/index.html +++ b/app/index.html @@ -1,20 +1,6 @@ - - - <%= htmlWebpackPlugin.options.title %> diff --git a/app/pages/EventDetailPage/EventDetailPage.tsx b/app/pages/EventDetailPage/EventDetailPage.tsx index 34229364f..00265ba3e 100644 --- a/app/pages/EventDetailPage/EventDetailPage.tsx +++ b/app/pages/EventDetailPage/EventDetailPage.tsx @@ -10,7 +10,7 @@ import { Loader } from "components/ui/Loader"; import DetailPageWrapper from "components/DetailPage/DetailPageWrapper"; import ListingPageHeader from "components/DetailPage/PageHeader"; import { ActionBarMobile } from "components/DetailPage"; -import PageNotFound from "components/ui/PageNotFound"; +import PageNotFound, { NotFoundType } from "components/ui/PageNotFound"; import { useEventData } from "hooks/StrapiAPI"; import { LabelTag } from "components/ui/LabelTag"; @@ -28,7 +28,7 @@ export const EventDetailPage = () => { sidebarActions={[]} onClickAction={() => "noop"} > - + ); } @@ -50,7 +50,7 @@ export const EventDetailPage = () => { sidebarActions={[]} onClickAction={() => "noop"} > - + ); } diff --git a/app/pages/HomePage/HomePage.tsx b/app/pages/HomePage/HomePage.tsx index f716d7c37..f2d8780c1 100644 --- a/app/pages/HomePage/HomePage.tsx +++ b/app/pages/HomePage/HomePage.tsx @@ -2,17 +2,16 @@ import React from "react"; import Hero from "components/ui/Hero/Hero"; import { CategorySection } from "components/ui/Section/CategorySection"; import { useHomepageData, useHomePageEventsData } from "hooks/StrapiAPI"; -import { useAllSFGovEvents } from "hooks/SFGovAPI"; import { Homepage, StrapiDatum } from "models/Strapi"; -import { EventCalendar } from "components/ui/Calendar/EventCalendar"; +import { EventCalendar } from "components/ui/Calendar"; import { HomePageSection } from "pages/HomePage/components/Section"; import { TwoColumnContentSection } from "components/ui/TwoColumnContentSection/TwoColumnContentSection"; import { EventCardSection } from "components/ui/Cards/EventCardSection"; +import { ErrorBoundary } from "components/ui/ErrorBoundary"; export const HomePage = () => { const { data: homepageData, isLoading: homepageDataIsLoading } = useHomepageData(); - const { data: eventsData, isLoading: eventsAreLoading } = useAllSFGovEvents(); const { data: featuredEventsData, isLoading: featuredEventsAreLoading } = useHomePageEventsData(); @@ -21,11 +20,7 @@ export const HomePage = () => { const homePageDataAttrs = homepageDataRes?.attributes; - if ( - homepageDataIsLoading || - eventsAreLoading || - (featuredEventsAreLoading && featuredEventsData) - ) { + if (homepageDataIsLoading) { return null; } @@ -48,25 +43,30 @@ export const HomePage = () => { - {eventsData && ( + {!featuredEventsAreLoading && ( - - - - - - + + + + + )} + + + + + + {two_column_content_block?.map((content) => ( ))} diff --git a/app/pages/OrganizationDetailPage.tsx b/app/pages/OrganizationDetailPage.tsx index 70affa07d..bb1c34c3f 100644 --- a/app/pages/OrganizationDetailPage.tsx +++ b/app/pages/OrganizationDetailPage.tsx @@ -16,7 +16,7 @@ import { ServiceCard, WebsiteRenderer, } from "../components/DetailPage"; -import PageNotFound from "components/ui/PageNotFound"; +import PageNotFound, { NotFoundType } from "components/ui/PageNotFound"; import { Loader } from "components/ui/Loader"; import { fetchOrganization, @@ -65,7 +65,7 @@ export const OrganizationDetailPage = () => { sidebarActions={[]} onClickAction={() => "noop"} > - + ); } @@ -79,7 +79,7 @@ export const OrganizationDetailPage = () => { sidebarActions={[]} onClickAction={() => "noop"} > - + ); } diff --git a/app/pages/PageNotFoundPage/PageNotFoundPage.tsx b/app/pages/PageNotFoundPage/PageNotFoundPage.tsx index 05198ea0e..ad01a6627 100644 --- a/app/pages/PageNotFoundPage/PageNotFoundPage.tsx +++ b/app/pages/PageNotFoundPage/PageNotFoundPage.tsx @@ -1,12 +1,12 @@ import React from "react"; -import PageNotFound from "components/ui/PageNotFound"; +import PageNotFound, { NotFoundType } from "components/ui/PageNotFound"; import styles from "./PageNotFoundPage.module.scss"; export const PageNotFoundPage = () => { return (
- +
); diff --git a/app/pages/ServiceDetailPage/ServiceDetailPage.tsx b/app/pages/ServiceDetailPage/ServiceDetailPage.tsx index bb725b26b..accc79c57 100644 --- a/app/pages/ServiceDetailPage/ServiceDetailPage.tsx +++ b/app/pages/ServiceDetailPage/ServiceDetailPage.tsx @@ -27,7 +27,7 @@ import { import styles from "./ServiceDetailPage.module.scss"; import { searchClient } from "@algolia/client-search"; import config from "../../config"; -import PageNotFound from "components/ui/PageNotFound"; +import PageNotFound, { NotFoundType } from "components/ui/PageNotFound"; const client = searchClient( config.ALGOLIA_APPLICATION_ID, @@ -147,7 +147,7 @@ export const ServiceDetailPage = () => { sidebarActions={[]} onClickAction={() => "noop"} > - + ); } @@ -156,7 +156,16 @@ export const ServiceDetailPage = () => { return ; } if (service.status === "inactive") { - return ; + return ( + "noop"} + > + + + ); } const { resource, recurringSchedule } = service; diff --git a/package-lock.json b/package-lock.json index 9033dcf1f..e063a3b14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "react-cookie": "^7.2.1", "react-dom": "^18.3.1", "react-ga4": "^2.1.0", + "react-gtm-module": "^2.0.11", "react-helmet-async": "^1.3.0", "react-instantsearch": "^7.13.0", "react-instantsearch-core": "^7.13.0", @@ -71,6 +72,7 @@ "@types/react-big-calendar": "^1.16.2", "@types/react-burger-menu": "^2.8.7", "@types/react-dom": "^18.3.0", + "@types/react-gtm-module": "^2.0.4", "@types/react-instantsearch": "^6.10.4", "@types/react-instantsearch-core": "^6.26.10", "@types/react-modal": "^3.12.0", @@ -5153,6 +5155,13 @@ "@types/react": "*" } }, + "node_modules/@types/react-gtm-module": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/react-gtm-module/-/react-gtm-module-2.0.4.tgz", + "integrity": "sha512-5wPMWsUE5AI6O0B0K1/zbs0rFHBKu+7NWXQwDXhqvA12ooLD6W1AYiWZqR4UiOd7ixZDV1H5Ys301zEsqyIfNg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react-instantsearch": { "version": "6.10.4", "resolved": "https://registry.npmjs.org/@types/react-instantsearch/-/react-instantsearch-6.10.4.tgz", @@ -20564,6 +20573,12 @@ "integrity": "sha512-ZKS7PGNFqqMd3PJ6+C2Jtz/o1iU9ggiy8Y8nUeksgVuvNISbmrQtJiZNvC/TjDsqD0QlU5Wkgs7i+w9+OjHhhQ==", "license": "MIT" }, + "node_modules/react-gtm-module": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/react-gtm-module/-/react-gtm-module-2.0.11.tgz", + "integrity": "sha512-8gyj4TTxeP7eEyc2QKawEuQoAZdjKvMY4pgWfycGmqGByhs17fR+zEBs0JUDq4US/l+vbTl+6zvUIx27iDo/Vw==", + "license": "MIT" + }, "node_modules/react-helmet-async": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-1.3.0.tgz", diff --git a/package.json b/package.json index 0e8fdea5b..701766d9a 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "react-cookie": "^7.2.1", "react-dom": "^18.3.1", "react-ga4": "^2.1.0", + "react-gtm-module": "^2.0.11", "react-helmet-async": "^1.3.0", "react-instantsearch": "^7.13.0", "react-instantsearch-core": "^7.13.0", @@ -81,6 +82,7 @@ "@types/react-big-calendar": "^1.16.2", "@types/react-burger-menu": "^2.8.7", "@types/react-dom": "^18.3.0", + "@types/react-gtm-module": "^2.0.4", "@types/react-instantsearch": "^6.10.4", "@types/react-instantsearch-core": "^6.26.10", "@types/react-modal": "^3.12.0",