feat: Restructure notifications - Staff and Customer separation (WIP)
## 🎯 Phase 1: Backend + Frontend Structure ### Backend Changes **NotificationsController.php:** - ✅ Added `/notifications/staff/events` endpoint - ✅ Added `/notifications/customer/events` endpoint - ✅ Created `get_all_events()` helper method - ✅ Added `recipient_type` field to all events - ✅ Filter events by recipient (staff vs customer) ### Frontend Changes **Main Notifications Page:** - ✅ Restructured to show cards for Staff, Customer, Activity Log - ✅ Entry point with clear separation - ✅ Modern card-based UI **Staff Notifications:** - ✅ Created `/settings/notifications/staff` route - ✅ Moved Channels.tsx → Staff/Channels.tsx - ✅ Moved Events.tsx → Staff/Events.tsx - ✅ Updated Staff/Events to use `/notifications/staff/events` - ✅ Fixed import paths ### Structure ``` Settings → Notifications ├── Staff Notifications (admin alerts) │ ├── Channels (Email, Push) │ ├── Events (Orders, Products, Customers) │ └── Templates └── Customer Notifications (customer emails) ├── Channels (Email, Push, SMS) ├── Events (Orders, Shipping, Account) └── Templates ``` --- **Next:** Customer notifications page + routes
This commit is contained in:
@@ -1,39 +1,116 @@
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { SettingsLayout } from './components/SettingsLayout';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import NotificationEvents from './Notifications/Events';
|
||||
import NotificationChannels from './Notifications/Channels';
|
||||
import NotificationTemplates from './Notifications/Templates';
|
||||
import { Bell, Users, ChevronRight, Activity } from 'lucide-react';
|
||||
|
||||
export default function NotificationsSettings() {
|
||||
const [activeTab, setActiveTab] = useState('events');
|
||||
|
||||
return (
|
||||
<SettingsLayout
|
||||
title={__('Notifications')}
|
||||
description={__('Manage notification events, channels, and templates')}
|
||||
description={__('Manage staff and customer notifications')}
|
||||
action={<div />} // Empty action to trigger contextual header
|
||||
>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="events">{__('Events')}</TabsTrigger>
|
||||
<TabsTrigger value="channels">{__('Channels')}</TabsTrigger>
|
||||
<TabsTrigger value="templates">{__('Templates')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Staff Notifications */}
|
||||
<Card className="hover:shadow-md transition-shadow">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<Bell className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>{__('Staff Notifications')}</CardTitle>
|
||||
<CardDescription>
|
||||
{__('Alerts for admins and staff members')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__('Get notified about orders, low stock, new customers, and more. Configure email and push notifications for your team.')}
|
||||
</p>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{__('Orders, Products, Customers')}
|
||||
</div>
|
||||
<Link to="/settings/notifications/staff">
|
||||
<Button variant="outline" size="sm">
|
||||
{__('Configure')}
|
||||
<ChevronRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<TabsContent value="events" className="space-y-4">
|
||||
<NotificationEvents />
|
||||
</TabsContent>
|
||||
{/* Customer Notifications */}
|
||||
<Card className="hover:shadow-md transition-shadow">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-500/10 rounded-lg">
|
||||
<Users className="h-6 w-6 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>{__('Customer Notifications')}</CardTitle>
|
||||
<CardDescription>
|
||||
{__('Transactional emails and updates for customers')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__('Manage order confirmations, shipping updates, account emails, and marketing messages sent to your customers.')}
|
||||
</p>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{__('Orders, Shipping, Account')}
|
||||
</div>
|
||||
<Link to="/settings/notifications/customer">
|
||||
<Button variant="outline" size="sm">
|
||||
{__('Configure')}
|
||||
<ChevronRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<TabsContent value="channels" className="space-y-4">
|
||||
<NotificationChannels />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="templates" className="space-y-4">
|
||||
<NotificationTemplates />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{/* Activity Log */}
|
||||
<Card className="hover:shadow-md transition-shadow">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-500/10 rounded-lg">
|
||||
<Activity className="h-6 w-6 text-purple-500" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>{__('Activity Log')}</CardTitle>
|
||||
<CardDescription>
|
||||
{__('View notification history and activities')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__('Track all notification activities, view delivery status, and monitor system events.')}
|
||||
</p>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{__('Coming soon')}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
{__('View Log')}
|
||||
<ChevronRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
49
admin-spa/src/routes/Settings/Notifications/Staff.tsx
Normal file
49
admin-spa/src/routes/Settings/Notifications/Staff.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { SettingsLayout } from '../components/SettingsLayout';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import StaffChannels from './Staff/Channels';
|
||||
import StaffEvents from './Staff/Events';
|
||||
import NotificationTemplates from './Templates';
|
||||
|
||||
export default function StaffNotifications() {
|
||||
const [activeTab, setActiveTab] = useState('channels');
|
||||
|
||||
return (
|
||||
<SettingsLayout
|
||||
title={__('Staff Notifications')}
|
||||
description={__('Configure notifications for admins and staff members')}
|
||||
action={
|
||||
<Link to="/settings/notifications">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
{__('Back to Notifications')}
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="channels">{__('Channels')}</TabsTrigger>
|
||||
<TabsTrigger value="events">{__('Events')}</TabsTrigger>
|
||||
<TabsTrigger value="templates">{__('Templates')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="channels" className="space-y-4">
|
||||
<StaffChannels />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="events" className="space-y-4">
|
||||
<StaffEvents />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="templates" className="space-y-4">
|
||||
<NotificationTemplates recipientType="staff" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
366
admin-spa/src/routes/Settings/Notifications/Staff/Channels.tsx
Normal file
366
admin-spa/src/routes/Settings/Notifications/Staff/Channels.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
303
admin-spa/src/routes/Settings/Notifications/Staff/Events.tsx
Normal file
303
admin-spa/src/routes/Settings/Notifications/Staff/Events.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
import React from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { SettingsCard } from '../../components/SettingsCard';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { RefreshCw, Mail, MessageCircle, Send, Bell } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { __ } from '@/lib/i18n';
|
||||
|
||||
interface NotificationEvent {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
category: string;
|
||||
enabled: boolean;
|
||||
channels: {
|
||||
[channelId: string]: {
|
||||
enabled: boolean;
|
||||
recipient: 'admin' | 'customer' | 'both';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface NotificationChannel {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
enabled: boolean;
|
||||
builtin?: boolean;
|
||||
addon?: string;
|
||||
}
|
||||
|
||||
export default function NotificationEvents() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch staff events
|
||||
const { data: eventsData, isLoading: eventsLoading } = useQuery({
|
||||
queryKey: ['notification-staff-events'],
|
||||
queryFn: () => api.get('/notifications/staff/events'),
|
||||
});
|
||||
|
||||
// Fetch channels
|
||||
const { data: channels, isLoading: channelsLoading } = useQuery({
|
||||
queryKey: ['notification-channels'],
|
||||
queryFn: () => api.get('/notifications/channels'),
|
||||
});
|
||||
|
||||
// Update event mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async ({ eventId, channelId, enabled, recipient }: any) => {
|
||||
return api.post('/notifications/events/update', {
|
||||
eventId,
|
||||
channelId,
|
||||
enabled,
|
||||
recipient,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notification-staff-events'] });
|
||||
toast.success(__('Event settings updated'));
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error?.message || __('Failed to update event'));
|
||||
},
|
||||
});
|
||||
|
||||
const getChannelIcon = (channelId: string) => {
|
||||
switch (channelId) {
|
||||
case 'email':
|
||||
return <Mail className="h-4 w-4" />;
|
||||
case 'whatsapp':
|
||||
return <MessageCircle className="h-4 w-4" />;
|
||||
case 'telegram':
|
||||
return <Send className="h-4 w-4" />;
|
||||
default:
|
||||
return <Bell className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleChannel = (eventId: string, channelId: string, currentlyEnabled: boolean) => {
|
||||
updateMutation.mutate({
|
||||
eventId,
|
||||
channelId,
|
||||
enabled: !currentlyEnabled,
|
||||
recipient: 'admin', // Default recipient
|
||||
});
|
||||
};
|
||||
|
||||
if (eventsLoading || channelsLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const orderEvents = eventsData?.orders || [];
|
||||
const productEvents = eventsData?.products || [];
|
||||
const customerEvents = eventsData?.customers || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Info Card */}
|
||||
<SettingsCard
|
||||
title={__('Notification Events')}
|
||||
description={__('Configure which channels to use for each notification event')}
|
||||
>
|
||||
<div className="text-sm space-y-3">
|
||||
<p className="text-muted-foreground">
|
||||
{__(
|
||||
'Choose which notification channels (Email, WhatsApp, Telegram, etc.) should be used for each event. Enable multiple channels to send notifications through different mediums.'
|
||||
)}
|
||||
</p>
|
||||
<div className="bg-muted/50 rounded-lg p-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
💡 {__('Tip: Email is always available. Install addons to enable WhatsApp, Telegram, SMS, and other channels.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Order Events */}
|
||||
{orderEvents.length > 0 && (
|
||||
<SettingsCard
|
||||
title={__('Order Events')}
|
||||
description={__('Notifications triggered by order status changes')}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{orderEvents.map((event: NotificationEvent) => (
|
||||
<div key={event.id} className="pb-6 border-b last:border-0">
|
||||
<div className="mb-4">
|
||||
<h3 className="font-medium text-sm">{event.label}</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">{event.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Channel Selection */}
|
||||
<div className="space-y-3">
|
||||
{channels?.map((channel: NotificationChannel) => {
|
||||
const channelEnabled = event.channels?.[channel.id]?.enabled || false;
|
||||
const recipient = event.channels?.[channel.id]?.recipient || 'admin';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={channel.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg border bg-card"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${channelEnabled ? 'bg-green-500/20 text-green-600' : 'bg-muted text-muted-foreground'}`}>
|
||||
{getChannelIcon(channel.id)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{channel.label}</span>
|
||||
{channel.builtin && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{__('Built-in')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{channelEnabled && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{__('Send to')}: {recipient === 'admin' ? __('Admin') : recipient === 'customer' ? __('Customer') : __('Both')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={channelEnabled}
|
||||
onCheckedChange={() => toggleChannel(event.id, channel.id, channelEnabled)}
|
||||
disabled={!channel.enabled || updateMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
{/* Product Events */}
|
||||
{productEvents.length > 0 && (
|
||||
<SettingsCard
|
||||
title={__('Product Events')}
|
||||
description={__('Notifications about product stock levels')}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{productEvents.map((event: NotificationEvent) => (
|
||||
<div key={event.id} className="pb-6 border-b last:border-0">
|
||||
<div className="mb-4">
|
||||
<h3 className="font-medium text-sm">{event.label}</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">{event.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{channels?.map((channel: NotificationChannel) => {
|
||||
const channelEnabled = event.channels?.[channel.id]?.enabled || false;
|
||||
const recipient = event.channels?.[channel.id]?.recipient || 'admin';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={channel.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg border bg-card"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${channelEnabled ? 'bg-green-500/20 text-green-600' : 'bg-muted text-muted-foreground'}`}>
|
||||
{getChannelIcon(channel.id)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{channel.label}</span>
|
||||
{channel.builtin && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{__('Built-in')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{channelEnabled && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{__('Send to')}: {recipient === 'admin' ? __('Admin') : recipient === 'customer' ? __('Customer') : __('Both')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={channelEnabled}
|
||||
onCheckedChange={() => toggleChannel(event.id, channel.id, channelEnabled)}
|
||||
disabled={!channel.enabled || updateMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
{/* Customer Events */}
|
||||
{customerEvents.length > 0 && (
|
||||
<SettingsCard
|
||||
title={__('Customer Events')}
|
||||
description={__('Notifications about customer activities')}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{customerEvents.map((event: NotificationEvent) => (
|
||||
<div key={event.id} className="pb-6 border-b last:border-0">
|
||||
<div className="mb-4">
|
||||
<h3 className="font-medium text-sm">{event.label}</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">{event.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{channels?.map((channel: NotificationChannel) => {
|
||||
const channelEnabled = event.channels?.[channel.id]?.enabled || false;
|
||||
const recipient = event.channels?.[channel.id]?.recipient || 'admin';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={channel.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg border bg-card"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${channelEnabled ? 'bg-green-500/20 text-green-600' : 'bg-muted text-muted-foreground'}`}>
|
||||
{getChannelIcon(channel.id)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{channel.label}</span>
|
||||
{channel.builtin && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{__('Built-in')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{channelEnabled && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{__('Send to')}: {recipient === 'admin' ? __('Admin') : recipient === 'customer' ? __('Customer') : __('Both')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={channelEnabled}
|
||||
onCheckedChange={() => toggleChannel(event.id, channel.id, channelEnabled)}
|
||||
disabled={!channel.enabled || updateMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -147,6 +147,24 @@ class NotificationsController {
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
],
|
||||
]);
|
||||
|
||||
// GET /woonoow/v1/notifications/staff/events
|
||||
register_rest_route($this->namespace, '/' . $this->rest_base . '/staff/events', [
|
||||
[
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_staff_events'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
],
|
||||
]);
|
||||
|
||||
// GET /woonoow/v1/notifications/customer/events
|
||||
register_rest_route($this->namespace, '/' . $this->rest_base . '/customer/events', [
|
||||
[
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_customer_events'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -290,6 +308,169 @@ class NotificationsController {
|
||||
return new WP_REST_Response($events, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get staff notification events (admin/staff recipient)
|
||||
*
|
||||
* @param WP_REST_Request $request Request object
|
||||
* @return WP_REST_Response
|
||||
*/
|
||||
public function get_staff_events(WP_REST_Request $request) {
|
||||
$all_events = $this->get_all_events();
|
||||
|
||||
// Filter events where default recipient is 'admin' or 'staff'
|
||||
$staff_events = [];
|
||||
foreach ($all_events as $category => $events) {
|
||||
$filtered = array_filter($events, function($event) {
|
||||
$first_channel = reset($event['channels']);
|
||||
return in_array($first_channel['recipient'] ?? 'admin', ['admin', 'staff']);
|
||||
});
|
||||
|
||||
if (!empty($filtered)) {
|
||||
$staff_events[$category] = array_values($filtered);
|
||||
}
|
||||
}
|
||||
|
||||
return new WP_REST_Response($staff_events, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get customer notification events (customer recipient)
|
||||
*
|
||||
* @param WP_REST_Request $request Request object
|
||||
* @return WP_REST_Response
|
||||
*/
|
||||
public function get_customer_events(WP_REST_Request $request) {
|
||||
$all_events = $this->get_all_events();
|
||||
|
||||
// Filter events where default recipient is 'customer'
|
||||
$customer_events = [];
|
||||
foreach ($all_events as $category => $events) {
|
||||
$filtered = array_filter($events, function($event) {
|
||||
$first_channel = reset($event['channels']);
|
||||
return ($first_channel['recipient'] ?? 'admin') === 'customer';
|
||||
});
|
||||
|
||||
if (!empty($filtered)) {
|
||||
$customer_events[$category] = array_values($filtered);
|
||||
}
|
||||
}
|
||||
|
||||
return new WP_REST_Response($customer_events, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all events (internal helper)
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function get_all_events() {
|
||||
// Get saved settings
|
||||
$settings = get_option('woonoow_notification_settings', []);
|
||||
|
||||
// Define all events
|
||||
$events = [
|
||||
'orders' => [
|
||||
[
|
||||
'id' => 'order_placed',
|
||||
'label' => __('Order Placed', 'woonoow'),
|
||||
'description' => __('When a new order is placed', 'woonoow'),
|
||||
'category' => 'orders',
|
||||
'wc_email' => 'new_order',
|
||||
'enabled' => true,
|
||||
'recipient_type' => 'staff',
|
||||
'channels' => $settings['order_placed']['channels'] ?? ['email' => ['enabled' => false, 'recipient' => 'admin'], 'push' => ['enabled' => false, 'recipient' => 'admin']],
|
||||
],
|
||||
[
|
||||
'id' => 'order_processing',
|
||||
'label' => __('Order Processing', 'woonoow'),
|
||||
'description' => __('When order status changes to processing', 'woonoow'),
|
||||
'category' => 'orders',
|
||||
'wc_email' => 'customer_processing_order',
|
||||
'enabled' => true,
|
||||
'recipient_type' => 'customer',
|
||||
'channels' => $settings['order_processing']['channels'] ?? ['email' => ['enabled' => false, 'recipient' => 'customer'], 'push' => ['enabled' => false, 'recipient' => 'customer']],
|
||||
],
|
||||
[
|
||||
'id' => 'order_completed',
|
||||
'label' => __('Order Completed', 'woonoow'),
|
||||
'description' => __('When order is marked as completed', 'woonoow'),
|
||||
'category' => 'orders',
|
||||
'wc_email' => 'customer_completed_order',
|
||||
'enabled' => true,
|
||||
'recipient_type' => 'customer',
|
||||
'channels' => $settings['order_completed']['channels'] ?? ['email' => ['enabled' => false, 'recipient' => 'customer'], 'push' => ['enabled' => false, 'recipient' => 'customer']],
|
||||
],
|
||||
[
|
||||
'id' => 'order_cancelled',
|
||||
'label' => __('Order Cancelled', 'woonoow'),
|
||||
'description' => __('When order is cancelled', 'woonoow'),
|
||||
'category' => 'orders',
|
||||
'wc_email' => 'cancelled_order',
|
||||
'enabled' => true,
|
||||
'recipient_type' => 'staff',
|
||||
'channels' => $settings['order_cancelled']['channels'] ?? ['email' => ['enabled' => false, 'recipient' => 'admin'], 'push' => ['enabled' => false, 'recipient' => 'admin']],
|
||||
],
|
||||
[
|
||||
'id' => 'order_refunded',
|
||||
'label' => __('Order Refunded', 'woonoow'),
|
||||
'description' => __('When order is refunded', 'woonoow'),
|
||||
'category' => 'orders',
|
||||
'wc_email' => 'customer_refunded_order',
|
||||
'enabled' => true,
|
||||
'recipient_type' => 'customer',
|
||||
'channels' => $settings['order_refunded']['channels'] ?? ['email' => ['enabled' => false, 'recipient' => 'customer'], 'push' => ['enabled' => false, 'recipient' => 'customer']],
|
||||
],
|
||||
],
|
||||
'products' => [
|
||||
[
|
||||
'id' => 'low_stock',
|
||||
'label' => __('Low Stock Alert', 'woonoow'),
|
||||
'description' => __('When product stock is low', 'woonoow'),
|
||||
'category' => 'products',
|
||||
'wc_email' => 'low_stock',
|
||||
'enabled' => true,
|
||||
'recipient_type' => 'staff',
|
||||
'channels' => $settings['low_stock']['channels'] ?? ['email' => ['enabled' => false, 'recipient' => 'admin'], 'push' => ['enabled' => false, 'recipient' => 'admin']],
|
||||
],
|
||||
[
|
||||
'id' => 'out_of_stock',
|
||||
'label' => __('Out of Stock Alert', 'woonoow'),
|
||||
'description' => __('When product is out of stock', 'woonoow'),
|
||||
'category' => 'products',
|
||||
'wc_email' => 'no_stock',
|
||||
'enabled' => true,
|
||||
'recipient_type' => 'staff',
|
||||
'channels' => $settings['out_of_stock']['channels'] ?? ['email' => ['enabled' => false, 'recipient' => 'admin'], 'push' => ['enabled' => false, 'recipient' => 'admin']],
|
||||
],
|
||||
],
|
||||
'customers' => [
|
||||
[
|
||||
'id' => 'new_customer',
|
||||
'label' => __('New Customer', 'woonoow'),
|
||||
'description' => __('When a new customer registers', 'woonoow'),
|
||||
'category' => 'customers',
|
||||
'wc_email' => 'customer_new_account',
|
||||
'enabled' => true,
|
||||
'recipient_type' => 'customer',
|
||||
'channels' => $settings['new_customer']['channels'] ?? ['email' => ['enabled' => false, 'recipient' => 'customer'], 'push' => ['enabled' => false, 'recipient' => 'customer']],
|
||||
],
|
||||
[
|
||||
'id' => 'customer_note',
|
||||
'label' => __('Customer Note Added', 'woonoow'),
|
||||
'description' => __('When a note is added to customer order', 'woonoow'),
|
||||
'category' => 'customers',
|
||||
'wc_email' => 'customer_note',
|
||||
'enabled' => true,
|
||||
'recipient_type' => 'customer',
|
||||
'channels' => $settings['customer_note']['channels'] ?? ['email' => ['enabled' => false, 'recipient' => 'customer'], 'push' => ['enabled' => false, 'recipient' => 'customer']],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
// Allow addons to add custom events
|
||||
return apply_filters('woonoow_notification_events', $events);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update event settings
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user