mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 15:11:13 -05:00
feat: Implement Novu notification system
This commit is contained in:
@@ -1,289 +1,358 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { Bell, Mail, Smartphone, Volume2 } from 'lucide-react';
|
||||
interface EmailNotifications {
|
||||
review_replies: boolean;
|
||||
new_followers: boolean;
|
||||
system_announcements: boolean;
|
||||
weekly_digest: boolean;
|
||||
monthly_digest: boolean;
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
import { notificationService } from "@/lib/notificationService";
|
||||
|
||||
interface ChannelPreferences {
|
||||
in_app: boolean;
|
||||
email: boolean;
|
||||
push: boolean;
|
||||
sms: boolean;
|
||||
}
|
||||
interface PushNotifications {
|
||||
browser_enabled: boolean;
|
||||
new_content: boolean;
|
||||
social_updates: boolean;
|
||||
|
||||
interface WorkflowPreferences {
|
||||
[workflowId: string]: boolean;
|
||||
}
|
||||
|
||||
interface FrequencySettings {
|
||||
digest: 'realtime' | 'hourly' | 'daily' | 'weekly';
|
||||
max_per_hour: number;
|
||||
}
|
||||
|
||||
interface NotificationTemplate {
|
||||
id: string;
|
||||
workflow_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export function NotificationsTab() {
|
||||
const {
|
||||
user
|
||||
} = useAuth();
|
||||
const {
|
||||
toast
|
||||
} = useToast();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [emailNotifications, setEmailNotifications] = useState<EmailNotifications>({
|
||||
review_replies: true,
|
||||
new_followers: true,
|
||||
system_announcements: true,
|
||||
weekly_digest: false,
|
||||
monthly_digest: true
|
||||
const { user } = useAuth();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [templates, setTemplates] = useState<NotificationTemplate[]>([]);
|
||||
const [channelPreferences, setChannelPreferences] = useState<ChannelPreferences>({
|
||||
in_app: true,
|
||||
email: true,
|
||||
push: false,
|
||||
sms: false,
|
||||
});
|
||||
const [pushNotifications, setPushNotifications] = useState<PushNotifications>({
|
||||
browser_enabled: false,
|
||||
new_content: true,
|
||||
social_updates: true
|
||||
const [workflowPreferences, setWorkflowPreferences] = useState<WorkflowPreferences>({});
|
||||
const [frequencySettings, setFrequencySettings] = useState<FrequencySettings>({
|
||||
digest: 'daily',
|
||||
max_per_hour: 10,
|
||||
});
|
||||
const isNovuEnabled = notificationService.isEnabled();
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotificationPreferences();
|
||||
if (user) {
|
||||
loadPreferences();
|
||||
loadTemplates();
|
||||
}
|
||||
}, [user]);
|
||||
const fetchNotificationPreferences = async () => {
|
||||
|
||||
const loadPreferences = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
const {
|
||||
data,
|
||||
error
|
||||
} = await supabase.from('user_preferences').select('email_notifications, push_notifications').eq('user_id', user.id).maybeSingle();
|
||||
if (error && error.code !== 'PGRST116') {
|
||||
console.error('Error fetching notification preferences:', error);
|
||||
return;
|
||||
}
|
||||
if (data) {
|
||||
if (data.email_notifications) {
|
||||
setEmailNotifications(data.email_notifications as unknown as EmailNotifications);
|
||||
}
|
||||
if (data.push_notifications) {
|
||||
setPushNotifications(data.push_notifications as unknown as PushNotifications);
|
||||
}
|
||||
} else {
|
||||
// Initialize preferences if they don't exist
|
||||
await initializePreferences();
|
||||
const preferences = await notificationService.getPreferences(user.id);
|
||||
|
||||
if (preferences) {
|
||||
setChannelPreferences(preferences.channelPreferences);
|
||||
setWorkflowPreferences(preferences.workflowPreferences);
|
||||
setFrequencySettings(preferences.frequencySettings);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching notification preferences:', error);
|
||||
}
|
||||
};
|
||||
const initializePreferences = async () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
const {
|
||||
error
|
||||
} = await supabase.from('user_preferences').insert([{
|
||||
user_id: user.id,
|
||||
email_notifications: emailNotifications as any,
|
||||
push_notifications: pushNotifications as any
|
||||
}]);
|
||||
if (error) throw error;
|
||||
} catch (error) {
|
||||
console.error('Error initializing preferences:', error);
|
||||
}
|
||||
};
|
||||
const updateEmailNotification = (key: keyof EmailNotifications, value: boolean) => {
|
||||
setEmailNotifications(prev => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}));
|
||||
};
|
||||
const updatePushNotification = (key: keyof PushNotifications, value: boolean) => {
|
||||
setPushNotifications(prev => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}));
|
||||
};
|
||||
const saveNotificationPreferences = async () => {
|
||||
if (!user) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const {
|
||||
error
|
||||
} = await supabase.from('user_preferences').upsert([{
|
||||
user_id: user.id,
|
||||
email_notifications: emailNotifications as any,
|
||||
push_notifications: pushNotifications as any,
|
||||
updated_at: new Date().toISOString()
|
||||
}]);
|
||||
if (error) throw error;
|
||||
toast({
|
||||
title: 'Preferences saved',
|
||||
description: 'Your notification preferences have been updated.'
|
||||
});
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.message || 'Failed to save notification preferences',
|
||||
variant: 'destructive'
|
||||
});
|
||||
console.error('Error loading preferences:', error);
|
||||
toast.error("Failed to load notification preferences");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const requestPushPermission = async () => {
|
||||
if ('Notification' in window) {
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission === 'granted') {
|
||||
updatePushNotification('browser_enabled', true);
|
||||
toast({
|
||||
title: 'Push notifications enabled',
|
||||
description: 'You will now receive browser push notifications.'
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'Permission denied',
|
||||
description: 'Push notifications require permission to work.',
|
||||
variant: 'destructive'
|
||||
});
|
||||
|
||||
const loadTemplates = async () => {
|
||||
try {
|
||||
const templateData = await notificationService.getTemplates();
|
||||
setTemplates(templateData);
|
||||
|
||||
// Initialize workflow preferences if not set
|
||||
const initialPrefs: WorkflowPreferences = {};
|
||||
templateData.forEach((template) => {
|
||||
if (!(template.workflow_id in workflowPreferences)) {
|
||||
initialPrefs[template.workflow_id] = true;
|
||||
}
|
||||
});
|
||||
if (Object.keys(initialPrefs).length > 0) {
|
||||
setWorkflowPreferences((prev) => ({ ...prev, ...initialPrefs }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading templates:', error);
|
||||
}
|
||||
};
|
||||
return <div className="space-y-8">
|
||||
{/* Email Notifications */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="w-5 h-5" />
|
||||
<h3 className="text-lg font-medium">Email Notifications</h3>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
|
||||
const savePreferences = async () => {
|
||||
if (!user) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await notificationService.updatePreferences(user.id, {
|
||||
channelPreferences,
|
||||
workflowPreferences,
|
||||
frequencySettings,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success("Notification preferences saved successfully");
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to save preferences');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error saving preferences:', error);
|
||||
toast.error(error.message || "Failed to save notification preferences");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateChannelPreference = (channel: keyof ChannelPreferences, value: boolean) => {
|
||||
setChannelPreferences((prev) => ({ ...prev, [channel]: value }));
|
||||
};
|
||||
|
||||
const updateWorkflowPreference = (workflowId: string, value: boolean) => {
|
||||
setWorkflowPreferences((prev) => ({ ...prev, [workflowId]: value }));
|
||||
};
|
||||
|
||||
const requestPushPermission = async () => {
|
||||
if (!('Notification' in window)) {
|
||||
toast.error("This browser doesn't support push notifications");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission === 'granted') {
|
||||
updateChannelPreference('push', true);
|
||||
toast.success("Push notifications enabled");
|
||||
} else {
|
||||
toast.error("Push notification permission denied");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error requesting push permission:', error);
|
||||
toast.error("Failed to enable push notifications");
|
||||
}
|
||||
};
|
||||
|
||||
const groupedTemplates = templates.reduce((acc, template) => {
|
||||
if (!acc[template.category]) {
|
||||
acc[template.category] = [];
|
||||
}
|
||||
acc[template.category].push(template);
|
||||
return acc;
|
||||
}, {} as Record<string, NotificationTemplate[]>);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{!isNovuEnabled && (
|
||||
<Card className="border-yellow-500/50 bg-yellow-500/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-yellow-600 dark:text-yellow-400">Novu Not Configured</CardTitle>
|
||||
<CardDescription>
|
||||
Choose which email notifications you'd like to receive.
|
||||
Novu notifications are not configured. To enable advanced notifications, add your Novu Application Identifier to the environment variables.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label>Review Replies</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Get notified when someone replies to your reviews
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={emailNotifications.review_replies} onCheckedChange={checked => updateEmailNotification('review_replies', checked)} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label>New Followers</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Get notified when someone follows you
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={emailNotifications.new_followers} onCheckedChange={checked => updateEmailNotification('new_followers', checked)} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label>System Announcements</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Important updates and announcements from ThrillWiki
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={emailNotifications.system_announcements} onCheckedChange={checked => updateEmailNotification('system_announcements', checked)} />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label>Weekly Digest</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Weekly summary of new parks, rides, and community activity
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={emailNotifications.weekly_digest} onCheckedChange={checked => updateEmailNotification('weekly_digest', checked)} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label>Monthly Digest</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Monthly roundup of popular content and your activity stats
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={emailNotifications.monthly_digest} onCheckedChange={checked => updateEmailNotification('monthly_digest', checked)} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notification Channels</CardTitle>
|
||||
<CardDescription>
|
||||
Choose which channels you'd like to receive notifications through
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="flex items-center gap-2">
|
||||
In-App Notifications
|
||||
<Badge variant="secondary" className="text-xs">Real-time</Badge>
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Receive notifications within the application
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={channelPreferences.in_app}
|
||||
onCheckedChange={(checked) => updateChannelPreference('in_app', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Push Notifications */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Smartphone className="w-5 h-5" />
|
||||
<h3 className="text-lg font-medium">Push Notifications</h3>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>
|
||||
Receive instant notifications in your browser when important events happen.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label>Browser Notifications</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enable push notifications in your browser
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!pushNotifications.browser_enabled && <Button variant="outline" size="sm" onClick={requestPushPermission}>
|
||||
Enable
|
||||
</Button>}
|
||||
<Switch checked={pushNotifications.browser_enabled} onCheckedChange={checked => {
|
||||
if (!checked) {
|
||||
updatePushNotification('browser_enabled', false);
|
||||
} else {
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Email Notifications</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Receive notifications via email
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={channelPreferences.email}
|
||||
onCheckedChange={(checked) => updateChannelPreference('email', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Push Notifications</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Browser push notifications
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={channelPreferences.push}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
requestPushPermission();
|
||||
} else {
|
||||
updateChannelPreference('push', false);
|
||||
}
|
||||
}} />
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isNovuEnabled && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between opacity-50">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="flex items-center gap-2">
|
||||
SMS Notifications
|
||||
<Badge variant="outline" className="text-xs">Coming Soon</Badge>
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Receive notifications via text message
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={channelPreferences.sms}
|
||||
onCheckedChange={(checked) => updateChannelPreference('sms', checked)}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{pushNotifications.browser_enabled && <>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notification Frequency</CardTitle>
|
||||
<CardDescription>
|
||||
Control how often you receive notifications
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Digest Frequency</Label>
|
||||
<Select
|
||||
value={frequencySettings.digest}
|
||||
onValueChange={(value: any) =>
|
||||
setFrequencySettings((prev) => ({ ...prev, digest: value }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="realtime">Real-time</SelectItem>
|
||||
<SelectItem value="hourly">Hourly</SelectItem>
|
||||
<SelectItem value="daily">Daily</SelectItem>
|
||||
<SelectItem value="weekly">Weekly</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Group notifications and send them in batches
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Maximum Notifications Per Hour</Label>
|
||||
<Select
|
||||
value={frequencySettings.max_per_hour.toString()}
|
||||
onValueChange={(value) =>
|
||||
setFrequencySettings((prev) => ({ ...prev, max_per_hour: parseInt(value) }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">5 per hour</SelectItem>
|
||||
<SelectItem value="10">10 per hour</SelectItem>
|
||||
<SelectItem value="20">20 per hour</SelectItem>
|
||||
<SelectItem value="50">50 per hour</SelectItem>
|
||||
<SelectItem value="999">Unlimited</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Limit the number of notifications you receive per hour
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{Object.keys(groupedTemplates).map((category) => (
|
||||
<Card key={category}>
|
||||
<CardHeader>
|
||||
<CardTitle className="capitalize">{category} Notifications</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your {category} notification preferences
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{groupedTemplates[category].map((template, index) => (
|
||||
<div key={template.id}>
|
||||
{index > 0 && <Separator className="my-4" />}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label>New Content</Label>
|
||||
<div className="space-y-0.5">
|
||||
<Label>{template.name}</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Notifications about new parks, rides, and reviews
|
||||
{template.description}
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={pushNotifications.new_content} onCheckedChange={checked => updatePushNotification('new_content', checked)} />
|
||||
<Switch
|
||||
checked={workflowPreferences[template.workflow_id] ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateWorkflowPreference(template.workflow_id, checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label>Social Updates</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Notifications about followers, replies, and mentions
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={pushNotifications.social_updates} onCheckedChange={checked => updatePushNotification('social_updates', checked)} />
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
|
||||
{/* Sound Settings */}
|
||||
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={saveNotificationPreferences} disabled={loading}>
|
||||
{loading ? 'Saving...' : 'Save Notification Preferences'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
<Button
|
||||
onClick={savePreferences}
|
||||
className="w-full"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Saving...' : 'Save Notification Preferences'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user