Skip to content

Commit 95a72db

Browse files
Merge pull request #110 from Shadow-MMN/feat/notification-system-multichannel-preferences
feat(notifications): implement multi-channel notification system with user preferences
2 parents 4cc1354 + a70439b commit 95a72db

6 files changed

Lines changed: 2781 additions & 41 deletions

File tree

Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
'use client';
2+
3+
import React, { useState, useCallback } from 'react';
4+
import {
5+
Mail,
6+
Smartphone,
7+
MessageSquare,
8+
Bell,
9+
Check,
10+
X,
11+
AlertCircle,
12+
Loader2,
13+
Send,
14+
Settings,
15+
} from 'lucide-react';
16+
import { useNotifications } from '@/app/hooks/useNotifications';
17+
import {
18+
NotificationChannel,
19+
NotificationPriority,
20+
NotificationCategory,
21+
} from '@/utils/notificationUtils';
22+
23+
interface MultiChannelDeliveryProps {
24+
userId?: string;
25+
onDeliveryComplete?: (results: Record<NotificationChannel, boolean>) => void;
26+
}
27+
28+
interface DeliveryStatus {
29+
channel: NotificationChannel;
30+
status: 'idle' | 'sending' | 'success' | 'failed';
31+
message?: string;
32+
}
33+
34+
const channelConfig: Record<
35+
NotificationChannel,
36+
{ icon: React.ReactNode; label: string; description: string; color: string }
37+
> = {
38+
'in-app': {
39+
icon: <Bell size={20} />,
40+
label: 'In-App',
41+
description: 'Notifications within the application',
42+
color: 'bg-blue-100 text-blue-800 border-blue-200',
43+
},
44+
push: {
45+
icon: <Smartphone size={20} />,
46+
label: 'Push',
47+
description: 'Mobile and desktop push notifications',
48+
color: 'bg-green-100 text-green-800 border-green-200',
49+
},
50+
email: {
51+
icon: <Mail size={20} />,
52+
label: 'Email',
53+
description: 'Email notifications to your inbox',
54+
color: 'bg-purple-100 text-purple-800 border-purple-200',
55+
},
56+
sms: {
57+
icon: <MessageSquare size={20} />,
58+
label: 'SMS',
59+
description: 'Text message notifications',
60+
color: 'bg-orange-100 text-orange-800 border-orange-200',
61+
},
62+
};
63+
64+
export default function MultiChannelDelivery({
65+
userId,
66+
onDeliveryComplete,
67+
}: MultiChannelDeliveryProps) {
68+
const { sendNotification, sendToChannel, sendToAllChannels, preferences } = useNotifications({
69+
userId,
70+
});
71+
72+
const [selectedChannels, setSelectedChannels] = useState<Set<NotificationChannel>>(
73+
new Set(['in-app'])
74+
);
75+
const [deliveryStatuses, setDeliveryStatuses] = useState<DeliveryStatus[]>([]);
76+
const [message, setMessage] = useState('');
77+
const [category, setCategory] = useState<NotificationCategory>('system');
78+
const [priority, setPriority] = useState<NotificationPriority>('medium');
79+
const [isSending, setIsSending] = useState(false);
80+
81+
// Toggle channel selection
82+
const toggleChannel = useCallback((channel: NotificationChannel) => {
83+
setSelectedChannels((prev) => {
84+
const next = new Set(prev);
85+
if (next.has(channel)) {
86+
next.delete(channel);
87+
} else {
88+
next.add(channel);
89+
}
90+
return next;
91+
});
92+
}, []);
93+
94+
// Send notification to selected channels
95+
const handleSend = useCallback(async () => {
96+
if (!message.trim() || selectedChannels.size === 0) return;
97+
98+
setIsSending(true);
99+
const channels = Array.from(selectedChannels);
100+
101+
// Initialize delivery statuses
102+
setDeliveryStatuses(
103+
channels.map((channel) => ({ channel, status: 'sending' }))
104+
);
105+
106+
try {
107+
// Create the notification
108+
const notification = sendNotification({
109+
message,
110+
type: priority === 'urgent' || priority === 'high' ? 'warning' : 'info',
111+
category,
112+
priority,
113+
channels,
114+
meta: { source: 'multi-channel-delivery' },
115+
});
116+
117+
// Send to all selected channels
118+
const results = await sendToAllChannels(notification, channels);
119+
120+
// Update delivery statuses
121+
setDeliveryStatuses(
122+
channels.map((channel) => ({
123+
channel,
124+
status: results[channel] ? 'success' : 'failed',
125+
message: results[channel]
126+
? 'Delivered successfully'
127+
: 'Delivery failed',
128+
}))
129+
);
130+
131+
onDeliveryComplete?.(results);
132+
133+
// Clear form after successful delivery
134+
if (Object.values(results).every(Boolean)) {
135+
setTimeout(() => {
136+
setMessage('');
137+
setDeliveryStatuses([]);
138+
}, 2000);
139+
}
140+
} catch (error) {
141+
console.error('Failed to send notification:', error);
142+
setDeliveryStatuses(
143+
channels.map((channel) => ({
144+
channel,
145+
status: 'failed',
146+
message: 'An error occurred',
147+
}))
148+
);
149+
} finally {
150+
setIsSending(false);
151+
}
152+
}, [message, selectedChannels, category, priority, sendNotification, sendToAllChannels, onDeliveryComplete]);
153+
154+
// Check if channel is enabled in preferences
155+
const isChannelEnabled = (channel: NotificationChannel): boolean => {
156+
if (!preferences) return true;
157+
const channelKey = channel === 'in-app' ? 'inApp' : channel;
158+
return preferences.channels[channelKey];
159+
};
160+
161+
// Get status icon
162+
const getStatusIcon = (status: DeliveryStatus['status']) => {
163+
switch (status) {
164+
case 'sending':
165+
return <Loader2 size={16} className="animate-spin text-blue-600" />;
166+
case 'success':
167+
return <Check size={16} className="text-green-600" />;
168+
case 'failed':
169+
return <X size={16} className="text-red-600" />;
170+
default:
171+
return null;
172+
}
173+
};
174+
175+
return (
176+
<div className="bg-white border rounded-lg shadow-lg overflow-hidden">
177+
{/* Header */}
178+
<div className="p-4 border-b bg-gray-50">
179+
<div className="flex items-center gap-2">
180+
<Send size={20} className="text-gray-700" />
181+
<h2 className="text-lg font-semibold text-gray-900">Multi-Channel Delivery</h2>
182+
</div>
183+
<p className="text-sm text-gray-500 mt-1">
184+
Send notifications through multiple channels simultaneously
185+
</p>
186+
</div>
187+
188+
{/* Channel Selection */}
189+
<div className="p-4 border-b">
190+
<label className="block text-sm font-medium text-gray-700 mb-3">
191+
Select Delivery Channels
192+
</label>
193+
<div className="grid grid-cols-2 gap-3">
194+
{(Object.entries(channelConfig) as [NotificationChannel, typeof channelConfig['in-app']][]).map(
195+
([channel, config]) => {
196+
const isSelected = selectedChannels.has(channel);
197+
const isEnabled = isChannelEnabled(channel);
198+
199+
return (
200+
<button
201+
key={channel}
202+
onClick={() => toggleChannel(channel)}
203+
disabled={!isEnabled}
204+
className={`
205+
p-3 rounded-lg border-2 text-left transition-all
206+
${isSelected
207+
? `${config.color} border-current`
208+
: 'bg-white border-gray-200 hover:border-gray-300'
209+
}
210+
${!isEnabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
211+
`}
212+
>
213+
<div className="flex items-center gap-2">
214+
<span className={isSelected ? '' : 'text-gray-400'}>{config.icon}</span>
215+
<span className={`font-medium ${isSelected ? '' : 'text-gray-700'}`}>
216+
{config.label}
217+
</span>
218+
{isSelected && <Check size={16} className="ml-auto" />}
219+
</div>
220+
<p className="text-xs mt-1 text-gray-500">{config.description}</p>
221+
{!isEnabled && (
222+
<p className="text-xs mt-1 text-orange-600 flex items-center gap-1">
223+
<AlertCircle size={12} />
224+
Disabled in preferences
225+
</p>
226+
)}
227+
</button>
228+
);
229+
}
230+
)}
231+
</div>
232+
</div>
233+
234+
{/* Message Composition */}
235+
<div className="p-4 border-b space-y-4">
236+
<div>
237+
<label className="block text-sm font-medium text-gray-700 mb-1">
238+
Notification Message
239+
</label>
240+
<textarea
241+
value={message}
242+
onChange={(e) => setMessage(e.target.value)}
243+
placeholder="Enter your notification message..."
244+
rows={3}
245+
className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
246+
/>
247+
</div>
248+
249+
<div className="grid grid-cols-2 gap-4">
250+
<div>
251+
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
252+
<select
253+
value={category}
254+
onChange={(e) => setCategory(e.target.value as NotificationCategory)}
255+
className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
256+
>
257+
<option value="system">System</option>
258+
<option value="course_update">Course Update</option>
259+
<option value="message">Message</option>
260+
<option value="achievement">Achievement</option>
261+
<option value="reminder">Reminder</option>
262+
<option value="social">Social</option>
263+
<option value="payment">Payment</option>
264+
</select>
265+
</div>
266+
<div>
267+
<label className="block text-sm font-medium text-gray-700 mb-1">Priority</label>
268+
<select
269+
value={priority}
270+
onChange={(e) => setPriority(e.target.value as NotificationPriority)}
271+
className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
272+
>
273+
<option value="low">Low</option>
274+
<option value="medium">Medium</option>
275+
<option value="high">High</option>
276+
<option value="urgent">Urgent</option>
277+
</select>
278+
</div>
279+
</div>
280+
</div>
281+
282+
{/* Delivery Status */}
283+
{deliveryStatuses.length > 0 && (
284+
<div className="p-4 border-b bg-gray-50">
285+
<label className="block text-sm font-medium text-gray-700 mb-3">
286+
Delivery Status
287+
</label>
288+
<div className="space-y-2">
289+
{deliveryStatuses.map((status) => {
290+
const config = channelConfig[status.channel];
291+
return (
292+
<div
293+
key={status.channel}
294+
className="flex items-center justify-between p-2 bg-white rounded border"
295+
>
296+
<div className="flex items-center gap-2">
297+
<span className="text-gray-600">{config.icon}</span>
298+
<span className="text-sm font-medium">{config.label}</span>
299+
</div>
300+
<div className="flex items-center gap-2">
301+
{getStatusIcon(status.status)}
302+
<span
303+
className={`text-xs ${
304+
status.status === 'success'
305+
? 'text-green-600'
306+
: status.status === 'failed'
307+
? 'text-red-600'
308+
: 'text-gray-500'
309+
}`}
310+
>
311+
{status.message || 'Pending'}
312+
</span>
313+
</div>
314+
</div>
315+
);
316+
})}
317+
</div>
318+
</div>
319+
)}
320+
321+
{/* Send Button */}
322+
<div className="p-4">
323+
<button
324+
onClick={handleSend}
325+
disabled={isSending || !message.trim() || selectedChannels.size === 0}
326+
className={`
327+
w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg font-medium
328+
transition-colors
329+
${
330+
isSending || !message.trim() || selectedChannels.size === 0
331+
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
332+
: 'bg-blue-600 text-white hover:bg-blue-700'
333+
}
334+
`}
335+
>
336+
{isSending ? (
337+
<>
338+
<Loader2 size={18} className="animate-spin" />
339+
Sending...
340+
</>
341+
) : (
342+
<>
343+
<Send size={18} />
344+
Send to {selectedChannels.size} channel{selectedChannels.size !== 1 ? 's' : ''}
345+
</>
346+
)}
347+
</button>
348+
</div>
349+
</div>
350+
);
351+
}

0 commit comments

Comments
 (0)