Compare commits

..

19 Commits

Author SHA1 Message Date
c23b93bef2 Reduce printable QR code page size to fit on single page 2025-11-20 19:37:50 +00:00
2dcd1ce78d Add printable QR code feature for event check-in 2025-11-20 19:30:59 +00:00
4baa2892b9 Add email prompt when users without email register for events 2025-11-20 19:24:27 +00:00
57b7cd2308 feat: auto-register walk-in attendees during check-in
- Automatically register users who scan QR code but aren't pre-registered
- Mark walk-in registrations with wasWalkIn flag
- Show different success messages for walk-ins vs pre-registered attendees
- Check in walk-ins immediately upon registration
- Remove 'not registered' error - now handles walk-ins gracefully
- Update both QR scan and manual check-in to support walk-ins
2025-11-20 19:10:29 +00:00
2102f053da feat: add comprehensive camera troubleshooting guide
- Add collapsible troubleshooting section with device-specific instructions
- Detect iOS/iPhone and show specific Settings app instructions
- Detect Safari and show Safari-specific permission steps
- Show Chrome/general browser instructions for other browsers
- Include common issues and solutions
- Highlight manual check-in alternative
- Auto-detect user's device/browser for relevant instructions
2025-11-20 19:04:28 +00:00
cc01e9adc8 feat: improve camera permission handling for QR scanner
- Add pre-check for camera devices to trigger permission prompt
- Add helpful info message explaining camera permission requirement
- Implement fallback to try default camera if facingMode constraint fails
- Better error messages for different camera access scenarios
- Log errors to console for debugging
- Handle NotAllowedError, NotFoundError, NotReadableError, OverconstrainedError
2025-11-20 19:02:33 +00:00
31af9bd083 fix: improve QR code check-in with manual fallback and better error handling
- Add manual check-in buttons for each attendee in the list
- Add manual QR code data input as fallback for camera issues
- Include clerkId in EventAttendee user object for proper check-in
- Improve camera error messages with helpful tips
- Fix TypeScript warnings
- Better UX for organizers with multiple check-in options
2025-11-20 19:00:09 +00:00
e793a8e3ea feat: implement QR code check-in system for event attendees
- Add UserQRCode component to display unique QR code on user profile
- Add QRCodeScanner component for mobile camera-based scanning
- Create EventCheckInPage for organizers to scan attendee QR codes
- Add backend endpoint POST /api/events/:id/checkin for check-in
- Add check-in button to EventDetailModal for organizers
- Install qrcode.react and html5-qrcode dependencies
- Implement real-time attendee list updates
- Add organizer permission validation
- Prevent duplicate check-ins
- Mobile-optimized with back camera support
2025-11-20 18:52:04 +00:00
cbafe85694 startup options 2025-11-20 18:39:37 +00:00
4f06b1fe50 Add equipment management to events
- Add equipmentProvided and requiredItems fields to Event model
- Update event creation form with equipment section
- Allow organizers to specify if equipment is provided or BYO
- Display required items list in event details
- Update database schema and run migration
2025-11-18 19:43:22 +00:00
c1f8c00728 Remove mock hotspot data from MapPage 2025-11-18 19:20:23 +00:00
a10e6329fe Filter out cancelled events from map display 2025-11-18 19:18:54 +00:00
ab06cc8afd Sort completed/cancelled events by most recent first
- Upcoming events: ascending order (next event first)
- Completed/Cancelled events: descending order (most recent first)
2025-11-18 19:09:28 +00:00
9eb1d3389f Improve event list layout: prioritize upcoming events and compact card design
- Sort events with upcoming first, then completed/cancelled at bottom
- Redesign event cards with horizontal layout to reduce vertical space
- Move event details (date, time, duration, location, participants) into single row
- Improve mobile responsiveness with flex-wrap
- Keep quick register button visible for upcoming events
2025-11-18 19:08:34 +00:00
3a6db92699 Add total litter collected display to user profile
- Updated backend userService to calculate total litter collected across all attended events
- Added totalLitterCollected field to UserProfile interface
- Added new statistics card showing total litter collected in kg
- Updated profile grid layout to accommodate 5 statistics cards
- Displays litter collected with 1 decimal place precision
2025-11-18 19:03:43 +00:00
3d7b84f376 feat: Add email field to profile edit form
- Add email input field to profile edit form
- Display current email or 'No email address set' message
- Show helpful hint when adding email for the first time
- Update backend to accept email in profile updates
- Add email uniqueness validation
- Allow users to add/update/remove email address
- Support empty string to remove email (converts to null)

Users can now add or update their email address from the profile page, enabling them to receive email notifications.
2025-11-18 18:22:03 +00:00
d1a06699e5 fix: Auto-create users on profile access and make email nullable in types
- Update getUserProfile, getUserReports, getUserEvents, and getUserActivity to call findOrCreateUser for Clerk IDs
- This ensures users are automatically created in database when accessing their profile for the first time
- Update User type to have email as 'string | null' instead of 'string'
- Fixes 404 errors when new users try to access their profile

This resolves the issue where new users signing up without email would get 404 errors when accessing their profile page.
2025-11-18 18:15:33 +00:00
d71c994f0f fix: Resolve TypeScript errors and apply database migration for email-optional
- Add Prisma import for JsonNull filter
- Fix async/await for calculateDistance function
- Install mysql2 for database operations
- Apply ALTER TABLE migration to make email nullable in database
- Verify all tests pass successfully

Tests confirm:
- Users can be created without email
- Notifications work for users without email
- Email sending gracefully skipped when no email present
- Database queries correctly filter by email presence
2025-11-18 18:13:20 +00:00
d87ec20714 docs: Add quick start guide for email-optional authentication 2025-11-18 18:07:05 +00:00
46 changed files with 3822 additions and 264 deletions

View File

@@ -0,0 +1,214 @@
# Event Attendee Tracking & Contributions Feature
## Overview
Added comprehensive attendee tracking and individual contribution management for cleanup events. Organizers can now mark who attended, track individual litter collection amounts, and add notes about each volunteer's contribution.
## Database Changes
### Schema Updates
- Added `litter_collected` (DECIMAL 10,2) to `event_registrations` table
- Added `contribution_note` (TEXT) to `event_registrations` table
- Migration: `20251118183315_add_attendee_contributions`
### New Type Definitions
```typescript
interface EventAttendee {
id: string;
userId: string;
eventId: string;
registeredAt: Date;
attended: boolean;
litterCollected?: number;
contributionNote?: string;
user: {
id: string;
username: string;
firstName?: string;
lastName?: string;
avatarUrl?: string;
};
}
```
## Backend API Endpoints
### New Endpoints
1. **GET** `/api/events/:id/attendees` - Get all attendees for an event
2. **PATCH** `/api/events/:id/attendees/:attendeeId` - Update single attendee status
3. **POST** `/api/events/:id/attendees/bulk` - Bulk update attendees
### Updated Endpoints
- **POST** `/api/events/:id/complete` - Now accepts optional `attendees` array
## Frontend Components
### 1. AttendeeManagement Component
**Location:** `src/components/event/AttendeeManagement.tsx`
**Features:**
- View all registered attendees
- Mark who attended with checkboxes
- Track individual litter collection (kg)
- Add contribution notes for each volunteer
- Real-time summary stats (registered, attended, total litter)
- Bulk save changes
**Usage:**
```tsx
<AttendeeManagement
eventId={eventId}
onClose={handleClose}
onSave={handleSave}
isLoading={isLoading}
/>
```
### 2. CompletedEventDetails Component
**Location:** `src/components/event/CompletedEventDetails.tsx`
**Features:**
- Display event completion summary
- Show top contributors with rankings (🥇🥈🥉)
- List all attendees with avatars
- Display event photos in grid
- Expandable contributor list
**Usage:**
```tsx
<CompletedEventDetails
eventId={event.id}
totalLitterCollected={event.litterCollected}
photos={event.photos}
/>
```
### 3. Updated EventCompletionForm
**Location:** `src/components/event/EventCompletionForm.tsx`
**New Flow:**
1. **Step 1: Manage Attendees** - Mark attendance and individual contributions
2. **Step 2: Event Summary** - Add photos and confirm total litter collected
3. Auto-calculates total litter from individual contributions
## Event Completion Workflow
### For Organizers:
1. **After Event Date Passes:**
- "Complete Event" button appears in event detail modal
2. **Step 1 - Manage Attendees:**
- View all registered participants
- Check boxes for who actually attended
- Enter litter collected by each volunteer (kg)
- Add optional notes about contributions
- See real-time totals
3. **Step 2 - Event Summary:**
- Total litter auto-calculated from attendees
- Upload event photos (max 5)
- Review and submit
4. **After Completion:**
- Event status changes to "completed"
- Attendee data saved
- Event marked with completion badge
### For Viewing Completed Events:
**Displays:**
- Total litter collected
- Number of volunteers
- Top contributors with rankings
- All attendees list
- Event photos
- Individual contribution notes
## Service Layer Updates
### Backend (`server/src/services/eventService.ts`)
- `getEventAttendees(eventId)` - Fetch attendees with user details
- `updateAttendeeStatus(eventId, userId, data)` - Update single attendee
- `bulkUpdateAttendees(eventId, attendees)` - Bulk update multiple attendees
### Frontend (`src/services/eventService.ts`)
- Updated `completeEvent()` to handle attendee data
- Automatically calls bulk update before completing event
## UI/UX Features
### Attendee Management Table
- Sortable by name
- Avatar display
- Inline editing
- Real-time validation
- Change tracking (shows count of unsaved changes)
### Completed Event Display
- Green success badge
- Medal icons for top 3 contributors
- Expandable lists
- Photo gallery with click-to-enlarge
- Responsive grid layouts
### Summary Statistics
- Registered count
- Attended count
- Total litter collected
- Individual contributions
## Data Flow
```
1. Organizer clicks "Complete Event"
↓
2. AttendeeManagement loads registered users
↓
3. Organizer marks attendance & contributions
↓
4. Data saved via bulk update API
↓
5. EventCompletionForm shows summary
↓
6. Organizer adds photos & confirms
↓
7. Event marked complete
↓
8. CompletedEventDetails displays results
```
## Validation
- Litter amounts must be positive numbers
- Attended status is boolean
- Contribution notes are optional text
- Total litter calculated from individual contributions
## Future Enhancements
Potential additions:
- Export attendee data to CSV
- Send completion certificates to volunteers
- Impact score updates based on contributions
- Leaderboards for top volunteers
- Photo uploads per attendee
- Time tracking for volunteer hours
## Testing
To test the feature:
1. Create an event with a past date
2. Register multiple users
3. As organizer, click "Complete Event"
4. Mark attendees and add contributions
5. Upload photos and complete
6. View completed event details
## Migration Instructions
If deploying to a new environment:
1. Run `npx prisma migrate deploy` in server directory
2. Or manually run: `npx tsx src/scripts/applyMigration.ts`
3. Verify with: `npx tsx src/scripts/verifyMigration.ts`
4. Restart the server

View File

@@ -0,0 +1,53 @@
# Email Prompt on Event Registration
## Overview
When users without a registered email address attempt to sign up for an event, they are now prompted to add an email address. This ensures they can receive notifications about event changes or cancellations.
## Implementation
### New Component
**`src/components/event/EmailPromptModal.tsx`**
- Modal that appears when a user without an email tries to register for an event
- Provides two options:
1. "Add Email Address" - Navigates to user profile page
2. "Continue Without Email" - Proceeds with registration anyway
### Modified Files
**`src/pages/EventsPage.tsx`**
- Added email check in `handleRegister` function
- Checks if user has `primaryEmailAddress` before registration
- Shows EmailPromptModal if no email is found
- Added navigation to user profile page for email management
- Maintains pending event ID to complete registration after user decision
**`src/components/event/index.ts`**
- Added export for `EmailPromptModal`
## User Flow
1. User clicks "Register" or "Quick Register" on an event
2. System checks if user has an email address registered
3. If no email:
- EmailPromptModal appears
- User can choose to:
- Add email (redirected to profile page)
- Continue without email (registration proceeds)
4. If email exists:
- Registration proceeds normally
## Benefits
- Encourages users to add email addresses for better communication
- Non-blocking - users can still register without email
- Clear messaging about why email is beneficial
- Seamless integration with existing registration flow
## Technical Details
- Uses Clerk's `user.primaryEmailAddress?.emailAddress` to check for email
- Modal is shown before API call to register
- Pending event ID is stored in state during modal interaction
- Registration completes after user makes a choice

View File

View File

@@ -0,0 +1,146 @@
# Printable QR Code Feature
## Overview
Users can now print their personal check-in QR code for easy access at cleanup events. This feature provides a dedicated printable page with a larger QR code and clear instructions.
## Implementation
### New Page
**`src/pages/PrintableQRCodePage.tsx`**
- Dedicated page for printing QR codes
- Larger QR code (300x300px) for better scanning
- Print-optimized layout with CSS media queries
- User information and instructions included
- Print button triggers browser print dialog
- Back button for easy navigation
### Modified Files
**`src/components/user/UserQRCode.tsx`**
- Added "Print QR Code" button
- Button navigates to the printable page
- Maintains existing QR code display functionality
**`src/App.tsx`**
- Added route: `/qr-code/print`
- Protected route (requires authentication)
- Lazy loaded for performance
**`src/pages/index.ts`**
- Added export for `PrintableQRCodePage`
## Features
### Printable Page Layout
1. **Header Section**
- Event check-in title
- Litter Scouts branding
2. **QR Code Display**
- Large 300x300px QR code
- High error correction level (H)
- Border for visual separation
3. **User Information**
- User avatar (if available)
- Full name or username
- Username handle
4. **Instructions**
- Step-by-step guide on how to use the QR code
- Numbered list for clarity
5. **Footer**
- Security reminder
- Unique code notice
### Print Optimization
- CSS media queries hide navigation elements when printing
- Only the QR code content is visible in print
- Optimized layout for standard paper sizes
- Clean, professional appearance
### User Experience
1. User views their QR code on profile page
2. Clicks "Print QR Code" button
3. Navigates to printable page
4. Reviews QR code and information
5. Clicks "Print QR Code" button or uses browser print (Cmd/Ctrl+P)
6. Prints or saves as PDF
7. Can navigate back using "Back" button
## Benefits
- **Convenience**: Users can print QR codes in advance
- **Reliability**: No need for phone battery or internet at events
- **Accessibility**: Physical QR codes work for all users
- **Professional**: Clean, branded printout
- **Flexible**: Can save as PDF or print on paper
## Technical Details
### QR Code Data Format
```json
{
"userId": "user_clerk_id",
"type": "attendee-checkin",
"timestamp": 1234567890
}
```
### Print Styles
```css
@media print {
/* Hide everything except printable content */
body * {
visibility: hidden;
}
#printable-qr-code, #printable-qr-code * {
visibility: visible;
}
/* Position printable content at top */
#printable-qr-code {
position: absolute;
left: 0;
top: 0;
width: 100%;
}
/* Hide action buttons */
.no-print {
display: none !important;
}
}
```
## Usage
### For Users
1. Navigate to your profile page
2. Scroll to "Your Check-in QR Code" section
3. Click "Print QR Code" button
4. Review the printable page
5. Click "Print QR Code" or use Cmd/Ctrl+P
6. Print or save as PDF
### For Event Organizers
- Attendees can show printed QR codes
- Scan works the same as digital QR codes
- No special handling required
- Reduces check-in time at events
## Future Enhancements
- Add event-specific QR codes
- Include event details on printout
- Support for multiple QR codes per page
- Customizable branding options
- QR code expiration dates

View File

@@ -0,0 +1,210 @@
# QR Code Check-In System Implementation
## Overview
Implemented a QR code-based check-in system for event attendees. Event organizers can scan attendee QR codes from their mobile devices to track attendance at cleanup events.
## Features
### 1. User QR Code Display
- **Location**: User Profile Page (Overview tab)
- **Component**: `UserQRCode` (`src/components/user/UserQRCode.tsx`)
- Each user has a unique QR code containing their Clerk user ID
- QR code is displayed prominently on their profile for easy access
- Users can show this QR code to event organizers for check-in
### 2. QR Code Scanner
- **Component**: `QRCodeScanner` (`src/components/event/QRCodeScanner.tsx`)
- Uses `html5-qrcode` library for camera-based scanning
- Mobile-optimized with back camera support
- Real-time scanning with visual feedback
- Error handling for invalid QR codes
### 3. Event Check-In Page
- **Route**: `/events/:eventId/checkin`
- **Component**: `EventCheckInPage` (`src/pages/EventCheckInPage.tsx`)
- **Access**: Only event organizers can access
- Features:
- Live QR code scanner
- List of registered attendees
- Visual indication of checked-in attendees
- Real-time updates after successful check-in
- Success/error notifications
### 4. Backend API
- **Endpoint**: `POST /api/events/:id/checkin`
- **Controller**: `eventController.checkInAttendee`
- **Service**: `eventService.checkInAttendee`
- Validates:
- User is the event organizer
- Attendee is registered for the event
- Attendee hasn't already checked in
- Returns attendee information on successful check-in
## User Flow
### For Attendees:
1. Navigate to their profile page
2. View their personal QR code in the Overview tab
3. Show QR code to event organizer at the event
### For Organizers:
1. Navigate to their event details
2. Click "QR Code Check-In" button
3. Grant camera permissions
4. Click "Start Scanning"
5. Point camera at attendee's QR code
6. System automatically checks in the attendee
7. Visual confirmation and attendee list updates
## Technical Implementation
### Dependencies Added:
```json
{
"qrcode.react": "^3.x.x",
"html5-qrcode": "^2.x.x",
"@types/qrcode.react": "^1.x.x"
}
```
### QR Code Data Format:
```json
{
"userId": "clerk_user_id",
"type": "attendee-checkin",
"timestamp": 1234567890
}
```
### Key Components:
1. **UserQRCode.tsx**
- Generates QR code using `qrcode.react`
- High error correction level (H)
- 200x200px size with margin
- Displays user's name below QR code
2. **QRCodeScanner.tsx**
- Uses Html5Qrcode for camera access
- 250x250px scanning box
- 10 FPS scanning rate
- Parses and validates QR code data
- Callbacks for successful scan and errors
3. **EventCheckInPage.tsx**
- Fetches event and attendee data
- Verifies organizer permissions
- Manages scanner state
- Handles check-in API calls
- Updates UI in real-time
### Backend Changes:
1. **eventController.ts**
- Added `checkInAttendee` method
- Validates organizer permissions
- Finds or creates user from Clerk ID
- Calls service layer for check-in
2. **eventService.ts**
- Added `checkInAttendee` method
- Validates registration status
- Prevents duplicate check-ins
- Updates attendance record
3. **eventRoutes.ts**
- Added `POST /:id/checkin` route
- Protected with `requireAuth` middleware
## UI/UX Enhancements
### User Profile:
- QR code card with clear instructions
- Prominent placement in Overview tab
- User identification below QR code
### Check-In Page:
- Mobile-responsive design
- Large, easy-to-use scanner interface
- Clear start/stop buttons
- Real-time attendee list with check-in status
- Color-coded status indicators (green for checked in)
- Success/error toast notifications
### Event Detail Modal:
- Added "QR Code Check-In" button for organizers
- Purple button with QR icon
- Available before and after event date
- Navigates to dedicated check-in page
## Security Considerations
1. **Authorization**: Only event organizers can access check-in page
2. **Validation**: Backend validates all check-in requests
3. **QR Code Format**: Structured JSON with type validation
4. **Timestamp**: Included in QR code (not currently validated, but available for future use)
## Mobile Optimization
- Camera access optimized for mobile devices
- Back camera selected by default
- Responsive layout for small screens
- Touch-friendly buttons and controls
- Large scanning area for easy targeting
## Future Enhancements
1. **Offline Support**: Cache attendee list for offline check-in
2. **Bulk Check-In**: Allow manual selection of multiple attendees
3. **Check-In History**: Track check-in timestamps
4. **QR Code Expiry**: Add time-based validation to QR codes
5. **Export**: Generate check-in reports
6. **Notifications**: Send confirmation to attendees upon check-in
## Testing
### Manual Testing Checklist:
- [ ] User can view their QR code on profile page
- [ ] QR code scanner requests camera permission
- [ ] Scanner successfully reads QR codes
- [ ] Only organizers can access check-in page
- [ ] Check-in updates attendee status
- [ ] Duplicate check-ins are prevented
- [ ] Error messages display correctly
- [ ] Mobile camera works properly
- [ ] Attendee list updates in real-time
## Files Modified/Created
### Frontend:
- `src/components/user/UserQRCode.tsx` (new)
- `src/components/user/index.ts` (new)
- `src/components/event/QRCodeScanner.tsx` (new)
- `src/pages/EventCheckInPage.tsx` (new)
- `src/pages/UserProfilePage.tsx` (modified)
- `src/components/event/EventDetailModal.tsx` (modified)
- `src/App.tsx` (modified)
- `package.json` (modified)
### Backend:
- `server/src/controllers/eventController.ts` (modified)
- `server/src/services/eventService.ts` (modified)
- `server/src/routes/eventRoutes.ts` (modified)
## Deployment Notes
1. Ensure camera permissions are properly configured in production
2. HTTPS required for camera access in browsers
3. Test on various mobile devices and browsers
4. Consider rate limiting for check-in endpoint
5. Monitor for QR code scanning performance issues
## Support
For issues or questions:
- Check browser camera permissions
- Ensure HTTPS is enabled
- Verify user is registered for the event
- Check that user is the event organizer
- Review browser console for errors

View File

@@ -0,0 +1,157 @@
# Quick Start: Email-Optional Authentication
## What Was Done
✅ Backend is fully configured to support users without email addresses
✅ Database schema updated (email is now optional)
✅ Notification system handles missing emails gracefully
✅ All code changes committed and pushed to git
## What You Need To Do
### 1. Configure Clerk Dashboard (5 minutes)
Go to [Clerk Dashboard](https://dashboard.clerk.com) and configure:
**User & Authentication → Email, Phone, Username:**
- ✅ Username: **ON** and **Required**
- ✅ Email: **ON** but **Optional** (not required)
**User & Authentication → Sign-up:**
- Required fields: ✅ Username only
- Optional fields: ✅ Email, First name, Last name
**User & Authentication → Sign-in:**
- Identifiers: ✅ Username (primary), ✅ Email (optional)
📖 **Detailed guide**: See `CLERK_EMAIL_OPTIONAL_SETUP.md`
### 2. Test It Works
**Test 1: Sign up without email**
```
1. Go to your sign-up page
2. Enter username: testuser123
3. Enter password: SecurePass123!
4. Leave email blank
5. Should succeed ✅
```
**Test 2: Notifications work**
```
1. Sign in as user without email
2. Create a report or event
3. Check in-app notifications → Should work ✅
4. Check email → Should not be sent (no errors) ✅
```
**Test 3: Run automated test**
```bash
cd server
npx ts-node src/scripts/testEmailOptional.ts
```
### 3. Optional: Update Frontend UI
Add to profile settings to show email status:
```typescript
// Show prompt to add email
{!user.email && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<p className="text-sm text-yellow-800">
📧 Add an email address to receive email notifications
</p>
</div>
)}
// Disable email notification toggle if no email
<label>
<input
type="checkbox"
checked={notificationEmail}
disabled={!user.email}
/>
Email notifications
{!user.email && <span className="text-sm text-gray-500 ml-2">(Add email first)</span>}
</label>
```
## How It Works
### Users Without Email
- ✅ Sign up with username + password only
- ✅ Sign in with username
- ✅ Receive in-app notifications
- ❌ Cannot receive email notifications (gracefully skipped)
### Users With Email
- ✅ Sign up with username + email + password
- ✅ Sign in with username or email
- ✅ Receive both in-app and email notifications
### Notification Logic
```typescript
// Backend automatically filters users
const users = await prisma.user.findMany({
where: {
notificationEmail: true,
email: { not: null }, // Only users with email
},
});
// Email service validates before sending
if (!to || to.trim() === '') {
console.log('Skipping - no email address');
return false;
}
```
## Files Changed
**Backend:**
- `server/prisma/schema.prisma` - Email now optional
- `server/src/services/userService.ts` - Handle null email
- `server/src/services/notificationService.ts` - Filter by email
- `server/src/services/emailService.ts` - Validate email
- `server/src/controllers/eventController.ts` - Handle null email
- `server/src/controllers/reportController.ts` - Handle null email
**Documentation:**
- `EMAIL_OPTIONAL_IMPLEMENTATION.md` - Technical details
- `CLERK_EMAIL_OPTIONAL_SETUP.md` - Clerk configuration guide
- `TASK_EMAIL_OPTIONAL_COMPLETE.md` - Complete summary
- `QUICK_START_EMAIL_OPTIONAL.md` - This file
**Testing:**
- `server/src/scripts/testEmailOptional.ts` - Test script
## Troubleshooting
**Issue**: Users still required to enter email
- **Fix**: Check Clerk Dashboard → Sign-up → Email should be in "Optional fields", not "Required fields"
**Issue**: Email notifications failing
- **Fix**: Check user has email in database: `SELECT email FROM users WHERE id = 'user_id'`
**Issue**: Errors when sending notifications
- **Fix**: Check logs - should see "Skipping email for user X (no email address)" - this is normal
## Support
- 📖 Full documentation: `CLERK_EMAIL_OPTIONAL_SETUP.md`
- 🧪 Test script: `server/src/scripts/testEmailOptional.ts`
- 📝 Implementation details: `EMAIL_OPTIONAL_IMPLEMENTATION.md`
- ✅ Task summary: `TASK_EMAIL_OPTIONAL_COMPLETE.md`
## Next Steps
1. ✅ Configure Clerk Dashboard (see above)
2. ✅ Test sign-up without email
3. ✅ Test notifications work
4. ⏳ Optional: Update frontend UI
5. ⏳ Monitor user adoption
---
**Status**: ✅ Backend complete | ⏳ Clerk configuration needed (manual step)

54
START_SERVERS.md Normal file
View File

@@ -0,0 +1,54 @@
# Starting the Application
## Backend Server
Open a terminal and run:
```bash
cd server
npm run dev
```
The backend server should start on `http://localhost:3005`
You should see output like:
```
Server running on port 3005
Connected to database
```
## Frontend Development Server
Open another terminal and run:
```bash
npm run dev
```
The frontend should start on `http://localhost:5173`
## Troubleshooting
### Backend won't start
- Check if port 3005 is already in use: `lsof -ti:3005`
- Check database connection in `server/.env`
- Run `cd server && npm install` to ensure dependencies are installed
### Frontend won't start
- Check if port 5173 is already in use
- Run `npm install` to ensure dependencies are installed
- Check `.env.local` for correct API URL
### "Server returned non-JSON response" error
- This means the backend server is not running
- Start the backend server first (see above)
- Check that `VITE_API_BASE_URL` in `.env.local` matches your backend URL
## Quick Check
Test if backend is running:
```bash
curl http://localhost:3005/api/events
```
Should return JSON with events list.

32
package-lock.json generated
View File

@@ -1,20 +1,22 @@
{
"name": "ocean-cleanup",
"name": "litter-scouts",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ocean-cleanup",
"name": "litter-scouts",
"version": "0.0.0",
"dependencies": {
"@clerk/clerk-react": "^5.55.0",
"@tanstack/react-query": "^5.90.9",
"date-fns": "^4.1.0",
"exifr": "^7.1.3",
"html5-qrcode": "^2.3.8",
"leaflet": "^1.9.4",
"leaflet.heat": "^0.2.0",
"leaflet.markercluster": "^1.5.3",
"qrcode.react": "^4.2.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-leaflet": "^5.0.0",
@@ -28,6 +30,7 @@
"@types/leaflet": "^1.9.21",
"@types/leaflet.heat": "^0.2.5",
"@types/leaflet.markercluster": "^1.5.6",
"@types/qrcode.react": "^1.0.5",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
@@ -3291,6 +3294,16 @@
"@types/leaflet": "^1.9"
}
},
"node_modules/@types/qrcode.react": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/qrcode.react/-/qrcode.react-1.0.5.tgz",
"integrity": "sha512-BghPtnlwvrvq8QkGa1H25YnN+5OIgCKFuQruncGWLGJYOzeSKiix/4+B9BtfKF2wf5ja8yfyWYA3OXju995G8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/react": {
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.5.tgz",
@@ -5141,6 +5154,12 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/html5-qrcode": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz",
"integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==",
"license": "Apache-2.0"
},
"node_modules/idb": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
@@ -6531,6 +6550,15 @@
"node": ">=6"
}
},
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",

View File

@@ -4,7 +4,9 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev": "concurrently \"npm run dev:frontend\" \"npm run dev:backend\" --names \"frontend,backend\" --prefix-colors \"cyan,magenta\"",
"dev:frontend": "vite",
"dev:backend": "cd server && npm run dev",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
@@ -14,9 +16,11 @@
"@tanstack/react-query": "^5.90.9",
"date-fns": "^4.1.0",
"exifr": "^7.1.3",
"html5-qrcode": "^2.3.8",
"leaflet": "^1.9.4",
"leaflet.heat": "^0.2.0",
"leaflet.markercluster": "^1.5.3",
"qrcode.react": "^4.2.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-leaflet": "^5.0.0",
@@ -30,6 +34,7 @@
"@types/leaflet": "^1.9.21",
"@types/leaflet.heat": "^0.2.5",
"@types/leaflet.markercluster": "^1.5.6",
"@types/qrcode.react": "^1.0.5",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",

130
server/package-lock.json generated
View File

@@ -1,11 +1,11 @@
{
"name": "beach-litter-api",
"name": "litter-scouts-api",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "beach-litter-api",
"name": "litter-scouts-api",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
@@ -20,6 +20,7 @@
"helmet": "^8.0.0",
"json2csv": "^6.0.0-alpha.2",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.15.3",
"node-cron": "^4.2.1",
"nodemailer": "^7.0.10",
"redis": "^5.9.0",
@@ -2811,6 +2812,15 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/aws-ssl-profiles": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/axios": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
@@ -3032,6 +3042,15 @@
"node": ">=0.4.0"
}
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -3402,6 +3421,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
"license": "MIT",
"dependencies": {
"is-property": "^1.0.2"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -3561,6 +3589,12 @@
"node": ">= 0.10"
}
},
"node_modules/is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
"license": "MIT"
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
@@ -3607,6 +3641,12 @@
"deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.",
"license": "MIT"
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/lower-case": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
@@ -3616,6 +3656,30 @@
"tslib": "^2.0.3"
}
},
"node_modules/lru-cache": {
"version": "7.18.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
"integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/lru.min": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.3.tgz",
"integrity": "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=1.30.0",
"node": ">=8.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/map-obj": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz",
@@ -3743,6 +3807,54 @@
"node": ">= 6.0.0"
}
},
"node_modules/mysql2": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz",
"integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==",
"license": "MIT",
"dependencies": {
"aws-ssl-profiles": "^1.1.1",
"denque": "^2.1.0",
"generate-function": "^2.3.1",
"iconv-lite": "^0.7.0",
"long": "^5.2.1",
"lru.min": "^1.0.0",
"named-placeholders": "^1.1.3",
"seq-queue": "^0.0.5",
"sqlstring": "^2.3.2"
},
"engines": {
"node": ">= 8.0"
}
},
"node_modules/mysql2/node_modules/iconv-lite": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/named-placeholders": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz",
"integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==",
"license": "MIT",
"dependencies": {
"lru-cache": "^7.14.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -4034,6 +4146,11 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/seq-queue": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="
},
"node_modules/serve-static": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
@@ -4151,6 +4268,15 @@
"node": ">=18"
}
},
"node_modules/sqlstring": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
"integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",

View File

@@ -31,6 +31,7 @@
"helmet": "^8.0.0",
"json2csv": "^6.0.0-alpha.2",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.15.3",
"node-cron": "^4.2.1",
"nodemailer": "^7.0.10",
"redis": "^5.9.0",

View File

@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE `event_registrations`
ADD COLUMN `litter_collected` DECIMAL(10, 2) NULL,
ADD COLUMN `contribution_note` TEXT NULL;

View File

@@ -0,0 +1,3 @@
-- Add equipment fields to events table
ALTER TABLE `events` ADD COLUMN `equipment_provided` BOOLEAN DEFAULT false AFTER `photos`;
ALTER TABLE `events` ADD COLUMN `required_items` JSON AFTER `equipment_provided`;

View File

@@ -83,6 +83,8 @@ model Event {
participantCount Int? @default(0) @map("participant_count")
litterCollected Decimal? @map("litter_collected") @db.Decimal(10, 2)
photos Json?
equipmentProvided Boolean? @default(false) @map("equipment_provided")
requiredItems Json? @map("required_items")
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
updatedAt DateTime? @default(now()) @updatedAt @map("updated_at") @db.Timestamp(0)
registrations EventRegistration[]
@@ -96,13 +98,15 @@ model Event {
}
model EventRegistration {
id String @id @default(dbgenerated("(uuid())")) @db.VarChar(36)
userId String @map("user_id") @db.VarChar(36)
eventId String @map("event_id") @db.VarChar(36)
registeredAt DateTime? @default(now()) @map("registered_at") @db.Timestamp(0)
attended Boolean? @default(false)
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "event_registrations_ibfk_1")
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "event_registrations_ibfk_2")
id String @id @default(dbgenerated("(uuid())")) @db.VarChar(36)
userId String @map("user_id") @db.VarChar(36)
eventId String @map("event_id") @db.VarChar(36)
registeredAt DateTime? @default(now()) @map("registered_at") @db.Timestamp(0)
attended Boolean? @default(false)
litterCollected Decimal? @map("litter_collected") @db.Decimal(10, 2)
contributionNote String? @map("contribution_note") @db.Text
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "event_registrations_ibfk_1")
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "event_registrations_ibfk_2")
@@unique([userId, eventId], map: "unique_user_event")
@@index([eventId], map: "event_id")

View File

@@ -109,7 +109,7 @@ export const eventController = {
// Find or create user in database
const userId = await findOrCreateUser(clerkId);
const { title, description, latitude, longitude, locationName, scheduledDate, duration } = req.body;
const { title, description, latitude, longitude, locationName, scheduledDate, duration, equipmentProvided, requiredItems } = req.body;
// Validation
if (!title || !description || !latitude || !longitude || !locationName || !scheduledDate || !duration) {
@@ -129,6 +129,8 @@ export const eventController = {
locationName,
scheduledDate: new Date(scheduledDate),
duration: parseInt(duration),
equipmentProvided: equipmentProvided !== undefined ? Boolean(equipmentProvided) : false,
requiredItems: Array.isArray(requiredItems) ? requiredItems : [],
};
const event = await eventService.createEvent(eventData);
@@ -405,4 +407,202 @@ export const eventController = {
});
}
},
// Get event attendees
async getEventAttendees(req: AuthRequest, res: Response) {
try {
const { id } = req.params;
const attendees = await eventService.getEventAttendees(id);
res.json(attendees);
} catch (error: any) {
console.error('Error fetching event attendees:', error);
res.status(500).json({
code: 'FETCH_ATTENDEES_ERROR',
message: 'Failed to fetch event attendees',
details: error.message,
timestamp: new Date().toISOString(),
});
}
},
// Update attendee status
async updateAttendeeStatus(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, attendeeId } = 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 update attendee status',
timestamp: new Date().toISOString(),
});
}
const { attended, litterCollected, contributionNote } = req.body;
const updatedAttendee = await eventService.updateAttendeeStatus(id, attendeeId, {
attended,
litterCollected: litterCollected !== undefined ? parseFloat(litterCollected) : undefined,
contributionNote,
});
res.json(updatedAttendee);
} catch (error: any) {
console.error('Error updating attendee status:', error);
res.status(500).json({
code: 'UPDATE_ATTENDEE_ERROR',
message: 'Failed to update attendee status',
details: error.message,
timestamp: new Date().toISOString(),
});
}
},
// Bulk update attendees
async bulkUpdateAttendees(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 update attendees',
timestamp: new Date().toISOString(),
});
}
const { attendees } = req.body;
if (!Array.isArray(attendees)) {
return res.status(400).json({
code: 'VALIDATION_ERROR',
message: 'attendees must be an array',
timestamp: new Date().toISOString(),
});
}
await eventService.bulkUpdateAttendees(id, attendees);
// Fetch updated attendees
const updatedAttendees = await eventService.getEventAttendees(id);
res.json(updatedAttendees);
} catch (error: any) {
console.error('Error bulk updating attendees:', error);
res.status(500).json({
code: 'BULK_UPDATE_ATTENDEES_ERROR',
message: 'Failed to bulk update attendees',
details: error.message,
timestamp: new Date().toISOString(),
});
}
},
// QR Code Check-in
async checkInAttendee(req: AuthRequest, res: Response) {
try {
const organizerClerkId = req.userId;
if (!organizerClerkId) {
return res.status(401).json({
code: 'UNAUTHORIZED',
message: 'User not authenticated',
timestamp: new Date().toISOString(),
});
}
const organizerId = await findOrCreateUser(organizerClerkId);
const { id } = req.params;
const { clerkUserId } = req.body;
if (!clerkUserId) {
return res.status(400).json({
code: 'VALIDATION_ERROR',
message: 'clerkUserId is required',
timestamp: new Date().toISOString(),
});
}
// Verify event exists and user is organizer
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(),
});
}
if (event.organizerId !== organizerId) {
return res.status(403).json({
code: 'FORBIDDEN',
message: 'Only the event organizer can check in attendees',
timestamp: new Date().toISOString(),
});
}
// Find or create the attendee user
const attendeeUserId = await findOrCreateUser(clerkUserId);
// Check in the attendee (will auto-register if not registered)
const result = await eventService.checkInAttendee(id, attendeeUserId);
res.json(result);
} catch (error: any) {
console.error('Error checking in attendee:', error);
if (error.message.includes('already checked in')) {
return res.status(409).json({
code: 'ALREADY_CHECKED_IN',
message: error.message,
timestamp: new Date().toISOString(),
});
}
res.status(500).json({
code: 'CHECKIN_ERROR',
message: 'Failed to check in attendee',
details: error.message,
timestamp: new Date().toISOString(),
});
}
},
};

View File

@@ -41,7 +41,7 @@ export class UserController {
async updateUserProfile(req: AuthRequest, res: Response) {
try {
const { id } = req.params;
const { firstName, lastName, username } = req.body;
const { firstName, lastName, username, email } = req.body;
// Authorization check - users can only update their own profile
if (!req.userId) {
@@ -91,6 +91,7 @@ export class UserController {
firstName,
lastName,
username,
email,
});
res.json(updatedProfile);

View File

@@ -8,6 +8,7 @@ const router = Router();
router.get('/', eventController.getEvents);
router.get('/:id', eventController.getEvent);
router.get('/:id/registrations', eventController.getEventRegistrations);
router.get('/:id/attendees', eventController.getEventAttendees);
// Protected routes (require authentication)
router.post('/', requireAuth, eventController.createEvent);
@@ -17,5 +18,8 @@ router.post('/:id/register', requireAuth, eventController.registerForEvent);
router.delete('/:id/register', requireAuth, eventController.unregisterFromEvent);
router.get('/:id/registration-status', requireAuth, eventController.getRegistrationStatus);
router.post('/:id/complete', requireAuth, eventController.completeEvent);
router.patch('/:id/attendees/:attendeeId', requireAuth, eventController.updateAttendeeStatus);
router.post('/:id/attendees/bulk', requireAuth, eventController.bulkUpdateAttendees);
router.post('/:id/checkin', requireAuth, eventController.checkInAttendee);
export default router;

View File

@@ -0,0 +1,57 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function applyMigration() {
try {
console.log('Applying migration: add_attendee_contributions');
// Check if columns exist
const result = await prisma.$queryRaw`
SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'beach_litter_mapping'
AND TABLE_NAME = 'event_registrations'
AND COLUMN_NAME IN ('litter_collected', 'contribution_note')
` as any[];
console.log('Existing columns:', result);
if (result.length === 0) {
console.log('Adding columns...');
await prisma.$executeRaw`
ALTER TABLE event_registrations
ADD COLUMN litter_collected DECIMAL(10, 2) NULL,
ADD COLUMN contribution_note TEXT NULL
`;
console.log('✓ Columns added successfully');
} else if (result.length === 1) {
const existingColumn = result[0].COLUMN_NAME;
const missingColumn = existingColumn === 'litter_collected' ? 'contribution_note' : 'litter_collected';
console.log(`Adding missing column: ${missingColumn}`);
await prisma.$executeRaw`
ALTER TABLE event_registrations
ADD COLUMN ${missingColumn} ${missingColumn === 'litter_collected' ? 'DECIMAL(10, 2)' : 'TEXT'} NULL
`;
console.log('✓ Column added successfully');
} else {
console.log('✓ Columns already exist');
}
} catch (error) {
console.error('Error applying migration:', error);
throw error;
} finally {
await prisma.$disconnect();
}
}
applyMigration()
.then(() => {
console.log('Migration completed');
process.exit(0);
})
.catch((error) => {
console.error('Migration failed:', error);
process.exit(1);
});

View File

@@ -0,0 +1,89 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function testAttendees() {
try {
console.log('Testing attendees query...\n');
// Get all events
const events = await prisma.event.findMany({
take: 1,
});
if (events.length === 0) {
console.log('No events found in database');
return;
}
const eventId = events[0].id;
console.log(`Testing with event ID: ${eventId}`);
console.log(`Event title: ${events[0].title}\n`);
// Get registrations
const registrations = await prisma.eventRegistration.findMany({
where: { eventId },
include: {
user: {
select: {
id: true,
username: true,
firstName: true,
lastName: true,
avatarUrl: true,
},
},
},
orderBy: {
registeredAt: 'asc',
},
});
console.log(`Found ${registrations.length} registrations\n`);
if (registrations.length > 0) {
console.log('Sample registration:');
const reg = registrations[0];
console.log({
id: reg.id,
userId: reg.userId,
eventId: reg.eventId,
registeredAt: reg.registeredAt,
attended: reg.attended,
litterCollected: reg.litterCollected,
contributionNote: reg.contributionNote,
user: reg.user,
});
}
// Test the mapping
const mapped = registrations.map((reg) => ({
id: reg.id,
userId: reg.userId,
eventId: reg.eventId,
registeredAt: reg.registeredAt || new Date(),
attended: reg.attended || false,
litterCollected: reg.litterCollected ? parseFloat(reg.litterCollected.toString()) : undefined,
contributionNote: reg.contributionNote || undefined,
user: reg.user,
}));
console.log('\nMapped data:');
console.log(JSON.stringify(mapped, null, 2));
console.log('\n✓ Test completed successfully');
} catch (error) {
console.error('Error testing attendees:', error);
throw error;
} finally {
await prisma.$disconnect();
}
}
testAttendees()
.then(() => process.exit(0))
.catch((error) => {
console.error('Test failed:', error);
process.exit(1);
});

View File

@@ -0,0 +1,43 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function verifyMigration() {
try {
console.log('Verifying migration...');
const result = await prisma.$queryRaw`
SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'beach_litter_mapping'
AND TABLE_NAME = 'event_registrations'
ORDER BY ORDINAL_POSITION
` as any[];
console.log('\nColumns in event_registrations table:');
console.table(result);
const hasLitterCollected = result.some((col: any) => col.COLUMN_NAME === 'litter_collected');
const hasContributionNote = result.some((col: any) => col.COLUMN_NAME === 'contribution_note');
if (hasLitterCollected && hasContributionNote) {
console.log('\n✓ Migration verified successfully!');
console.log('✓ litter_collected column exists');
console.log('✓ contribution_note column exists');
} else {
console.log('\n✗ Migration incomplete:');
if (!hasLitterCollected) console.log('✗ litter_collected column missing');
if (!hasContributionNote) console.log('✗ contribution_note column missing');
}
} catch (error) {
console.error('Error verifying migration:', error);
throw error;
} finally {
await prisma.$disconnect();
}
}
verifyMigration()
.then(() => process.exit(0))
.catch(() => process.exit(1));

View File

@@ -21,6 +21,8 @@ interface CreateEventData {
locationName: string;
scheduledDate: Date;
duration: number;
equipmentProvided?: boolean;
requiredItems?: string[];
}
interface UpdateEventData {
@@ -31,6 +33,8 @@ interface UpdateEventData {
status?: 'upcoming' | 'completed' | 'cancelled';
litterCollected?: number;
photos?: string[];
equipmentProvided?: boolean;
requiredItems?: string[];
}
export const eventService = {
@@ -93,6 +97,8 @@ export const eventService = {
participantCount: event._count.registrations,
litterCollected: event.litterCollected ? parseFloat(event.litterCollected.toString()) : undefined,
photos: event.photos ? (Array.isArray(event.photos) ? event.photos : []) : undefined,
equipmentProvided: (event as any).equipmentProvided || false,
requiredItems: (event as any).requiredItems ? (Array.isArray((event as any).requiredItems) ? (event as any).requiredItems : []) : [],
createdAt: event.createdAt,
updatedAt: event.updatedAt,
}));
@@ -137,6 +143,8 @@ export const eventService = {
participantCount: event._count.registrations,
litterCollected: event.litterCollected ? parseFloat(event.litterCollected.toString()) : undefined,
photos: event.photos ? (Array.isArray(event.photos) ? event.photos : []) : undefined,
equipmentProvided: (event as any).equipmentProvided || false,
requiredItems: (event as any).requiredItems ? (Array.isArray((event as any).requiredItems) ? (event as any).requiredItems : []) : [],
createdAt: event.createdAt,
updatedAt: event.updatedAt,
};
@@ -145,16 +153,18 @@ export const eventService = {
async createEvent(data: CreateEventData) {
// Create point geometry for spatial queries
const locationPoint = `POINT(${data.longitude} ${data.latitude})`;
const equipmentProvided = data.equipmentProvided !== undefined ? data.equipmentProvided : false;
const requiredItems = data.requiredItems && data.requiredItems.length > 0 ? JSON.stringify(data.requiredItems) : null;
const event = await prisma.$executeRaw`
INSERT INTO events (
id, organizer_id, title, description, location, latitude, longitude,
location_name, scheduled_date, duration, status, participant_count
location_name, scheduled_date, duration, status, participant_count, equipment_provided, required_items
) VALUES (
UUID(), ${data.organizerId}, ${data.title}, ${data.description},
ST_GeomFromText(${locationPoint}, 4326),
${data.latitude}, ${data.longitude}, ${data.locationName},
${data.scheduledDate}, ${data.duration}, 'upcoming', 0
${data.scheduledDate}, ${data.duration}, 'upcoming', 0, ${equipmentProvided}, ${requiredItems}
)
`;
@@ -195,6 +205,8 @@ export const eventService = {
participantCount: createdEvent._count.registrations,
litterCollected: createdEvent.litterCollected ? parseFloat(createdEvent.litterCollected.toString()) : undefined,
photos: createdEvent.photos ? (Array.isArray(createdEvent.photos) ? createdEvent.photos : []) : undefined,
equipmentProvided: (createdEvent as any).equipmentProvided || false,
requiredItems: (createdEvent as any).requiredItems ? (Array.isArray((createdEvent as any).requiredItems) ? (createdEvent as any).requiredItems : []) : [],
createdAt: createdEvent.createdAt,
updatedAt: createdEvent.updatedAt,
};
@@ -372,4 +384,217 @@ export const eventService = {
updatedAt: event.updatedAt,
};
},
async getEventAttendees(eventId: string) {
const registrations = await prisma.eventRegistration.findMany({
where: { eventId },
include: {
user: {
select: {
id: true,
clerkId: true,
username: true,
firstName: true,
lastName: true,
avatarUrl: true,
},
},
},
orderBy: {
registeredAt: 'asc',
},
});
return registrations.map((reg) => ({
id: reg.id,
userId: reg.userId,
eventId: reg.eventId,
registeredAt: reg.registeredAt || new Date(),
attended: reg.attended || false,
litterCollected: reg.litterCollected ? parseFloat(reg.litterCollected.toString()) : undefined,
contributionNote: reg.contributionNote || undefined,
user: reg.user,
}));
},
async updateAttendeeStatus(
eventId: string,
userId: string,
data: {
attended?: boolean;
litterCollected?: number;
contributionNote?: string;
}
) {
const registration = await prisma.eventRegistration.update({
where: {
userId_eventId: {
userId,
eventId,
},
},
data: {
attended: data.attended,
litterCollected: data.litterCollected,
contributionNote: data.contributionNote,
},
include: {
user: {
select: {
id: true,
username: true,
firstName: true,
lastName: true,
avatarUrl: true,
},
},
},
});
return {
id: registration.id,
userId: registration.userId,
eventId: registration.eventId,
registeredAt: registration.registeredAt || new Date(),
attended: registration.attended || false,
litterCollected: registration.litterCollected ? parseFloat(registration.litterCollected.toString()) : undefined,
contributionNote: registration.contributionNote || undefined,
user: registration.user,
};
},
async bulkUpdateAttendees(
eventId: string,
attendees: Array<{
userId: string;
attended: boolean;
litterCollected?: number;
contributionNote?: string;
}>
) {
const updates = await Promise.all(
attendees.map((attendee) =>
prisma.eventRegistration.update({
where: {
userId_eventId: {
userId: attendee.userId,
eventId,
},
},
data: {
attended: attendee.attended,
litterCollected: attendee.litterCollected,
contributionNote: attendee.contributionNote,
},
})
)
);
return updates;
},
async checkInAttendee(eventId: string, userId: string) {
// Check if user is registered
let registration = await prisma.eventRegistration.findUnique({
where: {
userId_eventId: {
userId,
eventId,
},
},
include: {
user: {
select: {
id: true,
username: true,
firstName: true,
lastName: true,
avatarUrl: true,
},
},
},
});
// If not registered, register them now (walk-in)
if (!registration) {
registration = await prisma.eventRegistration.create({
data: {
userId,
eventId,
attended: true, // Check them in immediately
},
include: {
user: {
select: {
id: true,
username: true,
firstName: true,
lastName: true,
avatarUrl: true,
},
},
},
});
return {
success: true,
username: registration.user.username,
firstName: registration.user.firstName,
lastName: registration.user.lastName,
wasWalkIn: true, // Flag to indicate this was a walk-in registration
attendee: {
id: registration.id,
userId: registration.userId,
eventId: registration.eventId,
registeredAt: registration.registeredAt || new Date(),
attended: registration.attended || false,
user: registration.user,
},
};
}
if (registration.attended) {
throw new Error('User is already checked in');
}
// Update attendance for pre-registered user
const updated = await prisma.eventRegistration.update({
where: {
userId_eventId: {
userId,
eventId,
},
},
data: {
attended: true,
},
include: {
user: {
select: {
id: true,
username: true,
firstName: true,
lastName: true,
avatarUrl: true,
},
},
},
});
return {
success: true,
username: updated.user.username,
firstName: updated.user.firstName,
lastName: updated.user.lastName,
wasWalkIn: false,
attendee: {
id: updated.id,
userId: updated.userId,
eventId: updated.eventId,
registeredAt: updated.registeredAt || new Date(),
attended: updated.attended || false,
user: updated.user,
},
};
},
};

View File

@@ -1,4 +1,5 @@
import prisma from '../config/database';
import { Prisma } from '@prisma/client';
import { calculateDistance } from '../utils/spatial';
import { emailService } from './emailService';
@@ -55,7 +56,7 @@ export class NotificationService {
{ notificationInApp: true },
],
areasOfInterest: {
not: null,
not: Prisma.JsonNull,
},
},
select: {
@@ -77,7 +78,7 @@ export class NotificationService {
// Check if the location falls within any of the user's areas of interest
for (const area of areasOfInterest) {
const distance = calculateDistance(
const distance = await calculateDistance(
latitude,
longitude,
area.lat,

View File

@@ -5,6 +5,7 @@ export interface UpdateUserProfileData {
firstName?: string;
lastName?: string;
username?: string;
email?: string;
}
export interface UpdateNotificationPreferencesData {
@@ -66,6 +67,11 @@ export class UserService {
// Check if this is a Clerk ID (starts with 'user_')
const isClerkId = userId.startsWith('user_');
// If it's a Clerk ID, ensure the user exists in our database
if (isClerkId) {
await this.findOrCreateUser(userId);
}
const user = await prisma.user.findUnique({
where: isClerkId ? { clerkId: userId } : { id: userId },
select: {
@@ -91,6 +97,17 @@ export class UserService {
organizedEvents: true,
},
},
eventRegistrations: {
where: {
attended: true,
litterCollected: {
not: null,
},
},
select: {
litterCollected: true,
},
},
},
});
@@ -110,11 +127,17 @@ export class UserService {
}
}
// Calculate total litter collected across all attended events
const totalLitterCollected = user.eventRegistrations.reduce((sum, reg) => {
return sum + (reg.litterCollected ? Number(reg.litterCollected) : 0);
}, 0);
return {
...user,
areasOfInterest,
reportCount: user._count.reports,
eventCount: user._count.eventRegistrations,
totalLitterCollected,
stats: {
reportsSubmitted: user._count.reports,
eventsAttended: user._count.eventRegistrations,
@@ -141,12 +164,30 @@ export class UserService {
}
}
// Check if email is being changed and if it's already taken
if (data.email !== undefined) {
// Allow setting email to null or empty string
if (data.email && data.email.trim() !== '') {
const existingUser = await prisma.user.findFirst({
where: {
email: data.email,
id: { not: userId },
},
});
if (existingUser) {
throw new Error('Email already in use');
}
}
}
const updatedUser = await prisma.user.update({
where: { id: userId },
data: {
firstName: data.firstName,
lastName: data.lastName,
username: data.username,
email: data.email !== undefined ? (data.email.trim() === '' ? null : data.email) : undefined,
},
select: {
id: true,
@@ -174,14 +215,8 @@ export class UserService {
let dbUserId = userId;
if (isClerkId) {
const user = await prisma.user.findUnique({
where: { clerkId: userId },
select: { id: true },
});
if (!user) {
throw new Error('User not found');
}
dbUserId = user.id;
// Ensure user exists in database
dbUserId = await this.findOrCreateUser(userId);
}
const reports = await prisma.report.findMany({
@@ -207,14 +242,8 @@ export class UserService {
let dbUserId = userId;
if (isClerkId) {
const user = await prisma.user.findUnique({
where: { clerkId: userId },
select: { id: true },
});
if (!user) {
throw new Error('User not found');
}
dbUserId = user.id;
// Ensure user exists in database
dbUserId = await this.findOrCreateUser(userId);
}
// Get events user has registered for
@@ -315,14 +344,8 @@ export class UserService {
let dbUserId = userId;
if (isClerkId) {
const user = await prisma.user.findUnique({
where: { clerkId: userId },
select: { id: true },
});
if (!user) {
throw new Error('User not found');
}
dbUserId = user.id;
// Ensure user exists in database
dbUserId = await this.findOrCreateUser(userId);
}
// Get user's reports

View File

@@ -24,6 +24,8 @@ const ReportDetailDemo = lazy(() => import('./pages/ReportDetailDemo'));
const WhyCleanPage = lazy(() => import('./pages/WhyCleanPage'));
const UserProfilePage = lazy(() => import('./pages/UserProfilePage'));
const EnvironmentalDataDemo = lazy(() => import('./pages/EnvironmentalDataDemo'));
const EventCheckInPage = lazy(() => import('./pages/EventCheckInPage'));
const PrintableQRCodePage = lazy(() => import('./pages/PrintableQRCodePage'));
// Services
import { apiClient } from './services/api';
@@ -152,6 +154,24 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/events/:eventId/checkin"
element={
<ProtectedRoute>
<Layout>
<EventCheckInPage />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/qr-code/print"
element={
<ProtectedRoute>
<PrintableQRCodePage />
</ProtectedRoute>
}
/>
{/* Demo routes for testing */}
<Route

View File

@@ -0,0 +1,277 @@
import { useState, useEffect } from 'react';
import type { EventAttendee } from '@/types';
interface AttendeeManagementProps {
eventId: string;
onClose: () => void;
onSave: (attendees: Array<{
userId: string;
attended: boolean;
litterCollected?: number;
contributionNote?: string;
}>) => Promise<void>;
isLoading?: boolean;
}
export function AttendeeManagement({
eventId,
onClose,
onSave,
isLoading = false,
}: AttendeeManagementProps) {
const [attendees, setAttendees] = useState<EventAttendee[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editedAttendees, setEditedAttendees] = useState<Map<string, Partial<EventAttendee>>>(new Map());
useEffect(() => {
fetchAttendees();
}, [eventId]);
const fetchAttendees = async () => {
try {
setLoading(true);
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3005/api';
const response = await fetch(`${API_BASE_URL}/events/${eventId}/attendees`, {
headers: {
'Authorization': `Bearer ${await (window as any).Clerk?.session?.getToken()}`,
},
});
if (!response.ok) {
const errorText = await response.text();
console.error('Failed to fetch attendees:', response.status, errorText);
throw new Error(`Failed to fetch attendees: ${response.status}`);
}
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
const text = await response.text();
console.error('Non-JSON response:', text);
throw new Error('Server returned non-JSON response');
}
const data = await response.json();
setAttendees(data);
} catch (err) {
console.error('Error in fetchAttendees:', err);
setError(err instanceof Error ? err.message : 'Failed to load attendees');
} finally {
setLoading(false);
}
};
const handleAttendeeChange = (userId: string, field: string, value: any) => {
setEditedAttendees(prev => {
const updated = new Map(prev);
const current = updated.get(userId) || {};
updated.set(userId, { ...current, [field]: value });
return updated;
});
};
const getAttendeeValue = (attendee: EventAttendee, field: keyof EventAttendee) => {
const edited = editedAttendees.get(attendee.userId);
return edited && field in edited ? edited[field] : attendee[field];
};
const handleSave = async () => {
const updates = Array.from(editedAttendees.entries()).map(([userId, changes]) => {
const attendee = attendees.find(a => a.userId === userId);
return {
userId,
attended: changes.attended ?? attendee?.attended ?? false,
litterCollected: changes.litterCollected ?? attendee?.litterCollected,
contributionNote: changes.contributionNote ?? attendee?.contributionNote,
};
});
await onSave(updates);
};
const getTotalLitter = () => {
return attendees.reduce((sum, attendee) => {
const edited = editedAttendees.get(attendee.userId);
const litter = edited?.litterCollected ?? attendee.litterCollected ?? 0;
return sum + litter;
}, 0);
};
const getAttendedCount = () => {
return attendees.filter(attendee => {
const edited = editedAttendees.get(attendee.userId);
return edited?.attended ?? attendee.attended;
}).length;
};
if (loading) {
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
</div>
);
}
if (error) {
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="text-center py-8">
<p className="text-red-600">{error}</p>
<button
onClick={fetchAttendees}
className="mt-4 text-blue-600 hover:text-blue-700"
>
Try Again
</button>
</div>
</div>
);
}
if (attendees.length === 0) {
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="text-center py-8">
<p className="text-gray-500">No registered attendees for this event</p>
<button
onClick={onClose}
className="mt-4 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200"
>
Close
</button>
</div>
</div>
);
}
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 flex flex-col max-h-[80vh]">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200 flex-shrink-0">
<h3 className="text-lg font-semibold text-gray-900">Manage Attendees</h3>
<p className="text-sm text-gray-500 mt-1">
Mark who attended and track individual contributions
</p>
</div>
{/* Summary Stats */}
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200 flex-shrink-0">
<div className="grid grid-cols-3 gap-4">
<div>
<p className="text-xs text-gray-500">Registered</p>
<p className="text-2xl font-bold text-gray-900">{attendees.length}</p>
</div>
<div>
<p className="text-xs text-gray-500">Attended</p>
<p className="text-2xl font-bold text-green-600">{getAttendedCount()}</p>
</div>
<div>
<p className="text-xs text-gray-500">Total Litter (kg)</p>
<p className="text-2xl font-bold text-blue-600">{getTotalLitter().toFixed(1)}</p>
</div>
</div>
</div>
{/* Attendee List */}
<div className="flex-1 overflow-y-auto">
<table className="w-full">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Attendee
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
Attended
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Litter (kg)
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Note
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{attendees.map((attendee) => (
<tr key={attendee.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
{attendee.user.avatarUrl ? (
<img
src={attendee.user.avatarUrl}
alt={attendee.user.username}
className="h-8 w-8 rounded-full"
/>
) : (
<div className="h-8 w-8 rounded-full bg-blue-100 flex items-center justify-center">
<span className="text-sm font-medium text-blue-600">
{attendee.user.username.charAt(0).toUpperCase()}
</span>
</div>
)}
<div className="ml-3">
<p className="text-sm font-medium text-gray-900">
{attendee.user.firstName && attendee.user.lastName
? `${attendee.user.firstName} ${attendee.user.lastName}`
: attendee.user.username}
</p>
<p className="text-xs text-gray-500">@{attendee.user.username}</p>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<input
type="checkbox"
checked={getAttendeeValue(attendee, 'attended') as boolean}
onChange={(e) => handleAttendeeChange(attendee.userId, 'attended', e.target.checked)}
className="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded cursor-pointer"
/>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<input
type="number"
step="0.1"
min="0"
value={getAttendeeValue(attendee, 'litterCollected') as number || ''}
onChange={(e) => handleAttendeeChange(attendee.userId, 'litterCollected', e.target.value ? parseFloat(e.target.value) : undefined)}
placeholder="0.0"
className="w-24 px-2 py-1 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</td>
<td className="px-6 py-4">
<input
type="text"
value={getAttendeeValue(attendee, 'contributionNote') as string || ''}
onChange={(e) => handleAttendeeChange(attendee.userId, 'contributionNote', e.target.value)}
placeholder="Optional note..."
className="w-full px-2 py-1 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Footer Actions */}
<div className="px-6 py-4 bg-gray-50 border-t border-gray-200 flex gap-3 flex-shrink-0">
<button
onClick={handleSave}
disabled={isLoading || editedAttendees.size === 0}
className="flex-1 bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors font-medium"
>
{isLoading ? 'Saving...' : `Save Changes${editedAttendees.size > 0 ? ` (${editedAttendees.size})` : ''}`}
</button>
<button
onClick={onClose}
disabled={isLoading}
className="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
>
Cancel
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,209 @@
import { useState, useEffect } from 'react';
import type { EventAttendee } from '@/types';
interface CompletedEventDetailsProps {
eventId: string;
totalLitterCollected: number;
photos?: string[];
}
export function CompletedEventDetails({
eventId,
totalLitterCollected,
photos = [],
}: CompletedEventDetailsProps) {
const [attendees, setAttendees] = useState<EventAttendee[]>([]);
const [loading, setLoading] = useState(true);
const [showAllAttendees, setShowAllAttendees] = useState(false);
useEffect(() => {
fetchAttendees();
}, [eventId]);
const fetchAttendees = async () => {
try {
setLoading(true);
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3005/api';
const token = await (window as any).Clerk?.session?.getToken();
const headers: HeadersInit = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE_URL}/events/${eventId}/attendees`, { headers });
if (response.ok) {
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
const data = await response.json();
setAttendees(data.filter((a: EventAttendee) => a.attended));
} else {
console.warn('Non-JSON response when fetching attendees');
}
} else {
console.error('Failed to fetch attendees:', response.status, await response.text());
}
} catch (error) {
console.error('Error fetching attendees:', error);
} finally {
setLoading(false);
}
};
const topContributors = attendees
.filter(a => a.litterCollected && a.litterCollected > 0)
.sort((a, b) => (b.litterCollected || 0) - (a.litterCollected || 0))
.slice(0, showAllAttendees ? undefined : 5);
const attendedCount = attendees.length;
return (
<div className="space-y-6">
{/* Summary Card */}
<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 className="flex-1">
<p className="text-sm font-medium text-green-900">Event Completed</p>
<p className="text-sm text-green-700 mt-1">
{totalLitterCollected} kg of litter collected by {attendedCount} {attendedCount === 1 ? 'volunteer' : 'volunteers'}
</p>
</div>
</div>
</div>
{/* Top Contributors */}
{topContributors.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-900 mb-3">Top Contributors</h4>
<div className="space-y-2">
{topContributors.map((attendee, index) => (
<div
key={attendee.id}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div className="flex items-center flex-1">
<div className="flex-shrink-0 mr-3">
{index < 3 && (
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
index === 0 ? 'bg-yellow-100 text-yellow-700' :
index === 1 ? 'bg-gray-200 text-gray-700' :
'bg-orange-100 text-orange-700'
}`}>
<span className="text-sm font-bold">#{index + 1}</span>
</div>
)}
{index >= 3 && attendee.user.avatarUrl && (
<img
src={attendee.user.avatarUrl}
alt={attendee.user.username}
className="w-8 h-8 rounded-full"
/>
)}
{index >= 3 && !attendee.user.avatarUrl && (
<div className="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center">
<span className="text-sm font-medium text-blue-600">
{attendee.user.username.charAt(0).toUpperCase()}
</span>
</div>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{attendee.user.firstName && attendee.user.lastName
? `${attendee.user.firstName} ${attendee.user.lastName}`
: attendee.user.username}
</p>
{attendee.contributionNote && (
<p className="text-xs text-gray-500 truncate">{attendee.contributionNote}</p>
)}
</div>
</div>
<div className="ml-4 flex-shrink-0">
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800">
{attendee.litterCollected?.toFixed(1)} kg
</span>
</div>
</div>
))}
</div>
{attendees.filter(a => a.litterCollected && a.litterCollected > 0).length > 5 && (
<button
onClick={() => setShowAllAttendees(!showAllAttendees)}
className="mt-3 text-sm text-blue-600 hover:text-blue-700 font-medium"
>
{showAllAttendees ? 'Show Less' : `Show All ${attendees.filter(a => a.litterCollected && a.litterCollected > 0).length} Contributors`}
</button>
)}
</div>
)}
{/* All Attendees */}
{attendees.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-900 mb-3">
All Attendees ({attendees.length})
</h4>
<div className="flex flex-wrap gap-2">
{attendees.map((attendee) => (
<div
key={attendee.id}
className="flex items-center gap-2 px-3 py-1.5 bg-gray-100 rounded-full"
title={attendee.user.username}
>
{attendee.user.avatarUrl ? (
<img
src={attendee.user.avatarUrl}
alt={attendee.user.username}
className="w-5 h-5 rounded-full"
/>
) : (
<div className="w-5 h-5 rounded-full bg-blue-100 flex items-center justify-center">
<span className="text-xs font-medium text-blue-600">
{attendee.user.username.charAt(0).toUpperCase()}
</span>
</div>
)}
<span className="text-sm text-gray-700">
{attendee.user.firstName && attendee.user.lastName
? `${attendee.user.firstName} ${attendee.user.lastName}`
: attendee.user.username}
</span>
</div>
))}
</div>
</div>
)}
{/* Event Photos */}
{photos.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-900 mb-3">Event Photos</h4>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{photos.map((photo, index) => (
<img
key={index}
src={photo}
alt={`Event photo ${index + 1}`}
className="w-full h-32 object-cover rounded-lg hover:opacity-90 transition-opacity cursor-pointer"
onClick={() => window.open(photo, '_blank')}
/>
))}
</div>
</div>
)}
{loading && (
<div className="flex items-center justify-center py-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,57 @@
interface EmailPromptModalProps {
onClose: () => void;
onAddEmail: () => void;
onContinue: () => void;
}
export function EmailPromptModal({ onClose, onAddEmail, onContinue }: EmailPromptModalProps) {
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:p-0">
{/* Background overlay */}
<div className="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" onClick={onClose}></div>
{/* Modal panel */}
<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-md sm:w-full z-10">
<div className="bg-white px-6 pt-6 pb-4">
{/* Icon */}
<div className="flex items-center justify-center w-12 h-12 mx-auto bg-amber-100 rounded-full mb-4">
<svg className="w-6 h-6 text-amber-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
{/* Content */}
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Add an Email Address?
</h3>
<p className="text-sm text-gray-600 mb-4">
You don't have an email address registered. Adding one will allow you to receive notifications about event changes or cancellations.
</p>
<p className="text-xs text-gray-500">
You'll be directed to your profile page where you can manage your account settings.
</p>
</div>
</div>
{/* Actions */}
<div className="bg-gray-50 px-6 py-4 space-y-2">
<button
onClick={onAddEmail}
className="w-full bg-blue-600 text-white py-2.5 px-4 rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
Add Email Address
</button>
<button
onClick={onContinue}
className="w-full bg-white text-gray-700 py-2.5 px-4 rounded-lg border border-gray-300 hover:bg-gray-50 transition-colors font-medium"
>
Continue Without Email
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,20 +1,34 @@
import { useState } from 'react';
import { PhotoUpload, type PhotoMetadata } from '@/components/report/PhotoUpload';
import { AttendeeManagement } from './AttendeeManagement';
interface EventCompletionFormProps {
eventId: string;
onSubmit: (data: { litterCollected: number; photos: string[] }) => Promise<void>;
onSubmit: (data: { litterCollected: number; photos: string[]; attendees?: Array<{
userId: string;
attended: boolean;
litterCollected?: number;
contributionNote?: string;
}> }) => Promise<void>;
onCancel: () => void;
isLoading?: boolean;
}
export function EventCompletionForm({
eventId,
onSubmit,
onCancel,
isLoading = false,
}: EventCompletionFormProps) {
const [step, setStep] = useState<'attendees' | 'summary'>('attendees');
const [litterCollected, setLitterCollected] = useState<string>('');
const [photos, setPhotos] = useState<PhotoMetadata[]>([]);
const [attendeeData, setAttendeeData] = useState<Array<{
userId: string;
attended: boolean;
litterCollected?: number;
contributionNote?: string;
}>>([]);
const [errors, setErrors] = useState<{ litterCollected?: string }>({});
const validateForm = (): boolean => {
@@ -33,6 +47,21 @@ export function EventCompletionForm({
return Object.keys(newErrors).length === 0;
};
const handleAttendeesSave = async (attendees: Array<{
userId: string;
attended: boolean;
litterCollected?: number;
contributionNote?: string;
}>) => {
setAttendeeData(attendees);
// Calculate total litter from attendees
const totalFromAttendees = attendees.reduce((sum, a) => sum + (a.litterCollected || 0), 0);
setLitterCollected(totalFromAttendees.toFixed(1));
setStep('summary');
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -41,13 +70,12 @@ export function EventCompletionForm({
}
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,
attendees: attendeeData.length > 0 ? attendeeData : undefined,
});
} catch (error) {
console.error('Error completing event:', error);
@@ -58,9 +86,29 @@ export function EventCompletionForm({
setPhotos(photoMetadata);
};
if (step === 'attendees') {
return (
<AttendeeManagement
eventId={eventId}
onClose={onCancel}
onSave={handleAttendeesSave}
isLoading={isLoading}
/>
);
}
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>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">Complete Event</h3>
<button
onClick={() => setStep('attendees')}
className="text-sm text-blue-600 hover:text-blue-700"
type="button"
>
← Back to Attendees
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Litter Collected Input */}

View File

@@ -1,9 +1,11 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { format } from 'date-fns';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import type { CleanupEvent } from '@/types';
import { EventCompletionForm } from './EventCompletionForm';
import { CompletedEventDetails } from './CompletedEventDetails';
// Fix for default marker icons
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
@@ -24,7 +26,16 @@ interface EventDetailModalProps {
onClose: () => void;
onRegister: () => void;
onUnregister: () => void;
onComplete: (data: { litterCollected: number; photos: string[] }) => Promise<void>;
onComplete: (data: {
litterCollected: number;
photos: string[];
attendees?: Array<{
userId: string;
attended: boolean;
litterCollected?: number;
contributionNote?: string;
}>;
}) => Promise<void>;
onCancel?: () => void;
isLoading?: boolean;
}
@@ -43,6 +54,7 @@ export function EventDetailModal({
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<L.Map | null>(null);
const [showCompletionForm, setShowCompletionForm] = useState(false);
const navigate = useNavigate();
// Check if event date has passed
const hasEventPassed = new Date(event.scheduledDate) < new Date();
@@ -140,148 +152,184 @@ export function EventDetailModal({
{/* Content - Scrollable */}
<div className="overflow-y-auto flex-1 bg-white">
<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 flex-shrink-0" 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>
{/* Event Details - Hide when showing completion form */}
{!(canComplete && showCompletionForm) && (
<>
<div className="space-y-4">
<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="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 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="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>
</div>
</div>
</div>
{/* Equipment Information */}
<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="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
/>
</svg>
<div className="flex-1">
<p className="text-sm font-medium text-gray-900">Equipment</p>
{event.equipmentProvided ? (
<div className="mt-1">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
✓ Equipment Provided
</span>
<p className="text-sm text-gray-600 mt-2">
All necessary cleanup equipment will be provided by the organizer.
</p>
</div>
) : (
<div className="mt-1">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800">
Bring Your Own
</span>
{event.requiredItems && event.requiredItems.length > 0 ? (
<div className="mt-2">
<p className="text-sm text-gray-600 mb-2">Please bring the following items:</p>
<ul className="space-y-1">
{event.requiredItems.map((item, index) => (
<li key={index} className="text-sm text-gray-700 flex items-center">
<span className="text-blue-600 mr-2">•</span>
{item}
</li>
))}
</ul>
</div>
) : (
<p className="text-sm text-gray-600 mt-2">
Participants should bring their own cleanup equipment.
</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 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 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>
</div>
</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"
/>
))}
{/* 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>
</div>
{/* Completed Event Details */}
{event.status === 'completed' && event.litterCollected !== undefined && (
<CompletedEventDetails
eventId={event.id}
totalLitterCollected={event.litterCollected}
photos={event.photos}
/>
)}
{/* 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> */}
</>
)}
{/* 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" />
{/* Completion Form for Organizers */}
{canComplete && showCompletionForm && (
<div className="mt-6">
<EventCompletionForm
eventId={event.id}
onSubmit={async (data) => {
await onComplete(data);
setShowCompletionForm(false);
}}
onCancel={() => setShowCompletionForm(false)}
isLoading={isLoading}
/>
</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 */}
{event.status === 'upcoming' && (
{event.status === 'upcoming' && !showCompletionForm && (
<div className="bg-gray-50 px-6 py-4 border-t border-gray-200 flex-shrink-0">
{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 && (
<div className="space-y-3">
<div className="flex gap-3">
<button
onClick={onCancel}
onClick={() => setShowCompletionForm(!showCompletionForm)}
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"
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"
>
Cancel Event
{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>
<button
onClick={() => navigate(`/events/${event.id}/checkin`)}
className="w-full bg-purple-600 text-white py-3 px-6 rounded-lg hover:bg-purple-700 transition-colors font-medium flex items-center justify-center"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
</svg>
QR Code Check-In
</button>
</div>
) : isOrganizer && !hasEventPassed ? (
<div className="space-y-3">
@@ -299,6 +347,15 @@ export function EventDetailModal({
</span>
</div>
</div>
<button
onClick={() => navigate(`/events/${event.id}/checkin`)}
className="w-full bg-purple-600 text-white py-3 px-6 rounded-lg hover:bg-purple-700 transition-colors font-medium flex items-center justify-center"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
</svg>
QR Code Check-In
</button>
{onCancel && (
<button
onClick={onCancel}

View File

@@ -25,6 +25,8 @@ interface EventFormData {
scheduledDate: string;
scheduledTime: string;
duration: number;
equipmentProvided: boolean;
requiredItems: string[];
}
interface EventFormProps {
@@ -44,8 +46,12 @@ export function EventForm({ onSubmit, onCancel, initialData, isSubmitting = fals
scheduledDate: initialData?.scheduledDate || '',
scheduledTime: initialData?.scheduledTime || '',
duration: initialData?.duration || 120,
equipmentProvided: initialData?.equipmentProvided || false,
requiredItems: initialData?.requiredItems || [],
});
const [newItem, setNewItem] = useState('');
const [selectedLocation, setSelectedLocation] = useState<Coordinates | null>(
initialData?.latitude && initialData?.longitude
? { latitude: initialData.latitude, longitude: initialData.longitude }
@@ -328,6 +334,130 @@ export function EventForm({ onSubmit, onCancel, initialData, isSubmitting = fals
{errors.duration && <p className="mt-1 text-sm text-red-600">{errors.duration}</p>}
</div>
{/* Equipment Section */}
<div className="border border-gray-200 rounded-lg p-4 bg-gray-50">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Equipment</h3>
{/* Equipment Provided Toggle */}
<div className="mb-4">
<label className="flex items-center cursor-pointer">
<input
type="checkbox"
checked={formData.equipmentProvided}
onChange={(e) => {
setFormData(prev => ({ ...prev, equipmentProvided: e.target.checked }));
// Clear required items if equipment is provided
if (e.target.checked) {
setFormData(prev => ({ ...prev, requiredItems: [] }));
}
}}
className="w-5 h-5 text-blue-600 border-gray-300 rounded focus:ring-2 focus:ring-blue-500"
/>
<span className="ml-3 text-sm font-medium text-gray-700">
Equipment will be provided by organizer
</span>
</label>
<p className="mt-1 ml-8 text-sm text-gray-500">
Check this if you'll supply all necessary cleanup equipment
</p>
</div>
{/* Required Items List (only shown if equipment not provided) */}
{!formData.equipmentProvided && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Items Participants Need to Bring
</label>
<p className="text-sm text-gray-500 mb-3">
Add items that participants should bring (e.g., gloves, litter picker, bags, coat)
</p>
{/* Add Item Input */}
<div className="flex gap-2 mb-3">
<input
type="text"
value={newItem}
onChange={(e) => setNewItem(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (newItem.trim() && !formData.requiredItems.includes(newItem.trim())) {
setFormData(prev => ({
...prev,
requiredItems: [...prev.requiredItems, newItem.trim()]
}));
setNewItem('');
}
}
}}
placeholder="e.g., Gloves, Litter picker, Bags"
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
/>
<button
type="button"
onClick={() => {
if (newItem.trim() && !formData.requiredItems.includes(newItem.trim())) {
setFormData(prev => ({
...prev,
requiredItems: [...prev.requiredItems, newItem.trim()]
}));
setNewItem('');
}
}}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium"
>
Add
</button>
</div>
{/* Required Items List */}
{formData.requiredItems.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium text-gray-700">Required Items:</p>
<ul className="space-y-1">
{formData.requiredItems.map((item, index) => (
<li
key={index}
className="flex items-center justify-between bg-white px-3 py-2 rounded-lg border border-gray-200"
>
<span className="text-sm text-gray-700">• {item}</span>
<button
type="button"
onClick={() => {
setFormData(prev => ({
...prev,
requiredItems: prev.requiredItems.filter((_, i) => i !== index)
}));
}}
className="text-red-600 hover:text-red-800 text-sm font-medium"
>
Remove
</button>
</li>
))}
</ul>
</div>
)}
{formData.requiredItems.length === 0 && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<p className="text-sm text-blue-700">
💡 Tip: Add common items like gloves, litter pickers, bags, or appropriate clothing
</p>
</div>
)}
</div>
)}
{formData.equipmentProvided && (
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
<p className="text-sm text-green-700">
✓ Participants will be informed that all equipment will be provided
</p>
</div>
)}
</div>
{/* Location Name */}
<div>
<label htmlFor="locationName" className="block text-sm font-medium text-gray-700 mb-1">

View File

@@ -85,30 +85,41 @@ export function EventList({ events, onEventClick, onRegister, isLoading = false
};
return (
<div className="space-y-4">
<div className="space-y-3">
{events.map((event) => (
<div
key={event.id}
className="bg-white rounded-lg shadow hover:shadow-lg transition-all cursor-pointer border-2 border-transparent hover:border-blue-200"
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">
<div className="p-4">
{/* Header Row */}
<div className="flex items-start justify-between gap-4 mb-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-lg font-semibold text-gray-900 truncate">{event.title}</h3>
{getStatusBadge(event.status)}
<span className="text-sm text-gray-500">
{event.participantCount} {event.participantCount === 1 ? 'participant' : 'participants'}
</span>
</div>
<p className="text-sm text-gray-600 line-clamp-1">{event.description}</p>
</div>
{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 whitespace-nowrap"
>
Quick Register
</button>
)}
</div>
<div className="space-y-2 mb-4">
{/* Info Row - Horizontal Layout */}
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm text-gray-600">
{/* 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">
<div className="flex items-center">
<svg className="h-4 w-4 mr-1.5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
@@ -116,14 +127,14 @@ export function EventList({ events, onEventClick, onRegister, isLoading = false
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>
<span className="whitespace-nowrap">
{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">
<div className="flex items-center">
<svg className="h-4 w-4 mr-1.5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
@@ -135,8 +146,8 @@ export function EventList({ events, onEventClick, onRegister, isLoading = false
</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">
<div className="flex items-center min-w-0 flex-1">
<svg className="h-4 w-4 mr-1.5 text-gray-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
@@ -145,52 +156,46 @@ export function EventList({ events, onEventClick, onRegister, isLoading = false
/>
<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>
<span className="truncate">{event.locationName}</span>
</div>
</div>
{/* Description Preview */}
<p className="text-sm text-gray-600 line-clamp-2 mb-4">{event.description}</p>
{/* Participants */}
<div className="flex items-center">
<svg className="h-4 w-4 mr-1.5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
<span>{event.participantCount} {event.participantCount === 1 ? 'participant' : 'participants'}</span>
</div>
{/* 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">
{/* Completed Event Info */}
{event.status === 'completed' && event.litterCollected !== undefined && (
<div className="flex items-center text-green-700 font-medium">
<svg className="h-4 w-4 mr-1.5 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>
<span>{event.litterCollected} kg collected</span>
</div>
</div>
)}
)}
{/* Action Area */}
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
{/* View Details Link */}
<button
onClick={() => onEventClick(event)}
className="text-blue-600 hover:text-blue-700 font-medium text-sm flex items-center gap-1"
className="text-blue-600 hover:text-blue-700 font-medium flex items-center gap-1 ml-auto"
>
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>
{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>

View File

@@ -0,0 +1,373 @@
import { useEffect, useRef, useState } from 'react';
import { Html5Qrcode } from 'html5-qrcode';
interface QRCodeScannerProps {
onScan: (userId: string) => void;
onError?: (error: string) => void;
}
export function QRCodeScanner({ onScan, onError }: QRCodeScannerProps) {
const [isScanning, setIsScanning] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showManualInput, setShowManualInput] = useState(false);
const [manualInput, setManualInput] = useState('');
const [showTroubleshooting, setShowTroubleshooting] = useState(false);
const scannerRef = useRef<Html5Qrcode | null>(null);
const scannerIdRef = useRef('qr-reader');
// Detect if user is on iOS
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
useEffect(() => {
return () => {
// Cleanup on unmount
if (scannerRef.current) {
scannerRef.current.stop().catch(console.error);
}
};
}, []);
const startScanning = async () => {
try {
setError(null);
// First, check if we can get camera devices (this will trigger permission prompt)
try {
const devices = await Html5Qrcode.getCameras();
if (!devices || devices.length === 0) {
throw new Error('No camera found on this device.');
}
} catch (permErr: any) {
// If we can't get cameras, it might be a permission issue
console.error('Camera access error:', permErr);
}
const scanner = new Html5Qrcode(scannerIdRef.current);
scannerRef.current = scanner;
await scanner.start(
{ facingMode: 'environment' }, // Use back camera
{
fps: 10,
qrbox: { width: 250, height: 250 },
},
(decodedText) => {
try {
const data = JSON.parse(decodedText);
if (data.type === 'attendee-checkin' && data.userId) {
onScan(data.userId);
} else {
const errorMsg = 'Invalid QR code format';
setError(errorMsg);
onError?.(errorMsg);
}
} catch (err) {
const errorMsg = 'Failed to parse QR code';
setError(errorMsg);
onError?.(errorMsg);
}
},
() => {
// Ignore scanning errors (happens continuously while scanning)
}
);
setIsScanning(true);
} catch (err: any) {
let errorMsg = 'Failed to start camera';
console.error('Scanner error:', err);
if (err.name === 'NotAllowedError' || err.message?.includes('NotAllowedError')) {
errorMsg = 'Camera permission denied. Please click "Allow" when your browser asks for camera access.';
} else if (err.name === 'NotFoundError' || err.message?.includes('NotFoundError')) {
errorMsg = 'No camera found on this device.';
} else if (err.name === 'NotReadableError' || err.message?.includes('NotReadableError')) {
errorMsg = 'Camera is already in use by another application.';
} else if (err.name === 'OverconstrainedError' || err.message?.includes('OverconstrainedError')) {
errorMsg = 'Camera constraints not supported. Trying with default camera...';
// Try again without facingMode constraint
setTimeout(() => startScanningWithoutConstraints(), 1000);
return;
} else if (err.message?.includes('secure') || err.message?.includes('https')) {
errorMsg = 'Camera access requires HTTPS. Use manual input instead.';
} else if (err.message) {
errorMsg = `Camera error: ${err.message}`;
}
setError(errorMsg);
onError?.(errorMsg);
setIsScanning(false);
setShowManualInput(true); // Show manual input as fallback
}
};
const startScanningWithoutConstraints = async () => {
try {
setError(null);
const scanner = new Html5Qrcode(scannerIdRef.current);
scannerRef.current = scanner;
// Try with any available camera
const devices = await Html5Qrcode.getCameras();
if (devices && devices.length > 0 && devices[0]) {
await scanner.start(
devices[0].id, // Use first available camera
{
fps: 10,
qrbox: { width: 250, height: 250 },
},
(decodedText) => {
try {
const data = JSON.parse(decodedText);
if (data.type === 'attendee-checkin' && data.userId) {
onScan(data.userId);
} else {
const errorMsg = 'Invalid QR code format';
setError(errorMsg);
onError?.(errorMsg);
}
} catch (err) {
const errorMsg = 'Failed to parse QR code';
setError(errorMsg);
onError?.(errorMsg);
}
},
() => {
// Ignore scanning errors
}
);
setIsScanning(true);
}
} catch (err: any) {
console.error('Fallback scanner error:', err);
setError('Unable to access camera. Please use manual check-in instead.');
setShowManualInput(true);
}
};
const stopScanning = async () => {
if (scannerRef.current) {
try {
await scannerRef.current.stop();
scannerRef.current = null;
setIsScanning(false);
} catch (err) {
console.error('Error stopping scanner:', err);
}
}
};
const handleManualSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!manualInput.trim()) return;
try {
const data = JSON.parse(manualInput);
if (data.type === 'attendee-checkin' && data.userId) {
onScan(data.userId);
setManualInput('');
setShowManualInput(false);
} else {
setError('Invalid QR code data format');
}
} catch (err) {
setError('Invalid JSON format. Please scan the QR code or paste valid data.');
}
};
return (
<div className="space-y-4">
{/* Info message before scanning */}
{!isScanning && !error && (
<div className="bg-blue-50 border border-blue-200 text-blue-800 px-4 py-3 rounded-lg">
<div className="flex items-start">
<svg className="w-5 h-5 mr-2 mt-0.5 flex-shrink-0" 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>
<div className="text-sm">
<p className="font-medium mb-1">Camera Permission Required</p>
<p>When you click "Start Camera Scan", your browser will ask for camera permission. Please click "Allow" to enable QR code scanning.</p>
</div>
</div>
</div>
)}
<div
id={scannerIdRef.current}
className="w-full rounded-lg overflow-hidden bg-black"
style={{ minHeight: isScanning ? '300px' : '0' }}
/>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
<p className="font-medium">{error}</p>
{error.includes('HTTPS') && (
<p className="text-sm mt-2">
💡 Tip: Camera access requires a secure connection (HTTPS) in most browsers. Use manual input below as an alternative.
</p>
)}
{error.includes('permission') && (
<p className="text-sm mt-2">
💡 Tip: Check your browser's address bar for camera permission settings, or use manual input below.
</p>
)}
</div>
)}
<div className="flex gap-3">
{!isScanning ? (
<>
<button
onClick={startScanning}
className="flex-1 bg-blue-600 text-white px-4 py-3 rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
Start Camera Scan
</button>
<button
onClick={() => setShowManualInput(!showManualInput)}
className="flex-1 bg-gray-600 text-white px-4 py-3 rounded-lg hover:bg-gray-700 transition-colors font-medium"
>
{showManualInput ? 'Hide' : 'Manual Input'}
</button>
</>
) : (
<button
onClick={stopScanning}
className="flex-1 bg-red-600 text-white px-4 py-3 rounded-lg hover:bg-red-700 transition-colors font-medium"
>
Stop Scanning
</button>
)}
</div>
{isScanning && (
<p className="text-sm text-gray-600 text-center">
Position the QR code within the frame to scan
</p>
)}
{/* Manual Input Form */}
{showManualInput && !isScanning && (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<h3 className="text-sm font-semibold text-gray-900 mb-2">
Manual Check-In
</h3>
<p className="text-xs text-gray-600 mb-3">
Paste the QR code data or enter the user's Clerk ID directly
</p>
<form onSubmit={handleManualSubmit} className="space-y-3">
<textarea
value={manualInput}
onChange={(e) => setManualInput(e.target.value)}
placeholder='{"userId":"user_xxx","type":"attendee-checkin"}'
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono"
rows={3}
/>
<button
type="submit"
className="w-full bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition-colors font-medium"
>
Check In User
</button>
</form>
</div>
)}
{/* Troubleshooting Button */}
<button
onClick={() => setShowTroubleshooting(!showTroubleshooting)}
className="w-full text-sm text-gray-600 hover:text-gray-900 underline"
>
{showTroubleshooting ? '▼ Hide' : '▶'} Camera not working? Troubleshooting guide
</button>
{/* Troubleshooting Guide */}
{showTroubleshooting && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 space-y-4">
<div>
<h3 className="font-semibold text-gray-900 mb-2 flex items-center">
<svg className="w-5 h-5 mr-2 text-amber-600" 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 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
Camera Troubleshooting
</h3>
</div>
{/* iOS/iPhone Instructions */}
{isIOS && (
<div className="bg-white rounded-lg p-3 border border-amber-300">
<h4 className="font-semibold text-gray-900 mb-2 flex items-center">
📱 iPhone/iPad Camera Permissions
</h4>
<ol className="text-sm text-gray-700 space-y-2 list-decimal list-inside">
<li>Open your iPhone <strong>Settings</strong> app</li>
<li>Scroll down and tap <strong>Safari</strong> (or <strong>Chrome</strong> if using Chrome)</li>
<li>Tap <strong>Camera</strong></li>
<li>Select <strong>"Ask"</strong> or <strong>"Allow"</strong></li>
<li>Close Settings and return to this page</li>
<li>Refresh the page and try scanning again</li>
</ol>
<div className="mt-3 p-2 bg-blue-50 rounded border border-blue-200">
<p className="text-xs text-blue-800">
<strong>Note:</strong> If you previously denied camera access, you must change it in Settings. The browser won't ask again until you do.
</p>
</div>
</div>
)}
{/* Safari-specific */}
{isSafari && !isIOS && (
<div className="bg-white rounded-lg p-3 border border-amber-300">
<h4 className="font-semibold text-gray-900 mb-2 flex items-center">
🧭 Safari Camera Permissions
</h4>
<ol className="text-sm text-gray-700 space-y-2 list-decimal list-inside">
<li>Click <strong>Safari</strong> in the menu bar</li>
<li>Select <strong>Settings for This Website</strong></li>
<li>Find <strong>Camera</strong> and set to <strong>"Allow"</strong></li>
<li>Refresh the page and try again</li>
</ol>
</div>
)}
{/* Chrome/General */}
{!isSafari && (
<div className="bg-white rounded-lg p-3 border border-amber-300">
<h4 className="font-semibold text-gray-900 mb-2 flex items-center">
🌐 Chrome/Browser Camera Permissions
</h4>
<ol className="text-sm text-gray-700 space-y-2 list-decimal list-inside">
<li>Look for a camera icon in your browser's address bar</li>
<li>Click it and select <strong>"Allow"</strong></li>
<li>Or click the lock/info icon next to the URL</li>
<li>Find <strong>Camera</strong> permissions and set to <strong>"Allow"</strong></li>
<li>Refresh the page and try scanning again</li>
</ol>
</div>
)}
{/* General Tips */}
<div className="bg-white rounded-lg p-3 border border-amber-300">
<h4 className="font-semibold text-gray-900 mb-2">💡 Other Common Issues</h4>
<ul className="text-sm text-gray-700 space-y-2 list-disc list-inside">
<li><strong>Camera in use:</strong> Close other apps/tabs using your camera</li>
<li><strong>No camera found:</strong> Make sure your device has a working camera</li>
<li><strong>HTTPS required:</strong> Camera access only works on secure (https://) sites</li>
<li><strong>Still not working?</strong> Use the "Manual Input" option or click "Check In" buttons in the attendee list below</li>
</ul>
</div>
{/* Quick Alternative */}
<div className="bg-green-50 rounded-lg p-3 border border-green-300">
<h4 className="font-semibold text-green-900 mb-2">✅ Easy Alternative</h4>
<p className="text-sm text-green-800">
Skip the camera entirely! Just scroll down to the attendee list and click the <strong>"Check In"</strong> button next to each person's name.
</p>
</div>
</div>
)}
</div>
);
}

View File

@@ -4,4 +4,7 @@ export { EventList } from './EventList';
export { EventFilters } from './EventFilters';
export { EventDetailModal } from './EventDetailModal';
export { EventCompletionForm } from './EventCompletionForm';
export { AttendeeManagement } from './AttendeeManagement';
export { CompletedEventDetails } from './CompletedEventDetails';
export { EmailPromptModal } from './EmailPromptModal';
export type { EventFilterState } from './EventFilters';

View File

@@ -0,0 +1,58 @@
import { QRCodeSVG } from 'qrcode.react';
import { useUser } from '@clerk/clerk-react';
import { useNavigate } from 'react-router-dom';
export function UserQRCode() {
const { user } = useUser();
const navigate = useNavigate();
if (!user) return null;
// Generate QR code data with user ID
const qrData = JSON.stringify({
userId: user.id,
type: 'attendee-checkin',
timestamp: Date.now(),
});
return (
<div className="bg-white rounded-lg shadow-md p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Your Check-in QR Code
</h3>
<p className="text-sm text-gray-600 mb-4">
Show this QR code to event organizers to check in at events
</p>
<div className="flex justify-center p-4 bg-gray-50 rounded-lg">
<QRCodeSVG
value={qrData}
size={200}
level="H"
includeMargin={true}
/>
</div>
<p className="text-xs text-gray-500 mt-4 text-center">
{user.username || user.firstName || 'User'}
</p>
<button
onClick={() => navigate('/qr-code/print')}
className="w-full mt-4 bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 transition-colors font-medium inline-flex items-center justify-center"
>
<svg
className="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z"
/>
</svg>
Print QR Code
</button>
</div>
);
}

View File

@@ -0,0 +1 @@
export { UserQRCode } from './UserQRCode';

View File

@@ -0,0 +1,315 @@
import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useUser } from '@clerk/clerk-react';
import { QRCodeScanner } from '@/components/event/QRCodeScanner';
import { apiClient } from '@/services/api';
import { CleanupEvent, EventAttendee } from '@/types';
export function EventCheckInPage() {
const { eventId } = useParams<{ eventId: string }>();
const { user } = useUser();
const [event, setEvent] = useState<CleanupEvent | null>(null);
const [attendees, setAttendees] = useState<EventAttendee[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
useEffect(() => {
loadEventData();
}, [eventId]);
const loadEventData = async () => {
if (!eventId) return;
try {
setLoading(true);
setError(null);
// Load event details
const eventResponse = await apiClient.get<CleanupEvent>(`/events/${eventId}`);
setEvent(eventResponse);
// Check if user is the organizer
if (eventResponse.organizerClerkId !== user?.id) {
setError('Only the event organizer can access check-in');
return;
}
// Load attendees
const attendeesResponse = await apiClient.get<EventAttendee[]>(
`/events/${eventId}/attendees`
);
setAttendees(attendeesResponse);
} catch (err: any) {
console.error('Error loading event data:', err);
setError(err.message || 'Failed to load event data');
} finally {
setLoading(false);
}
};
const handleScan = async (clerkUserId: string) => {
if (!eventId) return;
try {
// Send the Clerk ID to backend and let it handle the lookup
const response = await apiClient.post<{
success: boolean;
username?: string;
firstName?: string;
lastName?: string;
wasWalkIn?: boolean;
}>(
`/events/${eventId}/checkin`,
{ clerkUserId }
);
const displayName = response.firstName && response.lastName
? `${response.firstName} ${response.lastName}`
: response.username || 'user';
if (response.wasWalkIn) {
setSuccessMessage(`✨ ${displayName} registered and checked in as walk-in!`);
} else {
setSuccessMessage(`✓ Successfully checked in ${displayName}`);
}
// Reload attendees to get updated status
setTimeout(() => {
loadEventData();
setSuccessMessage(null);
}, 2000);
} catch (err: any) {
console.error('Error checking in user:', err);
setError(err.message || 'Failed to check in user');
setTimeout(() => setError(null), 3000);
}
};
const handleScanError = (errorMsg: string) => {
setError(errorMsg);
setTimeout(() => setError(null), 3000);
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading event...</p>
</div>
</div>
);
}
if (error && !event) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-md p-6 max-w-md w-full">
<div className="text-red-600 mb-4">
<svg
className="w-12 h-12 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h2 className="text-xl font-semibold text-gray-900 text-center mb-2">
Access Denied
</h2>
<p className="text-gray-600 text-center mb-4">{error}</p>
<Link
to="/events"
className="block w-full bg-blue-600 text-white text-center px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
>
Back to Events
</Link>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 py-8 px-4">
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="mb-6">
<Link
to={`/events/${eventId}`}
className="text-blue-600 hover:text-blue-700 mb-4 inline-flex items-center"
>
<svg
className="w-5 h-5 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
Back to Event
</Link>
<h1 className="text-3xl font-bold text-gray-900">Event Check-In</h1>
{event && (
<p className="text-gray-600 mt-2">{event.title}</p>
)}
</div>
{/* Success Message */}
{successMessage && (
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg mb-6">
<div className="flex items-center">
<svg
className="w-5 h-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>
{successMessage}
</div>
</div>
)}
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
{error}
</div>
)}
{/* QR Scanner */}
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">
Scan Attendee QR Code
</h2>
<QRCodeScanner onScan={handleScan} onError={handleScanError} />
</div>
{/* Attendee List */}
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">
Registered Attendees ({attendees.length})
</h2>
{attendees.length === 0 ? (
<p className="text-gray-500 text-center py-8">
No attendees registered yet
</p>
) : (
<div className="space-y-3">
{attendees.map((attendee) => (
<div
key={attendee.id}
className={`flex items-center justify-between p-3 rounded-lg border ${
attendee.attended
? 'bg-green-50 border-green-200'
: 'bg-gray-50 border-gray-200'
}`}
>
<div className="flex items-center gap-3">
{attendee.user.avatarUrl ? (
<img
src={attendee.user.avatarUrl}
alt={attendee.user.username}
className="w-10 h-10 rounded-full"
/>
) : (
<div className="w-10 h-10 rounded-full bg-blue-600 flex items-center justify-center text-white font-semibold">
{attendee.user.username.charAt(0).toUpperCase()}
</div>
)}
<div>
<p className="font-medium text-gray-900">
{attendee.user.firstName && attendee.user.lastName
? `${attendee.user.firstName} ${attendee.user.lastName}`
: attendee.user.username}
</p>
<p className="text-sm text-gray-500">
@{attendee.user.username}
</p>
</div>
</div>
{attendee.attended ? (
<div className="flex items-center text-green-600">
<svg
className="w-6 h-6"
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="ml-1 text-sm font-medium">Checked In</span>
</div>
) : (
<button
onClick={async () => {
try {
const response = await apiClient.post<{
success: boolean;
username?: string;
firstName?: string;
lastName?: string;
wasWalkIn?: boolean;
}>(
`/events/${eventId}/checkin`,
{ clerkUserId: attendee.user.clerkId }
);
const displayName = response.firstName && response.lastName
? `${response.firstName} ${response.lastName}`
: response.username || attendee.user.username;
if (response.wasWalkIn) {
setSuccessMessage(`✨ ${displayName} registered and checked in as walk-in!`);
} else {
setSuccessMessage(`✓ Successfully checked in ${displayName}`);
}
// Reload attendees to get updated status
setTimeout(() => {
loadEventData();
setSuccessMessage(null);
}, 2000);
} catch (err: any) {
console.error('Error checking in user:', err);
setError(err.message || 'Failed to check in user');
setTimeout(() => setError(null), 3000);
}
}}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium"
>
Check In
</button>
)}
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
export default EventCheckInPage;

View File

@@ -1,12 +1,14 @@
import { useState, useEffect } from 'react';
import { useUser } from '@clerk/clerk-react';
import { EventForm, EventList, EventFilters, EventDetailModal } from '@/components/event';
import { useNavigate } from 'react-router-dom';
import { EventForm, EventList, EventFilters, EventDetailModal, EmailPromptModal } from '@/components/event';
import type { EventFilterState } from '@/components/event';
import { eventService } from '@/services/eventService';
import type { CleanupEvent } from '@/types';
export function EventsPage() {
const { isSignedIn, user } = useUser();
const navigate = useNavigate();
const [events, setEvents] = useState<CleanupEvent[]>([]);
const [filteredEvents, setFilteredEvents] = useState<CleanupEvent[]>([]);
const [filters, setFilters] = useState<EventFilterState>({
@@ -19,6 +21,8 @@ export function EventsPage() {
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showEmailPrompt, setShowEmailPrompt] = useState(false);
const [pendingEventId, setPendingEventId] = useState<string | null>(null);
// Load events
useEffect(() => {
@@ -75,11 +79,24 @@ export function EventsPage() {
filtered = filtered.filter(event => new Date(event.scheduledDate) <= endDate);
}
// Sort by date (upcoming first, then by date)
// Sort: upcoming events first (ascending), then completed/cancelled (descending - most recent first)
filtered.sort((a, b) => {
// Priority order: upcoming > completed > cancelled
const statusPriority = { upcoming: 0, completed: 1, cancelled: 2 };
const priorityA = statusPriority[a.status];
const priorityB = statusPriority[b.status];
if (priorityA !== priorityB) {
return priorityA - priorityB;
}
// Within same status, sort by date
const dateA = new Date(a.scheduledDate).getTime();
const dateB = new Date(b.scheduledDate).getTime();
return dateA - dateB;
// Upcoming: ascending (next event first)
// Completed/Cancelled: descending (most recent first)
return a.status === 'upcoming' ? dateA - dateB : dateB - dateA;
});
setFilteredEvents(filtered);
@@ -135,6 +152,18 @@ export function EventsPage() {
return;
}
// Check if user has an email address
const hasEmail = user?.primaryEmailAddress?.emailAddress;
if (!hasEmail) {
setPendingEventId(eventId);
setShowEmailPrompt(true);
return;
}
await performRegistration(eventId);
};
const performRegistration = async (eventId: string) => {
try {
setIsSubmitting(true);
setError(null);
@@ -149,6 +178,22 @@ export function EventsPage() {
}
};
const handleAddEmail = () => {
setShowEmailPrompt(false);
// Navigate to user profile where they can manage their Clerk account
if (user?.id) {
navigate(`/profile/${user.id}`);
}
};
const handleContinueWithoutEmail = async () => {
setShowEmailPrompt(false);
if (pendingEventId) {
await performRegistration(pendingEventId);
setPendingEventId(null);
}
};
const handleUnregister = async () => {
if (!selectedEvent) return;
@@ -166,7 +211,16 @@ export function EventsPage() {
}
};
const handleCompleteEvent = async (data: { litterCollected: number; photos: string[] }) => {
const handleCompleteEvent = async (data: {
litterCollected: number;
photos: string[];
attendees?: Array<{
userId: string;
attended: boolean;
litterCollected?: number;
contributionNote?: string;
}>;
}) => {
if (!selectedEvent) return;
try {
@@ -301,6 +355,18 @@ export function EventsPage() {
isLoading={isSubmitting}
/>
)}
{/* Email Prompt Modal */}
{showEmailPrompt && (
<EmailPromptModal
onClose={() => {
setShowEmailPrompt(false);
setPendingEventId(null);
}}
onAddEmail={handleAddEmail}
onContinue={handleContinueWithoutEmail}
/>
)}
</div>
);
}

View File

@@ -77,13 +77,15 @@ export function MapPage() {
const fetchEvents = async () => {
try {
setIsLoadingEvents(true);
// Fetch upcoming and completed events to show on map
// Fetch upcoming and completed events to show on map (exclude cancelled)
const data = await eventService.getEvents();
setEvents(data);
// Filter out cancelled events
const activeEvents = data.filter(event => event.status !== 'cancelled');
setEvents(activeEvents);
} catch (error) {
console.error('Failed to fetch events:', error);
// Fall back to sample data on error
setEvents(sampleEvents);
// Fall back to sample data on error (also filter cancelled)
setEvents(sampleEvents.filter(event => event.status !== 'cancelled'));
} finally {
setIsLoadingEvents(false);
}
@@ -386,42 +388,6 @@ export function MapPage() {
})
.catch((error) => {
console.error('Failed to fetch hotspots:', error);
// Use sample hotspot data for demonstration
setHotspots([
{
id: 'hotspot1',
latitude: 53.3498,
longitude: -6.2603,
radius: 500,
reportCount: 15,
severityScore: 0.75,
lastReportDate: new Date('2024-01-20'),
calculatedAt: new Date('2024-01-21'),
severity: 'high',
},
{
id: 'hotspot2',
latitude: 51.8969,
longitude: -8.4863,
radius: 450,
reportCount: 8,
severityScore: 0.45,
lastReportDate: new Date('2024-01-19'),
calculatedAt: new Date('2024-01-21'),
severity: 'medium',
},
{
id: 'hotspot3',
latitude: 53.2707,
longitude: -9.0568,
radius: 600,
reportCount: 22,
severityScore: 0.88,
lastReportDate: new Date('2024-01-21'),
calculatedAt: new Date('2024-01-21'),
severity: 'critical',
},
]);
})
.finally(() => {
setIsLoadingHotspots(false);

View File

@@ -0,0 +1,201 @@
import { useEffect } from 'react';
import { useUser } from '@clerk/clerk-react';
import { QRCodeSVG } from 'qrcode.react';
import { useNavigate } from 'react-router-dom';
export function PrintableQRCodePage() {
const { user, isLoaded } = useUser();
const navigate = useNavigate();
useEffect(() => {
// Add print-specific styles
const style = document.createElement('style');
style.textContent = `
@media print {
body * {
visibility: hidden;
}
#printable-qr-code, #printable-qr-code * {
visibility: visible;
}
#printable-qr-code {
position: absolute;
left: 0;
top: 0;
width: 100%;
}
.no-print {
display: none !important;
}
}
`;
document.head.appendChild(style);
return () => {
document.head.removeChild(style);
};
}, []);
if (!isLoaded) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading...</p>
</div>
</div>
);
}
if (!user) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-md p-6 max-w-md w-full text-center">
<p className="text-gray-600 mb-4">Please sign in to view your QR code</p>
<button
onClick={() => navigate('/')}
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 transition-colors"
>
Go Home
</button>
</div>
</div>
);
}
const qrData = JSON.stringify({
userId: user.id,
type: 'attendee-checkin',
timestamp: Date.now(),
});
const handlePrint = () => {
window.print();
};
return (
<div className="min-h-screen bg-gray-50 py-8 px-4">
{/* Action Buttons - Hidden when printing */}
<div className="max-w-4xl mx-auto mb-6 no-print">
<div className="flex items-center justify-between">
<button
onClick={() => navigate(-1)}
className="text-blue-600 hover:text-blue-700 inline-flex items-center"
>
<svg
className="w-5 h-5 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
Back
</button>
<button
onClick={handlePrint}
className="bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition-colors font-medium inline-flex items-center"
>
<svg
className="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z"
/>
</svg>
Print QR Code
</button>
</div>
</div>
{/* Printable Content */}
<div id="printable-qr-code" className="max-w-3xl mx-auto">
<div className="bg-white rounded-lg shadow-md p-8">
{/* Header */}
<div className="text-center mb-4">
<h1 className="text-2xl font-bold text-gray-900 mb-1">
Event Check-In QR Code
</h1>
<p className="text-sm text-gray-600">
Litter Scouts - Beach Cleanup Events
</p>
</div>
{/* QR Code */}
<div className="flex justify-center mb-4">
<div className="p-4 bg-white border-4 border-gray-200 rounded-lg">
<QRCodeSVG
value={qrData}
size={240}
level="H"
includeMargin={true}
/>
</div>
</div>
{/* User Info */}
<div className="text-center mb-4">
<div className="inline-flex items-center justify-center mb-2">
{user.imageUrl && (
<img
src={user.imageUrl}
alt={user.fullName || user.username || 'User'}
className="w-12 h-12 rounded-full border-2 border-gray-300"
/>
)}
</div>
<h2 className="text-xl font-semibold text-gray-900 mb-0.5">
{user.fullName || user.username || 'User'}
</h2>
<p className="text-sm text-gray-600">@{user.username}</p>
</div>
{/* Instructions */}
<div className="border-t border-gray-200 pt-4">
<h3 className="text-base font-semibold text-gray-900 mb-2 text-center">
How to Use
</h3>
<div className="space-y-1.5 text-sm text-gray-700">
<div className="flex items-start">
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-blue-100 text-blue-600 font-semibold text-xs mr-2 flex-shrink-0 mt-0.5">
1
</span>
<p>Bring this QR code to any Litter Scouts cleanup event</p>
</div>
<div className="flex items-start">
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-blue-100 text-blue-600 font-semibold text-xs mr-2 flex-shrink-0 mt-0.5">
2
</span>
<p>Show it to the event organizer for quick check-in</p>
</div>
<div className="flex items-start">
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-blue-100 text-blue-600 font-semibold text-xs mr-2 flex-shrink-0 mt-0.5">
3
</span>
<p>Your attendance will be automatically recorded</p>
</div>
</div>
</div>
{/* Footer */}
<div className="mt-4 pt-4 border-t border-gray-200 text-center text-xs text-gray-500">
<p>This QR code is unique to your account - Keep it safe and don't share with others</p>
</div>
</div>
</div>
</div>
);
}
export default PrintableQRCodePage;

View File

@@ -6,6 +6,7 @@ import { userService, type UserProfile, type UserActivity } from '@/services/use
import type { Report, CleanupEvent } from '@/types';
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
import { NotificationSettings, type NotificationPreferences, BadgeDisplay } from '@/components/auth';
import { UserQRCode } from '@/components/user/UserQRCode';
import 'leaflet/dist/leaflet.css';
import L from 'leaflet';
@@ -39,6 +40,7 @@ export function UserProfilePage() {
firstName: '',
lastName: '',
username: '',
email: '',
});
const [notificationPreferences, setNotificationPreferences] = useState<NotificationPreferences>({
notificationEmail: true,
@@ -86,6 +88,7 @@ export function UserProfilePage() {
firstName: profileData.firstName || '',
lastName: profileData.lastName || '',
username: profileData.username || '',
email: profileData.email || '',
});
// Set notification preferences from profile
@@ -95,7 +98,9 @@ export function UserProfilePage() {
areasOfInterest: profileData.areasOfInterest || [],
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load profile');
console.error('Error loading profile data:', err);
const errorMessage = err instanceof Error ? err.message : 'Failed to load profile';
setError(errorMessage);
} finally {
setLoading(false);
}
@@ -112,6 +117,7 @@ export function UserProfilePage() {
firstName: profile.firstName || '',
lastName: profile.lastName || '',
username: profile.username || '',
email: profile.email || '',
});
}
};
@@ -235,6 +241,20 @@ export function UserProfilePage() {
placeholder="Username"
className="px-3 py-1 border border-gray-300 rounded-md w-full"
/>
<div className="relative">
<input
type="email"
value={editForm.email}
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
placeholder="Email address (optional)"
className="px-3 py-1 border border-gray-300 rounded-md w-full"
/>
{!profile.email && (
<p className="text-xs text-blue-600 mt-1">
💡 Add an email to receive email notifications
</p>
)}
</div>
</div>
) : (
<>
@@ -244,6 +264,16 @@ export function UserProfilePage() {
: profile.username}
</h1>
<p className="text-gray-600">@{profile.username}</p>
{!profile.email && (
<p className="text-sm text-amber-600 mt-1">
📧 No email address set
</p>
)}
{profile.email && (
<p className="text-sm text-gray-500 mt-1">
📧 {profile.email}
</p>
)}
</>
)}
</div>
@@ -289,7 +319,7 @@ export function UserProfilePage() {
</div>
{/* Statistics and Badge */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mt-6">
{/* Reports Submitted */}
<div className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow border border-blue-200">
<div className="flex flex-col items-center text-center space-y-3">
@@ -320,6 +350,22 @@ export function UserProfilePage() {
</div>
</div>
{/* Total Litter Collected */}
<div className="bg-gradient-to-br from-emerald-50 to-emerald-100 rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow border border-emerald-200">
<div className="flex flex-col items-center text-center space-y-3">
<div className="bg-emerald-200 rounded-full p-3">
<svg className="w-8 h-8 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>
</div>
<div>
<p className="text-sm font-medium text-emerald-700 mb-1">Litter Collected</p>
<p className="text-4xl font-bold text-emerald-600">{profile.totalLitterCollected.toFixed(1)}</p>
<p className="text-xs text-emerald-600 font-medium">kg</p>
</div>
</div>
</div>
{/* Impact Score */}
<div className="bg-gradient-to-br from-purple-50 to-purple-100 rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow border border-purple-200">
<div className="flex flex-col items-center text-center space-y-3">
@@ -377,6 +423,11 @@ export function UserProfilePage() {
<div className="p-6">
{activeTab === 'overview' && (
<div className="space-y-6">
{/* QR Code for own profile */}
{isOwnProfile && (
<UserQRCode />
)}
<div>
<h2 className="text-xl font-semibold mb-4">Contribution Map</h2>
{reports.length > 0 && reports[0] ? (

View File

@@ -9,3 +9,4 @@ export { ReportDetailDemo } from './ReportDetailDemo';
export { WhyCleanPage } from './WhyCleanPage';
export { UserProfilePage } from './UserProfilePage';
export { EnvironmentalDataDemo } from './EnvironmentalDataDemo';
export { PrintableQRCodePage } from './PrintableQRCodePage';

View File

@@ -46,11 +46,24 @@ export class ApiClient {
});
if (!response.ok) {
const error: ApiError = await response.json();
let error: ApiError;
try {
error = await response.json();
} catch {
// If response is not JSON, create a generic error
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
throw new Error(error.message || 'An error occurred');
}
return response.json();
// Handle empty responses (like DELETE)
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return response.json();
}
// For non-JSON responses, return empty object
return {} as T;
}
async get<T>(endpoint: string, options?: RequestInit): Promise<T> {

View File

@@ -101,7 +101,43 @@ export const eventService = {
},
// Complete event
async completeEvent(eventId: string, data: { litterCollected: number; photos?: string[] }): Promise<CleanupEvent> {
return apiClient.post<CleanupEvent>(`/events/${eventId}/complete`, data);
async completeEvent(
eventId: string,
data: {
litterCollected: number;
photos?: string[];
attendees?: Array<{
userId: string;
attended: boolean;
litterCollected?: number;
contributionNote?: string;
}>;
}
): Promise<CleanupEvent> {
try {
// If attendees data is provided, update them first
if (data.attendees && data.attendees.length > 0) {
console.log('Updating attendees:', data.attendees);
await apiClient.post(`/events/${eventId}/attendees/bulk`, {
attendees: data.attendees,
});
console.log('Attendees updated successfully');
}
// Then complete the event
console.log('Completing event with data:', {
litterCollected: data.litterCollected,
photos: data.photos,
});
const result = await apiClient.post<CleanupEvent>(`/events/${eventId}/complete`, {
litterCollected: data.litterCollected,
photos: data.photos,
});
console.log('Event completed successfully:', result);
return result;
} catch (error) {
console.error('Error in completeEvent:', error);
throw error;
}
},
};

View File

@@ -5,6 +5,7 @@ import type { User, Report, CleanupEvent } from '@/types';
export interface UserProfile extends User {
reportCount: number;
eventCount: number;
totalLitterCollected: number;
notificationEmail: boolean;
notificationInApp: boolean;
areasOfInterest: Array<{
@@ -31,6 +32,7 @@ export interface UpdateProfileDTO {
firstName?: string;
lastName?: string;
username?: string;
email?: string;
}
export interface NotificationPreferences {

View File

@@ -18,7 +18,7 @@ export interface Coordinates {
export interface User {
id: string;
clerkId: string;
email: string;
email: string | null;
username: string;
firstName?: string;
lastName?: string;
@@ -62,10 +62,30 @@ export interface CleanupEvent {
participantCount: number;
litterCollected?: number; // in kilograms
photos?: string[];
equipmentProvided?: boolean; // Whether equipment is provided by organizer
requiredItems?: string[]; // List of items participants need to bring
createdAt: Date;
updatedAt: Date;
}
export interface EventAttendee {
id: string;
userId: string;
eventId: string;
registeredAt: Date;
attended: boolean;
litterCollected?: number;
contributionNote?: string;
user: {
id: string;
clerkId: string;
username: string;
firstName?: string;
lastName?: string;
avatarUrl?: string;
};
}
export interface EnvironmentalConcern {
id: string;
reportId: string;

View File

@@ -6,6 +6,7 @@ import path from 'path'
// https://vite.dev/config/
export default defineConfig({
server: {
port: 5200,
host: true, // Allow external access
allowedHosts: [
'localhost',