Datavision / frontend /src /pages /NotificationSettings.tsx
DataVision CI/CD Bot
release: clean production build for HuggingFace Space
09801ca
Raw
History Blame Contribute Delete
16.8 kB
import React, { useState, useEffect } from 'react';
import { useUserStore } from '../store/userStore';
import { auth } from '../lib/auth-client';
import { Bell, Mail, MessageSquare, TrendingUp, Moon } from 'lucide-react';
import { useToast } from '@/contexts/ToastContext';
interface NotificationSettings {
email_notifications: boolean;
push_notifications: boolean;
weekly_reports: boolean;
ai_insights: boolean;
severity_threshold: 'low' | 'medium' | 'high';
dnd_start: string | null;
dnd_end: string | null;
}
export default function NotificationSettings() {
const { user } = useUserStore();
const toast = useToast();
const [settings, setSettings] = useState<NotificationSettings>({
email_notifications: true,
push_notifications: false,
weekly_reports: true,
ai_insights: true,
severity_threshold: 'medium',
dnd_start: null,
dnd_end: null,
});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
loadSettings();
}, [user]);
async function loadSettings() {
if (!user) return;
try {
// FIRST: Try to load from localStorage (fastest and most reliable)
const localStorageKey = `notification_settings_${user.id}`;
const savedSettings = localStorage.getItem(localStorageKey);
if (savedSettings) {
const parsed = JSON.parse(savedSettings);
setSettings(parsed);
console.log('✓ Loaded settings from localStorage:', parsed);
setLoading(false);
return;
}
// SECOND: Try to load from API
const workspaceId = user.id;
const session = await auth.getSession();
const token = session.data.session?.access_token;
if (!token) {
console.warn('No auth token, using default settings');
setLoading(false);
return;
}
const response = await fetch(
`/api/settings/${workspaceId}/${user.id}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (response.ok) {
const data = await response.json();
if (data) {
setSettings(data);
// Save to localStorage as backup
localStorage.setItem(localStorageKey, JSON.stringify(data));
console.log('✓ Loaded settings from API and saved to localStorage');
}
} else if (response.status === 404) {
console.log('No saved settings found, using defaults');
} else {
console.error('Failed to load settings:', response.statusText);
}
} catch (error) {
console.error('Failed to load settings:', error);
} finally {
setLoading(false);
}
}
async function updateSetting(key: keyof NotificationSettings, value: any) {
if (!user) return;
// Optimistic update
const newSettings = { ...settings, [key]: value };
setSettings(newSettings);
// IMMEDIATELY save to localStorage (guaranteed to work)
const localStorageKey = `notification_settings_${user.id}`;
localStorage.setItem(localStorageKey, JSON.stringify(newSettings));
console.log(`✓ Setting saved to localStorage: ${key} = ${value}`);
setSaving(true);
try {
// Also try to save to API (best effort)
const workspaceId = user.id;
const session = await auth.getSession();
const token = session.data.session?.access_token;
if (!token) {
console.warn('No auth token, settings saved locally only');
setSaving(false);
return;
}
const response = await fetch(`/api/settings/${workspaceId}/${user.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ [key]: value }),
});
if (response.ok) {
console.log(`✓ Setting also saved to API: ${key} = ${value}`);
} else {
console.warn('API save failed, but localStorage save succeeded');
}
// Handle push notification registration
if (key === 'push_notifications' && value === true) {
await registerPushNotifications(workspaceId);
}
} catch (error) {
console.error('Failed to update setting in API:', error);
console.log('Settings still saved in localStorage');
} finally {
setSaving(false);
}
}
async function registerPushNotifications(workspaceId: string) {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
toast.warning('Push notifications are not supported in this browser');
setSettings((prev) => ({ ...prev, push_notifications: false }));
return;
}
try {
// Register service worker
const registration = await navigator.serviceWorker.register('/sw.js');
// Wait for service worker to be ready
await navigator.serviceWorker.ready;
// Subscribe to push notifications
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
import.meta.env.VITE_VAPID_PUBLIC_KEY || ''
),
});
// Send subscription to backend
const session = await auth.getSession();
const token = session.data.session?.access_token;
await fetch('/api/push/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
workspace_id: workspaceId,
user_id: user?.id,
token: JSON.stringify(subscription),
platform: 'web',
}),
});
toast.success('Push notifications enabled successfully');
console.log('Push notifications registered successfully');
} catch (error) {
console.error('Failed to register push notifications:', error);
toast.error('Failed to enable push notifications. Please try again.');
setSettings((prev) => ({ ...prev, push_notifications: false }));
}
}
function urlBase64ToUint8Array(base64String: string) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-orange-500"></div>
</div>
);
}
return (
<div className="max-w-3xl mx-auto p-4 sm:p-6">
{/* Header */}
<div className="mb-6 sm:mb-8">
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white flex items-center gap-2 sm:gap-3">
<Bell className="h-6 w-6 sm:h-8 sm:w-8 text-orange-500" />
Notifications
</h1>
<p className="text-sm sm:text-base text-gray-500 dark:text-gray-400 mt-2">
Manage how you receive updates from DataVision
</p>
</div>
{/* Settings List */}
<div className="space-y-3 sm:space-y-4">
{/* Email Notifications */}
<SettingCard
icon={Mail}
title="Email Notifications"
description="Receive updates via email"
checked={settings.email_notifications}
onChange={(checked) => updateSetting('email_notifications', checked)}
disabled={saving}
/>
{/* Push Notifications */}
<SettingCard
icon={MessageSquare}
title="Push Notifications"
description="Get browser notifications"
checked={settings.push_notifications}
onChange={(checked) => updateSetting('push_notifications', checked)}
disabled={saving}
/>
{/* Weekly Reports */}
<SettingCard
icon={TrendingUp}
title="Weekly Reports"
description="Automatic weekly business reports"
checked={settings.weekly_reports}
onChange={(checked) => updateSetting('weekly_reports', checked)}
disabled={saving}
/>
{/* AI Insights */}
<SettingCard
icon={Bell}
title="AI Insights"
description="Get notified about new insights"
checked={settings.ai_insights}
onChange={(checked) => updateSetting('ai_insights', checked)}
disabled={saving}
/>
</div>
{/* Advanced Settings */}
<div className="mt-6 sm:mt-8 p-4 sm:p-6 bg-gray-50 dark:bg-dark-surface rounded-xl border border-gray-200 dark:border-dark-border">
<h3 className="text-base sm:text-lg font-semibold text-gray-900 dark:text-white mb-4">
Advanced Settings
</h3>
{/* Severity Threshold */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Notification Severity Threshold
</label>
<select
value={settings.severity_threshold}
onChange={(e) =>
updateSetting(
'severity_threshold',
e.target.value as 'low' | 'medium' | 'high'
)
}
className="w-full px-4 py-3 sm:py-2 text-base bg-white dark:bg-dark-bg border border-gray-300 dark:border-dark-border rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent"
disabled={saving}
>
<option value="low">All Insights (Low, Medium, High)</option>
<option value="medium">Important Only (Medium, High)</option>
<option value="high">Critical Only (High)</option>
</select>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Only receive notifications for insights at or above this severity level
</p>
</div>
{/* Do Not Disturb */}
<div>
<div className="flex items-center gap-2 mb-2">
<Moon className="h-5 w-5 text-gray-600 dark:text-gray-400" />
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Do Not Disturb Window
</label>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<input
type="time"
value={settings.dnd_start || ''}
onChange={(e) => updateSetting('dnd_start', e.target.value)}
className="w-full px-4 py-3 sm:py-2 text-base bg-white dark:bg-dark-bg border border-gray-300 dark:border-dark-border rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent"
placeholder="Start time"
disabled={saving}
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Start time
</p>
</div>
<div>
<input
type="time"
value={settings.dnd_end || ''}
onChange={(e) => updateSetting('dnd_end', e.target.value)}
className="w-full px-4 py-3 sm:py-2 text-base bg-white dark:bg-dark-bg border border-gray-300 dark:border-dark-border rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent"
placeholder="End time"
disabled={saving}
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
End time
</p>
</div>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
No notifications will be sent during this time window
</p>
</div>
</div>
{/* Save Indicator */}
{saving && (
<div className="mt-4 text-center text-sm text-gray-500 dark:text-gray-400">
Saving...
</div>
)}
</div>
);
}
interface SettingCardProps {
icon: React.ElementType;
title: string;
description: string;
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}
function SettingCard({
icon: Icon,
title,
description,
checked,
onChange,
disabled,
}: SettingCardProps) {
return (
<div className="flex items-center justify-between p-4 sm:p-5 bg-white dark:bg-dark-surface rounded-xl border border-gray-200 dark:border-dark-border hover:border-orange-300 dark:hover:border-orange-700 transition-colors">
<div className="flex items-center gap-3 sm:gap-4 flex-1 min-w-0">
<div className="p-2 bg-orange-50 dark:bg-orange-900/20 rounded-lg flex-shrink-0">
<Icon className="h-5 w-5 text-orange-600 dark:text-orange-400" />
</div>
<div className="min-w-0 flex-1">
<h3 className="font-medium text-gray-900 dark:text-white text-sm sm:text-base">{title}</h3>
<p className="text-xs sm:text-sm text-gray-500 dark:text-gray-400 truncate">
{description}
</p>
</div>
</div>
{/* Toggle Switch */}
<label className="relative inline-flex items-center cursor-pointer flex-shrink-0 ml-3">
<input
type="checkbox"
className="sr-only peer"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
/>
<div
className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-orange-300 dark:peer-focus:ring-orange-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-orange-500 disabled:opacity-50 disabled:cursor-not-allowed"
></div>
</label>
</div>
);
}