Implement notification preferences (Task 16)

- Create NotificationSettings component with map-based area selector
- Add radius configuration slider (1-50km range)
- Implement notification type toggles (email, in-app)
- Add notifications tab to user profile page (visible only to profile owner)
- Update backend to parse and return areasOfInterest JSON
- Save and load user notification preferences
- Requirements: 10.1, 10.4
This commit is contained in:
2025-11-16 22:01:01 +00:00
parent 515e85b1eb
commit 86169f6141
7 changed files with 366 additions and 6 deletions

View File

@@ -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

View File

@@ -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,
};
}
/**

View File

@@ -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<void>;
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<NotificationPreferences>(preferences);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedAreaIndex, setSelectedAreaIndex] = useState<number | null>(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 (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">Notification Preferences</h3>
{/* Notification Type Toggles */}
<div className="space-y-3 mb-6">
<label className="flex items-center space-x-3">
<input
type="checkbox"
checked={localPreferences.notificationEmail}
onChange={(e) =>
setLocalPreferences({
...localPreferences,
notificationEmail: e.target.checked,
})
}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<span className="text-sm text-gray-700">
Email notifications for new reports and events
</span>
</label>
<label className="flex items-center space-x-3">
<input
type="checkbox"
checked={localPreferences.notificationInApp}
onChange={(e) =>
setLocalPreferences({
...localPreferences,
notificationInApp: e.target.checked,
})
}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<span className="text-sm text-gray-700">
In-app notifications for new reports and events
</span>
</label>
</div>
</div>
{/* Areas of Interest */}
<div>
<h4 className="text-md font-semibold text-gray-900 mb-2">Areas of Interest</h4>
<p className="text-sm text-gray-600 mb-4">
Click on the map to add areas where you want to receive notifications about new reports and events.
</p>
{/* Map */}
<div className="h-96 rounded-lg overflow-hidden border border-gray-300 mb-4">
<MapContainer
center={mapCenter}
zoom={7}
style={{ height: '100%', width: '100%' }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MapClickHandler onMapClick={handleAddArea} />
{/* Render circles for each area of interest */}
{localPreferences.areasOfInterest.map((area, index) => (
<Circle
key={index}
center={[area.lat, area.lng]}
radius={area.radius * 1000} // Convert km to meters
pathOptions={{
color: selectedAreaIndex === index ? '#3b82f6' : '#6b7280',
fillColor: selectedAreaIndex === index ? '#3b82f6' : '#6b7280',
fillOpacity: 0.2,
}}
eventHandlers={{
click: () => setSelectedAreaIndex(index),
}}
/>
))}
</MapContainer>
</div>
{/* Areas List */}
{localPreferences.areasOfInterest.length > 0 && (
<div className="space-y-3">
<h5 className="text-sm font-medium text-gray-700">Your Areas ({localPreferences.areasOfInterest.length})</h5>
{localPreferences.areasOfInterest.map((area, index) => (
<div
key={index}
className={`p-3 rounded-lg border ${
selectedAreaIndex === index
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 bg-gray-50'
}`}
>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-900">
Area {index + 1}
</span>
<button
onClick={() => handleRemoveArea(index)}
className="text-red-600 hover:text-red-800 text-sm"
>
Remove
</button>
</div>
<div className="text-xs text-gray-600 mb-2">
Location: {area.lat.toFixed(4)}, {area.lng.toFixed(4)}
</div>
<div className="flex items-center space-x-2">
<label className="text-xs text-gray-700">Radius (km):</label>
<input
type="range"
min="1"
max="50"
value={area.radius}
onChange={(e) => handleRadiusChange(index, parseInt(e.target.value))}
className="flex-1"
/>
<span className="text-xs font-medium text-gray-900 w-12">
{area.radius} km
</span>
</div>
</div>
))}
</div>
)}
{localPreferences.areasOfInterest.length === 0 && (
<p className="text-sm text-gray-500 italic">
No areas added yet. Click on the map to add your first area of interest.
</p>
)}
</div>
{/* Error Message */}
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-md">
<p className="text-sm text-red-600">{error}</p>
</div>
)}
{/* Action Buttons */}
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200">
<button
onClick={onCancel}
disabled={saving}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save Preferences'}
</button>
</div>
</div>
);
}

View File

@@ -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';

View File

@@ -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<UserActivity[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<NotificationPreferences>({
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() {
<div className="bg-white rounded-lg shadow-md mb-6">
<div className="border-b border-gray-200">
<nav className="flex -mb-px">
{(['overview', 'reports', 'events', 'activity'] as const).map((tab) => (
{(['overview', 'reports', 'events', 'activity', ...(isOwnProfile ? ['notifications'] as const : [])] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
@@ -503,6 +545,14 @@ export function UserProfilePage() {
)}
</div>
)}
{activeTab === 'notifications' && isOwnProfile && (
<NotificationSettings
preferences={notificationPreferences}
onSave={handleSaveNotificationPreferences}
onCancel={handleCancelNotificationSettings}
/>
)}
</div>
</div>
</div>

View File

@@ -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 {