From 01fc3eb36debbbb41b29e9ce2f9aeb97d2ed9ea2 Mon Sep 17 00:00:00 2001 From: dwindown Date: Tue, 11 Nov 2025 12:11:08 +0700 Subject: [PATCH] feat: Implement notification system with extensible channel architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ✅ Notification System Implementation Following NOTIFICATION_STRATEGY.md, built on top of WooCommerce email infrastructure. ### Backend (PHP) **1. NotificationManager** (`includes/Core/Notifications/NotificationManager.php`) - Central manager for notification system - Registers email channel (built-in) - Registers default notification events (orders, products, customers) - Provides hooks for addon channels - Maps to WooCommerce email IDs **2. NotificationSettingsProvider** (`includes/Core/Notifications/NotificationSettingsProvider.php`) - Manages settings in wp_options - Per-event channel configuration - Per-channel recipient settings (admin/customer/both) - Default settings with email enabled **3. NotificationsController** (`includes/Api/NotificationsController.php`) - REST API endpoints: - GET /notifications/channels - List available channels - GET /notifications/events - List notification events (grouped by category) - GET /notifications/settings - Get all settings - POST /notifications/settings - Update settings ### Frontend (React) **Updated Notifications.tsx:** - Shows available notification channels (email + addons) - Channel cards with built-in/addon badges - Event configuration by category (orders, products, customers) - Toggle channels per event with button UI - Link to WooCommerce advanced email settings - Responsive and modern UI ### Key Features ✅ **Built on WooCommerce Emails** - Email channel uses existing WC email system - No reinventing the wheel - Maps events to WC email IDs ✅ **Extensible Architecture** - Addons can register channels via hooks - `woonoow_notification_channels` filter - `woonoow_notification_send_{channel}` action - Per-event channel selection ✅ **User-Friendly UI** - Clear channel status (Active/Inactive) - Per-event channel toggles - Category grouping (orders, products, customers) - Addon discovery hints ✅ **Settings Storage** - Stored in wp_options (woonoow_notification_settings) - Per-event configuration - Per-channel settings - Default: email enabled for all events ### Addon Integration Example ```php // Addon registers WhatsApp channel add_action("woonoow_register_notification_channels", function() { NotificationManager::register_channel("whatsapp", [ "label" => "WhatsApp", "icon" => "message-circle", "addon" => "woonoow-whatsapp", ]); }); // Addon handles sending add_action("woonoow_notification_send_whatsapp", function($event_id, $data) { // Send WhatsApp message }, 10, 2); ``` ### Files Created - NotificationManager.php - NotificationSettingsProvider.php - NotificationsController.php ### Files Modified - Routes.php - Register NotificationsController - Bootstrap.php - Initialize NotificationManager - Notifications.tsx - New UI with channels and events --- **Ready for addon development!** 🚀 Next: Build Telegram addon as proof of concept --- .../src/routes/Settings/Notifications.tsx | 337 ++++++++++++------ includes/Api/NotificationsController.php | 181 ++++++++++ includes/Api/Routes.php | 5 + includes/Core/Bootstrap.php | 2 + .../Notifications/NotificationManager.php | 230 ++++++++++++ .../NotificationSettingsProvider.php | 182 ++++++++++ 6 files changed, 829 insertions(+), 108 deletions(-) create mode 100644 includes/Api/NotificationsController.php create mode 100644 includes/Core/Notifications/NotificationManager.php create mode 100644 includes/Core/Notifications/NotificationSettingsProvider.php diff --git a/admin-spa/src/routes/Settings/Notifications.tsx b/admin-spa/src/routes/Settings/Notifications.tsx index deb023e..69febdb 100644 --- a/admin-spa/src/routes/Settings/Notifications.tsx +++ b/admin-spa/src/routes/Settings/Notifications.tsx @@ -1,43 +1,76 @@ -import React from 'react'; +import React, { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { api } from '@/lib/api'; import { SettingsLayout } from './components/SettingsLayout'; import { SettingsCard } from './components/SettingsCard'; import { Switch } from '@/components/ui/switch'; import { Button } from '@/components/ui/button'; -import { ExternalLink, RefreshCw, Mail } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { ExternalLink, RefreshCw, Mail, MessageCircle, Send, Bell } from 'lucide-react'; import { toast } from 'sonner'; import { __ } from '@/lib/i18n'; export default function NotificationsSettings() { const queryClient = useQueryClient(); - const wcAdminUrl = (window as any).WNW_CONFIG?.wpAdminUrl || '/wp-admin'; + const [activeTab, setActiveTab] = useState<'channels' | 'events'>('channels'); - // Fetch email settings - const { data: settings, isLoading, refetch } = useQuery({ - queryKey: ['email-settings'], - queryFn: () => api.get('/settings/emails'), + // Fetch notification channels + const { data: channels, isLoading: channelsLoading } = useQuery({ + queryKey: ['notification-channels'], + queryFn: () => api.get('/notifications/channels'), }); - // Toggle email mutation - const toggleMutation = useMutation({ - mutationFn: async ({ emailId, enabled }: { emailId: string; enabled: boolean }) => { - return api.post(`/settings/emails/${emailId}/toggle`, { enabled }); + // Fetch notification events + const { data: events, isLoading: eventsLoading } = useQuery({ + queryKey: ['notification-events'], + queryFn: () => api.get('/notifications/events'), + }); + + // Fetch notification settings + const { data: settings, isLoading: settingsLoading } = useQuery({ + queryKey: ['notification-settings'], + queryFn: () => api.get('/notifications/settings'), + }); + + // Update settings mutation + const updateMutation = useMutation({ + mutationFn: async (newSettings: any) => { + return api.post('/notifications/settings', newSettings); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['email-settings'] }); - toast.success(__('Email settings updated')); + queryClient.invalidateQueries({ queryKey: ['notification-settings'] }); + queryClient.invalidateQueries({ queryKey: ['notification-events'] }); + toast.success(__('Notification settings updated')); }, onError: (error: any) => { - toast.error(error?.message || __('Failed to update email settings')); + toast.error(error?.message || __('Failed to update settings')); }, }); + const toggleEventChannel = (eventId: string, channelId: string, enabled: boolean) => { + const newSettings = { ...settings }; + if (!newSettings.events) newSettings.events = {}; + if (!newSettings.events[eventId]) newSettings.events[eventId] = { enabled: true, channels: [], recipients: {} }; + + const channels = newSettings.events[eventId].channels || []; + if (enabled) { + if (!channels.includes(channelId)) { + newSettings.events[eventId].channels = [...channels, channelId]; + } + } else { + newSettings.events[eventId].channels = channels.filter((c: string) => c !== channelId); + } + + updateMutation.mutate(newSettings); + }; + + const isLoading = channelsLoading || eventsLoading || settingsLoading; + if (isLoading) { return (
@@ -46,110 +79,198 @@ export default function NotificationsSettings() { ); } - const customerEmails = settings?.emails?.filter((e: any) => e.recipient === 'customer') || []; - const adminEmails = settings?.emails?.filter((e: any) => e.recipient === 'admin') || []; + const getChannelIcon = (channelId: string) => { + switch (channelId) { + case 'email': return ; + case 'whatsapp': return ; + case 'telegram': return ; + default: return ; + } + }; return ( refetch()} - > - - {__('Refresh')} - - } + description={__('Manage notifications sent via email and other channels')} >
- {/* Info Card - Shopify/Marketplace Style */} + {/* Notification Channels */} +
+ {channels?.map((channel: any) => ( +
+
+
+ {getChannelIcon(channel.id)} +
+
+
+

{channel.label}

+ {channel.builtin && ( + {__('Built-in')} + )} + {channel.addon && ( + {__('Addon')} + )} +
+ {channel.addon && ( +

+ {__('Provided by')} {channel.addon} +

+ )} +
+
+ + {channel.enabled ? __('Active') : __('Inactive')} + +
+ ))} + +
+

+ 💡 {__('Want more channels like WhatsApp, Telegram, or SMS? Install notification addons to extend your notification capabilities.')} +

+
+
+
+ + {/* Order Notifications */} + {events?.orders && events.orders.length > 0 && ( + +
+ {events.orders.map((event: any) => ( +
+
+
+

{event.label}

+

{event.description}

+
+
+
+ {channels?.map((channel: any) => { + const isEnabled = event.channels?.includes(channel.id); + return ( + + ); + })} +
+
+ ))} +
+
+ )} + + {/* Product Notifications */} + {events?.products && events.products.length > 0 && ( + +
+ {events.products.map((event: any) => ( +
+
+
+

{event.label}

+

{event.description}

+
+
+
+ {channels?.map((channel: any) => { + const isEnabled = event.channels?.includes(channel.id); + return ( + + ); + })} +
+
+ ))} +
+
+ )} + + {/* Customer Notifications */} + {events?.customers && events.customers.length > 0 && ( + +
+ {events.customers.map((event: any) => ( +
+
+
+

{event.label}

+

{event.description}

+
+
+
+ {channels?.map((channel: any) => { + const isEnabled = event.channels?.includes(channel.id); + return ( + + ); + })} +
+
+ ))} +
+
+ )} + + {/* WooCommerce Email Settings Link */} +

- {__('Control which email notifications are sent automatically when orders are placed, shipped, or updated. All emails use your store branding and can be customized in WooCommerce settings.')} + {__('Email notifications are powered by WooCommerce. For advanced customization like templates, subject lines, and sender details, use the WooCommerce email settings.')}

- -
-

- 💡 {__('Quick Tips')} -

-
    -
  • • {__('Keep order confirmation emails enabled - customers expect immediate confirmation')}
  • -
  • • {__('Enable shipping notifications to reduce "where is my order?" inquiries')}
  • -
  • • {__('Admin notifications help you stay on top of new orders and issues')}
  • -
  • • {__('Disable emails you don\'t need to reduce inbox clutter')}
  • -
-
- -
-

- {__('Need to customize email templates, subject lines, or sender details?')}{' '} - - {__('Go to advanced email settings →')} - -

-
-
-
- - {/* Customer Emails */} - -
- {customerEmails.map((email: any) => ( -
-
-

{email.title}

-

{email.description}

-
- toggleMutation.mutate({ - emailId: email.id, - enabled: checked - })} - disabled={toggleMutation.isPending} - /> -
- ))} -
-
- - {/* Admin Emails */} - -
- {adminEmails.map((email: any) => ( -
-
-

{email.title}

-

{email.description}

-
- toggleMutation.mutate({ - emailId: email.id, - enabled: checked - })} - disabled={toggleMutation.isPending} - /> -
- ))} +
diff --git a/includes/Api/NotificationsController.php b/includes/Api/NotificationsController.php new file mode 100644 index 0000000..6953ed7 --- /dev/null +++ b/includes/Api/NotificationsController.php @@ -0,0 +1,181 @@ +namespace, '/' . $this->rest_base . '/channels', [ + [ + 'methods' => 'GET', + 'callback' => [$this, 'get_channels'], + 'permission_callback' => [$this, 'check_permission'], + ], + ]); + + // GET /woonoow/v1/notifications/events + register_rest_route($this->namespace, '/' . $this->rest_base . '/events', [ + [ + 'methods' => 'GET', + 'callback' => [$this, 'get_events'], + 'permission_callback' => [$this, 'check_permission'], + ], + ]); + + // GET /woonoow/v1/notifications/settings + register_rest_route($this->namespace, '/' . $this->rest_base . '/settings', [ + [ + 'methods' => 'GET', + 'callback' => [$this, 'get_settings'], + 'permission_callback' => [$this, 'check_permission'], + ], + ]); + + // POST /woonoow/v1/notifications/settings + register_rest_route($this->namespace, '/' . $this->rest_base . '/settings', [ + [ + 'methods' => 'POST', + 'callback' => [$this, 'update_settings'], + 'permission_callback' => [$this, 'check_permission'], + ], + ]); + } + + /** + * Get available notification channels + * + * @param WP_REST_Request $request Request object + * @return WP_REST_Response + */ + public function get_channels(WP_REST_Request $request) { + $channels = NotificationManager::get_channels(); + + // Add enabled status from settings + $settings = NotificationSettingsProvider::get_settings(); + $channel_settings = $settings['channels'] ?? []; + + foreach ($channels as $id => &$channel) { + $channel['enabled'] = $channel_settings[$id]['enabled'] ?? $channel['builtin']; + } + + return new WP_REST_Response(array_values($channels), 200); + } + + /** + * Get notification events + * + * @param WP_REST_Request $request Request object + * @return WP_REST_Response + */ + public function get_events(WP_REST_Request $request) { + $events = NotificationManager::get_events(); + $settings = NotificationSettingsProvider::get_settings(); + $event_settings = $settings['events'] ?? []; + + // Merge event data with settings + foreach ($events as $id => &$event) { + $event_config = $event_settings[$id] ?? []; + $event['enabled'] = $event_config['enabled'] ?? true; + $event['channels'] = $event_config['channels'] ?? ['email']; + $event['recipients'] = $event_config['recipients'] ?? ['email' => 'admin']; + } + + // Group by category + $grouped = [ + 'orders' => [], + 'products' => [], + 'customers' => [], + ]; + + foreach ($events as $event) { + $category = $event['category'] ?? 'general'; + if (!isset($grouped[$category])) { + $grouped[$category] = []; + } + $grouped[$category][] = $event; + } + + return new WP_REST_Response($grouped, 200); + } + + /** + * Get notification settings + * + * @param WP_REST_Request $request Request object + * @return WP_REST_Response + */ + public function get_settings(WP_REST_Request $request) { + $settings = NotificationSettingsProvider::get_settings(); + return new WP_REST_Response($settings, 200); + } + + /** + * Update notification settings + * + * @param WP_REST_Request $request Request object + * @return WP_REST_Response|WP_Error + */ + public function update_settings(WP_REST_Request $request) { + $new_settings = $request->get_json_params(); + + if (empty($new_settings)) { + return new WP_Error( + 'invalid_settings', + __('Invalid settings data', 'woonoow'), + ['status' => 400] + ); + } + + $updated = NotificationSettingsProvider::update_settings($new_settings); + + if (!$updated) { + return new WP_Error( + 'update_failed', + __('Failed to update notification settings', 'woonoow'), + ['status' => 500] + ); + } + + return new WP_REST_Response([ + 'success' => true, + 'message' => __('Notification settings updated successfully', 'woonoow'), + 'settings' => NotificationSettingsProvider::get_settings(), + ], 200); + } + + /** + * Check if user has permission + * + * @return bool + */ + public function check_permission() { + return current_user_can('manage_woocommerce') || current_user_can('manage_options'); + } +} diff --git a/includes/Api/Routes.php b/includes/Api/Routes.php index 5fa5dea..9504c7d 100644 --- a/includes/Api/Routes.php +++ b/includes/Api/Routes.php @@ -15,6 +15,7 @@ use WooNooW\Api\PickupLocationsController; use WooNooW\Api\EmailController; use WooNooW\API\DeveloperController; use WooNooW\API\SystemController; +use WooNooW\Api\NotificationsController; class Routes { public static function init() { @@ -79,6 +80,10 @@ class Routes { // System controller $system_controller = new SystemController(); $system_controller->register_routes(); + + // Notifications controller + $notifications_controller = new NotificationsController(); + $notifications_controller->register_routes(); }); } } diff --git a/includes/Core/Bootstrap.php b/includes/Core/Bootstrap.php index c2c8fcb..f8868b7 100644 --- a/includes/Core/Bootstrap.php +++ b/includes/Core/Bootstrap.php @@ -19,6 +19,7 @@ use WooNooW\Core\Mail\MailQueue; use WooNooW\Core\Mail\WooEmailOverride; use WooNooW\Core\DataStores\OrderStore; use WooNooW\Core\MediaUpload; +use WooNooW\Core\Notifications\NotificationManager; use WooNooW\Branding; class Bootstrap { @@ -30,6 +31,7 @@ class Bootstrap { StandaloneAdmin::init(); Branding::init(); MediaUpload::init(); + NotificationManager::init(); // Addon system (order matters: Registry → Routes → Navigation) AddonRegistry::init(); diff --git a/includes/Core/Notifications/NotificationManager.php b/includes/Core/Notifications/NotificationManager.php new file mode 100644 index 0000000..fea52d9 --- /dev/null +++ b/includes/Core/Notifications/NotificationManager.php @@ -0,0 +1,230 @@ + 'email', + 'label' => __('Email', 'woonoow'), + 'icon' => 'mail', + 'builtin' => true, + 'enabled' => true, + ]); + + // Register default notification events + self::register_default_events(); + + // Allow addons to register their channels + add_action('woonoow_register_notification_channels', [__CLASS__, 'allow_addon_registration']); + } + + /** + * Register a notification channel + * + * @param string $id Channel ID + * @param array $args Channel arguments + */ + public static function register_channel($id, $args = []) { + $defaults = [ + 'id' => $id, + 'label' => ucfirst($id), + 'icon' => 'bell', + 'builtin' => false, + 'enabled' => false, + 'addon' => '', + ]; + + self::$channels[$id] = wp_parse_args($args, $defaults); + } + + /** + * Get all registered channels + * + * @return array + */ + public static function get_channels() { + return apply_filters('woonoow_notification_channels', self::$channels); + } + + /** + * Register default notification events + */ + private static function register_default_events() { + // Order events + self::register_event('order_placed', [ + 'label' => __('Order Placed', 'woonoow'), + 'description' => __('When a new order is placed', 'woonoow'), + 'category' => 'orders', + 'wc_email' => 'new_order', // Maps to WC_Email_New_Order + ]); + + self::register_event('order_processing', [ + 'label' => __('Order Processing', 'woonoow'), + 'description' => __('When order status changes to processing', 'woonoow'), + 'category' => 'orders', + 'wc_email' => 'customer_processing_order', + ]); + + self::register_event('order_completed', [ + 'label' => __('Order Completed', 'woonoow'), + 'description' => __('When order is marked as completed', 'woonoow'), + 'category' => 'orders', + 'wc_email' => 'customer_completed_order', + ]); + + self::register_event('order_cancelled', [ + 'label' => __('Order Cancelled', 'woonoow'), + 'description' => __('When order is cancelled', 'woonoow'), + 'category' => 'orders', + 'wc_email' => 'cancelled_order', + ]); + + self::register_event('order_refunded', [ + 'label' => __('Order Refunded', 'woonoow'), + 'description' => __('When order is refunded', 'woonoow'), + 'category' => 'orders', + 'wc_email' => 'customer_refunded_order', + ]); + + // Product events + self::register_event('low_stock', [ + 'label' => __('Low Stock Alert', 'woonoow'), + 'description' => __('When product stock is low', 'woonoow'), + 'category' => 'products', + 'wc_email' => 'low_stock', + ]); + + self::register_event('out_of_stock', [ + 'label' => __('Out of Stock Alert', 'woonoow'), + 'description' => __('When product is out of stock', 'woonoow'), + 'category' => 'products', + 'wc_email' => 'no_stock', + ]); + + // Customer events + self::register_event('new_customer', [ + 'label' => __('New Customer', 'woonoow'), + 'description' => __('When a new customer registers', 'woonoow'), + 'category' => 'customers', + 'wc_email' => 'customer_new_account', + ]); + + self::register_event('customer_note', [ + 'label' => __('Customer Note Added', 'woonoow'), + 'description' => __('When a note is added to customer order', 'woonoow'), + 'category' => 'customers', + 'wc_email' => 'customer_note', + ]); + } + + /** + * Register a notification event + * + * @param string $id Event ID + * @param array $args Event arguments + */ + public static function register_event($id, $args = []) { + $defaults = [ + 'id' => $id, + 'label' => ucfirst(str_replace('_', ' ', $id)), + 'description' => '', + 'category' => 'general', + 'wc_email' => '', + 'channels' => [], + ]; + + self::$events[$id] = wp_parse_args($args, $defaults); + } + + /** + * Get all registered events + * + * @return array + */ + public static function get_events() { + return apply_filters('woonoow_notification_events', self::$events); + } + + /** + * Get events by category + * + * @param string $category Category name + * @return array + */ + public static function get_events_by_category($category) { + $events = self::get_events(); + return array_filter($events, function($event) use ($category) { + return $event['category'] === $category; + }); + } + + /** + * Send notification + * + * @param string $event_id Event ID + * @param array $data Notification data + * @param array $channels Channels to use (default: from settings) + */ + public static function send($event_id, $data = [], $channels = null) { + // Get event configuration + $event = self::$events[$event_id] ?? null; + if (!$event) { + return; + } + + // Get channels from settings if not specified + if ($channels === null) { + $settings = NotificationSettingsProvider::get_event_settings($event_id); + $channels = $settings['channels'] ?? ['email']; + } + + // Send via each enabled channel + foreach ($channels as $channel_id) { + // Email is handled by WooCommerce, skip it + if ($channel_id === 'email') { + continue; + } + + // Fire action for addon channels + do_action("woonoow_notification_send_{$channel_id}", $event_id, $data); + } + } + + /** + * Allow addons to register channels + */ + public static function allow_addon_registration() { + // Addons hook into this to register their channels + // Example: add_action('woonoow_register_notification_channels', function() { + // NotificationManager::register_channel('whatsapp', [...]); + // }); + } +} diff --git a/includes/Core/Notifications/NotificationSettingsProvider.php b/includes/Core/Notifications/NotificationSettingsProvider.php new file mode 100644 index 0000000..297c07c --- /dev/null +++ b/includes/Core/Notifications/NotificationSettingsProvider.php @@ -0,0 +1,182 @@ + self::get_default_event_settings(), + 'channels' => self::get_default_channel_settings(), + ]; + } + + /** + * Get default event settings + * + * @return array + */ + private static function get_default_event_settings() { + $events = NotificationManager::get_events(); + $settings = []; + + foreach ($events as $event_id => $event) { + $settings[$event_id] = [ + 'enabled' => true, + 'channels' => ['email'], // Email enabled by default + 'recipients' => [ + 'email' => 'admin', // admin, customer, or both + ], + ]; + } + + return $settings; + } + + /** + * Get default channel settings + * + * @return array + */ + private static function get_default_channel_settings() { + return [ + 'email' => [ + 'enabled' => true, + // Email settings are managed by WooCommerce + // We just track if it's enabled in our system + ], + ]; + } + + /** + * Get settings for a specific event + * + * @param string $event_id Event ID + * @return array + */ + public static function get_event_settings($event_id) { + $settings = self::get_settings(); + return $settings['events'][$event_id] ?? []; + } + + /** + * Get settings for a specific channel + * + * @param string $channel_id Channel ID + * @return array + */ + public static function get_channel_settings($channel_id) { + $settings = self::get_settings(); + return $settings['channels'][$channel_id] ?? []; + } + + /** + * Update notification settings + * + * @param array $new_settings New settings + * @return bool + */ + public static function update_settings($new_settings) { + $current = self::get_settings(); + $updated = wp_parse_args($new_settings, $current); + + return update_option(self::OPTION_KEY, $updated); + } + + /** + * Update event settings + * + * @param string $event_id Event ID + * @param array $event_settings Event settings + * @return bool + */ + public static function update_event_settings($event_id, $event_settings) { + $settings = self::get_settings(); + $settings['events'][$event_id] = $event_settings; + + return self::update_settings($settings); + } + + /** + * Update channel settings + * + * @param string $channel_id Channel ID + * @param array $channel_settings Channel settings + * @return bool + */ + public static function update_channel_settings($channel_id, $channel_settings) { + $settings = self::get_settings(); + $settings['channels'][$channel_id] = $channel_settings; + + return self::update_settings($settings); + } + + /** + * Check if event is enabled + * + * @param string $event_id Event ID + * @return bool + */ + public static function is_event_enabled($event_id) { + $event_settings = self::get_event_settings($event_id); + return $event_settings['enabled'] ?? true; + } + + /** + * Check if channel is enabled for event + * + * @param string $event_id Event ID + * @param string $channel_id Channel ID + * @return bool + */ + public static function is_channel_enabled_for_event($event_id, $channel_id) { + $event_settings = self::get_event_settings($event_id); + $channels = $event_settings['channels'] ?? []; + + return in_array($channel_id, $channels, true); + } + + /** + * Get recipient type for event channel + * + * @param string $event_id Event ID + * @param string $channel_id Channel ID + * @return string admin|customer|both + */ + public static function get_recipient_type($event_id, $channel_id) { + $event_settings = self::get_event_settings($event_id); + $recipients = $event_settings['recipients'] ?? []; + + return $recipients[$channel_id] ?? 'admin'; + } +}