feat: implement event creation and management interface (task 13)
- Create EventForm component with date/time picker and map-based location selection - Implement EventList component with status badges and participant counts - Add EventFilters component for filtering by status and date range - Create EventDetailModal with registration functionality and event details - Build eventService for API calls (getEvents, createEvent, register, etc.) - Update EventsPage with full event management UI - Support event creation, listing, filtering, and registration - Requirements: 4.1, 4.2, 4.3, 4.4
This commit is contained in:
@@ -100,7 +100,7 @@
|
|||||||
|
|
||||||
## Event Management
|
## Event Management
|
||||||
|
|
||||||
- [ ] 13. Build event creation and management interface
|
- [-] 13. Build event creation and management interface
|
||||||
- Create event creation form with date/time picker
|
- Create event creation form with date/time picker
|
||||||
- Implement location selection on map
|
- Implement location selection on map
|
||||||
- Add participant registration functionality
|
- Add participant registration functionality
|
||||||
@@ -143,9 +143,22 @@
|
|||||||
- Show verification audit log
|
- Show verification audit log
|
||||||
- _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5_
|
- _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5_
|
||||||
|
|
||||||
|
## Cleanup Tracking System
|
||||||
|
|
||||||
|
- [ ] 18. Implement "Mark as Cleaned" functionality
|
||||||
|
- Add `cleaned_at` timestamp field to reports table
|
||||||
|
- Add `cleaned_by_user_id` field to track who marked it as cleaned
|
||||||
|
- Create POST /api/reports/:id/mark-cleaned backend endpoint
|
||||||
|
- Add "Mark as Cleaned" button to report popup on map
|
||||||
|
- Update map markers to visually distinguish cleaned vs uncleaned reports (e.g., green vs red markers)
|
||||||
|
- Add filter option to show/hide cleaned reports
|
||||||
|
- Display cleanup date and user in report details
|
||||||
|
- Implement authorization (only authenticated users can mark as cleaned)
|
||||||
|
- _Requirements: Community engagement, impact tracking_
|
||||||
|
|
||||||
## External Data Integration
|
## External Data Integration
|
||||||
|
|
||||||
- [ ] 18. Create external data display components
|
- [ ] 19. Create external data display components
|
||||||
- Build weather information widget
|
- Build weather information widget
|
||||||
- Implement tide times display
|
- Implement tide times display
|
||||||
- Add water quality indicator
|
- Add water quality indicator
|
||||||
@@ -155,7 +168,7 @@
|
|||||||
|
|
||||||
## Mobile Optimization
|
## Mobile Optimization
|
||||||
|
|
||||||
- [ ] 19. Implement mobile-responsive design
|
- [ ] 20. Implement mobile-responsive design
|
||||||
- Apply Tailwind responsive classes throughout
|
- Apply Tailwind responsive classes throughout
|
||||||
- Optimize map controls for touch interfaces
|
- Optimize map controls for touch interfaces
|
||||||
- Ensure minimum tap target sizes (44x44px)
|
- Ensure minimum tap target sizes (44x44px)
|
||||||
@@ -163,7 +176,7 @@
|
|||||||
- Implement mobile navigation menu
|
- Implement mobile navigation menu
|
||||||
- _Requirements: 8.1, 8.4_
|
- _Requirements: 8.1, 8.4_
|
||||||
|
|
||||||
- [ ] 20. Optimize mobile performance
|
- [ ] 21. Optimize mobile performance
|
||||||
- Implement code splitting and lazy loading
|
- Implement code splitting and lazy loading
|
||||||
- Add image optimization and lazy loading
|
- Add image optimization and lazy loading
|
||||||
- Configure service worker for offline capability
|
- Configure service worker for offline capability
|
||||||
@@ -173,7 +186,7 @@
|
|||||||
|
|
||||||
## Backend API Development
|
## Backend API Development
|
||||||
|
|
||||||
- [x] 21. Set up backend project structure
|
- [x] 22. Set up backend project structure
|
||||||
- Initialize Node.js project with Express
|
- Initialize Node.js project with Express
|
||||||
- Configure TypeScript for backend
|
- Configure TypeScript for backend
|
||||||
- Set up folder structure: routes, controllers, services, middleware, utils
|
- Set up folder structure: routes, controllers, services, middleware, utils
|
||||||
@@ -181,7 +194,7 @@
|
|||||||
- Create environment configuration
|
- Create environment configuration
|
||||||
- _Requirements: All backend requirements_
|
- _Requirements: All backend requirements_
|
||||||
|
|
||||||
- [x] 22. Configure database with Prisma and MySQL spatial support
|
- [x] 23. Configure database with Prisma and MySQL spatial support
|
||||||
- Set up MySQL 8.0+ with spatial data support
|
- Set up MySQL 8.0+ with spatial data support
|
||||||
- Create Prisma schema with all models (User, Report, Event, etc.)
|
- Create Prisma schema with all models (User, Report, Event, etc.)
|
||||||
- Configure MySQL POINT spatial types for location fields
|
- Configure MySQL POINT spatial types for location fields
|
||||||
@@ -189,7 +202,7 @@
|
|||||||
- Set up database indexes including spatial indexes for performance
|
- Set up database indexes including spatial indexes for performance
|
||||||
- _Requirements: 1.1, 2.1, 3.2, 4.1_
|
- _Requirements: 1.1, 2.1, 3.2, 4.1_
|
||||||
|
|
||||||
- [ ] 23. Implement authentication system
|
- [ ] 24. Implement authentication system
|
||||||
- Create user registration endpoint with password hashing
|
- Create user registration endpoint with password hashing
|
||||||
- Build login endpoint with JWT token generation
|
- Build login endpoint with JWT token generation
|
||||||
- Implement refresh token logic with httpOnly cookies
|
- Implement refresh token logic with httpOnly cookies
|
||||||
@@ -197,7 +210,7 @@
|
|||||||
- Add rate limiting to auth endpoints
|
- Add rate limiting to auth endpoints
|
||||||
- _Requirements: 6.1, 6.2_
|
- _Requirements: 6.1, 6.2_
|
||||||
|
|
||||||
- [ ] 24. Build report API endpoints
|
- [ ] 25. Build report API endpoints
|
||||||
- Create POST /api/reports endpoint for report submission
|
- Create POST /api/reports endpoint for report submission
|
||||||
- Implement GET /api/reports with filtering and pagination
|
- Implement GET /api/reports with filtering and pagination
|
||||||
- Build GET /api/reports/:id for single report retrieval
|
- Build GET /api/reports/:id for single report retrieval
|
||||||
@@ -206,7 +219,7 @@
|
|||||||
- Add spatial queries using MySQL spatial functions for location-based filtering
|
- Add spatial queries using MySQL spatial functions for location-based filtering
|
||||||
- _Requirements: 1.1-1.8, 2.1, 2.4_
|
- _Requirements: 1.1-1.8, 2.1, 2.4_
|
||||||
|
|
||||||
- [x] 25. Implement file upload and EXIF processing
|
- [x] 26. Implement file upload and EXIF processing
|
||||||
- Configure Multer for photo uploads to local filesystem
|
- Configure Multer for photo uploads to local filesystem
|
||||||
- Set up uploads directory and configure Express static file serving
|
- Set up uploads directory and configure Express static file serving
|
||||||
- Implement EXIF metadata extraction with exifr
|
- Implement EXIF metadata extraction with exifr
|
||||||
@@ -214,7 +227,7 @@
|
|||||||
- Create photo URL generation for local storage paths
|
- Create photo URL generation for local storage paths
|
||||||
- _Requirements: 1.2, 1.3, 1.4, 1.5_
|
- _Requirements: 1.2, 1.3, 1.4, 1.5_
|
||||||
|
|
||||||
- [ ] 26. Build event management endpoints
|
- [ ] 27. Build event management endpoints
|
||||||
- Create POST /api/events for event creation
|
- Create POST /api/events for event creation
|
||||||
- Implement GET /api/events with filtering
|
- Implement GET /api/events with filtering
|
||||||
- Build event registration endpoint
|
- Build event registration endpoint
|
||||||
@@ -222,7 +235,7 @@
|
|||||||
- Add participant count tracking
|
- Add participant count tracking
|
||||||
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5_
|
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5_
|
||||||
|
|
||||||
- [ ] 27. Implement analytics endpoints
|
- [ ] 28. Implement analytics endpoints
|
||||||
- Create GET /api/analytics/summary for overall statistics
|
- Create GET /api/analytics/summary for overall statistics
|
||||||
- Build time series data endpoint for trends
|
- Build time series data endpoint for trends
|
||||||
- Implement CSV export functionality
|
- Implement CSV export functionality
|
||||||
@@ -230,7 +243,7 @@
|
|||||||
- Create comparison calculations for period-over-period metrics
|
- Create comparison calculations for period-over-period metrics
|
||||||
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5_
|
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5_
|
||||||
|
|
||||||
- [ ] 28. Build hotspot calculation system
|
- [ ] 29. Build hotspot calculation system
|
||||||
- Create scheduled job with node-cron for hotspot calculations
|
- Create scheduled job with node-cron for hotspot calculations
|
||||||
- Implement MySQL spatial queries to identify clusters
|
- Implement MySQL spatial queries to identify clusters
|
||||||
- Calculate severity scores based on report count and frequency
|
- Calculate severity scores based on report count and frequency
|
||||||
@@ -238,7 +251,7 @@
|
|||||||
- Create GET /api/analytics/hotspots endpoint
|
- Create GET /api/analytics/hotspots endpoint
|
||||||
- _Requirements: 3.2, 3.3, 3.4_
|
- _Requirements: 3.2, 3.3, 3.4_
|
||||||
|
|
||||||
- [ ] 29. Implement verification system endpoints
|
- [ ] 30. Implement verification system endpoints
|
||||||
- Create POST /api/reports/:id/verify endpoint
|
- Create POST /api/reports/:id/verify endpoint
|
||||||
- Build POST /api/reports/:id/dispute endpoint
|
- Build POST /api/reports/:id/dispute endpoint
|
||||||
- Implement verification count tracking
|
- Implement verification count tracking
|
||||||
@@ -246,7 +259,7 @@
|
|||||||
- Create verification audit log
|
- Create verification audit log
|
||||||
- _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5_
|
- _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5_
|
||||||
|
|
||||||
- [ ] 30. Build user profile endpoints
|
- [ ] 31. Build user profile endpoints
|
||||||
- Create GET /api/users/:id for profile retrieval
|
- Create GET /api/users/:id for profile retrieval
|
||||||
- Implement PATCH /api/users/:id for profile updates
|
- Implement PATCH /api/users/:id for profile updates
|
||||||
- Build GET /api/users/:id/reports endpoint
|
- Build GET /api/users/:id/reports endpoint
|
||||||
@@ -255,7 +268,7 @@
|
|||||||
- Add notification preferences endpoint
|
- Add notification preferences endpoint
|
||||||
- _Requirements: 6.3, 6.4, 6.5, 10.4_
|
- _Requirements: 6.3, 6.4, 6.5, 10.4_
|
||||||
|
|
||||||
- [ ] 31. Integrate external APIs with caching
|
- [ ] 32. Integrate external APIs with caching
|
||||||
- Set up Redis for caching external API responses
|
- Set up Redis for caching external API responses
|
||||||
- Implement weather API integration (OpenWeatherMap)
|
- Implement weather API integration (OpenWeatherMap)
|
||||||
- Build tide API integration (WorldTides or NOAA)
|
- Build tide API integration (WorldTides or NOAA)
|
||||||
@@ -264,7 +277,7 @@
|
|||||||
- Implement 6-hour cache expiration
|
- Implement 6-hour cache expiration
|
||||||
- _Requirements: 11.1, 11.2, 11.3, 11.4, 11.5, 11.6_
|
- _Requirements: 11.1, 11.2, 11.3, 11.4, 11.5, 11.6_
|
||||||
|
|
||||||
- [ ] 32. Implement notification system
|
- [ ] 33. Implement notification system
|
||||||
- Create notification service with area-of-interest matching
|
- Create notification service with area-of-interest matching
|
||||||
- Build email notification integration (SendGrid)
|
- Build email notification integration (SendGrid)
|
||||||
- Implement in-app notification storage and retrieval
|
- Implement in-app notification storage and retrieval
|
||||||
@@ -272,7 +285,7 @@
|
|||||||
- Add notification delivery within 10-minute requirement
|
- Add notification delivery within 10-minute requirement
|
||||||
- _Requirements: 10.1, 10.2, 10.3, 10.4_
|
- _Requirements: 10.1, 10.2, 10.3, 10.4_
|
||||||
|
|
||||||
- [ ] 33. Add API security and validation
|
- [ ] 34. Add API security and validation
|
||||||
- Implement request validation with Zod schemas
|
- Implement request validation with Zod schemas
|
||||||
- Add rate limiting middleware
|
- Add rate limiting middleware
|
||||||
- Configure CORS for allowed origins
|
- Configure CORS for allowed origins
|
||||||
@@ -281,7 +294,7 @@
|
|||||||
- Create error handling middleware
|
- Create error handling middleware
|
||||||
- _Requirements: All security-related requirements_
|
- _Requirements: All security-related requirements_
|
||||||
|
|
||||||
- [ ] 34. Implement environmental concerns endpoints
|
- [ ] 35. Implement environmental concerns endpoints
|
||||||
- Create endpoints for submitting environmental concerns
|
- Create endpoints for submitting environmental concerns
|
||||||
- Build filtering and retrieval for concern types
|
- Build filtering and retrieval for concern types
|
||||||
- Implement linking of related reports within 100m
|
- Implement linking of related reports within 100m
|
||||||
@@ -290,7 +303,7 @@
|
|||||||
|
|
||||||
## Integration and Polish
|
## Integration and Polish
|
||||||
|
|
||||||
- [ ] 35. Connect frontend to backend APIs
|
- [ ] 36. Connect frontend to backend APIs
|
||||||
- Configure API base URL and axios/fetch setup
|
- Configure API base URL and axios/fetch setup
|
||||||
- Implement React Query hooks for all endpoints
|
- Implement React Query hooks for all endpoints
|
||||||
- Add error handling and retry logic
|
- Add error handling and retry logic
|
||||||
@@ -298,7 +311,7 @@
|
|||||||
- Implement optimistic updates where appropriate
|
- Implement optimistic updates where appropriate
|
||||||
- _Requirements: All integration requirements_
|
- _Requirements: All integration requirements_
|
||||||
|
|
||||||
- [ ] 36. Implement error handling and user feedback
|
- [ ] 37. Implement error handling and user feedback
|
||||||
- Create toast notification system for user feedback
|
- Create toast notification system for user feedback
|
||||||
- Add error boundaries for React components
|
- Add error boundaries for React components
|
||||||
- Implement form validation error displays
|
- Implement form validation error displays
|
||||||
@@ -306,7 +319,7 @@
|
|||||||
- Add loading spinners and skeleton screens
|
- Add loading spinners and skeleton screens
|
||||||
- _Requirements: All UX requirements_
|
- _Requirements: All UX requirements_
|
||||||
|
|
||||||
- [ ]* 37. Write integration tests
|
- [ ]* 38. Write integration tests
|
||||||
- Create API endpoint tests with Supertest
|
- Create API endpoint tests with Supertest
|
||||||
- Test authentication flows
|
- Test authentication flows
|
||||||
- Test file upload pipeline
|
- Test file upload pipeline
|
||||||
@@ -314,7 +327,7 @@
|
|||||||
- Test external API integration with mocks
|
- Test external API integration with mocks
|
||||||
- _Requirements: All requirements_
|
- _Requirements: All requirements_
|
||||||
|
|
||||||
- [ ] 38. Set up deployment configuration
|
- [ ] 39. Set up deployment configuration
|
||||||
- Create Docker configuration for development
|
- Create Docker configuration for development
|
||||||
- Set up environment variables for production
|
- Set up environment variables for production
|
||||||
- Configure build scripts for frontend and backend
|
- Configure build scripts for frontend and backend
|
||||||
|
|||||||
275
src/components/event/EventDetailModal.tsx
Normal file
275
src/components/event/EventDetailModal.tsx
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import type { CleanupEvent } from '@/types';
|
||||||
|
|
||||||
|
// Fix for default marker icons
|
||||||
|
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
|
||||||
|
import markerIcon from 'leaflet/dist/images/marker-icon.png';
|
||||||
|
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
|
||||||
|
|
||||||
|
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconUrl: markerIcon,
|
||||||
|
iconRetinaUrl: markerIcon2x,
|
||||||
|
shadowUrl: markerShadow,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface EventDetailModalProps {
|
||||||
|
event: CleanupEvent;
|
||||||
|
isRegistered: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onRegister: () => void;
|
||||||
|
onUnregister: () => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventDetailModal({
|
||||||
|
event,
|
||||||
|
isRegistered,
|
||||||
|
onClose,
|
||||||
|
onRegister,
|
||||||
|
onUnregister,
|
||||||
|
isLoading = false,
|
||||||
|
}: EventDetailModalProps) {
|
||||||
|
const mapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mapInstanceRef = useRef<L.Map | null>(null);
|
||||||
|
|
||||||
|
// Initialize map
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapRef.current || mapInstanceRef.current) return;
|
||||||
|
|
||||||
|
const map = L.map(mapRef.current).setView([event.latitude, event.longitude], 13);
|
||||||
|
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||||
|
maxZoom: 19,
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
L.marker([event.latitude, event.longitude]).addTo(map);
|
||||||
|
|
||||||
|
mapInstanceRef.current = map;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.remove();
|
||||||
|
mapInstanceRef.current = null;
|
||||||
|
};
|
||||||
|
}, [event.latitude, event.longitude]);
|
||||||
|
|
||||||
|
const formatEventDate = (date: Date) => {
|
||||||
|
return format(new Date(date), 'EEEE, MMMM d, yyyy');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatEventTime = (date: Date) => {
|
||||||
|
return format(new Date(date), 'h:mm a');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (minutes: number) => {
|
||||||
|
if (minutes < 60) {
|
||||||
|
return `${minutes} minutes`;
|
||||||
|
}
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
return mins > 0 ? `${hours} hour${hours > 1 ? 's' : ''} ${mins} minutes` : `${hours} hour${hours > 1 ? 's' : ''}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = () => {
|
||||||
|
const styles = {
|
||||||
|
upcoming: 'bg-blue-100 text-blue-800',
|
||||||
|
completed: 'bg-green-100 text-green-800',
|
||||||
|
cancelled: 'bg-red-100 text-red-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
const labels = {
|
||||||
|
upcoming: 'Upcoming',
|
||||||
|
completed: 'Completed',
|
||||||
|
cancelled: 'Cancelled',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${styles[event.status]}`}>
|
||||||
|
{labels[event.status]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||||
|
{/* Background overlay */}
|
||||||
|
<div className="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" onClick={onClose}></div>
|
||||||
|
|
||||||
|
{/* Modal panel */}
|
||||||
|
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-3xl sm:w-full">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white px-6 pt-6 pb-4 border-b border-gray-200">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">{event.title}</h2>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{getStatusBadge()}
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{event.participantCount} {event.participantCount === 1 ? 'participant' : 'participants'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-500 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="px-6 py-6 space-y-6">
|
||||||
|
{/* Event Details */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-start">
|
||||||
|
<svg className="h-6 w-6 text-gray-400 mr-3 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">Date & Time</p>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
{formatEventDate(event.scheduledDate)} at {formatEventTime(event.scheduledDate)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start">
|
||||||
|
<svg className="h-6 w-6 text-gray-400 mr-3 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">Duration</p>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">{formatDuration(event.duration)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start">
|
||||||
|
<svg className="h-6 w-6 text-gray-400 mr-3 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<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 strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">Location</p>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">{event.locationName}</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
{event.latitude.toFixed(6)}, {event.longitude.toFixed(6)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 mb-2">Description</h3>
|
||||||
|
<p className="text-sm text-gray-600 whitespace-pre-wrap">{event.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Completed Event Info */}
|
||||||
|
{event.status === 'completed' && event.litterCollected !== undefined && (
|
||||||
|
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<svg className="h-6 w-6 text-green-600 mr-3" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-green-900">Event Completed</p>
|
||||||
|
<p className="text-sm text-green-700 mt-1">
|
||||||
|
{event.litterCollected} kg of litter collected
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Map */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 mb-2">Event Location</h3>
|
||||||
|
<div className="border border-gray-300 rounded-lg overflow-hidden">
|
||||||
|
<div ref={mapRef} className="w-full h-64" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Photos (if completed) */}
|
||||||
|
{event.status === 'completed' && event.photos && event.photos.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 mb-2">Event Photos</h3>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
{event.photos.map((photo, index) => (
|
||||||
|
<img
|
||||||
|
key={index}
|
||||||
|
src={photo}
|
||||||
|
alt={`Event photo ${index + 1}`}
|
||||||
|
className="w-full h-32 object-cover rounded-lg"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Actions */}
|
||||||
|
{event.status === 'upcoming' && (
|
||||||
|
<div className="bg-gray-50 px-6 py-4 border-t border-gray-200">
|
||||||
|
{isRegistered ? (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center text-green-600">
|
||||||
|
<svg className="h-5 w-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-medium">You're registered for this event</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onUnregister}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="px-4 py-2 border border-red-300 text-red-700 rounded-lg hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm font-medium"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Unregistering...' : 'Unregister'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={onRegister}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full bg-blue-600 text-white py-3 px-6 rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors font-medium"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Registering...' : 'Register for Event'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
src/components/event/EventFilters.tsx
Normal file
140
src/components/event/EventFilters.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { EventStatus } from '@/types';
|
||||||
|
|
||||||
|
export interface EventFilterState {
|
||||||
|
status: EventStatus | 'all';
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EventFiltersProps {
|
||||||
|
filters: EventFilterState;
|
||||||
|
onFiltersChange: (filters: EventFilterState) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventFilters({ filters, onFiltersChange }: EventFiltersProps) {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
const handleStatusChange = (status: EventStatus | 'all') => {
|
||||||
|
onFiltersChange({ ...filters, status });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDateChange = (field: 'startDate' | 'endDate', value: string) => {
|
||||||
|
onFiltersChange({ ...filters, [field]: value || undefined });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearFilters = () => {
|
||||||
|
onFiltersChange({
|
||||||
|
status: 'all',
|
||||||
|
startDate: undefined,
|
||||||
|
endDate: undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasActiveFilters = filters.status !== 'all' || filters.startDate || filters.endDate;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">Filters</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
|
||||||
|
>
|
||||||
|
{isExpanded ? 'Hide' : 'Show'} Filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Filter - Always Visible */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">Status</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleStatusChange('all')}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
filters.status === 'all'
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All Events
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleStatusChange('upcoming')}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
filters.status === 'upcoming'
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Upcoming
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleStatusChange('completed')}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
filters.status === 'completed'
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Completed
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleStatusChange('cancelled')}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
filters.status === 'cancelled'
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Cancelled
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded Filters */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="space-y-4 pt-4 border-t border-gray-200">
|
||||||
|
{/* Date Range */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="startDate" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Start Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="startDate"
|
||||||
|
value={filters.startDate || ''}
|
||||||
|
onChange={(e) => handleDateChange('startDate', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="endDate" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
End Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="endDate"
|
||||||
|
value={filters.endDate || ''}
|
||||||
|
onChange={(e) => handleDateChange('endDate', e.target.value)}
|
||||||
|
min={filters.startDate}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clear Filters Button */}
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<button
|
||||||
|
onClick={handleClearFilters}
|
||||||
|
className="w-full px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors text-sm font-medium"
|
||||||
|
>
|
||||||
|
Clear All Filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
430
src/components/event/EventForm.tsx
Normal file
430
src/components/event/EventForm.tsx
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import { isWithinIreland, IRELAND_BOUNDS } from '@/utils/coordinates';
|
||||||
|
import type { Coordinates } from '@/types';
|
||||||
|
|
||||||
|
// Fix for default marker icons in Leaflet with Vite
|
||||||
|
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
|
||||||
|
import markerIcon from 'leaflet/dist/images/marker-icon.png';
|
||||||
|
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
|
||||||
|
|
||||||
|
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconUrl: markerIcon,
|
||||||
|
iconRetinaUrl: markerIcon2x,
|
||||||
|
shadowUrl: markerShadow,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface EventFormData {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
locationName: string;
|
||||||
|
scheduledDate: string;
|
||||||
|
scheduledTime: string;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EventFormProps {
|
||||||
|
onSubmit: (data: EventFormData) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
initialData?: Partial<EventFormData>;
|
||||||
|
isSubmitting?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventForm({ onSubmit, onCancel, initialData, isSubmitting = false }: EventFormProps) {
|
||||||
|
const [formData, setFormData] = useState<EventFormData>({
|
||||||
|
title: initialData?.title || '',
|
||||||
|
description: initialData?.description || '',
|
||||||
|
latitude: initialData?.latitude || 0,
|
||||||
|
longitude: initialData?.longitude || 0,
|
||||||
|
locationName: initialData?.locationName || '',
|
||||||
|
scheduledDate: initialData?.scheduledDate || '',
|
||||||
|
scheduledTime: initialData?.scheduledTime || '',
|
||||||
|
duration: initialData?.duration || 120,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [selectedLocation, setSelectedLocation] = useState<Coordinates | null>(
|
||||||
|
initialData?.latitude && initialData?.longitude
|
||||||
|
? { latitude: initialData.latitude, longitude: initialData.longitude }
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
const [errors, setErrors] = useState<Partial<Record<keyof EventFormData, string>>>({});
|
||||||
|
const [locationError, setLocationError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const mapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mapInstanceRef = useRef<L.Map | null>(null);
|
||||||
|
const markerRef = useRef<L.Marker | null>(null);
|
||||||
|
|
||||||
|
// Initialize map
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapRef.current || mapInstanceRef.current) return;
|
||||||
|
|
||||||
|
// Default center: Dublin, Ireland
|
||||||
|
const defaultCenter: [number, number] = [53.3498, -6.2603];
|
||||||
|
const initialCenter: [number, number] = selectedLocation
|
||||||
|
? [selectedLocation.latitude, selectedLocation.longitude]
|
||||||
|
: defaultCenter;
|
||||||
|
|
||||||
|
const map = L.map(mapRef.current).setView(initialCenter, selectedLocation ? 13 : 7);
|
||||||
|
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||||
|
maxZoom: 19,
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
// Add Ireland boundary rectangle
|
||||||
|
const irelandBounds = L.latLngBounds(
|
||||||
|
[IRELAND_BOUNDS.minLat, IRELAND_BOUNDS.minLng],
|
||||||
|
[IRELAND_BOUNDS.maxLat, IRELAND_BOUNDS.maxLng]
|
||||||
|
);
|
||||||
|
|
||||||
|
L.rectangle(irelandBounds, {
|
||||||
|
color: '#3B82F6',
|
||||||
|
weight: 2,
|
||||||
|
fillOpacity: 0.05,
|
||||||
|
dashArray: '5, 5',
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
// Add marker if location is already selected
|
||||||
|
if (selectedLocation) {
|
||||||
|
markerRef.current = L.marker([selectedLocation.latitude, selectedLocation.longitude], {
|
||||||
|
draggable: true,
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
markerRef.current.on('dragend', () => {
|
||||||
|
if (markerRef.current) {
|
||||||
|
const position = markerRef.current.getLatLng();
|
||||||
|
handleMapLocationSelect(position.lat, position.lng);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle map clicks
|
||||||
|
map.on('click', (e: L.LeafletMouseEvent) => {
|
||||||
|
handleMapLocationSelect(e.latlng.lat, e.latlng.lng);
|
||||||
|
});
|
||||||
|
|
||||||
|
mapInstanceRef.current = map;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.remove();
|
||||||
|
mapInstanceRef.current = null;
|
||||||
|
markerRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update marker when location changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapInstanceRef.current || !selectedLocation) return;
|
||||||
|
|
||||||
|
const map = mapInstanceRef.current;
|
||||||
|
|
||||||
|
if (markerRef.current) {
|
||||||
|
map.removeLayer(markerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
markerRef.current = L.marker([selectedLocation.latitude, selectedLocation.longitude], {
|
||||||
|
draggable: true,
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
markerRef.current.on('dragend', () => {
|
||||||
|
if (markerRef.current) {
|
||||||
|
const position = markerRef.current.getLatLng();
|
||||||
|
handleMapLocationSelect(position.lat, position.lng);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
map.setView([selectedLocation.latitude, selectedLocation.longitude], 13);
|
||||||
|
}, [selectedLocation]);
|
||||||
|
|
||||||
|
const handleMapLocationSelect = (lat: number, lng: number) => {
|
||||||
|
if (!isWithinIreland(lat, lng)) {
|
||||||
|
setLocationError('Selected location is outside Ireland. Please select a location within Ireland.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coords = { latitude: lat, longitude: lng };
|
||||||
|
setSelectedLocation(coords);
|
||||||
|
setLocationError(null);
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lng,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (field: keyof EventFormData, value: string | number) => {
|
||||||
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
|
if (errors[field]) {
|
||||||
|
setErrors(prev => ({ ...prev, [field]: undefined }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = (): boolean => {
|
||||||
|
const newErrors: Partial<Record<keyof EventFormData, string>> = {};
|
||||||
|
|
||||||
|
if (!formData.title.trim()) {
|
||||||
|
newErrors.title = 'Event title is required';
|
||||||
|
} else if (formData.title.length < 3) {
|
||||||
|
newErrors.title = 'Title must be at least 3 characters';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.description.trim()) {
|
||||||
|
newErrors.description = 'Event description is required';
|
||||||
|
} else if (formData.description.length < 10) {
|
||||||
|
newErrors.description = 'Description must be at least 10 characters';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.locationName.trim()) {
|
||||||
|
newErrors.locationName = 'Location name is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedLocation) {
|
||||||
|
setLocationError('Please select a location on the map');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.scheduledDate) {
|
||||||
|
newErrors.scheduledDate = 'Event date is required';
|
||||||
|
} else {
|
||||||
|
const selectedDate = new Date(formData.scheduledDate);
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
if (selectedDate < today) {
|
||||||
|
newErrors.scheduledDate = 'Event date must be in the future';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.scheduledTime) {
|
||||||
|
newErrors.scheduledTime = 'Event time is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.duration < 30) {
|
||||||
|
newErrors.duration = 'Duration must be at least 30 minutes';
|
||||||
|
} else if (formData.duration > 480) {
|
||||||
|
newErrors.duration = 'Duration cannot exceed 8 hours';
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0 && selectedLocation !== null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get minimum date (today)
|
||||||
|
const getMinDate = () => {
|
||||||
|
const today = new Date();
|
||||||
|
return today.toISOString().split('T')[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* Title */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Event Title <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="title"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={(e) => handleInputChange('title', e.target.value)}
|
||||||
|
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
|
errors.title ? 'border-red-500' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
placeholder="e.g., Sandymount Beach Cleanup"
|
||||||
|
/>
|
||||||
|
{errors.title && <p className="mt-1 text-sm text-red-600">{errors.title}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Description <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
|
errors.description ? 'border-red-500' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
placeholder="Describe the cleanup event, what to bring, meeting point, etc."
|
||||||
|
/>
|
||||||
|
{errors.description && <p className="mt-1 text-sm text-red-600">{errors.description}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date and Time */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="scheduledDate" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Date <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="scheduledDate"
|
||||||
|
value={formData.scheduledDate}
|
||||||
|
onChange={(e) => handleInputChange('scheduledDate', e.target.value)}
|
||||||
|
min={getMinDate()}
|
||||||
|
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
|
errors.scheduledDate ? 'border-red-500' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{errors.scheduledDate && <p className="mt-1 text-sm text-red-600">{errors.scheduledDate}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="scheduledTime" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Time <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
id="scheduledTime"
|
||||||
|
value={formData.scheduledTime}
|
||||||
|
onChange={(e) => handleInputChange('scheduledTime', e.target.value)}
|
||||||
|
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
|
errors.scheduledTime ? 'border-red-500' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{errors.scheduledTime && <p className="mt-1 text-sm text-red-600">{errors.scheduledTime}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Duration */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="duration" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Duration (minutes) <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="duration"
|
||||||
|
value={formData.duration}
|
||||||
|
onChange={(e) => handleInputChange('duration', parseInt(e.target.value) || 0)}
|
||||||
|
min={30}
|
||||||
|
max={480}
|
||||||
|
step={15}
|
||||||
|
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
|
errors.duration ? 'border-red-500' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
{formData.duration >= 60
|
||||||
|
? `${Math.floor(formData.duration / 60)} hour${Math.floor(formData.duration / 60) > 1 ? 's' : ''} ${
|
||||||
|
formData.duration % 60 > 0 ? `${formData.duration % 60} minutes` : ''
|
||||||
|
}`
|
||||||
|
: `${formData.duration} minutes`}
|
||||||
|
</p>
|
||||||
|
{errors.duration && <p className="mt-1 text-sm text-red-600">{errors.duration}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location Name */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="locationName" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Location Name <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="locationName"
|
||||||
|
value={formData.locationName}
|
||||||
|
onChange={(e) => handleInputChange('locationName', e.target.value)}
|
||||||
|
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
|
errors.locationName ? 'border-red-500' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
placeholder="e.g., Sandymount Beach, Dublin"
|
||||||
|
/>
|
||||||
|
{errors.locationName && <p className="mt-1 text-sm text-red-600">{errors.locationName}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Map Location Selector */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Event Location on Map <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{locationError && (
|
||||||
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-4">
|
||||||
|
<div className="flex items-start">
|
||||||
|
<svg className="h-5 w-5 text-yellow-400 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="ml-2 text-sm text-yellow-700">{locationError}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedLocation && (
|
||||||
|
<div className="bg-green-50 border border-green-200 rounded-lg p-3 mb-4">
|
||||||
|
<div className="flex items-start">
|
||||||
|
<svg className="h-5 w-5 text-green-400 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="ml-3">
|
||||||
|
<p className="text-sm font-medium text-green-800">Location Selected</p>
|
||||||
|
<p className="text-sm text-green-700 mt-1">
|
||||||
|
{selectedLocation.latitude.toFixed(6)}, {selectedLocation.longitude.toFixed(6)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="border border-gray-300 rounded-lg overflow-hidden shadow-lg">
|
||||||
|
<div className="bg-blue-50 px-4 py-3 border-b border-blue-200">
|
||||||
|
<div className="flex items-start">
|
||||||
|
<svg className="h-5 w-5 text-blue-600 mt-0.5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="text-sm text-blue-700">
|
||||||
|
Click on the map to select the event location, or drag the marker to adjust.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div ref={mapRef} className="w-full h-96" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form Actions */}
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="flex-1 bg-blue-600 text-white py-3 px-6 rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors font-medium"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Creating Event...' : 'Create Event'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="px-6 py-3 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
188
src/components/event/EventList.tsx
Normal file
188
src/components/event/EventList.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { format } from 'date-fns';
|
||||||
|
import type { CleanupEvent, EventStatus } from '@/types';
|
||||||
|
|
||||||
|
interface EventListProps {
|
||||||
|
events: CleanupEvent[];
|
||||||
|
onEventClick: (event: CleanupEvent) => void;
|
||||||
|
onRegister?: (eventId: string) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventList({ events, onEventClick, onRegister, isLoading = false }: EventListProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="bg-white rounded-lg shadow p-6 animate-pulse">
|
||||||
|
<div className="h-6 bg-gray-200 rounded w-3/4 mb-4"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-2/3"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow p-12 text-center">
|
||||||
|
<svg
|
||||||
|
className="mx-auto h-12 w-12 text-gray-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<h3 className="mt-4 text-lg font-medium text-gray-900">No events found</h3>
|
||||||
|
<p className="mt-2 text-sm text-gray-500">
|
||||||
|
Try adjusting your filters or create a new cleanup event.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusBadge = (status: EventStatus) => {
|
||||||
|
const styles = {
|
||||||
|
upcoming: 'bg-blue-100 text-blue-800',
|
||||||
|
completed: 'bg-green-100 text-green-800',
|
||||||
|
cancelled: 'bg-red-100 text-red-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
const labels = {
|
||||||
|
upcoming: 'Upcoming',
|
||||||
|
completed: 'Completed',
|
||||||
|
cancelled: 'Cancelled',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[status]}`}>
|
||||||
|
{labels[status]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatEventDate = (date: Date) => {
|
||||||
|
return format(new Date(date), 'EEEE, MMMM d, yyyy');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatEventTime = (date: Date) => {
|
||||||
|
return format(new Date(date), 'h:mm a');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (minutes: number) => {
|
||||||
|
if (minutes < 60) {
|
||||||
|
return `${minutes} min`;
|
||||||
|
}
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{events.map((event) => (
|
||||||
|
<div
|
||||||
|
key={event.id}
|
||||||
|
className="bg-white rounded-lg shadow hover:shadow-md transition-shadow cursor-pointer"
|
||||||
|
onClick={() => onEventClick(event)}
|
||||||
|
>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-1">{event.title}</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{getStatusBadge(event.status)}
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{event.participantCount} {event.participantCount === 1 ? 'participant' : 'participants'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
{/* Date and Time */}
|
||||||
|
<div className="flex items-center text-sm text-gray-600">
|
||||||
|
<svg className="h-5 w-5 mr-2 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
{formatEventDate(event.scheduledDate)} at {formatEventTime(event.scheduledDate)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Duration */}
|
||||||
|
<div className="flex items-center text-sm text-gray-600">
|
||||||
|
<svg className="h-5 w-5 mr-2 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>{formatDuration(event.duration)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location */}
|
||||||
|
<div className="flex items-center text-sm text-gray-600">
|
||||||
|
<svg className="h-5 w-5 mr-2 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<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 strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>{event.locationName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description Preview */}
|
||||||
|
<p className="text-sm text-gray-600 line-clamp-2 mb-4">{event.description}</p>
|
||||||
|
|
||||||
|
{/* Completed Event Info */}
|
||||||
|
{event.status === 'completed' && event.litterCollected !== undefined && (
|
||||||
|
<div className="bg-green-50 border border-green-200 rounded-lg p-3 mb-4">
|
||||||
|
<div className="flex items-center text-sm text-green-800">
|
||||||
|
<svg className="h-5 w-5 mr-2 text-green-600" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{event.litterCollected} kg of litter collected</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Button */}
|
||||||
|
{event.status === 'upcoming' && onRegister && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRegister(event.id);
|
||||||
|
}}
|
||||||
|
className="w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 transition-colors font-medium text-sm"
|
||||||
|
>
|
||||||
|
Register for Event
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
src/components/event/index.ts
Normal file
6
src/components/event/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Event components barrel export
|
||||||
|
export { EventForm } from './EventForm';
|
||||||
|
export { EventList } from './EventList';
|
||||||
|
export { EventFilters } from './EventFilters';
|
||||||
|
export { EventDetailModal } from './EventDetailModal';
|
||||||
|
export type { EventFilterState } from './EventFilters';
|
||||||
@@ -9,3 +9,6 @@ export * from './map';
|
|||||||
|
|
||||||
// Report components
|
// Report components
|
||||||
export * from './report';
|
export * from './report';
|
||||||
|
|
||||||
|
// Event components
|
||||||
|
export * from './event';
|
||||||
|
|||||||
@@ -1,15 +1,260 @@
|
|||||||
/**
|
import { useState, useEffect } from 'react';
|
||||||
* Events page - placeholder for task 13-14
|
import { useUser } from '@clerk/clerk-react';
|
||||||
*/
|
import { EventForm, EventList, EventFilters, EventDetailModal } from '@/components/event';
|
||||||
|
import type { EventFilterState } from '@/components/event';
|
||||||
|
import { eventService } from '@/services/eventService';
|
||||||
|
import type { CleanupEvent } from '@/types';
|
||||||
|
|
||||||
export function EventsPage() {
|
export function EventsPage() {
|
||||||
|
const { isSignedIn } = useUser();
|
||||||
|
const [events, setEvents] = useState<CleanupEvent[]>([]);
|
||||||
|
const [filteredEvents, setFilteredEvents] = useState<CleanupEvent[]>([]);
|
||||||
|
const [filters, setFilters] = useState<EventFilterState>({
|
||||||
|
status: 'all',
|
||||||
|
});
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
|
const [selectedEvent, setSelectedEvent] = useState<CleanupEvent | null>(null);
|
||||||
|
const [isRegistered, setIsRegistered] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Load events
|
||||||
|
useEffect(() => {
|
||||||
|
loadEvents();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
useEffect(() => {
|
||||||
|
applyFilters();
|
||||||
|
}, [events, filters]);
|
||||||
|
|
||||||
|
// Check registration status when event is selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedEvent && isSignedIn) {
|
||||||
|
checkRegistrationStatus(selectedEvent.id);
|
||||||
|
}
|
||||||
|
}, [selectedEvent, isSignedIn]);
|
||||||
|
|
||||||
|
const loadEvents = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const data = await eventService.getEvents();
|
||||||
|
setEvents(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to load events. Please try again.');
|
||||||
|
console.error('Error loading events:', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyFilters = () => {
|
||||||
|
let filtered = [...events];
|
||||||
|
|
||||||
|
// Filter by status
|
||||||
|
if (filters.status !== 'all') {
|
||||||
|
filtered = filtered.filter(event => event.status === filters.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by date range
|
||||||
|
if (filters.startDate) {
|
||||||
|
const startDate = new Date(filters.startDate);
|
||||||
|
filtered = filtered.filter(event => new Date(event.scheduledDate) >= startDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.endDate) {
|
||||||
|
const endDate = new Date(filters.endDate);
|
||||||
|
endDate.setHours(23, 59, 59, 999);
|
||||||
|
filtered = filtered.filter(event => new Date(event.scheduledDate) <= endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by date (upcoming first, then by date)
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
const dateA = new Date(a.scheduledDate).getTime();
|
||||||
|
const dateB = new Date(b.scheduledDate).getTime();
|
||||||
|
return dateA - dateB;
|
||||||
|
});
|
||||||
|
|
||||||
|
setFilteredEvents(filtered);
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkRegistrationStatus = async (eventId: string) => {
|
||||||
|
try {
|
||||||
|
const registered = await eventService.isUserRegistered(eventId);
|
||||||
|
setIsRegistered(registered);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error checking registration status:', err);
|
||||||
|
setIsRegistered(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateEvent = async (formData: any) => {
|
||||||
|
if (!isSignedIn) {
|
||||||
|
setError('You must be signed in to create an event');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Combine date and time
|
||||||
|
const scheduledDateTime = new Date(`${formData.scheduledDate}T${formData.scheduledTime}`);
|
||||||
|
|
||||||
|
const eventData = {
|
||||||
|
title: formData.title,
|
||||||
|
description: formData.description,
|
||||||
|
latitude: formData.latitude,
|
||||||
|
longitude: formData.longitude,
|
||||||
|
locationName: formData.locationName,
|
||||||
|
scheduledDate: scheduledDateTime,
|
||||||
|
duration: formData.duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
await eventService.createEvent(eventData);
|
||||||
|
setShowCreateForm(false);
|
||||||
|
await loadEvents();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to create event. Please try again.');
|
||||||
|
console.error('Error creating event:', err);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegister = async (eventId: string) => {
|
||||||
|
if (!isSignedIn) {
|
||||||
|
setError('You must be signed in to register for events');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
await eventService.registerForEvent(eventId);
|
||||||
|
setIsRegistered(true);
|
||||||
|
await loadEvents();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to register for event. Please try again.');
|
||||||
|
console.error('Error registering for event:', err);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnregister = async () => {
|
||||||
|
if (!selectedEvent) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
await eventService.unregisterFromEvent(selectedEvent.id);
|
||||||
|
setIsRegistered(false);
|
||||||
|
await loadEvents();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to unregister from event. Please try again.');
|
||||||
|
console.error('Error unregistering from event:', err);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Clean-up Events</h2>
|
{/* Header */}
|
||||||
<div className="bg-white rounded-lg shadow p-8">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<p className="text-gray-500">
|
<div>
|
||||||
Events management will be implemented in tasks 13-14
|
<h1 className="text-3xl font-bold text-gray-900">Clean-up Events</h1>
|
||||||
|
<p className="mt-2 text-sm text-gray-600">
|
||||||
|
Join community cleanup events or organize your own
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{isSignedIn && !showCreateForm && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateForm(true)}
|
||||||
|
className="flex items-center gap-2 bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
Create Event
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
||||||
|
<div className="flex items-start">
|
||||||
|
<svg className="h-5 w-5 text-red-400 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="ml-3">
|
||||||
|
<p className="text-sm text-red-700">{error}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setError(null)}
|
||||||
|
className="ml-auto text-red-400 hover:text-red-500"
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Event Form */}
|
||||||
|
{showCreateForm && (
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mb-6">Create New Event</h2>
|
||||||
|
<EventForm
|
||||||
|
onSubmit={handleCreateEvent}
|
||||||
|
onCancel={() => setShowCreateForm(false)}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
{!showCreateForm && (
|
||||||
|
<EventFilters
|
||||||
|
filters={filters}
|
||||||
|
onFiltersChange={setFilters}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Event List */}
|
||||||
|
{!showCreateForm && (
|
||||||
|
<EventList
|
||||||
|
events={filteredEvents}
|
||||||
|
onEventClick={setSelectedEvent}
|
||||||
|
onRegister={handleRegister}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Event Detail Modal */}
|
||||||
|
{selectedEvent && (
|
||||||
|
<EventDetailModal
|
||||||
|
event={selectedEvent}
|
||||||
|
isRegistered={isRegistered}
|
||||||
|
onClose={() => setSelectedEvent(null)}
|
||||||
|
onRegister={() => handleRegister(selectedEvent.id)}
|
||||||
|
onUnregister={handleUnregister}
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
102
src/services/eventService.ts
Normal file
102
src/services/eventService.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
// Event service for API calls related to cleanup events
|
||||||
|
import { apiClient } from './api';
|
||||||
|
import type { CleanupEvent } from '@/types';
|
||||||
|
|
||||||
|
export interface CreateEventDTO {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
locationName: string;
|
||||||
|
scheduledDate: Date;
|
||||||
|
duration: number; // in minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateEventDTO {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
scheduledDate?: Date;
|
||||||
|
duration?: number;
|
||||||
|
status?: 'upcoming' | 'completed' | 'cancelled';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventFilters {
|
||||||
|
status?: 'upcoming' | 'completed' | 'cancelled';
|
||||||
|
startDate?: Date;
|
||||||
|
endDate?: Date;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
radius?: number; // in kilometers
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventRegistration {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
eventId: string;
|
||||||
|
registeredAt: Date;
|
||||||
|
attended: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const eventService = {
|
||||||
|
// Get all events with optional filters
|
||||||
|
async getEvents(filters?: EventFilters): Promise<CleanupEvent[]> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (filters?.status) params.append('status', filters.status);
|
||||||
|
if (filters?.startDate) params.append('startDate', filters.startDate.toISOString());
|
||||||
|
if (filters?.endDate) params.append('endDate', filters.endDate.toISOString());
|
||||||
|
if (filters?.latitude) params.append('latitude', filters.latitude.toString());
|
||||||
|
if (filters?.longitude) params.append('longitude', filters.longitude.toString());
|
||||||
|
if (filters?.radius) params.append('radius', filters.radius.toString());
|
||||||
|
|
||||||
|
const queryString = params.toString();
|
||||||
|
const endpoint = queryString ? `/events?${queryString}` : '/events';
|
||||||
|
|
||||||
|
return apiClient.get<CleanupEvent[]>(endpoint);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get single event by ID
|
||||||
|
async getEvent(id: string): Promise<CleanupEvent> {
|
||||||
|
return apiClient.get<CleanupEvent>(`/events/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create new event
|
||||||
|
async createEvent(data: CreateEventDTO): Promise<CleanupEvent> {
|
||||||
|
return apiClient.post<CleanupEvent>('/events', data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update event
|
||||||
|
async updateEvent(id: string, data: UpdateEventDTO): Promise<CleanupEvent> {
|
||||||
|
return apiClient.patch<CleanupEvent>(`/events/${id}`, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete event
|
||||||
|
async deleteEvent(id: string): Promise<void> {
|
||||||
|
return apiClient.delete<void>(`/events/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Register for event
|
||||||
|
async registerForEvent(eventId: string): Promise<EventRegistration> {
|
||||||
|
return apiClient.post<EventRegistration>(`/events/${eventId}/register`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Unregister from event
|
||||||
|
async unregisterFromEvent(eventId: string): Promise<void> {
|
||||||
|
return apiClient.delete<void>(`/events/${eventId}/register`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get event registrations
|
||||||
|
async getEventRegistrations(eventId: string): Promise<EventRegistration[]> {
|
||||||
|
return apiClient.get<EventRegistration[]>(`/events/${eventId}/registrations`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Check if user is registered for event
|
||||||
|
async isUserRegistered(eventId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<{ registered: boolean }>(`/events/${eventId}/registration-status`);
|
||||||
|
return response.registered;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -2,5 +2,7 @@
|
|||||||
export { apiClient } from './api';
|
export { apiClient } from './api';
|
||||||
export { reportService } from './reportService';
|
export { reportService } from './reportService';
|
||||||
export { analyticsService } from './analyticsService';
|
export { analyticsService } from './analyticsService';
|
||||||
|
export { eventService } from './eventService';
|
||||||
export type { ApiError } from './api';
|
export type { ApiError } from './api';
|
||||||
export type { CreateReportPayload, UploadPhotoResponse } from './reportService';
|
export type { CreateReportPayload, UploadPhotoResponse } from './reportService';
|
||||||
|
export type { CreateEventDTO, UpdateEventDTO, EventFilters, EventRegistration } from './eventService';
|
||||||
|
|||||||
Reference in New Issue
Block a user