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;