1/** 2 * NotificationManager - Handles update notifications with localStorage persistence 3 */ 4export class NotificationManager { 5 constructor() { 6 this.notifications = []; 7 this.container = null; 8 this.storageKey = 'mtgproxy_dismissed_notifications'; 9 } 10 11 /** 12 * Initialize the notification system 13 */ 14 init() { 15 this.createContainer(); 16 this.addStyles(); 17 18 // Define notifications to show 19 this.notifications = [ 20 { 21 id: 'onboarding-prompt-2025', 22 title: 'New to mtgproxy.net?', 23 message: '', 24 actionText: 'Get Started', 25 action: () => { 26 if (window.showOnboarding) window.showOnboarding(); 27 }, 28 priority: -1 29 }, 30 { 31 id: 'dfc-support-2024', 32 title: 'Double Faced Cards', 33 message: 'are now supported!', 34 priority: 1 35 }, 36 { 37 id: 'test-print-2024', 38 title: 'Test Print Feature', 39 message: 'Try a test print ', 40 linkText: 'here', 41 linkUrl: '/printing-guide#test-print', 42 priority: 2 43 }, 44 { 45 id: 'recommended-products-2025', 46 title: 'Recommended Products', 47 message: 'Check out our affiliate links ', 48 linkText: 'here', 49 linkUrl: '/printing-guide#recommended-products', 50 priority: 3 51 } 52 ]; 53 54 // Show notifications that haven't been dismissed 55 this.showPendingNotifications(); 56 } 57 58 /** 59 * Create notification container 60 */ 61 createContainer() { 62 this.container = document.createElement('div'); 63 this.container.id = 'notification-container'; 64 document.body.appendChild(this.container); 65 } 66 67 /** 68 * Get dismissed notifications from localStorage 69 */ 70 getDismissedNotifications() { 71 try { 72 const dismissed = localStorage.getItem(this.storageKey); 73 return dismissed ? JSON.parse(dismissed) : []; 74 } catch (error) { 75 console.error('Failed to load dismissed notifications:', error); 76 return []; 77 } 78 } 79 80 /** 81 * Save dismissed notification to localStorage 82 */ 83 saveNotificationDismissal(notificationId) { 84 try { 85 const dismissed = this.getDismissedNotifications(); 86 if (!dismissed.includes(notificationId)) { 87 dismissed.push(notificationId); 88 localStorage.setItem(this.storageKey, JSON.stringify(dismissed)); 89 } 90 } catch (error) { 91 console.error('Failed to save notification dismissal:', error); 92 } 93 } 94 95 /** 96 * Show all pending (non-dismissed) notifications 97 */ 98 showPendingNotifications() { 99 const dismissed = this.getDismissedNotifications(); 100 101 // Sort by priority and show non-dismissed notifications 102 this.notifications 103 .filter(notif => !dismissed.includes(notif.id)) 104 .sort((a, b) => a.priority - b.priority) 105 .forEach((notif, index) => { 106 // Stagger animation slightly for multiple notifications 107 setTimeout(() => { 108 this.showNotification(notif); 109 }, index * 150); 110 }); 111 } 112 113 /** 114 * Show a single notification 115 */ 116 showNotification(notification) { 117 const notifElement = document.createElement('div'); 118 notifElement.className = 'update-notification'; 119 notifElement.dataset.notificationId = notification.id; 120 121 // Create close button (static SVG, safe to use innerHTML) 122 const closeBtn = document.createElement('button'); 123 closeBtn.className = 'notification-close'; 124 closeBtn.setAttribute('aria-label', 'Dismiss notification'); 125 closeBtn.innerHTML = ` 126 <svg width="20" height="20" viewBox="0 0 20 20" fill="none"> 127 <circle cx="10" cy="10" r="9" stroke="currentColor" stroke-width="1.5"/> 128 <path d="M7 7L13 13M13 7L7 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> 129 </svg> 130 `; 131 132 // Create content container (use textContent for user data - XSS safe) 133 const content = document.createElement('div'); 134 content.className = 'notification-content'; 135 136 const titleEl = document.createElement('strong'); 137 titleEl.textContent = notification.title; // XSS-safe 138 139 const messageEl = document.createElement('span'); 140 messageEl.textContent = notification.message; // XSS-safe 141 142 // Add link if provided 143 if (notification.linkText && notification.linkUrl) { 144 const linkEl = document.createElement('a'); 145 linkEl.href = notification.linkUrl; 146 linkEl.textContent = notification.linkText; 147 linkEl.className = 'notification-link'; 148 messageEl.appendChild(linkEl); 149 } 150 151 // Add action button if provided 152 if (notification.actionText && notification.action) { 153 const actionBtn = document.createElement('button'); 154 actionBtn.textContent = notification.actionText; 155 actionBtn.className = 'notification-action'; 156 actionBtn.addEventListener('click', () => { 157 notification.action(); 158 this.dismissNotification(notification.id, notifElement); 159 }); 160 messageEl.appendChild(actionBtn); 161 } 162 163 content.appendChild(titleEl); 164 content.appendChild(messageEl); 165 166 notifElement.appendChild(closeBtn); 167 notifElement.appendChild(content); 168 169 // Add close handler 170 closeBtn.addEventListener('click', () => { 171 this.dismissNotification(notification.id, notifElement); 172 }); 173 174 // Add to container 175 this.container.appendChild(notifElement); 176 177 // Trigger animation after a brief delay 178 setTimeout(() => { 179 notifElement.classList.add('show'); 180 }, 10); 181 } 182 183 /** 184 * Dismiss and remove a notification 185 */ 186 dismissNotification(notificationId, element) { 187 // Save dismissal 188 this.saveNotificationDismissal(notificationId); 189 190 // Animate out 191 element.classList.add('dismissing'); 192 193 // Remove from DOM after animation 194 setTimeout(() => { 195 element.remove(); 196 }, 300); 197 } 198 199 /** 200 * Show an error notification. Always persists until manually dismissed â 201 * errors (e.g. "card not found") must stay readable, so this intentionally 202 * has no auto-dismiss option at all (unlike showSuccess/showInfo). 203 * SECURITY: Uses textContent to prevent XSS attacks from user input 204 */ 205 showError(title, message) { 206 const errorNotif = document.createElement('div'); 207 errorNotif.className = 'update-notification error-notification'; 208 209 // Create close button (static SVG, safe to use innerHTML) 210 const closeBtn = document.createElement('button'); 211 closeBtn.className = 'notification-close'; 212 closeBtn.setAttribute('aria-label', 'Dismiss notification'); 213 closeBtn.innerHTML = ` 214 <svg width="20" height="20" viewBox="0 0 20 20" fill="none"> 215 <circle cx="10" cy="10" r="9" stroke="currentColor" stroke-width="1.5"/> 216 <path d="M7 7L13 13M13 7L7 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> 217 </svg> 218 `; 219 220 // Create content container (use textContent for user data - XSS
220safe) 221 const content = document.createElement('div'); 222 content.className = 'notification-content'; 223 224 const titleEl = document.createElement('strong'); 225 titleEl.textContent = title; // XSS-safe: escapes HTML entities 226 227 const messageEl = document.createElement('span'); 228 messageEl.textContent = message; // XSS-safe: escapes HTML entities 229 230 content.appendChild(titleEl); 231 content.appendChild(messageEl); 232 233 errorNotif.appendChild(closeBtn); 234 errorNotif.appendChild(content); 235 236 // Add close handler 237 const dismissFn = () => { 238 errorNotif.classList.add('dismissing'); 239 setTimeout(() => errorNotif.remove(), 300); 240 }; 241 closeBtn.addEventListener('click', dismissFn); 242 243 // Add to container 244 if (!this.container) { 245 this.createContainer(); 246 } 247 this.container.appendChild(errorNotif); 248 249 // Trigger animation 250 setTimeout(() => errorNotif.classList.add('show'), 10); 251 252 return errorNotif; 253 } 254 255 /** 256 * Show a temporary info notification (auto-dismisses after delay) 257 * SECURITY: Uses textContent to prevent XSS attacks from user input 258 */ 259 showInfo(title, message, options = {}) { 260 const { autoDismissMs = 5000 } = options; 261 262 const infoNotif = document.createElement('div'); 263 infoNotif.className = 'update-notification info-notification'; 264 265 // Create close button (static SVG, safe to use innerHTML) 266 const closeBtn = document.createElement('button'); 267 closeBtn.className = 'notification-close'; 268 closeBtn.setAttribute('aria-label', 'Dismiss notification'); 269 closeBtn.innerHTML = ` 270 <svg width="20" height="20" viewBox="0 0 20 20" fill="none"> 271 <circle cx="10" cy="10" r="9" stroke="currentColor" stroke-width="1.5"/> 272 <path d="M7 7L13 13M13 7L7 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> 273 </svg> 274 `; 275 276 // Create content container (use textContent for user data - XSS safe) 277 const content = document.createElement('div'); 278 content.className = 'notification-content'; 279 280 const titleEl = document.createElement('strong'); 281 titleEl.textContent = title; // XSS-safe: escapes HTML entities 282 283 const messageEl = document.createElement('span'); 284 messageEl.textContent = message; // XSS-safe: escapes HTML entities 285 286 content.appendChild(titleEl); 287 content.appendChild(messageEl); 288 289 infoNotif.appendChild(closeBtn); 290 infoNotif.appendChild(content); 291 292 // Add close handler 293 const dismissFn = () => { 294 infoNotif.classList.add('dismissing'); 295 setTimeout(() => infoNotif.remove(), 300); 296 }; 297 closeBtn.addEventListener('click', dismissFn); 298 299 // Add to container 300 if (!this.container) { 301 this.createContainer(); 302 } 303 this.container.appendChild(infoNotif); 304 305 // Trigger animation 306 setTimeout(() => infoNotif.classList.add('show'), 10); 307 308 // Auto-dismiss after delay 309 if (autoDismissMs > 0) { 310 setTimeout(dismissFn, autoDismissMs); 311 } 312 313 return infoNotif; 314 } 315 316 /** 317 * Show a success notification with optional action button 318 * SECURITY: Uses textContent to prevent XSS attacks from user input 319 * @param {string} title - Notification title 320 * @param {string} message - Notification message 321 * @param {Object} options - Optional configuration 322 * @param {string} options.actionText - Text for action button 323 * @param {Function} options.onAction - Callback for action button 324 * @param {number} options.autoDismissMs - Auto-dismiss delay (0 = no auto-dismiss) 325 */ 326 showSuccess(title, message, options = {}) { 327 const { 328 actionText = null, 329 onAction = null, 330 autoDismissMs = 0 331 } = options; 332 333 const successNotif = document.createElement('div'); 334 successNotif.className = 'update-notification success-notification'; 335 336 // Create close button (static SVG, safe to use innerHTML) 337 const closeBtn = document.createElement('button'); 338 closeBtn.className = 'notification-close'; 339 closeBtn.setAttribute('aria-label', 'Dismiss notification'); 340 closeBtn.innerHTML = ` 341 <svg width="20" height="20" viewBox="0 0 20 20" fill="none"> 342 <circle cx="10" cy="10" r="9" stroke="currentColor" stroke-width="1.5"/> 343 <path d="M7 7L13 13M13 7L7 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> 344 </svg> 345 `; 346 347 // Create content container (use textContent for user data - XSS
347safe) 348 const content = document.createElement('div'); 349 content.className = 'notification-content'; 350 351 const titleEl = document.createElement('strong'); 352 titleEl.textContent = title; // XSS-safe: escapes HTML entities 353 354 const messageEl = document.createElement('span'); 355 messageEl.textContent = message; // XSS-safe: escapes HTML entities 356 357 content.appendChild(titleEl); 358 content.appendChild(messageEl); 359 360 // Add action button if provided 361 if (actionText) { 362 const actionBtn = document.createElement('button'); 363 actionBtn.className = 'notification-action-btn'; 364 actionBtn.textContent = actionText; // XSS-safe: escapes HTML entities 365 content.appendChild(actionBtn); 366 367 // Add action button handler 368 if (onAction) { 369 actionBtn.addEventListener('click', () => { 370 onAction(); 371 dismissFn(); 372 }); 373 } 374 } 375 376 successNotif.appendChild(closeBtn); 377 successNotif.appendChild(content); 378 379 // Add close handler 380 const dismissFn = () => { 381 successNotif.classList.add('dismissing'); 382 setTimeout(() => successNotif.remove(), 300); 383 }; 384 closeBtn.addEventListener('click', dismissFn); 385 386 // Add to container 387 if (!this.container) { 388 this.createContainer(); 389 } 390 this.container.appendChild(successNotif); 391 392 // Trigger animation 393 setTimeout(() => successNotif.classList.add('show'), 10); 394 395 // Auto-dismiss after delay if specified 396 if (autoDismissMs > 0) { 397 setTimeout(dismissFn, autoDismissMs); 398 } 399 400 return successNotif; 401 } 402 403 /** 404 * Add notification styles 405 */ 406 addStyles() { 407 const style = document.createElement('style'); 408 style.textContent = ` 409 #notification-container { 410 position: fixed; 411 top: 16px; 412 right: 20px; 413 z-index: 99999; 414 display: flex; 415 flex-direction: column; 416 gap: 12px; 417 pointer-events: none; 418 } 419 420 .update-notification { 421 background: var(--bg-primary, #1a1b1e); 422 border: 1px solid color-mix(in srgb, var(--accent-primary, #9333ea) 30%, transparent); 423 border-radius: 12px; 424 padding: 16px 20px; 425 padding-right: 44px; 426 min-width: 280px; 427 max-width: 320px; 428 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); 429 position: relative; 430 opacity: 0; 431 visibility: hidden; 432 transform: translateX(100%) scale(0.9); 433 transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55); 434 pointer-events: auto; 435 } 436 437 .update-notification.show { 438 opacity: 1; 439 visibility: visible; 440 transform: translateX(0) scale(1); 441 animation: jiggle 0.5s ease 0.3s; 442 } 443 444 .update-notification.dismissing { 445 opacity: 0; 446 visibility: hidden; 447 transform: translateX(100%) scale(0.8); 448 } 449 450 #notification-container:empty { 451 display: none; 452 } 453 454 .update-notification.error-notification { 455 background: linear-gradient(135deg, #dc3545, #c82333); 456 border-color: rgba(220, 53, 69, 0.5); 457 } 458 459 .update-notification.error-notification .notification-content strong { 460 color: #ffffff; 461 } 462 463 .update-notification.success-notification { 464 background: linear-gradient(135deg, #22c55e, #16a34a); 465 border-color: rgba(34, 197, 94, 0.5); 466 } 467 468 .update-notification.success-notification .notification-content strong { 469 color: #ffffff; 470 } 471 472 .update-notification.info-notification { 473 background: linear-gradient(135deg, #3b82f6, #2563eb); 474 border-color: rgba(59, 130, 246, 0.5); 475 } 476 477 .update-notification.info-notification .notification-content strong { 478 color: #ffffff; 479 } 480 481 .notification-action-btn { 482 background: rgba(255, 255, 255, 0.2); 483 color: white; 484 border: 1px solid rgba(255, 255, 255, 0.3); 485 padding: 8px 16px; 486 border-radius: 6px; 487 cursor: pointer; 488 font-weight: 600; 489 font-size: 14px; 490 margin-top: 10px; 491 transition: all 0.2s ease; 492 } 493 494 .notification-action-btn:hover { 495 background: rgba(255, 255, 255, 0.3); 496 border-color: rgba(255, 255, 255, 0.5); 497 transform: translateY(-1px); 498 } 499 500 @keyframes jiggle { 501 0%, 100% { transform: translateX(0) rotate(0deg); } 502 25% { transform: translateX(-3px) rotate(-1deg); } 503 50% { transform: translateX(3px) rotate(1deg); } 504 75% { transform: translateX(-2px) rotate(-0.5deg); } 505 } 506 507 .notification-close { 508 position: absolute; 509 top: 12px; 510 right: 12px; 511 background: transparent; 512 border: none; 513 color: rgba(255, 255, 255, 0.6); 514 cursor: pointer; 515 padding: 4px; 516 display: flex; 517 align-items: center; 518 justify-content: center; 519 transition: all 0.2s ease; 520 border-radius: 50%; 521 } 522 523 .notification-close:hover { 524 color: rgba(255, 255, 255, 0.9); 525 background: rgba(255, 255, 255, 0.1); 526 transform: rotate(90deg); 527 } 528 529 .notification-content { 530 display: flex; 531 flex-direction: column; 532 gap: 4px; 533 color: white; 534 font-size: 14px; 535 line-height: 1.4; 536 } 537 538 .notification-content strong { 539 font-size: 15px; 540 color: var(--text-primary, #ffffff); 541 font-weight: 600; 542 } 543 544 .notification-content span { 545 color: var(--text-secondary, rgba(255, 255, 255, 0.7)); 546 } 547 548 .notification-link { 549 color: var(--accent-primary, #9333ea); 550 text-decoration: underline; 551 cursor: pointer; 552 transition: color 0.2s ease; 553 } 554 555 .notification-link:hover { 556 color: var(--accent-hover, #7c3aed); 557 } 558 559 .notification-action { 560 display: inline-block; 561 margin-top: 8px; 562 padding: 8px 16px; 563 background: transparent; 564 border: 1px solid var(--border-primary, rgba(255, 255, 255, 0.2)); 565 border-radius: 8px; 566 color: var(--text-primary, #ffffff); 567 font-size: 14px; 568 font-weight: 500; 569 cursor: pointer; 570 transition: all 0.2s ease; 571 } 572 573 .notification-action:hover { 574 background: var(--accent-primary, #9333ea); 575 border-color: var(--accent-primary, #9333ea); 576 } 577 578 /* Mobile responsive */ 579 @media (max-width: 640px) { 580 #notification-container { 581 top: 12px; 582 right: 12px; 583 left: 12px; 584 } 585 586 .update-notification { 587 min-width: auto; 588 max-width: none; 589 } 590 } 591 592 /* Hide notifications when onboarding modal is open */ 593 body.onboarding-modal-open #notification-container { 594 display: none !important; 595 } 596 `; 597 document.head.appendChild(style); 598 } 599} 600 601// Create singleton instance 602const notificationManagerInstance = new NotificationManager(); 603 604// Make globally available 605window.notificationManagerInstance = notificationManagerInstance; 606 607// Auto-initialize when DOM is ready 608if (document.readyState === 'loading') { 609 document.addEventListener('DOMContentLoaded', () => { 610 notificationManagerInstance.init(); 611 }); 612} else { 613 notificationManagerInstance.init(); 614} 615 616export { notificationManagerInstance };
617export default NotificationManager;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.