1/** 2 * Customer Chat Push Notification Manager 3 * Handles browser push notification subscription for customers 4 * Version: 1.0.0 5 * Date: 2026-01-19 6 */ 7 8const ChatNotification = { 9 // State 10 status: 'not-supported', // not-supported, not-subscribed, subscribed, blocked 11 subscription: null, 12 vapidPublicKey: null, // Will be loaded from server 13 14 /** 15 * Initialize notification manager 16 */ 17 async init() { 18 // Check if user is logged in (only for logged-in users) 19 if (!this.isUserLoggedIn()) { 20 this.updateUI('not-supported'); 21 return; 22 } 23 24 // Check browser support 25 if (!this.isSupported()) { 26 this.updateUI('not-supported'); 27 return; 28 } 29 30 // Load VAPID key from server 31 await this.loadVapidKey(); 32 33 if (!this.vapidPublicKey) { 34 this.updateUI('not-supported'); 35 return; 36 } 37 38 // Register service worker if needed 39 await this.registerServiceWorker(); 40 41 // Check current permission 42 const permission = Notification.permission; 43 44 if (permission === 'denied') { 45 this.updateUI('blocked'); 46 } else if (permission === 'granted') { 47 // Check if already subscribed 48 const isSubscribed = await this.checkSubscriptionStatus(); 49 this.updateUI(isSubscribed ? 'subscribed' : 'not-subscribed'); 50 } else { 51 // Default: not subscribed yet 52 this.updateUI('not-subscribed'); 53 this.showInfoBar(); 54 } 55 }, 56 57 /** 58 * Check if user is logged in 59 */ 60 isUserLoggedIn() { 61 const metaTag = document.querySelector('meta[name="user-logged-in"]'); 62 return metaTag ? metaTag.getAttribute('content') === 'true' : false; 63 }, 64 65 /** 66 * Check if browser supports push notifications 67 */ 68 isSupported() { 69 return ( 70 'serviceWorker' in navigator && 71 'PushManager' in window && 72 'Notification' in window 73 ); 74 }, 75 76 /** 77 * Load VAPID public key from server 78 */ 79 async loadVapidKey() { 80 try { 81 const response = await fetch('/api/chat/notifications/vapid_key', { 82 method: 'GET', 83 headers: { 84 'Accept': 'application/json' 85 } 86 }); 87 88 if (!response.ok) { 89 return false; 90 } 91 92 const data = await response.json(); 93 if (data.success && data.data?.public_key) { 94 this.vapidPublicKey = data.data.public_key; 95 return true; 96 } 97 98 return false; 99 100 } catch (error) { 101 console.error('[ChatNotification] Error loading VAPID key:', error); 102 return false; 103 } 104 }, 105 106 /** 107 * Register service worker 108 */ 109 async registerServiceWorker() { 110 if (!('serviceWorker' in navigator)) return null; 111 112 try { 113 const registration = await navigator.serviceWorker.register('/sw-push.js', { 114 scope: '/' 115 }); 116 return registration; 117 } catch (error) { 118 return null; 119 } 120 }, 121 122 /** 123 * Check subscription status from server 124 */ 125 async checkSubscriptionStatus() { 126 try { 127 const response = await fetch('/api/chat/notifications/status', { 128 method: 'GET', 129 credentials: 'include', 130 headers: { 131 'Accept': 'application/json' 132 } 133 }); 134 135 if (!response.ok) { 136 return false; 137 } 138 139 const data = await response.json(); 140 return data.data?.subscribed || false; 141 142 } catch (error) { 143 console.error('[ChatNotification] Error checking subscription:', error); 144 return false; 145 } 146 }, 147 148 /** 149 * Update UI based on status 150 */ 151 updateUI(status) { 152 this.status = status; 153 const btn = document.getElementById('chatNotifToggle'); 154 155 if (!btn) { 156 // Retry after a short delay (button might not be in DOM yet) 157 setTimeout(() => this.updateUI(status), 100); 158 return; 159 } 160 161 btn.setAttribute('data-status', status); 162 163 // Show/hide button based on support 164 if (status === 'not-supported') { 165 btn.style.display = 'none'; 166 } else { 167 btn.style.display = 'block'; 168 } 169 170 // Update SVG icon (bell vs bell-slash) 171 const svg = btn.querySelector('svg'); 172 if (svg) { 173 if (status === 'subscribed') { 174 // Bell icon (active) 175 svg.innerHTML = '<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/>'; 176 } else { 177 // Bell slash icon (inactive/blocked) 178 svg.innerHTML = '<path d="M20 18.69L7.84 6.14 5.27 3.49 4 4.76l2.8 2.8v.01c-.52.99-.8 2.16-.8 3.42v5l-2 2v1h13.73l2 2L21 19.72l-1-1.03zM12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-7.32V11c0-3.08-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68c-.15.03-.29.08-.42.12-.1.03-.2.07-.3.11h-.01c-.01 0-.01 0-.02.01-.23.09-.46.2-.68.31 0 0-.01 0-.01.01L18 14.68z"/>'; 179 } 180 } 181 182 // Update tooltip 183 const tooltips = { 184 'not-subscribed': 'Klik untuk aktifkan notifikasi', 185 'subscribed': 'Notifikasi aktif', 186 'blocked': 'Notifikasi diblokir'
187 }; 188 189 if (tooltips[status]) { 190 btn.title = tooltips[status]; 191 } 192 193 // Hide info bar if subscribed or blocked 194 if (status === 'subscribed' || status === 'blocked') { 195 this.hideInfoBar(); 196 } 197 }, 198 199 /** 200 * Show info bar (if not dismissed) 201 */ 202 showInfoBar() { 203 if (localStorage.getItem('chat_notif_info_dismissed')) { 204 return; 205 } 206 207 const infoBar = document.getElementById('chatNotifInfoBar'); 208 const popupBody = document.getElementById('chat-popup')?.querySelector('.chat-popup-body'); 209 210 if (infoBar) { 211 infoBar.style.display = 'flex'; 212 } 213 214 if (popupBody) { 215 popupBody.classList.add('has-info-bar'); 216 } 217 }, 218 219 /** 220 * Hide info bar 221 */ 222 hideInfoBar() { 223 const infoBar = document.getElementById('chatNotifInfoBar'); 224 const popupBody = document.getElementById('chat-popup')?.querySelector('.chat-popup-body'); 225 226 if (infoBar) { 227 infoBar.style.display = 'none'; 228 } 229 230 if (popupBody) { 231 popupBody.classList.remove('has-info-bar'); 232 } 233 }, 234 235 /** 236 * Dismiss info bar permanently 237 */ 238 dismissInfo() { 239 this.hideInfoBar(); 240 localStorage.setItem('chat_notif_info_dismissed', '1'); 241 }, 242 243 /** 244 * Handle bell icon click 245 */ 246 async handleToggleClick() { 247 if (this.status === 'not-subscribed') { 248 await this.subscribe(); 249 } else if (this.status === 'subscribed') { 250 await this.unsubscribe(); 251 } else if (this.status === 'blocked') { 252 this.showUnblockHelp(); 253 } 254 }, 255 256 /** 257 * Subscribe to push notifications 258 */ 259 async subscribe() { 260 try { 261 // Request permission 262 const permission = await Notification.requestPermission(); 263 264 if (permission === 'denied') { 265 this.updateUI('blocked'); 266 this.showToast('Notifikasi diblokir', 'error'); 267 return; 268 } 269 270 if (permission !== 'granted') { 271 return; 272 } 273 274 // Get service worker registration 275 const registration = await navigator.serviceWorker.ready; 276 277 // Subscribe to push 278 const subscription = await registration.pushManager.subscribe({ 279 userVisibleOnly: true, 280 applicationServerKey: this.urlBase64ToUint8Array(this.vapidPublicKey) 281 }); 282 283 // Send subscription to server 284 const response = await fetch('/api/chat/notifications/subscribe', { 285 method: 'POST', 286 credentials: 'include', 287 headers: { 288 'Content-Type': 'application/json', 289 'Accept': 'application/json' 290 }, 291 body: JSON.stringify(subscription.toJSON()) 292 }); 293 294 if (!response.ok) { 295 throw new Error('Failed to save subscription'); 296 } 297 298 const result = await response.json(); 299 300 this.subscription = subscription; 301 this.updateUI('subscribed'); 302 this.hideInfoBar(); 303 this.showToast('Notifikasi berhasil diaktifkan!', 'success'); 304 305 // Send test notification 306 setTimeout(() => this.sendTestNotification(), 1000); 307 308 } catch (error) { 309 console.error('[ChatNotification] Subscribe error:', error); 310 this.showToast('Gagal mengaktifkan notifikasi', 'error'); 311 } 312 }, 313 314 /** 315 * Unsubscribe from push notifications 316 */ 317 async unsubscribe() { 318 try { 319 // Confirm first 320 if (!confirm('Nonaktifkan notifikasi chat?')) { 321 return; 322 } 323 324 // Get current subscription 325 const registration = await navigator.serviceWorker.ready; 326 const subscription = await registration.pushManager.getSubscription(); 327 328 if (subscription) { 329 // Unsubscribe from browser 330 await subscription.unsubscribe(); 331 332 // Tell server 333 await fetch('/api/chat/notifications/unsubscribe', { 334 method: 'POST', 335 credentials: 'include', 336 headers: { 337 'Content-Type': 'application/json', 338 'Accept': 'application/json' 339 }, 340 body: JSON.stringify(subscription.toJSON()) 341 }); 342 } 343 344 this.subscription = null; 345 this.updateUI('not-subscribed'); 346 this.showInfoBar(); 347 this.showToast('Notifikasi dinonaktifkan', 'info'); 348 349 } catch (error) { 350 console.error('[ChatNotification] Unsubscribe error:', error); 351 this.showToast('Gagal menonaktifkan notifikasi', 'error'); 352 } 353 }, 354 355 /** 356 * Send test notification 357 */ 358 async sendTestNotification() { 359 try { 360 const response = await fetch('/api/chat/notifications/test', { 361 method: 'POST', 362 credentials: 'include', 363 headers: { 364 'Accept': 'application/json' 365 } 366 }); 367 368 if (response.ok) { 369 // Test notification sent successfully 370 } 371 } catch (error) { 372 // Test notification failed silently 373 } 374 }, 375 376 /** 377 * Show unblock help modal/message 378 */ 379 showUnblockHelp() { 380 const message = ` 381 Notifikasi diblokir oleh browser.\n\n 382 Untuk mengaktifkan:\n 383 1. Klik ikon gembok/info di address bar\n 384 2. Cari "Notifications" atau "Notifikasi"\n 385 3. Ubah menjadi "Allow" atau "Izinkan"\n 386 4. Refresh halaman ini 387 `.trim().replace(/\s+/g, ' '); 388 389 alert(message); 390 }, 391 392 /** 393 * Show toast notification 394 */ 395 showToast(message, type = 'info') { 396 // Simple toast implementation 397 const toast = document.createElement('div'); 398 toast.className = `chat-toast chat-toast-${type}`; 399 toast.textContent = message; 400 toast.style.cssText = ` 401 position: fixed; 402 bottom: 80px; 403 right: 20px; 404 background: ${type === 'success' ? '#4caf50' : type === 'error' ? '#f44336' : '#2196f3'}; 405 color: white; 406 padding: 12px 20px; 407 border-radius: 4px; 408 box-shadow: 0 2px 8px rgba(0,0,0,0.2); 409 z-index: 10000; 410 animation: slideInUp 0.3s ease; 411 `; 412 413 document.body.appendChild(toast); 414 415 setTimeout(() => { 416 toast.style.animation = 'slideOutDown 0.3s ease'; 417 setTimeout(() => toast.remove(), 300); 418 }, 3000); 419 }, 420 421 /** 422 * Convert VAPID key to Uint8Array 423 */ 424 urlBase64ToUint8Array(base64String) { 425 const padding = '='.repeat((4 - base64String.length % 4) % 4); 426 const base64 = (base64String + padding) 427 .replace(/\-/g, '+') 428 .replace(/_/g, '/'); 429 430 const rawData = window.atob(base64); 431 const outputArray = new Uint8Array(rawData.length); 432 433 for (let i = 0; i < rawData.length; ++i) { 434 outputArray[i] = rawData.charCodeAt(i); 435 } 436 return outputArray; 437 }, 438 439 /** 440 * Check if push notification is currently active 441 * @returns {Promise<boolean>} 442 */ 443 async isPushActive() { 444 try { 445 // Check browser subscription 446 if (!('serviceWorker' in navigator)) return false; 447 448 const registration = await navigator.serviceWorker.getRegistration(); 449 if (!registration) return false; 450 451 const subscription = await registration.pushManager.getSubscription(); 452 if (!subscription) return false; 453 454 // Also check server status 455 const serverStatus = await this.checkSubscriptionStatus();
456 457 return serverStatus; 458 } catch (error) { 459 return false; 460 } 461 }, 462 463 /** 464 * Show logout confirmation if push notification is active 465 * Call this before logout action 466 * @returns {Promise<boolean>} true if user confirms logout, false if cancelled 467 */ 468 async confirmLogoutWithPushNotification() { 469 const isActive = await this.isPushActive(); 470 471 if (!isActive) { 472 // No active push, proceed with logout 473 return true; 474 } 475 476 // Show modal confirmation using modal.css 477 return new Promise((resolve) => { 478 // Create modal HTML 479 const modalHTML = ` 480 <div class="modal-overlay" id="logoutConfirmModal"> 481 <div class="modal-dialog"> 482 <div class="modal-header"> 483 <h3 class="modal-title"> 484 <span class="modal-icon warning">â ï¸</span> 485 Konfirmasi Logout 486 </h3> 487 </div> 488 <div class="modal-body"> 489 <p class="modal-message"> 490 Anda memiliki <strong>notifikasi push yang aktif</strong>.<br><br> 491 Setelah logout, Anda tidak akan menerima notifikasi chat lagi di perangkat ini.<br><br> 492 Apakah Anda yakin ingin melanjutkan logout? 493 </p> 494 </div> 495 <div class="modal-actions" style="padding: 0 20px 20px;"> 496 <button class="modal-btn modal-btn-secondary" id="logoutCancelBtn"> 497 Batal 498 </button> 499 <button class="modal-btn modal-btn-danger" id="logoutConfirmBtn"> 500 Lanjutkan Logout 501 </button> 502 </div> 503 </div> 504 </div> 505 `; 506 507 // Append to body 508 const modalContainer = document.createElement('div'); 509 modalContainer.innerHTML = modalHTML; 510 document.body.appendChild(modalContainer.firstElementChild); 511 512 const modal = document.getElementById('logoutConfirmModal'); 513 const confirmBtn = document.getElementById('logoutConfirmBtn'); 514 const cancelBtn = document.getElementById('logoutCancelBtn'); 515 516 // Show modal with animation 517 requestAnimationFrame(() => { 518 modal.classList.add('show'); 519 }); 520 521 // Handle confirm 522 const handleConfirm = () => { 523 cleanup(); 524 resolve(true); 525 }; 526 527 // Handle cancel 528 const handleCancel = () => { 529 cleanup(); 530 resolve(false); 531 }; 532 533 // Cleanup function 534 const cleanup = () => { 535 modal.classList.remove('show'); 536 setTimeout(() => { 537 modal.remove(); 538 }, 200); 539 confirmBtn.removeEventListener('click', handleConfirm); 540 cancelBtn.removeEventListener('click', handleCancel); 541 modal.removeEventListener('click', handleBackdropClick); 542 }; 543 544 // Handle backdrop click 545 const handleBackdropClick = (e) => { 546 if (e.target === modal) { 547 handleCancel(); 548 } 549 }; 550 551 // Add event listeners 552 confirmBtn.addEventListener('click', handleConfirm); 553 cancelBtn.addEventListener('click', handleCancel); 554 modal.addEventListener('click', handleBackdropClick); 555 }); 556 } 557}; 558 559// Add CSS animations 560const chatNotifStyle = document.createElement('style'); 561chatNotifStyle.textContent = ` 562 @keyframes slideInUp { 563 from { 564 transform: translateY(100%); 565 opacity: 0; 566 } 567 to { 568 transform: translateY(0); 569 opacity: 1;
570 } 571 } 572 573 @keyframes slideOutDown { 574 from { 575 transform: translateY(0); 576 opacity: 1; 577 } 578 to { 579 transform: translateY(100%); 580 opacity: 0; 581 } 582 } 583`; 584document.head.appendChild(chatNotifStyle); 585 586// Auto-initialize when DOM is ready 587if (document.readyState === 'loading') { 588 document.addEventListener('DOMContentLoaded', () => ChatNotification.init()); 589} else { 590 ChatNotification.init(); 591} 592 593// Expose to window for access from other scripts 594window.ChatNotification = ChatNotification;
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.