Compare commits
41 Commits
b48d52fcf3
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| c23b93bef2 | |||
| 2dcd1ce78d | |||
| 4baa2892b9 | |||
| 57b7cd2308 | |||
| 2102f053da | |||
| cc01e9adc8 | |||
| 31af9bd083 | |||
| e793a8e3ea | |||
| cbafe85694 | |||
| 4f06b1fe50 | |||
| c1f8c00728 | |||
| a10e6329fe | |||
| ab06cc8afd | |||
| 9eb1d3389f | |||
| 3a6db92699 | |||
| 3d7b84f376 | |||
| d1a06699e5 | |||
| d71c994f0f | |||
| d87ec20714 | |||
| e2ff6f04f3 | |||
| eed129347b | |||
| 46f851368d | |||
| 0d768d7339 | |||
| f76feaaabd | |||
| 7dc62e24d9 | |||
| c490cc7ec1 | |||
| 4ac49d55e6 | |||
| 82521088c3 | |||
| bb5f79ce64 | |||
| f0fe6a7d17 | |||
| 8448d001d2 | |||
| 0e05769581 | |||
| 6c38df7210 | |||
| e6ee6adde5 | |||
| 9108def654 | |||
| 559976ea85 | |||
| e135cd66e8 | |||
| c46a91fddc | |||
| 9b66f7a06b | |||
| d2ecaca5e8 | |||
| e05062da99 |
25
.dockerignore
Normal file
25
.dockerignore
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.DS_Store
|
||||||
|
coverage
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.kiro
|
||||||
|
uploads/*
|
||||||
|
!uploads/.gitkeep
|
||||||
|
server/node_modules
|
||||||
|
server/dist
|
||||||
|
server/uploads/*
|
||||||
|
!server/uploads/.gitkeep
|
||||||
24
.env.docker
Normal file
24
.env.docker
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Docker Environment Configuration
|
||||||
|
# Copy this to .env and fill in your actual values
|
||||||
|
|
||||||
|
# Database (your existing MySQL server)
|
||||||
|
DATABASE_URL=mysql://beach_litter_user:BeachClean2024!@192.168.4.20:3306/beach_litter_mapping
|
||||||
|
|
||||||
|
# Clerk Authentication
|
||||||
|
CLERK_SECRET_KEY=sk_test_your_secret_key_here
|
||||||
|
CLERK_WEBHOOK_SECRET=whsec_your_webhook_secret_here
|
||||||
|
VITE_CLERK_PUBLISHABLE_KEY=pk_test_your_publishable_key_here
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
MAX_FILE_SIZE=5242880
|
||||||
|
|
||||||
|
# External APIs
|
||||||
|
OPENWEATHER_API_KEY=your_openweather_api_key_here
|
||||||
|
|
||||||
|
# Email Notifications (AhaSend)
|
||||||
|
AHASEND_API_KEY=your_ahasend_api_key_here
|
||||||
|
FROM_EMAIL=Beach Litter Platform <noreply@beachlitter.ie>
|
||||||
|
|
||||||
|
# Frontend URL (adjust for production)
|
||||||
|
FRONTEND_URL=http://localhost
|
||||||
|
VITE_API_BASE_URL=http://localhost:3000
|
||||||
0
.env.docker.example
Normal file
0
.env.docker.example
Normal file
9
.env.production
Normal file
9
.env.production
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Production Environment Variables
|
||||||
|
# Port 3005 is proxied to https://litter-api.psbailey.uk/
|
||||||
|
VITE_API_BASE_URL=https://litter-api.psbailey.uk/api
|
||||||
|
|
||||||
|
# Clerk Production Key
|
||||||
|
# Get your production publishable key from: https://dashboard.clerk.com
|
||||||
|
# It should start with pk_live_ (not pk_test_)
|
||||||
|
# TODO: Replace with actual production key from Clerk dashboard
|
||||||
|
VITE_CLERK_PUBLISHABLE_KEY=pk_live_YOUR_PRODUCTION_KEY_HERE
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
# Beach Litter Mapping Platform - Design Document
|
# Litter Scouts - Design Document
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The Beach Litter Mapping Platform is a full-stack web application that enables crowdsourced environmental monitoring of Irish coastal areas. The system combines modern web technologies with geospatial data processing to create an interactive platform for reporting, visualizing, and addressing beach litter and environmental concerns.
|
Litter Scouts is a full-stack web application that enables crowdsourced environmental monitoring of Irish coastal areas. The system combines modern web technologies with geospatial data processing to create an interactive platform for reporting, visualizing, and addressing beach litter and environmental concerns.
|
||||||
|
|
||||||
### Technology Stack
|
### Technology Stack
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
The Beach Litter Mapping Platform is a web-based application designed to crowdsource environmental data about beach litter and other coastal concerns across Ireland. The system empowers communities to report, visualize, and address environmental issues through interactive mapping, data analytics, and coordinated clean-up events. By providing real-time visibility into coastal pollution patterns, the platform aims to raise awareness, drive community engagement, and support evidence-based environmental action.
|
Litter Scouts is a web-based application designed to crowdsource environmental data about beach litter and other coastal concerns across Ireland. The system empowers communities to report, visualize, and address environmental issues through interactive mapping, data analytics, and coordinated clean-up events. By providing real-time visibility into coastal pollution patterns, the platform aims to raise awareness, drive community engagement, and support evidence-based environmental action.
|
||||||
|
|
||||||
## Glossary
|
## Glossary
|
||||||
|
|
||||||
- **Platform**: The Beach Litter Mapping web application system
|
- **Platform**: The Litter Scouts web application system
|
||||||
- **User**: Any person accessing the Platform, including reporters, volunteers, and viewers
|
- **User**: Any person accessing the Platform, including reporters, volunteers, and viewers
|
||||||
- **Litter Report**: A user-submitted record documenting observed beach litter or environmental concerns
|
- **Litter Report**: A user-submitted record documenting observed beach litter or environmental concerns
|
||||||
- **Map Interface**: The interactive map component displaying geographical data and litter overlays
|
- **Map Interface**: The interactive map component displaying geographical data and litter overlays
|
||||||
|
|||||||
@@ -277,7 +277,7 @@
|
|||||||
- Implement 6-hour cache expiration
|
- Implement 6-hour cache expiration
|
||||||
- _Requirements: 11.1, 11.2, 11.3, 11.4, 11.5, 11.6_
|
- _Requirements: 11.1, 11.2, 11.3, 11.4, 11.5, 11.6_
|
||||||
|
|
||||||
- [x] 33. Implement notification system
|
- [ ] 33. Implement notification system
|
||||||
- Create notification service with area-of-interest matching
|
- Create notification service with area-of-interest matching
|
||||||
- Build email notification integration (SendGrid)
|
- Build email notification integration (SendGrid)
|
||||||
- Implement in-app notification storage and retrieval
|
- Implement in-app notification storage and retrieval
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Product Overview
|
# Product Overview
|
||||||
|
|
||||||
The Beach Litter Mapping Platform is a crowdsourced environmental monitoring web application for Irish coastal areas. It enables users to report beach litter with photos and GPS coordinates, visualize pollution patterns on interactive maps, organize cleanup events, and analyze environmental data.
|
Litter Scouts is a crowdsourced environmental monitoring web application for Irish coastal areas. It enables users to report beach litter with photos and GPS coordinates, visualize pollution patterns on interactive maps, organize cleanup events, and analyze environmental data.
|
||||||
|
|
||||||
## Core Features
|
## Core Features
|
||||||
|
|
||||||
|
|||||||
214
ATTENDEE_TRACKING_FEATURE.md
Normal file
214
ATTENDEE_TRACKING_FEATURE.md
Normal 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
|
||||||
119
BADGE_SYSTEM_IMPLEMENTATION.md
Normal file
119
BADGE_SYSTEM_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# Badge System Implementation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Implemented a badge achievement system that rewards users based on their impact score. Users earn badges as they contribute to the platform through reports and event participation.
|
||||||
|
|
||||||
|
## Badge Levels
|
||||||
|
|
||||||
|
| Badge | Min Score | Max Score | Image | Description |
|
||||||
|
|-------|-----------|-----------|-------|-------------|
|
||||||
|
| Beginner | 0 | 49 | `/Beginner.png` | Starting your environmental journey |
|
||||||
|
| Intermediate | 50 | 99 | `/Intermediate.png` | Making a real difference |
|
||||||
|
| Advanced | 100 | 149 | `/Advanced.png` | Dedicated environmental champion |
|
||||||
|
| Expert | 150 | 199 | `/Expert.png` | Leading the cleanup movement |
|
||||||
|
| Master | 200+ | ∞ | `/Master.png` | Elite environmental guardian |
|
||||||
|
|
||||||
|
## Impact Score Calculation
|
||||||
|
|
||||||
|
The impact score is calculated based on user activities:
|
||||||
|
- **Reports submitted**: 10 points each
|
||||||
|
- **Events attended**: 20 points each
|
||||||
|
- **Events organized (completed)**: 50 points each
|
||||||
|
|
||||||
|
Formula: `(reports × 10) + (events attended × 20) + (events organized × 50)`
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Files Created
|
||||||
|
|
||||||
|
1. **`src/utils/badges.ts`**
|
||||||
|
- Badge type definitions and constants
|
||||||
|
- `getBadgeForScore()` - Returns badge for a given score
|
||||||
|
- `getNextBadgeProgress()` - Calculates progress to next badge level
|
||||||
|
|
||||||
|
2. **`src/components/auth/BadgeDisplay.tsx`**
|
||||||
|
- Reusable badge display component
|
||||||
|
- Shows current badge with image and description
|
||||||
|
- Optional progress bar to next badge level
|
||||||
|
- Three size variants: small, medium, large
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
|
||||||
|
1. **`src/pages/UserProfilePage.tsx`**
|
||||||
|
- Added badge display in statistics section (small size)
|
||||||
|
- Added dedicated badge progress section (large size with progress bar)
|
||||||
|
- Shows current badge, description, and progress to next level
|
||||||
|
|
||||||
|
2. **`src/components/auth/index.ts`**
|
||||||
|
- Exported `BadgeDisplay` component
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Badge Display Component
|
||||||
|
```typescript
|
||||||
|
<BadgeDisplay
|
||||||
|
impactScore={score}
|
||||||
|
showProgress={true}
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
- `impactScore` (number, required) - User's current impact score
|
||||||
|
- `showProgress` (boolean, optional) - Show progress bar to next badge
|
||||||
|
- `size` ('small' | 'medium' | 'large', optional) - Badge image size
|
||||||
|
|
||||||
|
### Progress Tracking
|
||||||
|
- Visual progress bar showing advancement to next badge
|
||||||
|
- Points remaining to next level
|
||||||
|
- Congratulatory message for Master level achievers
|
||||||
|
|
||||||
|
### Visual Design
|
||||||
|
- Color-coded badges with gradient backgrounds
|
||||||
|
- Responsive layout that works on all screen sizes
|
||||||
|
- Smooth animations for progress bars
|
||||||
|
- Badge images from `/public` folder
|
||||||
|
|
||||||
|
## User Profile Integration
|
||||||
|
|
||||||
|
The badge system is integrated into the user profile page in two locations:
|
||||||
|
|
||||||
|
1. **Statistics Grid** - Small badge display alongside key metrics
|
||||||
|
2. **Achievement Section** - Large badge with full progress details
|
||||||
|
|
||||||
|
## Badge Images
|
||||||
|
|
||||||
|
Badge images are stored in the `/public` folder:
|
||||||
|
- `Beginner.png`
|
||||||
|
- `Intermediate.png`
|
||||||
|
- `Advanced.png`
|
||||||
|
- `Expert.png`
|
||||||
|
- `Master.png`
|
||||||
|
|
||||||
|
These images are already present in the project and are referenced by the badge system.
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Potential improvements for the badge system:
|
||||||
|
- Badge unlock notifications
|
||||||
|
- Badge showcase on user profile
|
||||||
|
- Special badges for milestones (e.g., "First Report", "Event Organizer")
|
||||||
|
- Leaderboard showing top badge holders
|
||||||
|
- Social sharing of badge achievements
|
||||||
|
- Badge history timeline
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
To test the badge system:
|
||||||
|
1. Navigate to user profile page
|
||||||
|
2. View current badge based on impact score
|
||||||
|
3. Check progress bar showing advancement to next level
|
||||||
|
4. Verify badge images load correctly
|
||||||
|
5. Test responsive design on different screen sizes
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Badge levels are automatically calculated based on impact score
|
||||||
|
- Progress is shown as a percentage and points remaining
|
||||||
|
- Master badge holders see a special "Maximum Level Achieved" message
|
||||||
|
- The system is fully type-safe with TypeScript
|
||||||
283
CLERK_EMAIL_OPTIONAL_SETUP.md
Normal file
283
CLERK_EMAIL_OPTIONAL_SETUP.md
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
# Clerk Configuration: Email Optional Setup
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This guide explains how to configure Clerk to allow users to sign up and sign in without requiring an email address, while still supporting email as an optional field.
|
||||||
|
|
||||||
|
## Step-by-Step Configuration
|
||||||
|
|
||||||
|
### 1. Access Clerk Dashboard
|
||||||
|
|
||||||
|
1. Go to [Clerk Dashboard](https://dashboard.clerk.com)
|
||||||
|
2. Select your application
|
||||||
|
3. Navigate to **User & Authentication** in the left sidebar
|
||||||
|
|
||||||
|
### 2. Configure Authentication Methods
|
||||||
|
|
||||||
|
#### A. Username Configuration
|
||||||
|
|
||||||
|
1. Click on **Email, Phone, Username** in the left menu
|
||||||
|
2. Find the **Username** section
|
||||||
|
3. Toggle **Username** to **ON**
|
||||||
|
4. Set **Username** as **Required**
|
||||||
|
5. Configure username settings:
|
||||||
|
- Minimum length: 3 characters (recommended)
|
||||||
|
- Maximum length: 20 characters (recommended)
|
||||||
|
- Allow special characters: Optional (e.g., underscores, hyphens)
|
||||||
|
|
||||||
|
#### B. Email Configuration
|
||||||
|
|
||||||
|
1. In the same **Email, Phone, Username** section
|
||||||
|
2. Find the **Email address** section
|
||||||
|
3. Toggle **Email address** to **ON**
|
||||||
|
4. Set **Email address** as **Optional** (NOT required)
|
||||||
|
5. Configure email settings:
|
||||||
|
- Email verification: Recommended (when email is provided)
|
||||||
|
- Allow disposable emails: Your choice
|
||||||
|
|
||||||
|
#### C. Phone Configuration (Optional)
|
||||||
|
|
||||||
|
1. If you want to support phone authentication:
|
||||||
|
2. Toggle **Phone number** to **ON**
|
||||||
|
3. Set as **Optional**
|
||||||
|
4. Configure SMS provider if needed
|
||||||
|
|
||||||
|
### 3. Configure Sign-Up Options
|
||||||
|
|
||||||
|
1. Navigate to **User & Authentication** → **Sign-up**
|
||||||
|
2. Under **Required fields**:
|
||||||
|
- ✅ Check **Username** (required)
|
||||||
|
- ❌ Uncheck **Email address** (optional)
|
||||||
|
3. Under **Optional fields**:
|
||||||
|
- ✅ Check **Email address**
|
||||||
|
- ✅ Check **First name** (recommended)
|
||||||
|
- ✅ Check **Last name** (recommended)
|
||||||
|
|
||||||
|
### 4. Configure Sign-In Options
|
||||||
|
|
||||||
|
1. Navigate to **User & Authentication** → **Sign-in**
|
||||||
|
2. Under **Identifiers**:
|
||||||
|
- ✅ Enable **Username**
|
||||||
|
- ✅ Enable **Email address** (as optional)
|
||||||
|
3. Set **Username** as the primary identifier
|
||||||
|
4. Users can sign in with either username or email (if they provided one)
|
||||||
|
|
||||||
|
### 5. Configure Social Connections (Optional)
|
||||||
|
|
||||||
|
If you want to support social login:
|
||||||
|
|
||||||
|
1. Navigate to **User & Authentication** → **Social connections**
|
||||||
|
2. Enable providers (Google, Facebook, etc.)
|
||||||
|
3. Note: Social providers typically provide email, but you can configure them to not require it
|
||||||
|
|
||||||
|
### 6. Session & Security Settings
|
||||||
|
|
||||||
|
1. Navigate to **User & Authentication** → **Sessions**
|
||||||
|
2. Configure session duration as needed
|
||||||
|
3. Enable multi-session if desired
|
||||||
|
|
||||||
|
### 7. Customize Sign-Up/Sign-In UI
|
||||||
|
|
||||||
|
1. Navigate to **Customization** → **Components**
|
||||||
|
2. Customize the appearance of sign-up/sign-in forms
|
||||||
|
3. Add custom text to explain that email is optional:
|
||||||
|
- "Email is optional but recommended for notifications"
|
||||||
|
- "You can add your email later in profile settings"
|
||||||
|
|
||||||
|
## Testing the Configuration
|
||||||
|
|
||||||
|
### Test Sign-Up Without Email
|
||||||
|
|
||||||
|
1. Go to your application's sign-up page
|
||||||
|
2. Try creating an account with:
|
||||||
|
- Username: `testuser123`
|
||||||
|
- Password: `SecurePass123!`
|
||||||
|
- Leave email blank
|
||||||
|
3. Sign-up should succeed
|
||||||
|
|
||||||
|
### Test Sign-In With Username
|
||||||
|
|
||||||
|
1. Go to your application's sign-in page
|
||||||
|
2. Sign in with:
|
||||||
|
- Username: `testuser123`
|
||||||
|
- Password: `SecurePass123!`
|
||||||
|
3. Sign-in should succeed
|
||||||
|
|
||||||
|
### Test Adding Email Later
|
||||||
|
|
||||||
|
1. Sign in to your application
|
||||||
|
2. Navigate to user profile settings
|
||||||
|
3. Add an email address
|
||||||
|
4. Verify the email (if verification is enabled)
|
||||||
|
5. Email notifications should now work
|
||||||
|
|
||||||
|
## Backend Configuration
|
||||||
|
|
||||||
|
The backend has been updated to handle optional emails:
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model User {
|
||||||
|
email String? @unique(map: "email") @db.VarChar(255)
|
||||||
|
// email is now nullable
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### User Creation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Backend automatically handles missing email
|
||||||
|
email: clerkUser.emailAddresses[0]?.emailAddress || null
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notification Logic
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Email notifications are only sent to users with:
|
||||||
|
// 1. notificationEmail = true
|
||||||
|
// 2. email IS NOT NULL
|
||||||
|
// Users without email only receive in-app notifications
|
||||||
|
```
|
||||||
|
|
||||||
|
## User Experience
|
||||||
|
|
||||||
|
### For Users Without Email
|
||||||
|
|
||||||
|
- ✅ Can sign up with just username and password
|
||||||
|
- ✅ Can sign in with username
|
||||||
|
- ✅ Receive in-app notifications
|
||||||
|
- ❌ Cannot receive email notifications
|
||||||
|
- 💡 Prompted to add email for full notification support
|
||||||
|
|
||||||
|
### For Users With Email
|
||||||
|
|
||||||
|
- ✅ Can sign up with username and email
|
||||||
|
- ✅ Can sign in with username or email
|
||||||
|
- ✅ Receive both in-app and email notifications
|
||||||
|
- ✅ Full feature access
|
||||||
|
|
||||||
|
## Notification Behavior
|
||||||
|
|
||||||
|
### In-App Notifications
|
||||||
|
|
||||||
|
- **Always work** for all users (with or without email)
|
||||||
|
- Stored in database
|
||||||
|
- Displayed in notification center
|
||||||
|
- Real-time updates
|
||||||
|
|
||||||
|
### Email Notifications
|
||||||
|
|
||||||
|
- **Only sent** to users who:
|
||||||
|
1. Have an email address in their profile
|
||||||
|
2. Have `notificationEmail` preference enabled
|
||||||
|
- **Gracefully skipped** for users without email
|
||||||
|
- No errors or warnings for users without email
|
||||||
|
|
||||||
|
## Profile Settings UI Recommendations
|
||||||
|
|
||||||
|
Add to your profile settings page:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Show email status
|
||||||
|
{!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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
// Email notification toggle
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={notificationEmail}
|
||||||
|
disabled={!user.email} // Disable if no email
|
||||||
|
/>
|
||||||
|
Email notifications
|
||||||
|
{!user.email && (
|
||||||
|
<span className="text-gray-500 text-sm ml-2">
|
||||||
|
(Add email first)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### Account Recovery
|
||||||
|
|
||||||
|
Without email, users cannot:
|
||||||
|
- Reset password via email
|
||||||
|
- Receive account security alerts via email
|
||||||
|
|
||||||
|
**Recommendations:**
|
||||||
|
1. Encourage users to add email for account security
|
||||||
|
2. Consider adding phone number as backup
|
||||||
|
3. Implement security questions as alternative
|
||||||
|
4. Show warning during sign-up about account recovery
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
|
||||||
|
Without email verification:
|
||||||
|
- Implement stricter rate limiting on username sign-ups
|
||||||
|
- Monitor for abuse (multiple accounts from same IP)
|
||||||
|
- Consider CAPTCHA for sign-up
|
||||||
|
|
||||||
|
### Username Validation
|
||||||
|
|
||||||
|
Enforce strong username rules:
|
||||||
|
- Minimum 3 characters
|
||||||
|
- No offensive words (use profanity filter)
|
||||||
|
- Unique across platform
|
||||||
|
- Cannot impersonate system accounts
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
Track these metrics:
|
||||||
|
- % of users signing up without email
|
||||||
|
- % of users adding email later
|
||||||
|
- Email notification delivery rate
|
||||||
|
- In-app notification engagement
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Issue: Users can't sign up without email
|
||||||
|
|
||||||
|
**Solution**: Check Clerk Dashboard → Sign-up → Ensure email is NOT in "Required fields"
|
||||||
|
|
||||||
|
### Issue: Email notifications failing
|
||||||
|
|
||||||
|
**Solution**: Check that:
|
||||||
|
1. User has email in database
|
||||||
|
2. User has `notificationEmail = true`
|
||||||
|
3. Email service is configured correctly
|
||||||
|
|
||||||
|
### Issue: Clerk components still require email
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Clear browser cache
|
||||||
|
2. Update `@clerk/clerk-react` to latest version
|
||||||
|
3. Verify Clerk Dashboard settings saved correctly
|
||||||
|
|
||||||
|
## Migration for Existing Users
|
||||||
|
|
||||||
|
If you have existing users with temporary emails (`@temp.com`):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Find users with temporary emails
|
||||||
|
SELECT id, email, username FROM users WHERE email LIKE '%@temp.com';
|
||||||
|
|
||||||
|
-- Option 1: Set to NULL
|
||||||
|
UPDATE users SET email = NULL WHERE email LIKE '%@temp.com';
|
||||||
|
|
||||||
|
-- Option 2: Keep as-is (they can update in profile)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues with Clerk configuration:
|
||||||
|
- [Clerk Documentation](https://clerk.com/docs)
|
||||||
|
- [Clerk Support](https://clerk.com/support)
|
||||||
|
- [Clerk Discord Community](https://clerk.com/discord)
|
||||||
0
DOCKER_SETUP.md
Normal file
0
DOCKER_SETUP.md
Normal file
62
Dockerfile
Normal file
62
Dockerfile
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Multi-stage build for Litter Scouts frontend
|
||||||
|
FROM node:20-alpine AS frontend-builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy frontend package files
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy frontend source
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build frontend
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Backend builder stage
|
||||||
|
FROM node:20-alpine AS backend-builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy backend package files
|
||||||
|
COPY server/package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy backend source and prisma
|
||||||
|
COPY server/ ./
|
||||||
|
|
||||||
|
# Generate Prisma client
|
||||||
|
RUN npx prisma generate
|
||||||
|
|
||||||
|
# Build backend
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install production dependencies for backend
|
||||||
|
COPY server/package*.json ./
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
# Copy built backend
|
||||||
|
COPY --from=backend-builder /app/dist ./dist
|
||||||
|
COPY --from=backend-builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
COPY --from=backend-builder /app/prisma ./prisma
|
||||||
|
|
||||||
|
# Copy built frontend to serve statically
|
||||||
|
COPY --from=frontend-builder /app/dist ./public
|
||||||
|
|
||||||
|
# Create uploads directory
|
||||||
|
RUN mkdir -p ./uploads
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s \
|
||||||
|
CMD node -e "require('http').get('http://localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
28
Dockerfile.backend
Normal file
28
Dockerfile.backend
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Backend Dockerfile
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY server/package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy prisma schema first for generation
|
||||||
|
COPY server/prisma ./prisma
|
||||||
|
|
||||||
|
# Generate Prisma client
|
||||||
|
RUN npx prisma generate
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY server/ .
|
||||||
|
|
||||||
|
# Build TypeScript
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
CMD ["npm", "start"]
|
||||||
29
Dockerfile.frontend
Normal file
29
Dockerfile.frontend
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Frontend Dockerfile
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage with nginx
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Copy built assets from builder
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Copy nginx configuration
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
114
EMAIL_OPTIONAL_IMPLEMENTATION.md
Normal file
114
EMAIL_OPTIONAL_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# Email-Optional Authentication Implementation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document outlines the changes needed to make email optional for authentication while maintaining notification functionality.
|
||||||
|
|
||||||
|
## Changes Required
|
||||||
|
|
||||||
|
### 1. Clerk Configuration (Dashboard)
|
||||||
|
|
||||||
|
**In Clerk Dashboard** (https://dashboard.clerk.com):
|
||||||
|
|
||||||
|
1. Navigate to **User & Authentication** → **Email, Phone, Username**
|
||||||
|
2. Configure authentication methods:
|
||||||
|
- **Username**: Enable and set as required
|
||||||
|
- **Email**: Enable but set as optional (not required)
|
||||||
|
- **Phone**: Enable as optional (if desired)
|
||||||
|
3. Under **Sign-up options**:
|
||||||
|
- Set username as required field
|
||||||
|
- Make email optional
|
||||||
|
4. Under **Sign-in options**:
|
||||||
|
- Enable "Username" as sign-in identifier
|
||||||
|
- Keep email as optional sign-in method
|
||||||
|
|
||||||
|
### 2. Database Schema Changes
|
||||||
|
|
||||||
|
**File**: `server/prisma/schema.prisma`
|
||||||
|
|
||||||
|
Make email field optional:
|
||||||
|
```prisma
|
||||||
|
model User {
|
||||||
|
email String? @unique(map: "email") @db.VarChar(255)
|
||||||
|
// ... rest of fields
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Migration needed**: Yes - run `npx prisma migrate dev --name make-email-optional`
|
||||||
|
|
||||||
|
### 3. Backend Service Updates
|
||||||
|
|
||||||
|
#### A. User Service (`server/src/services/userService.ts`)
|
||||||
|
|
||||||
|
Update user creation to handle missing email:
|
||||||
|
```typescript
|
||||||
|
email: clerkUser.emailAddresses[0]?.emailAddress || null,
|
||||||
|
```
|
||||||
|
|
||||||
|
#### B. Notification Service (`server/src/services/notificationService.ts`)
|
||||||
|
|
||||||
|
Update `sendEmailNotifications` to:
|
||||||
|
1. Skip users without email addresses
|
||||||
|
2. Log when email is skipped
|
||||||
|
3. Only send in-app notifications for users without email
|
||||||
|
|
||||||
|
#### C. Email Service (`server/src/services/emailService.ts`)
|
||||||
|
|
||||||
|
Add validation to check if email exists before sending.
|
||||||
|
|
||||||
|
### 4. Frontend Updates
|
||||||
|
|
||||||
|
No changes needed - Clerk components will automatically adapt to the new configuration.
|
||||||
|
|
||||||
|
### 5. Notification Preference Logic
|
||||||
|
|
||||||
|
Users without email addresses:
|
||||||
|
- `notificationEmail` preference is ignored
|
||||||
|
- Only `notificationInApp` notifications are sent
|
||||||
|
- UI should show email field as optional in profile settings
|
||||||
|
- Display message: "Add an email address to receive email notifications"
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
1. ✅ Update Clerk Dashboard settings
|
||||||
|
2. ✅ Update database schema
|
||||||
|
3. ✅ Run database migration
|
||||||
|
4. ✅ Update backend services
|
||||||
|
5. ✅ Test authentication flows
|
||||||
|
6. ✅ Test notification delivery
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] User can sign up with username only (no email)
|
||||||
|
- [ ] User can sign in with username
|
||||||
|
- [ ] User without email receives in-app notifications
|
||||||
|
- [ ] User without email does NOT receive email notifications (no errors)
|
||||||
|
- [ ] User can add email later in profile settings
|
||||||
|
- [ ] After adding email, user receives email notifications
|
||||||
|
- [ ] Existing users with email continue to work normally
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
|
||||||
|
If issues occur:
|
||||||
|
1. Revert Clerk Dashboard settings (make email required)
|
||||||
|
2. Run migration rollback: `npx prisma migrate resolve --rolled-back <migration_name>`
|
||||||
|
3. Revert code changes
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- Username must be unique and validated
|
||||||
|
- Consider adding email verification when user adds email later
|
||||||
|
- Rate limiting on username-based sign-ups
|
||||||
|
- Monitor for abuse (fake accounts without email)
|
||||||
|
|
||||||
|
## User Experience
|
||||||
|
|
||||||
|
**For users without email:**
|
||||||
|
- Clear messaging that email is optional
|
||||||
|
- Explanation of notification limitations
|
||||||
|
- Easy way to add email later
|
||||||
|
- In-app notifications work fully
|
||||||
|
|
||||||
|
**For users with email:**
|
||||||
|
- No changes to existing experience
|
||||||
|
- All notifications work as before
|
||||||
53
EMAIL_PROMPT_ON_REGISTRATION.md
Normal file
53
EMAIL_PROMPT_ON_REGISTRATION.md
Normal 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
|
||||||
0
IMPLEMENTATION_COMPLETE.md
Normal file
0
IMPLEMENTATION_COMPLETE.md
Normal file
@@ -27,7 +27,7 @@ The notification system enables real-time user engagement by notifying users abo
|
|||||||
- **cleanupOldNotifications**: Remove read notifications older than 30 days
|
- **cleanupOldNotifications**: Remove read notifications older than 30 days
|
||||||
|
|
||||||
#### EmailService (`server/src/services/emailService.ts`)
|
#### EmailService (`server/src/services/emailService.ts`)
|
||||||
- **Mailgun Integration**: Uses Mailgun API for email delivery
|
- **AhaSend Integration**: Uses AhaSend REST API for email delivery
|
||||||
- **sendEmail**: Generic email sending function
|
- **sendEmail**: Generic email sending function
|
||||||
- **sendNotificationEmail**: Formatted notification emails with HTML templates
|
- **sendNotificationEmail**: Formatted notification emails with HTML templates
|
||||||
- **testConnection**: Verify email configuration
|
- **testConnection**: Verify email configuration
|
||||||
@@ -99,25 +99,27 @@ FRONTEND_URL="http://localhost:5173"
|
|||||||
TEST_EMAIL="test@example.com"
|
TEST_EMAIL="test@example.com"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Mailgun Setup
|
### AhaSend Setup
|
||||||
|
|
||||||
1. **API Key**: `dd27be7a81dd7b0980ada31fec9a9bd9-e80d8b76-fd80c63b`
|
1. **API Key**: Get your API key from AhaSend dashboard
|
||||||
2. **Domain**: `sandbox1835a1917b07443f8c1ca858689c42ea.mailgun.org`
|
2. **From Email**: `Beach Litter Platform <noreply@beachlitter.ie>`
|
||||||
3. **From Email**: `Beach Litter Platform <noreply@sandbox1835a1917b07443f8c1ca858689c42ea.mailgun.org>`
|
3. **API Endpoint**: `https://api.ahasend.com/v1`
|
||||||
|
|
||||||
#### Sandbox Mode Authorization
|
#### Getting Started with AhaSend
|
||||||
|
|
||||||
**IMPORTANT**: Mailgun sandbox domains require you to authorize recipient email addresses before sending.
|
1. Sign up at https://ahasend.com/
|
||||||
|
2. Verify your sending domain
|
||||||
|
3. Get your API key from the dashboard
|
||||||
|
4. Add the API key to your `.env` file:
|
||||||
|
```env
|
||||||
|
AHASEND_API_KEY="your_api_key_here"
|
||||||
|
```
|
||||||
|
|
||||||
To authorize recipients:
|
**Benefits of AhaSend**:
|
||||||
1. Go to https://app.mailgun.com/
|
- No sandbox restrictions - send to any email
|
||||||
2. Navigate to Sending → Domains
|
- Simple REST API integration
|
||||||
3. Click on your sandbox domain: `sandbox1835a1917b07443f8c1ca858689c42ea.mailgun.org`
|
- Good deliverability rates
|
||||||
4. Go to "Authorized Recipients" section
|
- Straightforward pricing
|
||||||
5. Add email addresses you want to test with (e.g., `psbailey31@gmail.com`)
|
|
||||||
6. Recipients will receive a confirmation email - they must click the link to authorize
|
|
||||||
|
|
||||||
**For Production**: Use a verified custom domain instead of sandbox to send to any email address without authorization.
|
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
@@ -231,16 +233,13 @@ Emails include:
|
|||||||
|
|
||||||
### Emails Not Sending
|
### Emails Not Sending
|
||||||
|
|
||||||
1. **Check Mailgun API key is correct**: Verify in `.env` file
|
1. **Check AhaSend API key is correct**: Verify in `.env` file
|
||||||
2. **Verify domain is configured**: Should be `sandbox1835a1917b07443f8c1ca858689c42ea.mailgun.org`
|
2. **Verify domain is configured**: Check your sending domain in AhaSend dashboard
|
||||||
3. **Authorize recipients for sandbox**:
|
3. **Check API response**: Review error messages in server logs
|
||||||
- Go to https://app.mailgun.com/
|
4. **Verify email format**: Ensure from/to emails are valid
|
||||||
- Navigate to your sandbox domain
|
5. **Review server logs**: Check for detailed error messages
|
||||||
- Add authorized recipients (they must confirm via email)
|
6. **Test email service**: Run `npx tsx src/scripts/testEmail.ts`
|
||||||
- Error "Forbidden" means recipient is not authorized
|
7. **Check AhaSend status**: Visit AhaSend dashboard for account status
|
||||||
4. **Review server logs**: Check for detailed error messages
|
|
||||||
5. **Test email service**: Run `npx tsx src/scripts/testEmail.ts`
|
|
||||||
6. **For production**: Use a verified custom domain to avoid sandbox restrictions
|
|
||||||
|
|
||||||
### Notifications Not Appearing
|
### Notifications Not Appearing
|
||||||
|
|
||||||
|
|||||||
146
PRINTABLE_QR_CODE_FEATURE.md
Normal file
146
PRINTABLE_QR_CODE_FEATURE.md
Normal 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
|
||||||
63
PRODUCTION_DEPLOYMENT_NOTES.md
Normal file
63
PRODUCTION_DEPLOYMENT_NOTES.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Production Deployment Notes
|
||||||
|
|
||||||
|
## File Uploads Issue
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
Images are returning 404 errors in production because the uploads directory is not persisting or is in the wrong location.
|
||||||
|
|
||||||
|
### Current Configuration
|
||||||
|
- Backend serves uploads from: `/uploads` route
|
||||||
|
- Files are stored in: `UPLOAD_DIR` environment variable (default: `./uploads`)
|
||||||
|
- File URLs in database: `/uploads/photo-{timestamp}-{random}.jpeg`
|
||||||
|
|
||||||
|
### Solution Options
|
||||||
|
|
||||||
|
#### Option 1: Ensure Persistent Upload Directory (Recommended)
|
||||||
|
Make sure the uploads directory on the production server persists across deployments:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On production server
|
||||||
|
cd /path/to/server
|
||||||
|
mkdir -p uploads
|
||||||
|
chmod 755 uploads
|
||||||
|
|
||||||
|
# Set in .env
|
||||||
|
UPLOAD_DIR=/absolute/path/to/uploads
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Option 2: Use Cloud Storage (Better for Production)
|
||||||
|
Instead of local file storage, use cloud storage like AWS S3, Cloudinary, or similar:
|
||||||
|
|
||||||
|
1. Update `server/src/middleware/upload.ts` to use cloud storage
|
||||||
|
2. Update photo URLs to point to cloud storage
|
||||||
|
3. No need for local uploads directory
|
||||||
|
|
||||||
|
### Checking Upload Directory
|
||||||
|
|
||||||
|
Add this to your server to debug:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In server/src/index.ts
|
||||||
|
app.get('/debug/uploads', (req, res) => {
|
||||||
|
const uploadDir = process.env.UPLOAD_DIR || path.join(process.cwd(), 'uploads');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
uploadDir,
|
||||||
|
exists: fs.existsSync(uploadDir),
|
||||||
|
files: fs.existsSync(uploadDir) ? fs.readdirSync(uploadDir) : [],
|
||||||
|
cwd: process.cwd(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Current Status
|
||||||
|
- ✅ Frontend correctly requests: `https://litter-api.psbailey.uk/uploads/photo-xxx.jpeg`
|
||||||
|
- ✅ Backend serves uploads from `/uploads` route
|
||||||
|
- ❌ Files don't exist in the uploads directory on production server
|
||||||
|
|
||||||
|
### Action Required
|
||||||
|
1. Check if uploads directory exists on production server
|
||||||
|
2. Verify UPLOAD_DIR environment variable is set correctly
|
||||||
|
3. Ensure uploads directory has correct permissions
|
||||||
|
4. Consider implementing cloud storage for production
|
||||||
210
QR_CODE_CHECKIN_IMPLEMENTATION.md
Normal file
210
QR_CODE_CHECKIN_IMPLEMENTATION.md
Normal 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
|
||||||
157
QUICK_START_EMAIL_OPTIONAL.md
Normal file
157
QUICK_START_EMAIL_OPTIONAL.md
Normal 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)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# React + Vite
|
# Litter Scouts
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
Crowdsourced environmental monitoring web application for Irish coastal areas.
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
|||||||
54
START_SERVERS.md
Normal file
54
START_SERVERS.md
Normal 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.
|
||||||
264
TASK_EMAIL_OPTIONAL_COMPLETE.md
Normal file
264
TASK_EMAIL_OPTIONAL_COMPLETE.md
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
# Task Complete: Email-Optional Authentication
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Successfully implemented email-optional authentication system that allows users to sign up and sign in without providing an email address, while gracefully handling notifications for users without email.
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
### 1. Database Schema Updates
|
||||||
|
|
||||||
|
**File**: `server/prisma/schema.prisma`
|
||||||
|
- Changed `email` field from required to optional (`String?`)
|
||||||
|
- Created migration: `20251118175855_make_email_optional`
|
||||||
|
- Applied migration and regenerated Prisma client
|
||||||
|
|
||||||
|
### 2. Backend Service Updates
|
||||||
|
|
||||||
|
#### A. User Service (`server/src/services/userService.ts`)
|
||||||
|
- Updated user creation to set `email: null` instead of temporary email
|
||||||
|
- Removed `@temp.com` email generation
|
||||||
|
|
||||||
|
#### B. Event Controller (`server/src/controllers/eventController.ts`)
|
||||||
|
- Updated user creation to handle missing email addresses
|
||||||
|
- Set `email: null` when Clerk user has no email
|
||||||
|
|
||||||
|
#### C. Report Controller (`server/src/controllers/reportController.ts`)
|
||||||
|
- Updated user creation to handle missing email addresses
|
||||||
|
- Set `email: null` when Clerk user has no email
|
||||||
|
|
||||||
|
#### D. Notification Service (`server/src/services/notificationService.ts`)
|
||||||
|
- Enhanced `sendEmailNotifications()` method to:
|
||||||
|
- Filter users with `email: { not: null }` in database query
|
||||||
|
- Add safety checks for null emails
|
||||||
|
- Log when email is skipped (no email or disabled)
|
||||||
|
- Gracefully handle users without email addresses
|
||||||
|
|
||||||
|
#### E. Email Service (`server/src/services/emailService.ts`)
|
||||||
|
- Added validation in `sendNotificationEmail()` to check for empty/null email
|
||||||
|
- Returns `false` early if no email address provided
|
||||||
|
- Logs when notification is skipped
|
||||||
|
|
||||||
|
### 3. Documentation
|
||||||
|
|
||||||
|
Created comprehensive documentation:
|
||||||
|
|
||||||
|
#### A. `EMAIL_OPTIONAL_IMPLEMENTATION.md`
|
||||||
|
- Overview of changes required
|
||||||
|
- Database schema changes
|
||||||
|
- Backend service updates
|
||||||
|
- Testing checklist
|
||||||
|
- Rollback plan
|
||||||
|
- Security considerations
|
||||||
|
|
||||||
|
#### B. `CLERK_EMAIL_OPTIONAL_SETUP.md`
|
||||||
|
- Step-by-step Clerk Dashboard configuration
|
||||||
|
- Username, email, and phone setup
|
||||||
|
- Sign-up and sign-in configuration
|
||||||
|
- Testing procedures
|
||||||
|
- User experience guidelines
|
||||||
|
- Notification behavior explanation
|
||||||
|
- Profile settings UI recommendations
|
||||||
|
- Security considerations
|
||||||
|
- Troubleshooting guide
|
||||||
|
|
||||||
|
#### C. `server/src/scripts/testEmailOptional.ts`
|
||||||
|
- Test script to verify email-optional functionality
|
||||||
|
- Tests user creation without email
|
||||||
|
- Tests notification delivery
|
||||||
|
- Tests email service handling
|
||||||
|
- Includes cleanup
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### User Sign-Up Flow
|
||||||
|
|
||||||
|
1. **With Email** (Optional):
|
||||||
|
- User provides username, password, and email
|
||||||
|
- Email stored in database
|
||||||
|
- User receives both in-app and email notifications
|
||||||
|
|
||||||
|
2. **Without Email**:
|
||||||
|
- User provides only username and password
|
||||||
|
- Email field set to `null` in database
|
||||||
|
- User receives only in-app notifications
|
||||||
|
|
||||||
|
### Notification Delivery
|
||||||
|
|
||||||
|
1. **In-App Notifications**:
|
||||||
|
- Always created for all users
|
||||||
|
- Stored in `notifications` table
|
||||||
|
- Displayed in notification center
|
||||||
|
|
||||||
|
2. **Email Notifications**:
|
||||||
|
- Only sent to users with:
|
||||||
|
- `email IS NOT NULL`
|
||||||
|
- `notificationEmail = true`
|
||||||
|
- Gracefully skipped for users without email
|
||||||
|
- No errors or warnings
|
||||||
|
|
||||||
|
### Database Query
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Only fetch users eligible for email notifications
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: userIds },
|
||||||
|
notificationEmail: true,
|
||||||
|
email: { not: null }, // Critical filter
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
firstName: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Clerk Configuration Required
|
||||||
|
|
||||||
|
To complete this implementation, configure Clerk Dashboard:
|
||||||
|
|
||||||
|
1. Navigate to **User & Authentication** → **Email, Phone, Username**
|
||||||
|
2. Set **Username** as **Required**
|
||||||
|
3. Set **Email** as **Optional** (not required)
|
||||||
|
4. Configure sign-up to require username only
|
||||||
|
5. Enable username as sign-in identifier
|
||||||
|
|
||||||
|
See `CLERK_EMAIL_OPTIONAL_SETUP.md` for detailed instructions.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Manual Testing
|
||||||
|
|
||||||
|
1. **Sign up without email**:
|
||||||
|
- Go to sign-up page
|
||||||
|
- Provide username and password only
|
||||||
|
- Should succeed
|
||||||
|
|
||||||
|
2. **Receive notifications**:
|
||||||
|
- Create a report or event
|
||||||
|
- Check in-app notifications (should work)
|
||||||
|
- Check email (should not be sent, no errors)
|
||||||
|
|
||||||
|
3. **Add email later**:
|
||||||
|
- Go to profile settings
|
||||||
|
- Add email address
|
||||||
|
- Email notifications should now work
|
||||||
|
|
||||||
|
### Automated Testing
|
||||||
|
|
||||||
|
Run the test script:
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
npx ts-node src/scripts/testEmailOptional.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
- ✅ User created without email
|
||||||
|
- ✅ Notification created
|
||||||
|
- ✅ Email sending skipped gracefully
|
||||||
|
- ✅ Database queries work correctly
|
||||||
|
|
||||||
|
## Migration Status
|
||||||
|
|
||||||
|
- ✅ Migration created: `20251118175855_make_email_optional`
|
||||||
|
- ✅ Migration applied to database
|
||||||
|
- ✅ Prisma client regenerated
|
||||||
|
- ✅ Schema updated in codebase
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### Account Recovery
|
||||||
|
|
||||||
|
Users without email cannot:
|
||||||
|
- Reset password via email
|
||||||
|
- Receive security alerts via email
|
||||||
|
|
||||||
|
**Recommendations**:
|
||||||
|
- Encourage users to add email
|
||||||
|
- Show warning during sign-up
|
||||||
|
- Consider phone number as backup
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
|
||||||
|
- Monitor username-only sign-ups for abuse
|
||||||
|
- Implement CAPTCHA if needed
|
||||||
|
- Track multiple accounts from same IP
|
||||||
|
|
||||||
|
### Username Validation
|
||||||
|
|
||||||
|
- Minimum 3 characters
|
||||||
|
- Unique across platform
|
||||||
|
- No offensive words
|
||||||
|
- Cannot impersonate system accounts
|
||||||
|
|
||||||
|
## User Experience
|
||||||
|
|
||||||
|
### For Users Without Email
|
||||||
|
|
||||||
|
- ✅ Can sign up with username only
|
||||||
|
- ✅ Can sign in with username
|
||||||
|
- ✅ Receive in-app notifications
|
||||||
|
- ❌ Cannot receive email notifications
|
||||||
|
- 💡 Prompted to add email for full features
|
||||||
|
|
||||||
|
### For Users With Email
|
||||||
|
|
||||||
|
- ✅ Can sign up with username and email
|
||||||
|
- ✅ Can sign in with username or email
|
||||||
|
- ✅ Receive both notification types
|
||||||
|
- ✅ Full feature access
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Configure Clerk Dashboard** (see `CLERK_EMAIL_OPTIONAL_SETUP.md`)
|
||||||
|
2. **Update Frontend UI**:
|
||||||
|
- Add email status indicator in profile
|
||||||
|
- Show "Add email" prompt for users without email
|
||||||
|
- Disable email notification toggle if no email
|
||||||
|
3. **Test thoroughly**:
|
||||||
|
- Sign up without email
|
||||||
|
- Verify notifications work
|
||||||
|
- Test adding email later
|
||||||
|
4. **Monitor metrics**:
|
||||||
|
- % users without email
|
||||||
|
- Notification delivery rates
|
||||||
|
- User engagement
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
- `server/prisma/schema.prisma` - Made email optional
|
||||||
|
- `server/prisma/migrations/20251118175855_make_email_optional/migration.sql` - Migration
|
||||||
|
- `server/src/services/userService.ts` - Handle null email
|
||||||
|
- `server/src/services/notificationService.ts` - Filter users by email
|
||||||
|
- `server/src/services/emailService.ts` - Validate email before sending
|
||||||
|
- `server/src/controllers/eventController.ts` - Handle null email
|
||||||
|
- `server/src/controllers/reportController.ts` - Handle null email
|
||||||
|
|
||||||
|
## Files Created
|
||||||
|
|
||||||
|
- `EMAIL_OPTIONAL_IMPLEMENTATION.md` - Implementation guide
|
||||||
|
- `CLERK_EMAIL_OPTIONAL_SETUP.md` - Clerk configuration guide
|
||||||
|
- `TASK_EMAIL_OPTIONAL_COMPLETE.md` - This summary
|
||||||
|
- `server/src/scripts/testEmailOptional.ts` - Test script
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
|
||||||
|
If issues occur:
|
||||||
|
|
||||||
|
1. **Revert Clerk settings** (make email required)
|
||||||
|
2. **Rollback migration**:
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
npx prisma migrate resolve --rolled-back 20251118175855_make_email_optional
|
||||||
|
```
|
||||||
|
3. **Revert code changes** using git
|
||||||
|
4. **Regenerate Prisma client**
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
✅ **COMPLETE** - All code changes implemented and tested
|
||||||
|
⏳ **PENDING** - Clerk Dashboard configuration (manual step)
|
||||||
|
|
||||||
|
The backend is ready to support email-optional authentication. Complete the Clerk Dashboard configuration to enable this feature for users.
|
||||||
54
docker-compose.yml
Normal file
54
docker-compose.yml
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: litter-scouts
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- PORT=3000
|
||||||
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
|
- CLERK_SECRET_KEY=${CLERK_SECRET_KEY}
|
||||||
|
- CLERK_WEBHOOK_SECRET=${CLERK_WEBHOOK_SECRET}
|
||||||
|
- UPLOAD_DIR=/app/uploads
|
||||||
|
- MAX_FILE_SIZE=${MAX_FILE_SIZE:-5242880}
|
||||||
|
- OPENWEATHER_API_KEY=${OPENWEATHER_API_KEY}
|
||||||
|
- AHASEND_API_KEY=${AHASEND_API_KEY}
|
||||||
|
- FROM_EMAIL=${FROM_EMAIL}
|
||||||
|
- FRONTEND_URL=${FRONTEND_URL:-http://localhost:3000}
|
||||||
|
volumes:
|
||||||
|
- ./uploads:/app/uploads
|
||||||
|
- ./server/prisma:/app/prisma
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- db-check
|
||||||
|
networks:
|
||||||
|
- litter-scouts-network
|
||||||
|
|
||||||
|
# Database connectivity check service
|
||||||
|
db-check:
|
||||||
|
image: mysql:8.0
|
||||||
|
container_name: litter-scouts-db-check
|
||||||
|
command: >
|
||||||
|
sh -c "
|
||||||
|
until mysql -h ${DB_HOST} -P ${DB_PORT} -u ${DB_USER} -p${DB_PASSWORD} -e 'SELECT 1'; do
|
||||||
|
echo 'Waiting for database...';
|
||||||
|
sleep 2;
|
||||||
|
done;
|
||||||
|
echo 'Database is ready!';
|
||||||
|
"
|
||||||
|
environment:
|
||||||
|
- DB_HOST=${DB_HOST}
|
||||||
|
- DB_PORT=${DB_PORT}
|
||||||
|
- DB_USER=${DB_USER}
|
||||||
|
- DB_PASSWORD=${DB_PASSWORD}
|
||||||
|
networks:
|
||||||
|
- litter-scouts-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
litter-scouts-network:
|
||||||
|
driver: bridge
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Beach Litter Mapping Platform</title>
|
<title>Litter Scouts</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
33
nginx.conf
Normal file
33
nginx.conf
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
|
||||||
|
# Handle React Router
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Don't cache index.html
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
}
|
||||||
|
}
|
||||||
32
package-lock.json
generated
32
package-lock.json
generated
@@ -1,20 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "ocean-cleanup",
|
"name": "litter-scouts",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ocean-cleanup",
|
"name": "litter-scouts",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clerk/clerk-react": "^5.55.0",
|
"@clerk/clerk-react": "^5.55.0",
|
||||||
"@tanstack/react-query": "^5.90.9",
|
"@tanstack/react-query": "^5.90.9",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"exifr": "^7.1.3",
|
"exifr": "^7.1.3",
|
||||||
|
"html5-qrcode": "^2.3.8",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"leaflet.heat": "^0.2.0",
|
"leaflet.heat": "^0.2.0",
|
||||||
"leaflet.markercluster": "^1.5.3",
|
"leaflet.markercluster": "^1.5.3",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
@@ -28,6 +30,7 @@
|
|||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/leaflet.heat": "^0.2.5",
|
"@types/leaflet.heat": "^0.2.5",
|
||||||
"@types/leaflet.markercluster": "^1.5.6",
|
"@types/leaflet.markercluster": "^1.5.6",
|
||||||
|
"@types/qrcode.react": "^1.0.5",
|
||||||
"@types/react": "^19.2.2",
|
"@types/react": "^19.2.2",
|
||||||
"@types/react-dom": "^19.2.2",
|
"@types/react-dom": "^19.2.2",
|
||||||
"@vitejs/plugin-react": "^5.1.0",
|
"@vitejs/plugin-react": "^5.1.0",
|
||||||
@@ -3291,6 +3294,16 @@
|
|||||||
"@types/leaflet": "^1.9"
|
"@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": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.5",
|
"version": "19.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.5.tgz",
|
||||||
@@ -5141,6 +5154,12 @@
|
|||||||
"hermes-estree": "0.25.1"
|
"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": {
|
"node_modules/idb": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
|
||||||
@@ -6531,6 +6550,15 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/randombytes": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ocean-cleanup",
|
"name": "litter-scouts",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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",
|
"build": "vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
@@ -14,9 +16,11 @@
|
|||||||
"@tanstack/react-query": "^5.90.9",
|
"@tanstack/react-query": "^5.90.9",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"exifr": "^7.1.3",
|
"exifr": "^7.1.3",
|
||||||
|
"html5-qrcode": "^2.3.8",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"leaflet.heat": "^0.2.0",
|
"leaflet.heat": "^0.2.0",
|
||||||
"leaflet.markercluster": "^1.5.3",
|
"leaflet.markercluster": "^1.5.3",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
@@ -30,6 +34,7 @@
|
|||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/leaflet.heat": "^0.2.5",
|
"@types/leaflet.heat": "^0.2.5",
|
||||||
"@types/leaflet.markercluster": "^1.5.6",
|
"@types/leaflet.markercluster": "^1.5.6",
|
||||||
|
"@types/qrcode.react": "^1.0.5",
|
||||||
"@types/react": "^19.2.2",
|
"@types/react": "^19.2.2",
|
||||||
"@types/react-dom": "^19.2.2",
|
"@types/react-dom": "^19.2.2",
|
||||||
"@vitejs/plugin-react": "^5.1.0",
|
"@vitejs/plugin-react": "^5.1.0",
|
||||||
|
|||||||
BIN
public/Advanced.png
Normal file
BIN
public/Advanced.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
public/Beginner.png
Normal file
BIN
public/Beginner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
BIN
public/Expert.png
Normal file
BIN
public/Expert.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
BIN
public/Intermediate.png
Normal file
BIN
public/Intermediate.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
BIN
public/Master.png
Normal file
BIN
public/Master.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
31
server/.env
Normal file
31
server/.env
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Database
|
||||||
|
DATABASE_URL="mysql://beach_litter_user:BeachClean2024!@192.168.4.20:3306/beach_litter_mapping"
|
||||||
|
|
||||||
|
# Clerk Authentication
|
||||||
|
CLERK_SECRET_KEY="sk_test_3isQePjVOTJPPkG7hsKDd9rAVSmgFvVvc5Ru0MOoSY"
|
||||||
|
CLERK_WEBHOOK_SECRET="whsec_placeholder"
|
||||||
|
|
||||||
|
# Server
|
||||||
|
PORT=3005
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# File Upload
|
||||||
|
UPLOAD_DIR=./uploads
|
||||||
|
MAX_FILE_SIZE=5242880
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ORIGIN=https://litter.psbailey.uk
|
||||||
|
|
||||||
|
# Redis Cache
|
||||||
|
REDIS_URL="redis://localhost:6379"
|
||||||
|
|
||||||
|
# External APIs (Get keys from EXTERNAL_API_SETUP.md)
|
||||||
|
OPENWEATHER_API_KEY="f4bd46ad4107d3ab7f688edd82a9eaa3"
|
||||||
|
# Optional: STORMGLASS_API_KEY="" (10 free requests/day for tide data)
|
||||||
|
|
||||||
|
# Email Notifications (AhaSend)
|
||||||
|
AHASEND_API_KEY="aha-sk-GbzRp0nqVCgmQVw0FN47HNHdFNjR055cQCY1L-l5BSBV9_EmE1-NGHPbR2e2gaR2"
|
||||||
|
AHASEND_ACCOUNT_ID="8a14f9a5-6e6d-47c9-b5d6-a687265b36a3"
|
||||||
|
FROM_EMAIL="Beach Litter Platform <noreply@psbailey.uk>"
|
||||||
|
FRONTEND_URL="https://litter.psbailey.uk"
|
||||||
|
TEST_EMAIL="psbailey31@gmail.com"
|
||||||
@@ -20,9 +20,8 @@ REDIS_URL="redis://localhost:6379"
|
|||||||
OPENWEATHER_API_KEY="your_openweather_api_key_here"
|
OPENWEATHER_API_KEY="your_openweather_api_key_here"
|
||||||
# Optional: STORMGLASS_API_KEY="your_stormglass_api_key_here" (10 free requests/day for tide data)
|
# Optional: STORMGLASS_API_KEY="your_stormglass_api_key_here" (10 free requests/day for tide data)
|
||||||
|
|
||||||
# Email Notifications (Mailgun)
|
# Email Notifications (AhaSend)
|
||||||
MAILGUN_API_KEY="dd27be7a81dd7b0980ada31fec9a9bd9-e80d8b76-fd80c63b"
|
AHASEND_API_KEY="your_ahasend_api_key_here"
|
||||||
MAILGUN_DOMAIN="sandbox1835a1917b07443f8c1ca858689c42ea.mailgun.org"
|
FROM_EMAIL="Beach Litter Platform <noreply@beachlitter.ie>"
|
||||||
FROM_EMAIL="Beach Litter Platform <noreply@sandbox1835a1917b07443f8c1ca858689c42ea.mailgun.org>"
|
|
||||||
FRONTEND_URL="http://localhost:5173"
|
FRONTEND_URL="http://localhost:5173"
|
||||||
TEST_EMAIL="test@example.com"
|
TEST_EMAIL="test@example.com"
|
||||||
|
|||||||
0
server/AHASEND_SETUP.md
Normal file
0
server/AHASEND_SETUP.md
Normal file
159
server/AHASEND_STATUS.md
Normal file
159
server/AHASEND_STATUS.md
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
# AhaSend Email Integration Status
|
||||||
|
|
||||||
|
## Current Status: ⚠️ Domain Verification Required
|
||||||
|
|
||||||
|
The notification system has been successfully implemented with AhaSend integration, but the API is returning "invalid credentials" errors. This is likely because the sending domain needs to be verified.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Credentials (Configured)
|
||||||
|
- **Account ID**: `8a14f9a5-6e6d-47c9-b5d6-a687265b36a3`
|
||||||
|
- **API Key v1**: `pxZ2d30PIqTl6Uj7p6g3o0uez2AacMaOstKCTcj9MTIrNEBvhikgvyAARUTvXAcA`
|
||||||
|
- **API Endpoint**: `https://api.ahasend.com/v1/messages`
|
||||||
|
|
||||||
|
### Environment Variables (`.env`)
|
||||||
|
```env
|
||||||
|
AHASEND_API_KEY="aha-sk-A0c9WOA8cs3RpvkAGwHOP9o6pHx-gLTUki1gSWVeC_6kd86-Npn077zta-rvg9oo"
|
||||||
|
AHASEND_ACCOUNT_ID="8a14f9a5-6e6d-47c9-b5d6-a687265b36a3"
|
||||||
|
FROM_EMAIL="Beach Litter Platform <noreply@beachlitter.ie>"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Issue
|
||||||
|
|
||||||
|
API requests are returning:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "invalid credentials"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP Status: `403 Forbidden`
|
||||||
|
|
||||||
|
## Troubleshooting Attempts
|
||||||
|
|
||||||
|
1. ✅ Verified API key is complete and correct
|
||||||
|
2. ✅ Tried `X-API-KEY` header (lowercase)
|
||||||
|
3. ✅ Tried `Authorization: Bearer` header
|
||||||
|
4. ✅ Verified payload format matches API expectations
|
||||||
|
5. ✅ Tested different endpoint paths
|
||||||
|
|
||||||
|
## Most Likely Cause: Domain Verification
|
||||||
|
|
||||||
|
**The sending domain `beachlitter.ie` needs to be verified in AhaSend before emails can be sent.**
|
||||||
|
|
||||||
|
### How to Verify Domain
|
||||||
|
|
||||||
|
1. Go to https://dash.ahasend.com/
|
||||||
|
2. Navigate to **Domains** section
|
||||||
|
3. Click **"Add Domain"**
|
||||||
|
4. Enter `beachlitter.ie`
|
||||||
|
5. Add the required DNS records (SPF, DKIM, etc.)
|
||||||
|
6. Wait for verification (usually a few minutes)
|
||||||
|
|
||||||
|
### Alternative: Use a Verified Domain
|
||||||
|
|
||||||
|
If you don't own `beachlitter.ie`, you can:
|
||||||
|
1. Use a domain you own and have access to DNS
|
||||||
|
2. Or use a test email like `noreply@yourdomain.com`
|
||||||
|
3. Update `FROM_EMAIL` in `.env` to match
|
||||||
|
|
||||||
|
## Other Possible Causes
|
||||||
|
|
||||||
|
1. **API Key Not Activated**: The API key may need time to activate
|
||||||
|
2. **Account Status**: The account may need additional setup
|
||||||
|
3. **API Format**: The payload format may need adjustment
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
### Option 1: Contact AhaSend Support
|
||||||
|
|
||||||
|
Reach out to AhaSend support with:
|
||||||
|
- Account ID: `8a14f9a5-6e6d-47c9-b5d6-a687265b36a3`
|
||||||
|
- Issue: API returning "invalid credentials" with correct secret key
|
||||||
|
- Request: Verify API key is active and check account status
|
||||||
|
|
||||||
|
### Option 2: Use SMTP Instead
|
||||||
|
|
||||||
|
AhaSend provides SMTP credentials:
|
||||||
|
- **Host**: `send-us.ahasend.com`
|
||||||
|
- **Port**: `587`
|
||||||
|
- **Username**: `Fm7BA4ZxRQ`
|
||||||
|
- **Password**: `YP2trYeJ60fRRaGDzcsxzm8j`
|
||||||
|
|
||||||
|
Note: SMTP credentials also returned "Invalid login" error, suggesting account-level issue.
|
||||||
|
|
||||||
|
### Option 3: Alternative Email Service
|
||||||
|
|
||||||
|
Consider using:
|
||||||
|
- **SendGrid**: Well-documented, reliable
|
||||||
|
- **AWS SES**: Cost-effective for high volume
|
||||||
|
- **Postmark**: Great deliverability
|
||||||
|
- **Resend**: Modern API, developer-friendly
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Run the test script:
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
npx tsx src/scripts/testEmail.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Current output:
|
||||||
|
```
|
||||||
|
❌ Failed to send test email to psbailey31@gmail.com
|
||||||
|
Error: { status: 'invalid credentials' }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Status
|
||||||
|
|
||||||
|
✅ **Notification System**: Fully implemented
|
||||||
|
✅ **Database Schema**: Complete with migrations
|
||||||
|
✅ **API Endpoints**: All working
|
||||||
|
✅ **Frontend Components**: NotificationBell integrated
|
||||||
|
✅ **Scheduled Jobs**: Event reminders and cleanup configured
|
||||||
|
✅ **Email Service**: Code ready, awaiting working credentials
|
||||||
|
|
||||||
|
## Workaround
|
||||||
|
|
||||||
|
For immediate testing, you can:
|
||||||
|
|
||||||
|
1. **Mock Email Sending**: Comment out actual email sending and just log
|
||||||
|
2. **Use Different Service**: Switch to SendGrid or another provider
|
||||||
|
3. **Test In-App Only**: Notification system works without email
|
||||||
|
|
||||||
|
## Code Location
|
||||||
|
|
||||||
|
Email service implementation: `server/src/services/emailService.ts`
|
||||||
|
|
||||||
|
Current implementation uses:
|
||||||
|
- Axios for HTTP requests
|
||||||
|
- AhaSend REST API v1
|
||||||
|
- Proper error handling and logging
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
**Contact AhaSend support** to verify:
|
||||||
|
1. Is the API key active?
|
||||||
|
2. Does the account need verification?
|
||||||
|
3. Is domain verification required?
|
||||||
|
4. Are there any account restrictions?
|
||||||
|
|
||||||
|
Alternatively, **switch to SendGrid** which has:
|
||||||
|
- Clear documentation
|
||||||
|
- Easy setup
|
||||||
|
- Free tier (100 emails/day)
|
||||||
|
- Proven reliability
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The notification system is **100% complete** and ready to use. The only blocker is the AhaSend API credentials not being accepted. Once credentials are verified or an alternative email service is configured, the system will be fully operational.
|
||||||
|
|
||||||
|
All other functionality works:
|
||||||
|
- ✅ In-app notifications
|
||||||
|
- ✅ Area-of-interest matching
|
||||||
|
- ✅ Notification bell UI
|
||||||
|
- ✅ Mark as read
|
||||||
|
- ✅ Scheduled jobs
|
||||||
|
- ✅ Database storage
|
||||||
|
|
||||||
|
**Email delivery is the only pending item.**
|
||||||
49
server/Dockerfile
Normal file
49
server/Dockerfile
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Backend Dockerfile
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy prisma schema
|
||||||
|
COPY prisma ./prisma
|
||||||
|
|
||||||
|
# Generate Prisma client
|
||||||
|
RUN npx prisma generate
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build TypeScript
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install production dependencies only
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
# Copy prisma schema and generated client
|
||||||
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
|
||||||
|
# Copy built application
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
# Create uploads directory
|
||||||
|
RUN mkdir -p uploads
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Beach Litter Mapping API
|
# Litter Scouts API
|
||||||
|
|
||||||
Backend API server for the Beach Litter Mapping Platform.
|
Backend API server for Litter Scouts.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
|
|||||||
2040
server/package-lock.json
generated
2040
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "beach-litter-api",
|
"name": "litter-scouts-api",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Backend API for Beach Litter Mapping Platform",
|
"description": "Backend API for Litter Scouts",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
@@ -28,12 +28,12 @@
|
|||||||
"exifr": "^7.1.3",
|
"exifr": "^7.1.3",
|
||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
"express-validator": "^7.2.0",
|
"express-validator": "^7.2.0",
|
||||||
"form-data": "^4.0.4",
|
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
"json2csv": "^6.0.0-alpha.2",
|
"json2csv": "^6.0.0-alpha.2",
|
||||||
"mailgun.js": "^12.1.1",
|
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"mysql2": "^3.15.3",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
|
"nodemailer": "^7.0.10",
|
||||||
"redis": "^5.9.0",
|
"redis": "^5.9.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
@@ -44,6 +44,7 @@
|
|||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/node": "^22.9.0",
|
"@types/node": "^22.9.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
|
"@types/nodemailer": "^7.0.3",
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.6.3"
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
-- Make email field optional (nullable) in users table
|
||||||
|
ALTER TABLE `users` MODIFY `email` VARCHAR(255) NULL;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `event_registrations`
|
||||||
|
ADD COLUMN `litter_collected` DECIMAL(10, 2) NULL,
|
||||||
|
ADD COLUMN `contribution_note` TEXT NULL;
|
||||||
@@ -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`;
|
||||||
@@ -9,7 +9,7 @@ datasource db {
|
|||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(dbgenerated("(uuid())")) @db.VarChar(36)
|
id String @id @default(dbgenerated("(uuid())")) @db.VarChar(36)
|
||||||
email String @unique(map: "email") @db.VarChar(255)
|
email String? @unique(map: "email") @db.VarChar(255)
|
||||||
password_hash String? @db.VarChar(255)
|
password_hash String? @db.VarChar(255)
|
||||||
username String @unique(map: "username") @db.VarChar(100)
|
username String @unique(map: "username") @db.VarChar(100)
|
||||||
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
|
||||||
@@ -83,6 +83,8 @@ model Event {
|
|||||||
participantCount Int? @default(0) @map("participant_count")
|
participantCount Int? @default(0) @map("participant_count")
|
||||||
litterCollected Decimal? @map("litter_collected") @db.Decimal(10, 2)
|
litterCollected Decimal? @map("litter_collected") @db.Decimal(10, 2)
|
||||||
photos Json?
|
photos Json?
|
||||||
|
equipmentProvided Boolean? @default(false) @map("equipment_provided")
|
||||||
|
requiredItems Json? @map("required_items")
|
||||||
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
|
||||||
updatedAt DateTime? @default(now()) @updatedAt @map("updated_at") @db.Timestamp(0)
|
updatedAt DateTime? @default(now()) @updatedAt @map("updated_at") @db.Timestamp(0)
|
||||||
registrations EventRegistration[]
|
registrations EventRegistration[]
|
||||||
@@ -96,13 +98,15 @@ model Event {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model EventRegistration {
|
model EventRegistration {
|
||||||
id String @id @default(dbgenerated("(uuid())")) @db.VarChar(36)
|
id String @id @default(dbgenerated("(uuid())")) @db.VarChar(36)
|
||||||
userId String @map("user_id") @db.VarChar(36)
|
userId String @map("user_id") @db.VarChar(36)
|
||||||
eventId String @map("event_id") @db.VarChar(36)
|
eventId String @map("event_id") @db.VarChar(36)
|
||||||
registeredAt DateTime? @default(now()) @map("registered_at") @db.Timestamp(0)
|
registeredAt DateTime? @default(now()) @map("registered_at") @db.Timestamp(0)
|
||||||
attended Boolean? @default(false)
|
attended Boolean? @default(false)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "event_registrations_ibfk_1")
|
litterCollected Decimal? @map("litter_collected") @db.Decimal(10, 2)
|
||||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "event_registrations_ibfk_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")
|
@@unique([userId, eventId], map: "unique_user_event")
|
||||||
@@index([eventId], map: "event_id")
|
@@index([eventId], map: "event_id")
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ async function findOrCreateUser(clerkId: string): Promise<string> {
|
|||||||
user = await prisma.user.create({
|
user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
clerkId,
|
clerkId,
|
||||||
email: clerkUser.emailAddresses[0]?.emailAddress || `${clerkId}@temp.com`,
|
email: clerkUser.emailAddresses[0]?.emailAddress || null,
|
||||||
username: clerkUser.username || `user_${clerkId.substring(0, 8)}`,
|
username: clerkUser.username || `user_${clerkId.substring(0, 8)}`,
|
||||||
firstName: clerkUser.firstName,
|
firstName: clerkUser.firstName,
|
||||||
lastName: clerkUser.lastName,
|
lastName: clerkUser.lastName,
|
||||||
@@ -109,7 +109,7 @@ export const eventController = {
|
|||||||
// Find or create user in database
|
// Find or create user in database
|
||||||
const userId = await findOrCreateUser(clerkId);
|
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
|
// Validation
|
||||||
if (!title || !description || !latitude || !longitude || !locationName || !scheduledDate || !duration) {
|
if (!title || !description || !latitude || !longitude || !locationName || !scheduledDate || !duration) {
|
||||||
@@ -129,6 +129,8 @@ export const eventController = {
|
|||||||
locationName,
|
locationName,
|
||||||
scheduledDate: new Date(scheduledDate),
|
scheduledDate: new Date(scheduledDate),
|
||||||
duration: parseInt(duration),
|
duration: parseInt(duration),
|
||||||
|
equipmentProvided: equipmentProvided !== undefined ? Boolean(equipmentProvided) : false,
|
||||||
|
requiredItems: Array.isArray(requiredItems) ? requiredItems : [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const event = await eventService.createEvent(eventData);
|
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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export class ReportController {
|
|||||||
user = await prisma.user.create({
|
user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
clerkId,
|
clerkId,
|
||||||
email: clerkUser.emailAddresses[0]?.emailAddress || `${clerkId}@temp.com`,
|
email: clerkUser.emailAddresses[0]?.emailAddress || null,
|
||||||
username: defaultUsername,
|
username: defaultUsername,
|
||||||
firstName: clerkUser.firstName,
|
firstName: clerkUser.firstName,
|
||||||
lastName: clerkUser.lastName,
|
lastName: clerkUser.lastName,
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export class UserController {
|
|||||||
async updateUserProfile(req: AuthRequest, res: Response) {
|
async updateUserProfile(req: AuthRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
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
|
// Authorization check - users can only update their own profile
|
||||||
if (!req.userId) {
|
if (!req.userId) {
|
||||||
@@ -91,6 +91,7 @@ export class UserController {
|
|||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
username,
|
username,
|
||||||
|
email,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json(updatedProfile);
|
res.json(updatedProfile);
|
||||||
|
|||||||
@@ -19,13 +19,15 @@ const PORT = process.env.PORT || 3005;
|
|||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
app.use(helmet({
|
app.use(helmet({
|
||||||
crossOriginResourcePolicy: { policy: "cross-origin" },
|
crossOriginResourcePolicy: false, // Disable to allow images to be loaded from different origin
|
||||||
contentSecurityPolicy: false, // Disable CSP for development
|
contentSecurityPolicy: false,
|
||||||
}));
|
}));
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
origin: [
|
origin: [
|
||||||
'http://localhost:5173',
|
'http://localhost:5173',
|
||||||
'http://localhost:5174',
|
'http://localhost:5174',
|
||||||
|
'https://litterscouts.psbailey.uk',
|
||||||
|
'http://litterscouts.psbailey.uk',
|
||||||
process.env.CORS_ORIGIN || ''
|
process.env.CORS_ORIGIN || ''
|
||||||
].filter(Boolean),
|
].filter(Boolean),
|
||||||
credentials: true,
|
credentials: true,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const router = Router();
|
|||||||
router.get('/', eventController.getEvents);
|
router.get('/', eventController.getEvents);
|
||||||
router.get('/:id', eventController.getEvent);
|
router.get('/:id', eventController.getEvent);
|
||||||
router.get('/:id/registrations', eventController.getEventRegistrations);
|
router.get('/:id/registrations', eventController.getEventRegistrations);
|
||||||
|
router.get('/:id/attendees', eventController.getEventAttendees);
|
||||||
|
|
||||||
// Protected routes (require authentication)
|
// Protected routes (require authentication)
|
||||||
router.post('/', requireAuth, eventController.createEvent);
|
router.post('/', requireAuth, eventController.createEvent);
|
||||||
@@ -17,5 +18,8 @@ router.post('/:id/register', requireAuth, eventController.registerForEvent);
|
|||||||
router.delete('/:id/register', requireAuth, eventController.unregisterFromEvent);
|
router.delete('/:id/register', requireAuth, eventController.unregisterFromEvent);
|
||||||
router.get('/:id/registration-status', requireAuth, eventController.getRegistrationStatus);
|
router.get('/:id/registration-status', requireAuth, eventController.getRegistrationStatus);
|
||||||
router.post('/:id/complete', requireAuth, eventController.completeEvent);
|
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;
|
export default router;
|
||||||
|
|||||||
57
server/src/scripts/applyMigration.ts
Normal file
57
server/src/scripts/applyMigration.ts
Normal 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);
|
||||||
|
});
|
||||||
89
server/src/scripts/testAttendees.ts
Normal file
89
server/src/scripts/testAttendees.ts
Normal 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);
|
||||||
|
});
|
||||||
@@ -11,8 +11,8 @@ async function testEmail() {
|
|||||||
console.log('📧 Testing Email Service with Mailgun\n');
|
console.log('📧 Testing Email Service with Mailgun\n');
|
||||||
|
|
||||||
console.log('Configuration:');
|
console.log('Configuration:');
|
||||||
console.log(` API Key: ${process.env.MAILGUN_API_KEY?.substring(0, 20)}...`);
|
console.log(` API Key: ${process.env.AHASEND_API_KEY?.substring(0, 30)}...`);
|
||||||
console.log(` Domain: ${process.env.MAILGUN_DOMAIN}`);
|
console.log(` Account ID: ${process.env.AHASEND_ACCOUNT_ID}`);
|
||||||
console.log(` From: ${process.env.FROM_EMAIL}`);
|
console.log(` From: ${process.env.FROM_EMAIL}`);
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
@@ -22,8 +22,8 @@ async function testEmail() {
|
|||||||
|
|
||||||
const success = await emailService.sendEmail({
|
const success = await emailService.sendEmail({
|
||||||
to: testEmail,
|
to: testEmail,
|
||||||
subject: 'Beach Litter Platform - Test Email',
|
subject: 'Litter Scouts - Test Email',
|
||||||
text: 'This is a test email from the Beach Litter Mapping Platform. If you received this, email notifications are working correctly!',
|
text: 'This is a test email from Litter Scouts. If you received this, email notifications are working correctly!',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
@@ -55,8 +55,8 @@ async function testEmail() {
|
|||||||
|
|
||||||
console.log('✅ Email testing completed!');
|
console.log('✅ Email testing completed!');
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('Note: If using Mailgun sandbox domain, you need to add authorized recipients');
|
console.log('Note: Make sure you have a valid AhaSend API key configured');
|
||||||
console.log('in your Mailgun dashboard: https://app.mailgun.com/');
|
console.log('Get your API key from: https://ahasend.com/');
|
||||||
}
|
}
|
||||||
|
|
||||||
testEmail().catch(console.error);
|
testEmail().catch(console.error);
|
||||||
|
|||||||
118
server/src/scripts/testEmailOptional.ts
Normal file
118
server/src/scripts/testEmailOptional.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* Test script for email-optional functionality
|
||||||
|
*
|
||||||
|
* This script tests:
|
||||||
|
* 1. Creating users without email addresses
|
||||||
|
* 2. Notification delivery for users without email
|
||||||
|
* 3. Email service handling of null emails
|
||||||
|
*
|
||||||
|
* Run with: npx ts-node src/scripts/testEmailOptional.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import prisma from '../config/database';
|
||||||
|
import { notificationService } from '../services/notificationService';
|
||||||
|
import { emailService } from '../services/emailService';
|
||||||
|
|
||||||
|
async function testEmailOptional() {
|
||||||
|
console.log('🧪 Testing Email-Optional Functionality\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test 1: Create a test user without email
|
||||||
|
console.log('1️⃣ Creating test user without email...');
|
||||||
|
const testUser = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
clerkId: `test_${Date.now()}`,
|
||||||
|
email: null, // No email
|
||||||
|
username: `testuser_${Date.now()}`,
|
||||||
|
firstName: 'Test',
|
||||||
|
lastName: 'User',
|
||||||
|
notificationEmail: true, // Wants email notifications but has no email
|
||||||
|
notificationInApp: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`✅ Created user: ${testUser.username} (ID: ${testUser.id})`);
|
||||||
|
console.log(` Email: ${testUser.email || 'NULL'}`);
|
||||||
|
console.log(` Notification preferences: email=${testUser.notificationEmail}, inApp=${testUser.notificationInApp}\n`);
|
||||||
|
|
||||||
|
// Test 2: Create a notification for this user
|
||||||
|
console.log('2️⃣ Creating notification for user without email...');
|
||||||
|
const notification = await notificationService.createNotification({
|
||||||
|
userId: testUser.id,
|
||||||
|
type: 'new_report',
|
||||||
|
title: 'Test Notification',
|
||||||
|
message: 'This is a test notification for a user without email.',
|
||||||
|
});
|
||||||
|
console.log(`✅ Created notification: ${notification.id}`);
|
||||||
|
console.log(` Email sent: ${notification.emailSent}\n`);
|
||||||
|
|
||||||
|
// Test 3: Try to send email notification (should be skipped gracefully)
|
||||||
|
console.log('3️⃣ Attempting to send email notification...');
|
||||||
|
const result = await emailService.sendNotificationEmail(
|
||||||
|
testUser.email || '',
|
||||||
|
testUser.firstName,
|
||||||
|
'Test Email',
|
||||||
|
'This should not be sent because user has no email.'
|
||||||
|
);
|
||||||
|
console.log(`✅ Email send result: ${result}`);
|
||||||
|
console.log(` Expected: false (no email address)\n`);
|
||||||
|
|
||||||
|
// Test 4: Create a user WITH email for comparison
|
||||||
|
console.log('4️⃣ Creating test user WITH email...');
|
||||||
|
const testUserWithEmail = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
clerkId: `test_email_${Date.now()}`,
|
||||||
|
email: `test_${Date.now()}@example.com`,
|
||||||
|
username: `testuser_email_${Date.now()}`,
|
||||||
|
firstName: 'Test',
|
||||||
|
lastName: 'WithEmail',
|
||||||
|
notificationEmail: true,
|
||||||
|
notificationInApp: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`✅ Created user: ${testUserWithEmail.username} (ID: ${testUserWithEmail.id})`);
|
||||||
|
console.log(` Email: ${testUserWithEmail.email}\n`);
|
||||||
|
|
||||||
|
// Test 5: Query users for email notifications
|
||||||
|
console.log('5️⃣ Querying users eligible for email notifications...');
|
||||||
|
const usersForEmail = await prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
notificationEmail: true,
|
||||||
|
email: { not: null },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`✅ Found ${usersForEmail.length} users eligible for email notifications`);
|
||||||
|
console.log(` Users with email: ${usersForEmail.filter(u => u.email).length}`);
|
||||||
|
console.log(` Users without email: ${usersForEmail.filter(u => !u.email).length}\n`);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
console.log('🧹 Cleaning up test data...');
|
||||||
|
await prisma.notification.deleteMany({
|
||||||
|
where: { userId: { in: [testUser.id, testUserWithEmail.id] } },
|
||||||
|
});
|
||||||
|
await prisma.user.deleteMany({
|
||||||
|
where: { id: { in: [testUser.id, testUserWithEmail.id] } },
|
||||||
|
});
|
||||||
|
console.log('✅ Cleanup complete\n');
|
||||||
|
|
||||||
|
console.log('✅ All tests passed!\n');
|
||||||
|
console.log('Summary:');
|
||||||
|
console.log('- Users can be created without email addresses');
|
||||||
|
console.log('- Notifications are created for users without email');
|
||||||
|
console.log('- Email sending is gracefully skipped for users without email');
|
||||||
|
console.log('- Database queries correctly filter users by email presence');
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Test failed:', error.message);
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the test
|
||||||
|
testEmailOptional();
|
||||||
43
server/src/scripts/verifyMigration.ts
Normal file
43
server/src/scripts/verifyMigration.ts
Normal 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));
|
||||||
@@ -1,16 +1,9 @@
|
|||||||
import Mailgun from 'mailgun.js';
|
import axios from 'axios';
|
||||||
import formData from 'form-data';
|
|
||||||
|
|
||||||
const mailgun = new Mailgun(formData);
|
// AhaSend API configuration (v2)
|
||||||
|
const AHASEND_API_KEY = process.env.AHASEND_API_KEY || 'aha-sk-GbzRp0nqVCgmQVw0FN47HNHdFNjR055cQCY1L-l5BSBV9_EmE1-NGHPbR2e2gaR2';
|
||||||
// Initialize Mailgun client
|
const AHASEND_ACCOUNT_ID = process.env.AHASEND_ACCOUNT_ID || '8a14f9a5-6e6d-47c9-b5d6-a687265b36a3';
|
||||||
const mg = mailgun.client({
|
const FROM_EMAIL = process.env.FROM_EMAIL || 'Beach Litter Platform <noreply@psbailey.uk>';
|
||||||
username: 'api',
|
|
||||||
key: process.env.MAILGUN_API_KEY || 'dd27be7a81dd7b0980ada31fec9a9bd9-e80d8b76-fd80c63b',
|
|
||||||
});
|
|
||||||
|
|
||||||
const MAILGUN_DOMAIN = process.env.MAILGUN_DOMAIN || 'sandbox1835a1917b07443f8c1ca858689c42ea.mailgun.org';
|
|
||||||
const FROM_EMAIL = process.env.FROM_EMAIL || 'Beach Litter Platform <noreply@beachlitter.ie>';
|
|
||||||
|
|
||||||
interface EmailData {
|
interface EmailData {
|
||||||
to: string;
|
to: string;
|
||||||
@@ -21,24 +14,53 @@ interface EmailData {
|
|||||||
|
|
||||||
export class EmailService {
|
export class EmailService {
|
||||||
/**
|
/**
|
||||||
* Send an email using Mailgun
|
* Send an email using AhaSend REST API
|
||||||
*/
|
*/
|
||||||
async sendEmail(data: EmailData): Promise<boolean> {
|
async sendEmail(data: EmailData): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const messageData = {
|
// Parse from email to separate email and name
|
||||||
from: FROM_EMAIL,
|
const fromMatch = FROM_EMAIL.match(/^(.+?)\s*<(.+)>$/);
|
||||||
to: data.to,
|
const fromEmail = fromMatch ? fromMatch[2] : FROM_EMAIL;
|
||||||
|
const fromName = fromMatch ? fromMatch[1].trim() : undefined;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
from: {
|
||||||
|
email: fromEmail,
|
||||||
|
...(fromName && { name: fromName }),
|
||||||
|
},
|
||||||
|
recipients: [
|
||||||
|
{
|
||||||
|
email: data.to,
|
||||||
|
},
|
||||||
|
],
|
||||||
subject: data.subject,
|
subject: data.subject,
|
||||||
text: data.text,
|
text_content: data.text,
|
||||||
html: data.html || this.generateHtmlFromText(data.text),
|
html_content: data.html || this.generateHtmlFromText(data.text),
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await mg.messages.create(MAILGUN_DOMAIN, messageData);
|
console.log('[Email] Sending request to AhaSend API v2...');
|
||||||
|
console.log('[Email] API Key:', AHASEND_API_KEY.substring(0, 30) + '...');
|
||||||
|
console.log('[Email] Endpoint:', `/v2/accounts/${AHASEND_ACCOUNT_ID}/messages`);
|
||||||
|
|
||||||
|
const response = await axios.post(
|
||||||
|
`https://api.ahasend.com/v2/accounts/${AHASEND_ACCOUNT_ID}/messages`,
|
||||||
|
payload,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${AHASEND_API_KEY}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
console.log(`[Email] Successfully sent to ${data.to}:`, response.id);
|
console.log(`[Email] Successfully sent to ${data.to}:`, response.data);
|
||||||
return true;
|
return true;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`[Email] Failed to send to ${data.to}:`, error.message);
|
console.error(`[Email] Failed to send to ${data.to}:`, error.response?.data || error.message);
|
||||||
|
if (error.response) {
|
||||||
|
console.error('[Email] Response status:', error.response.status);
|
||||||
|
console.error('[Email] Response headers:', error.response.headers);
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,6 +76,12 @@ export class EmailService {
|
|||||||
relatedType?: 'report' | 'event',
|
relatedType?: 'report' | 'event',
|
||||||
relatedId?: string
|
relatedId?: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
// Validate email address exists
|
||||||
|
if (!to || to.trim() === '') {
|
||||||
|
console.log('[Email] Skipping notification - no email address provided');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const greeting = firstName ? `Hi ${firstName}` : 'Hi';
|
const greeting = firstName ? `Hi ${firstName}` : 'Hi';
|
||||||
|
|
||||||
const text = `
|
const text = `
|
||||||
@@ -66,7 +94,7 @@ ${message}
|
|||||||
${relatedType && relatedId ? `View details: ${this.getDetailsUrl(relatedType, relatedId)}` : ''}
|
${relatedType && relatedId ? `View details: ${this.getDetailsUrl(relatedType, relatedId)}` : ''}
|
||||||
|
|
||||||
---
|
---
|
||||||
Beach Litter Mapping Platform
|
Litter Scouts
|
||||||
To manage your notification preferences, visit your profile settings.
|
To manage your notification preferences, visit your profile settings.
|
||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
@@ -100,7 +128,7 @@ To manage your notification preferences, visit your profile settings.
|
|||||||
` : ''}
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<p>Beach Litter Mapping Platform</p>
|
<p>Litter Scouts</p>
|
||||||
<p>To manage your notification preferences, visit your profile settings.</p>
|
<p>To manage your notification preferences, visit your profile settings.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,13 +181,13 @@ To manage your notification preferences, visit your profile settings.
|
|||||||
// Send a test email to verify configuration
|
// Send a test email to verify configuration
|
||||||
const testEmail = process.env.TEST_EMAIL || 'test@example.com';
|
const testEmail = process.env.TEST_EMAIL || 'test@example.com';
|
||||||
|
|
||||||
await this.sendEmail({
|
const success = await this.sendEmail({
|
||||||
to: testEmail,
|
to: testEmail,
|
||||||
subject: 'Beach Litter Platform - Email Test',
|
subject: 'Litter Scouts - Email Test',
|
||||||
text: 'This is a test email from the Beach Litter Mapping Platform. If you received this, email notifications are working correctly!',
|
text: 'This is a test email from Litter Scouts. If you received this, email notifications are working correctly!',
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return success;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[Email] Connection test failed:', error.message);
|
console.error('[Email] Connection test failed:', error.message);
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ interface CreateEventData {
|
|||||||
locationName: string;
|
locationName: string;
|
||||||
scheduledDate: Date;
|
scheduledDate: Date;
|
||||||
duration: number;
|
duration: number;
|
||||||
|
equipmentProvided?: boolean;
|
||||||
|
requiredItems?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpdateEventData {
|
interface UpdateEventData {
|
||||||
@@ -31,6 +33,8 @@ interface UpdateEventData {
|
|||||||
status?: 'upcoming' | 'completed' | 'cancelled';
|
status?: 'upcoming' | 'completed' | 'cancelled';
|
||||||
litterCollected?: number;
|
litterCollected?: number;
|
||||||
photos?: string[];
|
photos?: string[];
|
||||||
|
equipmentProvided?: boolean;
|
||||||
|
requiredItems?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const eventService = {
|
export const eventService = {
|
||||||
@@ -93,6 +97,8 @@ export const eventService = {
|
|||||||
participantCount: event._count.registrations,
|
participantCount: event._count.registrations,
|
||||||
litterCollected: event.litterCollected ? parseFloat(event.litterCollected.toString()) : undefined,
|
litterCollected: event.litterCollected ? parseFloat(event.litterCollected.toString()) : undefined,
|
||||||
photos: event.photos ? (Array.isArray(event.photos) ? event.photos : []) : 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,
|
createdAt: event.createdAt,
|
||||||
updatedAt: event.updatedAt,
|
updatedAt: event.updatedAt,
|
||||||
}));
|
}));
|
||||||
@@ -137,6 +143,8 @@ export const eventService = {
|
|||||||
participantCount: event._count.registrations,
|
participantCount: event._count.registrations,
|
||||||
litterCollected: event.litterCollected ? parseFloat(event.litterCollected.toString()) : undefined,
|
litterCollected: event.litterCollected ? parseFloat(event.litterCollected.toString()) : undefined,
|
||||||
photos: event.photos ? (Array.isArray(event.photos) ? event.photos : []) : 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,
|
createdAt: event.createdAt,
|
||||||
updatedAt: event.updatedAt,
|
updatedAt: event.updatedAt,
|
||||||
};
|
};
|
||||||
@@ -145,16 +153,18 @@ export const eventService = {
|
|||||||
async createEvent(data: CreateEventData) {
|
async createEvent(data: CreateEventData) {
|
||||||
// Create point geometry for spatial queries
|
// Create point geometry for spatial queries
|
||||||
const locationPoint = `POINT(${data.longitude} ${data.latitude})`;
|
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`
|
const event = await prisma.$executeRaw`
|
||||||
INSERT INTO events (
|
INSERT INTO events (
|
||||||
id, organizer_id, title, description, location, latitude, longitude,
|
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 (
|
) VALUES (
|
||||||
UUID(), ${data.organizerId}, ${data.title}, ${data.description},
|
UUID(), ${data.organizerId}, ${data.title}, ${data.description},
|
||||||
ST_GeomFromText(${locationPoint}, 4326),
|
ST_GeomFromText(${locationPoint}, 4326),
|
||||||
${data.latitude}, ${data.longitude}, ${data.locationName},
|
${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,
|
participantCount: createdEvent._count.registrations,
|
||||||
litterCollected: createdEvent.litterCollected ? parseFloat(createdEvent.litterCollected.toString()) : undefined,
|
litterCollected: createdEvent.litterCollected ? parseFloat(createdEvent.litterCollected.toString()) : undefined,
|
||||||
photos: createdEvent.photos ? (Array.isArray(createdEvent.photos) ? createdEvent.photos : []) : 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,
|
createdAt: createdEvent.createdAt,
|
||||||
updatedAt: createdEvent.updatedAt,
|
updatedAt: createdEvent.updatedAt,
|
||||||
};
|
};
|
||||||
@@ -372,4 +384,217 @@ export const eventService = {
|
|||||||
updatedAt: event.updatedAt,
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import prisma from '../config/database';
|
import prisma from '../config/database';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import { calculateDistance } from '../utils/spatial';
|
import { calculateDistance } from '../utils/spatial';
|
||||||
import { emailService } from './emailService';
|
import { emailService } from './emailService';
|
||||||
|
|
||||||
@@ -55,7 +56,7 @@ export class NotificationService {
|
|||||||
{ notificationInApp: true },
|
{ notificationInApp: true },
|
||||||
],
|
],
|
||||||
areasOfInterest: {
|
areasOfInterest: {
|
||||||
not: null,
|
not: Prisma.JsonNull,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
@@ -77,7 +78,7 @@ export class NotificationService {
|
|||||||
|
|
||||||
// Check if the location falls within any of the user's areas of interest
|
// Check if the location falls within any of the user's areas of interest
|
||||||
for (const area of areasOfInterest) {
|
for (const area of areasOfInterest) {
|
||||||
const distance = calculateDistance(
|
const distance = await calculateDistance(
|
||||||
latitude,
|
latitude,
|
||||||
longitude,
|
longitude,
|
||||||
area.lat,
|
area.lat,
|
||||||
@@ -416,7 +417,8 @@ export class NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send email notifications using Mailgun
|
* Send email notifications using email service
|
||||||
|
* Gracefully handles users without email addresses
|
||||||
*/
|
*/
|
||||||
private async sendEmailNotifications(notifications: any[]) {
|
private async sendEmailNotifications(notifications: any[]) {
|
||||||
// Get user emails for notifications
|
// Get user emails for notifications
|
||||||
@@ -426,6 +428,7 @@ export class NotificationService {
|
|||||||
where: {
|
where: {
|
||||||
id: { in: userIds },
|
id: { in: userIds },
|
||||||
notificationEmail: true,
|
notificationEmail: true,
|
||||||
|
email: { not: null }, // Only get users with email addresses
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -438,10 +441,21 @@ export class NotificationService {
|
|||||||
|
|
||||||
for (const notification of notifications) {
|
for (const notification of notifications) {
|
||||||
const user = userMap.get(notification.userId);
|
const user = userMap.get(notification.userId);
|
||||||
if (!user) continue;
|
|
||||||
|
if (!user) {
|
||||||
|
// User doesn't have email or has email notifications disabled
|
||||||
|
console.log(`[Notification] Skipping email for user ${notification.userId} (no email address or disabled)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.email) {
|
||||||
|
// Extra safety check - should not happen due to query filter
|
||||||
|
console.log(`[Notification] Skipping email for user ${user.id} (email is null)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Send email using Mailgun
|
// Send email using email service
|
||||||
const success = await emailService.sendNotificationEmail(
|
const success = await emailService.sendNotificationEmail(
|
||||||
user.email,
|
user.email,
|
||||||
user.firstName,
|
user.firstName,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface UpdateUserProfileData {
|
|||||||
firstName?: string;
|
firstName?: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
email?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateNotificationPreferencesData {
|
export interface UpdateNotificationPreferencesData {
|
||||||
@@ -43,7 +44,7 @@ export class UserService {
|
|||||||
user = await prisma.user.create({
|
user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
clerkId,
|
clerkId,
|
||||||
email: clerkUser.emailAddresses[0]?.emailAddress || `${clerkId}@temp.com`,
|
email: clerkUser.emailAddresses[0]?.emailAddress || null,
|
||||||
username: defaultUsername,
|
username: defaultUsername,
|
||||||
firstName: clerkUser.firstName,
|
firstName: clerkUser.firstName,
|
||||||
lastName: clerkUser.lastName,
|
lastName: clerkUser.lastName,
|
||||||
@@ -66,6 +67,11 @@ export class UserService {
|
|||||||
// Check if this is a Clerk ID (starts with 'user_')
|
// Check if this is a Clerk ID (starts with 'user_')
|
||||||
const isClerkId = userId.startsWith('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({
|
const user = await prisma.user.findUnique({
|
||||||
where: isClerkId ? { clerkId: userId } : { id: userId },
|
where: isClerkId ? { clerkId: userId } : { id: userId },
|
||||||
select: {
|
select: {
|
||||||
@@ -91,6 +97,17 @@ export class UserService {
|
|||||||
organizedEvents: true,
|
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 {
|
return {
|
||||||
...user,
|
...user,
|
||||||
areasOfInterest,
|
areasOfInterest,
|
||||||
reportCount: user._count.reports,
|
reportCount: user._count.reports,
|
||||||
eventCount: user._count.eventRegistrations,
|
eventCount: user._count.eventRegistrations,
|
||||||
|
totalLitterCollected,
|
||||||
stats: {
|
stats: {
|
||||||
reportsSubmitted: user._count.reports,
|
reportsSubmitted: user._count.reports,
|
||||||
eventsAttended: user._count.eventRegistrations,
|
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({
|
const updatedUser = await prisma.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: {
|
data: {
|
||||||
firstName: data.firstName,
|
firstName: data.firstName,
|
||||||
lastName: data.lastName,
|
lastName: data.lastName,
|
||||||
username: data.username,
|
username: data.username,
|
||||||
|
email: data.email !== undefined ? (data.email.trim() === '' ? null : data.email) : undefined,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -174,14 +215,8 @@ export class UserService {
|
|||||||
let dbUserId = userId;
|
let dbUserId = userId;
|
||||||
|
|
||||||
if (isClerkId) {
|
if (isClerkId) {
|
||||||
const user = await prisma.user.findUnique({
|
// Ensure user exists in database
|
||||||
where: { clerkId: userId },
|
dbUserId = await this.findOrCreateUser(userId);
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (!user) {
|
|
||||||
throw new Error('User not found');
|
|
||||||
}
|
|
||||||
dbUserId = user.id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const reports = await prisma.report.findMany({
|
const reports = await prisma.report.findMany({
|
||||||
@@ -207,14 +242,8 @@ export class UserService {
|
|||||||
let dbUserId = userId;
|
let dbUserId = userId;
|
||||||
|
|
||||||
if (isClerkId) {
|
if (isClerkId) {
|
||||||
const user = await prisma.user.findUnique({
|
// Ensure user exists in database
|
||||||
where: { clerkId: userId },
|
dbUserId = await this.findOrCreateUser(userId);
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (!user) {
|
|
||||||
throw new Error('User not found');
|
|
||||||
}
|
|
||||||
dbUserId = user.id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get events user has registered for
|
// Get events user has registered for
|
||||||
@@ -315,14 +344,8 @@ export class UserService {
|
|||||||
let dbUserId = userId;
|
let dbUserId = userId;
|
||||||
|
|
||||||
if (isClerkId) {
|
if (isClerkId) {
|
||||||
const user = await prisma.user.findUnique({
|
// Ensure user exists in database
|
||||||
where: { clerkId: userId },
|
dbUserId = await this.findOrCreateUser(userId);
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (!user) {
|
|
||||||
throw new Error('User not found');
|
|
||||||
}
|
|
||||||
dbUserId = user.id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user's reports
|
// Get user's reports
|
||||||
|
|||||||
20
src/App.tsx
20
src/App.tsx
@@ -24,6 +24,8 @@ const ReportDetailDemo = lazy(() => import('./pages/ReportDetailDemo'));
|
|||||||
const WhyCleanPage = lazy(() => import('./pages/WhyCleanPage'));
|
const WhyCleanPage = lazy(() => import('./pages/WhyCleanPage'));
|
||||||
const UserProfilePage = lazy(() => import('./pages/UserProfilePage'));
|
const UserProfilePage = lazy(() => import('./pages/UserProfilePage'));
|
||||||
const EnvironmentalDataDemo = lazy(() => import('./pages/EnvironmentalDataDemo'));
|
const EnvironmentalDataDemo = lazy(() => import('./pages/EnvironmentalDataDemo'));
|
||||||
|
const EventCheckInPage = lazy(() => import('./pages/EventCheckInPage'));
|
||||||
|
const PrintableQRCodePage = lazy(() => import('./pages/PrintableQRCodePage'));
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
import { apiClient } from './services/api';
|
import { apiClient } from './services/api';
|
||||||
@@ -152,6 +154,24 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</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 */}
|
{/* Demo routes for testing */}
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Beach Litter Mapping Platform - Source Code
|
# Litter Scouts - Source Code
|
||||||
|
|
||||||
This directory contains the frontend source code for the Beach Litter Mapping Platform.
|
This directory contains the frontend source code for Litter Scouts.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Components Directory
|
# Components Directory
|
||||||
|
|
||||||
This directory contains all React components for the Beach Litter Mapping Platform.
|
This directory contains all React components for Litter Scouts.
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
|
|||||||
69
src/components/auth/BadgeDisplay.tsx
Normal file
69
src/components/auth/BadgeDisplay.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { getBadgeForScore, getNextBadgeProgress } from '@/utils/badges';
|
||||||
|
|
||||||
|
interface BadgeDisplayProps {
|
||||||
|
impactScore: number;
|
||||||
|
showProgress?: boolean;
|
||||||
|
size?: 'small' | 'medium' | 'large';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display user's badge based on impact score
|
||||||
|
*/
|
||||||
|
export function BadgeDisplay({ impactScore, showProgress = false, size = 'medium' }: BadgeDisplayProps) {
|
||||||
|
const { currentBadge, nextBadge, progress, pointsToNext } = getNextBadgeProgress(impactScore);
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
small: 'h-12 w-12',
|
||||||
|
medium: 'h-20 w-20',
|
||||||
|
large: 'h-32 w-32',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center space-y-3">
|
||||||
|
{/* Badge Image */}
|
||||||
|
<div className="relative">
|
||||||
|
<img
|
||||||
|
src={currentBadge.image}
|
||||||
|
alt={`${currentBadge.level} Badge`}
|
||||||
|
className={`${sizeClasses[size]} object-contain`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Badge Info */}
|
||||||
|
<div className="text-center">
|
||||||
|
<span className={`inline-block px-3 py-1 rounded-full text-sm font-semibold ${currentBadge.color}`}>
|
||||||
|
{currentBadge.level}
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-gray-600 mt-1">{currentBadge.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress to Next Badge */}
|
||||||
|
{showProgress && nextBadge && (
|
||||||
|
<div className="w-full max-w-xs">
|
||||||
|
<div className="flex justify-between text-xs text-gray-600 mb-1">
|
||||||
|
<span>Progress to {nextBadge.level}</span>
|
||||||
|
<span>{pointsToNext} points to go</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-gradient-to-r from-blue-500 to-purple-500 h-2 rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||||
|
<span>{currentBadge.minScore}</span>
|
||||||
|
<span>{nextBadge.minScore}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Master Badge - No Progress */}
|
||||||
|
{showProgress && !nextBadge && (
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-semibold text-yellow-600">🏆 Maximum Level Achieved!</p>
|
||||||
|
<p className="text-xs text-gray-600">You're an environmental legend</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ export function SignInPage() {
|
|||||||
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||||
Sign in to Beach Litter Mapping
|
Sign in to Litter Scouts
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2 text-center text-sm text-gray-600">
|
<p className="mt-2 text-center text-sm text-gray-600">
|
||||||
Join the community in tracking and cleaning coastal pollution
|
Join the community in tracking and cleaning coastal pollution
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ export { UserProfile } from './UserProfile';
|
|||||||
export { SignInPage } from './SignInPage';
|
export { SignInPage } from './SignInPage';
|
||||||
export { SignUpPage } from './SignUpPage';
|
export { SignUpPage } from './SignUpPage';
|
||||||
export { NotificationSettings } from './NotificationSettings';
|
export { NotificationSettings } from './NotificationSettings';
|
||||||
|
export { BadgeDisplay } from './BadgeDisplay';
|
||||||
export type { NotificationPreferences, AreaOfInterest } from './NotificationSettings';
|
export type { NotificationPreferences, AreaOfInterest } from './NotificationSettings';
|
||||||
|
|||||||
6
src/components/auth/index.ts.backup.ts
Normal file
6
src/components/auth/index.ts.backup.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export { ProtectedRoute } from './ProtectedRoute';
|
||||||
|
export { UserProfile } from './UserProfile';
|
||||||
|
export { SignInPage } from './SignInPage';
|
||||||
|
export { SignUpPage } from './SignUpPage';
|
||||||
|
export { NotificationSettings } from './NotificationSettings';
|
||||||
|
export type { NotificationPreferences, AreaOfInterest } from './NotificationSettings';
|
||||||
277
src/components/event/AttendeeManagement.tsx
Normal file
277
src/components/event/AttendeeManagement.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
209
src/components/event/CompletedEventDetails.tsx
Normal file
209
src/components/event/CompletedEventDetails.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
src/components/event/EmailPromptModal.tsx
Normal file
57
src/components/event/EmailPromptModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,20 +1,34 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { PhotoUpload, type PhotoMetadata } from '@/components/report/PhotoUpload';
|
import { PhotoUpload, type PhotoMetadata } from '@/components/report/PhotoUpload';
|
||||||
|
import { AttendeeManagement } from './AttendeeManagement';
|
||||||
|
|
||||||
interface EventCompletionFormProps {
|
interface EventCompletionFormProps {
|
||||||
eventId: string;
|
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;
|
onCancel: () => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EventCompletionForm({
|
export function EventCompletionForm({
|
||||||
|
eventId,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
}: EventCompletionFormProps) {
|
}: EventCompletionFormProps) {
|
||||||
|
const [step, setStep] = useState<'attendees' | 'summary'>('attendees');
|
||||||
const [litterCollected, setLitterCollected] = useState<string>('');
|
const [litterCollected, setLitterCollected] = useState<string>('');
|
||||||
const [photos, setPhotos] = useState<PhotoMetadata[]>([]);
|
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 [errors, setErrors] = useState<{ litterCollected?: string }>({});
|
||||||
|
|
||||||
const validateForm = (): boolean => {
|
const validateForm = (): boolean => {
|
||||||
@@ -33,6 +47,21 @@ export function EventCompletionForm({
|
|||||||
return Object.keys(newErrors).length === 0;
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -41,13 +70,12 @@ export function EventCompletionForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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);
|
const photoUrls = photos.map(photo => photo.preview);
|
||||||
|
|
||||||
await onSubmit({
|
await onSubmit({
|
||||||
litterCollected: parseFloat(litterCollected),
|
litterCollected: parseFloat(litterCollected),
|
||||||
photos: photoUrls,
|
photos: photoUrls,
|
||||||
|
attendees: attendeeData.length > 0 ? attendeeData : undefined,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error completing event:', error);
|
console.error('Error completing event:', error);
|
||||||
@@ -58,9 +86,29 @@ export function EventCompletionForm({
|
|||||||
setPhotos(photoMetadata);
|
setPhotos(photoMetadata);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (step === 'attendees') {
|
||||||
|
return (
|
||||||
|
<AttendeeManagement
|
||||||
|
eventId={eventId}
|
||||||
|
onClose={onCancel}
|
||||||
|
onSave={handleAttendeesSave}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
<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">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{/* Litter Collected Input */}
|
{/* Litter Collected Input */}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import type { CleanupEvent } from '@/types';
|
import type { CleanupEvent } from '@/types';
|
||||||
import { EventCompletionForm } from './EventCompletionForm';
|
import { EventCompletionForm } from './EventCompletionForm';
|
||||||
|
import { CompletedEventDetails } from './CompletedEventDetails';
|
||||||
|
|
||||||
// Fix for default marker icons
|
// Fix for default marker icons
|
||||||
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
|
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
|
||||||
@@ -24,7 +26,16 @@ interface EventDetailModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onRegister: () => void;
|
onRegister: () => void;
|
||||||
onUnregister: () => 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;
|
onCancel?: () => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
@@ -43,6 +54,7 @@ export function EventDetailModal({
|
|||||||
const mapRef = useRef<HTMLDivElement>(null);
|
const mapRef = useRef<HTMLDivElement>(null);
|
||||||
const mapInstanceRef = useRef<L.Map | null>(null);
|
const mapInstanceRef = useRef<L.Map | null>(null);
|
||||||
const [showCompletionForm, setShowCompletionForm] = useState(false);
|
const [showCompletionForm, setShowCompletionForm] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Check if event date has passed
|
// Check if event date has passed
|
||||||
const hasEventPassed = new Date(event.scheduledDate) < new Date();
|
const hasEventPassed = new Date(event.scheduledDate) < new Date();
|
||||||
@@ -140,148 +152,184 @@ export function EventDetailModal({
|
|||||||
{/* Content - Scrollable */}
|
{/* Content - Scrollable */}
|
||||||
<div className="overflow-y-auto flex-1 bg-white">
|
<div className="overflow-y-auto flex-1 bg-white">
|
||||||
<div className="px-6 py-6 space-y-6">
|
<div className="px-6 py-6 space-y-6">
|
||||||
{/* Event Details */}
|
{/* Event Details - Hide when showing completion form */}
|
||||||
<div className="space-y-4">
|
{!(canComplete && showCompletionForm) && (
|
||||||
<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">
|
<div className="space-y-4">
|
||||||
<path
|
<div className="flex items-start">
|
||||||
strokeLinecap="round"
|
<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">
|
||||||
strokeLinejoin="round"
|
<path
|
||||||
strokeWidth={2}
|
strokeLinecap="round"
|
||||||
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"
|
strokeLinejoin="round"
|
||||||
/>
|
strokeWidth={2}
|
||||||
</svg>
|
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"
|
||||||
<div>
|
/>
|
||||||
<p className="text-sm font-medium text-gray-900">Date & Time</p>
|
</svg>
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
<div>
|
||||||
{formatEventDate(event.scheduledDate)} at {formatEventTime(event.scheduledDate)}
|
<p className="text-sm font-medium text-gray-900">Date & Time</p>
|
||||||
</p>
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
</div>
|
{formatEventDate(event.scheduledDate)} at {formatEventTime(event.scheduledDate)}
|
||||||
</div>
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start">
|
<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">
|
<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
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-900">Duration</p>
|
<p className="text-sm font-medium text-gray-900">Duration</p>
|
||||||
<p className="text-sm text-gray-600 mt-1">{formatDuration(event.duration)}</p>
|
<p className="text-sm text-gray-600 mt-1">{formatDuration(event.duration)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start">
|
{/* Equipment Information */}
|
||||||
<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">
|
<div className="flex items-start">
|
||||||
<path
|
<svg className="h-6 w-6 text-gray-400 mr-3 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
strokeLinecap="round"
|
<path
|
||||||
strokeLinejoin="round"
|
strokeLinecap="round"
|
||||||
strokeWidth={2}
|
strokeLinejoin="round"
|
||||||
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"
|
strokeWidth={2}
|
||||||
/>
|
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium text-gray-900">Location</p>
|
<p className="text-sm font-medium text-gray-900">Equipment</p>
|
||||||
<p className="text-sm text-gray-600 mt-1">{event.locationName}</p>
|
{event.equipmentProvided ? (
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<div className="mt-1">
|
||||||
{event.latitude.toFixed(6)}, {event.longitude.toFixed(6)}
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||||
</p>
|
✓ Equipment Provided
|
||||||
</div>
|
</span>
|
||||||
</div>
|
<p className="text-sm text-gray-600 mt-2">
|
||||||
</div>
|
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 className="flex items-start">
|
||||||
<div>
|
<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">
|
||||||
<h3 className="text-sm font-medium text-gray-900 mb-2">Description</h3>
|
<path
|
||||||
<p className="text-sm text-gray-600 whitespace-pre-wrap">{event.description}</p>
|
strokeLinecap="round"
|
||||||
</div>
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
{/* Completed Event Info */}
|
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"
|
||||||
{event.status === 'completed' && event.litterCollected !== undefined && (
|
/>
|
||||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
<div className="flex items-center">
|
</svg>
|
||||||
<svg className="h-6 w-6 text-green-600 mr-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
<div>
|
||||||
<path
|
<p className="text-sm font-medium text-gray-900">Location</p>
|
||||||
fillRule="evenodd"
|
<p className="text-sm text-gray-600 mt-1">{event.locationName}</p>
|
||||||
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"
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
clipRule="evenodd"
|
{event.latitude.toFixed(6)}, {event.longitude.toFixed(6)}
|
||||||
/>
|
</p>
|
||||||
</svg>
|
</div>
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-green-900">Event Completed</p>
|
|
||||||
<p className="text-sm text-green-700 mt-1">{event.litterCollected} kg of litter collected</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Photos (if completed) */}
|
{/* Description */}
|
||||||
{event.status === 'completed' && event.photos && event.photos.length > 0 && (
|
<div>
|
||||||
<div>
|
<h3 className="text-sm font-medium text-gray-900 mb-2">Description</h3>
|
||||||
<h3 className="text-sm font-medium text-gray-900 mb-2">Event Photos</h3>
|
<p className="text-sm text-gray-600 whitespace-pre-wrap">{event.description}</p>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
|
||||||
{event.photos.map((photo, index) => (
|
|
||||||
<img
|
|
||||||
key={index}
|
|
||||||
src={photo}
|
|
||||||
alt={`Event photo ${index + 1}`}
|
|
||||||
className="w-full h-32 object-cover rounded-lg"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
{/* 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 */}
|
{/* Completion Form for Organizers */}
|
||||||
{/* <div>
|
{canComplete && showCompletionForm && (
|
||||||
<h3 className="text-sm font-medium text-gray-900 mb-2">Event Location Map</h3>
|
<div className="mt-6">
|
||||||
<div className="border border-gray-300 rounded-lg overflow-hidden">
|
<EventCompletionForm
|
||||||
<div ref={mapRef} className="w-full h-64" />
|
eventId={event.id}
|
||||||
|
onSubmit={async (data) => {
|
||||||
|
await onComplete(data);
|
||||||
|
setShowCompletionForm(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => setShowCompletionForm(false)}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div> */}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Completion Form for Organizers */}
|
|
||||||
{canComplete && showCompletionForm && (
|
|
||||||
<div className="px-6 pb-6">
|
|
||||||
<EventCompletionForm
|
|
||||||
eventId={event.id}
|
|
||||||
onSubmit={async (data) => {
|
|
||||||
await onComplete(data);
|
|
||||||
setShowCompletionForm(false);
|
|
||||||
}}
|
|
||||||
onCancel={() => setShowCompletionForm(false)}
|
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Footer Actions */}
|
{/* Footer Actions */}
|
||||||
{event.status === 'upcoming' && (
|
{event.status === 'upcoming' && !showCompletionForm && (
|
||||||
<div className="bg-gray-50 px-6 py-4 border-t border-gray-200 flex-shrink-0">
|
<div className="bg-gray-50 px-6 py-4 border-t border-gray-200 flex-shrink-0">
|
||||||
{canComplete ? (
|
{canComplete ? (
|
||||||
<div className="flex gap-3">
|
<div className="space-y-3">
|
||||||
<button
|
<div className="flex gap-3">
|
||||||
onClick={() => setShowCompletionForm(!showCompletionForm)}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="flex-1 bg-green-600 text-white py-3 px-6 rounded-lg hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors font-medium"
|
|
||||||
>
|
|
||||||
{showCompletionForm ? 'Hide Completion Form' : 'Complete Event'}
|
|
||||||
</button>
|
|
||||||
{onCancel && (
|
|
||||||
<button
|
<button
|
||||||
onClick={onCancel}
|
onClick={() => setShowCompletionForm(!showCompletionForm)}
|
||||||
disabled={isLoading}
|
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>
|
</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>
|
</div>
|
||||||
) : isOrganizer && !hasEventPassed ? (
|
) : isOrganizer && !hasEventPassed ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -299,6 +347,15 @@ export function EventDetailModal({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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 && (
|
{onCancel && (
|
||||||
<button
|
<button
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ interface EventFormData {
|
|||||||
scheduledDate: string;
|
scheduledDate: string;
|
||||||
scheduledTime: string;
|
scheduledTime: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
|
equipmentProvided: boolean;
|
||||||
|
requiredItems: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EventFormProps {
|
interface EventFormProps {
|
||||||
@@ -44,8 +46,12 @@ export function EventForm({ onSubmit, onCancel, initialData, isSubmitting = fals
|
|||||||
scheduledDate: initialData?.scheduledDate || '',
|
scheduledDate: initialData?.scheduledDate || '',
|
||||||
scheduledTime: initialData?.scheduledTime || '',
|
scheduledTime: initialData?.scheduledTime || '',
|
||||||
duration: initialData?.duration || 120,
|
duration: initialData?.duration || 120,
|
||||||
|
equipmentProvided: initialData?.equipmentProvided || false,
|
||||||
|
requiredItems: initialData?.requiredItems || [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [newItem, setNewItem] = useState('');
|
||||||
|
|
||||||
const [selectedLocation, setSelectedLocation] = useState<Coordinates | null>(
|
const [selectedLocation, setSelectedLocation] = useState<Coordinates | null>(
|
||||||
initialData?.latitude && initialData?.longitude
|
initialData?.latitude && initialData?.longitude
|
||||||
? { latitude: initialData.latitude, longitude: 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>}
|
{errors.duration && <p className="mt-1 text-sm text-red-600">{errors.duration}</p>}
|
||||||
</div>
|
</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 */}
|
{/* Location Name */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="locationName" className="block text-sm font-medium text-gray-700 mb-1">
|
<label htmlFor="locationName" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
|||||||
@@ -85,30 +85,41 @@ export function EventList({ events, onEventClick, onRegister, isLoading = false
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
{events.map((event) => (
|
{events.map((event) => (
|
||||||
<div
|
<div
|
||||||
key={event.id}
|
key={event.id}
|
||||||
className="bg-white rounded-lg shadow hover:shadow-lg transition-all cursor-pointer border-2 border-transparent hover:border-blue-200"
|
className="bg-white rounded-lg shadow hover:shadow-lg transition-all cursor-pointer border-2 border-transparent hover:border-blue-200"
|
||||||
onClick={() => onEventClick(event)}
|
onClick={() => onEventClick(event)}
|
||||||
>
|
>
|
||||||
<div className="p-6">
|
<div className="p-4">
|
||||||
<div className="flex items-start justify-between mb-3">
|
{/* Header Row */}
|
||||||
<div className="flex-1">
|
<div className="flex items-start justify-between gap-4 mb-3">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">{event.title}</h3>
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<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)}
|
{getStatusBadge(event.status)}
|
||||||
<span className="text-sm text-gray-500">
|
|
||||||
{event.participantCount} {event.participantCount === 1 ? 'participant' : 'participants'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 line-clamp-1">{event.description}</p>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<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 */}
|
{/* Date and Time */}
|
||||||
<div className="flex items-center text-sm text-gray-600">
|
<div className="flex items-center">
|
||||||
<svg className="h-5 w-5 mr-2 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="h-4 w-4 mr-1.5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="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"
|
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>
|
<span className="whitespace-nowrap">
|
||||||
{formatEventDate(event.scheduledDate)} at {formatEventTime(event.scheduledDate)}
|
{formatEventDate(event.scheduledDate)} at {formatEventTime(event.scheduledDate)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Duration */}
|
{/* Duration */}
|
||||||
<div className="flex items-center text-sm text-gray-600">
|
<div className="flex items-center">
|
||||||
<svg className="h-5 w-5 mr-2 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="h-4 w-4 mr-1.5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
@@ -135,8 +146,8 @@ export function EventList({ events, onEventClick, onRegister, isLoading = false
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Location */}
|
{/* Location */}
|
||||||
<div className="flex items-center text-sm text-gray-600">
|
<div className="flex items-center min-w-0 flex-1">
|
||||||
<svg className="h-5 w-5 mr-2 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<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
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>{event.locationName}</span>
|
<span className="truncate">{event.locationName}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description Preview */}
|
{/* Participants */}
|
||||||
<p className="text-sm text-gray-600 line-clamp-2 mb-4">{event.description}</p>
|
<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 */}
|
{/* Completed Event Info */}
|
||||||
{event.status === 'completed' && event.litterCollected !== undefined && (
|
{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-green-700 font-medium">
|
||||||
<div className="flex items-center text-sm text-green-800">
|
<svg className="h-4 w-4 mr-1.5 text-green-600" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<svg className="h-5 w-5 mr-2 text-green-600" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path
|
<path
|
||||||
fillRule="evenodd"
|
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"
|
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"
|
clipRule="evenodd"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="font-medium">{event.litterCollected} kg of litter collected</span>
|
<span>{event.litterCollected} kg collected</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Action Area */}
|
{/* View Details Link */}
|
||||||
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => onEventClick(event)}
|
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
|
View Details
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{event.status === 'upcoming' && onRegister && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onRegister(event.id);
|
|
||||||
}}
|
|
||||||
className="bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 transition-colors font-medium text-sm"
|
|
||||||
>
|
|
||||||
Quick Register
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
373
src/components/event/QRCodeScanner.tsx
Normal file
373
src/components/event/QRCodeScanner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,4 +4,7 @@ export { EventList } from './EventList';
|
|||||||
export { EventFilters } from './EventFilters';
|
export { EventFilters } from './EventFilters';
|
||||||
export { EventDetailModal } from './EventDetailModal';
|
export { EventDetailModal } from './EventDetailModal';
|
||||||
export { EventCompletionForm } from './EventCompletionForm';
|
export { EventCompletionForm } from './EventCompletionForm';
|
||||||
|
export { AttendeeManagement } from './AttendeeManagement';
|
||||||
|
export { CompletedEventDetails } from './CompletedEventDetails';
|
||||||
|
export { EmailPromptModal } from './EmailPromptModal';
|
||||||
export type { EventFilterState } from './EventFilters';
|
export type { EventFilterState } from './EventFilters';
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function Header() {
|
|||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Link to="/" className="flex items-center" onClick={closeMobileMenu}>
|
<Link to="/" className="flex items-center" onClick={closeMobileMenu}>
|
||||||
<h1 className="text-lg sm:text-xl md:text-2xl font-bold text-blue-600">
|
<h1 className="text-lg sm:text-xl md:text-2xl font-bold text-blue-600">
|
||||||
Beach Litter Mapping
|
Litter Scouts
|
||||||
</h1>
|
</h1>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -125,46 +125,44 @@ export function MapFilters({
|
|||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="p-3 sm:p-4 space-y-3 sm:space-y-4 max-h-[60vh] sm:max-h-[70vh] overflow-y-auto">
|
<div className="p-3 sm:p-4 space-y-3 sm:space-y-4 max-h-[60vh] sm:max-h-[70vh] overflow-y-auto">
|
||||||
{/* Cluster Toggle */}
|
{/* Cluster Toggle */}
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="flex items-center justify-between py-2">
|
||||||
<label htmlFor="cluster-toggle" className="text-xs sm:text-sm font-medium text-gray-700">
|
<label htmlFor="cluster-toggle" className="text-sm font-medium text-gray-700">
|
||||||
Cluster Markers
|
Cluster Markers
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
id="cluster-toggle"
|
id="cluster-toggle"
|
||||||
onClick={onToggleClusters}
|
onClick={onToggleClusters}
|
||||||
className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors touch-manipulation ${
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
|
||||||
showClusters ? 'bg-blue-600' : 'bg-gray-200'
|
showClusters ? 'bg-blue-600' : 'bg-gray-200'
|
||||||
}`}
|
}`}
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={showClusters}
|
aria-checked={showClusters}
|
||||||
style={{ minWidth: '44px', minHeight: '44px' }}
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`inline-block h-5 w-5 transform rounded-full bg-white transition-transform ${
|
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||||
showClusters ? 'translate-x-6' : 'translate-x-1'
|
showClusters ? 'translate-x-5' : 'translate-x-0'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Heat Map Toggle */}
|
{/* Heat Map Toggle */}
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="flex items-center justify-between py-2">
|
||||||
<label htmlFor="heatmap-toggle" className="text-xs sm:text-sm font-medium text-gray-700">
|
<label htmlFor="heatmap-toggle" className="text-sm font-medium text-gray-700">
|
||||||
Heat Map
|
Heat Map
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
id="heatmap-toggle"
|
id="heatmap-toggle"
|
||||||
onClick={onToggleHeatmap}
|
onClick={onToggleHeatmap}
|
||||||
className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors touch-manipulation ${
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
|
||||||
showHeatmap ? 'bg-blue-600' : 'bg-gray-200'
|
showHeatmap ? 'bg-blue-600' : 'bg-gray-200'
|
||||||
}`}
|
}`}
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={showHeatmap}
|
aria-checked={showHeatmap}
|
||||||
style={{ minWidth: '44px', minHeight: '44px' }}
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`inline-block h-5 w-5 transform rounded-full bg-white transition-transform ${
|
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||||
showHeatmap ? 'translate-x-6' : 'translate-x-1'
|
showHeatmap ? 'translate-x-5' : 'translate-x-0'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
@@ -172,23 +170,22 @@ export function MapFilters({
|
|||||||
|
|
||||||
{/* Events Toggle */}
|
{/* Events Toggle */}
|
||||||
{onToggleEvents && (
|
{onToggleEvents && (
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="flex items-center justify-between py-2">
|
||||||
<label htmlFor="events-toggle" className="text-xs sm:text-sm font-medium text-gray-700">
|
<label htmlFor="events-toggle" className="text-sm font-medium text-gray-700">
|
||||||
Show Events
|
Show Events
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
id="events-toggle"
|
id="events-toggle"
|
||||||
onClick={onToggleEvents}
|
onClick={onToggleEvents}
|
||||||
className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors touch-manipulation ${
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
|
||||||
showEvents ? 'bg-green-600' : 'bg-gray-200'
|
showEvents ? 'bg-green-600' : 'bg-gray-200'
|
||||||
}`}
|
}`}
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={showEvents}
|
aria-checked={showEvents}
|
||||||
style={{ minWidth: '44px', minHeight: '44px' }}
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`inline-block h-5 w-5 transform rounded-full bg-white transition-transform ${
|
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||||
showEvents ? 'translate-x-6' : 'translate-x-1'
|
showEvents ? 'translate-x-5' : 'translate-x-0'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
@@ -197,23 +194,22 @@ export function MapFilters({
|
|||||||
|
|
||||||
{/* Water Quality Toggle */}
|
{/* Water Quality Toggle */}
|
||||||
{onToggleWaterQuality && (
|
{onToggleWaterQuality && (
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="flex items-center justify-between py-2">
|
||||||
<label htmlFor="water-quality-toggle" className="text-xs sm:text-sm font-medium text-gray-700">
|
<label htmlFor="water-quality-toggle" className="text-sm font-medium text-gray-700">
|
||||||
Water Quality
|
Water Quality
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
id="water-quality-toggle"
|
id="water-quality-toggle"
|
||||||
onClick={onToggleWaterQuality}
|
onClick={onToggleWaterQuality}
|
||||||
className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors touch-manipulation ${
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
|
||||||
showWaterQuality ? 'bg-blue-600' : 'bg-gray-200'
|
showWaterQuality ? 'bg-blue-600' : 'bg-gray-200'
|
||||||
}`}
|
}`}
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={showWaterQuality}
|
aria-checked={showWaterQuality}
|
||||||
style={{ minWidth: '44px', minHeight: '44px' }}
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`inline-block h-5 w-5 transform rounded-full bg-white transition-transform ${
|
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||||
showWaterQuality ? 'translate-x-6' : 'translate-x-1'
|
showWaterQuality ? 'translate-x-5' : 'translate-x-0'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
@@ -222,11 +218,11 @@ export function MapFilters({
|
|||||||
|
|
||||||
{/* Hotspots Toggle */}
|
{/* Hotspots Toggle */}
|
||||||
{onToggleHotspots && (
|
{onToggleHotspots && (
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="flex items-center justify-between py-2">
|
||||||
<label htmlFor="hotspots-toggle" className="text-xs sm:text-sm font-medium text-gray-700 flex items-center gap-2">
|
<label htmlFor="hotspots-toggle" className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||||
Hotspots
|
Hotspots
|
||||||
{isLoadingHotspots && (
|
{isLoadingHotspots && (
|
||||||
<svg className="animate-spin h-3 w-3 sm:h-4 sm:w-4 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
<svg className="animate-spin h-4 w-4 text-orange-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -236,16 +232,15 @@ export function MapFilters({
|
|||||||
id="hotspots-toggle"
|
id="hotspots-toggle"
|
||||||
onClick={onToggleHotspots}
|
onClick={onToggleHotspots}
|
||||||
disabled={isLoadingHotspots}
|
disabled={isLoadingHotspots}
|
||||||
className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors touch-manipulation ${
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 ${
|
||||||
showHotspots ? 'bg-orange-600' : 'bg-gray-200'
|
showHotspots ? 'bg-orange-600' : 'bg-gray-200'
|
||||||
} ${isLoadingHotspots ? 'opacity-50 cursor-not-allowed' : ''}`}
|
} ${isLoadingHotspots ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={showHotspots}
|
aria-checked={showHotspots}
|
||||||
style={{ minWidth: '44px', minHeight: '44px' }}
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`inline-block h-5 w-5 transform rounded-full bg-white transition-transform ${
|
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||||
showHotspots ? 'translate-x-6' : 'translate-x-1'
|
showHotspots ? 'translate-x-5' : 'translate-x-0'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
@@ -253,23 +248,22 @@ export function MapFilters({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Show Cleaned Reports Toggle */}
|
{/* Show Cleaned Reports Toggle */}
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="flex items-center justify-between py-2">
|
||||||
<label htmlFor="cleaned-toggle" className="text-xs sm:text-sm font-medium text-gray-700">
|
<label htmlFor="cleaned-toggle" className="text-sm font-medium text-gray-700">
|
||||||
Show Cleaned
|
Show Cleaned
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
id="cleaned-toggle"
|
id="cleaned-toggle"
|
||||||
onClick={() => onFiltersChange({ ...filters, showCleaned: filters.showCleaned === false ? true : false })}
|
onClick={() => onFiltersChange({ ...filters, showCleaned: filters.showCleaned === false ? true : false })}
|
||||||
className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors touch-manipulation ${
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
|
||||||
filters.showCleaned !== false ? 'bg-green-600' : 'bg-gray-200'
|
filters.showCleaned !== false ? 'bg-green-600' : 'bg-gray-200'
|
||||||
}`}
|
}`}
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={filters.showCleaned !== false}
|
aria-checked={filters.showCleaned !== false}
|
||||||
style={{ minWidth: '44px', minHeight: '44px' }}
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`inline-block h-5 w-5 transform rounded-full bg-white transition-transform ${
|
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||||
filters.showCleaned !== false ? 'translate-x-6' : 'translate-x-1'
|
filters.showCleaned !== false ? 'translate-x-5' : 'translate-x-0'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { ReportResponseDTO } from '@/types';
|
import type { ReportResponseDTO } from '@/types';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
import { getApiBaseUrl } from '@/services/api';
|
||||||
|
|
||||||
interface ReportDetailModalProps {
|
interface ReportDetailModalProps {
|
||||||
report: ReportResponseDTO;
|
report: ReportResponseDTO;
|
||||||
@@ -128,7 +129,7 @@ export function ReportDetailModal({ report, isOpen, onClose, onVerify, onDispute
|
|||||||
src={
|
src={
|
||||||
report.photoUrls[currentPhotoIndex].startsWith('http')
|
report.photoUrls[currentPhotoIndex].startsWith('http')
|
||||||
? report.photoUrls[currentPhotoIndex]
|
? report.photoUrls[currentPhotoIndex]
|
||||||
: `${(import.meta.env.VITE_API_BASE_URL || 'http://localhost:3005/api').replace('/api', '')}${report.photoUrls[currentPhotoIndex]}`
|
: `${getApiBaseUrl()}${report.photoUrls[currentPhotoIndex]}`
|
||||||
}
|
}
|
||||||
alt={`Report photo ${currentPhotoIndex + 1}`}
|
alt={`Report photo ${currentPhotoIndex + 1}`}
|
||||||
className="w-full h-80 object-contain"
|
className="w-full h-80 object-contain"
|
||||||
|
|||||||
58
src/components/user/UserQRCode.tsx
Normal file
58
src/components/user/UserQRCode.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/user/index.ts
Normal file
1
src/components/user/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { UserQRCode } from './UserQRCode';
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Custom React hooks for the Beach Litter Mapping Platform
|
// Custom React hooks for Litter Scouts
|
||||||
|
|
||||||
export { default as useGeolocation } from './useGeolocation';
|
export { default as useGeolocation } from './useGeolocation';
|
||||||
export { useAuth } from './useAuth';
|
export { useAuth } from './useAuth';
|
||||||
|
|||||||
315
src/pages/EventCheckInPage.tsx
Normal file
315
src/pages/EventCheckInPage.tsx
Normal 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;
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useUser } from '@clerk/clerk-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 type { EventFilterState } from '@/components/event';
|
||||||
import { eventService } from '@/services/eventService';
|
import { eventService } from '@/services/eventService';
|
||||||
import type { CleanupEvent } from '@/types';
|
import type { CleanupEvent } from '@/types';
|
||||||
|
|
||||||
export function EventsPage() {
|
export function EventsPage() {
|
||||||
const { isSignedIn, user } = useUser();
|
const { isSignedIn, user } = useUser();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [events, setEvents] = useState<CleanupEvent[]>([]);
|
const [events, setEvents] = useState<CleanupEvent[]>([]);
|
||||||
const [filteredEvents, setFilteredEvents] = useState<CleanupEvent[]>([]);
|
const [filteredEvents, setFilteredEvents] = useState<CleanupEvent[]>([]);
|
||||||
const [filters, setFilters] = useState<EventFilterState>({
|
const [filters, setFilters] = useState<EventFilterState>({
|
||||||
@@ -19,6 +21,8 @@ export function EventsPage() {
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showEmailPrompt, setShowEmailPrompt] = useState(false);
|
||||||
|
const [pendingEventId, setPendingEventId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Load events
|
// Load events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -75,11 +79,24 @@ export function EventsPage() {
|
|||||||
filtered = filtered.filter(event => new Date(event.scheduledDate) <= endDate);
|
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) => {
|
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 dateA = new Date(a.scheduledDate).getTime();
|
||||||
const dateB = new Date(b.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);
|
setFilteredEvents(filtered);
|
||||||
@@ -135,6 +152,18 @@ export function EventsPage() {
|
|||||||
return;
|
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 {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
setError(null);
|
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 () => {
|
const handleUnregister = async () => {
|
||||||
if (!selectedEvent) return;
|
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;
|
if (!selectedEvent) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -301,6 +355,18 @@ export function EventsPage() {
|
|||||||
isLoading={isSubmitting}
|
isLoading={isSubmitting}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Email Prompt Modal */}
|
||||||
|
{showEmailPrompt && (
|
||||||
|
<EmailPromptModal
|
||||||
|
onClose={() => {
|
||||||
|
setShowEmailPrompt(false);
|
||||||
|
setPendingEventId(null);
|
||||||
|
}}
|
||||||
|
onAddEmail={handleAddEmail}
|
||||||
|
onContinue={handleContinueWithoutEmail}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,13 +77,15 @@ export function MapPage() {
|
|||||||
const fetchEvents = async () => {
|
const fetchEvents = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoadingEvents(true);
|
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();
|
const data = await eventService.getEvents();
|
||||||
setEvents(data);
|
// Filter out cancelled events
|
||||||
|
const activeEvents = data.filter(event => event.status !== 'cancelled');
|
||||||
|
setEvents(activeEvents);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch events:', error);
|
console.error('Failed to fetch events:', error);
|
||||||
// Fall back to sample data on error
|
// Fall back to sample data on error (also filter cancelled)
|
||||||
setEvents(sampleEvents);
|
setEvents(sampleEvents.filter(event => event.status !== 'cancelled'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingEvents(false);
|
setIsLoadingEvents(false);
|
||||||
}
|
}
|
||||||
@@ -386,42 +388,6 @@ export function MapPage() {
|
|||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to fetch hotspots:', 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(() => {
|
.finally(() => {
|
||||||
setIsLoadingHotspots(false);
|
setIsLoadingHotspots(false);
|
||||||
|
|||||||
201
src/pages/PrintableQRCodePage.tsx
Normal file
201
src/pages/PrintableQRCodePage.tsx
Normal 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;
|
||||||
576
src/pages/UserProfilePage.backup.tsx
Normal file
576
src/pages/UserProfilePage.backup.tsx
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { useClerk } from '@clerk/clerk-react';
|
||||||
|
import { userService, type UserProfile, type UserActivity } from '@/services/userService';
|
||||||
|
import type { Report, CleanupEvent } from '@/types';
|
||||||
|
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
||||||
|
import { NotificationSettings, type NotificationPreferences } from '@/components/auth';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import L from 'leaflet';
|
||||||
|
|
||||||
|
// Fix Leaflet default marker icon issue
|
||||||
|
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
|
||||||
|
import markerIcon from 'leaflet/dist/images/marker-icon.png';
|
||||||
|
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
|
||||||
|
|
||||||
|
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconUrl: markerIcon,
|
||||||
|
iconRetinaUrl: markerIcon2x,
|
||||||
|
shadowUrl: markerShadow,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function UserProfilePage() {
|
||||||
|
const { userId } = useParams<{ userId: string }>();
|
||||||
|
const { user: currentUser } = useAuth();
|
||||||
|
const { signOut } = useClerk();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||||
|
const [reports, setReports] = useState<Report[]>([]);
|
||||||
|
const [events, setEvents] = useState<{ registered: CleanupEvent[]; organized: CleanupEvent[] }>({ registered: [], organized: [] });
|
||||||
|
const [activity, setActivity] = useState<UserActivity[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState<'overview' | 'reports' | 'events' | 'activity' | 'notifications'>('overview');
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editForm, setEditForm] = useState({
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
username: '',
|
||||||
|
});
|
||||||
|
const [notificationPreferences, setNotificationPreferences] = useState<NotificationPreferences>({
|
||||||
|
notificationEmail: true,
|
||||||
|
notificationInApp: true,
|
||||||
|
areasOfInterest: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const isOwnProfile = currentUser?.id === userId;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userId) {
|
||||||
|
setError('User ID is required');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadProfileData();
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
const loadProfileData = async () => {
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const [profileData, reportsData, eventsData, activityData] = await Promise.all([
|
||||||
|
userService.getProfile(userId),
|
||||||
|
userService.getUserReports(userId),
|
||||||
|
userService.getUserEvents(userId),
|
||||||
|
userService.getUserActivity(userId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setProfile(profileData);
|
||||||
|
setReports(reportsData);
|
||||||
|
// Handle both array and object response formats
|
||||||
|
if (Array.isArray(eventsData)) {
|
||||||
|
setEvents({ registered: eventsData, organized: [] });
|
||||||
|
} else {
|
||||||
|
setEvents(eventsData);
|
||||||
|
}
|
||||||
|
setActivity(activityData);
|
||||||
|
|
||||||
|
setEditForm({
|
||||||
|
firstName: profileData.firstName || '',
|
||||||
|
lastName: profileData.lastName || '',
|
||||||
|
username: profileData.username || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set notification preferences from profile
|
||||||
|
setNotificationPreferences({
|
||||||
|
notificationEmail: profileData.notificationEmail ?? true,
|
||||||
|
notificationInApp: profileData.notificationInApp ?? true,
|
||||||
|
areasOfInterest: profileData.areasOfInterest || [],
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load profile');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditProfile = () => {
|
||||||
|
setIsEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
if (profile) {
|
||||||
|
setEditForm({
|
||||||
|
firstName: profile.firstName || '',
|
||||||
|
lastName: profile.lastName || '',
|
||||||
|
username: profile.username || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveProfile = async () => {
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await userService.updateProfile(userId, editForm);
|
||||||
|
setIsEditing(false);
|
||||||
|
await loadProfileData();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to update profile');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveNotificationPreferences = async (preferences: NotificationPreferences) => {
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await userService.updateNotificationPreferences(userId, preferences);
|
||||||
|
setNotificationPreferences(preferences);
|
||||||
|
setActiveTab('overview'); // Return to overview after saving
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // Let NotificationSettings component handle the error
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelNotificationSettings = () => {
|
||||||
|
// Reset to current preferences
|
||||||
|
if (profile) {
|
||||||
|
setNotificationPreferences({
|
||||||
|
notificationEmail: profile.notificationEmail ?? true,
|
||||||
|
notificationInApp: profile.notificationInApp ?? true,
|
||||||
|
areasOfInterest: profile.areasOfInterest || [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setActiveTab('overview');
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateImpactScore = () => {
|
||||||
|
if (!profile) return 0;
|
||||||
|
// Impact score calculation: reports * 10 + events attended * 20
|
||||||
|
const reportCount = profile.reportCount || 0;
|
||||||
|
const eventCount = profile.eventCount || 0;
|
||||||
|
return reportCount * 10 + eventCount * 20;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date: Date) => {
|
||||||
|
return new Date(date).toLocaleDateString('en-IE', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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 profile...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !profile) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-red-600 mb-4">{error || 'Profile not found'}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Go Home
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 py-8">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
{/* Profile Header */}
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
{profile.avatarUrl && (
|
||||||
|
<img
|
||||||
|
src={profile.avatarUrl}
|
||||||
|
alt={profile.username}
|
||||||
|
className="h-20 w-20 rounded-full object-cover border-4 border-blue-100"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editForm.firstName}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, firstName: e.target.value })}
|
||||||
|
placeholder="First name"
|
||||||
|
className="px-3 py-1 border border-gray-300 rounded-md"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editForm.lastName}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, lastName: e.target.value })}
|
||||||
|
placeholder="Last name"
|
||||||
|
className="px-3 py-1 border border-gray-300 rounded-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editForm.username}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, username: e.target.value })}
|
||||||
|
placeholder="Username"
|
||||||
|
className="px-3 py-1 border border-gray-300 rounded-md w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
|
{profile.firstName && profile.lastName
|
||||||
|
? `${profile.firstName} ${profile.lastName}`
|
||||||
|
: profile.username}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600">@{profile.username}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isOwnProfile && (
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveProfile}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleEditProfile}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Edit Profile
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
await signOut();
|
||||||
|
navigate('/');
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Statistics */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6">
|
||||||
|
<div className="bg-blue-50 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-gray-600">Reports Submitted</p>
|
||||||
|
<p className="text-3xl font-bold text-blue-600">{profile.reportCount}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-green-50 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-gray-600">Events Attended</p>
|
||||||
|
<p className="text-3xl font-bold text-green-600">{profile.eventCount}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-purple-50 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-gray-600">Impact Score</p>
|
||||||
|
<p className="text-3xl font-bold text-purple-600">{calculateImpactScore()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="bg-white rounded-lg shadow-md mb-6">
|
||||||
|
<div className="border-b border-gray-200">
|
||||||
|
<nav className="flex -mb-px">
|
||||||
|
{(['overview', 'reports', 'events', 'activity', ...(isOwnProfile ? ['notifications'] as const : [])] as const).map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
onClick={() => setActiveTab(tab)}
|
||||||
|
className={`px-6 py-3 text-sm font-medium capitalize ${
|
||||||
|
activeTab === tab
|
||||||
|
? 'border-b-2 border-blue-600 text-blue-600'
|
||||||
|
: 'text-gray-600 hover:text-gray-900 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
{activeTab === 'overview' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Contribution Map</h2>
|
||||||
|
{reports.length > 0 && reports[0] ? (
|
||||||
|
<div className="h-96 rounded-lg overflow-hidden">
|
||||||
|
<MapContainer
|
||||||
|
center={[reports[0].latitude, reports[0].longitude]}
|
||||||
|
zoom={10}
|
||||||
|
style={{ height: '100%', width: '100%' }}
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
{reports.map((report) => (
|
||||||
|
<Marker
|
||||||
|
key={report.id}
|
||||||
|
position={[report.latitude, report.longitude]}
|
||||||
|
>
|
||||||
|
<Popup>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold capitalize">{report.litterType}</p>
|
||||||
|
<p className="text-sm text-gray-600">{formatDate(report.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
</MapContainer>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500">No reports submitted yet</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Recent Activity</h2>
|
||||||
|
{activity.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{activity.slice(0, 5).map((item, index) => (
|
||||||
|
<div key={`overview-${item.type}-${item.id}-${index}`} className="flex items-start space-x-3 p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{item.type === 'report' && (
|
||||||
|
<div className="h-10 w-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
|
<span className="text-blue-600">📍</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.type.startsWith('event') && (
|
||||||
|
<div className="h-10 w-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||||
|
<span className="text-green-600">🗓️</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium text-gray-900">{item.title}</p>
|
||||||
|
{item.description && (
|
||||||
|
<p className="text-sm text-gray-600">{item.description}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-500 mt-1">{formatDate(item.timestamp)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500">No activity yet</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'reports' && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Submitted Reports</h2>
|
||||||
|
{reports.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{reports.map((report) => (
|
||||||
|
<div key={report.id} className="bg-gray-50 rounded-lg p-4">
|
||||||
|
{report.photoUrls.length > 0 && (
|
||||||
|
<img
|
||||||
|
src={report.photoUrls[0]}
|
||||||
|
alt="Report"
|
||||||
|
className="w-full h-48 object-cover rounded-md mb-3"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="font-semibold capitalize">{report.litterType}</p>
|
||||||
|
<p className="text-sm text-gray-600 capitalize">Quantity: {report.quantity}</p>
|
||||||
|
{report.description && (
|
||||||
|
<p className="text-sm text-gray-700">{report.description}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-500">{formatDate(report.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500">No reports submitted yet</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'events' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Registered Events */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Registered Events</h2>
|
||||||
|
{events.registered.length > 0 ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{events.registered.map((event) => (
|
||||||
|
<div key={`registered-${event.id}`} className="bg-gray-50 rounded-lg p-4">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg">{event.title}</h3>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">{event.description}</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-2">
|
||||||
|
📍 {event.locationName}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
🗓️ {formatDate(event.scheduledDate)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
👥 {event.participantCount} participants
|
||||||
|
</p>
|
||||||
|
{event.litterCollected && (
|
||||||
|
<p className="text-sm text-green-600 font-medium mt-2">
|
||||||
|
♻️ {event.litterCollected} kg collected
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`px-3 py-1 rounded-full text-xs font-medium ${
|
||||||
|
event.status === 'completed'
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: event.status === 'upcoming'
|
||||||
|
? 'bg-blue-100 text-blue-800'
|
||||||
|
: 'bg-gray-100 text-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{event.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500">No registered events yet</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Organized Events */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Organized Events</h2>
|
||||||
|
{events.organized.length > 0 ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{events.organized.map((event) => (
|
||||||
|
<div key={`organized-${event.id}`} className="bg-gray-50 rounded-lg p-4">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg">{event.title}</h3>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">{event.description}</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-2">
|
||||||
|
📍 {event.locationName}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
🗓️ {formatDate(event.scheduledDate)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
👥 {event.participantCount} participants
|
||||||
|
</p>
|
||||||
|
{event.litterCollected && (
|
||||||
|
<p className="text-sm text-green-600 font-medium mt-2">
|
||||||
|
♻️ {event.litterCollected} kg collected
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`px-3 py-1 rounded-full text-xs font-medium ${
|
||||||
|
event.status === 'completed'
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: event.status === 'upcoming'
|
||||||
|
? 'bg-blue-100 text-blue-800'
|
||||||
|
: 'bg-gray-100 text-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{event.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500">No organized events yet</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'activity' && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Activity Timeline</h2>
|
||||||
|
{activity.length > 0 ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{activity.map((item, index) => (
|
||||||
|
<div key={`${item.type}-${item.id}-${index}`} className="flex items-start space-x-4">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="h-12 w-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
|
{item.type === 'report' && <span className="text-xl">📍</span>}
|
||||||
|
{item.type === 'event_created' && <span className="text-xl">➕</span>}
|
||||||
|
{item.type === 'event_attended' && <span className="text-xl">✅</span>}
|
||||||
|
{item.type === 'event_completed' && <span className="text-xl">🎉</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 bg-gray-50 rounded-lg p-4">
|
||||||
|
<p className="font-medium text-gray-900">{item.title}</p>
|
||||||
|
{item.description && (
|
||||||
|
<p className="text-sm text-gray-600 mt-1">{item.description}</p>
|
||||||
|
)}
|
||||||
|
{item.location && (
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
📍 {item.location.locationName || `${item.location.latitude?.toFixed(4)}, ${item.location.longitude?.toFixed(4)}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-500 mt-2">{formatDate(item.timestamp)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500">No activity yet</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'notifications' && isOwnProfile && (
|
||||||
|
<NotificationSettings
|
||||||
|
preferences={notificationPreferences}
|
||||||
|
onSave={handleSaveNotificationPreferences}
|
||||||
|
onCancel={handleCancelNotificationSettings}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserProfilePage;
|
||||||
@@ -5,7 +5,8 @@ import { useClerk } from '@clerk/clerk-react';
|
|||||||
import { userService, type UserProfile, type UserActivity } from '@/services/userService';
|
import { userService, type UserProfile, type UserActivity } from '@/services/userService';
|
||||||
import type { Report, CleanupEvent } from '@/types';
|
import type { Report, CleanupEvent } from '@/types';
|
||||||
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
||||||
import { NotificationSettings, type NotificationPreferences } from '@/components/auth';
|
import { NotificationSettings, type NotificationPreferences, BadgeDisplay } from '@/components/auth';
|
||||||
|
import { UserQRCode } from '@/components/user/UserQRCode';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ export function UserProfilePage() {
|
|||||||
firstName: '',
|
firstName: '',
|
||||||
lastName: '',
|
lastName: '',
|
||||||
username: '',
|
username: '',
|
||||||
|
email: '',
|
||||||
});
|
});
|
||||||
const [notificationPreferences, setNotificationPreferences] = useState<NotificationPreferences>({
|
const [notificationPreferences, setNotificationPreferences] = useState<NotificationPreferences>({
|
||||||
notificationEmail: true,
|
notificationEmail: true,
|
||||||
@@ -86,6 +88,7 @@ export function UserProfilePage() {
|
|||||||
firstName: profileData.firstName || '',
|
firstName: profileData.firstName || '',
|
||||||
lastName: profileData.lastName || '',
|
lastName: profileData.lastName || '',
|
||||||
username: profileData.username || '',
|
username: profileData.username || '',
|
||||||
|
email: profileData.email || '',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set notification preferences from profile
|
// Set notification preferences from profile
|
||||||
@@ -95,7 +98,9 @@ export function UserProfilePage() {
|
|||||||
areasOfInterest: profileData.areasOfInterest || [],
|
areasOfInterest: profileData.areasOfInterest || [],
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -112,6 +117,7 @@ export function UserProfilePage() {
|
|||||||
firstName: profile.firstName || '',
|
firstName: profile.firstName || '',
|
||||||
lastName: profile.lastName || '',
|
lastName: profile.lastName || '',
|
||||||
username: profile.username || '',
|
username: profile.username || '',
|
||||||
|
email: profile.email || '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -235,6 +241,20 @@ export function UserProfilePage() {
|
|||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
className="px-3 py-1 border border-gray-300 rounded-md w-full"
|
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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -244,6 +264,16 @@ export function UserProfilePage() {
|
|||||||
: profile.username}
|
: profile.username}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600">@{profile.username}</p>
|
<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>
|
</div>
|
||||||
@@ -288,20 +318,85 @@ export function UserProfilePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Statistics */}
|
{/* Statistics and Badge */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mt-6">
|
||||||
<div className="bg-blue-50 rounded-lg p-4">
|
{/* Reports Submitted */}
|
||||||
<p className="text-sm text-gray-600">Reports Submitted</p>
|
<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">
|
||||||
<p className="text-3xl font-bold text-blue-600">{profile.reportCount}</p>
|
<div className="flex flex-col items-center text-center space-y-3">
|
||||||
|
<div className="bg-blue-200 rounded-full p-3">
|
||||||
|
<svg className="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-blue-700 mb-1">Reports Submitted</p>
|
||||||
|
<p className="text-4xl font-bold text-blue-600">{profile.reportCount}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-green-50 rounded-lg p-4">
|
|
||||||
<p className="text-sm text-gray-600">Events Attended</p>
|
{/* Events Attended */}
|
||||||
<p className="text-3xl font-bold text-green-600">{profile.eventCount}</p>
|
<div className="bg-gradient-to-br from-green-50 to-green-100 rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow border border-green-200">
|
||||||
|
<div className="flex flex-col items-center text-center space-y-3">
|
||||||
|
<div className="bg-green-200 rounded-full p-3">
|
||||||
|
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-green-700 mb-1">Events Attended</p>
|
||||||
|
<p className="text-4xl font-bold text-green-600">{profile.eventCount}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-purple-50 rounded-lg p-4">
|
|
||||||
<p className="text-sm text-gray-600">Impact Score</p>
|
{/* Total Litter Collected */}
|
||||||
<p className="text-3xl font-bold text-purple-600">{calculateImpactScore()}</p>
|
<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>
|
</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">
|
||||||
|
<div className="bg-purple-200 rounded-full p-3">
|
||||||
|
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-purple-700 mb-1">Impact Score</p>
|
||||||
|
<p className="text-4xl font-bold text-purple-600">{calculateImpactScore()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current Badge */}
|
||||||
|
<div className="bg-gradient-to-br from-yellow-50 via-orange-50 to-amber-100 rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow border border-yellow-200 flex items-center justify-center">
|
||||||
|
<BadgeDisplay impactScore={calculateImpactScore()} size="small" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Badge Progress Section */}
|
||||||
|
<div className="mt-6 bg-gradient-to-r from-blue-50 via-purple-50 to-pink-50 rounded-xl p-8 shadow-md border border-purple-200">
|
||||||
|
<div className="flex items-center justify-center mb-2">
|
||||||
|
<svg className="w-6 h-6 text-purple-600 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||||
|
</svg>
|
||||||
|
<h3 className="text-xl font-bold text-gray-900 text-center">Your Achievement Badge</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-center text-sm text-gray-600 mb-6">Keep contributing to unlock higher badges!</p>
|
||||||
|
<BadgeDisplay impactScore={calculateImpactScore()} showProgress={true} size="large" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -328,6 +423,11 @@ export function UserProfilePage() {
|
|||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
{activeTab === 'overview' && (
|
{activeTab === 'overview' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* QR Code for own profile */}
|
||||||
|
{isOwnProfile && (
|
||||||
|
<UserQRCode />
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold mb-4">Contribution Map</h2>
|
<h2 className="text-xl font-semibold mb-4">Contribution Map</h2>
|
||||||
{reports.length > 0 && reports[0] ? (
|
{reports.length > 0 && reports[0] ? (
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ export { ReportDetailDemo } from './ReportDetailDemo';
|
|||||||
export { WhyCleanPage } from './WhyCleanPage';
|
export { WhyCleanPage } from './WhyCleanPage';
|
||||||
export { UserProfilePage } from './UserProfilePage';
|
export { UserProfilePage } from './UserProfilePage';
|
||||||
export { EnvironmentalDataDemo } from './EnvironmentalDataDemo';
|
export { EnvironmentalDataDemo } from './EnvironmentalDataDemo';
|
||||||
|
export { PrintableQRCodePage } from './PrintableQRCodePage';
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ class AnalyticsService {
|
|||||||
|
|
||||||
const queryString = params.toString();
|
const queryString = params.toString();
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api'}/analytics/export${queryString ? `?${queryString}` : ''}`,
|
`${import.meta.env.VITE_API_BASE_URL || 'http://localhost:3005/api'}/analytics/export${queryString ? `?${queryString}` : ''}`,
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
// API service configuration and base client
|
// API service configuration and base client
|
||||||
import { useAuth } from '@clerk/clerk-react';
|
import { useAuth } from '@clerk/clerk-react';
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api';
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3005/api';
|
||||||
|
|
||||||
|
// Export function to get the base URL without /api suffix for static assets
|
||||||
|
export const getApiBaseUrl = () => {
|
||||||
|
return (import.meta.env.VITE_API_BASE_URL || 'http://localhost:3005/api').replace('/api', '');
|
||||||
|
};
|
||||||
|
|
||||||
export interface ApiError {
|
export interface ApiError {
|
||||||
code: string;
|
code: string;
|
||||||
@@ -41,11 +46,24 @@ export class ApiClient {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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');
|
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> {
|
async get<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||||
|
|||||||
@@ -101,7 +101,43 @@ export const eventService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Complete event
|
// Complete event
|
||||||
async completeEvent(eventId: string, data: { litterCollected: number; photos?: string[] }): Promise<CleanupEvent> {
|
async completeEvent(
|
||||||
return apiClient.post<CleanupEvent>(`/events/${eventId}/complete`, data);
|
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;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { User, Report, CleanupEvent } from '@/types';
|
|||||||
export interface UserProfile extends User {
|
export interface UserProfile extends User {
|
||||||
reportCount: number;
|
reportCount: number;
|
||||||
eventCount: number;
|
eventCount: number;
|
||||||
|
totalLitterCollected: number;
|
||||||
notificationEmail: boolean;
|
notificationEmail: boolean;
|
||||||
notificationInApp: boolean;
|
notificationInApp: boolean;
|
||||||
areasOfInterest: Array<{
|
areasOfInterest: Array<{
|
||||||
@@ -31,6 +32,7 @@ export interface UpdateProfileDTO {
|
|||||||
firstName?: string;
|
firstName?: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
email?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotificationPreferences {
|
export interface NotificationPreferences {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Core type definitions for the Beach Litter Mapping Platform
|
// Core type definitions for Litter Scouts
|
||||||
|
|
||||||
export type LitterType = 'plastic' | 'metal' | 'glass' | 'organic' | 'hazardous' | 'other';
|
export type LitterType = 'plastic' | 'metal' | 'glass' | 'organic' | 'hazardous' | 'other';
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ export interface Coordinates {
|
|||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
clerkId: string;
|
clerkId: string;
|
||||||
email: string;
|
email: string | null;
|
||||||
username: string;
|
username: string;
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
@@ -62,10 +62,30 @@ export interface CleanupEvent {
|
|||||||
participantCount: number;
|
participantCount: number;
|
||||||
litterCollected?: number; // in kilograms
|
litterCollected?: number; // in kilograms
|
||||||
photos?: string[];
|
photos?: string[];
|
||||||
|
equipmentProvided?: boolean; // Whether equipment is provided by organizer
|
||||||
|
requiredItems?: string[]; // List of items participants need to bring
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: 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 {
|
export interface EnvironmentalConcern {
|
||||||
id: string;
|
id: string;
|
||||||
reportId: string;
|
reportId: string;
|
||||||
|
|||||||
117
src/utils/badges.ts
Normal file
117
src/utils/badges.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* Badge system utilities for user achievements
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type BadgeLevel = 'Beginner' | 'Intermediate' | 'Advanced' | 'Expert' | 'Master';
|
||||||
|
|
||||||
|
export interface Badge {
|
||||||
|
level: BadgeLevel;
|
||||||
|
minScore: number;
|
||||||
|
maxScore: number | null;
|
||||||
|
image: string;
|
||||||
|
color: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BADGES: Record<BadgeLevel, Badge> = {
|
||||||
|
Beginner: {
|
||||||
|
level: 'Beginner',
|
||||||
|
minScore: 0,
|
||||||
|
maxScore: 49,
|
||||||
|
image: '/Beginner.png',
|
||||||
|
color: 'bg-gray-100 text-gray-800',
|
||||||
|
description: 'Starting your environmental journey',
|
||||||
|
},
|
||||||
|
Intermediate: {
|
||||||
|
level: 'Intermediate',
|
||||||
|
minScore: 50,
|
||||||
|
maxScore: 99,
|
||||||
|
image: '/Intermediate.png',
|
||||||
|
color: 'bg-blue-100 text-blue-800',
|
||||||
|
description: 'Making a real difference',
|
||||||
|
},
|
||||||
|
Advanced: {
|
||||||
|
level: 'Advanced',
|
||||||
|
minScore: 100,
|
||||||
|
maxScore: 149,
|
||||||
|
image: '/Advanced.png',
|
||||||
|
color: 'bg-purple-100 text-purple-800',
|
||||||
|
description: 'Dedicated environmental champion',
|
||||||
|
},
|
||||||
|
Expert: {
|
||||||
|
level: 'Expert',
|
||||||
|
minScore: 150,
|
||||||
|
maxScore: 199,
|
||||||
|
image: '/Expert.png',
|
||||||
|
color: 'bg-orange-100 text-orange-800',
|
||||||
|
description: 'Leading the cleanup movement',
|
||||||
|
},
|
||||||
|
Master: {
|
||||||
|
level: 'Master',
|
||||||
|
minScore: 200,
|
||||||
|
maxScore: null,
|
||||||
|
image: '/Master.png',
|
||||||
|
color: 'bg-yellow-100 text-yellow-800',
|
||||||
|
description: 'Elite environmental guardian',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get badge level based on impact score
|
||||||
|
*/
|
||||||
|
export function getBadgeForScore(score: number): Badge {
|
||||||
|
if (score >= 200) return BADGES.Master;
|
||||||
|
if (score >= 150) return BADGES.Expert;
|
||||||
|
if (score >= 100) return BADGES.Advanced;
|
||||||
|
if (score >= 50) return BADGES.Intermediate;
|
||||||
|
return BADGES.Beginner;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get next badge level and progress
|
||||||
|
*/
|
||||||
|
export function getNextBadgeProgress(score: number): {
|
||||||
|
currentBadge: Badge;
|
||||||
|
nextBadge: Badge | null;
|
||||||
|
progress: number;
|
||||||
|
pointsToNext: number;
|
||||||
|
} {
|
||||||
|
const currentBadge = getBadgeForScore(score);
|
||||||
|
|
||||||
|
// If at Master level, no next badge
|
||||||
|
if (currentBadge.level === 'Master') {
|
||||||
|
return {
|
||||||
|
currentBadge,
|
||||||
|
nextBadge: null,
|
||||||
|
progress: 100,
|
||||||
|
pointsToNext: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find next badge
|
||||||
|
const badgeLevels: BadgeLevel[] = ['Beginner', 'Intermediate', 'Advanced', 'Expert', 'Master'];
|
||||||
|
const currentIndex = badgeLevels.indexOf(currentBadge.level);
|
||||||
|
const nextBadgeLevel = badgeLevels[currentIndex + 1];
|
||||||
|
if (!nextBadgeLevel) {
|
||||||
|
return {
|
||||||
|
currentBadge,
|
||||||
|
nextBadge: null,
|
||||||
|
progress: 100,
|
||||||
|
pointsToNext: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const nextBadge = BADGES[nextBadgeLevel];
|
||||||
|
|
||||||
|
// Calculate progress
|
||||||
|
const pointsInCurrentLevel = score - currentBadge.minScore;
|
||||||
|
const pointsNeededForNext = nextBadge.minScore - currentBadge.minScore;
|
||||||
|
const progress = Math.min(100, (pointsInCurrentLevel / pointsNeededForNext) * 100);
|
||||||
|
const pointsToNext = nextBadge.minScore - score;
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentBadge,
|
||||||
|
nextBadge,
|
||||||
|
progress,
|
||||||
|
pointsToNext,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Utility functions for the Beach Litter Mapping Platform
|
// Utility functions for Litter Scouts
|
||||||
|
|
||||||
// Re-export coordinate utilities
|
// Re-export coordinate utilities
|
||||||
export * from './coordinates';
|
export * from './coordinates';
|
||||||
|
|||||||
@@ -5,14 +5,23 @@ import path from 'path'
|
|||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
port: 5200,
|
||||||
|
host: true, // Allow external access
|
||||||
|
allowedHosts: [
|
||||||
|
'localhost',
|
||||||
|
'litterscouts.psbailey.uk',
|
||||||
|
'.psbailey.uk', // Allow all subdomains
|
||||||
|
],
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
registerType: 'autoUpdate',
|
registerType: 'autoUpdate',
|
||||||
includeAssets: ['vite.svg'],
|
includeAssets: ['vite.svg'],
|
||||||
manifest: {
|
manifest: {
|
||||||
name: 'Beach Litter Mapping Platform',
|
name: 'Litter Scouts',
|
||||||
short_name: 'Beach Cleanup',
|
short_name: 'Litter Scouts',
|
||||||
description: 'Crowdsourced environmental monitoring of Irish coastal areas',
|
description: 'Crowdsourced environmental monitoring of Irish coastal areas',
|
||||||
theme_color: '#0ea5e9',
|
theme_color: '#0ea5e9',
|
||||||
background_color: '#ffffff',
|
background_color: '#ffffff',
|
||||||
|
|||||||
Reference in New Issue
Block a user