diff --git a/.kiro/specs/beach-litter-mapping/tasks.md b/.kiro/specs/beach-litter-mapping/tasks.md index 1ce521c..7a0bf1a 100644 --- a/.kiro/specs/beach-litter-mapping/tasks.md +++ b/.kiro/specs/beach-litter-mapping/tasks.md @@ -125,7 +125,7 @@ - Add profile editing functionality - _Requirements: 6.3, 6.4, 6.5_ -- [ ] 16. Implement notification preferences +- [-] 16. Implement notification preferences - Create notification settings interface - Build area of interest selector with map - Implement radius configuration (1-50km) @@ -259,7 +259,7 @@ - Create verification audit log - _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5_ -- [x] 31. Build user profile endpoints +- [ ] 31. Build user profile endpoints - Create GET /api/users/:id for profile retrieval - Implement PATCH /api/users/:id for profile updates - Build GET /api/users/:id/reports endpoint diff --git a/NOTIFICATION_PREFERENCES_IMPLEMENTATION.md b/NOTIFICATION_PREFERENCES_IMPLEMENTATION.md new file mode 100644 index 0000000..e69de29 diff --git a/server/src/services/userService.ts b/server/src/services/userService.ts index 5821566..a71d474 100644 --- a/server/src/services/userService.ts +++ b/server/src/services/userService.ts @@ -98,8 +98,21 @@ export class UserService { throw new Error('User not found'); } + // Parse areasOfInterest from JSON string if it exists + let areasOfInterest = []; + if (user.areasOfInterest) { + try { + areasOfInterest = typeof user.areasOfInterest === 'string' + ? JSON.parse(user.areasOfInterest) + : user.areasOfInterest; + } catch (e) { + console.error('Error parsing areasOfInterest:', e); + } + } + return { ...user, + areasOfInterest, reportCount: user._count.reports, eventCount: user._count.eventRegistrations, stats: { @@ -275,7 +288,22 @@ export class UserService { }, }); - return updatedUser; + // Parse areasOfInterest from JSON string + let areasOfInterest = []; + if (updatedUser.areasOfInterest) { + try { + areasOfInterest = typeof updatedUser.areasOfInterest === 'string' + ? JSON.parse(updatedUser.areasOfInterest) + : updatedUser.areasOfInterest; + } catch (e) { + console.error('Error parsing areasOfInterest:', e); + } + } + + return { + ...updatedUser, + areasOfInterest, + }; } /** diff --git a/src/components/auth/NotificationSettings.tsx b/src/components/auth/NotificationSettings.tsx new file mode 100644 index 0000000..8fdb526 --- /dev/null +++ b/src/components/auth/NotificationSettings.tsx @@ -0,0 +1,273 @@ +import { useState, useEffect } from 'react'; +import { MapContainer, TileLayer, Circle, useMapEvents } from 'react-leaflet'; +import 'leaflet/dist/leaflet.css'; +import L from 'leaflet'; + +// Fix Leaflet default marker icon issue +import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png'; +import markerIcon from 'leaflet/dist/images/marker-icon.png'; +import markerShadow from 'leaflet/dist/images/marker-shadow.png'; + +delete (L.Icon.Default.prototype as any)._getIconUrl; +L.Icon.Default.mergeOptions({ + iconUrl: markerIcon, + iconRetinaUrl: markerIcon2x, + shadowUrl: markerShadow, +}); + +export interface AreaOfInterest { + lat: number; + lng: number; + radius: number; // in kilometers +} + +export interface NotificationPreferences { + notificationEmail: boolean; + notificationInApp: boolean; + areasOfInterest: AreaOfInterest[]; +} + +interface NotificationSettingsProps { + preferences: NotificationPreferences; + onSave: (preferences: NotificationPreferences) => Promise; + onCancel: () => void; +} + +// Component to handle map clicks +function MapClickHandler({ onMapClick }: { onMapClick: (lat: number, lng: number) => void }) { + useMapEvents({ + click: (e) => { + onMapClick(e.latlng.lat, e.latlng.lng); + }, + }); + return null; +} + +export function NotificationSettings({ preferences, onSave, onCancel }: NotificationSettingsProps) { + const [localPreferences, setLocalPreferences] = useState(preferences); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [selectedAreaIndex, setSelectedAreaIndex] = useState(null); + + // Default map center (Ireland) + const defaultCenter: [number, number] = [53.1424, -7.6921]; + + useEffect(() => { + setLocalPreferences(preferences); + }, [preferences]); + + const handleAddArea = (lat: number, lng: number) => { + const newArea: AreaOfInterest = { + lat, + lng, + radius: 10, // Default 10km radius + }; + setLocalPreferences({ + ...localPreferences, + areasOfInterest: [...localPreferences.areasOfInterest, newArea], + }); + setSelectedAreaIndex(localPreferences.areasOfInterest.length); + }; + + const handleRemoveArea = (index: number) => { + const newAreas = localPreferences.areasOfInterest.filter((_, i) => i !== index); + setLocalPreferences({ + ...localPreferences, + areasOfInterest: newAreas, + }); + if (selectedAreaIndex === index) { + setSelectedAreaIndex(null); + } + }; + + const handleRadiusChange = (index: number, radius: number) => { + const newAreas = [...localPreferences.areasOfInterest]; + const currentArea = newAreas[index]; + if (currentArea) { + newAreas[index] = { ...currentArea, radius }; + setLocalPreferences({ + ...localPreferences, + areasOfInterest: newAreas, + }); + } + }; + + const handleSave = async () => { + try { + setSaving(true); + setError(null); + await onSave(localPreferences); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save preferences'); + } finally { + setSaving(false); + } + }; + + const mapCenter: [number, number] = + localPreferences.areasOfInterest.length > 0 && localPreferences.areasOfInterest[0] + ? [localPreferences.areasOfInterest[0].lat, localPreferences.areasOfInterest[0].lng] + : defaultCenter; + + return ( +
+
+

Notification Preferences

+ + {/* Notification Type Toggles */} +
+ + + +
+
+ + {/* Areas of Interest */} +
+

Areas of Interest

+

+ Click on the map to add areas where you want to receive notifications about new reports and events. +

+ + {/* Map */} +
+ + + + + {/* Render circles for each area of interest */} + {localPreferences.areasOfInterest.map((area, index) => ( + setSelectedAreaIndex(index), + }} + /> + ))} + +
+ + {/* Areas List */} + {localPreferences.areasOfInterest.length > 0 && ( +
+
Your Areas ({localPreferences.areasOfInterest.length})
+ {localPreferences.areasOfInterest.map((area, index) => ( +
+
+ + Area {index + 1} + + +
+
+ Location: {area.lat.toFixed(4)}, {area.lng.toFixed(4)} +
+
+ + handleRadiusChange(index, parseInt(e.target.value))} + className="flex-1" + /> + + {area.radius} km + +
+
+ ))} +
+ )} + + {localPreferences.areasOfInterest.length === 0 && ( +

+ No areas added yet. Click on the map to add your first area of interest. +

+ )} +
+ + {/* Error Message */} + {error && ( +
+

{error}

+
+ )} + + {/* Action Buttons */} +
+ + +
+
+ ); +} diff --git a/src/components/auth/index.ts b/src/components/auth/index.ts index f2caa70..e22706f 100644 --- a/src/components/auth/index.ts +++ b/src/components/auth/index.ts @@ -2,3 +2,5 @@ export { ProtectedRoute } from './ProtectedRoute'; export { UserProfile } from './UserProfile'; export { SignInPage } from './SignInPage'; export { SignUpPage } from './SignUpPage'; +export { NotificationSettings } from './NotificationSettings'; +export type { NotificationPreferences, AreaOfInterest } from './NotificationSettings'; diff --git a/src/pages/UserProfilePage.tsx b/src/pages/UserProfilePage.tsx index 92a4a97..dd4c40a 100644 --- a/src/pages/UserProfilePage.tsx +++ b/src/pages/UserProfilePage.tsx @@ -4,6 +4,7 @@ import { useAuth } from '@/hooks/useAuth'; import { userService, type UserProfile, type UserActivity } from '@/services/userService'; import type { Report, CleanupEvent } from '@/types'; import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet'; +import { NotificationSettings, type NotificationPreferences } from '@/components/auth'; import 'leaflet/dist/leaflet.css'; import L from 'leaflet'; @@ -30,13 +31,18 @@ export function UserProfilePage() { const [activity, setActivity] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [activeTab, setActiveTab] = useState<'overview' | 'reports' | 'events' | 'activity'>('overview'); + const [activeTab, setActiveTab] = useState<'overview' | 'reports' | 'events' | 'activity' | 'notifications'>('overview'); const [isEditing, setIsEditing] = useState(false); const [editForm, setEditForm] = useState({ firstName: '', lastName: '', username: '', }); + const [notificationPreferences, setNotificationPreferences] = useState({ + notificationEmail: true, + notificationInApp: true, + areasOfInterest: [], + }); const isOwnProfile = currentUser?.id === userId; @@ -66,7 +72,12 @@ export function UserProfilePage() { setProfile(profileData); setReports(reportsData); - setEvents(eventsData); + // Handle both array and object response formats + if (Array.isArray(eventsData)) { + setEvents({ registered: eventsData, organized: [] }); + } else { + setEvents(eventsData); + } setActivity(activityData); setEditForm({ @@ -74,6 +85,13 @@ export function UserProfilePage() { lastName: profileData.lastName || '', username: profileData.username || '', }); + + // Set notification preferences from profile + setNotificationPreferences({ + notificationEmail: profileData.notificationEmail ?? true, + notificationInApp: profileData.notificationInApp ?? true, + areasOfInterest: profileData.areasOfInterest || [], + }); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load profile'); } finally { @@ -108,6 +126,30 @@ export function UserProfilePage() { } }; + const handleSaveNotificationPreferences = async (preferences: NotificationPreferences) => { + if (!userId) return; + + try { + await userService.updateNotificationPreferences(userId, preferences); + setNotificationPreferences(preferences); + setActiveTab('overview'); // Return to overview after saving + } catch (err) { + throw err; // Let NotificationSettings component handle the error + } + }; + + const handleCancelNotificationSettings = () => { + // Reset to current preferences + if (profile) { + setNotificationPreferences({ + notificationEmail: profile.notificationEmail ?? true, + notificationInApp: profile.notificationInApp ?? true, + areasOfInterest: profile.areasOfInterest || [], + }); + } + setActiveTab('overview'); + }; + const calculateImpactScore = () => { if (!profile) return 0; // Impact score calculation: reports * 10 + events attended * 20 @@ -254,7 +296,7 @@ export function UserProfilePage() {
)} + + {activeTab === 'notifications' && isOwnProfile && ( + + )}
diff --git a/src/services/userService.ts b/src/services/userService.ts index 17b142b..9934d7a 100644 --- a/src/services/userService.ts +++ b/src/services/userService.ts @@ -5,6 +5,13 @@ import type { User, Report, CleanupEvent } from '@/types'; export interface UserProfile extends User { reportCount: number; eventCount: number; + notificationEmail: boolean; + notificationInApp: boolean; + areasOfInterest: Array<{ + lat: number; + lng: number; + radius: number; + }>; } export interface UserActivity {