From be91b3efd77f4b16ba349ff059fac92b0299e958 Mon Sep 17 00:00:00 2001 From: SysAdmin Date: Mon, 6 Oct 2025 17:57:10 +0100 Subject: [PATCH] Add: SignalR real-time notifications for admin panel - Created NotificationHub for instant browser notifications - Updated CryptoPaymentService to broadcast via SignalR - Added JavaScript client with toast notifications - Works with custom SSL certificates (no FCM dependency) - Automatic reconnection with exponential backoff - Notification sound and visual indicators - Bypasses all Web Push SSL certificate issues --- .../Areas/Admin/Views/Shared/_Layout.cshtml | 3 + LittleShop/Hubs/NotificationHub.cs | 39 ++++ LittleShop/Program.cs | 3 +- LittleShop/Services/CryptoPaymentService.cs | 26 ++- .../wwwroot/js/signalr-notifications.js | 206 ++++++++++++++++++ 5 files changed, 272 insertions(+), 5 deletions(-) create mode 100644 LittleShop/Hubs/NotificationHub.cs create mode 100644 LittleShop/wwwroot/js/signalr-notifications.js diff --git a/LittleShop/Areas/Admin/Views/Shared/_Layout.cshtml b/LittleShop/Areas/Admin/Views/Shared/_Layout.cshtml index d73c8ba..50d1661 100644 --- a/LittleShop/Areas/Admin/Views/Shared/_Layout.cshtml +++ b/LittleShop/Areas/Admin/Views/Shared/_Layout.cshtml @@ -159,6 +159,9 @@ + + + @await RenderSectionAsync("Scripts", required: false) diff --git a/LittleShop/Hubs/NotificationHub.cs b/LittleShop/Hubs/NotificationHub.cs new file mode 100644 index 0000000..edbd92d --- /dev/null +++ b/LittleShop/Hubs/NotificationHub.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; + +namespace LittleShop.Hubs; + +[Authorize(Roles = "Admin")] +public class NotificationHub : Hub +{ + private readonly ILogger _logger; + + public NotificationHub(ILogger logger) + { + _logger = logger; + } + + public override async Task OnConnectedAsync() + { + _logger.LogInformation("Admin user connected to notification hub: {ConnectionId}", Context.ConnectionId); + await base.OnConnectedAsync(); + } + + public override async Task OnDisconnectedAsync(Exception? exception) + { + _logger.LogInformation("Admin user disconnected from notification hub: {ConnectionId}", Context.ConnectionId); + await base.OnDisconnectedAsync(exception); + } + + // Client can call this to test notifications + public async Task SendTestNotification() + { + await Clients.Caller.SendAsync("ReceiveNotification", new + { + title = "Test Notification", + message = "This is a test notification from SignalR!", + type = "info", + timestamp = DateTime.UtcNow + }); + } +} diff --git a/LittleShop/Program.cs b/LittleShop/Program.cs index fb78a9e..1356ffd 100644 --- a/LittleShop/Program.cs +++ b/LittleShop/Program.cs @@ -397,8 +397,9 @@ app.MapBlazorHub(); // Map Blazor Server hub app.MapRazorPages(); // Enable Razor Pages for Blazor app.MapFallbackToPage("/blazor/{*path}", "/_Host"); // Fallback for all Blazor routes -// Map SignalR hub +// Map SignalR hubs app.MapHub("/activityHub"); +app.MapHub("/notificationHub"); // Health check endpoint app.MapHealthChecks("/health"); diff --git a/LittleShop/Services/CryptoPaymentService.cs b/LittleShop/Services/CryptoPaymentService.cs index 29a4731..454ea20 100644 --- a/LittleShop/Services/CryptoPaymentService.cs +++ b/LittleShop/Services/CryptoPaymentService.cs @@ -1,8 +1,10 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.SignalR; using LittleShop.Data; using LittleShop.Models; using LittleShop.DTOs; using LittleShop.Enums; +using LittleShop.Hubs; namespace LittleShop.Services; @@ -14,6 +16,7 @@ public class CryptoPaymentService : ICryptoPaymentService private readonly IConfiguration _configuration; private readonly IPushNotificationService _pushNotificationService; private readonly ITeleBotMessagingService _teleBotMessagingService; + private readonly IHubContext _notificationHub; public CryptoPaymentService( LittleShopContext context, @@ -21,7 +24,8 @@ public class CryptoPaymentService : ICryptoPaymentService ILogger logger, IConfiguration configuration, IPushNotificationService pushNotificationService, - ITeleBotMessagingService teleBotMessagingService) + ITeleBotMessagingService teleBotMessagingService, + IHubContext notificationHub) { _context = context; _silverPayService = silverPayService; @@ -29,8 +33,9 @@ public class CryptoPaymentService : ICryptoPaymentService _configuration = configuration; _pushNotificationService = pushNotificationService; _teleBotMessagingService = teleBotMessagingService; + _notificationHub = notificationHub; - _logger.LogInformation("CryptoPaymentService initialized with SilverPAY"); + _logger.LogInformation("CryptoPaymentService initialized with SilverPAY and SignalR notifications"); } public async Task CreatePaymentAsync(Guid orderId, CryptoCurrency currency) @@ -215,13 +220,26 @@ public class CryptoPaymentService : ICryptoPaymentService var title = "💰 Payment Confirmed"; var body = $"Order #{orderId.ToString()[..8]} payment of ÂŖ{amount:F2} confirmed. Ready for acceptance."; - // Send push notification to admin users + // Send SignalR real-time notification to connected admin users + await _notificationHub.Clients.All.SendAsync("ReceiveNotification", new + { + title = title, + message = body, + type = "payment", + orderId = orderId, + amount = amount, + timestamp = DateTime.UtcNow, + icon = "💰", + url = $"/Admin/Orders/Details/{orderId}" + }); + + // Send push notification to admin users (may not work with custom CA) await _pushNotificationService.SendOrderNotificationAsync(orderId, title, body); // Send TeleBot message to customer await _teleBotMessagingService.SendPaymentConfirmedAsync(orderId); - _logger.LogInformation("Sent payment confirmation notifications for order {OrderId} (Admin + Customer)", orderId); + _logger.LogInformation("Sent payment confirmation notifications for order {OrderId} (SignalR + Push + Telegram)", orderId); } catch (Exception ex) { diff --git a/LittleShop/wwwroot/js/signalr-notifications.js b/LittleShop/wwwroot/js/signalr-notifications.js new file mode 100644 index 0000000..7e17964 --- /dev/null +++ b/LittleShop/wwwroot/js/signalr-notifications.js @@ -0,0 +1,206 @@ +// SignalR Real-Time Notifications for LittleShop Admin +// Connects to NotificationHub and displays toast notifications + +class SignalRNotificationManager { + constructor() { + this.connection = null; + this.isConnected = false; + this.reconnectAttempts = 0; + this.maxReconnectAttempts = 10; + this.init(); + } + + async init() { + console.log('SignalR Notifications: Initializing...'); + + try { + // Create SignalR connection + this.connection = new signalR.HubConnectionBuilder() + .withUrl("/notificationHub") + .withAutomaticReconnect({ + nextRetryDelayInMilliseconds: (retryContext) => { + // Exponential backoff: 0s, 2s, 10s, 30s, then 30s + if (retryContext.elapsedMilliseconds < 60000) { + return Math.min(1000 * Math.pow(2, retryContext.previousRetryCount), 30000); + } + return 30000; + } + }) + .configureLogging(signalR.LogLevel.Information) + .build(); + + // Set up event handlers + this.setupEventHandlers(); + + // Start connection + await this.start(); + + } catch (error) { + console.error('SignalR Notifications: Failed to initialize:', error); + } + } + + setupEventHandlers() { + // Handle incoming notifications + this.connection.on("ReceiveNotification", (notification) => { + console.log('SignalR Notifications: Received:', notification); + this.showNotification(notification); + }); + + // Connection lifecycle events + this.connection.onclose((error) => { + this.isConnected = false; + console.log('SignalR Notifications: Connection closed', error); + this.updateConnectionStatus('disconnected'); + }); + + this.connection.onreconnecting((error) => { + console.log('SignalR Notifications: Reconnecting...', error); + this.updateConnectionStatus('reconnecting'); + }); + + this.connection.onreconnected((connectionId) => { + this.isConnected = true; + console.log('SignalR Notifications: Reconnected', connectionId); + this.updateConnectionStatus('connected'); + this.reconnectAttempts = 0; + }); + } + + async start() { + try { + await this.connection.start(); + this.isConnected = true; + console.log('SignalR Notifications: Connected successfully'); + this.updateConnectionStatus('connected'); + } catch (error) { + console.error('SignalR Notifications: Failed to connect:', error); + this.updateConnectionStatus('disconnected'); + + // Retry connection + setTimeout(() => this.start(), 5000); + } + } + + showNotification(notification) { + // Create toast notification + const toast = document.createElement('div'); + toast.className = `alert alert-info alert-dismissible position-fixed`; + toast.style.cssText = ` + top: 80px; + right: 20px; + z-index: 9999; + min-width: 350px; + max-width: 500px; + box-shadow: 0 8px 24px rgba(0,0,0,0.2); + animation: slideInRight 0.3s ease-out; + `; + + // Notification icon based on type + const iconMap = { + 'payment': '💰', + 'order': 'đŸ“Ļ', + 'info': 'â„šī¸', + 'warning': 'âš ī¸', + 'success': '✅', + 'error': '❌' + }; + + const icon = notification.icon || iconMap[notification.type] || '🔔'; + const url = notification.url || '#'; + + toast.innerHTML = ` +
+
${icon}
+
+
${notification.title}
+

${notification.message}

+ ${url !== '#' ? ` + View Order + ` : ''} + ${this.formatTimestamp(notification.timestamp)} +
+ +
+ `; + + document.body.appendChild(toast); + + // Play notification sound + this.playNotificationSound(); + + // Auto-remove after 10 seconds + setTimeout(() => { + if (toast.parentNode) { + toast.remove(); + } + }, 10000); + + // Update notification badge if it exists + this.updateNotificationBadge(); + } + + formatTimestamp(timestamp) { + const date = new Date(timestamp); + const now = new Date(); + const diff = Math.floor((now - date) / 1000); // seconds + + if (diff < 60) return 'Just now'; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return date.toLocaleString(); + } + + playNotificationSound() { + try { + // Simple beep sound (you can replace with a custom sound file) + const audio = new Audio('data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2/LDciUFLIHO8tiJNwgZaLvt559NEAxQp+PwtmMcBjiR1/LMeSwFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBShzw+7bjT8JElyv5e2nVhYQTKXi0adkHAYnfcHr4JBNDA9PotTtqV4bCkSa5O/IbCcMKHTH9N+ROwgYarfs5o5MDQ5Pm+Xzr2YeCCF5wOnlkTkKG2a16+2iUxoOSKLe8bp4Kgk0fMnw24k6EBxmsuvsoFMYDk+m4PO2eCQMOXjE7t2OPhkZaK7n7aJbGw1Fn+Hxu3ooDzZ8yO/ciD8PG2+36OybUhoPSpzg8bhwKQo2e8bs3Y5CDQxVqerurF4bCkGc5PG9eywNNHnH9N+PQQ8bZ7Tr7aVVGQ5Fn+Hxtn0qDTZ6x+zelkAMEF216+6kVxwLQqHk8rhvKQs4esr13o9CDhxntujuoFYYDUad4vK9fS0OOX7K9N6OQw0QWK7n7qNYGw5Ip+LxvYAuETl+zPTfkUUNElyy6e6mWBsNRp/j8biCKg88fs/z4I9ADQxbsOXupVYaDkSg4vK3gzULPIHP8N6PTg0NXKvn7aRUFwxNoeXxt4QuCzh+zu/djkAPDVip5+2kVhgMSp/i8bZ8LQo5fc7w3Y5ADAxaqOftpVYZDUmg4/K4gSwKOn3O8N2OQAwMWqjn7aVWGQ1Joezi8biALgo6fc7w3Y5ADA1aqejtpFYYDUmf4/K4gSwJOn7O8N2PQA0MWqro7aRVGg1Jn+Pyu4UuCjp+zfDdjkAPDVqp6O2lVxoOSaHk8rqDLgo4fc3w3I1ADAxZqejto1caDUqh5PG5gjAJPn/N8d6MQQ0NWqrn7aNWGQxKn+Txt4EqCjl+zPHdjkAMDFuq6O2kVhgMTJ/i8rZ8KQo4fc3w3Y5ADA1bq+jtpFYYDU2f4vK2fCkKOH3N8N2OQAwLWqrn7aRWGQ1JoOPyuIArCjl+zPHcj0EPDVqr5+2kVhkLSZ/i8rZ7LQs5fs3w3I1ADAxdqufupFYYDUie4/K4gCwLOX3M8N2OQAwLXKvn7aRWGg1IoOPyuH8rCTl+zfDdjj8MDFuo6O2kVhkLSZ/i8rZ7LQo5fc3w3I1ADAxcq+jtpFYaDUie4/K5gCsJOX7M8d6OQAwLW6ro7aRVGQ1IoOPyt4ArCTl+zfHejT8MDFup6O2jVxkNSaDi8rd9Kgo4fs/w3Y4/DAxcqejvo1YaDUqg4vK3fSoKOH7P8NyNPwsLW6nn7aRXGQ1Jn+LytXwuCzh9zvDcjkAKC1yr5+2jVxkNSJ/i8rZ+Kgo5fs3w3I4/CwxbqefupFcZDEme4vK3fCoJOX7N8N2PQAsMXKnn7aRXGQ1IoOLytX4pCTh+zfDbjj8LC1qq6O2kVxkNSJ/i8rV+Kgo5fs3w3I4/DAtcqefto1caDEie4vK2fioJOX3N8N2OQAsMXKro7aNXGQ1InuLytXwqCTh+zfDdjj8MC1yo5+2kVxkNSJ/i8rV+Kgo5fs3w3Y4/DAtcqefto1cZDUie4vK1fioJOH7N8N2OQAwLXKnn7aRXGQ1InuLytXwqCTh+zfDdjj8MC1yo5+2kVxkNSJ/i8rV9Kwo5fs3w3Y4/DAtcqefto1cZDUie4vK1fioJOH7N8N2OQAwLXKnn7aRXGQ1InuLytXwqCTh+zfDdjj8MC1yo5+2kVxkNSJ/i8rV9Kwo5fs3w3Y4/DAtcqefto1cZDUie4vK1fioJOH7N8N2OQAwLXKnn7aRXGQ1InuLytXwqCTh+zfDdjj8MC1yo5+2kVxkNSJ/i8rV9Kgo5fs3w3Y4/DAtcqefto1cZDUie4vK1fioJOH7N8N2OQAwLXKnn7aRXGQ1InuLytXwqCTh+zfDdjj8MC1yo5+2kVxkNSJ/i8rV9Kgo5fs3w3Y4/DAtcqefto1cZDUie4vK1fioJOH7N8N2OQAwLXKnn7aRXGQ1InuLytXwqCTh+zfDdjj8MC1yo5+2kVxkNSJ/i8rV9Kgo5fs3w3Y4/DAtcqefto1cZDUie4vK1fioJOH7N8N2OQAwLXKnn7aRXGQ1InuLytXwqCTh+zfDdjj8MC1yo5+2kVxkNSJ/i8rV9Kgo5fs3w3Y4/DAtcqefto1cZDUie4vK1fioJOH7N8N2OQAwLXKnn7aRXGQ1InuLytXwqCTh+zfDdjj8MC1yo5+2kVxkNSJ/i8rV9Kgo5fs3w3Y4/DAtcqefto1cZDUie4vK1fioJOH7N8N2OQAwLXKnn7aRXGQ1InuLytXwqCTh+zfDdjj8MC1yo5+2kVxkNSJ/i8rV9Kgo5fs3w3Y4/DAtcqefto1cZDUie4vK1fioJOH7N8N2OQAwLXKnn7aRXGQ1InuLytXwqCTh+zfDdjj8MC1yo5+2kVxkNSJ/i8rV9Kgo5fs3w3Y4/DAtcqefto1cZDUie4vK1fioJOH7N8N2OQAwLXKnn7aRXGQ1InuLytXwqCTh+zfDdjj8MC1yo5+2kVxkNSJ/i8rV9Kgo5fs3w3Y4/DAtcqefto1cZDUie4vK1fioJOH7N8N2OQAwLXKnn7aRXGQ1InuLytXwqCTh+zfDdjj8MC1yo5+2kVxkNSJ/i8rV9Kgo5fs3w3Y4/DAtcqefto1cZDUie4vK1fioJOH7N8N2OQAwLXKnn7aRXGQ1InuLytXwqCTh+zfDdjj8MC1yo5+2kVxkNSJ/i8rV9Kgo5fs3w3Y4/DAtcqefto1cZDUie4vK1fioJOH7N8N2OQAwLXKnn7aRXGQ1InuLytXwqCTh+zfDdjj8='); + audio.volume = 0.3; + audio.play().catch(e => console.log('Cannot play sound:', e)); + } catch (error) { + console.log('Notification sound error:', error); + } + } + + updateConnectionStatus(status) { + // Update UI status indicator if exists + const statusIndicator = document.getElementById('signalr-connection-status'); + if (statusIndicator) { + statusIndicator.className = `badge bg-${status === 'connected' ? 'success' : status === 'reconnecting' ? 'warning' : 'danger'}`; + statusIndicator.textContent = status.charAt(0).toUpperCase() + status.slice(1); + } + + console.log('SignalR Notifications: Status:', status); + } + + updateNotificationBadge() { + const badge = document.getElementById('notification-badge'); + if (badge) { + const currentCount = parseInt(badge.textContent) || 0; + badge.textContent = currentCount + 1; + badge.style.display = 'inline'; + } + } + + async sendTestNotification() { + if (!this.isConnected) { + console.error('SignalR Notifications: Not connected'); + return; + } + + try { + await this.connection.invoke("SendTestNotification"); + console.log('SignalR Notifications: Test notification requested'); + } catch (error) { + console.error('SignalR Notifications: Failed to send test notification:', error); + } + } +} + +// Initialize on page load +document.addEventListener('DOMContentLoaded', () => { + window.signalRNotifications = new SignalRNotificationManager(); +}); + +// Export for global access +window.SignalRNotificationManager = SignalRNotificationManager;