Files
WooNooW/admin-spa/src/routes/Settings/Notifications/Channels.tsx
dwindown 3ef5087f09 fix: Critical data structure and mutation bugs
## 🐛 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!** 
2025-11-11 16:05:21 +07:00

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>
);
}