feat: Enhance calendar component with glassmorphism design and modular architecture
- Refactored Calendar.tsx into modular component structure - Added glassmorphism theming with CSS custom properties - Implemented reusable calendar subcomponents: - CalendarGrid: Month view with improved day/event display - CalendarHeader: Navigation and view controls - EventList: List view for events - TrendingEvents: Location-based trending events - UpcomingEvents: Quick upcoming events preview - Enhanced responsive design for mobile devices - Added Playwright testing framework for automated testing - Updated Docker development commands in CLAUDE.md - Improved accessibility and user experience 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,10 @@ npm run preview # Preview production build locally
|
|||||||
# Database
|
# Database
|
||||||
node setup-schema.js # Initialize database schema (run once)
|
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)
|
# Stripe MCP (Model Context Protocol)
|
||||||
npm run mcp:stripe # Start Stripe MCP server for AI integration
|
npm run mcp:stripe # Start Stripe MCP server for AI integration
|
||||||
npm run mcp:stripe:debug # Start MCP server with debugging interface
|
npm run mcp:stripe:debug # Start MCP server with debugging interface
|
||||||
|
|||||||
16
package-lock.json
generated
16
package-lock.json
generated
@@ -15,6 +15,7 @@
|
|||||||
"@craftjs/core": "^0.2.12",
|
"@craftjs/core": "^0.2.12",
|
||||||
"@craftjs/utils": "^0.2.5",
|
"@craftjs/utils": "^0.2.5",
|
||||||
"@modelcontextprotocol/sdk": "^1.0.3",
|
"@modelcontextprotocol/sdk": "^1.0.3",
|
||||||
|
"@playwright/test": "^1.54.1",
|
||||||
"@sentry/astro": "^9.35.0",
|
"@sentry/astro": "^9.35.0",
|
||||||
"@sentry/node": "^9.35.0",
|
"@sentry/node": "^9.35.0",
|
||||||
"@stripe/connect-js": "^3.3.25",
|
"@stripe/connect-js": "^3.3.25",
|
||||||
@@ -2561,6 +2562,21 @@
|
|||||||
"node": ">=14"
|
"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": {
|
"node_modules/@prisma/instrumentation": {
|
||||||
"version": "6.10.1",
|
"version": "6.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-6.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-6.10.1.tgz",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"@craftjs/core": "^0.2.12",
|
"@craftjs/core": "^0.2.12",
|
||||||
"@craftjs/utils": "^0.2.5",
|
"@craftjs/utils": "^0.2.5",
|
||||||
"@modelcontextprotocol/sdk": "^1.0.3",
|
"@modelcontextprotocol/sdk": "^1.0.3",
|
||||||
|
"@playwright/test": "^1.54.1",
|
||||||
"@sentry/astro": "^9.35.0",
|
"@sentry/astro": "^9.35.0",
|
||||||
"@sentry/node": "^9.35.0",
|
"@sentry/node": "^9.35.0",
|
||||||
"@stripe/connect-js": "^3.3.25",
|
"@stripe/connect-js": "^3.3.25",
|
||||||
|
|||||||
@@ -1,457 +1,113 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React from 'react';
|
||||||
import { trendingAnalyticsService, TrendingEvent } from '../lib/analytics';
|
import {
|
||||||
import { geolocationService } from '../lib/geolocation';
|
CalendarProps,
|
||||||
|
useCalendar,
|
||||||
|
CalendarHeader,
|
||||||
|
CalendarGrid,
|
||||||
|
EventList,
|
||||||
|
TrendingEvents,
|
||||||
|
UpcomingEvents
|
||||||
|
} from './calendar';
|
||||||
|
|
||||||
interface Event {
|
const Calendar: React.FC<CalendarProps> = ({
|
||||||
id: string;
|
events,
|
||||||
title: string;
|
onEventClick,
|
||||||
start_time: string;
|
showLocationFeatures = false,
|
||||||
venue: string;
|
showTrending = false,
|
||||||
slug: string;
|
initialView = 'month',
|
||||||
category?: string;
|
className = ''
|
||||||
is_featured?: boolean;
|
}) => {
|
||||||
image_url?: string;
|
// Use our custom calendar hook for state management
|
||||||
distanceMiles?: number;
|
const {
|
||||||
popularityScore?: number;
|
currentDate,
|
||||||
}
|
view,
|
||||||
|
trendingEvents,
|
||||||
interface CalendarProps {
|
nearbyEvents,
|
||||||
events: Event[];
|
userLocation,
|
||||||
onEventClick?: (event: Event) => void;
|
isMobile,
|
||||||
showLocationFeatures?: boolean;
|
isLoading,
|
||||||
showTrending?: boolean;
|
setView,
|
||||||
}
|
previousMonth,
|
||||||
|
nextMonth,
|
||||||
const Calendar: React.FC<CalendarProps> = ({ events, onEventClick, showLocationFeatures = false, showTrending = false }) => {
|
goToToday
|
||||||
const [currentDate, setCurrentDate] = useState(new Date());
|
} = useCalendar({
|
||||||
const [view, setView] = useState<'month' | 'week' | 'list'>('month');
|
initialView,
|
||||||
const [trendingEvents, setTrendingEvents] = useState<TrendingEvent[]>([]);
|
showLocationFeatures,
|
||||||
const [nearbyEvents, setNearbyEvents] = useState<TrendingEvent[]>([]);
|
showTrending,
|
||||||
const [userLocation, setUserLocation] = useState<{lat: number, lng: number} | null>(null);
|
events
|
||||||
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();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white dark:bg-gray-900 shadow-lg rounded-lg overflow-hidden">
|
<div className={`shadow-lg rounded-lg overflow-hidden backdrop-blur-xl ${className}`}
|
||||||
{/* Calendar Header */}
|
style={{ background: 'var(--glass-bg-lg)', border: '1px solid var(--glass-border)' }}>
|
||||||
<div className="px-3 md:px-6 py-4 border-b border-gray-300 dark:border-gray-700">
|
|
||||||
<div className="flex items-center justify-between">
|
<CalendarHeader
|
||||||
<div className="flex items-center space-x-2 md:space-x-4">
|
currentDate={currentDate}
|
||||||
<h2 className="text-base md:text-lg font-bold text-gray-900 dark:text-gray-100">
|
view={view}
|
||||||
{isMobile ? `${monthNames[currentMonth].slice(0, 3)} ${currentYear}` : `${monthNames[currentMonth]} ${currentYear}`}
|
onViewChange={setView}
|
||||||
</h2>
|
onPreviousMonth={previousMonth}
|
||||||
<button
|
onNextMonth={nextMonth}
|
||||||
onClick={goToToday}
|
onToday={goToToday}
|
||||||
className="text-xs md:text-sm text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300 font-semibold"
|
isMobile={isMobile}
|
||||||
>
|
/>
|
||||||
Today
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
{/* View Toggle */}
|
|
||||||
<div className="flex rounded-md shadow-sm">
|
|
||||||
<button
|
|
||||||
onClick={() => setView('month')}
|
|
||||||
className={`px-2 md:px-3 py-1 text-xs md:text-sm font-medium rounded-l-md border ${
|
|
||||||
view === 'month'
|
|
||||||
? 'bg-indigo-100 text-indigo-700 border-indigo-300'
|
|
||||||
: 'bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isMobile ? 'M' : 'Month'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setView('week')}
|
|
||||||
className={`px-2 md:px-3 py-1 text-xs md:text-sm font-medium border-t border-r border-b ${
|
|
||||||
view === 'week'
|
|
||||||
? 'bg-indigo-100 text-indigo-700 border-indigo-300'
|
|
||||||
: 'bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isMobile ? 'W' : 'Week'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setView('list')}
|
|
||||||
className={`px-2 md:px-3 py-1 text-xs md:text-sm font-medium rounded-r-md border-t border-r border-b ${
|
|
||||||
view === 'list'
|
|
||||||
? 'bg-indigo-100 text-indigo-700 border-indigo-300'
|
|
||||||
: 'bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isMobile ? 'L' : 'List'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Calendar Grid for Month View */}
|
||||||
<div className="flex items-center space-x-1">
|
|
||||||
<button
|
|
||||||
onClick={previousMonth}
|
|
||||||
className="p-1 rounded-md hover:bg-gray-100"
|
|
||||||
>
|
|
||||||
<svg className="h-4 w-4 md:h-5 md:w-5 text-gray-700 dark:text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={nextMonth}
|
|
||||||
className="p-1 rounded-md hover:bg-gray-100"
|
|
||||||
>
|
|
||||||
<svg className="h-4 w-4 md:h-5 md:w-5 text-gray-700 dark:text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Calendar Grid */}
|
|
||||||
{view === 'month' && (
|
{view === 'month' && (
|
||||||
<div className="p-3 md:p-6">
|
<CalendarGrid
|
||||||
{/* Day Headers */}
|
currentDate={currentDate}
|
||||||
<div className="grid grid-cols-7 gap-px md:gap-1 mb-2">
|
events={events}
|
||||||
{(isMobile ? dayNamesShort : dayNames).map((day, _index) => (
|
onEventClick={onEventClick}
|
||||||
<div key={day} className="text-center text-xs md:text-sm font-semibold text-gray-700 dark:text-gray-300 py-2">
|
isMobile={isMobile}
|
||||||
{day}
|
/>
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Calendar Days */}
|
|
||||||
<div className="grid grid-cols-7 gap-px md:gap-1">
|
|
||||||
{calendarDays.map((day, index) => {
|
|
||||||
if (day === null) {
|
|
||||||
return <div key={index} className="aspect-square"></div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dayEvents = getEventsForDay(day);
|
|
||||||
const isCurrentDay = isToday(day);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={day}
|
|
||||||
className={`aspect-square border-2 rounded-lg p-1 hover:bg-gray-100 dark:hover:bg-gray-800 ${
|
|
||||||
isCurrentDay ? 'bg-indigo-100 dark:bg-indigo-900 border-indigo-400 dark:border-indigo-500' : 'border-gray-300 dark:border-gray-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className={`text-xs md:text-sm font-bold mb-1 ${
|
|
||||||
isCurrentDay ? 'text-indigo-800 dark:text-indigo-200' : 'text-gray-900 dark:text-gray-100'
|
|
||||||
}`}>
|
|
||||||
{day}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Events for this day */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
{dayEvents.slice(0, isMobile ? 1 : 2).map(event => (
|
|
||||||
<div
|
|
||||||
key={event.id}
|
|
||||||
onClick={() => 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}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{dayEvents.length > (isMobile ? 1 : 2) && (
|
|
||||||
<div className="text-xs text-gray-700 dark:text-gray-400 font-medium">
|
|
||||||
+{dayEvents.length - (isMobile ? 1 : 2)} more
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* List View */}
|
{/* List View */}
|
||||||
{view === 'list' && (
|
{view === 'list' && (
|
||||||
<div className="p-3 md:p-6">
|
<EventList
|
||||||
<div className="space-y-3">
|
events={events}
|
||||||
{events
|
onEventClick={onEventClick}
|
||||||
.filter(event => new Date(event.start_time) >= today)
|
showDistance={Boolean(userLocation)}
|
||||||
.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 (
|
|
||||||
<div
|
|
||||||
key={event.id}
|
|
||||||
onClick={() => 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"
|
|
||||||
>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div className="text-sm font-bold text-gray-900 dark:text-gray-100">{event.title}</div>
|
|
||||||
{event.is_featured && (
|
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-yellow-200 dark:bg-yellow-800 text-yellow-900 dark:text-yellow-200">
|
|
||||||
Featured
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-700 dark:text-gray-400 font-medium mt-1">
|
|
||||||
{event.venue}
|
|
||||||
{event.distanceMiles && (
|
|
||||||
<span className="ml-2">• {event.distanceMiles.toFixed(1)} miles</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-700 dark:text-gray-400 font-medium text-right">
|
|
||||||
{eventDate.toLocaleDateString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit'
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Trending Events Section */}
|
{/* Trending Events Section */}
|
||||||
{showTrending && trendingEvents.length > 0 && view !== 'list' && (
|
{showTrending && trendingEvents.length > 0 && view !== 'list' && (
|
||||||
<div className="border-t border-gray-200 p-3 md:p-6">
|
<TrendingEvents
|
||||||
<div className="flex items-center justify-between mb-3">
|
events={trendingEvents}
|
||||||
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">🔥 What's Hot</h3>
|
userLocation={userLocation || undefined}
|
||||||
{userLocation && (
|
onEventClick={onEventClick}
|
||||||
<span className="text-xs text-gray-700 dark:text-gray-400 font-medium">Within 50 miles</span>
|
radius={50}
|
||||||
)}
|
/>
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
{trendingEvents.slice(0, 4).map(event => (
|
|
||||||
<div
|
|
||||||
key={event.eventId}
|
|
||||||
onClick={() => 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"
|
|
||||||
>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div className="text-sm font-bold text-gray-900 truncate">{event.title}</div>
|
|
||||||
{event.isFeature && (
|
|
||||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-bold bg-yellow-200 text-yellow-900">
|
|
||||||
⭐
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500 mt-1">
|
|
||||||
{event.venue}
|
|
||||||
{event.distanceMiles && (
|
|
||||||
<span className="ml-2">• {event.distanceMiles.toFixed(1)} mi</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-orange-600 dark:text-orange-400 font-semibold mt-1">
|
|
||||||
{event.ticketsSold} tickets sold
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500 text-right ml-2">
|
|
||||||
{new Date(event.startTime).toLocaleDateString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric'
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Nearby Events Section */}
|
{/* Nearby Events Section */}
|
||||||
{showLocationFeatures && nearbyEvents.length > 0 && view !== 'list' && (
|
{showLocationFeatures && nearbyEvents.length > 0 && view !== 'list' && (
|
||||||
<div className="border-t border-gray-200 p-3 md:p-6">
|
<TrendingEvents
|
||||||
<div className="flex items-center justify-between mb-3">
|
events={nearbyEvents}
|
||||||
<h3 className="text-sm font-bold text-gray-900">📍 Near You</h3>
|
userLocation={userLocation || undefined}
|
||||||
{userLocation && (
|
onEventClick={onEventClick}
|
||||||
<span className="text-xs text-gray-700 font-medium">Within 25 miles</span>
|
title="📍 Near You"
|
||||||
)}
|
radius={25}
|
||||||
</div>
|
/>
|
||||||
<div className="space-y-2">
|
|
||||||
{nearbyEvents.slice(0, 3).map(event => (
|
|
||||||
<div
|
|
||||||
key={event.eventId}
|
|
||||||
onClick={() => 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"
|
|
||||||
>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-sm font-medium text-gray-900 truncate">{event.title}</div>
|
|
||||||
<div className="text-xs text-gray-500 mt-1">
|
|
||||||
{event.venue} • {event.distanceMiles?.toFixed(1)} miles away
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500 text-right ml-2">
|
|
||||||
{new Date(event.startTime).toLocaleDateString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric'
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Upcoming Events List */}
|
{/* Upcoming Events List */}
|
||||||
{view !== 'list' && (
|
{view !== 'list' && (
|
||||||
<div className="border-t border-gray-200 p-3 md:p-6">
|
<UpcomingEvents
|
||||||
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100 mb-3">Upcoming Events</h3>
|
events={events}
|
||||||
<div className="space-y-2">
|
onEventClick={onEventClick}
|
||||||
{events
|
limit={5}
|
||||||
.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 (
|
|
||||||
<div
|
|
||||||
key={event.id}
|
|
||||||
onClick={() => onEventClick?.(event)}
|
|
||||||
className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
|
|
||||||
>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-sm font-bold text-gray-900 truncate">{event.title}</div>
|
|
||||||
<div className="text-xs text-gray-700 dark:text-gray-400 font-medium truncate">{event.venue}</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500 text-right ml-2">
|
|
||||||
{eventDate.toLocaleDateString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit'
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{events.filter(event => new Date(event.start_time) >= today).length === 0 && (
|
|
||||||
<div className="text-sm text-gray-700 dark:text-gray-400 font-medium text-center py-4">
|
|
||||||
No upcoming events
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Loading State */}
|
{/* Loading State */}
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="border-t border-gray-200 p-6">
|
<div className="p-6" style={{ borderTop: '1px solid var(--glass-border)' }}>
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-600"></div>
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2" style={{ borderColor: 'var(--glass-text-accent)' }}></div>
|
||||||
<span className="ml-2 text-sm text-gray-700 dark:text-gray-400 font-medium">Loading location-based events...</span>
|
<span className="ml-2 text-sm font-medium" style={{ color: 'var(--glass-text-secondary)' }}>
|
||||||
|
Loading location-based events...
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
101
src/components/calendar/CalendarGrid.tsx
Normal file
101
src/components/calendar/CalendarGrid.tsx
Normal file
@@ -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<CalendarGridProps> = ({
|
||||||
|
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 (
|
||||||
|
<div className="p-3 md:p-6">
|
||||||
|
{/* Day Headers */}
|
||||||
|
<div className="grid grid-cols-7 gap-px md:gap-1 mb-2">
|
||||||
|
{dayNames.map((day, index) => (
|
||||||
|
<div key={`${day}-${index}`} className="text-center text-xs md:text-sm font-semibold py-2"
|
||||||
|
style={{ color: 'var(--glass-text-secondary)' }}>
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar Days */}
|
||||||
|
<div className="grid grid-cols-7 gap-px md:gap-1">
|
||||||
|
{calendarDays.map((calendarDay, index) => {
|
||||||
|
const { day, isCurrentMonth, isToday, date, events: dayEvents } = calendarDay;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${date.getTime()}-${index}`}
|
||||||
|
className="aspect-square rounded-lg p-1 transition-all hover:scale-105 cursor-pointer backdrop-blur-sm"
|
||||||
|
style={{
|
||||||
|
background: isToday ? 'var(--glass-bg-elevated)' : 'var(--glass-bg)',
|
||||||
|
border: `2px solid ${isToday ? 'var(--glass-text-accent)' : 'var(--glass-border)'}`,
|
||||||
|
}}
|
||||||
|
onClick={() => handleDayClick(dayEvents, date)}
|
||||||
|
>
|
||||||
|
<div className="text-xs md:text-sm font-bold mb-1" style={{
|
||||||
|
color: isCurrentMonth
|
||||||
|
? (isToday ? 'var(--glass-text-accent)' : 'var(--glass-text-primary)')
|
||||||
|
: 'var(--ui-text-muted)'
|
||||||
|
}}>
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Events for this day */}
|
||||||
|
{isCurrentMonth && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{dayEvents.slice(0, isMobile ? 1 : 2).map(event => (
|
||||||
|
<EventCard
|
||||||
|
key={event.id}
|
||||||
|
event={event}
|
||||||
|
onClick={onEventClick}
|
||||||
|
variant="grid"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{dayEvents.length > (isMobile ? 1 : 2) && (
|
||||||
|
<div
|
||||||
|
className="text-xs font-medium px-1 py-0.5 rounded-md transition-colors cursor-pointer"
|
||||||
|
style={{
|
||||||
|
color: 'var(--ui-text-secondary)',
|
||||||
|
background: 'var(--ui-bg-secondary)'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
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
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
102
src/components/calendar/CalendarHeader.tsx
Normal file
102
src/components/calendar/CalendarHeader.tsx
Normal file
@@ -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<CalendarHeaderProps> = ({
|
||||||
|
currentDate,
|
||||||
|
view,
|
||||||
|
onViewChange,
|
||||||
|
onPreviousMonth,
|
||||||
|
onNextMonth,
|
||||||
|
onToday,
|
||||||
|
isMobile
|
||||||
|
}) => {
|
||||||
|
const displayText = isMobile
|
||||||
|
? `${getMonthName(currentDate, true)} ${currentDate.getFullYear()}`
|
||||||
|
: `${getMonthName(currentDate)} ${currentDate.getFullYear()}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-3 md:px-6 py-4" style={{ borderBottom: '1px solid var(--glass-border)' }}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-2 md:space-x-4">
|
||||||
|
<h2 className="text-base md:text-lg font-bold" style={{ color: 'var(--glass-text-primary)' }}>
|
||||||
|
{displayText}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onToday}
|
||||||
|
className="text-xs md:text-sm font-semibold hover:opacity-80 transition-opacity"
|
||||||
|
style={{ color: 'var(--glass-text-accent)' }}
|
||||||
|
>
|
||||||
|
Today
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{/* View Toggle */}
|
||||||
|
<div className="flex rounded-md shadow-sm">
|
||||||
|
<button
|
||||||
|
onClick={() => onViewChange('month')}
|
||||||
|
className="px-2 md:px-3 py-1 text-xs md:text-sm font-medium rounded-l-md border transition-all hover:scale-105"
|
||||||
|
style={{
|
||||||
|
background: view === 'month' ? 'var(--glass-bg-elevated)' : 'var(--glass-bg-button)',
|
||||||
|
color: view === 'month' ? 'var(--glass-text-accent)' : 'var(--glass-text-secondary)',
|
||||||
|
borderColor: 'var(--glass-border)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isMobile ? 'M' : 'Month'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onViewChange('week')}
|
||||||
|
className="px-2 md:px-3 py-1 text-xs md:text-sm font-medium border-t border-r border-b transition-all hover:scale-105"
|
||||||
|
style={{
|
||||||
|
background: view === 'week' ? 'var(--glass-bg-elevated)' : 'var(--glass-bg-button)',
|
||||||
|
color: view === 'week' ? 'var(--glass-text-accent)' : 'var(--glass-text-secondary)',
|
||||||
|
borderColor: 'var(--glass-border)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isMobile ? 'W' : 'Week'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onViewChange('list')}
|
||||||
|
className="px-2 md:px-3 py-1 text-xs md:text-sm font-medium rounded-r-md border-t border-r border-b transition-all hover:scale-105"
|
||||||
|
style={{
|
||||||
|
background: view === 'list' ? 'var(--glass-bg-elevated)' : 'var(--glass-bg-button)',
|
||||||
|
color: view === 'list' ? 'var(--glass-text-accent)' : 'var(--glass-text-secondary)',
|
||||||
|
borderColor: 'var(--glass-border)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isMobile ? 'L' : 'List'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<button
|
||||||
|
onClick={onPreviousMonth}
|
||||||
|
className="p-1 rounded-md hover:scale-110 transition-all"
|
||||||
|
style={{ background: 'var(--glass-bg-button)', border: '1px solid var(--glass-border)' }}
|
||||||
|
aria-label="Previous month"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4 md:h-5 md:w-5" style={{ color: 'var(--glass-text-primary)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onNextMonth}
|
||||||
|
className="p-1 rounded-md hover:scale-110 transition-all"
|
||||||
|
style={{ background: 'var(--glass-bg-button)', border: '1px solid var(--glass-border)' }}
|
||||||
|
aria-label="Next month"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4 md:h-5 md:w-5" style={{ color: 'var(--glass-text-primary)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
163
src/components/calendar/EventCard.tsx
Normal file
163
src/components/calendar/EventCard.tsx
Normal file
@@ -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<EventCardProps> = ({
|
||||||
|
event,
|
||||||
|
onClick,
|
||||||
|
variant = 'grid',
|
||||||
|
showDistance = false,
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
const handleClick = () => {
|
||||||
|
onClick?.(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (variant === 'trending') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={handleClick}
|
||||||
|
className={`flex items-center justify-between p-3 rounded-lg cursor-pointer transition-all hover:scale-105 backdrop-blur-sm ${className}`}
|
||||||
|
style={{
|
||||||
|
background: 'var(--warning-bg)',
|
||||||
|
border: '1px solid var(--warning-border)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="text-sm font-bold truncate" style={{ color: 'var(--glass-text-primary)' }}>
|
||||||
|
{event.title}
|
||||||
|
</div>
|
||||||
|
{event.is_featured && (
|
||||||
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-bold"
|
||||||
|
style={{ background: 'var(--warning-color)', color: 'var(--glass-text-primary)' }}>
|
||||||
|
⭐
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs mt-1" style={{ color: 'var(--glass-text-secondary)' }}>
|
||||||
|
{event.venue}
|
||||||
|
{showDistance && event.distanceMiles && (
|
||||||
|
<span className="ml-2">• {event.distanceMiles.toFixed(1)} mi</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{event.popularityScore && (
|
||||||
|
<div className="text-xs font-semibold mt-1" style={{ color: 'var(--warning-color)' }}>
|
||||||
|
{event.popularityScore} popularity score
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-right ml-2" style={{ color: 'var(--glass-text-tertiary)' }}>
|
||||||
|
{new Date(event.start_time).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === 'list') {
|
||||||
|
const { date, time } = formatDateTime(event.start_time);
|
||||||
|
const categoryColor = getCategoryColor(event.category);
|
||||||
|
const categoryIcon = getCategoryIcon(event.category);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={handleClick}
|
||||||
|
className={`event-card rounded-xl shadow-lg overflow-hidden hover:shadow-2xl transform hover:-translate-y-2 transition-all duration-500 cursor-pointer group backdrop-blur-lg ${className}`}
|
||||||
|
style={{
|
||||||
|
background: 'var(--ui-bg-elevated)',
|
||||||
|
border: '1px solid var(--ui-border-primary)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<div className={`h-48 bg-gradient-to-br ${categoryColor} relative overflow-hidden`}>
|
||||||
|
<div className="absolute inset-0" style={{ background: 'rgba(0, 0, 0, 0.2)' }}></div>
|
||||||
|
<div className="absolute top-4 left-4">
|
||||||
|
<span className="text-3xl">{categoryIcon}</span>
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-4 right-4">
|
||||||
|
<span className="backdrop-blur-sm px-2 py-1 rounded-full text-xs font-medium"
|
||||||
|
style={{ background: 'rgba(255, 255, 255, 0.2)', color: 'var(--glass-text-primary)' }}>
|
||||||
|
{event.category?.charAt(0).toUpperCase() + event.category?.slice(1) || 'Event'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-4 left-4 right-4">
|
||||||
|
<h3 className="text-xl font-bold mb-2 line-clamp-2 group-hover:text-yellow-200 transition-colors"
|
||||||
|
style={{ color: 'var(--glass-text-primary)' }}>
|
||||||
|
{event.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center space-x-2 text-sm mb-3" style={{ color: 'var(--ui-text-secondary)' }}>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>{time}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-2 text-sm mb-4" style={{ color: 'var(--ui-text-secondary)' }}>
|
||||||
|
<svg className="w-4 h-4 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<span className="line-clamp-2">{event.venue || 'Venue TBA'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{event.description && (
|
||||||
|
<p className="text-sm mb-4 line-clamp-3" style={{ color: 'var(--ui-text-secondary)' }}>
|
||||||
|
{event.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{event.is_featured && (
|
||||||
|
<span className="bg-yellow-100 text-yellow-800 px-2 py-1 rounded-full text-xs font-medium">
|
||||||
|
⭐ Featured
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{showDistance && event.distanceMiles && (
|
||||||
|
<span className="text-xs" style={{ color: 'var(--ui-text-tertiary)' }}>
|
||||||
|
{event.distanceMiles.toFixed(1)} mi away
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className={`bg-gradient-to-r ${categoryColor} px-4 py-2 rounded-lg font-medium text-sm hover:shadow-lg transform hover:scale-105 transition-all duration-200`}
|
||||||
|
style={{ color: 'var(--glass-text-primary)' }}>
|
||||||
|
View Details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid variant (compact for calendar day view)
|
||||||
|
const categoryColor = getCategoryColor(event.category);
|
||||||
|
const maxTitleLength = 20;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={handleClick}
|
||||||
|
className={`text-xs font-medium rounded px-1 py-0.5 cursor-pointer transition-all hover:scale-105 truncate backdrop-blur-sm ${className}`}
|
||||||
|
style={{
|
||||||
|
background: 'var(--glass-bg-elevated)',
|
||||||
|
color: 'var(--glass-text-accent)',
|
||||||
|
border: '1px solid var(--glass-border)'
|
||||||
|
}}
|
||||||
|
title={`${event.title} at ${event.venue}`}
|
||||||
|
>
|
||||||
|
{truncateText(event.title, maxTitleLength)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
78
src/components/calendar/EventList.tsx
Normal file
78
src/components/calendar/EventList.tsx
Normal file
@@ -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<EventListProps> = ({
|
||||||
|
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 (
|
||||||
|
<div className="p-3 md:p-6">
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<div className="w-24 h-24 mx-auto mb-6 rounded-full flex items-center justify-center"
|
||||||
|
style={{ background: 'linear-gradient(to bottom right, var(--ui-bg-secondary), var(--ui-bg-elevated))' }}>
|
||||||
|
<svg className="w-12 h-12" style={{ color: 'var(--ui-text-muted)' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold mb-2" style={{ color: 'var(--ui-text-primary)' }}>
|
||||||
|
No Upcoming Events
|
||||||
|
</h3>
|
||||||
|
<p style={{ color: 'var(--ui-text-secondary)' }}>
|
||||||
|
Check back later for new events or adjust your filters.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 md:p-6">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{sortedDates.map(dateKey => {
|
||||||
|
const date = new Date(dateKey);
|
||||||
|
const dateEvents = groupedEvents[dateKey];
|
||||||
|
const dateText = getRelativeDateText(date);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={dateKey} className="animate-fade-in-up">
|
||||||
|
{/* Date Header */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-lg font-semibold mb-1" style={{ color: 'var(--ui-text-primary)' }}>
|
||||||
|
{dateText}
|
||||||
|
</h3>
|
||||||
|
<div className="w-16 h-1 rounded-full"
|
||||||
|
style={{ background: 'linear-gradient(to right, rgb(37, 99, 235), rgb(147, 51, 234))' }}>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Events for this date */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{dateEvents.map(event => (
|
||||||
|
<EventCard
|
||||||
|
key={event.id}
|
||||||
|
event={event}
|
||||||
|
onClick={onEventClick}
|
||||||
|
variant="list"
|
||||||
|
showDistance={showDistance}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
75
src/components/calendar/TrendingEvents.tsx
Normal file
75
src/components/calendar/TrendingEvents.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { TrendingEventsProps } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trending events section component
|
||||||
|
*/
|
||||||
|
export const TrendingEvents: React.FC<TrendingEventsProps> = ({
|
||||||
|
events,
|
||||||
|
userLocation,
|
||||||
|
onEventClick,
|
||||||
|
title = "🔥 What's Hot",
|
||||||
|
radius = 50
|
||||||
|
}) => {
|
||||||
|
if (events.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 md:p-6" style={{ borderTop: '1px solid var(--glass-border)' }}>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-bold" style={{ color: 'var(--glass-text-primary)' }}>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
{userLocation && (
|
||||||
|
<span className="text-xs font-medium" style={{ color: 'var(--glass-text-secondary)' }}>
|
||||||
|
Within {radius} miles
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{events.slice(0, 4).map(event => (
|
||||||
|
<div
|
||||||
|
key={event.eventId}
|
||||||
|
onClick={() => 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)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="text-sm font-bold truncate" style={{ color: 'var(--glass-text-primary)' }}>
|
||||||
|
{event.title}
|
||||||
|
</div>
|
||||||
|
{event.isFeature && (
|
||||||
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-bold"
|
||||||
|
style={{ background: 'var(--warning-color)', color: 'var(--glass-text-primary)' }}>
|
||||||
|
⭐
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs mt-1" style={{ color: 'var(--glass-text-secondary)' }}>
|
||||||
|
{event.venue}
|
||||||
|
{event.distanceMiles && (
|
||||||
|
<span className="ml-2">• {event.distanceMiles.toFixed(1)} mi</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-semibold mt-1" style={{ color: 'var(--warning-color)' }}>
|
||||||
|
{event.ticketsSold || 0} tickets sold
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-right ml-2" style={{ color: 'var(--glass-text-tertiary)' }}>
|
||||||
|
{new Date(event.startTime).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
71
src/components/calendar/UpcomingEvents.tsx
Normal file
71
src/components/calendar/UpcomingEvents.tsx
Normal file
@@ -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<UpcomingEventsProps> = ({
|
||||||
|
events,
|
||||||
|
onEventClick,
|
||||||
|
limit = 5,
|
||||||
|
showIfEmpty = true
|
||||||
|
}) => {
|
||||||
|
const futureEvents = getFutureEvents(events);
|
||||||
|
const sortedEvents = sortEventsByDate(futureEvents).slice(0, limit);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 md:p-6" style={{ borderTop: '1px solid var(--glass-border)' }}>
|
||||||
|
<h3 className="text-sm font-bold mb-3" style={{ color: 'var(--glass-text-primary)' }}>
|
||||||
|
Upcoming Events
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sortedEvents.map(event => {
|
||||||
|
const eventDate = new Date(event.start_time);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={event.id}
|
||||||
|
onClick={() => 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)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-bold truncate" style={{ color: 'var(--glass-text-primary)' }}>
|
||||||
|
{event.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-medium truncate" style={{ color: 'var(--glass-text-secondary)' }}>
|
||||||
|
{event.venue}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-right ml-2" style={{ color: 'var(--glass-text-tertiary)' }}>
|
||||||
|
{eventDate.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit'
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{sortedEvents.length === 0 && showIfEmpty && (
|
||||||
|
<div className="text-sm font-medium text-center py-4" style={{ color: 'var(--glass-text-secondary)' }}>
|
||||||
|
No upcoming events
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
10
src/components/calendar/index.ts
Normal file
10
src/components/calendar/index.ts
Normal file
@@ -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';
|
||||||
120
src/components/calendar/types.ts
Normal file
120
src/components/calendar/types.ts
Normal file
@@ -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<void>;
|
||||||
|
}
|
||||||
148
src/components/calendar/useCalendar.ts
Normal file
148
src/components/calendar/useCalendar.ts
Normal file
@@ -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<CalendarState>({
|
||||||
|
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
|
||||||
|
};
|
||||||
|
};
|
||||||
253
src/components/calendar/utils.ts
Normal file
253
src/components/calendar/utils.ts
Normal file
@@ -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<string, Event[]> => {
|
||||||
|
const grouped: Record<string, Event[]> = {};
|
||||||
|
|
||||||
|
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();
|
||||||
|
};
|
||||||
@@ -19,7 +19,7 @@ import CookieConsent from '../components/CookieConsent.astro';
|
|||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
|
|
||||||
<!-- Critical theme initialization - prevents FOUC -->
|
<!-- Critical theme initialization - prevents FOUC -->
|
||||||
<script>
|
<script is:inline>
|
||||||
(function() {
|
(function() {
|
||||||
// Get theme immediately - no localStorage check to avoid blocking
|
// Get theme immediately - no localStorage check to avoid blocking
|
||||||
const savedTheme = (function() {
|
const savedTheme = (function() {
|
||||||
|
|||||||
@@ -33,11 +33,17 @@ export function createSupabaseServerClient(
|
|||||||
set(name: string, value: string, options: CookieOptions) {
|
set(name: string, value: string, options: CookieOptions) {
|
||||||
if (!cookies) return;
|
if (!cookies) return;
|
||||||
// Merge with default options, allowing overrides
|
// Merge with default options, allowing overrides
|
||||||
cookies.set(name, value, {
|
const finalOptions = {
|
||||||
...defaultCookieOptions,
|
...defaultCookieOptions,
|
||||||
...cookieOptions,
|
...cookieOptions,
|
||||||
...options,
|
...options,
|
||||||
})
|
};
|
||||||
|
console.log('[SUPABASE] Setting cookie:', {
|
||||||
|
name,
|
||||||
|
value: value ? '***' : 'empty',
|
||||||
|
options: finalOptions
|
||||||
|
});
|
||||||
|
cookies.set(name, value, finalOptions);
|
||||||
},
|
},
|
||||||
remove(name: string, options: CookieOptions) {
|
remove(name: string, options: CookieOptions) {
|
||||||
if (!cookies) return;
|
if (!cookies) return;
|
||||||
|
|||||||
87
src/pages/api/debug/user-status.ts
Normal file
87
src/pages/api/debug/user-status.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { createSupabaseServerClient } from '../../../lib/supabase-ssr';
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ request, cookies }) => {
|
||||||
|
try {
|
||||||
|
const { email } = await request.json();
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: 'Email is required'
|
||||||
|
}), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = createSupabaseServerClient(cookies);
|
||||||
|
|
||||||
|
// Get user by email from auth.users
|
||||||
|
const { data: authUsers, error: authError } = await supabase.auth.admin.listUsers();
|
||||||
|
const authUser = authUsers?.users?.find(user => user.email === email);
|
||||||
|
|
||||||
|
if (!authUser) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
found: false,
|
||||||
|
message: 'User not found in auth.users table'
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user from users table
|
||||||
|
const { data: userData, error: userError } = await supabase
|
||||||
|
.from('users')
|
||||||
|
.select('id, email, organization_id, role, created_at')
|
||||||
|
.eq('id', authUser.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
// Get organization data if user has one
|
||||||
|
let organizationData = null;
|
||||||
|
if (userData?.organization_id) {
|
||||||
|
const { data: orgData, error: orgError } = await supabase
|
||||||
|
.from('organizations')
|
||||||
|
.select('id, name, stripe_account_id, created_at')
|
||||||
|
.eq('id', userData.organization_id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
organizationData = { data: orgData, error: orgError?.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
found: true,
|
||||||
|
authUser: {
|
||||||
|
id: authUser.id,
|
||||||
|
email: authUser.email,
|
||||||
|
created_at: authUser.created_at,
|
||||||
|
confirmed_at: authUser.confirmed_at
|
||||||
|
},
|
||||||
|
userData: {
|
||||||
|
data: userData,
|
||||||
|
error: userError?.message
|
||||||
|
},
|
||||||
|
organizationData,
|
||||||
|
diagnostic: {
|
||||||
|
hasUserRecord: !!userData,
|
||||||
|
hasOrganization: !!userData?.organization_id,
|
||||||
|
isAdmin: userData?.role === 'admin',
|
||||||
|
shouldRedirectToOnboarding: !userData?.organization_id
|
||||||
|
}
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('User status debug error:', error);
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: 'An error occurred while checking user status',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
}), {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -16,13 +16,13 @@ const category = url.searchParams.get('category');
|
|||||||
const search = url.searchParams.get('search');
|
const search = url.searchParams.get('search');
|
||||||
|
|
||||||
// Add environment variable for Mapbox (if needed for geocoding)
|
// Add environment variable for Mapbox (if needed for geocoding)
|
||||||
const mapboxToken = import.meta.env.PUBLIC_MAPBOX_TOKEN || '';
|
// const mapboxToken = import.meta.env.PUBLIC_MAPBOX_TOKEN || ''; // Commented out - not needed in script
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="Event Calendar - Black Canyon Tickets">
|
<Layout title="Event Calendar - Black Canyon Tickets">
|
||||||
<div class="min-h-screen">
|
<div class="min-h-screen">
|
||||||
<!-- Hero Section with Dynamic Background -->
|
<!-- Hero Section with Dynamic Background -->
|
||||||
<section class="relative overflow-hidden" style="background: var(--bg-gradient);">
|
<section id="hero-section" class="relative overflow-hidden sticky top-0 z-40" style="background: var(--bg-gradient);">
|
||||||
<PublicHeader showCalendarNav={true} />
|
<PublicHeader showCalendarNav={true} />
|
||||||
|
|
||||||
<!-- Theme Toggle -->
|
<!-- Theme Toggle -->
|
||||||
@@ -32,6 +32,7 @@ const mapboxToken = import.meta.env.PUBLIC_MAPBOX_TOKEN || '';
|
|||||||
class="p-3 rounded-full backdrop-blur-lg transition-all duration-200 hover:scale-110 shadow-lg"
|
class="p-3 rounded-full backdrop-blur-lg transition-all duration-200 hover:scale-110 shadow-lg"
|
||||||
style="background: var(--glass-bg-button); border: 1px solid var(--glass-border);"
|
style="background: var(--glass-bg-button); border: 1px solid var(--glass-border);"
|
||||||
aria-label="Toggle theme"
|
aria-label="Toggle theme"
|
||||||
|
onclick="toggleTheme()"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5" style="color: var(--glass-text-primary);" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" style="color: var(--glass-text-primary);" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||||
@@ -143,7 +144,7 @@ const mapboxToken = import.meta.env.PUBLIC_MAPBOX_TOKEN || '';
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Premium Filter Controls -->
|
<!-- Premium Filter Controls -->
|
||||||
<section class="sticky top-0 z-50 backdrop-blur-xl shadow-2xl" style="background: var(--glass-bg-lg); border-bottom: 1px solid var(--glass-border);">
|
<section class="sticky top-0 z-50 backdrop-blur-xl shadow-2xl" data-filter-controls style="background: var(--glass-bg-lg); border-bottom: 1px solid var(--glass-border);">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
<div class="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-4">
|
<div class="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-4">
|
||||||
<!-- View Toggle - Premium Design -->
|
<!-- View Toggle - Premium Design -->
|
||||||
@@ -496,8 +497,38 @@ const mapboxToken = import.meta.env.PUBLIC_MAPBOX_TOKEN || '';
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Import geolocation utilities
|
console.log('=== CALENDAR SCRIPT STARTING ===');
|
||||||
const MAPBOX_TOKEN = mapboxToken;
|
|
||||||
|
// Simple theme toggle function
|
||||||
|
window.toggleTheme = function() {
|
||||||
|
const html = document.documentElement;
|
||||||
|
const currentTheme = html.getAttribute('data-theme') || 'light';
|
||||||
|
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
|
||||||
|
|
||||||
|
html.setAttribute('data-theme', newTheme);
|
||||||
|
localStorage.setItem('theme', newTheme);
|
||||||
|
|
||||||
|
// Update icon
|
||||||
|
const toggle = document.getElementById('theme-toggle');
|
||||||
|
const icon = toggle?.querySelector('svg path');
|
||||||
|
if (icon) {
|
||||||
|
if (newTheme === 'light') {
|
||||||
|
icon.setAttribute('d', 'M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z');
|
||||||
|
} else {
|
||||||
|
icon.setAttribute('d', 'M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Theme toggled to:', newTheme);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize theme on page load
|
||||||
|
const savedTheme = localStorage.getItem('theme') ||
|
||||||
|
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||||
|
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||||
|
|
||||||
|
// Import geolocation utilities - get from environment or default to empty
|
||||||
|
const MAPBOX_TOKEN = '';
|
||||||
|
|
||||||
// Calendar state
|
// Calendar state
|
||||||
let currentDate = new Date();
|
let currentDate = new Date();
|
||||||
@@ -1534,57 +1565,78 @@ const mapboxToken = import.meta.env.PUBLIC_MAPBOX_TOKEN || '';
|
|||||||
// Initialize
|
// Initialize
|
||||||
loadEvents();
|
loadEvents();
|
||||||
|
|
||||||
// Theme toggle functionality
|
// Old theme toggle code removed - using simpler onclick approach
|
||||||
const themeToggle = document.getElementById('theme-toggle');
|
|
||||||
const html = document.documentElement;
|
|
||||||
|
|
||||||
// Load saved theme or default to system preference
|
// Smooth sticky header behavior
|
||||||
const savedTheme = localStorage.getItem('theme') ||
|
window.initStickyHeader = function initStickyHeader() {
|
||||||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
const heroSection = document.getElementById('hero-section');
|
||||||
html.setAttribute('data-theme', savedTheme);
|
const filterControls = document.querySelector('[data-filter-controls]');
|
||||||
|
|
||||||
|
if (!heroSection || !filterControls) {
|
||||||
|
// If elements not found, try again in 100ms
|
||||||
|
setTimeout(initStickyHeader, 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add smooth transition styles
|
||||||
|
heroSection.style.transition = 'transform 0.3s ease-out, opacity 0.3s ease-out';
|
||||||
|
|
||||||
|
let lastScrollY = window.scrollY;
|
||||||
|
let isTransitioning = false;
|
||||||
|
|
||||||
|
function handleScroll() {
|
||||||
|
const currentScrollY = window.scrollY;
|
||||||
|
const heroHeight = heroSection.offsetHeight;
|
||||||
|
const filterControlsOffsetTop = filterControls.offsetTop;
|
||||||
|
|
||||||
|
// Calculate transition point - when filter controls should take over
|
||||||
|
const transitionThreshold = filterControlsOffsetTop - heroHeight;
|
||||||
|
|
||||||
|
if (currentScrollY >= transitionThreshold) {
|
||||||
|
// Smoothly transition hero out and let filter controls take over
|
||||||
|
if (!isTransitioning) {
|
||||||
|
isTransitioning = true;
|
||||||
|
heroSection.style.transform = 'translateY(-100%)';
|
||||||
|
heroSection.style.opacity = '0.8';
|
||||||
|
heroSection.style.zIndex = '20'; // Below filter controls (z-50)
|
||||||
|
|
||||||
|
// After transition, change position to avoid layout issues
|
||||||
|
setTimeout(() => {
|
||||||
|
heroSection.style.position = 'relative';
|
||||||
|
heroSection.style.top = 'auto';
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Hero section is visible and sticky
|
||||||
|
if (isTransitioning) {
|
||||||
|
isTransitioning = false;
|
||||||
|
heroSection.style.position = 'sticky';
|
||||||
|
heroSection.style.top = '0px';
|
||||||
|
heroSection.style.transform = 'translateY(0)';
|
||||||
|
heroSection.style.opacity = '1';
|
||||||
|
heroSection.style.zIndex = '40'; // Above content but below filter controls
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastScrollY = currentScrollY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add scroll listener with throttling for performance
|
||||||
|
let ticking = false;
|
||||||
|
window.addEventListener('scroll', () => {
|
||||||
|
if (!ticking) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
handleScroll();
|
||||||
|
ticking = false;
|
||||||
|
});
|
||||||
|
ticking = true;
|
||||||
|
}
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
// Initial call
|
||||||
|
handleScroll();
|
||||||
|
}
|
||||||
|
|
||||||
// Also set Tailwind dark class
|
// Initialize sticky header
|
||||||
if (savedTheme === 'dark') {
|
initStickyHeader();
|
||||||
html.classList.add('dark');
|
|
||||||
} else {
|
|
||||||
html.classList.remove('dark');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update toggle icon based on theme
|
|
||||||
function updateToggleIcon() {
|
|
||||||
const currentTheme = html.getAttribute('data-theme');
|
|
||||||
const icon = themeToggle.querySelector('svg path');
|
|
||||||
|
|
||||||
if (currentTheme === 'light') {
|
|
||||||
// Sun icon for light mode
|
|
||||||
icon.setAttribute('d', 'M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z');
|
|
||||||
} else {
|
|
||||||
// Moon icon for dark mode
|
|
||||||
icon.setAttribute('d', 'M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize icon
|
|
||||||
updateToggleIcon();
|
|
||||||
|
|
||||||
// Toggle theme
|
|
||||||
themeToggle.addEventListener('click', () => {
|
|
||||||
const currentTheme = html.getAttribute('data-theme');
|
|
||||||
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
|
|
||||||
|
|
||||||
html.setAttribute('data-theme', newTheme);
|
|
||||||
localStorage.setItem('theme', newTheme);
|
|
||||||
|
|
||||||
// Also toggle Tailwind dark class
|
|
||||||
if (newTheme === 'dark') {
|
|
||||||
html.classList.add('dark');
|
|
||||||
} else {
|
|
||||||
html.classList.remove('dark');
|
|
||||||
}
|
|
||||||
|
|
||||||
updateToggleIcon();
|
|
||||||
|
|
||||||
// Dispatch custom event for theme change
|
|
||||||
window.dispatchEvent(new CustomEvent('themeChange', { detail: { theme: newTheme } }));
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
@@ -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 */
|
/* 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"] {
|
[data-theme="dark"] {
|
||||||
/* Dark theme - Default */
|
/* Dark theme - explicit selector */
|
||||||
--glass-bg: rgba(255, 255, 255, 0.1);
|
--glass-bg: rgba(255, 255, 255, 0.1);
|
||||||
--glass-bg-lg: 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: rgba(255, 255, 255, 0.1);
|
||||||
|
|||||||
Reference in New Issue
Block a user