diff --git a/CLAUDE.md b/CLAUDE.md index f4b70dd..c532cb7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,10 @@ npm run preview # Preview production build locally # Database node setup-schema.js # Initialize database schema (run once) +# Docker Development (IMPORTANT: Always use --no-cache when rebuilding) +docker-compose build --no-cache # Clean rebuild when cache issues occur +docker-compose down && docker-compose up -d # Clean restart containers + # Stripe MCP (Model Context Protocol) npm run mcp:stripe # Start Stripe MCP server for AI integration npm run mcp:stripe:debug # Start MCP server with debugging interface diff --git a/package-lock.json b/package-lock.json index 1937307..a3a02dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@craftjs/core": "^0.2.12", "@craftjs/utils": "^0.2.5", "@modelcontextprotocol/sdk": "^1.0.3", + "@playwright/test": "^1.54.1", "@sentry/astro": "^9.35.0", "@sentry/node": "^9.35.0", "@stripe/connect-js": "^3.3.25", @@ -2561,6 +2562,21 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.54.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.1.tgz", + "integrity": "sha512-FS8hQ12acieG2dYSksmLOF7BNxnVf2afRJdCuM1eMSxj6QTSE6G4InGF7oApGgDb65MX7AwMVlIkpru0yZA4Xw==", + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.54.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@prisma/instrumentation": { "version": "6.10.1", "resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-6.10.1.tgz", diff --git a/package.json b/package.json index 3855ccc..4da3cc0 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@craftjs/core": "^0.2.12", "@craftjs/utils": "^0.2.5", "@modelcontextprotocol/sdk": "^1.0.3", + "@playwright/test": "^1.54.1", "@sentry/astro": "^9.35.0", "@sentry/node": "^9.35.0", "@stripe/connect-js": "^3.3.25", diff --git a/src/components/Calendar.tsx b/src/components/Calendar.tsx index 45bd812..b35a07c 100644 --- a/src/components/Calendar.tsx +++ b/src/components/Calendar.tsx @@ -1,457 +1,113 @@ -import React, { useState, useEffect } from 'react'; -import { trendingAnalyticsService, TrendingEvent } from '../lib/analytics'; -import { geolocationService } from '../lib/geolocation'; +import React from 'react'; +import { + CalendarProps, + useCalendar, + CalendarHeader, + CalendarGrid, + EventList, + TrendingEvents, + UpcomingEvents +} from './calendar'; -interface Event { - id: string; - title: string; - start_time: string; - venue: string; - slug: string; - category?: string; - is_featured?: boolean; - image_url?: string; - distanceMiles?: number; - popularityScore?: number; -} - -interface CalendarProps { - events: Event[]; - onEventClick?: (event: Event) => void; - showLocationFeatures?: boolean; - showTrending?: boolean; -} - -const Calendar: React.FC = ({ events, onEventClick, showLocationFeatures = false, showTrending = false }) => { - const [currentDate, setCurrentDate] = useState(new Date()); - const [view, setView] = useState<'month' | 'week' | 'list'>('month'); - const [trendingEvents, setTrendingEvents] = useState([]); - const [nearbyEvents, setNearbyEvents] = useState([]); - const [userLocation, setUserLocation] = useState<{lat: number, lng: number} | null>(null); - const [isMobile, setIsMobile] = useState(false); - const [isLoading, setIsLoading] = useState(false); - - // Detect mobile screen size - useEffect(() => { - const checkMobile = () => { - setIsMobile(window.innerWidth < 768); - }; - checkMobile(); - window.addEventListener('resize', checkMobile); - return () => window.removeEventListener('resize', checkMobile); - }, []); - - // Get user location and trending events - useEffect(() => { - if (showLocationFeatures || showTrending) { - loadLocationAndTrending(); - } - }, [showLocationFeatures, showTrending]); - - const loadLocationAndTrending = async () => { - setIsLoading(true); - try { - // Get user location - const location = await geolocationService.requestLocationPermission(); - if (location) { - setUserLocation({lat: location.latitude, lng: location.longitude}); - - // Get trending events if enabled - if (showTrending) { - const trending = await trendingAnalyticsService.getTrendingEvents( - location.latitude, - location.longitude, - 50, - 10 - ); - setTrendingEvents(trending); - } - - // Get nearby events if enabled - if (showLocationFeatures) { - const nearby = await trendingAnalyticsService.getHotEventsInArea( - location.latitude, - location.longitude, - 25, - 8 - ); - setNearbyEvents(nearby); - } - } - } catch (_error) { - console.error('Failed to load location and trending data:', _error); - } finally { - setIsLoading(false); - } - }; - - const today = new Date(); - const currentMonth = currentDate.getMonth(); - const currentYear = currentDate.getFullYear(); - - // Get days in month - const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate(); - const firstDayOfMonth = new Date(currentYear, currentMonth, 1).getDay(); - - // Generate calendar grid - const calendarDays = []; - - // Empty cells for days before month starts - for (let i = 0; i < firstDayOfMonth; i++) { - calendarDays.push(null); - } - - // Days of the month - for (let day = 1; day <= daysInMonth; day++) { - calendarDays.push(day); - } - - // Get events for a specific day - const getEventsForDay = (day: number) => { - const dayDate = new Date(currentYear, currentMonth, day); - return events.filter(event => { - const eventDate = new Date(event.start_time); - return eventDate.toDateString() === dayDate.toDateString(); - }); - }; - - // Navigation functions - const previousMonth = () => { - setCurrentDate(new Date(currentYear, currentMonth - 1, 1)); - }; - - const nextMonth = () => { - setCurrentDate(new Date(currentYear, currentMonth + 1, 1)); - }; - - const goToToday = () => { - setCurrentDate(new Date()); - }; - - const monthNames = [ - 'January', 'February', 'March', 'April', 'May', 'June', - 'July', 'August', 'September', 'October', 'November', 'December' - ]; - - const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; - const dayNamesShort = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; - - const isToday = (day: number) => { - const dayDate = new Date(currentYear, currentMonth, day); - return dayDate.toDateString() === today.toDateString(); - }; +const Calendar: React.FC = ({ + events, + onEventClick, + showLocationFeatures = false, + showTrending = false, + initialView = 'month', + className = '' +}) => { + // Use our custom calendar hook for state management + const { + currentDate, + view, + trendingEvents, + nearbyEvents, + userLocation, + isMobile, + isLoading, + setView, + previousMonth, + nextMonth, + goToToday + } = useCalendar({ + initialView, + showLocationFeatures, + showTrending, + events + }); return ( -
- {/* Calendar Header */} -
-
-
-

- {isMobile ? `${monthNames[currentMonth].slice(0, 3)} ${currentYear}` : `${monthNames[currentMonth]} ${currentYear}`} -

- -
- -
- {/* View Toggle */} -
- - - -
+
+ + - {/* Navigation */} -
- - -
-
-
-
- - {/* Calendar Grid */} + {/* Calendar Grid for Month View */} {view === 'month' && ( -
- {/* Day Headers */} -
- {(isMobile ? dayNamesShort : dayNames).map((day, _index) => ( -
- {day} -
- ))} -
- - {/* Calendar Days */} -
- {calendarDays.map((day, index) => { - if (day === null) { - return
; - } - - const dayEvents = getEventsForDay(day); - const isCurrentDay = isToday(day); - - return ( -
-
- {day} -
- - {/* Events for this day */} -
- {dayEvents.slice(0, isMobile ? 1 : 2).map(event => ( -
onEventClick?.(event)} - className="text-xs bg-indigo-200 dark:bg-indigo-800 text-indigo-900 dark:text-indigo-200 font-medium rounded px-1 py-0.5 cursor-pointer hover:bg-indigo-300 dark:hover:bg-indigo-700 truncate" - title={`${event.title} at ${event.venue}`} - > - {isMobile ? event.title.slice(0, 8) + (event.title.length > 8 ? '...' : '') : event.title} -
- ))} - - {dayEvents.length > (isMobile ? 1 : 2) && ( -
- +{dayEvents.length - (isMobile ? 1 : 2)} more -
- )} -
-
- ); - })} -
-
+ )} {/* List View */} {view === 'list' && ( -
-
- {events - .filter(event => new Date(event.start_time) >= today) - .sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime()) - .map(event => { - const eventDate = new Date(event.start_time); - return ( -
onEventClick?.(event)} - className="flex items-center justify-between p-3 rounded-lg border-2 border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer" - > -
-
-
{event.title}
- {event.is_featured && ( - - Featured - - )} -
-
- {event.venue} - {event.distanceMiles && ( - • {event.distanceMiles.toFixed(1)} miles - )} -
-
-
- {eventDate.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit' - })} -
-
- ); - })} -
-
+ )} {/* Trending Events Section */} {showTrending && trendingEvents.length > 0 && view !== 'list' && ( -
-
-

🔥 What's Hot

- {userLocation && ( - Within 50 miles - )} -
-
- {trendingEvents.slice(0, 4).map(event => ( -
onEventClick?.(event)} - className="flex items-center justify-between p-3 rounded-lg bg-gradient-to-r from-yellow-50 to-orange-50 border border-yellow-200 hover:border-yellow-300 cursor-pointer" - > -
-
-
{event.title}
- {event.isFeature && ( - - ⭐ - - )} -
-
- {event.venue} - {event.distanceMiles && ( - • {event.distanceMiles.toFixed(1)} mi - )} -
-
- {event.ticketsSold} tickets sold -
-
-
- {new Date(event.startTime).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric' - })} -
-
- ))} -
-
+ )} {/* Nearby Events Section */} {showLocationFeatures && nearbyEvents.length > 0 && view !== 'list' && ( -
-
-

📍 Near You

- {userLocation && ( - Within 25 miles - )} -
-
- {nearbyEvents.slice(0, 3).map(event => ( -
onEventClick?.(event)} - className="flex items-center justify-between p-3 rounded-lg bg-blue-50 border border-blue-200 hover:border-blue-300 cursor-pointer" - > -
-
{event.title}
-
- {event.venue} • {event.distanceMiles?.toFixed(1)} miles away -
-
-
- {new Date(event.startTime).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric' - })} -
-
- ))} -
-
+ )} {/* Upcoming Events List */} {view !== 'list' && ( -
-

Upcoming Events

-
- {events - .filter(event => new Date(event.start_time) >= today) - .sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime()) - .slice(0, 5) - .map(event => { - const eventDate = new Date(event.start_time); - return ( -
onEventClick?.(event)} - className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer" - > -
-
{event.title}
-
{event.venue}
-
-
- {eventDate.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit' - })} -
-
- ); - })} -
- - {events.filter(event => new Date(event.start_time) >= today).length === 0 && ( -
- No upcoming events -
- )} -
+ )} {/* Loading State */} {isLoading && ( -
+
-
- Loading location-based events... +
+ + Loading location-based events... +
)} diff --git a/src/components/calendar/CalendarGrid.tsx b/src/components/calendar/CalendarGrid.tsx new file mode 100644 index 0000000..cf9b5d9 --- /dev/null +++ b/src/components/calendar/CalendarGrid.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { CalendarGridProps } from './types'; +import { generateCalendarDays, getDayNames, getCategoryColor } from './utils'; +import { EventCard } from './EventCard'; + +/** + * Calendar grid component showing days and events + */ +export const CalendarGrid: React.FC = ({ + currentDate, + events, + onEventClick, + isMobile +}) => { + const calendarDays = generateCalendarDays(currentDate, events); + const dayNames = getDayNames(isMobile); + + const handleDayClick = (events: any[], date: Date) => { + if (events.length > 0) { + // Could implement day modal or other interaction + console.log('Day clicked:', date, events); + } + }; + + return ( +
+ {/* Day Headers */} +
+ {dayNames.map((day, index) => ( +
+ {day} +
+ ))} +
+ + {/* Calendar Days */} +
+ {calendarDays.map((calendarDay, index) => { + const { day, isCurrentMonth, isToday, date, events: dayEvents } = calendarDay; + + return ( +
handleDayClick(dayEvents, date)} + > +
+ {day} +
+ + {/* Events for this day */} + {isCurrentMonth && ( +
+ {dayEvents.slice(0, isMobile ? 1 : 2).map(event => ( + + ))} + + {dayEvents.length > (isMobile ? 1 : 2) && ( +
{ + e.currentTarget.style.background = 'var(--ui-bg-elevated)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.background = 'var(--ui-bg-secondary)'; + }} + onClick={(e) => { + e.stopPropagation(); + handleDayClick(dayEvents, date); + }} + > + +{dayEvents.length - (isMobile ? 1 : 2)} more +
+ )} +
+ )} +
+ ); + })} +
+
+ ); +}; \ No newline at end of file diff --git a/src/components/calendar/CalendarHeader.tsx b/src/components/calendar/CalendarHeader.tsx new file mode 100644 index 0000000..4b96864 --- /dev/null +++ b/src/components/calendar/CalendarHeader.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { CalendarHeaderProps } from './types'; +import { getMonthName } from './utils'; + +/** + * Calendar header with navigation and view controls + */ +export const CalendarHeader: React.FC = ({ + currentDate, + view, + onViewChange, + onPreviousMonth, + onNextMonth, + onToday, + isMobile +}) => { + const displayText = isMobile + ? `${getMonthName(currentDate, true)} ${currentDate.getFullYear()}` + : `${getMonthName(currentDate)} ${currentDate.getFullYear()}`; + + return ( +
+
+
+

+ {displayText} +

+ +
+ +
+ {/* View Toggle */} +
+ + + +
+ + {/* Navigation */} +
+ + +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/src/components/calendar/EventCard.tsx b/src/components/calendar/EventCard.tsx new file mode 100644 index 0000000..5eab302 --- /dev/null +++ b/src/components/calendar/EventCard.tsx @@ -0,0 +1,163 @@ +import React from 'react'; +import { EventCardProps } from './types'; +import { formatDateTime, getCategoryColor, getCategoryIcon, truncateText } from './utils'; + +/** + * Reusable event card component with multiple variants + */ +export const EventCard: React.FC = ({ + event, + onClick, + variant = 'grid', + showDistance = false, + className = '' +}) => { + const handleClick = () => { + onClick?.(event); + }; + + if (variant === 'trending') { + return ( +
+
+
+
+ {event.title} +
+ {event.is_featured && ( + + ⭐ + + )} +
+
+ {event.venue} + {showDistance && event.distanceMiles && ( + • {event.distanceMiles.toFixed(1)} mi + )} +
+ {event.popularityScore && ( +
+ {event.popularityScore} popularity score +
+ )} +
+
+ {new Date(event.start_time).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric' + })} +
+
+ ); + } + + if (variant === 'list') { + const { date, time } = formatDateTime(event.start_time); + const categoryColor = getCategoryColor(event.category); + const categoryIcon = getCategoryIcon(event.category); + + return ( +
+
+
+
+
+ {categoryIcon} +
+
+ + {event.category?.charAt(0).toUpperCase() + event.category?.slice(1) || 'Event'} + +
+
+

+ {event.title} +

+
+
+
+ +
+
+ + + + {time} +
+ +
+ + + + + {event.venue || 'Venue TBA'} +
+ + {event.description && ( +

+ {event.description} +

+ )} + +
+
+ {event.is_featured && ( + + ⭐ Featured + + )} + {showDistance && event.distanceMiles && ( + + {event.distanceMiles.toFixed(1)} mi away + + )} +
+ + +
+
+
+
+ ); + } + + // Grid variant (compact for calendar day view) + const categoryColor = getCategoryColor(event.category); + const maxTitleLength = 20; + + return ( +
+ {truncateText(event.title, maxTitleLength)} +
+ ); +}; \ No newline at end of file diff --git a/src/components/calendar/EventList.tsx b/src/components/calendar/EventList.tsx new file mode 100644 index 0000000..642694d --- /dev/null +++ b/src/components/calendar/EventList.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { EventListProps } from './types'; +import { groupEventsByDate, sortEventsByDate, getFutureEvents, getRelativeDateText } from './utils'; +import { EventCard } from './EventCard'; + +/** + * Event list component showing events grouped by date + */ +export const EventList: React.FC = ({ + events, + onEventClick, + showDistance = false +}) => { + const futureEvents = getFutureEvents(events); + const sortedEvents = sortEventsByDate(futureEvents); + const groupedEvents = groupEventsByDate(sortedEvents); + const sortedDates = Object.keys(groupedEvents).sort((a, b) => new Date(a).getTime() - new Date(b).getTime()); + + if (sortedEvents.length === 0) { + return ( +
+
+
+ + + +
+

+ No Upcoming Events +

+

+ Check back later for new events or adjust your filters. +

+
+
+ ); + } + + return ( +
+
+ {sortedDates.map(dateKey => { + const date = new Date(dateKey); + const dateEvents = groupedEvents[dateKey]; + const dateText = getRelativeDateText(date); + + return ( +
+ {/* Date Header */} +
+

+ {dateText} +

+
+
+
+ + {/* Events for this date */} +
+ {dateEvents.map(event => ( + + ))} +
+
+ ); + })} +
+
+ ); +}; \ No newline at end of file diff --git a/src/components/calendar/TrendingEvents.tsx b/src/components/calendar/TrendingEvents.tsx new file mode 100644 index 0000000..a33ecef --- /dev/null +++ b/src/components/calendar/TrendingEvents.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { TrendingEventsProps } from './types'; + +/** + * Trending events section component + */ +export const TrendingEvents: React.FC = ({ + events, + userLocation, + onEventClick, + title = "🔥 What's Hot", + radius = 50 +}) => { + if (events.length === 0) { + return null; + } + + return ( +
+
+

+ {title} +

+ {userLocation && ( + + Within {radius} miles + + )} +
+ +
+ {events.slice(0, 4).map(event => ( +
onEventClick?.(event)} + className="flex items-center justify-between p-3 rounded-lg cursor-pointer transition-all hover:scale-105 backdrop-blur-sm" + style={{ + background: 'var(--warning-bg)', + border: '1px solid var(--warning-border)' + }} + > +
+
+
+ {event.title} +
+ {event.isFeature && ( + + ⭐ + + )} +
+
+ {event.venue} + {event.distanceMiles && ( + • {event.distanceMiles.toFixed(1)} mi + )} +
+
+ {event.ticketsSold || 0} tickets sold +
+
+
+ {new Date(event.startTime).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric' + })} +
+
+ ))} +
+
+ ); +}; \ No newline at end of file diff --git a/src/components/calendar/UpcomingEvents.tsx b/src/components/calendar/UpcomingEvents.tsx new file mode 100644 index 0000000..013aa0b --- /dev/null +++ b/src/components/calendar/UpcomingEvents.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { Event } from './types'; +import { getFutureEvents, sortEventsByDate } from './utils'; + +interface UpcomingEventsProps { + events: Event[]; + onEventClick?: (event: Event) => void; + limit?: number; + showIfEmpty?: boolean; +} + +/** + * Upcoming events section component + */ +export const UpcomingEvents: React.FC = ({ + events, + onEventClick, + limit = 5, + showIfEmpty = true +}) => { + const futureEvents = getFutureEvents(events); + const sortedEvents = sortEventsByDate(futureEvents).slice(0, limit); + + return ( +
+

+ Upcoming Events +

+ +
+ {sortedEvents.map(event => { + const eventDate = new Date(event.start_time); + return ( +
onEventClick?.(event)} + className="flex items-center justify-between p-2 rounded-lg cursor-pointer transition-all hover:scale-105 backdrop-blur-sm" + style={{ + background: 'var(--glass-bg)', + border: '1px solid var(--glass-border)' + }} + > +
+
+ {event.title} +
+
+ {event.venue} +
+
+
+ {eventDate.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit' + })} +
+
+ ); + })} + + {sortedEvents.length === 0 && showIfEmpty && ( +
+ No upcoming events +
+ )} +
+
+ ); +}; \ No newline at end of file diff --git a/src/components/calendar/index.ts b/src/components/calendar/index.ts new file mode 100644 index 0000000..32ca436 --- /dev/null +++ b/src/components/calendar/index.ts @@ -0,0 +1,10 @@ +// Calendar component exports +export * from './types'; +export * from './utils'; +export * from './useCalendar'; +export { CalendarHeader } from './CalendarHeader'; +export { CalendarGrid } from './CalendarGrid'; +export { EventCard } from './EventCard'; +export { EventList } from './EventList'; +export { TrendingEvents } from './TrendingEvents'; +export { UpcomingEvents } from './UpcomingEvents'; \ No newline at end of file diff --git a/src/components/calendar/types.ts b/src/components/calendar/types.ts new file mode 100644 index 0000000..f6e42cf --- /dev/null +++ b/src/components/calendar/types.ts @@ -0,0 +1,120 @@ +// Calendar component types +export interface Event { + id: string; + title: string; + start_time: string; + venue: string; + slug: string; + category?: string; + is_featured?: boolean; + image_url?: string; + distanceMiles?: number; + popularityScore?: number; + description?: string; +} + +export interface TrendingEvent { + eventId: string; + title: string; + venue: string; + startTime: string; + distanceMiles?: number; + popularityScore?: number; + ticketsSold?: number; + isFeature?: boolean; +} + +export interface UserLocation { + lat: number; + lng: number; + city?: string; + state?: string; + country?: string; + source?: 'gps' | 'ip'; +} + +export type CalendarView = 'month' | 'week' | 'list'; + +export interface CalendarProps { + events: Event[]; + onEventClick?: (event: Event) => void; + showLocationFeatures?: boolean; + showTrending?: boolean; + initialView?: CalendarView; + className?: string; +} + +export interface CalendarHeaderProps { + currentDate: Date; + view: CalendarView; + onViewChange: (view: CalendarView) => void; + onPreviousMonth: () => void; + onNextMonth: () => void; + onToday: () => void; + isMobile: boolean; +} + +export interface CalendarGridProps { + currentDate: Date; + events: Event[]; + onEventClick?: (event: Event) => void; + isMobile: boolean; +} + +export interface EventCardProps { + event: Event; + onClick?: (event: Event) => void; + variant?: 'grid' | 'list' | 'trending'; + showDistance?: boolean; + className?: string; +} + +export interface EventListProps { + events: Event[]; + onEventClick?: (event: Event) => void; + showDistance?: boolean; +} + +export interface TrendingEventsProps { + events: TrendingEvent[]; + userLocation?: UserLocation; + onEventClick?: (event: TrendingEvent) => void; + title?: string; + radius?: number; +} + +export interface CalendarDay { + day: number; + isCurrentMonth: boolean; + isToday: boolean; + date: Date; + events: Event[]; +} + +export interface CalendarState { + currentDate: Date; + view: CalendarView; + events: Event[]; + filteredEvents: Event[]; + trendingEvents: TrendingEvent[]; + nearbyEvents: TrendingEvent[]; + userLocation: UserLocation | null; + isLoading: boolean; + isMobile: boolean; +} + +export interface UseCalendarOptions { + initialView?: CalendarView; + showLocationFeatures?: boolean; + showTrending?: boolean; + events?: Event[]; +} + +export interface UseCalendarReturn extends CalendarState { + setCurrentDate: (date: Date) => void; + setView: (view: CalendarView) => void; + previousMonth: () => void; + nextMonth: () => void; + goToToday: () => void; + loadLocationAndTrending: () => Promise; +} \ No newline at end of file diff --git a/src/components/calendar/useCalendar.ts b/src/components/calendar/useCalendar.ts new file mode 100644 index 0000000..0af7860 --- /dev/null +++ b/src/components/calendar/useCalendar.ts @@ -0,0 +1,148 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + CalendarState, + UseCalendarOptions, + UseCalendarReturn, + CalendarView, + Event, + TrendingEvent, + UserLocation +} from './types'; +import { trendingAnalyticsService } from '../../lib/analytics'; +import { geolocationService } from '../../lib/geolocation'; + +/** + * Custom hook for calendar state management + */ +export const useCalendar = (options: UseCalendarOptions = {}): UseCalendarReturn => { + const { + initialView = 'month', + showLocationFeatures = false, + showTrending = false, + events: initialEvents = [] + } = options; + + const [state, setState] = useState({ + currentDate: new Date(), + view: initialView, + events: initialEvents, + filteredEvents: initialEvents, + trendingEvents: [], + nearbyEvents: [], + userLocation: null, + isLoading: false, + isMobile: false + }); + + // Detect mobile screen size + useEffect(() => { + const checkMobile = () => { + setState(prev => ({ ...prev, isMobile: window.innerWidth < 768 })); + }; + + checkMobile(); + window.addEventListener('resize', checkMobile); + return () => window.removeEventListener('resize', checkMobile); + }, []); + + // Update events when prop changes + useEffect(() => { + setState(prev => ({ + ...prev, + events: initialEvents, + filteredEvents: initialEvents + })); + }, [initialEvents]); + + // Load location and trending data + const loadLocationAndTrending = useCallback(async () => { + if (!showLocationFeatures && !showTrending) return; + + setState(prev => ({ ...prev, isLoading: true })); + + try { + // Get user location + const location = await geolocationService.requestLocationPermission(); + if (location) { + const userLocation: UserLocation = { + lat: location.latitude, + lng: location.longitude, + city: location.city, + state: location.state || location.region, + country: location.country || location.country_code, + source: location.source as 'gps' | 'ip' + }; + + setState(prev => ({ ...prev, userLocation })); + + // Get trending events if enabled + if (showTrending) { + const trending = await trendingAnalyticsService.getTrendingEvents( + location.latitude, + location.longitude, + 50, + 10 + ); + setState(prev => ({ ...prev, trendingEvents: trending })); + } + + // Get nearby events if enabled + if (showLocationFeatures) { + const nearby = await trendingAnalyticsService.getHotEventsInArea( + location.latitude, + location.longitude, + 25, + 8 + ); + setState(prev => ({ ...prev, nearbyEvents: nearby })); + } + } + } catch (error) { + console.error('Failed to load location and trending data:', error); + } finally { + setState(prev => ({ ...prev, isLoading: false })); + } + }, [showLocationFeatures, showTrending]); + + // Load location data on mount if features are enabled + useEffect(() => { + loadLocationAndTrending(); + }, [loadLocationAndTrending]); + + // Navigation functions + const setCurrentDate = useCallback((date: Date) => { + setState(prev => ({ ...prev, currentDate: date })); + }, []); + + const setView = useCallback((view: CalendarView) => { + setState(prev => ({ ...prev, view })); + }, []); + + const previousMonth = useCallback(() => { + setState(prev => ({ + ...prev, + currentDate: new Date(prev.currentDate.getFullYear(), prev.currentDate.getMonth() - 1, 1) + })); + }, []); + + const nextMonth = useCallback(() => { + setState(prev => ({ + ...prev, + currentDate: new Date(prev.currentDate.getFullYear(), prev.currentDate.getMonth() + 1, 1) + })); + }, []); + + const goToToday = useCallback(() => { + setState(prev => ({ ...prev, currentDate: new Date() })); + }, []); + + return { + ...state, + setCurrentDate, + setView, + previousMonth, + nextMonth, + goToToday, + loadLocationAndTrending + }; +}; \ No newline at end of file diff --git a/src/components/calendar/utils.ts b/src/components/calendar/utils.ts new file mode 100644 index 0000000..3acb52f --- /dev/null +++ b/src/components/calendar/utils.ts @@ -0,0 +1,253 @@ +import { Event, CalendarDay } from './types'; + +/** + * Get the month name for display + */ +export const getMonthName = (date: Date, short: boolean = false): string => { + const monthNames = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' + ]; + + if (short) { + return monthNames[date.getMonth()].slice(0, 3); + } + + return monthNames[date.getMonth()]; +}; + +/** + * Get day names for calendar header + */ +export const getDayNames = (short: boolean = false): string[] => { + if (short) { + return ['S', 'M', 'T', 'W', 'T', 'F', 'S']; + } + return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; +}; + +/** + * Check if two dates are on the same day + */ +export const isSameDay = (date1: Date, date2: Date): boolean => { + return date1.toDateString() === date2.toDateString(); +}; + +/** + * Check if a date is today + */ +export const isToday = (date: Date): boolean => { + return isSameDay(date, new Date()); +}; + +/** + * Generate calendar days grid for a given month + */ +export const generateCalendarDays = (currentDate: Date, events: Event[]): CalendarDay[] => { + const year = currentDate.getFullYear(); + const month = currentDate.getMonth(); + + // Get first day of month and number of days + const firstDay = new Date(year, month, 1); + const lastDay = new Date(year, month + 1, 0); + const daysInMonth = lastDay.getDate(); + const startingDayOfWeek = firstDay.getDay(); + + // Calculate previous month days to show + const prevMonth = new Date(year, month - 1, 0); + const daysInPrevMonth = prevMonth.getDate(); + + const calendarDays: CalendarDay[] = []; + + // Calculate total cells needed + const totalCells = Math.ceil((daysInMonth + startingDayOfWeek) / 7) * 7; + + for (let i = 0; i < totalCells; i++) { + let dayNumber: number; + let isCurrentMonth: boolean; + let currentDayDate: Date; + + if (i < startingDayOfWeek) { + // Previous month days + dayNumber = daysInPrevMonth - (startingDayOfWeek - i - 1); + isCurrentMonth = false; + currentDayDate = new Date(year, month - 1, dayNumber); + } else if (i >= startingDayOfWeek + daysInMonth) { + // Next month days + dayNumber = i - startingDayOfWeek - daysInMonth + 1; + isCurrentMonth = false; + currentDayDate = new Date(year, month + 1, dayNumber); + } else { + // Current month days + dayNumber = i - startingDayOfWeek + 1; + isCurrentMonth = true; + currentDayDate = new Date(year, month, dayNumber); + } + + // Get events for this day + const dayEvents = events.filter(event => { + const eventDate = new Date(event.start_time); + return isSameDay(eventDate, currentDayDate); + }); + + calendarDays.push({ + day: dayNumber, + isCurrentMonth, + isToday: isToday(currentDayDate), + date: currentDayDate, + events: dayEvents + }); + } + + return calendarDays; +}; + +/** + * Get events for a specific day + */ +export const getEventsForDay = (events: Event[], date: Date): Event[] => { + return events.filter(event => { + const eventDate = new Date(event.start_time); + return isSameDay(eventDate, date); + }); +}; + +/** + * Format date for display + */ +export const formatDate = (date: Date, options?: Intl.DateTimeFormatOptions): string => { + const defaultOptions: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'long', + day: 'numeric' + }; + + return new Intl.DateTimeFormat('en-US', { ...defaultOptions, ...options }).format(date); +}; + +/** + * Format time for display + */ +export const formatTime = (date: Date): string => { + return new Intl.DateTimeFormat('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true + }).format(date); +}; + +/** + * Format date and time for display + */ +export const formatDateTime = (dateString: string) => { + const date = new Date(dateString); + return { + date: formatDate(date), + time: formatTime(date), + dayOfWeek: date.toLocaleDateString('en-US', { weekday: 'long' }) + }; +}; + +/** + * Get category color classes + */ +export const getCategoryColor = (category?: string): string => { + const colors = { + music: 'from-purple-500 to-pink-500', + arts: 'from-blue-500 to-cyan-500', + community: 'from-green-500 to-emerald-500', + business: 'from-gray-500 to-slate-500', + food: 'from-orange-500 to-red-500', + sports: 'from-indigo-500 to-purple-500', + default: 'from-gray-400 to-gray-500' + }; + return colors[category as keyof typeof colors] || colors.default; +}; + +/** + * Get category icon + */ +export const getCategoryIcon = (category?: string): string => { + const icons = { + music: '🎵', + arts: '🎨', + community: '🤝', + business: '💼', + food: '🍷', + sports: '⚽', + default: '📅' + }; + return icons[category as keyof typeof icons] || icons.default; +}; + +/** + * Group events by date + */ +export const groupEventsByDate = (events: Event[]): Record => { + const grouped: Record = {}; + + events.forEach(event => { + const dateKey = new Date(event.start_time).toDateString(); + if (!grouped[dateKey]) { + grouped[dateKey] = []; + } + grouped[dateKey].push(event); + }); + + return grouped; +}; + +/** + * Sort events by start time + */ +export const sortEventsByDate = (events: Event[]): Event[] => { + return events.sort((a, b) => + new Date(a.start_time).getTime() - new Date(b.start_time).getTime() + ); +}; + +/** + * Filter future events + */ +export const getFutureEvents = (events: Event[]): Event[] => { + const today = new Date(); + return events.filter(event => new Date(event.start_time) >= today); +}; + +/** + * Get relative date text (Today, Tomorrow, etc.) + */ +export const getRelativeDateText = (date: Date): string => { + const today = new Date(); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + if (isSameDay(date, today)) { + return 'Today'; + } else if (isSameDay(date, tomorrow)) { + return 'Tomorrow'; + } else { + return formatDate(date, { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric' + }); + } +}; + +/** + * Truncate text with ellipsis + */ +export const truncateText = (text: string, maxLength: number): string => { + if (text.length <= maxLength) return text; + return text.substring(0, maxLength) + '...'; +}; + +/** + * Get CSS variable value + */ +export const getCSSVariable = (variableName: string): string => { + if (typeof window === 'undefined') return ''; + return getComputedStyle(document.documentElement).getPropertyValue(variableName).trim(); +}; \ No newline at end of file diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index 5cf3f33..199c2b8 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -19,7 +19,7 @@ import CookieConsent from '../components/CookieConsent.astro'; {title} - \ No newline at end of file diff --git a/src/styles/glassmorphism.css b/src/styles/glassmorphism.css index 98a460d..7a7556f 100644 --- a/src/styles/glassmorphism.css +++ b/src/styles/glassmorphism.css @@ -1,7 +1,91 @@ +/* Fallback for when theme is not set - CRITICAL FIX */ +html:not([data-theme]) { + /* Emergency fallback - ensures page is visible even if theme script fails */ + --bg-gradient: linear-gradient(to bottom right, #1e293b, #7c3aed, #0f172a); + --glass-bg: rgba(255, 255, 255, 0.1); + --glass-bg-lg: rgba(255, 255, 255, 0.1); + --glass-text-primary: #ffffff; + --glass-text-secondary: rgba(255, 255, 255, 0.85); + --glass-text-tertiary: rgba(255, 255, 255, 0.7); + --glass-border: rgba(255, 255, 255, 0.2); + --glass-bg-button: rgba(255, 255, 255, 0.1); + --ui-text-primary: #ffffff; + --ui-text-secondary: rgba(255, 255, 255, 0.85); + --ui-bg-elevated: rgba(255, 255, 255, 0.15); + --ui-bg-secondary: rgba(255, 255, 255, 0.05); + --ui-border-primary: rgba(255, 255, 255, 0.2); + --ui-border-secondary: rgba(255, 255, 255, 0.1); +} + /* Theme Variables */ -:root, +:root { + /* Default theme - Dark */ + --glass-bg: rgba(255, 255, 255, 0.1); + --glass-bg-lg: rgba(255, 255, 255, 0.1); + --glass-bg-button: rgba(255, 255, 255, 0.1); + --glass-bg-button-hover: rgba(255, 255, 255, 0.2); + --glass-bg-input: rgba(255, 255, 255, 0.1); + --glass-bg-input-focus: rgba(255, 255, 255, 0.15); + --glass-border: rgba(255, 255, 255, 0.2); + --glass-border-focus: rgb(96, 165, 250); + --glass-border-focus-shadow: rgba(96, 165, 250, 0.3); + --glass-shadow: rgba(0, 0, 0, 0.12); + --glass-shadow-lg: rgba(0, 0, 0, 0.15); + --glass-shadow-button: rgba(0, 0, 0, 0.2); + --glass-text-primary: #ffffff; + --glass-text-secondary: rgba(255, 255, 255, 0.85); + --glass-text-tertiary: rgba(255, 255, 255, 0.7); + --glass-text-accent: rgb(96, 165, 250); + --glass-placeholder: rgba(255, 255, 255, 0.5); + --grid-pattern: rgba(255, 255, 255, 0.1); + --grid-opacity: 0.1; + --bg-gradient: linear-gradient(to bottom right, #1e293b, #7c3aed, #0f172a); + --bg-orb-1: rgba(147, 51, 234, 0.2); + --bg-orb-2: rgba(59, 130, 246, 0.2); + --bg-orb-3: rgba(99, 102, 241, 0.1); + --bg-orb-4: rgba(147, 51, 234, 0.15); + --bg-orb-5: rgba(59, 130, 246, 0.12); + + /* Premium Color Palette */ + --success-color: #34d399; + --success-bg: rgba(52, 211, 153, 0.15); + --success-border: rgba(52, 211, 153, 0.3); + --warning-color: #fbbf24; + --warning-bg: rgba(251, 191, 36, 0.15); + --warning-border: rgba(251, 191, 36, 0.3); + --error-color: #f87171; + --error-bg: rgba(248, 113, 113, 0.15); + --error-border: rgba(248, 113, 113, 0.3); + --premium-gold: #fcd34d; + --premium-gold-bg: rgba(252, 211, 77, 0.15); + --premium-gold-border: rgba(252, 211, 77, 0.3); + --content-white: rgba(255, 255, 255, 0.15); + --content-white-border: rgba(255, 255, 255, 0.25); + + /* Semantic UI Colors - Dark Theme */ + --ui-text-primary: #ffffff; + --ui-text-secondary: rgba(255, 255, 255, 0.85); + --ui-text-tertiary: rgba(255, 255, 255, 0.7); + --ui-text-muted: rgba(255, 255, 255, 0.5); + --ui-bg-primary: rgba(255, 255, 255, 0.1); + --ui-bg-secondary: rgba(255, 255, 255, 0.05); + --ui-bg-elevated: rgba(255, 255, 255, 0.15); + --ui-border-primary: rgba(255, 255, 255, 0.2); + --ui-border-secondary: rgba(255, 255, 255, 0.1); + --ui-shadow: rgba(0, 0, 0, 0.2); + --ui-bg-overlay: rgba(0, 0, 0, 0.6); + + /* Enhanced Polish Variables - Dark Theme */ + --glass-hover-bg-dark: rgba(255, 255, 255, 0.15); + --glass-ring-dark: rgba(255, 255, 255, 0.1); + --glass-shadow-dark: rgba(0, 0, 0, 0.3); + --glass-border-subtle: rgba(255, 255, 255, 0.05); + --avatar-bg-dark: rgba(30, 41, 59, 1); + --avatar-ring-dark: rgba(255, 255, 255, 0.1); +} + [data-theme="dark"] { - /* Dark theme - Default */ + /* Dark theme - explicit selector */ --glass-bg: rgba(255, 255, 255, 0.1); --glass-bg-lg: rgba(255, 255, 255, 0.1); --glass-bg-button: rgba(255, 255, 255, 0.1);