Implement multi-channel notification system with: - Core notification service with email, push, and in-app channels - Email templates for all notification types (welcome, plant registered, transport alerts, farm alerts, harvest ready, demand matches, weekly digest) - Push notification support with VAPID authentication - In-app notification management with read/unread tracking - Notification scheduler for recurring and scheduled notifications - API endpoints for notifications CRUD, preferences, and subscriptions - UI components (NotificationBell, NotificationList, NotificationItem, PreferencesForm) - Full notifications page with preferences management - Service worker for push notification handling
285 lines
9.7 KiB
TypeScript
285 lines
9.7 KiB
TypeScript
/**
|
|
* PreferencesForm Component
|
|
* User notification preferences management
|
|
*/
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
|
|
interface NotificationPreferences {
|
|
email: boolean;
|
|
push: boolean;
|
|
inApp: boolean;
|
|
plantReminders: boolean;
|
|
transportAlerts: boolean;
|
|
farmAlerts: boolean;
|
|
harvestAlerts: boolean;
|
|
demandMatches: boolean;
|
|
weeklyDigest: boolean;
|
|
quietHoursStart?: string;
|
|
quietHoursEnd?: string;
|
|
timezone?: string;
|
|
}
|
|
|
|
interface PreferencesFormProps {
|
|
userId?: string;
|
|
onSave?: (preferences: NotificationPreferences) => void;
|
|
}
|
|
|
|
export function PreferencesForm({ userId = 'demo-user', onSave }: PreferencesFormProps) {
|
|
const [preferences, setPreferences] = useState<NotificationPreferences>({
|
|
email: true,
|
|
push: true,
|
|
inApp: true,
|
|
plantReminders: true,
|
|
transportAlerts: true,
|
|
farmAlerts: true,
|
|
harvestAlerts: true,
|
|
demandMatches: true,
|
|
weeklyDigest: true
|
|
});
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
|
|
|
useEffect(() => {
|
|
fetchPreferences();
|
|
}, [userId]);
|
|
|
|
async function fetchPreferences() {
|
|
try {
|
|
const response = await fetch(`/api/notifications/preferences?userId=${userId}`);
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
setPreferences(data.data);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch preferences:', error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setIsSaving(true);
|
|
setMessage(null);
|
|
|
|
try {
|
|
const response = await fetch('/api/notifications/preferences', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ...preferences, userId })
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
setMessage({ type: 'success', text: 'Preferences saved successfully!' });
|
|
onSave?.(data.data);
|
|
} else {
|
|
setMessage({ type: 'error', text: data.error || 'Failed to save preferences' });
|
|
}
|
|
} catch (error) {
|
|
setMessage({ type: 'error', text: 'Failed to save preferences' });
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}
|
|
|
|
function handleToggle(key: keyof NotificationPreferences) {
|
|
setPreferences(prev => ({
|
|
...prev,
|
|
[key]: !prev[key]
|
|
}));
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="p-8 text-center">
|
|
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-gray-200 border-t-green-500"></div>
|
|
<p className="mt-2 text-gray-500">Loading preferences...</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
{message && (
|
|
<div
|
|
className={`p-4 rounded-lg ${
|
|
message.type === 'success' ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'
|
|
}`}
|
|
>
|
|
{message.text}
|
|
</div>
|
|
)}
|
|
|
|
{/* Notification Channels */}
|
|
<div className="bg-white p-6 rounded-lg border">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Notification Channels</h3>
|
|
<p className="text-sm text-gray-600 mb-4">Choose how you want to receive notifications</p>
|
|
|
|
<div className="space-y-4">
|
|
<ToggleRow
|
|
label="Email notifications"
|
|
description="Receive notifications via email"
|
|
enabled={preferences.email}
|
|
onChange={() => handleToggle('email')}
|
|
/>
|
|
<ToggleRow
|
|
label="Push notifications"
|
|
description="Receive browser push notifications"
|
|
enabled={preferences.push}
|
|
onChange={() => handleToggle('push')}
|
|
/>
|
|
<ToggleRow
|
|
label="In-app notifications"
|
|
description="See notifications in the app"
|
|
enabled={preferences.inApp}
|
|
onChange={() => handleToggle('inApp')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Notification Types */}
|
|
<div className="bg-white p-6 rounded-lg border">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Notification Types</h3>
|
|
<p className="text-sm text-gray-600 mb-4">Choose which types of notifications you want to receive</p>
|
|
|
|
<div className="space-y-4">
|
|
<ToggleRow
|
|
label="Plant reminders"
|
|
description="Reminders for watering, fertilizing, and plant care"
|
|
enabled={preferences.plantReminders}
|
|
onChange={() => handleToggle('plantReminders')}
|
|
/>
|
|
<ToggleRow
|
|
label="Transport alerts"
|
|
description="Updates about plant transport and logistics"
|
|
enabled={preferences.transportAlerts}
|
|
onChange={() => handleToggle('transportAlerts')}
|
|
/>
|
|
<ToggleRow
|
|
label="Farm alerts"
|
|
description="Alerts about vertical farm conditions and issues"
|
|
enabled={preferences.farmAlerts}
|
|
onChange={() => handleToggle('farmAlerts')}
|
|
/>
|
|
<ToggleRow
|
|
label="Harvest alerts"
|
|
description="Notifications when crops are ready for harvest"
|
|
enabled={preferences.harvestAlerts}
|
|
onChange={() => handleToggle('harvestAlerts')}
|
|
/>
|
|
<ToggleRow
|
|
label="Demand matches"
|
|
description="Alerts when your supply matches consumer demand"
|
|
enabled={preferences.demandMatches}
|
|
onChange={() => handleToggle('demandMatches')}
|
|
/>
|
|
<ToggleRow
|
|
label="Weekly digest"
|
|
description="Weekly summary of your activity and insights"
|
|
enabled={preferences.weeklyDigest}
|
|
onChange={() => handleToggle('weeklyDigest')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Quiet Hours */}
|
|
<div className="bg-white p-6 rounded-lg border">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Quiet Hours</h3>
|
|
<p className="text-sm text-gray-600 mb-4">Set times when you don't want to receive notifications</p>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Start time</label>
|
|
<input
|
|
type="time"
|
|
value={preferences.quietHoursStart || ''}
|
|
onChange={e =>
|
|
setPreferences(prev => ({ ...prev, quietHoursStart: e.target.value }))
|
|
}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">End time</label>
|
|
<input
|
|
type="time"
|
|
value={preferences.quietHoursEnd || ''}
|
|
onChange={e =>
|
|
setPreferences(prev => ({ ...prev, quietHoursEnd: e.target.value }))
|
|
}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4">
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Timezone</label>
|
|
<select
|
|
value={preferences.timezone || ''}
|
|
onChange={e => setPreferences(prev => ({ ...prev, timezone: e.target.value }))}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
|
>
|
|
<option value="">Select timezone</option>
|
|
<option value="America/New_York">Eastern Time</option>
|
|
<option value="America/Chicago">Central Time</option>
|
|
<option value="America/Denver">Mountain Time</option>
|
|
<option value="America/Los_Angeles">Pacific Time</option>
|
|
<option value="Europe/London">London</option>
|
|
<option value="Europe/Paris">Paris</option>
|
|
<option value="Asia/Tokyo">Tokyo</option>
|
|
<option value="UTC">UTC</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Submit */}
|
|
<div className="flex justify-end">
|
|
<button
|
|
type="submit"
|
|
disabled={isSaving}
|
|
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
{isSaving ? 'Saving...' : 'Save Preferences'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
interface ToggleRowProps {
|
|
label: string;
|
|
description: string;
|
|
enabled: boolean;
|
|
onChange: () => void;
|
|
}
|
|
|
|
function ToggleRow({ label, description, enabled, onChange }: ToggleRowProps) {
|
|
return (
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="font-medium text-gray-900">{label}</p>
|
|
<p className="text-sm text-gray-500">{description}</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={enabled}
|
|
onClick={onChange}
|
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
|
|
enabled ? 'bg-green-600' : 'bg-gray-200'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
|
enabled ? 'translate-x-5' : 'translate-x-0'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|