## 🐛 Critical Fixes ### Issue 1: Toggling One Channel Affects Both **Problem:** Disabling email disabled both email and push **Root Cause:** Optimistic update with `onSettled` refetch caused race condition **Fix:** Removed optimistic update, use server response directly **Before:** ```ts onMutate: async () => { // Optimistic update queryClient.setQueryData(...) } onSettled: () => { // This refetch caused race condition queryClient.invalidateQueries(...) } ``` **After:** ```ts onSuccess: (data, variables) => { // Update cache with verified server response queryClient.setQueryData([...], (old) => old.map(channel => channel.id === variables.channelId ? { ...channel, enabled: data.enabled } : channel ) ); } ``` ### Issue 2: Events Cannot Be Enabled **Problem:** All event channels disabled and cannot be enabled **Root Cause:** Wrong data structure in `update_event()` **Before:** ```php $settings[$event_id][$channel_id] = [...]; // Saved as: { "order_placed": { "email": {...} } } ``` **After:** ```php $settings[$event_id]['channels'][$channel_id] = [...]; // Saves as: { "order_placed": { "channels": { "email": {...} } } } ``` ### Issue 3: POST Data Not Parsed **Problem:** Event updates not working **Root Cause:** Using `get_param()` instead of `get_json_params()` **Fix:** Changed to `get_json_params()` in `update_event()` ### What Was Fixed 1. ✅ Channel toggles work independently 2. ✅ No race conditions from optimistic updates 3. ✅ Event channel data structure matches get_events 4. ✅ Event toggles save correctly 5. ✅ POST data parsed properly 6. ✅ Boolean type enforcement ### Data Structure **Correct Structure:** ```php [ 'order_placed' => [ 'channels' => [ 'email' => ['enabled' => true, 'recipient' => 'admin'], 'push' => ['enabled' => false, 'recipient' => 'admin'] ] ] ] ``` --- **All toggles should now work correctly!** ✅
367 lines
14 KiB
TypeScript
367 lines
14 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { api } from '@/lib/api';
|
|
import { SettingsCard } from '../components/SettingsCard';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { RefreshCw, Mail, MessageCircle, Send, Bell, ExternalLink, Settings, Check, X } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { __ } from '@/lib/i18n';
|
|
import ChannelConfig from './ChannelConfig';
|
|
|
|
interface NotificationChannel {
|
|
id: string;
|
|
label: string;
|
|
icon: string;
|
|
enabled: boolean;
|
|
builtin?: boolean;
|
|
addon?: string;
|
|
}
|
|
|
|
// Helper function to convert VAPID key
|
|
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;
|
|
}
|
|
|
|
export default function NotificationChannels() {
|
|
const queryClient = useQueryClient();
|
|
const [pushSubscribed, setPushSubscribed] = useState(false);
|
|
const [pushSupported, setPushSupported] = useState(false);
|
|
const [configOpen, setConfigOpen] = useState(false);
|
|
const [selectedChannel, setSelectedChannel] = useState<NotificationChannel | null>(null);
|
|
|
|
// Fetch channels
|
|
const { data: channels, isLoading } = useQuery({
|
|
queryKey: ['notification-channels'],
|
|
queryFn: () => api.get('/notifications/channels'),
|
|
});
|
|
|
|
// Toggle channel mutation
|
|
const toggleChannelMutation = useMutation({
|
|
mutationFn: async ({ channelId, enabled }: { channelId: string; enabled: boolean }) => {
|
|
const response = await api.post('/notifications/channels/toggle', { channelId, enabled });
|
|
return response;
|
|
},
|
|
onSuccess: (data, variables) => {
|
|
// Update cache with server response
|
|
queryClient.setQueryData(['notification-channels'], (old: any) => {
|
|
if (!old) return old;
|
|
return old.map((channel: any) =>
|
|
channel.id === variables.channelId
|
|
? { ...channel, enabled: data.enabled }
|
|
: channel
|
|
);
|
|
});
|
|
toast.success(__('Channel updated'));
|
|
},
|
|
onError: (error: any) => {
|
|
// Refetch on error to sync with server
|
|
queryClient.invalidateQueries({ queryKey: ['notification-channels'] });
|
|
toast.error(error?.message || __('Failed to update channel'));
|
|
},
|
|
});
|
|
|
|
// Check push notification support
|
|
useEffect(() => {
|
|
if ('Notification' in window && 'serviceWorker' in navigator && 'PushManager' in window) {
|
|
setPushSupported(true);
|
|
// Check if already subscribed
|
|
checkPushSubscription();
|
|
}
|
|
}, []);
|
|
|
|
const checkPushSubscription = async () => {
|
|
try {
|
|
const registration = await navigator.serviceWorker.ready;
|
|
const subscription = await registration.pushManager.getSubscription();
|
|
setPushSubscribed(!!subscription);
|
|
} catch (error) {
|
|
console.error('Error checking push subscription:', error);
|
|
}
|
|
};
|
|
|
|
const subscribeToPush = useMutation({
|
|
mutationFn: async () => {
|
|
// Request notification permission
|
|
const permission = await Notification.requestPermission();
|
|
if (permission !== 'granted') {
|
|
throw new Error('Notification permission denied');
|
|
}
|
|
|
|
// Get VAPID public key
|
|
const { publicKey } = await api.get('/notifications/push/vapid-key');
|
|
|
|
// Register service worker if not already registered
|
|
let registration = await navigator.serviceWorker.getRegistration();
|
|
if (!registration) {
|
|
// For now, we'll wait for service worker to be registered elsewhere
|
|
// In production, you'd register it here
|
|
registration = await navigator.serviceWorker.ready;
|
|
}
|
|
|
|
// Subscribe to push
|
|
const subscription = await registration.pushManager.subscribe({
|
|
userVisibleOnly: true,
|
|
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
|
});
|
|
|
|
// Send subscription to server
|
|
await api.post('/notifications/push/subscribe', {
|
|
subscription: subscription.toJSON(),
|
|
});
|
|
|
|
return subscription;
|
|
},
|
|
onSuccess: () => {
|
|
setPushSubscribed(true);
|
|
toast.success(__('Push notifications enabled'));
|
|
},
|
|
onError: (error: any) => {
|
|
console.error('Push subscription error:', error);
|
|
toast.error(error?.message || __('Failed to enable push notifications'));
|
|
},
|
|
});
|
|
|
|
const unsubscribeFromPush = useMutation({
|
|
mutationFn: async () => {
|
|
const registration = await navigator.serviceWorker.ready;
|
|
const subscription = await registration.pushManager.getSubscription();
|
|
if (subscription) {
|
|
await subscription.unsubscribe();
|
|
// Notify server
|
|
await api.post('/notifications/push/unsubscribe', {
|
|
subscriptionId: btoa(JSON.stringify(subscription.toJSON())),
|
|
});
|
|
}
|
|
},
|
|
onSuccess: () => {
|
|
setPushSubscribed(false);
|
|
toast.success(__('Push notifications disabled'));
|
|
},
|
|
onError: (error: any) => {
|
|
toast.error(error?.message || __('Failed to disable push notifications'));
|
|
},
|
|
});
|
|
|
|
const getChannelIcon = (channelId: string) => {
|
|
switch (channelId) {
|
|
case 'email':
|
|
return <Mail className="h-5 w-5" />;
|
|
case 'whatsapp':
|
|
return <MessageCircle className="h-5 w-5" />;
|
|
case 'telegram':
|
|
return <Send className="h-5 w-5" />;
|
|
default:
|
|
return <Bell className="h-5 w-5" />;
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-12">
|
|
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const builtinChannels = channels?.filter((c: NotificationChannel) => c.builtin) || [];
|
|
const addonChannels = channels?.filter((c: NotificationChannel) => !c.builtin) || [];
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Info Card */}
|
|
<SettingsCard
|
|
title={__('Notification Channels')}
|
|
description={__('Configure how notifications are sent')}
|
|
>
|
|
<div className="text-sm space-y-3">
|
|
<p className="text-muted-foreground">
|
|
{__(
|
|
'Channels are the different ways notifications can be sent. Email is built-in and always available. Install addons to enable additional channels like WhatsApp, Telegram, SMS, and more.'
|
|
)}
|
|
</p>
|
|
</div>
|
|
</SettingsCard>
|
|
|
|
{/* All Channels */}
|
|
<SettingsCard title={__('Channels')} description={__('Manage notification delivery channels')}>
|
|
<div className="space-y-4">
|
|
{builtinChannels.map((channel: NotificationChannel) => (
|
|
<div key={channel.id} className="flex flex-col sm:flex-row sm:items-center gap-4 p-4 rounded-lg border bg-card">
|
|
<div className="flex items-start gap-3 flex-1 min-w-0">
|
|
<div className={`p-3 rounded-lg shrink-0 ${channel.enabled ? 'bg-green-500/20 text-green-600' : 'bg-muted text-muted-foreground'}`}>
|
|
{getChannelIcon(channel.id)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<h3 className="font-medium">{channel.label}</h3>
|
|
<Badge variant="secondary" className="text-xs">
|
|
{__('Built-in')}
|
|
</Badge>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">
|
|
{channel.id === 'email' &&
|
|
__('Email notifications powered by WooCommerce. Configure templates and SMTP settings.')}
|
|
{channel.id === 'push' &&
|
|
__('Browser push notifications for real-time updates. Perfect for PWA.')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 sm:gap-2">
|
|
{/* Channel Enable/Disable Toggle */}
|
|
<div className="flex items-center justify-between sm:justify-start gap-2 p-2 sm:p-0 rounded-lg sm:rounded-none border sm:border-0">
|
|
<span className="text-sm text-muted-foreground">
|
|
{channel.enabled ? __('Enabled') : __('Disabled')}
|
|
</span>
|
|
<Switch
|
|
checked={channel.enabled}
|
|
onCheckedChange={(checked) => {
|
|
toggleChannelMutation.mutate({ channelId: channel.id, enabled: checked });
|
|
// If enabling push, also subscribe
|
|
if (channel.id === 'push' && checked && pushSupported && !pushSubscribed) {
|
|
subscribeToPush.mutate();
|
|
}
|
|
}}
|
|
disabled={toggleChannelMutation.isPending}
|
|
/>
|
|
</div>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
setSelectedChannel(channel);
|
|
setConfigOpen(true);
|
|
}}
|
|
className="w-full sm:w-auto"
|
|
>
|
|
<Settings className="h-4 w-4 sm:mr-2" />
|
|
<span className="sm:inline">{__('Configure')}</span>
|
|
</Button>
|
|
|
|
{channel.id === 'push' && !pushSupported && (
|
|
<Badge variant="destructive" className="text-xs w-full sm:w-auto justify-center">
|
|
{__('Not Supported')}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</SettingsCard>
|
|
|
|
{/* Addon Channels */}
|
|
{addonChannels.length > 0 ? (
|
|
<SettingsCard title={__('Addon Channels')} description={__('Channels provided by installed addons')}>
|
|
<div className="space-y-4">
|
|
{addonChannels.map((channel: NotificationChannel) => (
|
|
<div key={channel.id} className="flex items-center justify-between p-4 rounded-lg border bg-card">
|
|
<div className="flex items-center gap-4">
|
|
<div className="p-3 rounded-lg bg-primary/10">{getChannelIcon(channel.id)}</div>
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<h3 className="font-medium">{channel.label}</h3>
|
|
<Badge variant="outline" className="text-xs">
|
|
{__('Addon')}
|
|
</Badge>
|
|
<Badge variant={channel.enabled ? 'default' : 'secondary'} className="text-xs">
|
|
{channel.enabled ? __('Active') : __('Inactive')}
|
|
</Badge>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">
|
|
{__('Provided by')} {channel.addon}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<Button variant="outline" size="sm">
|
|
<Settings className="h-4 w-4 mr-2" />
|
|
{__('Configure')}
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</SettingsCard>
|
|
) : (
|
|
<SettingsCard
|
|
title={__('Extend with Addons')}
|
|
description={__('Add more notification channels to your store')}
|
|
>
|
|
<div className="space-y-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
{__(
|
|
'Install notification addons to send notifications via WhatsApp, Telegram, SMS, and more.'
|
|
)}
|
|
</p>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
{/* Example addon cards */}
|
|
<div className="p-4 rounded-lg border bg-card">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<MessageCircle className="h-5 w-5 text-green-600" />
|
|
<h4 className="font-medium">{__('WhatsApp Notifications')}</h4>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground mb-3">
|
|
{__('Send order updates and notifications via WhatsApp Business API')}
|
|
</p>
|
|
<Button variant="outline" size="sm" className="w-full">
|
|
<ExternalLink className="h-4 w-4 mr-2" />
|
|
{__('View Addon')}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="p-4 rounded-lg border bg-card">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<Send className="h-5 w-5 text-blue-600" />
|
|
<h4 className="font-medium">{__('Telegram Notifications')}</h4>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground mb-3">
|
|
{__('Get instant notifications in your Telegram channel or group')}
|
|
</p>
|
|
<Button variant="outline" size="sm" className="w-full">
|
|
<ExternalLink className="h-4 w-4 mr-2" />
|
|
{__('View Addon')}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="p-4 rounded-lg border bg-card">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<Bell className="h-5 w-5 text-purple-600" />
|
|
<h4 className="font-medium">{__('SMS Notifications')}</h4>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground mb-3">
|
|
{__('Send SMS notifications via Twilio, Nexmo, or other providers')}
|
|
</p>
|
|
<Button variant="outline" size="sm" className="w-full">
|
|
<ExternalLink className="h-4 w-4 mr-2" />
|
|
{__('View Addon')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</SettingsCard>
|
|
)}
|
|
|
|
{/* Channel Configuration Dialog */}
|
|
{selectedChannel && (
|
|
<ChannelConfig
|
|
open={configOpen}
|
|
onClose={() => {
|
|
setConfigOpen(false);
|
|
setSelectedChannel(null);
|
|
}}
|
|
channelId={selectedChannel.id}
|
|
channelLabel={selectedChannel.label}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|