diff --git a/app/components/ui/Calendar/EventCalendar.module.scss b/app/components/ui/Calendar/EventCalendar.module.scss index 2c4253e46..fdf25c83d 100644 --- a/app/components/ui/Calendar/EventCalendar.module.scss +++ b/app/components/ui/Calendar/EventCalendar.module.scss @@ -400,34 +400,40 @@ align-items: center; justify-content: space-between; padding: 1rem 0.5rem; - margin-bottom: 1rem; + margin-bottom: 0; border-bottom: 1px solid #dee2e6; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); - - @media (min-width: 769px) { - display: none; - } + background: white; + position: relative; + flex-shrink: 0; } .dateNavButton { background: white; - border: 2px solid #007bff; + border: 2px solid #cd4a23; border-radius: 50%; - width: 36px; - height: 36px; + width: 40px; + height: 40px; display: flex; align-items: center; justify-content: center; font-size: 1.5rem; font-weight: 700; - color: #007bff; + color: #cd4a23; cursor: pointer; transition: all 0.2s ease; user-select: none; box-shadow: 0 2px 4px rgba(0, 123, 255, 0.1); + pointer-events: auto; + position: relative; + + @media (max-width: 768px) { + width: 36px; + height: 36px; + } &:hover { - background: #007bff; + background: #cd4a23; color: white; transform: scale(1.05); box-shadow: 0 4px 8px rgba(0, 123, 255, 0.2); @@ -447,17 +453,33 @@ } .dateHeaderTitle { - font-size: 1rem; - font-weight: 600; - color: #2c3e50; - margin: 0; - text-align: center; - flex: 1; - letter-spacing: 0.5px; display: flex; - align-items: center; - justify-content: center; - line-height: 1; + flex-direction: column; + + button { + color: #1a56db; + &:hover { + text-decoration: underline; + } + } + + h2 { + font-size: 1.1rem; + font-weight: 600; + color: #2c3e50; + margin: 0; + text-align: center; + flex: 1; + letter-spacing: 0.5px; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + + @media (max-width: 768px) { + font-size: 1rem; + } + } } // Mobile Agenda Styles @@ -467,6 +489,63 @@ overflow: hidden; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05); border: 1px solid #e9ecef; + display: flex; + flex-direction: column; + max-height: 600px; + + // On larger screens, allow more height + @media (min-width: 1200px) { + max-height: 700px; + } + + // On mobile, adjust height for smaller screens + @media (max-width: 768px) { + max-height: 500px; + } +} + +.agendaContent { + overflow-y: auto; + overflow-x: hidden; + flex: 1; + + // Custom scrollbar styling + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 4px; + } + + &::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 4px; + + &:hover { + background: #a8a8a8; + } + } +} + +.agendaDateDisplay { + padding: 1.25rem 1.5rem; + background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); + border-bottom: 2px solid #dee2e6; + font-size: 1.1rem; + font-weight: 700; + color: #2c3e50; + text-align: center; + letter-spacing: 0.5px; + position: sticky; + top: 0; + z-index: 10; + + @media (max-width: 768px) { + padding: 1rem 1.25rem; + font-size: 1rem; + } } .noEventsMessage { @@ -480,6 +559,7 @@ display: flex; border-bottom: 1px solid #f0f0f0; min-height: 60px; + align-items: stretch; &:last-child { border-bottom: none; @@ -487,7 +567,7 @@ } .agendaTimeColumn { - width: 33.333%; + width: 20%; padding: 16px 12px; background: #f8f9fa; border-right: 2px solid #e9ecef; @@ -495,6 +575,11 @@ align-items: flex-start; justify-content: center; position: relative; + + // On mobile and smaller tablets, use wider time column + @media (max-width: 768px) { + width: 30%; + } } .agendaTime { @@ -505,24 +590,37 @@ } .agendaEventsColumn { - width: 66.667%; + width: 80%; padding: 12px 16px; display: flex; flex-direction: column; gap: 8px; + + // On mobile, use slightly different widths + @media (max-width: 768px) { + width: 70%; + } } .agendaEventChip { background: white; - border: none; - border-left: 4px solid var(--category-color, #007bff); border-radius: 8px; - padding: 12px 16px; + padding: 18px 24px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); transition: all 0.2s ease; cursor: pointer; text-align: left; width: 100%; + display: flex; + align-items: center; + gap: 20px; + flex-wrap: wrap; + + // On mobile, use slightly less padding + @media (max-width: 768px) { + padding: 14px 18px; + gap: 12px; + } &:hover { box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); @@ -543,11 +641,7 @@ font-size: 0.75rem; font-weight: 600; color: #000000; - margin-bottom: 6px; -} - -.agendaChipContent { - width: 100%; + flex-shrink: 0; } .agendaChipTitle { @@ -555,17 +649,29 @@ font-weight: 600; color: #2c3e50; line-height: 1.4; - margin: 0 0 4px 0; + margin: 0; + flex: 1; + min-width: 200px; + + // On mobile, allow smaller min-width + @media (max-width: 768px) { + min-width: 150px; + } } .agendaChipLocation { font-size: 0.85rem; color: #6c757d; - margin-top: 4px; display: flex; align-items: center; gap: 4px; - font-style: italic; + white-space: nowrap; + flex-shrink: 0; + + // On mobile, allow wrapping + @media (max-width: 768px) { + white-space: normal; + } } // Responsive adjustments for mobile agenda @@ -628,8 +734,9 @@ gap: 0.5rem; padding: 0.75rem 1.25rem; border-radius: 25px; - border: 2px solid transparent; + border: 2px solid #000000; background: white; + color: #000000; cursor: pointer; transition: all 0.2s ease; font-weight: 600; @@ -637,35 +744,26 @@ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); position: relative; overflow: hidden; + transition: all 0.2s ease; &.enabled { - background: var(--category-color); - color: #2c3e50; - border-color: var(--category-dark-color); + background: #000000; + color: #ffffff; + border-color: #000000; border-radius: 25px; transform: translateY(-1px); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); - - .chipIndicator { - background: #2c3e50; - box-shadow: 0 0 0 2px var(--category-dark-color); - } } &.disabled { - background: #f8f9fa; - color: #6c757d; - border-color: #dee2e6; + background: #ffffff; + color: #000000; + border-color: #000000; border-radius: 25px; - .chipIndicator { - background: var(--category-color); - opacity: 0.4; - } - &:hover { - background: #e9ecef; - color: #495057; + background: #f8f9fa; + color: #000000; } } @@ -856,7 +954,7 @@ .primaryButton { flex: 1; padding: 0.75rem 1.5rem; - background: #007bff; + background: #cd4a23; color: white; border: none; border-radius: 6px; @@ -865,9 +963,9 @@ transition: all 0.2s ease; &:hover { - background: #0056b3; + background: #a63b1b; transform: translateY(-1px); - box-shadow: 0 4px 8px rgba(0, 123, 255, 0.3); + box-shadow: 0 4px 8px rgba(205, 74, 35, 0.3); } &:active { diff --git a/app/components/ui/Calendar/EventCalendar.tsx b/app/components/ui/Calendar/EventCalendar.tsx index 525487151..942d478ef 100644 --- a/app/components/ui/Calendar/EventCalendar.tsx +++ b/app/components/ui/Calendar/EventCalendar.tsx @@ -1,25 +1,13 @@ import React, { useMemo, useState } from "react"; -import { Calendar, momentLocalizer, Views } from "react-big-calendar"; -import moment from "moment"; 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 { formatMobileDateHeader } from "./utils"; import styles from "./EventCalendar.module.scss"; -import "react-big-calendar/lib/css/react-big-calendar.css"; - -// Setup the localizer -const localizer = momentLocalizer(moment); export const EventCalendar: React.FC = ({ onEventSelect, @@ -41,7 +29,6 @@ export const EventCalendar: React.FC = ({ availableCategories, categoryFilters, enabledCategories, - categoryColorMap, toggleCategory, } = useEventProcessing(events); @@ -70,34 +57,12 @@ export const EventCalendar: React.FC = ({ }); }; - // Responsive views and default view for mobile - const calendarViews = useMemo(() => { - if (typeof window !== "undefined" && window.innerWidth <= 768) { - return [Views.AGENDA]; - } - return [Views.MONTH]; - }, []); - - const defaultView = useMemo(() => { - if (typeof window !== "undefined" && window.innerWidth <= 768) { - return Views.AGENDA; - } - return Views.MONTH; - }, []); - - // Responsive calendar height - const calendarHeight = useMemo(() => { - if (typeof window !== "undefined") { - return window.innerWidth <= 768 ? 600 : 800; - } - return 800; - }, []); + const goToToday = () => { + setCurrentMobileDate(new Date()); + }; - // Group events by start time for mobile agenda view + // Group events by start time for agenda view (used for both mobile and desktop) const groupedEventsByTime = useMemo(() => { - const isMobile = typeof window !== "undefined" && window.innerWidth <= 768; - if (!isMobile) return {}; - const groups: { [timeKey: string]: CalendarEvent[] } = {}; calendarEvents.forEach((event) => { @@ -150,14 +115,6 @@ export const EventCalendar: React.FC = ({ } }; - const handleShowMore = (events: CalendarEvent[], date: Date) => { - // Open slideout with multiple events for the day - setDayEvents(events); - setSelectedDate(date); - setSelectedEvent(null); - setSlideoutOpen(true); - }; - const closeSlideout = () => { setSlideoutOpen(false); setSelectedEvent(null); @@ -172,41 +129,6 @@ export const EventCalendar: React.FC = ({ setSelectedDate(null); }; - 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]; - } - - const darkerColor = getDarkerColor(categoryColor); - - return { - style: { - backgroundColor: categoryColor, - borderRadius: "6px", - opacity: 0.9, - color: "#000000", - border: `2px solid ${darkerColor}`, - display: "block", - cursor: "pointer", - fontWeight: "500", - }, - }; - }; - // Loading states if (!events || eventsAreLoading) { return ; @@ -221,8 +143,6 @@ export const EventCalendar: React.FC = ({ return ; } - const isMobile = typeof window !== "undefined" && window.innerWidth <= 768; - return (
= ({ onToggleCategory={toggleCategory} /> - {isMobile ? ( - - ) : ( - - `${event.title} - ${event.originalEvent.category}` - } - /> - )} + {/* Event Details Slideout */} = ({ selectedEvent={selectedEvent} dayEvents={dayEvents} selectedDate={selectedDate} - categoryColorMap={categoryColorMap} onEventSelect={handleSlideoutEventSelect} />
diff --git a/app/components/ui/Calendar/components/CategoryFilters.tsx b/app/components/ui/Calendar/components/CategoryFilters.tsx index 864aa290c..8460b27be 100644 --- a/app/components/ui/Calendar/components/CategoryFilters.tsx +++ b/app/components/ui/Calendar/components/CategoryFilters.tsx @@ -1,6 +1,5 @@ import React from "react"; import { CategoryFiltersProps } from "../types"; -import { getDarkerColor } from "../utils"; import styles from "../EventCalendar.module.scss"; export const CategoryFilters: React.FC = ({ @@ -16,22 +15,16 @@ export const CategoryFilters: React.FC = ({

Filter by Category

- All categories are selected by default. Click a category to deselect it. + Select a category to view only those events.

- {categoryFilters.map(({ category, enabled, color }) => ( + {categoryFilters.map(({ category, enabled }) => ( diff --git a/app/components/ui/Calendar/components/CustomToolbar.tsx b/app/components/ui/Calendar/components/CustomToolbar.tsx deleted file mode 100644 index bc96c1b8f..000000000 --- a/app/components/ui/Calendar/components/CustomToolbar.tsx +++ /dev/null @@ -1,45 +0,0 @@ -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 index beeead6ad..88fce1d54 100644 --- a/app/components/ui/Calendar/components/EventSlideout.tsx +++ b/app/components/ui/Calendar/components/EventSlideout.tsx @@ -10,7 +10,6 @@ export const EventSlideout: React.FC = ({ selectedEvent, dayEvents, selectedDate, - categoryColorMap, onEventSelect, }) => { const closeButtonRef = useRef(null); @@ -62,11 +61,17 @@ export const EventSlideout: React.FC = ({

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

@@ -197,12 +200,15 @@ export const EventSlideout: React.FC = ({
+

-

{event.title}

@@ -227,13 +233,11 @@ export const EventSlideout: React.FC = ({ - {event.originalEvent.category} + {event.originalEvent.events_category}
diff --git a/app/components/ui/Calendar/components/MobileAgenda.tsx b/app/components/ui/Calendar/components/MobileAgenda.tsx index ade1d086a..f39d2c8bc 100644 --- a/app/components/ui/Calendar/components/MobileAgenda.tsx +++ b/app/components/ui/Calendar/components/MobileAgenda.tsx @@ -1,7 +1,7 @@ import React from "react"; import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/16/solid"; import { MobileAgendaProps, CalendarEvent } from "../types"; -import { CATEGORY_COLORS } from "../constants"; +import { sanitizeHtml } from "../utils"; import styles from "../EventCalendar.module.scss"; export const MobileAgenda: React.FC = ({ @@ -9,6 +9,7 @@ export const MobileAgenda: React.FC = ({ groupedEvents, onNavigatePrevious, onNavigateNext, + onGoToToday, onEventSelect, formatDateHeader, }) => { @@ -17,47 +18,64 @@ export const MobileAgenda: React.FC = ({ {/* Mobile Navigation Header */}
-

- {formatDateHeader(currentDate)} -

+
+

{formatDateHeader(currentDate)}

+ {new Date().toDateString() !== currentDate.toDateString() && ( + + )} +
- {/* Events Content */} - {Object.keys(groupedEvents).length === 0 ? ( -
-

No events scheduled for this day.

-
- ) : ( - Object.entries(groupedEvents).map(([timeKey, events]) => ( -
-
- {timeKey} -
-
- {events.map((event) => ( - - ))} -
+ {/* Scrollable Events Content */} +
+ {Object.keys(groupedEvents).length === 0 ? ( +
+

No events scheduled for this day.

- )) - )} + ) : ( + Object.entries(groupedEvents).map(([timeKey, events]) => ( +
+
+ {timeKey} +
+
+ {events.map((event) => ( + + ))} +
+
+ )) + )} +
); }; @@ -71,32 +89,21 @@ 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/hooks.ts b/app/components/ui/Calendar/hooks.ts index f119954aa..b3f6655a3 100644 --- a/app/components/ui/Calendar/hooks.ts +++ b/app/components/ui/Calendar/hooks.ts @@ -3,8 +3,6 @@ import { SFGovEvent } from "hooks/SFGovAPI"; import { CalendarEvent, CategoryFilter } from "./types"; import { extractUniqueCategories, - createCategoryColorMap, - getCategoryColor, shouldEventOccurOnDay, ensureHttpsProtocol, } from "./utils"; @@ -21,16 +19,39 @@ export const useEventProcessing = (events: SFGovEvent[] | null) => { // Use useLayoutEffect to set category filters before paint to prevent flashing useLayoutEffect(() => { - if (availableCategories.length > 0) { + if (availableCategories.length > 0 && events) { + // Count events per category + const categoryCounts = new Map(); + + events.forEach((event) => { + if (event.events_category) { + categoryCounts.set( + event.events_category, + (categoryCounts.get(event.events_category) || 0) + 1 + ); + } + }); + + // Find category with most events + let maxCount = 0; + let categoryWithMostEvents = availableCategories[0]; + + availableCategories.forEach((category) => { + const count = categoryCounts.get(category) || 0; + if (count > maxCount) { + maxCount = count; + categoryWithMostEvents = category; + } + }); + setCategoryFilters( - availableCategories.map((category, index) => ({ + availableCategories.map((category) => ({ category, - enabled: true, - color: getCategoryColor(index), + enabled: category === categoryWithMostEvents, // Only category with most events is enabled by default })) ); } - }, [availableCategories]); + }, [availableCategories, events]); // Update category filters when available categories change useEffect(() => { @@ -38,13 +59,12 @@ export const useEventProcessing = (events: SFGovEvent[] | null) => { const existingCategories = new Set(prev.map((f) => f.category)); const newFilters = [...prev]; - // Add new categories that weren't present before - availableCategories.forEach((category, index) => { + // Add new categories that weren't present before (default to disabled) + availableCategories.forEach((category) => { if (!existingCategories.has(category)) { newFilters.push({ category, - enabled: true, - color: getCategoryColor(availableCategories.indexOf(category)), + enabled: false, // New categories default to disabled to maintain single-select }); } }); @@ -63,29 +83,13 @@ export const useEventProcessing = (events: SFGovEvent[] | null) => { [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 + // Toggle category filter - only one can be selected at a time (radio button behavior) const toggleCategory = (category: string) => { setCategoryFilters((prev) => - prev.map((filter) => - filter.category === category - ? { ...filter, enabled: !filter.enabled } - : filter - ) + prev.map((filter) => ({ + ...filter, + enabled: filter.category === category, + })) ); }; @@ -93,7 +97,6 @@ export const useEventProcessing = (events: SFGovEvent[] | null) => { availableCategories, categoryFilters, enabledCategories, - categoryColorMap, toggleCategory, }; }; @@ -117,7 +120,7 @@ export const useEventTransformation = ( // Filter by category if category filters are active if ( categoryFilters.length > 0 && - !enabledCategories.has(event.category) + !enabledCategories.has(event.events_category) ) { return false; } @@ -230,16 +233,11 @@ export const useEventTransformation = ( } }); - // 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; + // Filter events to show only the current selected day (for agenda view) + const currentDateStr = currentMobileDate.toDateString(); + return transformedEvents.filter((event) => { + return event.start && event.start.toDateString() === currentDateStr; + }); }, [events, categoryFilters.length, enabledCategories, currentMobileDate]); return calendarEvents; diff --git a/app/components/ui/Calendar/index.ts b/app/components/ui/Calendar/index.ts index 8aadd2bab..dbc365eb9 100644 --- a/app/components/ui/Calendar/index.ts +++ b/app/components/ui/Calendar/index.ts @@ -5,7 +5,6 @@ export { EventCalendar } from "./EventCalendar"; export { CategoryFilters } from "./components/CategoryFilters"; export { MobileAgenda } from "./components/MobileAgenda"; export { EventSlideout } from "./components/EventSlideout"; -export { CustomToolbar } from "./components/CustomToolbar"; // Types export type { diff --git a/app/components/ui/Calendar/types.ts b/app/components/ui/Calendar/types.ts index 714faa385..ea7b1ba1c 100644 --- a/app/components/ui/Calendar/types.ts +++ b/app/components/ui/Calendar/types.ts @@ -1,7 +1,15 @@ -import { Event } from "react-big-calendar"; import { SFGovEvent } from "hooks/SFGovAPI"; -export interface CalendarEvent extends Event { +// Base event interface (replacing react-big-calendar's Event type) +export interface BaseEvent { + title?: string; + start?: Date; + end?: Date; + allDay?: boolean; + resource?: unknown; +} + +export interface CalendarEvent extends BaseEvent { id: string; pageLink: string; description: string; @@ -16,7 +24,6 @@ export interface EventCalendarProps { export interface CategoryFilter { category: string; enabled: boolean; - color: string; } export interface CategoryFiltersProps { @@ -30,6 +37,7 @@ export interface MobileAgendaProps { groupedEvents: { [timeKey: string]: CalendarEvent[] }; onNavigatePrevious: () => void; onNavigateNext: () => void; + onGoToToday: () => void; onEventSelect: (event: CalendarEvent) => void; formatDateHeader: (date: Date) => string; } @@ -40,6 +48,5 @@ export interface EventSlideoutProps { 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 index f85c1a872..d1c9f80b3 100644 --- a/app/components/ui/Calendar/utils.ts +++ b/app/components/ui/Calendar/utils.ts @@ -42,7 +42,7 @@ export const createCategoryColorMap = ( if (!events) return colorMap; const uniqueCategories = Array.from( - new Set(events.map((event) => event.category).filter(Boolean)) + new Set(events.map((event) => event.events_category).filter(Boolean)) ).sort(); uniqueCategories.forEach((category, index) => { @@ -113,7 +113,7 @@ export const extractUniqueCategories = ( if (!events) return []; const uniqueCategories = Array.from( - new Set(events.map((event) => event.category).filter(Boolean)) + new Set(events.map((event) => event.events_category).filter(Boolean)) ).sort(); return uniqueCategories; diff --git a/app/components/ui/Cards/EventCardSection.module.scss b/app/components/ui/Cards/EventCardSection.module.scss index 81cf235ee..538167427 100644 --- a/app/components/ui/Cards/EventCardSection.module.scss +++ b/app/components/ui/Cards/EventCardSection.module.scss @@ -28,3 +28,26 @@ flex-direction: column; } } + +.showMoreButton { + width: 1fr; + padding: 12px 24px; + margin-top: $general-spacing-lg; + border: none; + border-radius: 0.375rem; + background-color: #111928; + color: #fff; + font-size: 16px; + font-weight: 500; + cursor: pointer; + transition: opacity 0.2s ease-in-out; + + &:hover { + opacity: 75%; + } + + &:focus { + outline: 2px solid #007acc; + outline-offset: 2px; + } +} diff --git a/app/components/ui/Cards/EventCardSection.tsx b/app/components/ui/Cards/EventCardSection.tsx index d830e7c85..dba28b701 100644 --- a/app/components/ui/Cards/EventCardSection.tsx +++ b/app/components/ui/Cards/EventCardSection.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState } from "react"; import { EventCard } from "./EventCard"; import styles from "./EventCardSection.module.scss"; import { Loader } from "../Loader"; @@ -9,19 +9,44 @@ export const EventCardSection = ({ }: { events: ReturnType; }) => { + const [showAll, setShowAll] = useState(false); + if (!events) { return ; } + const displayedEvents = showAll ? events : events.slice(0, 4); + const hasMoreEvents = events.length > 4; + return ( <> {events && ( -
- {events?.map((eventData) => ( +
+ {displayedEvents?.map((eventData) => ( ))}
)} + {hasMoreEvents && !showAll && ( + + )} + {hasMoreEvents && showAll && ( + + )} ); }; diff --git a/app/hooks/SFGovAPI.ts b/app/hooks/SFGovAPI.ts index 4e55b663f..456ab5fe1 100644 --- a/app/hooks/SFGovAPI.ts +++ b/app/hooks/SFGovAPI.ts @@ -17,8 +17,7 @@ export interface SFGovEvent { event_photo?: { url: string; }; - category: string; - subcategory: string; + events_category: string; age_group_eligibility_tags: string; site_address: string; site_phone: string; diff --git a/app/hooks/StrapiAPI.ts b/app/hooks/StrapiAPI.ts index 08b5f2288..0c5f3d130 100644 --- a/app/hooks/StrapiAPI.ts +++ b/app/hooks/StrapiAPI.ts @@ -80,7 +80,7 @@ export function useNavigationData() { /** * Fetches only featured events with embedded data for associated content types */ -export function useHomePageEventsData() { +export function useHomePageFeaturedResourcesData() { const path = "events?populate[address]=*&populate[calendar_event]=*&populate[page_link]=*&populate[image][populate]=*&filters[featured][$eq]=true"; diff --git a/app/pages/BrowseResultsPage/BrowseResultsPage.tsx b/app/pages/BrowseResultsPage/BrowseResultsPage.tsx index f3e5f1843..b29b71759 100644 --- a/app/pages/BrowseResultsPage/BrowseResultsPage.tsx +++ b/app/pages/BrowseResultsPage/BrowseResultsPage.tsx @@ -6,6 +6,7 @@ import { useAppContext, useAppContextUpdater, } from "utils"; +import { createExclusionFilters } from "utils/exclusionFilters"; import { SearchMapActions } from "components/SearchAndBrowse/SearchResults/SearchResults"; import { Loader } from "components/ui/Loader"; import Sidebar from "components/SearchAndBrowse/Sidebar/Sidebar"; @@ -113,6 +114,11 @@ export const BrowseResultsPage = () => { ? escapeApostrophes(parentCategory.name) : null; + // Apply Our415 category filtering rules + const exclusionFilters = createExclusionFilters(); + const categoryFilter = `categories:'${algoliaCategoryName}'`; + const combinedFilters = `${categoryFilter} AND ${exclusionFilters}`; + const searchMapHitData = transformSearchResults(searchResults); const hasNoResults = searchMapHitData.nbHits === 0 && status === "idle"; @@ -130,7 +136,7 @@ export const BrowseResultsPage = () => { } }; - // TS compiler requires explict null type checks + // TS compiler requires explicit null type checks if ( eligibilities === null || subcategories === null || @@ -151,7 +157,7 @@ export const BrowseResultsPage = () => { {/* Only render the Configure component (which triggers the search) when the map is initialized */} {isMapInitialized && ( { - - - rowRenderer={(detail) => ( - - {detail.title} - {detail.value} - - )} - rows={detailsRows} - /> - + {detailsRows.length > 0 && ( + + + rowRenderer={(detail) => ( + + {detail.title} + {detail.value} + + )} + rows={detailsRows} + /> + + )} {data.registration_link?.url && ( ["data"]; + data: ReturnType["data"]; isLoading: boolean; } = { data: [], @@ -34,7 +34,7 @@ const SF_GOV_EVENTS_MOCK = { jest.mock("hooks/StrapiAPI", () => ({ useHomepageData: () => HOME_PAGE_MOCK, - useHomePageEventsData: () => EVENTS_MOCK, + useHomePageFeaturedResourcesData: () => EVENTS_MOCK, })); jest.mock("hooks/SFGovAPI", () => ({ diff --git a/app/pages/HomePage/HomePage.tsx b/app/pages/HomePage/HomePage.tsx index f2d8780c1..fdf96ab5a 100644 --- a/app/pages/HomePage/HomePage.tsx +++ b/app/pages/HomePage/HomePage.tsx @@ -1,7 +1,10 @@ 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 { + useHomepageData, + useHomePageFeaturedResourcesData, +} from "hooks/StrapiAPI"; import { Homepage, StrapiDatum } from "models/Strapi"; import { EventCalendar } from "components/ui/Calendar"; import { HomePageSection } from "pages/HomePage/components/Section"; @@ -13,8 +16,10 @@ export const HomePage = () => { const { data: homepageData, isLoading: homepageDataIsLoading } = useHomepageData(); - const { data: featuredEventsData, isLoading: featuredEventsAreLoading } = - useHomePageEventsData(); + const { + data: featuredResourcesData, + isLoading: featuredResourcesAreLoading, + } = useHomePageFeaturedResourcesData(); const homepageDataRes = homepageData as StrapiDatum; @@ -43,29 +48,30 @@ export const HomePage = () => { - {!featuredEventsAreLoading && ( - + {!featuredResourcesAreLoading && ( + - + )} - - - - - - + + + + + + + {two_column_content_block?.map((content) => ( diff --git a/app/pages/SearchResultsPage/SearchResultsPage.tsx b/app/pages/SearchResultsPage/SearchResultsPage.tsx index 3baec8509..afce70fd3 100644 --- a/app/pages/SearchResultsPage/SearchResultsPage.tsx +++ b/app/pages/SearchResultsPage/SearchResultsPage.tsx @@ -17,6 +17,7 @@ import { Loader } from "components/ui/Loader"; import ResultsPagination from "components/SearchAndBrowse/Pagination/ResultsPagination"; import { NoSearchResultsDisplay } from "components/ui/NoSearchResultsDisplay"; import { SearchResultsHeader } from "components/ui/SearchResultsHeader"; +import { createExclusionFilters } from "utils/exclusionFilters"; export const HITS_PER_PAGE = 40; @@ -35,19 +36,8 @@ export const SearchResultsPage = () => { indexUiState: { query = null }, } = useInstantSearch(); - // const excludedEligibilities: string[] = ["I am a Senior"]; - - // // Create a filter to exclude resources that have ONLY "I am a Senior" as their eligibility - // // This uses a combination of filters: NOT (has ONLY this eligibility) - // const excludedEligibilitiesFilter = - // excludedEligibilities.length > 0 - // ? excludedEligibilities - // .map( - // (eligibility) => - // `NOT (eligibilities:"${eligibility}" AND eligibilities_count:1)` - // ) - // .join(" AND ") - // : ""; + // Apply Our415 category filtering rules + const exclusionFilters = createExclusionFilters(); useEffect(() => window.scrollTo(0, 0), []); @@ -90,12 +80,14 @@ export const SearchResultsPage = () => { // Convert bounding box string to array of numbers that Algolia expects insideBoundingBox: [boundingBox.split(",").map(Number)], hitsPerPage: HITS_PER_PAGE, + filters: exclusionFilters, } : { aroundLatLng: aroundLatLng, aroundRadius: aroundUserLocationRadius, aroundPrecision: DEFAULT_AROUND_PRECISION, minimumAroundRadius: 100, // Prevent the radius from being too small (100m minimum) + filters: exclusionFilters, })} /> )} diff --git a/app/utils/exclusionFilters.ts b/app/utils/exclusionFilters.ts new file mode 100644 index 000000000..615c7ea23 --- /dev/null +++ b/app/utils/exclusionFilters.ts @@ -0,0 +1,52 @@ +/** + * Creates Algolia filter strings to exclude resources based on Our415 business rules. + * Resources are filtered out if they contain ONLY certain restricted eligibility categories. + */ + +export const createExclusionFilters = (): string => { + const filters = []; + + // Rule 1: ONLY elderly, maturing adult, senior, and/or "I am a senior" in age category + const ageOnlyFilters = [ + 'NOT (eligibilities:"elderly" AND eligibilities_count:1)', + 'NOT (eligibilities:"maturing adult" AND eligibilities_count:1)', + 'NOT (eligibilities:"senior" AND eligibilities_count:1)', + 'NOT (eligibilities:"I am a Senior" AND eligibilities_count:1)', + // Combinations of these age categories only + 'NOT (eligibilities:"elderly" AND eligibilities:"senior" AND eligibilities_count:2)', + 'NOT (eligibilities:"elderly" AND eligibilities:"I am a Senior" AND eligibilities_count:2)', + 'NOT (eligibilities:"senior" AND eligibilities:"I am a Senior" AND eligibilities_count:2)', + 'NOT (eligibilities:"maturing adult" AND eligibilities:"senior" AND eligibilities_count:2)', + 'NOT (eligibilities:"maturing adult" AND eligibilities:"elderly" AND eligibilities_count:2)', + 'NOT (eligibilities:"maturing adult" AND eligibilities:"I am a Senior" AND eligibilities_count:2)', + // Three combinations + 'NOT (eligibilities:"elderly" AND eligibilities:"senior" AND eligibilities:"I am a Senior" AND eligibilities_count:3)', + 'NOT (eligibilities:"elderly" AND eligibilities:"maturing adult" AND eligibilities:"senior" AND eligibilities_count:3)', + 'NOT (eligibilities:"elderly" AND eligibilities:"maturing adult" AND eligibilities:"I am a Senior" AND eligibilities_count:3)', + 'NOT (eligibilities:"maturing adult" AND eligibilities:"senior" AND eligibilities:"I am a Senior" AND eligibilities_count:3)', + // All four + 'NOT (eligibilities:"elderly" AND eligibilities:"maturing adult" AND eligibilities:"senior" AND eligibilities:"I am a Senior" AND eligibilities_count:4)', + ]; + + // Rule 2: ONLY retired in employment status category + filters.push('NOT (eligibilities:"retired" AND eligibilities_count:1)'); + + // Rule 3: ONLY married with no children in family status category + filters.push( + 'NOT (eligibilities:"married with no children" AND eligibilities_count:1)' + ); + + // Rule 4: ONLY men in gender category AND no TAY (18-24) in age category + filters.push( + 'NOT (eligibilities:"men" AND eligibilities_count:1 AND NOT eligibilities:"TAY (18-24)")' + ); + + // Rule 5: ONLY Alzheimers and/or people who use drugs in health concerns category + const healthOnlyFilters = [ + 'NOT (eligibilities:"Alzheimers" AND eligibilities_count:1)', + 'NOT (eligibilities:"people who use drugs" AND eligibilities_count:1)', + 'NOT (eligibilities:"Alzheimers" AND eligibilities:"people who use drugs" AND eligibilities_count:2)', + ]; + + return [...filters, ...ageOnlyFilters, ...healthOnlyFilters].join(" AND "); +}; diff --git a/package.json b/package.json index 701766d9a..c0970277b 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,6 @@ "prop-types": "^15.5.10", "qs": "^6.5.1", "react": "^18.3.1", - "react-big-calendar": "^1.19.4", "react-burger-menu": "^3.0.9", "react-cookie": "^7.2.1", "react-dom": "^18.3.1",