From 9b09c4a2a1ff406a40e7d7c29ca0a28a127e6ab8 Mon Sep 17 00:00:00 2001 From: Caden Cheng Date: Wed, 5 Aug 2026 16:37:50 -0700 Subject: [PATCH] redesign events page and sync with cses gcal --- README.md | 34 ++ backend/.env.example | 27 ++ backend/controllers/calendarController.js | 126 +++++ backend/database/connect-db.js | 4 + backend/index.js | 2 + backend/mailchimp/connect-mailchimp.js | 12 +- backend/package-lock.json | 3 + backend/package.json | 2 +- backend/routes/calendar.js | 13 + frontend/src/api/index.js | 12 + .../src/components/NewEvents/EventCard.tsx | 132 +++-- frontend/src/components/NewEvents/Events.tsx | 450 +++--------------- frontend/src/components/NewEvents/styles.ts | 105 ++++ 13 files changed, 454 insertions(+), 468 deletions(-) create mode 100644 backend/.env.example create mode 100644 backend/controllers/calendarController.js create mode 100644 backend/routes/calendar.js create mode 100644 frontend/src/components/NewEvents/styles.ts diff --git a/README.md b/README.md index f268f60e..4b5255f5 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,40 @@ 9. Run `npm install` to install all the node packages. 10. Run `npm start` to run the React App and check if you can see the rendered site at http://localhost:3000/ +## Google Calendar sync (Events page) + +The Events page displays upcoming events from the CSES Google Calendar (`cses@ucsd.edu`) via +`GET /api/v1/calendar/events`. Backend setup (see `backend/.env.example`): + +1. Requires Node 18+ (the backend uses the built-in `fetch`). +2. In [Google Cloud Console](https://console.cloud.google.com/), create/select a project, enable the + **Google Calendar API**, and create an **API key**. Restrict the key to the Calendar API. +3. In each calendar's settings (as `cses@ucsd.edu`), enable **"Make available to public"** under + Access permissions and set the dropdown to **"See all event details"** (free/busy mode strips + titles and locations). The API returns 404 for private calendars even with a valid key. +4. Add to `backend/.env`: + - `GOOGLE_CALENDAR_API_KEY=` + - One calendar ID per community tab: `GOOGLE_CALENDAR_ID_GENERAL`, `GOOGLE_CALENDAR_ID_OPEN_SOURCE`, + `GOOGLE_CALENDAR_ID_INNOVATE`, `GOOGLE_CALENDAR_ID_DEV`. Events are categorized by which + calendar they're on. Each ID is under that calendar's Settings > "Integrate calendar". + - If none of those are set, `GOOGLE_CALENDAR_ID` (default `cses@ucsd.edu`) is used as a single + calendar whose events all show under the General tab. + +When `GOOGLE_CALENDAR_API_KEY` is unset the endpoint logs a warning and returns `[]`, and the +Events page shows its empty state. Responses are cached in memory for 5 minutes. + +### Labelling an event's type + +Each event card shows a small label under the title ("Social", "Career", "Workshop", ...). Set it +on the Google Calendar event in either of these ways: + +- Add a `Type: Social` line anywhere in the event's **description**, or +- Prefix the event **title** with the type in square brackets: `[Social] Welcome Week Social`. + +Either way the tag is stripped before display, so the card shows a clean title and description. The +label is free-form — any word works, no code change needed. Untagged events fall back to showing +their community (General / Open-Source / Innovate / Dev). + ## Development - Prior to any local development, you should pull the latest code from `main` and work on your separate branch. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 00000000..608c21a6 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,27 @@ +# MongoDB connection string +CONNECTION_URL= + +# Mailchimp API key +MAILCHIMP_API_KEY= + +# Server port +PORT=5000 + +# Google Calendar sync (Events page). +# Create an API key in Google Cloud Console (enable the Google Calendar API and +# restrict the key to it). Each calendar must be public ("Make available to +# public" + "See all event details" in its sharing settings). +# When the key is unset, /api/v1/calendar/events returns an empty list. +GOOGLE_CALENDAR_API_KEY= + +# One calendar per community tab; events are categorized by which calendar +# they're on. Find each ID under the calendar's Settings > "Integrate calendar" +# (secondary calendars look like c_xxxx@group.calendar.google.com). +GOOGLE_CALENDAR_ID_GENERAL= +GOOGLE_CALENDAR_ID_OPEN_SOURCE= +GOOGLE_CALENDAR_ID_INNOVATE= +GOOGLE_CALENDAR_ID_DEV= + +# Fallback: used only when none of the per-community IDs above are set; +# all its events show under the General tab. +GOOGLE_CALENDAR_ID=cses@ucsd.edu diff --git a/backend/controllers/calendarController.js b/backend/controllers/calendarController.js new file mode 100644 index 00000000..e834bd7b --- /dev/null +++ b/backend/controllers/calendarController.js @@ -0,0 +1,126 @@ +import asyncHandler from 'express-async-handler'; + +// One Google Calendar per community; each event's category comes from the +// calendar it lives on. Calendars with no configured ID are skipped. +const CALENDAR_CATEGORIES = [ + { category: 'General', envKey: 'GOOGLE_CALENDAR_ID_GENERAL' }, + { category: 'Open-Source', envKey: 'GOOGLE_CALENDAR_ID_OPEN_SOURCE' }, + { category: 'Innovate', envKey: 'GOOGLE_CALENDAR_ID_INNOVATE' }, + { category: 'Dev', envKey: 'GOOGLE_CALENDAR_ID_DEV' }, +]; + +// In-memory cache so we don't burn Google API quota on every page load. +const CACHE_TTL_MS = 5 * 60 * 1000; +let cache = { data: null, fetchedAt: 0 }; + +// Organizers tag an event's type ("Social", "Career", ...) either with a +// "Type: X" line anywhere in the description or with a "[X]" prefix on the +// title. Both are stripped from what we display. +const TYPE_IN_DESCRIPTION = /^[ \t]*type[ \t]*:[ \t]*(.+?)[ \t]*$/im; +const TYPE_IN_TITLE = /^\s*\[([^\]]+)\]\s*/; + +const extractType = (summary, description) => { + const fromDescription = description.match(TYPE_IN_DESCRIPTION); + if (fromDescription) { + return { + type: fromDescription[1], + title: summary, + description: description.replace(TYPE_IN_DESCRIPTION, '').trim(), + }; + } + + const fromTitle = summary.match(TYPE_IN_TITLE); + if (fromTitle) { + return { + type: fromTitle[1].trim(), + title: summary.replace(TYPE_IN_TITLE, '').trim(), + description, + }; + } + + return { type: '', title: summary, description }; +}; + +const fetchCalendar = async (apiKey, calendarId, category) => { + const url = new URL( + `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events`, + ); + url.search = new URLSearchParams({ + key: apiKey, + timeMin: new Date().toISOString(), + singleEvents: 'true', + orderBy: 'startTime', + maxResults: '25', + }).toString(); + + const response = await fetch(url); + if (!response.ok) { + const body = await response.text(); + console.error(`Google Calendar API error for "${category}" (${response.status}): ${body}`); + return []; + } + + const { items = [] } = await response.json(); + return items + .filter((item) => item.status !== 'cancelled') + .map((item) => { + const { type, title, description } = extractType( + item.summary ?? 'Untitled event', + item.description ?? '', + ); + + return { + id: item.id, + title, + description, + type, + location: item.location ?? '', + start: item.start?.dateTime ?? item.start?.date, + end: item.end?.dateTime ?? item.end?.date, + allDay: !item.start?.dateTime, + htmlLink: item.htmlLink ?? '', + category, + }; + }); +}; + +// Display list of upcoming events from the CSES Google Calendars. +export const calendarEventList = asyncHandler(async (req, res) => { + // Read env inside the handler: dotenv.config() runs after module imports. + const apiKey = process.env.GOOGLE_CALENDAR_API_KEY; + + if (!apiKey) { + console.warn('GOOGLE_CALENDAR_API_KEY is not set; returning empty calendar event list'); + return res.json([]); + } + + const calendars = CALENDAR_CATEGORIES.filter(({ envKey }) => process.env[envKey]).map( + ({ category, envKey }) => ({ category, calendarId: process.env[envKey] }), + ); + + // Fallback: a single calendar (all events shown as General) when no + // per-community calendars are configured. + if (calendars.length === 0) { + calendars.push({ + category: 'General', + calendarId: process.env.GOOGLE_CALENDAR_ID || 'cses@ucsd.edu', + }); + } + + if (cache.data && Date.now() - cache.fetchedAt < CACHE_TTL_MS) { + return res.json(cache.data); + } + + const results = await Promise.all( + calendars.map(({ category, calendarId }) => fetchCalendar(apiKey, calendarId, category)), + ); + const events = results.flat().sort((a, b) => new Date(a.start) - new Date(b.start)); + + cache = { data: events, fetchedAt: Date.now() }; + res.json(events); +}); + +// Export default controller methods +export default { + calendarEventList, +}; diff --git a/backend/database/connect-db.js b/backend/database/connect-db.js index 7e4d420f..07fd6b87 100644 --- a/backend/database/connect-db.js +++ b/backend/database/connect-db.js @@ -6,6 +6,10 @@ const uri = process.env.CONNECTION_URL; // Connect to database const connectDB = async () => { + if (!uri) { + console.warn('CONNECTION_URL is not set; skipping MongoDB connection (event/user routes will fail)'); + return; + } try { await mongoose.connect(uri, { useNewUrlParser: true, diff --git a/backend/index.js b/backend/index.js index 28346f74..a9ed92f1 100644 --- a/backend/index.js +++ b/backend/index.js @@ -9,6 +9,7 @@ import connectMailchimp from './mailchimp/connect-mailchimp.js'; // import routes import eventRoutes from './routes/event.js'; +import calendarRoutes from './routes/calendar.js'; import subscriptionRoutes from './routes/emailSubscription.js'; import userRoutes from './routes/user.js'; @@ -39,6 +40,7 @@ app.get('/', function (_, res) { }); app.use(`${baseApi}`, eventRoutes); +app.use(`${baseApi}/calendar`, calendarRoutes); app.use(`${baseApi}/subscribers`, subscriptionRoutes); app.use(`${baseApi}/users`, userRoutes); diff --git a/backend/mailchimp/connect-mailchimp.js b/backend/mailchimp/connect-mailchimp.js index 00a3315a..d4f0588e 100644 --- a/backend/mailchimp/connect-mailchimp.js +++ b/backend/mailchimp/connect-mailchimp.js @@ -9,8 +9,16 @@ mailchimp.setConfig({ }); async function connectMailchimp() { - const response = await mailchimp.ping.get(); - console.log(response.health_status); // if successful, returns "Everything's Chimpy!" + if (!apikey) { + console.warn('MAILCHIMP_API_KEY is not set; skipping Mailchimp connection (subscriber routes will fail)'); + return; + } + try { + const response = await mailchimp.ping.get(); + console.log(response.health_status); // if successful, returns "Everything's Chimpy!" + } catch (error) { + console.error('Mailchimp connection error:', error); + } } export default connectMailchimp; \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 3587215d..0941a7bd 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -23,6 +23,9 @@ }, "devDependencies": { "eslint": "^8.43.0" + }, + "engines": { + "node": ">=18" } }, "node_modules/@aashutoshrathi/word-wrap": { diff --git a/backend/package.json b/backend/package.json index bdcc2348..baa4ddd6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -25,7 +25,7 @@ "qrcode": "^1.5.3" }, "engines": { - "node": ">=16" + "node": ">=18" }, "devDependencies": { "eslint": "^8.43.0" diff --git a/backend/routes/calendar.js b/backend/routes/calendar.js new file mode 100644 index 00000000..2caca985 --- /dev/null +++ b/backend/routes/calendar.js @@ -0,0 +1,13 @@ +import express from 'express'; +const router = express.Router(); + +// Require controller modules. +import calendarController from '../controllers/calendarController.js'; + +/// CALENDAR ROUTES /// + +// GET request for upcoming events synced from the CSES Google Calendar. +router.get('/events', calendarController.calendarEventList); + +// Export router. +export default router; diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 2b1358a3..5a340590 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -127,6 +127,18 @@ export const eventListAPI = () => { }); }; +export const calendarEventsAPI = () => { + return new Promise((resolve, reject) => { + API.get('/calendar/events') + .then((response) => { + resolve(response.data); + }) + .catch((error) => { + reject(error); + }); + }); +}; + export const eventCreateAPI = (newEvent) => { return new Promise((resolve, reject) => { API.post('/event/create', newEvent) diff --git a/frontend/src/components/NewEvents/EventCard.tsx b/frontend/src/components/NewEvents/EventCard.tsx index c274c9e0..58c079a6 100644 --- a/frontend/src/components/NewEvents/EventCard.tsx +++ b/frontend/src/components/NewEvents/EventCard.tsx @@ -1,91 +1,65 @@ -import React from "react"; -import { Typography, Box } from "@mui/material"; +import { Box } from '@mui/material'; +import CalendarTodayOutlinedIcon from '@mui/icons-material/CalendarTodayOutlined'; +import AccessTimeOutlinedIcon from '@mui/icons-material/AccessTimeOutlined'; +import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined'; +import { CalendarEvent } from '../../utils/types'; +import { eventCardStyles } from './styles'; -type EventCardProps = { - title: string; - startDate: string; - endDate: string; - location: string; - calendar_link: string; - description: string; - instagram_link: string; - _id: string; -}; +const TIME_ZONE = 'America/Los_Angeles'; + +const dateFormatter = new Intl.DateTimeFormat('en-US', { + timeZone: TIME_ZONE, + month: 'long', + day: 'numeric', + year: 'numeric', +}); -const EventCard = ({ - title, - startDate, - endDate, - location, - calendar_link, - description, - instagram_link, - _id, -}: EventCardProps) => { - const start = new Date(startDate); - const end = new Date(endDate); +const timeFormatter = new Intl.DateTimeFormat('en-US', { + timeZone: TIME_ZONE, + hour: 'numeric', + minute: '2-digit', +}); - const formattedDate = start.toLocaleDateString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - }); +const formatDate = (event: CalendarEvent) => { + // All-day events use date-only strings (e.g. "2026-04-28"); parse as local + // midnight rather than UTC so the displayed day doesn't shift. + const date = event.allDay ? new Date(`${event.start}T00:00:00`) : new Date(event.start); + return dateFormatter.format(date); +}; - const formattedTime = `${start.toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - })} - ${end.toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - })}`; +const formatTimeRange = (event: CalendarEvent) => { + if (event.allDay) return 'All day'; + return `${timeFormatter.format(new Date(event.start))} - ${timeFormatter.format( + new Date(event.end), + )}`; +}; + +const EventCard = ({ event }: { event: CalendarEvent }) => { + const styles = eventCardStyles(); return ( - - - - {title} - - - - {formattedDate} | {formattedTime} - - - {location} - + + {event.title} + {event.type || event.category} + + + + + {formatDate(event)} + + + + {formatTimeRange(event)} + {event.location && ( + + + {event.location} + + )} ); }; -export default EventCard; \ No newline at end of file +export default EventCard; diff --git a/frontend/src/components/NewEvents/Events.tsx b/frontend/src/components/NewEvents/Events.tsx index c5c9dcaf..4114a8e4 100644 --- a/frontend/src/components/NewEvents/Events.tsx +++ b/frontend/src/components/NewEvents/Events.tsx @@ -1,394 +1,72 @@ -import React, { useState, useEffect } from "react"; -import { Box, Grid, Button, Typography, IconButton, useTheme, useMediaQuery } from "@mui/material"; -import { ArrowBackIosNewRounded, ArrowForwardIosRounded } from "@mui/icons-material"; -import axios from "axios"; -import EventCard from "./EventCard"; -import { positions } from "@mui/system"; +import { useEffect, useMemo, useState } from 'react'; +import { Box, CircularProgress, Container, Stack } from '@mui/material'; +import EventCard from './EventCard'; +import SegmentedTabs from '../common/SegmentedTabs'; +import { calendarEventsAPI } from '../../api'; +import { CalendarEvent } from '../../utils/types'; +import { colors } from '../../theme'; +import { eventsStyles } from './styles'; -const categories = ["General", "Dev", "Open Source", "Innovate"]; - -const categoryColors: Record = { - General: "#EBB111", - Dev: "#5DF0C4", - "Open Source": "#64C3E3", - Innovate: "#725DF0", -}; - -interface Event { - calendar_link: string; - description: string; - end_time: string; - instagram_link: string; - location: string; - start_time: string; - title: string; - _id: string; - event_type: string; -} +const CATEGORIES = ['General', 'Open-Source', 'Innovate', 'Dev']; const EventsPage = () => { - const [selectedCategory, setSelectedCategory] = useState(null); - const [upcomingEvents, setUpcomingEvents] = useState([]); - const [pastEvents, setPastEvents] = useState([]); - const [currentIndex, setCurrentIndex] = useState(0); - - useEffect(() => { - const fetchEvents = async () => { - try { - const [upcomingRes, pastRes] = await Promise.all([ - axios.get(`${process.env.REACT_APP_BACKEND_URL}/api/v1/events?type=upcoming`), - axios.get(`${process.env.REACT_APP_BACKEND_URL}/api/v1/events?type=past`), - ]); - - const sortedUpcoming = upcomingRes.data.sort( - (a, b) => - new Date(a.start_time).getTime() - new Date(b.start_time).getTime() - ); - const sortedPast = pastRes.data.sort( - (a, b) => - new Date(b.start_time).getTime() - new Date(a.start_time).getTime() - ); - - setUpcomingEvents(sortedUpcoming); - setPastEvents(sortedPast); - } catch (error) { - console.error("Error fetching events:", error); - } - }; - - fetchEvents(); - }, []); - - const theme = useTheme(); - const isXs = useMediaQuery(theme.breakpoints.down("sm")); - const isSm = useMediaQuery(theme.breakpoints.between("sm", "md")); - const isMd = useMediaQuery(theme.breakpoints.between("md", "lg")); - - const VISIBLE_COUNT = isXs ? 1 : isSm ? 2 : isMd ? 3 : 4; - - const handlePrev = () => { - setCurrentIndex((prev) => - prev === 0 ? Math.max(filteredUpcomingEvents.length - VISIBLE_COUNT, 0) : prev - 1 - ); - }; - - const handleNext = () => { - setCurrentIndex((prev) => - prev >= Math.max(filteredUpcomingEvents.length - VISIBLE_COUNT, 0) ? 0 : prev + 1 - ); - }; - - const filteredUpcomingEvents = selectedCategory - ? upcomingEvents.filter((event: any) => event.event_type === selectedCategory) - : upcomingEvents; - - const filteredPastEvents = selectedCategory - ? pastEvents.filter((event: any) => event.event_type === selectedCategory) - : pastEvents; - - let visibleEvents: Event[] = []; - if (filteredUpcomingEvents.length <= VISIBLE_COUNT) { - visibleEvents = filteredUpcomingEvents; - } else { - visibleEvents = filteredUpcomingEvents.slice(currentIndex, currentIndex + VISIBLE_COUNT); - - if (visibleEvents.length < VISIBLE_COUNT) { - const wrapCount = VISIBLE_COUNT - visibleEvents.length; - visibleEvents = visibleEvents.concat(filteredUpcomingEvents.slice(0, wrapCount)); - } - } - - const [pastIndex, setPastIndex] = useState(0); - - const pastCols = isXs ? 1 : isSm ? 2 : isMd ? 3 : 4; - const pastRows = 3; - const pastPerPage = pastCols * pastRows; - - const paginatedPastEvents = []; - for (let i = 0; i < filteredPastEvents.length; i += pastPerPage) { - paginatedPastEvents.push(filteredPastEvents.slice(i, i + pastPerPage)); - } - - const handlePastPrev = () => { - setPastIndex((prev) => (prev === 0 ? paginatedPastEvents.length - 1 : prev - 1)); - }; - - const handlePastNext = () => { - setPastIndex((prev) => (prev === paginatedPastEvents.length - 1 ? 0 : prev + 1)); - }; - - return ( - - - Events - - - {/* Category Buttons */} - - {categories.map((cat) => { - const isSelected = selectedCategory === cat; - const color = categoryColors[cat]; - return ( - - ); - })} - - - {/* Upcoming Events */} - - {filteredUpcomingEvents.length > 0 ? ( - - {filteredUpcomingEvents.length > VISIBLE_COUNT && ( - - - - )} - - - {visibleEvents.map((event) => ( - - - - ))} - - - {filteredUpcomingEvents.length > VISIBLE_COUNT && ( - - - - )} - - ) : ( - - No upcoming events. - - )} - - - {/* Past Events */} - - - Past Events - - - {filteredPastEvents.length > 0 ? ( - - {paginatedPastEvents.length > 1 && ( - - - - )} + const styles = eventsStyles(); + + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [category, setCategory] = useState('General'); + + useEffect(() => { + calendarEventsAPI() + .then((data: CalendarEvent[]) => setEvents(data)) + .catch((error: unknown) => { + console.error('Error fetching calendar events:', error); + setEvents([]); + }) + .finally(() => setLoading(false)); + }, []); + + const filteredEvents = useMemo( + () => events.filter((event) => event.category === category), + [events, category], + ); + + return ( + + + + Events + + + Join us for workshops, hackathons, tech talks, and networking events + - - - {paginatedPastEvents[pastIndex].map((event) => ( - - - - ))} - - + + Upcoming Events + - {paginatedPastEvents.length > 1 && ( - - - - )} - - ) : ( - - No past events. - - )} - + + - ); + + {loading ? ( + + + + ) : filteredEvents.length > 0 ? ( + + {filteredEvents.map((event) => ( + + ))} + + ) : ( + + No upcoming events. Check back soon! + + )} + + + ); }; -export default EventsPage; \ No newline at end of file +export default EventsPage; diff --git a/frontend/src/components/NewEvents/styles.ts b/frontend/src/components/NewEvents/styles.ts new file mode 100644 index 00000000..fd3f3c8f --- /dev/null +++ b/frontend/src/components/NewEvents/styles.ts @@ -0,0 +1,105 @@ +import { colors, fonts, radii } from '../../theme'; + +export const eventsStyles = () => ({ + pageWrapper: { + backgroundColor: colors.background, + minHeight: '100vh', + pb: 10, + }, + container: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + px: { xs: 3, md: 6 }, + pt: { xs: 16, md: 18 }, + }, + title: { + fontFamily: fonts.heading, + fontWeight: 700, + fontSize: { xs: '2.2rem', md: '3rem' }, + color: colors.textPrimary, + textAlign: 'center', + }, + subtitle: { + fontFamily: fonts.heading, + fontSize: { xs: '0.85rem', md: '1rem' }, + color: colors.textSecondary, + textAlign: 'center', + mt: 2, + px: 2, + }, + sectionHeading: { + fontFamily: fonts.heading, + fontWeight: 700, + fontSize: { xs: '1.6rem', md: '2.2rem' }, + color: colors.textPrimary, + textAlign: 'center', + mt: { xs: 6, md: 8 }, + }, + tabsWrapper: { + width: '100%', + maxWidth: '1000px', + mt: 4, + }, + cardsStack: { + width: '100%', + maxWidth: '1000px', + mt: 4, + }, + statusWrapper: { + display: 'flex', + justifyContent: 'center', + width: '100%', + mt: 8, + }, + emptyText: { + fontFamily: fonts.body, + fontSize: '1rem', + color: colors.textSecondary, + textAlign: 'center', + }, +}); + +export const eventCardStyles = () => ({ + card: { + display: 'block', + backgroundColor: colors.surface, + border: `1px solid ${colors.border}`, + borderRadius: radii.card, + p: { xs: 2.5, md: 3 }, + width: '100%', + }, + title: { + fontFamily: fonts.body, + fontWeight: 600, + fontSize: { xs: '1.1rem', md: '1.25rem' }, + color: colors.textPrimary, + }, + category: { + fontFamily: fonts.body, + fontSize: '0.9rem', + color: colors.purple, + mt: 0.5, + }, + detailsWrapper: { + display: 'flex', + flexDirection: 'column', + gap: 1, + mt: 2.5, + }, + detailRow: { + display: 'flex', + alignItems: 'center', + gap: 1.2, + color: colors.textSecondary, + }, + detailIcon: { + fontSize: '1.1rem', + color: colors.textSecondary, + }, + detailText: { + fontFamily: fonts.body, + fontSize: '0.95rem', + color: colors.textSecondary, + }, +});