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:
@@ -125,7 +125,7 @@
|
|||||||
- Add profile editing functionality
|
- Add profile editing functionality
|
||||||
- _Requirements: 6.3, 6.4, 6.5_
|
- _Requirements: 6.3, 6.4, 6.5_
|
||||||
|
|
||||||
- [ ] 16. Implement notification preferences
|
- [-] 16. Implement notification preferences
|
||||||
- Create notification settings interface
|
- Create notification settings interface
|
||||||
- Build area of interest selector with map
|
- Build area of interest selector with map
|
||||||
- Implement radius configuration (1-50km)
|
- Implement radius configuration (1-50km)
|
||||||
@@ -259,7 +259,7 @@
|
|||||||
- Create verification audit log
|
- Create verification audit log
|
||||||
- _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5_
|
- _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
|
- Create GET /api/users/:id for profile retrieval
|
||||||
- Implement PATCH /api/users/:id for profile updates
|
- Implement PATCH /api/users/:id for profile updates
|
||||||
- Build GET /api/users/:id/reports endpoint
|
- Build GET /api/users/:id/reports endpoint
|
||||||
|
|||||||
0
NOTIFICATION_PREFERENCES_IMPLEMENTATION.md
Normal file
0
NOTIFICATION_PREFERENCES_IMPLEMENTATION.md
Normal file
@@ -98,8 +98,21 @@ export class UserService {
|
|||||||
throw new Error('User not found');
|
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 {
|
return {
|
||||||
...user,
|
...user,
|
||||||
|
areasOfInterest,
|
||||||
reportCount: user._count.reports,
|
reportCount: user._count.reports,
|
||||||
eventCount: user._count.eventRegistrations,
|
eventCount: user._count.eventRegistrations,
|
||||||
stats: {
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
273
src/components/auth/NotificationSettings.tsx
Normal file
273
src/components/auth/NotificationSettings.tsx
Normal 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='© <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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,3 +2,5 @@ export { ProtectedRoute } from './ProtectedRoute';
|
|||||||
export { UserProfile } from './UserProfile';
|
export { UserProfile } from './UserProfile';
|
||||||
export { SignInPage } from './SignInPage';
|
export { SignInPage } from './SignInPage';
|
||||||
export { SignUpPage } from './SignUpPage';
|
export { SignUpPage } from './SignUpPage';
|
||||||
|
export { NotificationSettings } from './NotificationSettings';
|
||||||
|
export type { NotificationPreferences, AreaOfInterest } from './NotificationSettings';
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useAuth } from '@/hooks/useAuth';
|
|||||||
import { userService, type UserProfile, type UserActivity } from '@/services/userService';
|
import { userService, type UserProfile, type UserActivity } from '@/services/userService';
|
||||||
import type { Report, CleanupEvent } from '@/types';
|
import type { Report, CleanupEvent } from '@/types';
|
||||||
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
||||||
|
import { NotificationSettings, type NotificationPreferences } from '@/components/auth';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
|
|
||||||
@@ -30,13 +31,18 @@ export function UserProfilePage() {
|
|||||||
const [activity, setActivity] = useState<UserActivity[]>([]);
|
const [activity, setActivity] = useState<UserActivity[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 [isEditing, setIsEditing] = useState(false);
|
||||||
const [editForm, setEditForm] = useState({
|
const [editForm, setEditForm] = useState({
|
||||||
firstName: '',
|
firstName: '',
|
||||||
lastName: '',
|
lastName: '',
|
||||||
username: '',
|
username: '',
|
||||||
});
|
});
|
||||||
|
const [notificationPreferences, setNotificationPreferences] = useState<NotificationPreferences>({
|
||||||
|
notificationEmail: true,
|
||||||
|
notificationInApp: true,
|
||||||
|
areasOfInterest: [],
|
||||||
|
});
|
||||||
|
|
||||||
const isOwnProfile = currentUser?.id === userId;
|
const isOwnProfile = currentUser?.id === userId;
|
||||||
|
|
||||||
@@ -66,7 +72,12 @@ export function UserProfilePage() {
|
|||||||
|
|
||||||
setProfile(profileData);
|
setProfile(profileData);
|
||||||
setReports(reportsData);
|
setReports(reportsData);
|
||||||
|
// Handle both array and object response formats
|
||||||
|
if (Array.isArray(eventsData)) {
|
||||||
|
setEvents({ registered: eventsData, organized: [] });
|
||||||
|
} else {
|
||||||
setEvents(eventsData);
|
setEvents(eventsData);
|
||||||
|
}
|
||||||
setActivity(activityData);
|
setActivity(activityData);
|
||||||
|
|
||||||
setEditForm({
|
setEditForm({
|
||||||
@@ -74,6 +85,13 @@ export function UserProfilePage() {
|
|||||||
lastName: profileData.lastName || '',
|
lastName: profileData.lastName || '',
|
||||||
username: profileData.username || '',
|
username: profileData.username || '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Set notification preferences from profile
|
||||||
|
setNotificationPreferences({
|
||||||
|
notificationEmail: profileData.notificationEmail ?? true,
|
||||||
|
notificationInApp: profileData.notificationInApp ?? true,
|
||||||
|
areasOfInterest: profileData.areasOfInterest || [],
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load profile');
|
setError(err instanceof Error ? err.message : 'Failed to load profile');
|
||||||
} finally {
|
} 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 = () => {
|
const calculateImpactScore = () => {
|
||||||
if (!profile) return 0;
|
if (!profile) return 0;
|
||||||
// Impact score calculation: reports * 10 + events attended * 20
|
// 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="bg-white rounded-lg shadow-md mb-6">
|
||||||
<div className="border-b border-gray-200">
|
<div className="border-b border-gray-200">
|
||||||
<nav className="flex -mb-px">
|
<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
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
@@ -503,6 +545,14 @@ export function UserProfilePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'notifications' && isOwnProfile && (
|
||||||
|
<NotificationSettings
|
||||||
|
preferences={notificationPreferences}
|
||||||
|
onSave={handleSaveNotificationPreferences}
|
||||||
|
onCancel={handleCancelNotificationSettings}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ import type { User, Report, CleanupEvent } from '@/types';
|
|||||||
export interface UserProfile extends User {
|
export interface UserProfile extends User {
|
||||||
reportCount: number;
|
reportCount: number;
|
||||||
eventCount: number;
|
eventCount: number;
|
||||||
|
notificationEmail: boolean;
|
||||||
|
notificationInApp: boolean;
|
||||||
|
areasOfInterest: Array<{
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
radius: number;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserActivity {
|
export interface UserActivity {
|
||||||
|
|||||||
Reference in New Issue
Block a user