1/** 2 * Power Coupons - Checkout Drawer JavaScript 3 * 4 * @param {Object} $ jQuery object 5 * @package 6 * @since 1.0.0 7 */ 8( function ( $ ) { 9 'use strict'; 10 11 const PowerCouponsDrawer = { 12 renderedTriggerButton: false, 13 eventsBound: false, 14 lastFocusedElement: null, 15 focusableElements: null, 16 firstFocusableElement: null, 17 lastFocusableElement: null, 18 19 /** 20 * Sync every cart-dependent UI after the server-side cart changed. 21 * 22 * Delegates to the shared cart-refresh helper so the drawer never 23 * reloads the page: a reload resets the in-page state that multi-step 24 * checkout plugins such as CartFlows and FunnelKit hold in the 25 * browser. Reloading stays available as an opt-in via the 26 * `power_coupons_reload_page_after_coupon_is_applied` filter. 27 * 28 * @since 1.0.6 29 * @return {Promise} Resolves once the cart UI is in sync. 30 */ 31 syncCartUi() { 32 if ( powerCouponsData.reloadPageAfterCouponApplied ) { 33 window.location.reload(); 34 35 // Never resolves: the document is being torn down. 36 return new Promise( function () {} ); 37 } 38 39 if ( ! window.PowerCouponsCartRefresh ) { 40 return Promise.resolve( false ); 41 } 42 43 return window.PowerCouponsCartRefresh.refresh( { 44 source: 'checkout-drawer', 45 } ); 46 }, 47 48 /** 49 * Initialize 50 */ 51 init() { 52 this.drawer = $( '#power-coupons-drawer' ); 53 this.triggerBtn = $( '#power-coupons-view-coupons-btn' ); 54 this.closeBtn = $( '.power-coupons-drawer-close' ); 55 this.overlay = $( '.power-coupons-drawer-overlay' ); 56 this.loadingEl = $( '.power-coupons-drawer-loading' ); 57 this.couponsListEl = $( '.power-coupons-drawer-coupons-list' ); 58 this.isOpen = false; 59 this.couponsLoaded = false; 60 61 if ( this.drawer.length && powerCouponsDrawer.displayMode ) { 62 this.drawer.attr( 63 'data-display-mode', 64 powerCouponsDrawer.displayMode 65 ); 66 } 67 68 this.bindEvents(); 69 70 // Check if WooCommerce Blocks API is available 71 if ( 72 window && 73 window.wc && 74 window.wc.blocksCheckout && 75 this.triggerBtn.length === 0 && 76 typeof window.wc.blocksCheckout.registerCheckoutFilters === 77 'function' 78 ) { 79 // Register filters for blocks checkout 80 window.wc.blocksCheckout.registerCheckoutFilters( 81 'powerCouponsInjectDrawerButton', 82 { 83 itemName( defaultValue ) { 84 if ( 85 true === 86 PowerCouponsDrawer.renderedTriggerButton 87 ) { 88 return defaultValue; 89 } 90 91 const wcPageType = 92 null !== 93 document.querySelector( 94 '.wp-block-woocommerce-cart' 95 ) 96 ? 'cart' 97 : 'checkout'; 98 99 if ( 100 '' === powerCouponsDrawer.showOnCart && 101 'cart' === wcPageType 102 ) { 103 return defaultValue; 104 } 105 106 if ( 107 '' === powerCouponsDrawer.showOnCheckout && 108 'checkout' === wcPageType 109 ) { 110 return defaultValue; 111 } 112 113 const couponFormBlock = document.querySelector( 114 `.wp-block-woocommerce-${ wcPageType }-order-summary-coupon-form-block` 115 ); 116 117 if ( ! couponFormBlock ) { 118 return defaultValue; 119 } 120 121 // Insert after BOGO notifications if present, otherwise after coupon form. 122 const bogoNotifications = 123 couponFormBlock.parentElement.querySelector( 124 '.power-coupons-bogo-notifications' 125 ); 126 const targetElement = 127 bogoNotifications || couponFormBlock; 128 129 const tempDiv = document.createElement( 'div' ); 130 tempDiv.classList = 131 'power-coupons-drawer-button-wrapper wc-block-components-totals-wrapper'; 132 tempDiv.innerHTML = 133 powerCouponsDrawer.html.drawerButton; 134 135 targetElement.insertAdjacentElement( 136 'afterend', 137 tempDiv 138 ); 139 140 // Mark the button as rendered BEFORE re-initialising. 141 // init() can re-enter this filter, and the flag is 142 // what stops the button being injected twice. 143 PowerCouponsDrawer.renderedTriggerButton = true; 144 145 // Re-initialize element references against the 146 // markup we just inserted. Event handlers are 147 // delegated and bound once â see bindEvents(). 148 PowerCouponsDrawer.init(); 149 150 return defaultValue; 151 }, 152 } 153 ); 154 } 155 }, 156 157 /** 158 * Bind events 159 * 160 * Every handler below is delegated from `document`, so binding twice 161 * would leave two live handlers per selector and fire two apply/remove 162 * AJAX requests for a single click. init() is deliberately called more 163 * than once â the Blocks cart/checkout re-runs it after injecting the 164 * trigger button â so the guard lives here rather than at the call site. 165 */ 166 bindEvents() { 167 const self = this; 168 169 if ( this.eventsBound ) { 170 return; 171 } 172 this.eventsBound = true; 173 174 // Open drawer - use event delegation to handle dynamically replaced buttons 175 $( document ).on( 176 'click', 177 '#power-coupons-view-coupons-btn', 178 function ( e ) { 179 e.preventDefault(); 180 self.openDrawer(); 181 } 182 ); 183 184 // Close drawer - use event delegation 185 $( document ).on( 186 'click', 187 '.power-coupons-drawer-close', 188 function ( e ) { 189 e.preventDefault(); 190 self.closeDrawer(); 191 } 192 ); 193 194 // Close on overlay click - use event delegation 195 $( document ).on( 196 'click', 197 '.power-coupons-drawer-overlay', 198 function () { 199 self.closeDrawer(); 200 } 201 ); 202 203 // Close on ESC key and implement focus trap 204 $( document ).on( 'keydown', function ( e ) { 205 if ( ! self.isOpen ) { 206 return; 207 } 208 209 // ESC key closes drawer 210 if ( e.key === 'Escape' ) { 211 e.preventDefault(); 212 self.closeDrawer(); 213 return; 214 } 215 216 // Tab key - implement focus trap 217 if ( e.key === 'Tab' ) { 218 if ( 219 ! self.focusableElements || 220 self.focusableElements.length === 0 221 ) { 222 return; 223 } 224 225 const activeElement = 226 self.drawer[ 0 ].ownerDocument.activeElement; 227 228 if ( e.shiftKey ) { 229 // Shift + Tab 230 if ( activeElement === self.firstFocusableElement ) { 231 e.preventDefault(); 232 self.lastFocusableElement.focus(); 233 } 234 } else if ( activeElement === self.lastFocusableElement ) { 235 // Tab 236 e.preventDefault(); 237 self.firstFocusableElement.focus(); 238 } 239 } 240 } ); 241 242 // Handle apply coupon clicks (delegated) 243 $( document ).on( 244 'click', 245 '.power-coupon-apply-btn', 246 function ( e ) { 247 e.preventDefault(); 248 const couponCode = $( this ).data( 'coupon-code' ); 249 self.applyCoupon( couponCode, $( this ) ); 250 } 251 ); 252 253 // Handle remove coupon clicks (delegated) 254 $( document ).on( 255 'click', 256 '.power-coupon-remove-btn', 257 function ( e ) { 258 e.preventDefault(); 259 const couponCode = $( this ).data( 'coupon-code' ); 260 self.removeCoupon( couponCode, $( this ) ); 261 } 262 ); 263 }, 264 265 /** 266 * Open drawer 267 */ 268 openDrawer() {
269 // Store the element that had focus before opening 270 this.lastFocusedElement = 271 this.drawer[ 0 ].ownerDocument.activeElement; 272 273 this.isOpen = true; 274 this.drawer.addClass( 'is-open' ); 275 this.drawer.attr( 'aria-hidden', 'false' ); 276 this.triggerBtn.attr( 'aria-expanded', 'true' ); 277 $( 'body' ).css( 'overflow', 'hidden' ); 278 279 // Load coupons if not already loaded 280 if ( ! this.couponsLoaded ) { 281 this.loadCoupons(); 282 } 283 284 // Set up focus trap 285 this.setupFocusTrap(); 286 287 // Move focus to close button after a short delay 288 setTimeout( () => { 289 this.closeBtn.focus(); 290 }, 100 ); 291 }, 292 293 /** 294 * Setup focus trap for drawer 295 */ 296 setupFocusTrap() { 297 // Get all focusable elements within the drawer 298 const focusableSelectors = 299 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; 300 this.focusableElements = this.drawer 301 .find( focusableSelectors ) 302 .filter( ':visible' ) 303 .get(); 304 305 if ( this.focusableElements.length > 0 ) { 306 this.firstFocusableElement = this.focusableElements[ 0 ]; 307 this.lastFocusableElement = 308 this.focusableElements[ this.focusableElements.length - 1 ]; 309 } 310 }, 311 312 /** 313 * Close drawer 314 */ 315 closeDrawer() { 316 this.isOpen = false; 317 this.drawer.removeClass( 'is-open' ); 318 this.drawer.attr( 'aria-hidden', 'true' ); 319 this.triggerBtn.attr( 'aria-expanded', 'false' ); 320 $( 'body' ).css( 'overflow', '' ); 321 322 // Restore focus to the element that opened the drawer 323 if ( this.lastFocusedElement ) { 324 this.lastFocusedElement.focus(); 325 this.lastFocusedElement = null; 326 } 327 }, 328 329 /** 330 * Load coupons via AJAX 331 */ 332 loadCoupons() { 333 const self = this; 334 335 this.loadingEl.show(); 336 this.couponsListEl.hide().removeClass( 'loaded' ); 337 338 $.ajax( { 339 url: powerCouponsDrawer.ajaxUrl, 340 type: 'POST', 341 data: { 342 action: 'power_coupons_get_drawer_coupons', 343 nonce: powerCouponsDrawer.nonce, 344 }, 345 success( response ) { 346 if ( response.success && response.data.html ) { 347 self.couponsListEl.html( response.data.html ); 348 self.couponsListEl.addClass( 'loaded' ).show(); 349 self.loadingEl.hide(); 350 self.couponsLoaded = true; 351 } else { 352 self.showError( 353 'Failed to load coupons. Please try again.' 354 ); 355 } 356 }, 357 error() { 358 self.showError( 'Connection error. Please try again.' ); 359 }, 360 } ); 361 }, 362 363 /** 364 * Reload coupons (for refresh after apply/remove) 365 */ 366 reloadCoupons() { 367 this.couponsLoaded = false; 368 this.loadCoupons(); 369 // Refresh focus trap after loading new content 370 setTimeout( () => { 371 this.setupFocusTrap(); 372 }, 100 ); 373 }, 374 375 /** 376 * Apply coupon 377 * 378 * @param {string} couponCode Coupon code to apply 379 * @param {Object} $button jQuery button element 380 */ 381 applyCoupon( couponCode, $button ) { 382 const self = this; 383 384 if ( ! couponCode ) { 385 return; 386 } 387 388 // Disable button and show loading state 389 $button.prop( 'disabled', true ).addClass( 'loading' ); 390 391 $.ajax( { 392 url: powerCouponsData.ajaxUrl.applyCoupon, 393 type: 'POST', 394 data: { 395 coupon_code: couponCode, 396 nonce: powerCouponsData.nonce, 397 }, 398 success( response ) { 399 if ( response.success ) { 400 // Show success message 401 self.showNotice( 402 powerCouponsData.text.successMessage || 403 'Coupon applied successfully!', 404 'success' 405 ); 406 407 // Sync cart totals in place, then re-render the 408 // drawer against the fresh cart. 409 self.syncCartUi().then( function () { 410 self.reloadCoupons(); 411 } ); 412 } else { 413 const errorMessage = 414 response.data && response.data.message 415 ? response.data.message 416 : powerCouponsData.text.genericErrorMessage || 417 'Failed to apply coupon.'; 418 self.showNotice( errorMessage, 'error' ); 419 $button 420 .prop( 'disabled', false ) 421 .removeClass( 'loading' ); 422 } 423 }, 424 error() { 425 self.showNotice( 426 powerCouponsData.text.networkErrorMessage || 427 'Connection error.', 428 'error' 429 ); 430 $button.prop( 'disabled', false ).removeClass( 'loading' ); 431 }, 432 } ); 433 }, 434 435 /**
436 * Remove coupon 437 * 438 * @param {string} couponCode Coupon code to remove 439 * @param {Object} $button jQuery button element 440 */ 441 removeCoupon( couponCode, $button ) { 442 const self = this; 443 444 if ( ! couponCode ) { 445 return; 446 } 447 448 // Disable button and show loading state 449 $button.prop( 'disabled', true ).addClass( 'loading' ); 450 451 $.ajax( { 452 url: powerCouponsData.ajaxUrl.removeCoupon, 453 type: 'POST', 454 data: { 455 coupon_code: couponCode, 456 nonce: powerCouponsData.nonce, 457 }, 458 success( response ) { 459 if ( response.success ) { 460 // Show success message 461 self.showNotice( 462 powerCouponsData.text.couponRemovedMessage || 463 'Coupon removed.', 464 'success' 465 ); 466 467 // Sync cart totals in place, then re-render the 468 // drawer against the fresh cart. 469 self.syncCartUi().then( function () { 470 self.reloadCoupons(); 471 } ); 472 } else { 473 const errorMessage = 474 response.data && response.data.message 475 ? response.data.message 476 : powerCouponsData.text.genericErrorMessage || 477 'Failed to remove coupon.'; 478 self.showNotice( errorMessage, 'error' ); 479 $button 480 .prop( 'disabled', false ) 481 .removeClass( 'loading' ); 482 } 483 }, 484 error() { 485 self.showNotice( 486 powerCouponsData.text.networkErrorMessage || 487 'Connection error.', 488 'error' 489 ); 490 $button.prop( 'disabled', false ).removeClass( 'loading' ); 491 }, 492 } ); 493 }, 494 495 /** 496 * Show error message in drawer 497 * 498 * @param {string} message Error message to display 499 */ 500 showError( message ) { 501 this.loadingEl.hide(); 502 this.couponsListEl 503 .html( 504 '<div class="power-coupons-no-coupons" role="alert"><p>' + 505 message + 506 '</p></div>' 507 ) 508 .addClass( 'loaded' ) 509 .show(); 510 }, 511 512 /** 513 * Show notice/toast message 514 * 515 * @param {string} message Notice message to display 516 * @param {string} type Notice type (success or error) 517 */ 518 showNotice( message, type ) { 519 // Use role="status" (polite) for success, role="alert" (assertive) for errors. 520 const noticeRole = type === 'error' ? 'alert' : 'status'; 521 const ariaLive = type === 'error' ? 'assertive' : 'polite'; 522 523 // Use WooCommerce notices if available 524 const noticeWrapper = $( '.woocommerce-notices-wrapper' ).first(); 525 526 if ( noticeWrapper.length ) { 527 const noticeClass = 528 type === 'success' 529 ? 'woocommerce-message' 530 : 'woocommerce-error'; 531 const notice = $( '<div>' ) 532 .addClass( noticeClass ) 533 .attr( 'role', noticeRole ) 534 .attr( 'aria-live', ariaLive ) 535 .attr( 'aria-atomic', 'true' ) 536 .text( message ); 537 538 noticeWrapper.html( notice ); 539 540 // Scroll to notice 541 $( 'html, body' ).animate( 542 { 543 scrollTop: noticeWrapper.offset().top - 100, 544 }, 545 500 546 ); 547 } else { 548 // Fallback: create a temporary inline notice instead of disruptive alert(). 549 const fallbackNotice = $( '<div>' ) 550 .addClass( 'power-coupons-inline-notice' ) 551 .attr( 'role', noticeRole ) 552 .attr( 'aria-atomic', 'true' ) 553 .text( message ) 554 .css( { 555 position: 'fixed', 556 top: '20px', 557 right: '20px', 558 zIndex: 999999, 559 padding: '12px 20px', 560 borderRadius: '4px', 561 maxWidth: '400px', 562 background: type === 'error' ? '#fee2e2' : '#f0fdf4', 563 color: type === 'error' ? '#991b1b' : '#166534', 564 border: 565 '1px solid ' + 566 ( type === 'error' ? '#fecaca' : '#86efac' ), 567 boxShadow: '0 4px 12px rgba(0,0,0,0.15)', 568 fontSize: '14px', 569 lineHeight: '1.5', 570 } ); 571 572 $( 'body' ).append( fallbackNotice ); 573 574 setTimeout( function () { 575 fallbackNotice.fadeOut( function () { 576 $( this ).remove(); 577 } ); 578 }, 5000 ); 579 } 580 }, 581 }; 582 583 // Initialize on document ready 584 $( document ).ready( function () { 585 // Check if drawer elements exist 586 if ( $( '#power-coupons-drawer' ).length ) { 587 PowerCouponsDrawer.init(); 588 } 589 } ); 590 591 // Re-initialize after WooCommerce updates that replace DOM elements 592 // These events fire when WooCommerce refreshes cart/checkout content 593 $( document.body ).on( 594 'updated_checkout updated_cart_totals updated_wc_div', 595 function () { 596 // Re-initialize drawer references if button was replaced 597 if ( $( '#power-coupons-view-coupons-btn' ).length ) { 598 PowerCouponsDrawer.triggerBtn = $( 599 '#power-coupons-view-coupons-btn' 600 ); 601 // Update aria-expanded attribute if drawer is open 602 if ( PowerCouponsDrawer.isOpen ) { 603 PowerCouponsDrawer.triggerBtn.attr( 604 'aria-expanded', 605 'true' 606 ); 607 } 608 } 609 } 610 ); 611} )( jQuery );
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.