Compare commits
12 Commits
ec70baa498
...
7153e55f4d
| Author | SHA1 | Date | |
|---|---|---|---|
| 7153e55f4d | |||
| 9a0358d25e | |||
| b208eb7f63 | |||
| a953e63dd8 | |||
| 9d1f2e735e | |||
| 53496f77be | |||
| 73fc4e290f | |||
| 493acdaeb4 | |||
| 8ee17848a6 | |||
| 529cdd0c05 | |||
| 25f8c46d7f | |||
| c1f362a604 |
@@ -108,7 +108,7 @@
|
|||||||
- Create event detail page with participant count
|
- Create event detail page with participant count
|
||||||
- _Requirements: 4.1, 4.2, 4.3, 4.4_
|
- _Requirements: 4.1, 4.2, 4.3, 4.4_
|
||||||
|
|
||||||
- [ ] 14. Implement event completion workflow
|
- [x] 14. Implement event completion workflow
|
||||||
- Create event completion form for organizers
|
- Create event completion form for organizers
|
||||||
- Add litter collected quantity input
|
- Add litter collected quantity input
|
||||||
- Implement photo upload for cleanup results
|
- Implement photo upload for cleanup results
|
||||||
|
|||||||
@@ -343,4 +343,66 @@ export const eventController = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Complete event
|
||||||
|
async completeEvent(req: AuthRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const clerkId = req.userId;
|
||||||
|
if (!clerkId) {
|
||||||
|
return res.status(401).json({
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
message: 'User not authenticated',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = await findOrCreateUser(clerkId);
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const event = await eventService.getEventById(id);
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return res.status(404).json({
|
||||||
|
code: 'EVENT_NOT_FOUND',
|
||||||
|
message: 'Event not found',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is the organizer
|
||||||
|
if (event.organizerId !== userId) {
|
||||||
|
return res.status(403).json({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'Only the event organizer can complete this event',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { litterCollected, photos } = req.body;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (litterCollected === undefined || litterCollected === null) {
|
||||||
|
return res.status(400).json({
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
message: 'litterCollected is required',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const completedEvent = await eventService.completeEvent(id, {
|
||||||
|
litterCollected: parseFloat(litterCollected),
|
||||||
|
photos: photos || [],
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(completedEvent);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error completing event:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
code: 'COMPLETE_EVENT_ERROR',
|
||||||
|
message: 'Failed to complete event',
|
||||||
|
details: error.message,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ app.use(helmet({
|
|||||||
contentSecurityPolicy: false, // Disable CSP for development
|
contentSecurityPolicy: false, // Disable CSP for development
|
||||||
}));
|
}));
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
origin: [
|
||||||
|
'http://localhost:5173',
|
||||||
|
'http://localhost:5174',
|
||||||
|
process.env.CORS_ORIGIN || ''
|
||||||
|
].filter(Boolean),
|
||||||
credentials: true,
|
credentials: true,
|
||||||
}));
|
}));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|||||||
@@ -16,5 +16,6 @@ router.delete('/:id', requireAuth, eventController.deleteEvent);
|
|||||||
router.post('/:id/register', requireAuth, eventController.registerForEvent);
|
router.post('/:id/register', requireAuth, eventController.registerForEvent);
|
||||||
router.delete('/:id/register', requireAuth, eventController.unregisterFromEvent);
|
router.delete('/:id/register', requireAuth, eventController.unregisterFromEvent);
|
||||||
router.get('/:id/registration-status', requireAuth, eventController.getRegistrationStatus);
|
router.get('/:id/registration-status', requireAuth, eventController.getRegistrationStatus);
|
||||||
|
router.post('/:id/complete', requireAuth, eventController.completeEvent);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export const eventService = {
|
|||||||
organizer: {
|
organizer: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
clerkId: true,
|
||||||
username: true,
|
username: true,
|
||||||
firstName: true,
|
firstName: true,
|
||||||
lastName: true,
|
lastName: true,
|
||||||
@@ -79,6 +80,7 @@ export const eventService = {
|
|||||||
return events.map((event) => ({
|
return events.map((event) => ({
|
||||||
id: event.id,
|
id: event.id,
|
||||||
organizerId: event.organizerId,
|
organizerId: event.organizerId,
|
||||||
|
organizerClerkId: event.organizer.clerkId,
|
||||||
title: event.title,
|
title: event.title,
|
||||||
description: event.description,
|
description: event.description,
|
||||||
latitude: parseFloat(event.latitude.toString()),
|
latitude: parseFloat(event.latitude.toString()),
|
||||||
@@ -102,6 +104,7 @@ export const eventService = {
|
|||||||
organizer: {
|
organizer: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
clerkId: true,
|
||||||
username: true,
|
username: true,
|
||||||
firstName: true,
|
firstName: true,
|
||||||
lastName: true,
|
lastName: true,
|
||||||
@@ -121,6 +124,7 @@ export const eventService = {
|
|||||||
return {
|
return {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
organizerId: event.organizerId,
|
organizerId: event.organizerId,
|
||||||
|
organizerClerkId: event.organizer.clerkId,
|
||||||
title: event.title,
|
title: event.title,
|
||||||
description: event.description,
|
description: event.description,
|
||||||
latitude: parseFloat(event.latitude.toString()),
|
latitude: parseFloat(event.latitude.toString()),
|
||||||
@@ -324,4 +328,40 @@ export const eventService = {
|
|||||||
|
|
||||||
return !!registration;
|
return !!registration;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async completeEvent(eventId: string, data: { litterCollected: number; photos?: string[] }) {
|
||||||
|
const event = await prisma.event.update({
|
||||||
|
where: { id: eventId },
|
||||||
|
data: {
|
||||||
|
status: 'completed',
|
||||||
|
litterCollected: data.litterCollected,
|
||||||
|
photos: data.photos || [],
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
registrations: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
organizerId: event.organizerId,
|
||||||
|
title: event.title,
|
||||||
|
description: event.description,
|
||||||
|
latitude: parseFloat(event.latitude.toString()),
|
||||||
|
longitude: parseFloat(event.longitude.toString()),
|
||||||
|
locationName: event.locationName,
|
||||||
|
scheduledDate: event.scheduledDate,
|
||||||
|
duration: event.duration,
|
||||||
|
status: event.status || 'upcoming',
|
||||||
|
participantCount: event._count.registrations,
|
||||||
|
litterCollected: event.litterCollected ? parseFloat(event.litterCollected.toString()) : undefined,
|
||||||
|
photos: event.photos ? (Array.isArray(event.photos) ? event.photos : []) : undefined,
|
||||||
|
createdAt: event.createdAt,
|
||||||
|
updatedAt: event.updatedAt,
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -60,7 +60,10 @@ function ApiInitializer({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<ClerkProvider publishableKey={clerkPubKey}>
|
<ClerkProvider
|
||||||
|
publishableKey={clerkPubKey}
|
||||||
|
afterSignOutUrl="/"
|
||||||
|
>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ApiInitializer>
|
<ApiInitializer>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
|||||||
126
src/components/event/EventCompletionForm.tsx
Normal file
126
src/components/event/EventCompletionForm.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { PhotoUpload, type PhotoMetadata } from '@/components/report/PhotoUpload';
|
||||||
|
|
||||||
|
interface EventCompletionFormProps {
|
||||||
|
eventId: string;
|
||||||
|
onSubmit: (data: { litterCollected: number; photos: string[] }) => Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventCompletionForm({
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
isLoading = false,
|
||||||
|
}: EventCompletionFormProps) {
|
||||||
|
const [litterCollected, setLitterCollected] = useState<string>('');
|
||||||
|
const [photos, setPhotos] = useState<PhotoMetadata[]>([]);
|
||||||
|
const [errors, setErrors] = useState<{ litterCollected?: string }>({});
|
||||||
|
|
||||||
|
const validateForm = (): boolean => {
|
||||||
|
const newErrors: { litterCollected?: string } = {};
|
||||||
|
|
||||||
|
if (!litterCollected || litterCollected.trim() === '') {
|
||||||
|
newErrors.litterCollected = 'Please enter the amount of litter collected';
|
||||||
|
} else {
|
||||||
|
const value = parseFloat(litterCollected);
|
||||||
|
if (isNaN(value) || value < 0) {
|
||||||
|
newErrors.litterCollected = 'Please enter a valid positive number';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// In a real implementation, we would upload photos to the server
|
||||||
|
// For now, we'll just pass the preview URLs
|
||||||
|
const photoUrls = photos.map(photo => photo.preview);
|
||||||
|
|
||||||
|
await onSubmit({
|
||||||
|
litterCollected: parseFloat(litterCollected),
|
||||||
|
photos: photoUrls,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error completing event:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePhotosChange = (photoMetadata: PhotoMetadata[]) => {
|
||||||
|
setPhotos(photoMetadata);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Complete Event</h3>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* Litter Collected Input */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="litterCollected" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Litter Collected (kg) *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="litterCollected"
|
||||||
|
step="0.1"
|
||||||
|
min="0"
|
||||||
|
value={litterCollected}
|
||||||
|
onChange={(e) => setLitterCollected(e.target.value)}
|
||||||
|
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
|
errors.litterCollected ? 'border-red-500' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
placeholder="e.g., 25.5"
|
||||||
|
/>
|
||||||
|
{errors.litterCollected && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.litterCollected}</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
Enter the total weight of litter collected during the event
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Photo Upload */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Event Photos (Optional)
|
||||||
|
</label>
|
||||||
|
<PhotoUpload
|
||||||
|
onPhotosChange={handlePhotosChange}
|
||||||
|
maxFiles={5}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
Upload photos from the cleanup event (max 5 photos)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-1 bg-green-600 text-white py-2 px-4 rounded-lg hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors font-medium"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Completing...' : 'Complete Event'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-1 bg-gray-100 text-gray-700 py-2 px-4 rounded-lg hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import type { CleanupEvent } from '@/types';
|
import type { CleanupEvent } from '@/types';
|
||||||
|
import { EventCompletionForm } from './EventCompletionForm';
|
||||||
|
|
||||||
// Fix for default marker icons
|
// Fix for default marker icons
|
||||||
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
|
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
|
||||||
@@ -19,22 +20,33 @@ L.Icon.Default.mergeOptions({
|
|||||||
interface EventDetailModalProps {
|
interface EventDetailModalProps {
|
||||||
event: CleanupEvent;
|
event: CleanupEvent;
|
||||||
isRegistered: boolean;
|
isRegistered: boolean;
|
||||||
|
isOrganizer: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onRegister: () => void;
|
onRegister: () => void;
|
||||||
onUnregister: () => void;
|
onUnregister: () => void;
|
||||||
|
onComplete: (data: { litterCollected: number; photos: string[] }) => Promise<void>;
|
||||||
|
onCancel?: () => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EventDetailModal({
|
export function EventDetailModal({
|
||||||
event,
|
event,
|
||||||
isRegistered,
|
isRegistered,
|
||||||
|
isOrganizer,
|
||||||
onClose,
|
onClose,
|
||||||
onRegister,
|
onRegister,
|
||||||
onUnregister,
|
onUnregister,
|
||||||
|
onComplete,
|
||||||
|
onCancel,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
}: EventDetailModalProps) {
|
}: EventDetailModalProps) {
|
||||||
const mapRef = useRef<HTMLDivElement>(null);
|
const mapRef = useRef<HTMLDivElement>(null);
|
||||||
const mapInstanceRef = useRef<L.Map | null>(null);
|
const mapInstanceRef = useRef<L.Map | null>(null);
|
||||||
|
const [showCompletionForm, setShowCompletionForm] = useState(false);
|
||||||
|
|
||||||
|
// Check if event date has passed
|
||||||
|
const hasEventPassed = new Date(event.scheduledDate) < new Date();
|
||||||
|
const canComplete = isOrganizer && event.status === 'upcoming' && hasEventPassed;
|
||||||
|
|
||||||
// Initialize map
|
// Initialize map
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -96,14 +108,14 @@ export function EventDetailModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
<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">
|
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0">
|
||||||
{/* Background overlay */}
|
{/* Background overlay */}
|
||||||
<div className="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" onClick={onClose}></div>
|
<div className="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" onClick={onClose}></div>
|
||||||
|
|
||||||
{/* Modal panel */}
|
{/* 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">
|
<div className="relative inline-block align-bottom bg-white rounded-lg text-left shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-3xl sm:w-full max-h-[90vh] overflow-hidden flex flex-col z-10">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-white px-6 pt-6 pb-4 border-b border-gray-200">
|
<div className="bg-white px-6 pt-6 pb-4 border-b border-gray-200 flex-shrink-0">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">{event.title}</h2>
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">{event.title}</h2>
|
||||||
@@ -125,119 +137,179 @@ export function EventDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content - Scrollable */}
|
||||||
<div className="px-6 py-6 space-y-6">
|
<div className="overflow-y-auto flex-1 bg-white">
|
||||||
{/* Event Details */}
|
<div className="px-6 py-6 space-y-6">
|
||||||
<div className="space-y-4">
|
{/* Event Details */}
|
||||||
<div className="flex items-start">
|
<div className="space-y-4">
|
||||||
<svg className="h-6 w-6 text-gray-400 mr-3 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<div className="flex items-start">
|
||||||
<path
|
<svg className="h-6 w-6 text-gray-400 mr-3 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
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
|
<path
|
||||||
fillRule="evenodd"
|
strokeLinecap="round"
|
||||||
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"
|
strokeLinejoin="round"
|
||||||
clipRule="evenodd"
|
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>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-green-900">Event Completed</p>
|
<p className="text-sm font-medium text-gray-900">Date & Time</p>
|
||||||
<p className="text-sm text-green-700 mt-1">
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
{event.litterCollected} kg of litter collected
|
{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 flex-shrink-0" 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 flex-shrink-0" 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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Map */}
|
{/* Description */}
|
||||||
<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>
|
<div>
|
||||||
<h3 className="text-sm font-medium text-gray-900 mb-2">Event Photos</h3>
|
<h3 className="text-sm font-medium text-gray-900 mb-2">Description</h3>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
<p className="text-sm text-gray-600 whitespace-pre-wrap">{event.description}</p>
|
||||||
{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>
|
||||||
)}
|
|
||||||
|
{/* 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 flex-shrink-0" 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Map - Temporarily removed for debugging */}
|
||||||
|
{/* <div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 mb-2">Event Location Map</h3>
|
||||||
|
<div className="border border-gray-300 rounded-lg overflow-hidden">
|
||||||
|
<div ref={mapRef} className="w-full h-64" />
|
||||||
|
</div>
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Completion Form for Organizers */}
|
||||||
|
{canComplete && showCompletionForm && (
|
||||||
|
<div className="px-6 pb-6">
|
||||||
|
<EventCompletionForm
|
||||||
|
eventId={event.id}
|
||||||
|
onSubmit={async (data) => {
|
||||||
|
await onComplete(data);
|
||||||
|
setShowCompletionForm(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => setShowCompletionForm(false)}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Footer Actions */}
|
{/* Footer Actions */}
|
||||||
{event.status === 'upcoming' && (
|
{event.status === 'upcoming' && (
|
||||||
<div className="bg-gray-50 px-6 py-4 border-t border-gray-200">
|
<div className="bg-gray-50 px-6 py-4 border-t border-gray-200 flex-shrink-0">
|
||||||
{isRegistered ? (
|
{canComplete ? (
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCompletionForm(!showCompletionForm)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-1 bg-green-600 text-white py-3 px-6 rounded-lg hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors font-medium"
|
||||||
|
>
|
||||||
|
{showCompletionForm ? 'Hide Completion Form' : 'Complete Event'}
|
||||||
|
</button>
|
||||||
|
{onCancel && (
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="px-6 py-3 border border-red-300 text-red-700 rounded-lg hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Cancel Event
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : isOrganizer && !hasEventPassed ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<svg className="h-5 w-5 text-blue-600 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm text-blue-800">
|
||||||
|
You can complete this event after {format(new Date(event.scheduledDate), 'MMM d, yyyy')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{onCancel && (
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full px-6 py-3 border border-red-300 text-red-700 rounded-lg hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Cancel Event
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : isRegistered ? (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center text-green-600">
|
<div className="flex items-center text-green-600">
|
||||||
<svg className="h-5 w-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
<svg className="h-5 w-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export function EventList({ events, onEventClick, onRegister, isLoading = false
|
|||||||
{events.map((event) => (
|
{events.map((event) => (
|
||||||
<div
|
<div
|
||||||
key={event.id}
|
key={event.id}
|
||||||
className="bg-white rounded-lg shadow hover:shadow-md transition-shadow cursor-pointer"
|
className="bg-white rounded-lg shadow hover:shadow-lg transition-all cursor-pointer border-2 border-transparent hover:border-blue-200"
|
||||||
onClick={() => onEventClick(event)}
|
onClick={() => onEventClick(event)}
|
||||||
>
|
>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
@@ -168,18 +168,30 @@ export function EventList({ events, onEventClick, onRegister, isLoading = false
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action Button */}
|
{/* Action Area */}
|
||||||
{event.status === 'upcoming' && onRegister && (
|
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={() => onEventClick(event)}
|
||||||
e.stopPropagation();
|
className="text-blue-600 hover:text-blue-700 font-medium text-sm flex items-center gap-1"
|
||||||
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
|
View Details
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
|
{event.status === 'upcoming' && onRegister && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRegister(event.id);
|
||||||
|
}}
|
||||||
|
className="bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 transition-colors font-medium text-sm"
|
||||||
|
>
|
||||||
|
Quick Register
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ export { EventForm } from './EventForm';
|
|||||||
export { EventList } from './EventList';
|
export { EventList } from './EventList';
|
||||||
export { EventFilters } from './EventFilters';
|
export { EventFilters } from './EventFilters';
|
||||||
export { EventDetailModal } from './EventDetailModal';
|
export { EventDetailModal } from './EventDetailModal';
|
||||||
|
export { EventCompletionForm } from './EventCompletionForm';
|
||||||
export type { EventFilterState } from './EventFilters';
|
export type { EventFilterState } from './EventFilters';
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { eventService } from '@/services/eventService';
|
|||||||
import type { CleanupEvent } from '@/types';
|
import type { CleanupEvent } from '@/types';
|
||||||
|
|
||||||
export function EventsPage() {
|
export function EventsPage() {
|
||||||
const { isSignedIn } = useUser();
|
const { isSignedIn, user } = useUser();
|
||||||
const [events, setEvents] = useState<CleanupEvent[]>([]);
|
const [events, setEvents] = useState<CleanupEvent[]>([]);
|
||||||
const [filteredEvents, setFilteredEvents] = useState<CleanupEvent[]>([]);
|
const [filteredEvents, setFilteredEvents] = useState<CleanupEvent[]>([]);
|
||||||
const [filters, setFilters] = useState<EventFilterState>({
|
const [filters, setFilters] = useState<EventFilterState>({
|
||||||
@@ -15,6 +15,7 @@ export function EventsPage() {
|
|||||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
const [selectedEvent, setSelectedEvent] = useState<CleanupEvent | null>(null);
|
const [selectedEvent, setSelectedEvent] = useState<CleanupEvent | null>(null);
|
||||||
const [isRegistered, setIsRegistered] = useState(false);
|
const [isRegistered, setIsRegistered] = useState(false);
|
||||||
|
const [isOrganizer, setIsOrganizer] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -29,12 +30,16 @@ export function EventsPage() {
|
|||||||
applyFilters();
|
applyFilters();
|
||||||
}, [events, filters]);
|
}, [events, filters]);
|
||||||
|
|
||||||
// Check registration status when event is selected
|
// Check registration status and organizer status when event is selected
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedEvent && isSignedIn) {
|
if (selectedEvent && isSignedIn) {
|
||||||
checkRegistrationStatus(selectedEvent.id);
|
checkRegistrationStatus(selectedEvent.id);
|
||||||
|
// Check if current user is the organizer by comparing Clerk IDs
|
||||||
|
setIsOrganizer(user?.id === selectedEvent.organizerClerkId);
|
||||||
|
} else {
|
||||||
|
setIsOrganizer(false);
|
||||||
}
|
}
|
||||||
}, [selectedEvent, isSignedIn]);
|
}, [selectedEvent, isSignedIn, user]);
|
||||||
|
|
||||||
const loadEvents = async () => {
|
const loadEvents = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -161,6 +166,44 @@ export function EventsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCompleteEvent = async (data: { litterCollected: number; photos: string[] }) => {
|
||||||
|
if (!selectedEvent) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
await eventService.completeEvent(selectedEvent.id, data);
|
||||||
|
setSelectedEvent(null);
|
||||||
|
await loadEvents();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to complete event. Please try again.');
|
||||||
|
console.error('Error completing event:', err);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEvent = async () => {
|
||||||
|
if (!selectedEvent) return;
|
||||||
|
|
||||||
|
if (!confirm('Are you sure you want to cancel this event? This action cannot be undone.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
await eventService.updateEvent(selectedEvent.id, { status: 'cancelled' });
|
||||||
|
setSelectedEvent(null);
|
||||||
|
await loadEvents();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to cancel event. Please try again.');
|
||||||
|
console.error('Error cancelling 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">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -249,9 +292,12 @@ export function EventsPage() {
|
|||||||
<EventDetailModal
|
<EventDetailModal
|
||||||
event={selectedEvent}
|
event={selectedEvent}
|
||||||
isRegistered={isRegistered}
|
isRegistered={isRegistered}
|
||||||
|
isOrganizer={isOrganizer}
|
||||||
onClose={() => setSelectedEvent(null)}
|
onClose={() => setSelectedEvent(null)}
|
||||||
onRegister={() => handleRegister(selectedEvent.id)}
|
onRegister={() => handleRegister(selectedEvent.id)}
|
||||||
onUnregister={handleUnregister}
|
onUnregister={handleUnregister}
|
||||||
|
onComplete={handleCompleteEvent}
|
||||||
|
onCancel={isOrganizer ? handleCancelEvent : undefined}
|
||||||
isLoading={isSubmitting}
|
isLoading={isSubmitting}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -99,4 +99,9 @@ export const eventService = {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Complete event
|
||||||
|
async completeEvent(eventId: string, data: { litterCollected: number; photos?: string[] }): Promise<CleanupEvent> {
|
||||||
|
return apiClient.post<CleanupEvent>(`/events/${eventId}/complete`, data);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export interface Report {
|
|||||||
export interface CleanupEvent {
|
export interface CleanupEvent {
|
||||||
id: string;
|
id: string;
|
||||||
organizerId: string;
|
organizerId: string;
|
||||||
|
organizerClerkId?: string; // Clerk ID of the organizer for permission checks
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
latitude: number;
|
latitude: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user