Compare commits

...

11 Commits

Author SHA1 Message Date
82a365f357 Update tasks to reflect MySQL instead of PostgreSQL/PostGIS
- Change task 22 from PostgreSQL+PostGIS to MySQL with spatial support
- Update references to PostGIS spatial queries to MySQL spatial functions
- Remove materialized view reference (MySQL specific)
- Update test task to reference MySQL instead of PostGIS
2025-11-16 18:21:09 +00:00
48e75465ab Add Clerk authentication to API requests
- Update API client to include Bearer token in requests
- Add token getter method to reportService for photo uploads
- Create ApiInitializer component to set up auth tokens
- Use Clerk's getToken() to retrieve session tokens
- Fix 401 Unauthorized errors on report submission
2025-11-16 17:34:07 +00:00
ae19d9b690 Change backend API port from 3000 to 3005
- Update server default port to 3005
- Update frontend API base URL to point to port 3005
2025-11-16 17:31:11 +00:00
c0ec28c5c9 Set up backend API server with Express and Prisma
- Initialize Node.js/Express backend project structure
- Configure TypeScript for backend development
- Set up Prisma ORM with MySQL database schema
- Implement authentication middleware with Clerk
- Create photo upload endpoint with EXIF extraction
- Build report CRUD endpoints (create, read, delete)
- Add file upload handling with Multer
- Configure CORS and security middleware (helmet)
- Set up error handling and validation
- Create development environment configuration

Task 21 complete: Backend foundation ready for report submissions
2025-11-16 17:29:26 +00:00
7dc1ff740b Auto-show zoomed map when EXIF location data is detected
- Automatically display map when photo contains GPS coordinates
- Zoom to level 15 for EXIF locations (more detailed view)
- Add contextual header explaining location source
- Improve map container styling with shadow and colored header
- Show helpful message for EXIF vs manual location selection
2025-11-16 17:18:30 +00:00
51a8b4999d Make Why Clean page more impactful and visually engaging
- Add beach background image with gradient overlay
- Use larger, bolder headlines with emotional impact
- Add color-coded sections (blue, red, green, purple, orange)
- Include visual cards and grid layouts for better readability
- Add hover effects and transitions for interactivity
- Use emojis and icons more prominently
- Create compelling call-to-action with yellow highlight box
- Improve typography with larger text and better hierarchy
2025-11-16 17:15:20 +00:00
96e28e3017 Add Why Clean Our Beaches page with environmental facts
- Create new WhyCleanPage with comprehensive environmental information
- Include sections on marine life impact, wave power generation, economic impact, and human health
- Add button to homepage for easy access to environmental facts
- Update routing to include /why-clean path
- Use responsive button layout with flex-wrap for mobile support
2025-11-16 17:12:57 +00:00
8be807b379 Add Irish beach background image to landing page
- Use IMG_6703.jpeg as full-page background with parallax effect
- Add dark overlay for better text readability
- Update feature cards with semi-transparent backgrounds
- Enhance buttons with hover effects and transitions
2025-11-16 17:07:05 +00:00
520970993d chore: mark task 9 as complete 2025-11-16 16:52:01 +00:00
3aa7576487 feat: implement report submission and display (task 9)
- Created reportService for API interactions (upload, create, get, verify, dispute)
- Built ReportDetailModal component with photo gallery and verification UI
- Updated ReportForm with actual API submission and progress tracking
- Added error handling and success states
- Created ReportDetailDemo page to showcase modal functionality
- Added route for demo page at /demo/report-detail
- Exported services through index file for cleaner imports
- Documented implementation in SUBMISSION_IMPLEMENTATION.md

Requirements fulfilled: 1.8, 7.4
2025-11-16 16:35:05 +00:00
c4aad5d099 feat: implement litter report form with multi-step wizard
- Create comprehensive ReportForm component with 4-step wizard
- Add progress indicator showing current step
- Implement litter type categorization with descriptions
- Add quantity estimation with visual indicators
- Include optional description textarea with character limit
- Create environmental concerns section with severity levels
- Integrate PhotoUpload and LocationSelector components
- Add form validation with error messages
- Implement review step showing all submitted data
- Update ReportFormPage to use new ReportForm component
- Create barrel export for report components

Requirements: 1.6, 1.7, 9.1, 9.2
2025-11-16 16:26:32 +00:00
30 changed files with 5337 additions and 103 deletions

View File

@@ -57,7 +57,7 @@
- Validate coordinate ranges for Ireland
- _Requirements: 1.1, 1.2, 1.3, 8.2_
- [ ] 8. Create litter report form
- [x] 8. Create litter report form
- Build multi-step form with progress indicator
- Implement litter type categorization dropdown
- Create quantity estimation slider with visual indicators
@@ -66,7 +66,7 @@
- Create environmental concerns section
- _Requirements: 1.6, 1.7, 9.1, 9.2_
- [ ] 9. Implement report submission and display
- [x] 9. Implement report submission and display
- Connect form to API endpoint for report creation
- Handle submission success and error states
- Create report detail modal/popup component
@@ -173,7 +173,7 @@
## Backend API Development
- [ ] 21. Set up backend project structure
- [x] 21. Set up backend project structure
- Initialize Node.js project with Express
- Configure TypeScript for backend
- Set up folder structure: routes, controllers, services, middleware, utils
@@ -181,12 +181,12 @@
- Create environment configuration
- _Requirements: All backend requirements_
- [ ] 22. Configure database with Prisma and PostGIS
- Set up PostgreSQL with PostGIS extension
- [ ] 22. Configure database with Prisma and MySQL spatial support
- Set up MySQL 8.0+ with spatial data support
- Create Prisma schema with all models (User, Report, Event, etc.)
- Configure PostGIS geography types for location fields
- Configure MySQL POINT spatial types for location fields
- Create database migrations
- Set up database indexes for performance
- Set up database indexes including spatial indexes for performance
- _Requirements: 1.1, 2.1, 3.2, 4.1_
- [ ] 23. Implement authentication system
@@ -203,7 +203,7 @@
- Build GET /api/reports/:id for single report retrieval
- Create PATCH /api/reports/:id for updates
- Implement DELETE /api/reports/:id with authorization
- Add spatial queries using PostGIS for location-based filtering
- Add spatial queries using MySQL spatial functions for location-based filtering
- _Requirements: 1.1-1.8, 2.1, 2.4_
- [ ] 25. Implement file upload and EXIF processing
@@ -232,9 +232,9 @@
- [ ] 28. Build hotspot calculation system
- Create scheduled job with node-cron for hotspot calculations
- Implement PostGIS spatial queries to identify clusters
- Implement MySQL spatial queries to identify clusters
- Calculate severity scores based on report count and frequency
- Store hotspot data in database or materialized view
- Store hotspot data in database
- Create GET /api/analytics/hotspots endpoint
- _Requirements: 3.2, 3.3, 3.4_
@@ -310,7 +310,7 @@
- Create API endpoint tests with Supertest
- Test authentication flows
- Test file upload pipeline
- Test spatial queries with PostGIS
- Test spatial queries with MySQL
- Test external API integration with mocks
- _Requirements: All requirements_

14
server/.env.example Normal file
View File

@@ -0,0 +1,14 @@
# Database
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"
# Server
PORT=3000
NODE_ENV=development
# File Upload
UPLOAD_DIR=./uploads
MAX_FILE_SIZE=5242880

26
server/.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Dependencies
node_modules/
# Environment variables
.env
.env.local
# Build output
dist/
# Uploads
uploads/
# Logs
*.log
npm-debug.log*
# OS
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
*.swp
*.swo

56
server/README.md Normal file
View File

@@ -0,0 +1,56 @@
# Beach Litter Mapping API
Backend API server for the Beach Litter Mapping Platform.
## Setup
1. Install dependencies:
```bash
cd server
npm install
```
2. Configure environment variables:
```bash
cp .env.example .env
# Edit .env with your configuration
```
3. Set up the database:
```bash
npm run prisma:generate
npm run prisma:migrate
```
4. Start the development server:
```bash
npm run dev
```
The API will be available at `http://localhost:3000`
## API Endpoints
### Reports
- `POST /api/reports/upload` - Upload a photo (requires auth)
- `POST /api/reports` - Create a new report (requires auth)
- `GET /api/reports` - Get all reports (with optional filters)
- `GET /api/reports/:id` - Get a single report
- `DELETE /api/reports/:id` - Delete a report (requires auth, owner only)
### Health Check
- `GET /health` - Server health status
## Environment Variables
See `.env.example` for required environment variables.
## Database
The API uses MySQL 8.0+ with Prisma ORM. Connection details are configured in the `.env` file.
## Authentication
Authentication is handled by Clerk. Protected endpoints require a Bearer token in the Authorization header.

2220
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
server/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "beach-litter-api",
"version": "1.0.0",
"description": "Backend API for Beach Litter Mapping Platform",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio"
},
"keywords": ["beach", "litter", "environment", "api"],
"author": "",
"license": "MIT",
"dependencies": {
"@clerk/clerk-sdk-node": "^5.0.0",
"@prisma/client": "^5.22.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.1",
"express-validator": "^7.2.0",
"exifr": "^7.1.3",
"helmet": "^8.0.0",
"multer": "^1.4.5-lts.1",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/multer": "^1.4.12",
"@types/node": "^22.9.0",
"prisma": "^5.22.0",
"tsx": "^4.19.2",
"typescript": "^5.6.3"
}
}

192
server/prisma/schema.prisma Normal file
View File

@@ -0,0 +1,192 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(dbgenerated("(uuid())")) @db.VarChar(36)
email String @unique(map: "email") @db.VarChar(255)
password_hash String @db.VarChar(255)
username String @unique(map: "username") @db.VarChar(100)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
updatedAt DateTime? @default(now()) @updatedAt @map("updated_at") @db.Timestamp(0)
firstName String? @map("first_name") @db.VarChar(100)
lastName String? @map("last_name") @db.VarChar(100)
avatarUrl String? @map("avatar_url") @db.VarChar(500)
impactScore Int? @default(0) @map("impact_score")
notificationEmail Boolean? @default(true) @map("notification_email")
notificationInApp Boolean? @default(true) @map("notification_in_app")
areasOfInterest Json? @map("areas_of_interest")
eventRegistrations EventRegistration[]
organizedEvents Event[] @relation("EventOrganizer")
reports Report[]
verifications Verification[]
@@index([email], map: "idx_email")
@@index([username], map: "idx_username")
@@map("users")
}
model Report {
id String @id @default(dbgenerated("(uuid())")) @db.VarChar(36)
userId String @map("user_id") @db.VarChar(36)
location Unsupported("point")
latitude Decimal @db.Decimal(10, 8)
longitude Decimal @db.Decimal(11, 8)
locationSource reports_location_source @map("location_source")
photoUrls Json @map("photo_urls")
photoTimestamp DateTime? @map("photo_timestamp") @db.Timestamp(0)
litterType reports_litter_type @map("litter_type")
quantity reports_quantity
description String? @db.Text
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
updatedAt DateTime? @default(now()) @updatedAt @map("updated_at") @db.Timestamp(0)
submittedAt DateTime? @default(now()) @map("submitted_at") @db.Timestamp(0)
verificationStatus reports_verification_status? @default(pending) @map("verification_status")
environmentalConcerns EnvironmentalConcern[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "reports_ibfk_1")
verifications Verification[]
@@index([createdAt], map: "idx_created_at")
@@index([latitude, longitude], map: "idx_lat_lng")
@@index([litterType], map: "idx_litter_type")
@@index([location(length: 32)], map: "idx_location")
@@index([userId], map: "user_id")
@@map("reports")
}
model Event {
id String @id @default(dbgenerated("(uuid())")) @db.VarChar(36)
organizerId String @map("organizer_id") @db.VarChar(36)
title String @db.VarChar(255)
description String @db.Text
location Unsupported("point")
latitude Decimal @db.Decimal(10, 8)
longitude Decimal @db.Decimal(11, 8)
locationName String @map("location_name") @db.VarChar(255)
scheduledDate DateTime @map("scheduled_date") @db.Timestamp(0)
duration Int
status events_status? @default(upcoming)
participantCount Int? @default(0) @map("participant_count")
litterCollected Decimal? @map("litter_collected") @db.Decimal(10, 2)
photos Json?
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
updatedAt DateTime? @default(now()) @updatedAt @map("updated_at") @db.Timestamp(0)
registrations EventRegistration[]
organizer User @relation("EventOrganizer", fields: [organizerId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "events_ibfk_1")
@@index([location(length: 32)], map: "idx_location")
@@index([scheduledDate], map: "idx_scheduled_date")
@@index([status], map: "idx_status")
@@index([organizerId], map: "organizer_id")
@@map("events")
}
model EventRegistration {
id String @id @default(dbgenerated("(uuid())")) @db.VarChar(36)
userId String @map("user_id") @db.VarChar(36)
eventId String @map("event_id") @db.VarChar(36)
registeredAt DateTime? @default(now()) @map("registered_at") @db.Timestamp(0)
attended Boolean? @default(false)
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "event_registrations_ibfk_1")
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "event_registrations_ibfk_2")
@@unique([userId, eventId], map: "unique_user_event")
@@index([eventId], map: "event_id")
@@map("event_registrations")
}
model Verification {
id String @id @default(dbgenerated("(uuid())")) @db.VarChar(36)
reportId String @map("report_id") @db.VarChar(36)
userId String @map("user_id") @db.VarChar(36)
verificationType verifications_verification_type @map("verification_type")
comment String? @db.Text
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "verifications_ibfk_1")
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "verifications_ibfk_2")
@@unique([reportId, userId], map: "unique_report_user")
@@index([userId], map: "user_id")
@@map("verifications")
}
model EnvironmentalConcern {
id String @id @default(dbgenerated("(uuid())")) @db.VarChar(36)
reportId String @map("report_id") @db.VarChar(36)
concernType String @map("concern_type") @db.VarChar(100)
severity environmental_concerns_severity
description String @db.Text
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "environmental_concerns_ibfk_1")
@@index([concernType], map: "idx_concern_type")
@@index([reportId], map: "report_id")
@@map("environmental_concerns")
}
model Hotspot {
id String @id @default(dbgenerated("(uuid())")) @db.VarChar(36)
location Unsupported("point")
latitude Decimal @db.Decimal(10, 8)
longitude Decimal @db.Decimal(11, 8)
radius Decimal @db.Decimal(10, 2)
reportCount Int @map("report_count")
severityScore Decimal @map("severity_score") @db.Decimal(5, 2)
lastReportDate DateTime @map("last_report_date") @db.Timestamp(0)
calculatedAt DateTime? @default(now()) @map("calculated_at") @db.Timestamp(0)
@@index([location(length: 32)], map: "idx_location")
@@index([severityScore], map: "idx_severity_score")
@@map("hotspots")
}
enum verifications_verification_type {
verify
dispute
}
enum environmental_concerns_severity {
low
medium
high
critical
}
enum reports_location_source {
exif
gps
manual
}
enum reports_litter_type {
plastic
metal
glass
organic
hazardous
other
}
enum reports_quantity {
minimal
moderate
significant
severe
}
enum events_status {
upcoming
completed
cancelled
}
enum reports_verification_status {
pending
verified
disputed
}

View File

@@ -0,0 +1,7 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
export default prisma;

View File

@@ -0,0 +1,240 @@
import { Response } from 'express';
import { AuthRequest } from '../middleware/auth';
import { reportService } from '../services/reportService';
import { extractExifData } from '../utils/exif';
import path from 'path';
export class ReportController {
async uploadPhoto(req: AuthRequest, res: Response) {
try {
if (!req.file) {
return res.status(400).json({
error: {
code: 'NO_FILE',
message: 'No file uploaded',
timestamp: new Date().toISOString(),
},
});
}
// Extract EXIF data
const exifData = await extractExifData(req.file.path);
// Generate URL for the uploaded file
const fileUrl = `/uploads/${req.file.filename}`;
res.json({
url: fileUrl,
exifData: {
latitude: exifData.latitude,
longitude: exifData.longitude,
timestamp: exifData.timestamp,
},
});
} catch (error) {
console.error('Error uploading photo:', error);
res.status(500).json({
error: {
code: 'UPLOAD_ERROR',
message: 'Failed to upload photo',
timestamp: new Date().toISOString(),
},
});
}
}
async createReport(req: AuthRequest, res: Response) {
try {
if (!req.userId) {
return res.status(401).json({
error: {
code: 'UNAUTHORIZED',
message: 'User not authenticated',
timestamp: new Date().toISOString(),
},
});
}
const {
latitude,
longitude,
locationSource,
litterType,
quantity,
description,
photoUrls,
photoTimestamp,
environmentalConcerns,
} = req.body;
// Validation
if (!latitude || !longitude) {
return res.status(400).json({
error: {
code: 'VALIDATION_ERROR',
message: 'Latitude and longitude are required',
timestamp: new Date().toISOString(),
},
});
}
if (!photoUrls || photoUrls.length === 0) {
return res.status(400).json({
error: {
code: 'VALIDATION_ERROR',
message: 'At least one photo is required',
timestamp: new Date().toISOString(),
},
});
}
const report = await reportService.createReport({
userId: req.userId,
latitude: parseFloat(latitude),
longitude: parseFloat(longitude),
locationSource,
photoUrls,
photoTimestamp: photoTimestamp ? new Date(photoTimestamp) : undefined,
litterType,
quantity,
description,
environmentalConcerns,
});
res.status(201).json(report);
} catch (error) {
console.error('Error creating report:', error);
res.status(500).json({
error: {
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to create report',
timestamp: new Date().toISOString(),
},
});
}
}
async getReports(req: AuthRequest, res: Response) {
try {
const {
startDate,
endDate,
litterTypes,
quantities,
verificationStatus,
north,
south,
east,
west,
} = req.query;
const filters: any = {};
if (startDate) filters.startDate = new Date(startDate as string);
if (endDate) filters.endDate = new Date(endDate as string);
if (litterTypes) filters.litterTypes = (litterTypes as string).split(',');
if (quantities) filters.quantities = (quantities as string).split(',');
if (verificationStatus) filters.verificationStatus = (verificationStatus as string).split(',');
if (north && south && east && west) {
filters.bounds = {
north: parseFloat(north as string),
south: parseFloat(south as string),
east: parseFloat(east as string),
west: parseFloat(west as string),
};
}
const reports = await reportService.getReports(filters);
res.json(reports);
} catch (error) {
console.error('Error fetching reports:', error);
res.status(500).json({
error: {
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to fetch reports',
timestamp: new Date().toISOString(),
},
});
}
}
async getReportById(req: AuthRequest, res: Response) {
try {
const { id } = req.params;
const report = await reportService.getReportById(id);
if (!report) {
return res.status(404).json({
error: {
code: 'RESOURCE_NOT_FOUND',
message: 'Report not found',
timestamp: new Date().toISOString(),
},
});
}
res.json(report);
} catch (error) {
console.error('Error fetching report:', error);
res.status(500).json({
error: {
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to fetch report',
timestamp: new Date().toISOString(),
},
});
}
}
async deleteReport(req: AuthRequest, res: Response) {
try {
if (!req.userId) {
return res.status(401).json({
error: {
code: 'UNAUTHORIZED',
message: 'User not authenticated',
timestamp: new Date().toISOString(),
},
});
}
const { id } = req.params;
await reportService.deleteReport(id, req.userId);
res.status(204).send();
} catch (error: any) {
console.error('Error deleting report:', error);
if (error.message === 'Report not found') {
return res.status(404).json({
error: {
code: 'RESOURCE_NOT_FOUND',
message: 'Report not found',
timestamp: new Date().toISOString(),
},
});
}
if (error.message === 'Unauthorized to delete this report') {
return res.status(403).json({
error: {
code: 'FORBIDDEN',
message: 'You are not authorized to delete this report',
timestamp: new Date().toISOString(),
},
});
}
res.status(500).json({
error: {
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to delete report',
timestamp: new Date().toISOString(),
},
});
}
}
}
export const reportController = new ReportController();

66
server/src/index.ts Normal file
View File

@@ -0,0 +1,66 @@
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import dotenv from 'dotenv';
import path from 'path';
import reportRoutes from './routes/reportRoutes';
// Load environment variables
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3005;
// Middleware
app.use(helmet());
app.use(cors({
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
credentials: true,
}));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Serve uploaded files
app.use('/uploads', express.static(path.join(__dirname, '../uploads')));
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// API Routes
app.use('/api/reports', reportRoutes);
// Error handling middleware
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error('Error:', err);
if (err.name === 'MulterError') {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({
error: {
code: 'FILE_TOO_LARGE',
message: 'File size exceeds the maximum limit of 5MB',
timestamp: new Date().toISOString(),
},
});
}
}
res.status(500).json({
error: {
code: 'INTERNAL_SERVER_ERROR',
message: err.message || 'An unexpected error occurred',
timestamp: new Date().toISOString(),
},
});
});
// Start server
app.listen(PORT, () => {
console.log(`🚀 Server running on port ${PORT}`);
console.log(`📍 Environment: ${process.env.NODE_ENV || 'development'}`);
console.log(`🔗 API: http://localhost:${PORT}/api`);
});
export default app;

View File

@@ -0,0 +1,56 @@
import { Request, Response, NextFunction } from 'express';
import { clerkClient } from '@clerk/clerk-sdk-node';
export interface AuthRequest extends Request {
userId?: string;
user?: any;
}
export const requireAuth = async (
req: AuthRequest,
res: Response,
next: NextFunction
) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
error: {
code: 'UNAUTHORIZED',
message: 'No authorization token provided',
timestamp: new Date().toISOString(),
},
});
}
const token = authHeader.substring(7);
// Verify the session token with Clerk
const session = await clerkClient.sessions.verifySession(token, token);
if (!session) {
return res.status(401).json({
error: {
code: 'INVALID_TOKEN',
message: 'Invalid or expired token',
timestamp: new Date().toISOString(),
},
});
}
// Attach user ID to request
req.userId = session.userId;
next();
} catch (error) {
console.error('Auth middleware error:', error);
return res.status(401).json({
error: {
code: 'UNAUTHORIZED',
message: 'Authentication failed',
timestamp: new Date().toISOString(),
},
});
}
};

View File

@@ -0,0 +1,40 @@
import multer from 'multer';
import path from 'path';
import fs from 'fs';
// Ensure upload directory exists
const uploadDir = process.env.UPLOAD_DIR || './uploads';
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
// Configure storage
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname));
},
});
// File filter
const fileFilter = (req: any, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Invalid file type. Only JPEG, PNG, and WebP images are allowed.'));
}
};
// Configure multer
export const upload = multer({
storage,
fileFilter,
limits: {
fileSize: parseInt(process.env.MAX_FILE_SIZE || '5242880'), // 5MB default
},
});

View File

@@ -0,0 +1,17 @@
import { Router } from 'express';
import { reportController } from '../controllers/reportController';
import { requireAuth } from '../middleware/auth';
import { upload } from '../middleware/upload';
const router = Router();
// Photo upload endpoint (protected)
router.post('/upload', requireAuth, upload.single('photo'), reportController.uploadPhoto.bind(reportController));
// Report CRUD endpoints
router.post('/', requireAuth, reportController.createReport.bind(reportController));
router.get('/', reportController.getReports.bind(reportController));
router.get('/:id', reportController.getReportById.bind(reportController));
router.delete('/:id', requireAuth, reportController.deleteReport.bind(reportController));
export default router;

View File

@@ -0,0 +1,168 @@
import prisma from '../config/database';
import { Prisma } from '@prisma/client';
export interface CreateReportData {
userId: string;
latitude: number;
longitude: number;
locationSource: 'exif' | 'gps' | 'manual';
photoUrls: string[];
photoTimestamp?: Date;
litterType: string;
quantity: string;
description?: string;
environmentalConcerns?: Array<{
type: string;
severity: string;
description: string;
}>;
}
export class ReportService {
async createReport(data: CreateReportData) {
const report = await prisma.report.create({
data: {
userId: data.userId,
latitude: new Prisma.Decimal(data.latitude),
longitude: new Prisma.Decimal(data.longitude),
locationSource: data.locationSource,
photoUrls: data.photoUrls,
photoTimestamp: data.photoTimestamp,
litterType: data.litterType,
quantity: data.quantity,
description: data.description,
environmentalConcerns: data.environmentalConcerns
? {
create: data.environmentalConcerns.map((concern) => ({
concernType: concern.type,
severity: concern.severity,
description: concern.description,
})),
}
: undefined,
},
include: {
user: {
select: {
id: true,
username: true,
avatarUrl: true,
},
},
environmentalConcerns: true,
},
});
return report;
}
async getReports(filters?: {
startDate?: Date;
endDate?: Date;
litterTypes?: string[];
quantities?: string[];
verificationStatus?: string[];
bounds?: { north: number; south: number; east: number; west: number };
}) {
const where: any = {};
if (filters?.startDate || filters?.endDate) {
where.createdAt = {};
if (filters.startDate) where.createdAt.gte = filters.startDate;
if (filters.endDate) where.createdAt.lte = filters.endDate;
}
if (filters?.litterTypes?.length) {
where.litterType = { in: filters.litterTypes };
}
if (filters?.quantities?.length) {
where.quantity = { in: filters.quantities };
}
if (filters?.verificationStatus?.length) {
where.verificationStatus = { in: filters.verificationStatus };
}
if (filters?.bounds) {
where.latitude = {
gte: new Prisma.Decimal(filters.bounds.south),
lte: new Prisma.Decimal(filters.bounds.north),
};
where.longitude = {
gte: new Prisma.Decimal(filters.bounds.west),
lte: new Prisma.Decimal(filters.bounds.east),
};
}
const reports = await prisma.report.findMany({
where,
include: {
user: {
select: {
id: true,
username: true,
avatarUrl: true,
},
},
environmentalConcerns: true,
verifications: true,
},
orderBy: {
createdAt: 'desc',
},
});
return reports;
}
async getReportById(id: string) {
const report = await prisma.report.findUnique({
where: { id },
include: {
user: {
select: {
id: true,
username: true,
avatarUrl: true,
},
},
environmentalConcerns: true,
verifications: {
include: {
user: {
select: {
id: true,
username: true,
},
},
},
},
},
});
return report;
}
async deleteReport(id: string, userId: string) {
// Verify ownership
const report = await prisma.report.findUnique({
where: { id },
select: { userId: true },
});
if (!report) {
throw new Error('Report not found');
}
if (report.userId !== userId) {
throw new Error('Unauthorized to delete this report');
}
await prisma.report.delete({
where: { id },
});
}
}
export const reportService = new ReportService();

33
server/src/utils/exif.ts Normal file
View File

@@ -0,0 +1,33 @@
import exifr from 'exifr';
export interface ExifData {
latitude?: number;
longitude?: number;
timestamp?: Date;
make?: string;
model?: string;
}
export async function extractExifData(filePath: string): Promise<ExifData> {
try {
const exif = await exifr.parse(filePath, {
gps: true,
pick: ['latitude', 'longitude', 'DateTimeOriginal', 'Make', 'Model'],
});
if (!exif) {
return {};
}
return {
latitude: exif.latitude,
longitude: exif.longitude,
timestamp: exif.DateTimeOriginal,
make: exif.Make,
model: exif.Model,
};
} catch (error) {
console.error('Error extracting EXIF data:', error);
return {};
}
}

18
server/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,6 +1,7 @@
import { ClerkProvider } from '@clerk/clerk-react';
import { ClerkProvider, useAuth } from '@clerk/clerk-react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { useEffect } from 'react';
// Import Leaflet CSS
import 'leaflet/dist/leaflet.css';
@@ -19,9 +20,15 @@ import {
EventsPage,
AnalyticsPage,
PhotoUploadDemo,
LocationSelectorDemo
LocationSelectorDemo,
ReportDetailDemo,
WhyCleanPage
} from './pages';
// Services
import { apiClient } from './services/api';
import { reportService } from './services/reportService';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
@@ -37,10 +44,25 @@ if (!clerkPubKey) {
throw new Error('Missing Clerk Publishable Key');
}
// Component to initialize API clients with auth token
function ApiInitializer({ children }: { children: React.ReactNode }) {
const { getToken } = useAuth();
useEffect(() => {
// Set up token getter for API clients
const tokenGetter = () => getToken();
apiClient.setTokenGetter(tokenGetter);
reportService.setTokenGetter(tokenGetter);
}, [getToken]);
return <>{children}</>;
}
function App() {
return (
<ClerkProvider publishableKey={clerkPubKey}>
<QueryClientProvider client={queryClient}>
<ApiInitializer>
<BrowserRouter>
<Routes>
{/* Public routes */}
@@ -64,6 +86,14 @@ function App() {
</Layout>
}
/>
<Route
path="/why-clean"
element={
<Layout>
<WhyCleanPage />
</Layout>
}
/>
{/* Protected routes */}
<Route
@@ -114,8 +144,17 @@ function App() {
</Layout>
}
/>
<Route
path="/demo/report-detail"
element={
<Layout>
<ReportDetailDemo />
</Layout>
}
/>
</Routes>
</BrowserRouter>
</ApiInitializer>
</QueryClientProvider>
</ClerkProvider>
);

View File

@@ -55,6 +55,7 @@ export function LocationSelector({ photos, onLocationChange, initialLocation }:
setSelectedLocation(coords);
setLocationSource('exif');
setValidationError(null);
setShowMap(true); // Automatically show map with EXIF location
onLocationChange(coords, 'exif');
} else {
setValidationError('Photo location is outside Ireland. Please select a location manually.');
@@ -87,8 +88,9 @@ export function LocationSelector({ photos, onLocationChange, initialLocation }:
? [selectedLocation.latitude, selectedLocation.longitude]
: defaultCenter;
// Create map instance
const map = L.map(mapRef.current).setView(initialCenter, selectedLocation ? 13 : 7);
// Create map instance with higher zoom for EXIF locations
const initialZoom = selectedLocation && locationSource === 'exif' ? 15 : selectedLocation ? 13 : 7;
const map = L.map(mapRef.current).setView(initialCenter, initialZoom);
// Add OpenStreetMap tiles
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
@@ -315,11 +317,23 @@ export function LocationSelector({ photos, onLocationChange, initialLocation }:
{/* Map Picker */}
{showMap && (
<div className="border border-gray-300 rounded-lg overflow-hidden">
<div className="bg-gray-100 px-4 py-2 border-b border-gray-300">
<p className="text-sm text-gray-700">
Click on the map to select a location, or drag the marker to adjust.
<div className="border border-gray-300 rounded-lg overflow-hidden shadow-lg">
<div className="bg-blue-50 px-4 py-3 border-b border-blue-200">
<div className="flex items-start">
<svg className="h-5 w-5 text-blue-600 mt-0.5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<p className="text-sm font-medium text-blue-900">
{locationSource === 'exif' ? 'Location from Photo' : 'Select Location'}
</p>
<p className="text-sm text-blue-700 mt-1">
{locationSource === 'exif'
? 'This location was extracted from your photo. You can adjust it by dragging the marker or clicking a new location.'
: 'Click on the map to select a location, or drag the marker to adjust.'}
</p>
</div>
</div>
</div>
<div ref={mapRef} className="w-full h-96" />
</div>

View File

@@ -0,0 +1,386 @@
// Modal component for displaying detailed report information
import { useState } from 'react';
import type { ReportResponseDTO } from '@/types';
import { format } from 'date-fns';
interface ReportDetailModalProps {
report: ReportResponseDTO;
isOpen: boolean;
onClose: () => void;
onVerify?: (reportId: string) => void;
onDispute?: (reportId: string) => void;
}
export function ReportDetailModal({ report, isOpen, onClose, onVerify, onDispute }: ReportDetailModalProps) {
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
const [showVerifyComment, setShowVerifyComment] = useState(false);
const [showDisputeComment, setShowDisputeComment] = useState(false);
const [comment, setComment] = useState('');
if (!isOpen) return null;
const handleVerify = () => {
if (onVerify) {
onVerify(report.id);
setShowVerifyComment(false);
setComment('');
}
};
const handleDispute = () => {
if (onDispute) {
onDispute(report.id);
setShowDisputeComment(false);
setComment('');
}
};
const nextPhoto = () => {
setCurrentPhotoIndex((prev) => (prev + 1) % report.photoUrls.length);
};
const prevPhoto = () => {
setCurrentPhotoIndex((prev) => (prev - 1 + report.photoUrls.length) % report.photoUrls.length);
};
const getVerificationStatusColor = (status: string) => {
switch (status) {
case 'verified':
return 'bg-green-100 text-green-800 border-green-200';
case 'disputed':
return 'bg-red-100 text-red-800 border-red-200';
default:
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
}
};
const getVerificationStatusIcon = (status: string) => {
switch (status) {
case 'verified':
return (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
);
case 'disputed':
return (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
);
default:
return (
<svg className="w-5 h-5" 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>
);
}
};
const getLitterTypeLabel = (type: string) => {
const labels: Record<string, string> = {
plastic: 'Plastic',
metal: 'Metal',
glass: 'Glass',
organic: 'Organic',
hazardous: 'Hazardous',
other: 'Other',
};
return labels[type] || type;
};
const getQuantityLabel = (quantity: string) => {
const labels: Record<string, string> = {
minimal: 'Minimal (1-10 items)',
moderate: 'Moderate (11-50 items)',
significant: 'Significant (51-200 items)',
severe: 'Severe (200+ items)',
};
return labels[quantity] || quantity;
};
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
onClick={onClose}
/>
{/* Modal */}
<div className="flex min-h-full items-center justify-center p-4">
<div className="relative bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
{/* Close Button */}
<button
onClick={onClose}
className="absolute top-4 right-4 z-10 p-2 rounded-full bg-white shadow-lg hover:bg-gray-100 transition-colors"
>
<svg className="w-6 h-6 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/* Photo Gallery */}
<div className="relative bg-gray-900">
<img
src={report.photoUrls[currentPhotoIndex]}
alt={`Report photo ${currentPhotoIndex + 1}`}
className="w-full h-96 object-contain"
/>
{/* Photo Navigation */}
{report.photoUrls.length > 1 && (
<>
<button
onClick={prevPhoto}
className="absolute left-4 top-1/2 -translate-y-1/2 p-2 rounded-full bg-white bg-opacity-75 hover:bg-opacity-100 transition-all"
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
onClick={nextPhoto}
className="absolute right-4 top-1/2 -translate-y-1/2 p-2 rounded-full bg-white bg-opacity-75 hover:bg-opacity-100 transition-all"
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
{/* Photo Counter */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full bg-black bg-opacity-75 text-white text-sm">
{currentPhotoIndex + 1} / {report.photoUrls.length}
</div>
</>
)}
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* Header with Verification Status */}
<div className="flex items-start justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900">Litter Report</h2>
<p className="text-sm text-gray-500 mt-1">
Reported {format(new Date(report.createdAt), 'PPP')} at {format(new Date(report.createdAt), 'p')}
</p>
</div>
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg border ${getVerificationStatusColor(report.verificationStatus)}`}>
{getVerificationStatusIcon(report.verificationStatus)}
<span className="font-medium capitalize">{report.verificationStatus}</span>
</div>
</div>
{/* Reporter Info */}
<div className="flex items-center gap-3 p-4 bg-gray-50 rounded-lg">
{report.user.avatarUrl ? (
<img
src={report.user.avatarUrl}
alt={report.user.username}
className="w-12 h-12 rounded-full"
/>
) : (
<div className="w-12 h-12 rounded-full bg-blue-600 flex items-center justify-center text-white font-semibold">
{report.user.username.charAt(0).toUpperCase()}
</div>
)}
<div>
<p className="font-medium text-gray-900">{report.user.username}</p>
<p className="text-sm text-gray-500">Reporter</p>
</div>
</div>
{/* Litter Details */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-4 border border-gray-200 rounded-lg">
<p className="text-sm text-gray-500 mb-1">Litter Type</p>
<p className="font-semibold text-gray-900">{getLitterTypeLabel(report.litterType)}</p>
</div>
<div className="p-4 border border-gray-200 rounded-lg">
<p className="text-sm text-gray-500 mb-1">Quantity</p>
<p className="font-semibold text-gray-900">{getQuantityLabel(report.quantity)}</p>
</div>
</div>
{/* Location */}
<div className="p-4 border border-gray-200 rounded-lg">
<p className="text-sm text-gray-500 mb-2">Location</p>
<div className="space-y-1">
<p className="font-mono text-sm text-gray-900">
{report.latitude.toFixed(6)}, {report.longitude.toFixed(6)}
</p>
<p className="text-xs text-gray-500">
Source: {report.locationSource === 'exif' ? 'Photo EXIF data' :
report.locationSource === 'gps' ? 'Device GPS' : 'Manual selection'}
</p>
</div>
</div>
{/* Description */}
{report.description && (
<div className="p-4 border border-gray-200 rounded-lg">
<p className="text-sm text-gray-500 mb-2">Description</p>
<p className="text-gray-900">{report.description}</p>
</div>
)}
{/* Photo Timestamp */}
{report.photoTimestamp && (
<div className="p-4 border border-gray-200 rounded-lg">
<p className="text-sm text-gray-500 mb-1">Photo Taken</p>
<p className="text-gray-900">
{format(new Date(report.photoTimestamp), 'PPP')} at {format(new Date(report.photoTimestamp), 'p')}
</p>
</div>
)}
{/* Environmental Concerns */}
{report.environmentalConcerns && report.environmentalConcerns.length > 0 && (
<div className="border border-gray-200 rounded-lg p-4">
<p className="text-sm text-gray-500 mb-3">Environmental Concerns</p>
<div className="space-y-3">
{report.environmentalConcerns.map((concern) => (
<div key={concern.id} className="p-3 bg-gray-50 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<span className="font-medium text-gray-900">{concern.concernType}</span>
<span
className={`
px-2 py-1 rounded text-xs font-medium capitalize
${concern.severity === 'low' ? 'bg-green-100 text-green-800' :
concern.severity === 'medium' ? 'bg-yellow-100 text-yellow-800' :
concern.severity === 'high' ? 'bg-orange-100 text-orange-800' :
'bg-red-100 text-red-800'}
`}
>
{concern.severity}
</span>
</div>
<p className="text-sm text-gray-700">{concern.description}</p>
</div>
))}
</div>
</div>
)}
{/* Verification Stats */}
<div className="flex items-center gap-6 p-4 bg-gray-50 rounded-lg">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-green-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<span className="text-sm text-gray-700">
<span className="font-semibold">{report.verificationCount}</span> verifications
</span>
</div>
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-red-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
<span className="text-sm text-gray-700">
<span className="font-semibold">{report.disputeCount}</span> disputes
</span>
</div>
</div>
{/* Action Buttons */}
{(onVerify || onDispute) && (
<div className="border-t border-gray-200 pt-6">
<p className="text-sm text-gray-700 mb-4">
Help maintain data quality by verifying or disputing this report.
</p>
{!showVerifyComment && !showDisputeComment && (
<div className="flex gap-3">
{onVerify && (
<button
onClick={() => setShowVerifyComment(true)}
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 transition-colors flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
Verify Report
</button>
)}
{onDispute && (
<button
onClick={() => setShowDisputeComment(true)}
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
Dispute Report
</button>
)}
</div>
)}
{/* Verify Comment Form */}
{showVerifyComment && (
<div className="space-y-3">
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Optional: Add a comment about why you're verifying this report..."
rows={3}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent resize-none"
/>
<div className="flex gap-3">
<button
onClick={handleVerify}
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 transition-colors"
>
Confirm Verification
</button>
<button
onClick={() => {
setShowVerifyComment(false);
setComment('');
}}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors"
>
Cancel
</button>
</div>
</div>
)}
{/* Dispute Comment Form */}
{showDisputeComment && (
<div className="space-y-3">
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Optional: Explain why you're disputing this report..."
rows={3}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent resize-none"
/>
<div className="flex gap-3">
<button
onClick={handleDispute}
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors"
>
Confirm Dispute
</button>
<button
onClick={() => {
setShowDisputeComment(false);
setComment('');
}}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors"
>
Cancel
</button>
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,698 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { PhotoUpload, type PhotoMetadata } from './PhotoUpload';
import { LocationSelector } from './LocationSelector';
import { reportService } from '@/services/reportService';
import type { LitterType, QuantityLevel, LocationSource, Coordinates } from '@/types';
interface EnvironmentalConcern {
type: string;
severity: 'low' | 'medium' | 'high' | 'critical';
description: string;
}
interface FormData {
photos: PhotoMetadata[];
location: Coordinates | null;
locationSource: LocationSource | null;
locationAccuracy?: number;
litterType: LitterType | '';
quantity: QuantityLevel | '';
description: string;
environmentalConcerns: EnvironmentalConcern[];
}
interface FormErrors {
photos?: string;
location?: string;
litterType?: string;
quantity?: string;
}
const LITTER_TYPES: { value: LitterType; label: string; description: string }[] = [
{ value: 'plastic', label: 'Plastic', description: 'Bottles, bags, packaging, microplastics' },
{ value: 'metal', label: 'Metal', description: 'Cans, foil, wire, metal fragments' },
{ value: 'glass', label: 'Glass', description: 'Bottles, broken glass, jars' },
{ value: 'organic', label: 'Organic', description: 'Food waste, natural debris' },
{ value: 'hazardous', label: 'Hazardous', description: 'Chemicals, batteries, medical waste' },
{ value: 'other', label: 'Other', description: 'Mixed or unclassified litter' },
];
const QUANTITY_LEVELS: { value: QuantityLevel; label: string; description: string }[] = [
{ value: 'minimal', label: 'Minimal', description: '1-10 items, scattered' },
{ value: 'moderate', label: 'Moderate', description: '11-50 items, noticeable' },
{ value: 'significant', label: 'Significant', description: '51-200 items, concentrated' },
{ value: 'severe', label: 'Severe', description: '200+ items, heavily polluted' },
];
const CONCERN_TYPES = [
'Wildlife hazard',
'Water contamination',
'Beach access blocked',
'Sharp objects',
'Chemical spill',
'Other',
];
export function ReportForm() {
const navigate = useNavigate();
const [currentStep, setCurrentStep] = useState(1);
const [formData, setFormData] = useState<FormData>({
photos: [],
location: null,
locationSource: null,
litterType: '',
quantity: '',
description: '',
environmentalConcerns: [],
});
const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [uploadProgress, setUploadProgress] = useState<string>('');
const totalSteps = 4;
const handlePhotosChange = (photos: PhotoMetadata[]) => {
setFormData(prev => ({ ...prev, photos }));
if (photos.length > 0 && errors.photos) {
setErrors(prev => ({ ...prev, photos: undefined }));
}
};
const handleLocationChange = (location: Coordinates, source: LocationSource, accuracy?: number) => {
setFormData(prev => ({
...prev,
location,
locationSource: source,
locationAccuracy: accuracy,
}));
if (errors.location) {
setErrors(prev => ({ ...prev, location: undefined }));
}
};
const validateStep = (step: number): boolean => {
const newErrors: FormErrors = {};
if (step === 1) {
if (formData.photos.length === 0) {
newErrors.photos = 'At least one photo is required';
}
}
if (step === 2) {
if (!formData.location) {
newErrors.location = 'Location is required';
}
}
if (step === 3) {
if (!formData.litterType) {
newErrors.litterType = 'Please select a litter type';
}
if (!formData.quantity) {
newErrors.quantity = 'Please select a quantity level';
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleNext = () => {
if (validateStep(currentStep)) {
setCurrentStep(prev => Math.min(prev + 1, totalSteps));
}
};
const handleBack = () => {
setCurrentStep(prev => Math.max(prev - 1, 1));
};
const handleSubmit = async () => {
if (!validateStep(currentStep)) {
return;
}
if (!formData.location || !formData.locationSource || !formData.litterType || !formData.quantity) {
setSubmitError('Please complete all required fields');
return;
}
setIsSubmitting(true);
setSubmitError(null);
try {
// Step 1: Upload photos
setUploadProgress('Uploading photos...');
const photoUrls: string[] = [];
for (let i = 0; i < formData.photos.length; i++) {
const photo = formData.photos[i];
if (!photo) continue;
setUploadProgress(`Uploading photo ${i + 1} of ${formData.photos.length}...`);
const uploadResult = await reportService.uploadPhoto(photo.file);
photoUrls.push(uploadResult.url);
}
// Step 2: Submit report
setUploadProgress('Creating report...');
const reportData = {
latitude: formData.location!.latitude,
longitude: formData.location!.longitude,
locationSource: formData.locationSource!,
litterType: formData.litterType as LitterType,
quantity: formData.quantity as QuantityLevel,
description: formData.description || undefined,
photoUrls,
photoTimestamp: formData.photos[0]?.timestamp?.toISOString(),
environmentalConcerns: formData.environmentalConcerns.length > 0
? formData.environmentalConcerns.filter(c => c.type && c.description)
: undefined,
};
await reportService.createReport(reportData);
setUploadProgress('Report submitted successfully!');
// Show success message
setTimeout(() => {
// Reset form
setFormData({
photos: [],
location: null,
locationSource: null,
litterType: '',
quantity: '',
description: '',
environmentalConcerns: [],
});
setCurrentStep(1);
setUploadProgress('');
// Navigate to map page to see the new report
navigate('/map');
}, 1500);
} catch (error) {
console.error('Error submitting report:', error);
setSubmitError(error instanceof Error ? error.message : 'Failed to submit report. Please try again.');
setUploadProgress('');
} finally {
setIsSubmitting(false);
}
};
const addEnvironmentalConcern = () => {
setFormData(prev => ({
...prev,
environmentalConcerns: [
...prev.environmentalConcerns,
{ type: '', severity: 'medium', description: '' },
],
}));
};
const updateEnvironmentalConcern = (index: number, field: keyof EnvironmentalConcern, value: string) => {
setFormData(prev => ({
...prev,
environmentalConcerns: prev.environmentalConcerns.map((concern, i) =>
i === index ? { ...concern, [field]: value } : concern
),
}));
};
const removeEnvironmentalConcern = (index: number) => {
setFormData(prev => ({
...prev,
environmentalConcerns: prev.environmentalConcerns.filter((_, i) => i !== index),
}));
};
return (
<div className="max-w-3xl mx-auto">
{/* Progress Indicator */}
<div className="mb-8">
<div className="flex items-center justify-between">
{[1, 2, 3, 4].map((step) => (
<div key={step} className="flex items-center flex-1">
<div className="flex flex-col items-center flex-1">
<div
className={`
w-10 h-10 rounded-full flex items-center justify-center font-semibold text-sm
${step < currentStep
? 'bg-green-500 text-white'
: step === currentStep
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-600'
}
`}
>
{step < currentStep ? (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
) : (
step
)}
</div>
<div className="mt-2 text-xs font-medium text-gray-600 text-center">
{step === 1 && 'Photos'}
{step === 2 && 'Location'}
{step === 3 && 'Details'}
{step === 4 && 'Review'}
</div>
</div>
{step < totalSteps && (
<div
className={`
h-1 flex-1 mx-2
${step < currentStep ? 'bg-green-500' : 'bg-gray-200'}
`}
/>
)}
</div>
))}
</div>
</div>
{/* Form Content */}
<div className="bg-white rounded-lg shadow-lg p-6 md:p-8">
{/* Step 1: Photos */}
{currentStep === 1 && (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Upload Photos</h2>
<p className="text-gray-600">
Take or upload photos of the litter. Photos with GPS data will auto-populate the location.
</p>
</div>
<PhotoUpload onPhotosChange={handlePhotosChange} />
{errors.photos && (
<p className="text-sm text-red-600">{errors.photos}</p>
)}
</div>
)}
{/* Step 2: Location */}
{currentStep === 2 && (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Select Location</h2>
<p className="text-gray-600">
Confirm or select the location where the litter was found.
</p>
</div>
<LocationSelector
photos={formData.photos}
onLocationChange={handleLocationChange}
initialLocation={formData.location || undefined}
/>
{errors.location && (
<p className="text-sm text-red-600">{errors.location}</p>
)}
</div>
)}
{/* Step 3: Litter Details */}
{currentStep === 3 && (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Litter Details</h2>
<p className="text-gray-600">
Provide information about the type and quantity of litter.
</p>
</div>
{/* Litter Type */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Litter Type <span className="text-red-500">*</span>
</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{LITTER_TYPES.map((type) => (
<button
key={type.value}
type="button"
onClick={() => {
setFormData(prev => ({ ...prev, litterType: type.value }));
if (errors.litterType) {
setErrors(prev => ({ ...prev, litterType: undefined }));
}
}}
className={`
text-left p-4 rounded-lg border-2 transition-all
${formData.litterType === type.value
? 'border-blue-600 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}
`}
>
<div className="font-semibold text-gray-900">{type.label}</div>
<div className="text-sm text-gray-600 mt-1">{type.description}</div>
</button>
))}
</div>
{errors.litterType && (
<p className="text-sm text-red-600 mt-2">{errors.litterType}</p>
)}
</div>
{/* Quantity Level */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Quantity Level <span className="text-red-500">*</span>
</label>
<div className="space-y-3">
{QUANTITY_LEVELS.map((level, index) => (
<button
key={level.value}
type="button"
onClick={() => {
setFormData(prev => ({ ...prev, quantity: level.value }));
if (errors.quantity) {
setErrors(prev => ({ ...prev, quantity: undefined }));
}
}}
className={`
w-full text-left p-4 rounded-lg border-2 transition-all flex items-center gap-4
${formData.quantity === level.value
? 'border-blue-600 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}
`}
>
{/* Visual Indicator */}
<div className="flex gap-1">
{[...Array(4)].map((_, i) => (
<div
key={i}
className={`
w-3 h-8 rounded
${i <= index
? formData.quantity === level.value
? 'bg-blue-600'
: 'bg-gray-400'
: 'bg-gray-200'
}
`}
/>
))}
</div>
<div className="flex-1">
<div className="font-semibold text-gray-900">{level.label}</div>
<div className="text-sm text-gray-600">{level.description}</div>
</div>
</button>
))}
</div>
{errors.quantity && (
<p className="text-sm text-red-600 mt-2">{errors.quantity}</p>
)}
</div>
{/* Description */}
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-2">
Additional Description (Optional)
</label>
<textarea
id="description"
rows={4}
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
placeholder="Provide any additional details about the litter, its condition, or the surrounding area..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
maxLength={500}
/>
<div className="text-sm text-gray-500 mt-1 text-right">
{formData.description.length}/500 characters
</div>
</div>
{/* Environmental Concerns */}
<div>
<div className="flex items-center justify-between mb-3">
<label className="block text-sm font-medium text-gray-700">
Environmental Concerns (Optional)
</label>
<button
type="button"
onClick={addEnvironmentalConcern}
className="text-sm text-blue-600 hover:text-blue-700 font-medium flex items-center gap-1"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add Concern
</button>
</div>
{formData.environmentalConcerns.length === 0 ? (
<p className="text-sm text-gray-500 italic">
No environmental concerns added. Click "Add Concern" to report specific hazards or issues.
</p>
) : (
<div className="space-y-4">
{formData.environmentalConcerns.map((concern, index) => (
<div key={index} className="border border-gray-200 rounded-lg p-4 space-y-3">
<div className="flex items-start justify-between">
<h4 className="text-sm font-medium text-gray-900">Concern {index + 1}</h4>
<button
type="button"
onClick={() => removeEnvironmentalConcern(index)}
className="text-red-600 hover:text-red-700"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Type</label>
<select
value={concern.type}
onChange={(e) => updateEnvironmentalConcern(index, 'type', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
>
<option value="">Select type...</option>
{CONCERN_TYPES.map((type) => (
<option key={type} value={type}>{type}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Severity</label>
<div className="grid grid-cols-4 gap-2">
{(['low', 'medium', 'high', 'critical'] as const).map((severity) => (
<button
key={severity}
type="button"
onClick={() => updateEnvironmentalConcern(index, 'severity', severity)}
className={`
px-3 py-2 rounded-lg text-xs font-medium capitalize transition-colors
${concern.severity === severity
? severity === 'low' ? 'bg-green-600 text-white'
: severity === 'medium' ? 'bg-yellow-600 text-white'
: severity === 'high' ? 'bg-orange-600 text-white'
: 'bg-red-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}
`}
>
{severity}
</button>
))}
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Description</label>
<textarea
value={concern.description}
onChange={(e) => updateEnvironmentalConcern(index, 'description', e.target.value)}
placeholder="Describe the environmental concern..."
rows={2}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none text-sm"
maxLength={200}
/>
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* Step 4: Review */}
{currentStep === 4 && (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Review Your Report</h2>
<p className="text-gray-600">
Please review all information before submitting.
</p>
</div>
{/* Error Message */}
{submitError && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
<svg className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
<div>
<h3 className="font-medium text-red-900">Submission Error</h3>
<p className="text-sm text-red-700 mt-1">{submitError}</p>
</div>
</div>
)}
{/* Upload Progress */}
{uploadProgress && (
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg flex items-center gap-3">
<svg className="animate-spin h-5 w-5 text-blue-600" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<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" />
</svg>
<p className="text-sm text-blue-900 font-medium">{uploadProgress}</p>
</div>
)}
{/* Photos Summary */}
<div className="border-t border-gray-200 pt-4">
<h3 className="text-lg font-semibold text-gray-900 mb-3">Photos</h3>
<div className="grid grid-cols-3 md:grid-cols-4 gap-3">
{formData.photos.map((photo, index) => (
<img
key={index}
src={photo.preview}
alt={`Upload ${index + 1}`}
className="w-full h-24 object-cover rounded-lg border border-gray-200"
/>
))}
</div>
</div>
{/* Location Summary */}
<div className="border-t border-gray-200 pt-4">
<h3 className="text-lg font-semibold text-gray-900 mb-3">Location</h3>
{formData.location && formData.locationSource && (
<div className="bg-gray-50 rounded-lg p-4 space-y-2 text-sm">
<p>
<span className="font-medium">Coordinates:</span>{' '}
{formData.location.latitude.toFixed(6)}, {formData.location.longitude.toFixed(6)}
</p>
<p>
<span className="font-medium">Source:</span>{' '}
{formData.locationSource === 'exif' ? 'Photo EXIF data' :
formData.locationSource === 'gps' ? 'Device GPS' : 'Manual selection'}
</p>
</div>
)}
</div>
{/* Litter Details Summary */}
<div className="border-t border-gray-200 pt-4">
<h3 className="text-lg font-semibold text-gray-900 mb-3">Litter Details</h3>
<div className="bg-gray-50 rounded-lg p-4 space-y-2 text-sm">
<p>
<span className="font-medium">Type:</span>{' '}
{LITTER_TYPES.find(t => t.value === formData.litterType)?.label}
</p>
<p>
<span className="font-medium">Quantity:</span>{' '}
{QUANTITY_LEVELS.find(q => q.value === formData.quantity)?.label}
</p>
{formData.description && (
<p>
<span className="font-medium">Description:</span>{' '}
{formData.description}
</p>
)}
</div>
</div>
{/* Environmental Concerns Summary */}
{formData.environmentalConcerns.length > 0 && (
<div className="border-t border-gray-200 pt-4">
<h3 className="text-lg font-semibold text-gray-900 mb-3">Environmental Concerns</h3>
<div className="space-y-3">
{formData.environmentalConcerns.map((concern, index) => (
<div key={index} className="bg-gray-50 rounded-lg p-4 text-sm">
<div className="flex items-center gap-2 mb-2">
<span className="font-medium">{concern.type}</span>
<span
className={`
px-2 py-1 rounded text-xs font-medium capitalize
${concern.severity === 'low' ? 'bg-green-100 text-green-800' :
concern.severity === 'medium' ? 'bg-yellow-100 text-yellow-800' :
concern.severity === 'high' ? 'bg-orange-100 text-orange-800' :
'bg-red-100 text-red-800'}
`}
>
{concern.severity}
</span>
</div>
{concern.description && (
<p className="text-gray-700">{concern.description}</p>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Navigation Buttons */}
<div className="flex justify-between mt-8 pt-6 border-t border-gray-200">
<button
type="button"
onClick={handleBack}
disabled={currentStep === 1}
className="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 font-medium hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Back
</button>
{currentStep < totalSteps ? (
<button
type="button"
onClick={handleNext}
className="px-6 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
>
Next
</button>
) : (
<button
type="button"
onClick={handleSubmit}
disabled={isSubmitting}
className="px-6 py-2 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
>
{isSubmitting ? (
<>
<svg className="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<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" />
</svg>
Submitting...
</>
) : (
'Submit Report'
)}
</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,271 @@
# Report Submission and Display Implementation
This document describes the implementation of report submission and display functionality for Task 9.
## Components Implemented
### 1. Report Service (`src/services/reportService.ts`)
A service layer for handling all report-related API interactions:
**Features:**
- Photo upload with multipart/form-data
- Report creation with full metadata
- Report retrieval (single and list with filters)
- Verification and dispute actions
- Report deletion
**Key Methods:**
- `uploadPhoto(file: File)` - Uploads a single photo and returns URL
- `createReport(data)` - Creates a new litter report
- `getReport(id)` - Fetches a single report by ID
- `getReports(filters?)` - Fetches reports with optional filtering
- `verifyReport(id, comment?)` - Marks a report as verified
- `disputeReport(id, comment?)` - Disputes a report
- `deleteReport(id)` - Deletes a report
### 2. Report Detail Modal (`src/components/report/ReportDetailModal.tsx`)
A comprehensive modal component for displaying detailed report information:
**Features:**
- Photo gallery with navigation (prev/next buttons)
- Photo counter for multiple images
- Verification status indicators (verified, pending, disputed)
- Reporter information with avatar
- Litter type and quantity display
- Location coordinates with source information
- Photo timestamp display
- Description and environmental concerns
- Verification statistics (verification count, dispute count)
- Interactive verification/dispute actions with comment forms
**Props:**
```typescript
interface ReportDetailModalProps {
report: ReportResponseDTO;
isOpen: boolean;
onClose: () => void;
onVerify?: (reportId: string) => void;
onDispute?: (reportId: string) => void;
}
```
**Visual Features:**
- Color-coded verification status badges
- Responsive photo gallery with touch-friendly controls
- Severity indicators for environmental concerns
- Clean, accessible UI with proper contrast
### 3. Updated Report Form (`src/components/report/ReportForm.tsx`)
Enhanced the existing form with actual API submission:
**New Features:**
- Photo upload progress tracking
- Multi-step photo upload (shows "Uploading photo X of Y...")
- Error handling with user-friendly messages
- Success state with automatic navigation to map
- Form reset after successful submission
- Integration with report service
**Submission Flow:**
1. Validate all required fields
2. Upload photos one by one (with progress feedback)
3. Create report with photo URLs and metadata
4. Show success message
5. Navigate to map page to view new report
**Error Handling:**
- Network errors
- Upload failures
- Validation errors
- User-friendly error messages displayed in UI
## Demo Page
### Report Detail Demo (`src/pages/ReportDetailDemo.tsx`)
A demonstration page showcasing the ReportDetailModal with three example reports:
1. **Verified Report** - Shows a report with multiple verifications and environmental concerns
2. **Pending Report** - Shows a newly submitted report awaiting verification
3. **Disputed Report** - Shows a report with dispute flags
**Access:** Navigate to `/demo/report-detail` to view the demo
## API Integration
### Expected Backend Endpoints
The implementation expects the following API endpoints:
```
POST /api/reports/upload # Upload photo
POST /api/reports # Create report
GET /api/reports/:id # Get single report
GET /api/reports # List reports with filters
POST /api/reports/:id/verify # Verify report
POST /api/reports/:id/dispute # Dispute report
DELETE /api/reports/:id # Delete report
```
### Request/Response Formats
**Upload Photo:**
```typescript
// Request: multipart/form-data with 'photo' field
// Response:
{
url: string;
thumbnailUrl?: string;
}
```
**Create Report:**
```typescript
// Request:
{
latitude: number;
longitude: number;
locationSource: 'exif' | 'gps' | 'manual';
litterType: string;
quantity: string;
description?: string;
photoUrls: string[];
photoTimestamp?: string;
environmentalConcerns?: Array<{
type: string;
severity: 'low' | 'medium' | 'high' | 'critical';
description: string;
}>;
}
// Response: ReportResponseDTO
```
## Usage Examples
### Using Report Service
```typescript
import { reportService } from '@/services/reportService';
// Upload a photo
const result = await reportService.uploadPhoto(file);
console.log('Photo URL:', result.url);
// Create a report
const report = await reportService.createReport({
latitude: 53.3498,
longitude: -6.2603,
locationSource: 'gps',
litterType: 'plastic',
quantity: 'significant',
photoUrls: ['https://example.com/photo.jpg'],
description: 'Large amount of plastic bottles',
});
// Get reports with filters
const reports = await reportService.getReports({
litterTypes: ['plastic', 'metal'],
dateRange: {
start: '2024-01-01',
end: '2024-12-31',
},
});
```
### Using Report Detail Modal
```typescript
import { ReportDetailModal } from '@/components/report';
function MyComponent() {
const [selectedReport, setSelectedReport] = useState<ReportResponseDTO | null>(null);
const [isOpen, setIsOpen] = useState(false);
const handleVerify = (reportId: string) => {
// Call API to verify report
reportService.verifyReport(reportId);
};
return (
<>
<button onClick={() => {
setSelectedReport(report);
setIsOpen(true);
}}>
View Report
</button>
{selectedReport && (
<ReportDetailModal
report={selectedReport}
isOpen={isOpen}
onClose={() => setIsOpen(false)}
onVerify={handleVerify}
onDispute={handleDispute}
/>
)}
</>
);
}
```
## Requirements Fulfilled
This implementation fulfills the following requirements from the spec:
### Requirement 1.8
- ✅ Records submission timestamp in ISO 8601 format
- ✅ Handles all report metadata (photos, location, type, quantity, description)
### Requirement 7.4
- ✅ Displays verification status indicators (verified, pending, disputed)
- ✅ Shows verification and dispute counts
- ✅ Provides UI for verification actions
- ✅ Includes comment functionality for verification/dispute
## Testing
To test the implementation:
1. **Report Submission:**
- Navigate to `/reports/new`
- Complete the multi-step form
- Submit and observe upload progress
- Verify navigation to map page
2. **Report Display:**
- Navigate to `/demo/report-detail`
- Click on different report cards
- Test photo gallery navigation
- Try verification/dispute actions
3. **Error Handling:**
- Test with backend unavailable
- Test with invalid data
- Verify error messages display correctly
## Future Enhancements
Potential improvements for future iterations:
1. **Optimistic Updates:** Update UI before API confirmation
2. **Offline Support:** Queue submissions when offline
3. **Image Compression:** Compress photos before upload
4. **Batch Upload:** Upload multiple photos in parallel
5. **Progress Bar:** Visual progress bar for uploads
6. **Report Editing:** Allow users to edit their reports
7. **Share Functionality:** Share reports via social media
8. **Print View:** Printable report format
## Notes
- The implementation uses mock data in the demo page
- Backend API endpoints need to be implemented separately
- Authentication is handled by Clerk (tokens automatically included)
- All dates are handled in ISO 8601 format
- Photo uploads support JPEG, PNG, and WebP formats
- Maximum photo size is 5MB per file

View File

@@ -1,3 +1,5 @@
export { PhotoUpload } from './PhotoUpload';
export type { PhotoMetadata } from './PhotoUpload';
export { LocationSelector } from './LocationSelector';
export { ReportForm } from './ReportForm';
export { ReportDetailModal } from './ReportDetailModal';
export type { PhotoMetadata } from './PhotoUpload';

View File

@@ -6,53 +6,74 @@ import { Link } from 'react-router-dom';
*/
export function HomePage() {
return (
<div className="max-w-7xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
<div className="text-center">
<h2 className="text-4xl font-extrabold text-gray-900 sm:text-5xl">
<div
className="min-h-screen bg-cover bg-center bg-no-repeat bg-fixed"
style={{ backgroundImage: 'url(/IMG_6703.jpeg)' }}
>
{/* Full page overlay for better text readability */}
<div className="min-h-screen bg-black/40">
{/* Hero Section */}
<div className="relative z-10 max-w-7xl mx-auto py-24 px-4 sm:px-6 lg:px-8 text-center">
<h2 className="text-4xl font-extrabold text-white sm:text-5xl md:text-6xl drop-shadow-lg">
Track and Clean Ireland's Beaches
</h2>
<p className="mt-4 text-xl text-gray-600 max-w-3xl mx-auto">
<p className="mt-6 text-xl text-white max-w-3xl mx-auto drop-shadow-md">
Join our community in mapping beach litter, organizing clean-up events,
and protecting our coastal environment through data-driven action.
</p>
<SignedOut>
<div className="mt-8 flex justify-center space-x-4">
<div className="mt-10 flex flex-wrap justify-center gap-4">
<Link
to="/sign-up"
className="px-8 py-3 border border-transparent text-base font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 md:py-4 md:text-lg md:px-10"
className="px-8 py-3 border border-transparent text-base font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 md:py-4 md:text-lg md:px-10 shadow-lg transition-all hover:scale-105"
>
Get Started
</Link>
<Link
to="/map"
className="px-8 py-3 border border-gray-300 text-base font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 md:py-4 md:text-lg md:px-10"
className="px-8 py-3 border-2 border-white text-base font-medium rounded-md text-white bg-white/10 backdrop-blur-sm hover:bg-white/20 md:py-4 md:text-lg md:px-10 shadow-lg transition-all hover:scale-105"
>
View Map
</Link>
<Link
to="/why-clean"
className="px-8 py-3 border-2 border-white text-base font-medium rounded-md text-white bg-white/10 backdrop-blur-sm hover:bg-white/20 md:py-4 md:text-lg md:px-10 shadow-lg transition-all hover:scale-105"
>
Why Clean Our Beaches?
</Link>
</div>
</SignedOut>
<SignedIn>
<div className="mt-8 flex justify-center space-x-4">
<div className="mt-10 flex flex-wrap justify-center gap-4">
<Link
to="/reports/new"
className="px-8 py-3 border border-transparent text-base font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 md:py-4 md:text-lg md:px-10"
className="px-8 py-3 border border-transparent text-base font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 md:py-4 md:text-lg md:px-10 shadow-lg transition-all hover:scale-105"
>
Report Litter
</Link>
<Link
to="/map"
className="px-8 py-3 border border-gray-300 text-base font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 md:py-4 md:text-lg md:px-10"
className="px-8 py-3 border-2 border-white text-base font-medium rounded-md text-white bg-white/10 backdrop-blur-sm hover:bg-white/20 md:py-4 md:text-lg md:px-10 shadow-lg transition-all hover:scale-105"
>
View Map
</Link>
<Link
to="/why-clean"
className="px-8 py-3 border-2 border-white text-base font-medium rounded-md text-white bg-white/10 backdrop-blur-sm hover:bg-white/20 md:py-4 md:text-lg md:px-10 shadow-lg transition-all hover:scale-105"
>
Why Clean Our Beaches?
</Link>
</div>
</SignedIn>
</div>
<div className="mt-16 grid grid-cols-1 gap-8 md:grid-cols-3">
<div className="bg-white p-6 rounded-lg shadow">
{/* Features Section */}
<div className="max-w-7xl mx-auto py-16 px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
<div className="bg-white/95 backdrop-blur-sm p-6 rounded-lg shadow-lg hover:shadow-xl transition-shadow">
<div className="text-blue-600 text-3xl mb-4">📍</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Report Litter
@@ -63,7 +84,7 @@ export function HomePage() {
</p>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<div className="bg-white/95 backdrop-blur-sm p-6 rounded-lg shadow-lg hover:shadow-xl transition-shadow">
<div className="text-blue-600 text-3xl mb-4">🗺️</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Interactive Maps
@@ -74,7 +95,7 @@ export function HomePage() {
</p>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<div className="bg-white/95 backdrop-blur-sm p-6 rounded-lg shadow-lg hover:shadow-xl transition-shadow">
<div className="text-blue-600 text-3xl mb-4">🤝</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Organize Events
@@ -86,5 +107,7 @@ export function HomePage() {
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,223 @@
// Demo page for ReportDetailModal component
import { useState } from 'react';
import { ReportDetailModal } from '@/components/report';
import type { ReportResponseDTO } from '@/types';
// Mock report data for demonstration
const mockReport: ReportResponseDTO = {
id: 'demo-report-1',
userId: 'user-123',
latitude: 53.3498,
longitude: -6.2603,
locationSource: 'gps',
photoUrls: [
'https://images.unsplash.com/photo-1621451537084-482c73073a0f?w=800',
'https://images.unsplash.com/photo-1618477461853-cf6ed80faba5?w=800',
],
photoTimestamp: new Date('2024-11-15T14:30:00Z'),
litterType: 'plastic',
quantity: 'significant',
description: 'Large amount of plastic bottles and bags scattered along the shoreline. Some items appear to be recent, while others show signs of weathering. The area is popular with tourists and locals.',
createdAt: new Date('2024-11-15T15:00:00Z'),
updatedAt: new Date('2024-11-15T15:00:00Z'),
submittedAt: new Date('2024-11-15T15:00:00Z'),
verificationStatus: 'verified',
user: {
id: 'user-123',
username: 'beachguardian',
avatarUrl: 'https://api.dicebear.com/7.x/avataaars/svg?seed=beachguardian',
},
verificationCount: 5,
disputeCount: 0,
environmentalConcerns: [
{
id: 'concern-1',
reportId: 'demo-report-1',
concernType: 'Wildlife hazard',
severity: 'high',
description: 'Plastic bags pose entanglement risk for seabirds and marine life in the area.',
createdAt: new Date('2024-11-15T15:00:00Z'),
},
],
};
const mockReportPending: ReportResponseDTO = {
...mockReport,
id: 'demo-report-2',
verificationStatus: 'pending',
verificationCount: 0,
disputeCount: 0,
description: 'Small collection of glass bottles near the beach entrance.',
litterType: 'glass',
quantity: 'minimal',
environmentalConcerns: [],
};
const mockReportDisputed: ReportResponseDTO = {
...mockReport,
id: 'demo-report-3',
verificationStatus: 'disputed',
verificationCount: 2,
disputeCount: 3,
description: 'Hazardous waste containers found near the water.',
litterType: 'hazardous',
quantity: 'moderate',
};
export function ReportDetailDemo() {
const [selectedReport, setSelectedReport] = useState<ReportResponseDTO | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const handleOpenModal = (report: ReportResponseDTO) => {
setSelectedReport(report);
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
};
const handleVerify = (reportId: string) => {
console.log('Verifying report:', reportId);
alert(`Report ${reportId} verified!`);
setIsModalOpen(false);
};
const handleDispute = (reportId: string) => {
console.log('Disputing report:', reportId);
alert(`Report ${reportId} disputed!`);
setIsModalOpen(false);
};
return (
<div className="py-6 px-4 sm:px-6 lg:px-8">
<div className="max-w-6xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Report Detail Modal Demo</h1>
<p className="mt-2 text-gray-600">
Click on any report card to view its details in the modal.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Verified Report Card */}
<button
onClick={() => handleOpenModal(mockReport)}
className="bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow p-6 text-left border-2 border-transparent hover:border-blue-500"
>
<div className="flex items-center justify-between mb-4">
<span className="px-3 py-1 bg-green-100 text-green-800 rounded-full text-sm font-medium">
Verified
</span>
<span className="text-sm text-gray-500">5 verifications</span>
</div>
<img
src={mockReport.photoUrls[0]}
alt="Report preview"
className="w-full h-48 object-cover rounded-lg mb-4"
/>
<h3 className="font-semibold text-gray-900 mb-2">Plastic Litter - Significant</h3>
<p className="text-sm text-gray-600 line-clamp-2 mb-3">
{mockReport.description}
</p>
<div className="flex items-center gap-2 text-sm text-gray-500">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>Dublin Bay</span>
</div>
</button>
{/* Pending Report Card */}
<button
onClick={() => handleOpenModal(mockReportPending)}
className="bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow p-6 text-left border-2 border-transparent hover:border-blue-500"
>
<div className="flex items-center justify-between mb-4">
<span className="px-3 py-1 bg-yellow-100 text-yellow-800 rounded-full text-sm font-medium">
Pending
</span>
<span className="text-sm text-gray-500">0 verifications</span>
</div>
<img
src={mockReportPending.photoUrls[0]}
alt="Report preview"
className="w-full h-48 object-cover rounded-lg mb-4"
/>
<h3 className="font-semibold text-gray-900 mb-2">Glass Litter - Minimal</h3>
<p className="text-sm text-gray-600 line-clamp-2 mb-3">
{mockReportPending.description}
</p>
<div className="flex items-center gap-2 text-sm text-gray-500">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>Dublin Bay</span>
</div>
</button>
{/* Disputed Report Card */}
<button
onClick={() => handleOpenModal(mockReportDisputed)}
className="bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow p-6 text-left border-2 border-transparent hover:border-blue-500"
>
<div className="flex items-center justify-between mb-4">
<span className="px-3 py-1 bg-red-100 text-red-800 rounded-full text-sm font-medium">
Disputed
</span>
<span className="text-sm text-gray-500">3 disputes</span>
</div>
<img
src={mockReportDisputed.photoUrls[0]}
alt="Report preview"
className="w-full h-48 object-cover rounded-lg mb-4"
/>
<h3 className="font-semibold text-gray-900 mb-2">Hazardous Litter - Moderate</h3>
<p className="text-sm text-gray-600 line-clamp-2 mb-3">
{mockReportDisputed.description}
</p>
<div className="flex items-center gap-2 text-sm text-gray-500">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>Dublin Bay</span>
</div>
</button>
</div>
{/* Info Section */}
<div className="mt-12 bg-blue-50 border border-blue-200 rounded-lg p-6">
<h2 className="text-lg font-semibold text-blue-900 mb-3">About This Demo</h2>
<div className="space-y-2 text-sm text-blue-800">
<p>
<strong>Verified Report:</strong> Shows a report with multiple verifications and environmental concerns.
</p>
<p>
<strong>Pending Report:</strong> Shows a newly submitted report awaiting community verification.
</p>
<p>
<strong>Disputed Report:</strong> Shows a report that has received dispute flags from the community.
</p>
<p className="mt-4">
Click any card to open the detailed modal view with photo gallery, verification actions, and full report information.
</p>
</div>
</div>
</div>
{/* Report Detail Modal */}
{selectedReport && (
<ReportDetailModal
report={selectedReport}
isOpen={isModalOpen}
onClose={handleCloseModal}
onVerify={handleVerify}
onDispute={handleDispute}
/>
)}
</div>
);
}

View File

@@ -1,15 +1,15 @@
/**
* Report form page - placeholder for task 6-9
*/
import { ReportForm } from '@/components/report';
export function ReportFormPage() {
return (
<div className="max-w-3xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Report Litter</h2>
<div className="bg-white rounded-lg shadow p-8">
<p className="text-gray-500">
Report form will be implemented in tasks 6-9
<div className="py-6 px-4 sm:px-6 lg:px-8">
<div className="max-w-3xl mx-auto mb-6">
<h1 className="text-3xl font-bold text-gray-900">Report Beach Litter</h1>
<p className="mt-2 text-gray-600">
Help us track and combat beach pollution by reporting litter you find along Irish coastlines.
</p>
</div>
<ReportForm />
</div>
);
}

236
src/pages/WhyCleanPage.tsx Normal file
View File

@@ -0,0 +1,236 @@
/**
* Why Clean Our Beaches page - Environmental facts and impact information
*/
export function WhyCleanPage() {
return (
<div
className="min-h-screen bg-cover bg-center bg-fixed"
style={{ backgroundImage: 'url(/IMG_6703.jpeg)' }}
>
<div className="min-h-screen bg-gradient-to-b from-blue-900/80 via-blue-800/85 to-blue-900/80">
<div className="max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className="text-center mb-16">
<h1 className="text-5xl font-extrabold text-white sm:text-6xl mb-6 drop-shadow-lg">
Why Clean Our Beaches?
</h1>
<p className="text-2xl text-blue-100 drop-shadow-md max-w-3xl mx-auto">
Every piece of litter removed is a step toward healthier oceans, thriving wildlife, and a sustainable future
</p>
</div>
{/* Environmental Impact Section */}
<div className="bg-white/95 backdrop-blur-md rounded-2xl shadow-2xl p-8 mb-8 transform hover:scale-[1.02] transition-transform duration-300">
<h2 className="text-3xl font-bold text-gray-900 mb-6 flex items-center">
<span className="text-5xl mr-4">🌊</span>
Our Oceans Are Drowning in Plastic
</h2>
<div className="space-y-4 text-gray-700 text-lg">
<p className="font-semibold text-xl text-blue-900">
Beach litter isn't just unsightly—it's deadly. Plastic waste takes hundreds of years to decompose,
breaking down into microplastics that poison our food chain.
</p>
<div className="bg-blue-50 border-l-4 border-blue-600 p-4 my-6">
<p className="font-bold text-blue-900 text-xl mb-2">The Reality:</p>
<ul className="space-y-3 ml-4">
<li className="flex items-start">
<span className="text-blue-600 mr-2 text-xl">•</span>
<span>Marine animals mistake plastic for food, leading to starvation and death</span>
</li>
<li className="flex items-start">
<span className="text-blue-600 mr-2 text-xl">•</span>
<span>Toxic chemicals leach into water, poisoning entire ecosystems</span>
</li>
<li className="flex items-start">
<span className="text-blue-600 mr-2 text-xl">•</span>
<span>Microplastics are now found in the fish we eat and the water we drink</span>
</li>
<li className="flex items-start">
<span className="text-blue-600 mr-2 text-xl">•</span>
<span>Nesting sites for birds and marine life are destroyed by debris</span>
</li>
</ul>
</div>
</div>
</div>
{/* Marine Life Section */}
<div className="bg-white/95 backdrop-blur-md rounded-2xl shadow-2xl p-8 mb-8 transform hover:scale-[1.02] transition-transform duration-300">
<h2 className="text-3xl font-bold text-gray-900 mb-6 flex items-center">
<span className="text-5xl mr-4">🐢</span>
100,000+ Lives Lost Every Year
</h2>
<div className="space-y-4 text-gray-700 text-lg">
<p className="font-semibold text-xl text-red-900">
Over 100,000 marine mammals and sea turtles die annually from plastic.
90% of seabirds now have plastic in their stomachs.
</p>
<div className="grid md:grid-cols-2 gap-4 my-6">
<div className="bg-gradient-to-br from-red-50 to-orange-50 border-l-4 border-red-500 p-4 rounded-r-lg">
<p className="font-bold text-red-900 mb-2">🎣 Entanglement</p>
<p className="text-sm">Fishing nets and lines trap and drown marine animals</p>
</div>
<div className="bg-gradient-to-br from-red-50 to-orange-50 border-l-4 border-red-500 p-4 rounded-r-lg">
<p className="font-bold text-red-900 mb-2">🥤 Ingestion</p>
<p className="text-sm">Turtles mistake plastic bags for jellyfish, leading to starvation</p>
</div>
<div className="bg-gradient-to-br from-red-50 to-orange-50 border-l-4 border-red-500 p-4 rounded-r-lg">
<p className="font-bold text-red-900 mb-2">☠️ Poisoning</p>
<p className="text-sm">Chemical pollutants disrupt reproduction and development</p>
</div>
<div className="bg-gradient-to-br from-red-50 to-orange-50 border-l-4 border-red-500 p-4 rounded-r-lg">
<p className="font-bold text-red-900 mb-2">👻 Ghost Gear</p>
<p className="text-sm">Abandoned fishing equipment kills for decades</p>
</div>
</div>
</div>
</div>
{/* Renewable Energy Section */}
<div className="bg-white/95 backdrop-blur-md rounded-2xl shadow-2xl p-8 mb-8 transform hover:scale-[1.02] transition-transform duration-300">
<h2 className="text-3xl font-bold text-gray-900 mb-6 flex items-center">
<span className="text-5xl mr-4">⚡</span>
Threatening Ireland's Green Future
</h2>
<div className="space-y-4 text-gray-700 text-lg">
<p className="font-semibold text-xl text-green-900">
Ireland is leading the way in wave power generation—but marine litter is sabotaging our renewable energy future.
</p>
<div className="bg-green-50 border-l-4 border-green-600 p-6 my-6">
<p className="font-bold text-green-900 text-xl mb-4">How Litter Damages Our Energy Infrastructure:</p>
<ul className="space-y-3">
<li className="flex items-start">
<span className="text-green-600 mr-3 text-2xl font-bold">⚙️</span>
<span><strong>Clogs turbines:</strong> Floating debris damages wave energy converters</span>
</li>
<li className="flex items-start">
<span className="text-green-600 mr-3 text-2xl font-bold">🔧</span>
<span><strong>Increases costs:</strong> Millions spent on debris removal and repairs</span>
</li>
<li className="flex items-start">
<span className="text-green-600 mr-3 text-2xl font-bold">📉</span>
<span><strong>Reduces efficiency:</strong> Fouled equipment generates less power</span>
</li>
<li className="flex items-start">
<span className="text-green-600 mr-3 text-2xl font-bold">⚠️</span>
<span><strong>Creates hazards:</strong> Installation and maintenance become dangerous</span>
</li>
</ul>
<p className="mt-4 font-semibold text-green-900">
Clean oceans aren't just good for wildlife—they're essential for Ireland's climate goals.
</p>
</div>
</div>
</div>
{/* Economic Impact Section */}
<div className="bg-white/95 backdrop-blur-md rounded-2xl shadow-2xl p-8 mb-8 transform hover:scale-[1.02] transition-transform duration-300">
<h2 className="text-3xl font-bold text-gray-900 mb-6 flex items-center">
<span className="text-5xl mr-4">💰</span>
Costing Communities Millions
</h2>
<div className="space-y-4 text-gray-700 text-lg">
<p className="font-semibold text-xl text-purple-900">
Polluted beaches don't just hurt nature—they devastate local economies and livelihoods.
</p>
<div className="grid md:grid-cols-3 gap-4 my-6">
<div className="bg-gradient-to-br from-purple-50 to-pink-50 p-5 rounded-xl text-center">
<p className="text-4xl mb-2">🏖️</p>
<p className="font-bold text-purple-900 text-lg">Tourism Loss</p>
<p className="text-sm mt-2">Visitors avoid polluted beaches</p>
</div>
<div className="bg-gradient-to-br from-purple-50 to-pink-50 p-5 rounded-xl text-center">
<p className="text-4xl mb-2">🎣</p>
<p className="font-bold text-purple-900 text-lg">Fishing Impact</p>
<p className="text-sm mt-2">Contaminated catches, damaged gear</p>
</div>
<div className="bg-gradient-to-br from-purple-50 to-pink-50 p-5 rounded-xl text-center">
<p className="text-4xl mb-2">🧹</p>
<p className="font-bold text-purple-900 text-lg">Cleanup Costs</p>
<p className="text-sm mt-2">Millions spent annually by councils</p>
</div>
</div>
</div>
</div>
{/* Human Health Section */}
<div className="bg-white/95 backdrop-blur-md rounded-2xl shadow-2xl p-8 mb-8 transform hover:scale-[1.02] transition-transform duration-300">
<h2 className="text-3xl font-bold text-gray-900 mb-6 flex items-center">
<span className="text-5xl mr-4">🏥</span>
It's Personal: Your Health at Risk
</h2>
<div className="space-y-4 text-gray-700 text-lg">
<p className="font-semibold text-xl text-orange-900">
Beach litter isn't just an environmental issue—it's a direct threat to you and your family.
</p>
<div className="bg-orange-50 border-l-4 border-orange-600 p-6 my-6">
<p className="font-bold text-orange-900 text-xl mb-4">The Dangers Are Real:</p>
<ul className="space-y-3">
<li className="flex items-start">
<span className="text-orange-600 mr-3 text-xl">🔪</span>
<span><strong>Injuries:</strong> Broken glass and sharp metal cause cuts and infections</span>
</li>
<li className="flex items-start">
<span className="text-orange-600 mr-3 text-xl">🍽️</span>
<span><strong>Food contamination:</strong> Microplastics are now in the seafood we eat</span>
</li>
<li className="flex items-start">
<span className="text-orange-600 mr-3 text-xl">🦠</span>
<span><strong>Disease:</strong> Bacteria and pathogens thrive on marine debris</span>
</li>
<li className="flex items-start">
<span className="text-orange-600 mr-3 text-xl">⚗️</span>
<span><strong>Chemical exposure:</strong> Toxic pollutants cause hormonal disruption</span>
</li>
</ul>
</div>
</div>
</div>
{/* What You Can Do Section */}
<div className="bg-gradient-to-br from-blue-600 via-blue-700 to-blue-800 rounded-2xl shadow-2xl p-10 text-white transform hover:scale-[1.02] transition-transform duration-300">
<h2 className="text-4xl font-bold mb-6 flex items-center justify-center">
<span className="text-5xl mr-4">🤝</span>
You Have the Power to Change This
</h2>
<div className="space-y-6">
<p className="text-xl text-center font-semibold text-blue-100">
Every action counts. Every piece of litter removed saves a life. Here's how you can be part of the solution:
</p>
<div className="grid md:grid-cols-2 gap-4 my-8">
<div className="bg-white/10 backdrop-blur-sm p-5 rounded-xl border border-white/20">
<p className="text-2xl mb-2">📱</p>
<p className="font-bold text-lg mb-2">Report Litter</p>
<p className="text-blue-100 text-sm">Use this platform to document and track pollution patterns</p>
</div>
<div className="bg-white/10 backdrop-blur-sm p-5 rounded-xl border border-white/20">
<p className="text-2xl mb-2">🧹</p>
<p className="font-bold text-lg mb-2">Join Cleanups</p>
<p className="text-blue-100 text-sm">Participate in organized beach cleanup events</p>
</div>
<div className="bg-white/10 backdrop-blur-sm p-5 rounded-xl border border-white/20">
<p className="text-2xl mb-2">♻️</p>
<p className="font-bold text-lg mb-2">Reduce Plastic</p>
<p className="text-blue-100 text-sm">Cut single-use plastics from your daily life</p>
</div>
<div className="bg-white/10 backdrop-blur-sm p-5 rounded-xl border border-white/20">
<p className="text-2xl mb-2">📢</p>
<p className="font-bold text-lg mb-2">Spread the Word</p>
<p className="text-blue-100 text-sm">Educate others about marine litter impact</p>
</div>
</div>
<div className="bg-yellow-400 text-gray-900 p-6 rounded-xl text-center mt-8">
<p className="text-2xl font-bold mb-2">
Together, We Can Save Our Beaches
</p>
<p className="text-lg">
Start making a difference today. Report litter, join a cleanup, and protect Ireland's coastline.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -5,3 +5,5 @@ export { EventsPage } from './EventsPage';
export { AnalyticsPage } from './AnalyticsPage';
export { PhotoUploadDemo } from './PhotoUploadDemo';
export { default as LocationSelectorDemo } from './LocationSelectorDemo';
export { ReportDetailDemo } from './ReportDetailDemo';
export { WhyCleanPage } from './WhyCleanPage';

View File

@@ -1,4 +1,5 @@
// API service configuration and base client
import { useAuth } from '@clerk/clerk-react';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api';
@@ -11,21 +12,30 @@ export interface ApiError {
export class ApiClient {
private baseUrl: string;
private getToken?: () => Promise<string | null>;
constructor(baseUrl: string = API_BASE_URL) {
this.baseUrl = baseUrl;
}
setTokenGetter(getToken: () => Promise<string | null>) {
this.getToken = getToken;
}
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
// Get auth token if available
const token = this.getToken ? await this.getToken() : null;
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});

5
src/services/index.ts Normal file
View File

@@ -0,0 +1,5 @@
// Service exports
export { apiClient } from './api';
export { reportService } from './reportService';
export type { ApiError } from './api';
export type { CreateReportPayload, UploadPhotoResponse } from './reportService';

View File

@@ -0,0 +1,134 @@
// Report service for API interactions
import { apiClient } from './api';
import type { CreateReportDTO, ReportResponseDTO, Report } from '@/types';
export interface CreateReportPayload {
latitude: number;
longitude: number;
locationSource: 'exif' | 'gps' | 'manual';
litterType: string;
quantity: string;
description?: string;
photoUrls: string[];
photoTimestamp?: string;
environmentalConcerns?: Array<{
type: string;
severity: 'low' | 'medium' | 'high' | 'critical';
description: string;
}>;
}
export interface UploadPhotoResponse {
url: string;
thumbnailUrl?: string;
}
class ReportService {
private getToken?: () => Promise<string | null>;
setTokenGetter(getToken: () => Promise<string | null>) {
this.getToken = getToken;
}
/**
* Upload a photo to the server
*/
async uploadPhoto(file: File): Promise<UploadPhotoResponse> {
const formData = new FormData();
formData.append('photo', file);
// Get auth token
const token = this.getToken ? await this.getToken() : null;
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'http://localhost:3005/api'}/reports/upload`, {
method: 'POST',
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: formData,
});
if (!response.ok) {
throw new Error('Failed to upload photo');
}
return response.json();
}
/**
* Create a new litter report
*/
async createReport(data: CreateReportPayload): Promise<ReportResponseDTO> {
return apiClient.post<ReportResponseDTO>('/reports', data);
}
/**
* Get a single report by ID
*/
async getReport(id: string): Promise<ReportResponseDTO> {
return apiClient.get<ReportResponseDTO>(`/reports/${id}`);
}
/**
* Get all reports with optional filters
*/
async getReports(filters?: {
dateRange?: { start: string; end: string };
litterTypes?: string[];
quantities?: string[];
verificationStatus?: string[];
bounds?: { north: number; south: number; east: number; west: number };
}): Promise<ReportResponseDTO[]> {
const params = new URLSearchParams();
if (filters?.dateRange) {
params.append('startDate', filters.dateRange.start);
params.append('endDate', filters.dateRange.end);
}
if (filters?.litterTypes?.length) {
params.append('litterTypes', filters.litterTypes.join(','));
}
if (filters?.quantities?.length) {
params.append('quantities', filters.quantities.join(','));
}
if (filters?.verificationStatus?.length) {
params.append('verificationStatus', filters.verificationStatus.join(','));
}
if (filters?.bounds) {
params.append('north', filters.bounds.north.toString());
params.append('south', filters.bounds.south.toString());
params.append('east', filters.bounds.east.toString());
params.append('west', filters.bounds.west.toString());
}
const queryString = params.toString();
return apiClient.get<ReportResponseDTO[]>(`/reports${queryString ? `?${queryString}` : ''}`);
}
/**
* Verify a report
*/
async verifyReport(reportId: string, comment?: string): Promise<void> {
return apiClient.post(`/reports/${reportId}/verify`, { comment });
}
/**
* Dispute a report
*/
async disputeReport(reportId: string, comment?: string): Promise<void> {
return apiClient.post(`/reports/${reportId}/dispute`, { comment });
}
/**
* Delete a report (only by owner)
*/
async deleteReport(reportId: string): Promise<void> {
return apiClient.delete(`/reports/${reportId}`);
}
}
export const reportService = new ReportService();