1(function($) { 2 'use strict'; 3 4 // Plugin configuration and state 5 const WSDM = { 6 config: { 7 shipping_format: 'radio', 8 is_blocks_enabled: false 9 }, 10 cache: { 11 observers: new WeakMap(), 12 timeouts: new Map(), 13 convertedPackages: new Set(), // Track converted packages to prevent duplicates 14 conversionInProgress: false, // Prevent multiple simultaneous conversions 15 selectChangeInProgress: false // Track when our select changes are happening 16 }, 17 constants: { 18 CONVERSION_DELAY: 100, 19 SYNC_DEBOUNCE_DELAY: 50, 20 BLOCK_SELECTORS: { 21 CART: '.wp-block-woocommerce-cart .wc-block-components-radio-control', 22 CHECKOUT: '.wp-block-woocommerce-checkout #shipping-option .wc-block-components-radio-control' 23 }, 24 CLASSES: { 25 CONVERTED: 'wsdm-converted', 26 SHIPPING_SELECT: 'wsdm-shipping-select', 27 BLOCK_SELECT: 'wsdm-block-shipping-select', 28 PACKAGE_CONVERTED: 'wsdm-package-converted' // Mark converted packages 29 } 30 } 31 }; 32 33 // Initialize plugin when DOM is ready 34 $(document).ready(function() { 35 // Enable debug mode temporarily to help diagnose the issue 36 window.wsdm_debug = false; 37 38 logDebug('DOM ready, initializing plugin'); 39 initializePlugin(); 40 bindEvents(); 41 42 // Add manual trigger for testing 43 window.wsdmForceConversion = function() { 44 logDebug('Manual conversion triggered'); 45 // Clear conversion cache before forcing 46 WSDM.cache.convertedPackages.clear(); 47 WSDM.cache.conversionInProgress = false; 48 initializeShippingMethods(); 49 }; 50 }); 51 52 /** 53 * Initialize the plugin 54 */ 55 function initializePlugin() { 56 loadConfiguration(); 57 initializeShippingMethods(); 58 } 59 60 /** 61 * Bind all event listeners 62 */ 63 function bindEvents() { 64 // Classic cart/checkout events - handle WooCommerce HTML refreshes 65 $('body').on('updated_cart_totals updated_checkout', debounce(function() { 66 logDebug('Cart/checkout update event detected'); 67 68 // If we had select dropdowns before but they're gone now, WooCommerce refreshed the HTML 69 const existingSelects = $('.' + WSDM.constants.CLASSES.SHIPPING_SELECT).length; 70 const hadConvertedPackages = WSDM.cache.convertedPackages.size > 0; 71 const shippingMethods = $('.shipping_method').length; 72 73 logDebug('Update check - existingSelects: ' + existingSelects + ', hadConverted: ' + hadConvertedPackages + ', shippingMethods: ' + shippingMethods); 74 75 if (hadConvertedPackages && existingSelects === 0 && shippingMethods > 0) { 76 logDebug('WooCommerce refreshed HTML - re-converting dropdowns...'); 77 // Clear the converted packages cache since HTML was refreshed 78 WSDM.cache.convertedPackages.clear(); 79 WSDM.cache.conversionInProgress = false; 80 // Re-convert immediately 81 setTimeout(function() { 82 WSDM.cache.selectChangeInProgress = false; 83 initializeShippingMethods(); 84 }, 100); 85 } else if (existingSelects === 0 && !hadConvertedPackages && !WSDM.cache.conversionInProgress && shippingMethods > 0) { 86 logDebug('Initial conversion needed...'); 87 initializeShippingMethods(); 88 } else { 89 logDebug('No conversion needed - selects exist or no shipping methods found'); 90 } 91 }, 200)); 92 93 // Global event delegation for WSDM select dropdowns as fallback 94 $('body').on('change.wsdm', '.' + WSDM.constants.CLASSES.SHIPPING_SELECT, function() { 95 logDebug('Global event handler triggered for select: ' + $(this).attr('id')); 96 handleShippingSelectChange($(this)); 97 }); 98 99 // Block-based cart/checkout events 100 if (isWpDataAvailable()) { 101 setupStoreSubscription(); 102 setupCleanupHandlers(); 103 } 104 } 105 106 /** 107 * Set up Store API subscription for blocks 108 */ 109 function setupStoreSubscription() { 110 let unsubscribe = null; 111 112 const subscribeToStore = () => { 113 if (!wp.data.select('wc/store/cart')) { 114 return; 115 } 116 117 unsubscribe = wp.data.subscribe(() => { 118 const cart = wp.data.select('wc/store/cart').getCartData(); 119 if (cart && cart.shippingRates) { 120 debounce(initializeShippingMethods, WSDM.constants.CONVERSION_DELAY)(); 121 } 122 }); 123 }; 124 125 // Subscribe when ready 126 if (document.readyState === 'loading') { 127 document.addEventListener('DOMContentLoaded', subscribeToStore); 128 } else { 129 subscribeToStore(); 130 } 131 132 // Store cleanup function 133 WSDM.cache.unsubscribe = unsubscribe; 134 } 135 136 /** 137 * Set up cleanup handlers 138 */ 139 function setupCleanupHandlers() { 140 window.addEventListener('beforeunload', () => { 141 if (WSDM.cache.unsubscribe) { 142 WSDM.cache.unsubscribe(); 143 } 144 if (WSDM.cache.mutationObserver) { 145 WSDM.cache.mutationObserver.disconnect(); 146 } 147 cleanupObservers(); 148 clearAllTimeouts(); 149 }); 150 } 151 152 /** 153 * Load plugin configuration from localized script 154 */ 155 function loadConfiguration() { 156 const params = window.wsdm_params || {}; 157 logDebug('Loading configuration from wsdm_params: ' + JSON.stringify(params)); 158 159 WSDM.config.shipping_format = sanitizeString(params.shipping_format, 'radio'); 160 WSDM.config.is_blocks_enabled = Boolean(params.is_blocks_enabled); 161 162 logDebug('Final config - shipping_format: ' + WSDM.config.shipping_format + ', is_blocks_enabled: ' + WSDM.config.is_blocks_enabled); 163 164 // Maintain legacy global for backward compatibility 165 window.wsdmConfig = WSDM.config; 166 167 // If configuration is not 'select', provide a way to force it for testing 168 if (WSDM.config.shipping_format !== 'select') { 169 logDebug('WARNING: shipping_format is not set to "select". Current value: ' + WSDM.config.shipping_format); 170 logDebug('To test select mode, use: window.wsdmShippingDisplayMode.debug.forceSelectMode()'); 171 } 172 } 173 174 /** 175 * Global handler for shipping select changes (fallback) 176 */ 177 function handleShippingSelectChange($select) { 178 const selectedValue = $select.val(); 179 const packageIndex = $select.data('package-index') || 0; 180 181 logDebug('Handling select change - preventing HTML refresh'); 182 WSDM.cache.selectChangeInProgress = true; 183 184 // Store current state before triggering WooCommerce update 185 const currentPackages = new Set(WSDM.cache.convertedPackages); 186 187 // Find corresponding hidden radios 188 const $wrapper = $select.closest('.wsdm-shipping-wrapper'); 189 const $hiddenRadios = $wrapper.find('.wsdm-hidden-shipping-radios'); 190 191 if ($hiddenRadios.length) { 192 const $hiddenRadio = $hiddenRadios.find('input[value="' + selectedValue + '"]'); 193 if ($hiddenRadio.length) { 194 $hiddenRadios.find('input[type="radio"]').prop('checked', false); 195 $hiddenRadio.prop('checked', true); 196 197 // Trigger change directly without using jQuery trigger to avoid interference 198 const event = new Event('change', { bubbles: true }); 199 $hiddenRadio[0].dispatchEvent(event); 200 201 logDebug('Global handler updated hidden radio for package ' + packageIndex); 202 } 203 } 204 205 // Use a more direct approach for WooCommerce updates 206 const $form = $select.closest('form'); 207 if ($form.length && ($form.hasClass('checkout') || $form.attr('name') === 'checkout')) { 208 // For checkout, trigger the specific update 209 $('body').trigger('update_checkout'); 210 } else { 211 // For cart, trigger cart totals update 212 $('body').trigger('updated_cart_totals'); 213 } 214 215 // Set up monitoring for HTML refresh and immediate re-conversion 216 let checkCount = 0; 217 const checkInterval = setInterval(function() { 218 checkCount++; 219 const selectsExist = $('.' + WSDM.constants.CLASSES.SH
219IPPING_SELECT).length > 0; 220 221 if (!selectsExist && checkCount < 20) { 222 logDebug('Select dropdowns disappeared, re-converting immediately...'); 223 WSDM.cache.convertedPackages = currentPackages; 224 WSDM.cache.conversionInProgress = false; 225 initializeShippingMethods(); 226 clearInterval(checkInterval); 227 } else if (checkCount >= 20) { 228 clearInterval(checkInterval); 229 } 230 }, 50); 231 232 // Clear the flag after sufficient delay 233 setTimeout(function() { 234 WSDM.cache.selectChangeInProgress = false; 235 clearInterval(checkInterval); 236 logDebug('Cleared selectChangeInProgress flag'); 237 }, 2000); 238 } 239 240 /** 241 * Initialize shipping method conversion 242 */ 243 function initializeShippingMethods() { 244 logDebug('Initializing shipping methods. Config format: ' + WSDM.config.shipping_format); 245 logDebug('Blocks enabled: ' + WSDM.config.is_blocks_enabled); 246 247 // Prevent multiple simultaneous conversions 248 if (WSDM.cache.conversionInProgress) { 249 logDebug('Conversion already in progress, skipping...'); 250 return; 251 } 252 253 if (WSDM.config.shipping_format === 'select') { 254 logDebug('Starting conversion to select dropdowns'); 255 WSDM.cache.conversionInProgress = true; 256 257 try { 258 convertClassicShippingMethods(); 259 convertBlockShippingMethods(); 260 } finally { 261 // Reset flag after conversion completes 262 setTimeout(function() { 263 WSDM.cache.conversionInProgress = false; 264 }, 500); 265 } 266 } else { 267 logDebug('Shipping format is not select, skipping conversion'); 268 } 269 } 270 271 /** 272 * Convert classic shipping methods to dropdown 273 */ 274 function convertClassicShippingMethods() { 275 logDebug('Starting classic shipping methods conversion'); 276 277 // First check if there are any shipping methods at all 278 const $allShippingMethods = $('.shipping_method'); 279 logDebug('Found ' + $allShippingMethods.length + ' total shipping methods on page'); 280 281 if ($allShippingMethods.length === 0) { 282 logDebug('No shipping methods found, exiting classic conversion'); 283 return; 284 } 285 286 // Group shipping methods by package instead of processing all together 287 const packageGroups = groupShippingMethodsByPackage(); 288 logDebug('Detected ' + packageGroups.length + ' package groups'); 289 290 if (packageGroups.length === 0) { 291 logDebug('No package groups detected, exiting classic conversion'); 292 return; 293 } 294 295 // Process each package separately 296 packageGroups.forEach(function(packageData, index) { 297 logDebug('Processing package ' + index + ' with ' + packageData.methods.length + ' methods'); 298 299 if (shouldConvertClassicPackage(packageData)) { 300 logDebug('Converting package ' + index + ' to select dropdown'); 301 convertPackageToSelect(packageData, index); 302 } else { 303 logDebug('Package ' + index + ' should not be converted'); 304 } 305 }); 306 } 307 308 /** 309 * Group shipping methods by package 310 */ 311 function groupShippingMethodsByPackage() { 312 const packageGroups = []; 313 logDebug('Starting package grouping'); 314 315 const $allMethods = $('.shipping_method'); 316 if ($allMethods.length === 0) { 317 logDebug('No shipping methods found with class .shipping_method'); 318 return packageGroups; 319 } 320 321 logDebug('Found ' + $allMethods.length + ' shipping methods total'); 322 323 // Strategy 1: Look for shipping methods grouped by name attribute (for multiple packages) 324 const methodsByName = {}; 325 $allMethods.each(function() { 326 const $method = $(this); 327 const nameAttr = $method.attr('name') || 'shipping_method[0]'; 328 logDebug('Found shipping method with name: ' + nameAttr + ', value: ' + $method.val()); 329 330 if (!methodsByName[nameAttr]) { 331 methodsByName[nameAttr] = []; 332 } 333 methodsByName[nameAttr].push($method); 334 }); 335 336 logDebug('Methods grouped by name attribute: ' + Object.keys(methodsByName).length + ' groups'); 337 338 // Convert each name group to a package 339 Object.entries(methodsByName).forEach(function([name, methods], index) { 340 341 if (methods.length > 0) { 342 const $methods = $(methods); 343 344 // Find the best container for this package - try multiple selectors 345 const $firstMethod = $methods.first()[0]; 346 347 let $container = null; 348
349 // Try different container selectors in order of preference 350 const containerSelectors = [ 351 '.woocommerce-shipping-methods', 352 'ul.woocommerce-shipping-methods', 353 '#shipping_method', 354 '.shipping', 355 'table.woocommerce-checkout-review-order-table tbody', 356 '.shop_table tbody', 357 'tbody', 358 'ul', 359 'ol', 360 '.shipping-methods', 361 '[class*="shipping"]', 362 'tr.shipping', 363 'tr' 364 ]; 365 366 logDebug('Trying to find container for first method...'); 367 logDebug('First method HTML: ' + $firstMethod.prop('outerHTML')); 368 logDebug('First method parent: ' + $firstMethod.parent().prop('tagName') + ' (class: ' + $firstMethod.parent().attr('class') + ')'); 369 370 for (let i = 0; i < containerSelectors.length; i++) { 371 $container = $firstMethod.closest(containerSelectors[i]); 372 if ($container.length) { 373 logDebug('Found container with selector "' + containerSelectors[i] + '": ' + $container.prop('tagName')); 374 break; 375 } 376 } 377 378 // If still no container found, use the immediate parent 379 if (!$container || !$container.length) { 380 logDebug('No specific container found, using immediate parent'); 381 $container = $firstMethod.parent(); 382 383 // If parent is a label, go up one more level 384 if ($container.is('label')) { 385 $container = $container.parent(); 386 logDebug('Parent was a label, using grandparent: ' + $container.prop('tagName')); 387 } 388 } 389 390 logDebug('Final container: ' + $container.prop('tagName') + ' (class: ' + $container.attr('class') + ')'); 391 logDebug('Container HTML preview: ' + $container.prop('outerHTML').substring(0, 200) + '...'); 392 393 logDebug('Package ' + index + ' - Name: ' + name + ', Methods: ' + methods.length + ', Container: ' + $container.prop('tagName')); 394 395 packageGroups.push({ 396 container: $container, 397 methods: $methods, 398 packageName: name, 399 packageIndex: index 400 }); 401 } 402 }); 403 404 logDebug('Created ' + packageGroups.length + ' package groups'); 405 return packageGroups; 406 } 407 408 /** 409 * Check if a classic package should be converted 410 */ 411 function shouldConvertClassicPackage(packageData) { 412 const methodCount = packageData.methods.length; 413 const isAlreadySelect = packageData.methods.is('select'); 414 const isAlreadyConverted = packageData.methods.hasClass(WSDM.constants.CLASSES.SHIPPING_SELECT); 415 const isBlockContext = packageData.container.closest('.wp-block-woocommerce-cart, .wp-block-woocommerce-checkout').length > 0; 416 417 // Check if this package was already converted 418 const packageId = packageData.packageName || packageData.packageIndex || 'unknown'; 419 const wasAlreadyConverted = WSDM.cache.convertedPackages.has(packageId); 420 421 // Check if container already has a converted package 422 const containerHasSelect = packageData.container.find('.' + WSDM.constants.CLASSES.SHIPPING_SELECT).length > 0; 423 424 // Check if any of the methods are already in a converted wrapper 425 const methodsInWrapper = packageData.methods.closest('.wsdm-shipping-wrapper').length > 0; 426 427 logDebug('Package conversion check:'); 428 logDebug(' - Package ID: ' + packageId); 429 logDebug(' - Methods count: ' + methodCount); 430 logDebug(' - Is already select: ' + isAlreadySelect); 431 logDebug(' - Is already converted: ' + isAlreadyConverted); 432 logDebug(' - Was already converted: ' + wasAlreadyConverted); 433 logDebug(' - Container has select: ' + containerHasSelect); 434 logDebug(' - Methods in wrapper: ' + methodsInWrapper); 435 logDebug(' - Is block context: ' + isBlockContext); 436 437 // Convert if there are 2 or more methods, not already converted, and not in block context 438 const shouldConvert = methodCount >= 2 && 439 !isAlreadySelect && 440 !isAlreadyConverted && 441 !wasAlreadyConverted && 442 !containerHasSelect && 443 !methodsInWrapper && 444 !isBlockContext; 445 446 logDebug(' - Should convert: ' + shouldConvert); 447 448 return shouldConvert; 449 } 450 451 /** 452 * Convert a package to select dropdown 453 */ 454 function convertPackageToSelect(packageData, packageIndex) { 455 const packageId = packageData.packageName || packageData.packageIndex || packageIndex; 456 logDebug('Starting conversion for package ' + packageIndex + ' (ID: ' + packageId + ')'); 457 458 // Mark this package as being converted 459 WSDM.cache.convertedPackages.add(packageId); 460 461 try { 462 const $select = createClassicSelectElement(packageData.methods, packageIndex); 463 logDebug('Created select element with ID: ' + $select.attr('id')); 464 465 populateClassicOptions(packageData.methods, $select); 466 logDebug('Populated ' + $select.find('option').length + ' options'); 467 468 // Create hidden radio buttons for WooCommerce compatibility 469 const $hiddenRadios = createHiddenRadiosForPackage(packageData); 470 logDebug('Created hidden radios container with ' + $hiddenRadios.find('input').length + ' radios'); 471 472 replaceClassicPackageElements(packageData, $select, $hiddenRadios); 473 logDebug('Replaced package elements in DOM'); 474 475 // Mark the container as converted 476 packageData.container.addClass(WSDM.constants.CLASSES.PACKAGE_CONVERTED); 477 478 bindClassicPackageEvents($select, packageData, $hiddenRadios, packageIndex); 479 logDebug('Bound events for package ' + packageIndex); 480 481 logDebug('Package ' + packageIndex + ' (ID: ' + packageId + ') conversion completed successfully'); 482 483 } catch (error) { 484 logError('Error converting package ' + packageIndex, error); 485 // Remove from converted cache if conversion failed 486 WSDM.cache.convertedPackages.delete(packageId); 487 } 488 } 489 490 /** 491 * Create hidden radio buttons for WooCommerce compatibility 492 */ 493 function createHiddenRadiosForPackage(packageData) {
494 const $hiddenContainer = $('<div>', { 495 style: 'display: none !important;', 496 class: 'wsdm-hidden-shipping-radios' 497 }); 498 499 packageData.methods.each(function() { 500 const $originalRadio = $(this); 501 const $hiddenRadio = $originalRadio.clone(true); 502 503 // Ensure the hidden radio maintains all original attributes 504 $hiddenRadio.attr({ 505 'data-wsdm-hidden': 'true', 506 'style': 'display: none !important;' 507 }); 508 509 $hiddenContainer.append($hiddenRadio); 510 }); 511 512 return $hiddenContainer; 513 } 514 515 /** 516 * Create select element for classic shipping 517 */ 518 function createClassicSelectElement($shippingOptions, packageIndex) { 519 const $first = $shippingOptions.first(); 520 return $('<select>', { 521 class: `${WSDM.constants.CLASSES.SHIPPING_SELECT}`, 522 name: sanitizeAttribute($first.attr('name')), 523 'data-index': sanitizeAttribute($first.attr('data-index')), 524 'data-package-index': packageIndex || 0, 525 'id': 'wsdm-shipping-select-' + (packageIndex || 0) 526 }); 527 } 528 529 /** 530 * Populate classic select options 531 */ 532 function populateClassicOptions($shippingOptions, $select) { 533 $shippingOptions.each(function() { 534 const $radio = $(this); 535 const label = sanitizeString($radio.closest('li').text().trim()); 536 const value = sanitizeAttribute($radio.val()); 537 const isChecked = $radio.prop('checked'); 538 539 $('<option>', { 540 value: value, 541 text: label, 542 selected: isChecked 543 }).appendTo($select); 544 }); 545 } 546 547 /** 548 * Replace classic package elements with select 549 */ 550 function replaceClassicPackageElements(packageData, $select, $hiddenRadios) { 551 logDebug('Starting element replacement'); 552 logDebug('Package container: ' + packageData.container.prop('tagName') + ' (class: ' + packageData.container.attr('class') + ')'); 553 554 // Create a wrapper div for select and hidden radios 555 const $wrapper = $('<div>', { 556 class: 'wsdm-shipping-wrapper', 557 'data-package': packageData.packageName || 'unknown' 558 }); 559 $wrapper.append($select, $hiddenRadios); 560 561 // Try different replacement strategies 562 let replacementSuccess = false; 563 564 // Strategy 1: Replace individual method containers 565 const $methodContainers = packageData.methods.closest('li, tr, .shipping-method-item, label'); 566 logDebug('Found ' + $methodContainers.length + ' method containers'); 567 568 if ($methodContainers.length > 0) { 569 try { 570 const $firstContainer = $methodContainers.first(); 571 logDebug('Method containers found. First container: ' + $firstContainer.prop('tagName') + ' (class: ' + $firstContainer.attr('class') + ')'); 572 573 // Remove all other method containers in this package 574 $methodContainers.not($firstContainer).remove(); 575 logDebug('Removed ' + ($methodContainers.length - 1) + ' other method containers'); 576 577 // Replace the first container with our wrapper 578 $firstContainer.replaceWith($wrapper); 579 logDebug('Replaced first container with wrapper'); 580 replacementSuccess = true; 581 582 } catch (error) { 583 logError('Error replacing method containers', error); 584 } 585 } 586 587 // Strategy 2: Replace all methods within their common container 588 if (!replacementSuccess) { 589 try { 590 logDebug('Trying to replace all methods within container'); 591 592 // Hide all the original radio methods 593 // packageData.methods.each(function() { 594 // $(this).closest('li, tr, label, div').hide(); 595 // }); 596 597 // Insert the wrapper at the beginning of the container 598 packageData.container.prepend($wrapper); 599 logDebug('Prepended wrapper to container'); 600 replacementSuccess = true; 601 602 } catch (error) { 603 logError('Error with container replacement', error); 604 } 605 } 606 607 // Strategy 3: Replace the entire container content (last resort) 608 if (!replacementSuccess) { 609 try { 610 logDebug('Last resort: replacing entire container content'); 611 packageData.container.html($wrapper); 612 logDebug('Replaced container content'); 613 replacementSuccess = true; 614 615 } catch (error) { 616 logError('Error replacing container content', error); 617 } 618 } 619 620 // Verify the replacement worked 621 setTimeout(function() { 622 const $insertedSelect = $('.' + WSDM.constants.CLASSES.SH
622IPPING_SELECT); 623 logDebug('Verification: Found ' + $insertedSelect.length + ' select elements after replacement'); 624 625 if ($insertedSelect.length === 0) { 626 logError('No select elements found after replacement!'); 627 } 628 }, 100); 629 } 630 631 /** 632 * Bind events for classic package select 633 */ 634 function bindClassicPackageEvents($select, packageData, $hiddenRadios, packageIndex) { 635 logDebug('Binding events for package ' + packageIndex + ' select with ID: ' + $select.attr('id')); 636 637 // Remove any existing event handlers first 638 $select.off('change.wsdm'); 639 640 $select.on('change.wsdm', function() { 641 const selectedValue = $(this).val(); 642 const selectId = $(this).attr('id'); 643 logDebug('Package ' + packageIndex + ' select changed to: ' + selectedValue + ' (ID: ' + selectId + ')'); 644 645 // Set flag to prevent re-initialization 646 WSDM.cache.selectChangeInProgress = true; 647 648 // Update the corresponding hidden radio button 649 const $hiddenRadio = $hiddenRadios.find('input[value="' + selectedValue + '"]'); 650 if ($hiddenRadio.length) { 651 // Uncheck all hidden radios in this package first 652 $hiddenRadios.find('input[type="radio"]').prop('checked', false); 653 654 // Check the selected one 655 $hiddenRadio.prop('checked', true); 656 657 // Trigger change event on the hidden radio for WooCommerce 658 $hiddenRadio.trigger('change'); 659 logDebug('Hidden radio updated for package ' + packageIndex); 660 } else { 661 logDebug('No hidden radio found for value: ' + selectedValue); 662 } 663 664 // Get form context for proper WooCommerce integration 665 const $form = $(this).closest('form'); 666 667 // Trigger appropriate WooCommerce events 668 if ($form.length && ($form.hasClass('checkout') || $form.attr('name') === 'checkout')) { 669 logDebug('Triggering checkout update events for package ' + packageIndex); 670 // For checkout page 671 $('body').trigger('update_checkout'); 672 $form.trigger('checkout_updated'); 673 } else { 674 logDebug('Triggering cart update events for package ' + packageIndex); 675 // For cart page or other contexts 676 $('body').trigger('updated_cart_totals'); 677 678 // Also trigger WooCommerce cart update if available 679 if (typeof wc_cart_params !== 'undefined') { 680 $('body').trigger('wc_update_cart'); 681 } 682 } 683 684 // Additional fallback for legacy WooCommerce versions 685 if (typeof wc_checkout_params !== 'undefined') { 686 $(this).trigger('change'); 687 } 688 689 // Force form submission for immediate update - delay to prevent conflicts 690 setTimeout(function() { 691 if ($form.length) { 692 const $updateBtn = $form.find('[name="update_cart"], .button[name="update_cart"]'); 693 if ($updateBtn.length) { 694 logDebug('Triggering update cart button for package ' + packageIndex); 695 $updateBtn.trigger('click'); 696 } 697 } 698 }, 150); 699 700 // Clear the flag after sufficient delay 701 setTimeout(function() { 702 WSDM.cache.selectChangeInProgress = false; 703 logDebug('Cleared selectChangeInProgress flag after package ' + packageIndex + ' change'); 704 }, 1200); 705 }); 706 707 logDebug('Events bound successfully for package ' + packageIndex); 708 } 709 710 /** 711 * Convert block-based shipping methods to dropdown 712 */ 713 function convertBlockShippingMethods() {
714 if (!WSDM.config.is_blocks_enabled || WSDM.config.shipping_format !== 'select') { 715 return; 716 } 717 718 // Use timeout to ensure block rendering is complete 719 const timeoutId = setTimeout(() => { 720 processBlockShippingMethods(); 721 WSDM.cache.timeouts.delete('blockConversion'); 722 }, WSDM.constants.CONVERSION_DELAY); 723 724 WSDM.cache.timeouts.set('blockConversion', timeoutId); 725 } 726 727 /** 728 * Process all block shipping methods for conversion 729 */ 730 function processBlockShippingMethods() { 731 const selector = `${WSDM.constants.BLOCK_SELECTORS.CART}, ${WSDM.constants.BLOCK_SELECTORS.CHECKOUT}`; 732 const $methods = $(selector); 733 734 $methods.each(function() { 735 const $radioGroup = $(this); 736 const $radios = $radioGroup.find('input[type="radio"]'); 737 738 if (shouldConvertBlockMethod($radioGroup, $radios)) { 739 convertBlockRadioToSelect($radioGroup, $radios); 740 $radioGroup.addClass(WSDM.constants.CLASSES.CONVERTED); 741 } 742 }); 743 } 744 745 /** 746 * Check if block method should be converted 747 */ 748 function shouldConvertBlockMethod($radioGroup, $radios) { 749 return $radios.length > 1 && 750 !$radioGroup.hasClass(WSDM.constants.CLASSES.CONVERTED) && 751 $radioGroup.is(':visible'); 752 } 753 754 function convertBlockRadioToSelect($radioGroup, $radios) { 755 const controlName = $radios.first().attr('name'); 756 const labelId = $radioGroup.closest('[id]').attr('id') || ('shipping-option-' + Math.random().toString(36).slice(2)); 757 758 // Resolve package index from name like radio-control-<index> 759 const pkgMatch = String(controlName || '').match(/radio-control-(\d+)/); 760 const packageIndex = pkgMatch ? parseInt(pkgMatch[1], 10) : 0; 761 762 // Build WC Blocks select structure 763 const $outer = $('<div>', { class: 'wc-blocks-components-select' });
764 const $container = $('<div>', { class: 'wc-blocks-components-select__container' }); 765 766 // Try to find the group label text from preceding h3/label; fallback to Shipping options 767 let groupLabel = window.wsdm_params.shipping_options_label; 768 const $maybeHeading = $radioGroup.prev('h3, h4, label').first(); 769 if ($maybeHeading.length) { 770 groupLabel = $.trim($maybeHeading.text()); 771 } 772 773 const $label = $('<label>', { 774 class: 'wc-blocks-components-select__label', 775 for: labelId + '-select' 776 }).text(groupLabel); 777 778 const $select = $('<select>', { 779 id: labelId + '-select', 780 class: 'wc-blocks-components-select__select wsdm-block-shipping-select', 781 'data-package-id': controlName, 782 size: 1 783 }); 784 785 // Try to fetch method metadata from Store API to format price properly 786 const ratesById = getStoreRatesByIdForPackage(packageIndex); 787 788 $radios.each(function() { 789 const $radio = $(this); 790 const methodId = $radio.val(); 791 let optionLabel; 792 793 if (ratesById && ratesById[methodId]) { 794 const meta = ratesById[methodId]; 795 const formattedCost = formatCurrency(meta.cost); 796 optionLabel = `${meta.label} (${formattedCost})`; 797 } else { 798 // Fallback to DOM parsing 799 const $labelNode = $radioGroup.find('label[for="' + $radio.attr('id') + '"]'); 800 const priceText = $.trim($labelNode.find('.wc-block-components-radio-control__secondary-label').text()); 801 let nameText = $.trim( 802 $labelNode 803 .clone() 804 .find('.wc-block-components-radio-control__secondary-label') 805 .remove() 806 .end() 807 .text() 808 ); 809 nameText = nameText.replace(/\s+/g, ' ').trim(); 810 let finalPrice = priceText; 811 if (!finalPrice) { 812 const matchPrice = $labelNode.text().match(/([â¹$â¬Â£]\s?[\d.,]+(?:\s?[A-Z]{3})?)/); 813 if (matchPrice) { 814 finalPrice = matchPrice[1].trim(); 815 } 816 } 817 optionLabel = finalPrice ? `${nameText} (${finalPrice})` : nameText; 818 } 819 820 $('<option>', { 821 value: methodId, 822 text: optionLabel, 823 selected: $radio.is(':checked') 824 }).appendTo($select); 825 }); 826 827 // SVG arrow same as country select 828 const $svg = $( 829 '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="24" height="24" class="wc-blocks-components-select__expand" aria-hidden="true" focusable="false">' + 830 '<path d="M17.5 11.6L12 16l-5.5-4.4.9-1.2L12 14l4.5-3.6 1 1.2z"></path>' + 831 '</svg>' 832 ); 833 834 $container.append($label, $select, $svg); 835 $outer.append($container); 836 837 // Hide the original radio wrapper but keep it for React updates 838 const $wrapper = $radioGroup.closest('.wc-block-components-radio-control'); 839 $wrapper.hide().after($outer); 840 841 // Store reference to hidden wrapper for syncing 842 $outer.data('original-wrapper', $wrapper); 843 844 // Handle selection changes -> sync with hidden radio and trigger Store API 845 $select.on('change', function() { 846 const selectedRate = $(this).val(); 847 848 // Update hidden radio to keep React state in sync 849 const $hiddenRadio = $wrapper.find('input[type="radio"][value="' + selectedRate + '"]'); 850 if ($hiddenRadio.length) { 851 $hiddenRadio.prop('checked', true).trigger('change'); 852 } 853 854 // Also dispatch to Store API as backup 855 const match = String($(this).attr('data-package-id') || '').match(/radio-control-(\d+)/); 856 const packageId = match ? parseInt(match[1], 10) : 0; 857 858 if (typeof wp !== 'undefined' && wp.data && wp.data.dispatch('wc/store/cart')) { 859 wp.data.dispatch('wc/store/cart').selectShippingRate(selectedRate, packageId); 860 } 861 }); 862 863 // Set up observer to sync dropdown when React updates the hidden radios 864 setupRadioSyncObserver($wrapper, $select); 865 } 866 867 /** 868 * Set up mutation observer to sync dropdown with hidden radios 869 */ 870 function setupRadioSyncObserver($hiddenWrapper, $dropdown) { 871 if (!window.MutationObserver) { 872 return; 873 } 874 875 const syncHandler = debounce(() => { 876 syncDropdownWithRadios($hiddenWrapper, $dropdown); 877 }, WSDM.constants.SYNC_DEBOUNCE_DELAY); 878 879 const observer = new MutationObserver((mutations) => { 880 const shouldSync = mutations.some(mutation => 881 mutation.type === 'childList' || 882 (mutation.type === 'attributes' && 883 mutation.target.type === 'radio' && 884 ['checked', 'value'].includes(mutation.attributeName)) 885 ); 886 887 if (shouldSync) { 888 syncHandler(); 889 } 890 }); 891 892 try { 893 observer.observe($hiddenWrapper[0], { 894 childList: true, 895 subtree: true, 896 attributes: true, 897 attributeFilter: ['checked', 'value'] 898 }); 899 900 // Store observer reference for cleanup 901 $dropdown.data('radio-observer', observer); 902 WSDM.cache.observers.set($dropdown[0], observer); 903 } catch (error) { 904 logError('Failed to set up mutation observer', error); 905 } 906 } 907 908 function syncDropdownWithRadios($hiddenWrapper, $dropdown) { 909 try { 910 // Check if dropdown still exists and is visible 911 if (!$dropdown.length || !$dropdown.is(':visible')) { 912 return; 913 } 914 915 const $radios = $hiddenWrapper.find('input[type="radio"]'); 916 917 // If no radios found, don't update 918 if ($radios.length === 0) { 919 return; 920 } 921 922 const currentValue = $dropdown.val(); 923 924 // Check if options actually changed by comparing values 925 const newValues = $radios.map(function() { return $(this).val(); }).get().sort(); 926 const oldValues = $dropdown.find('option').map(function() { return $(this).val(); }).get().sort(); 927 928 if (JSON.stringify(newValues) === JSON.stringify(oldValues)) { 929 // Options haven't changed, just update selection 930 const checkedValue = $radios.filter(':checked').val(); 931 if (checkedValue && checkedValue !== currentValue) { 932 $dropdown.val(checkedValue); 933 } 934 return; 935 } 936 937 // Clear and rebuild dropdown options 938 $dropdown.empty(); 939 940 const packageIndex = getPackageIndexFromWrapper($hiddenWrapper); 941 const ratesById = getStoreRatesByIdForPackage(packageIndex); 942 943 let hasCurrentValue = false;
944 let firstValue = null; 945 let checkedValue = null; 946 947 $radios.each(function() { 948 const $radio = $(this); 949 const methodId = $radio.val(); 950 if (!firstValue) { 951 firstValue = methodId; 952 } 953 if (methodId === currentValue) { 954 hasCurrentValue = true; 955 } 956 if ($radio.is(':checked')) { 957 checkedValue = methodId; 958 } 959 960 let optionLabel; 961 962 if (ratesById && ratesById[methodId]) { 963 const meta = ratesById[methodId]; 964 const formattedCost = formatCurrency(meta.cost); 965 optionLabel = `${meta.label} (${formattedCost})`; 966 } else { 967 // Fallback to DOM parsing from hidden radio label 968 const $labelNode = $hiddenWrapper.find('label[for="' + $radio.attr('id') + '"]'); 969 const priceText = $.trim($labelNode.find('.wc-block-components-radio-control__secondary-label').text()); 970 let nameText = $.trim( 971 $labelNode 972 .clone() 973 .find('.wc-block-components-radio-control__secondary-label') 974 .remove() 975 .end() 976 .text() 977 ); 978 nameText = nameText.replace(/\s+/g, ' ').trim(); 979 let finalPrice = priceText; 980 if (!finalPrice) { 981 const matchPrice = $labelNode.text().match(/([â¹$â¬Â£]\s?[\d.,]+(?:\s?[A-Z]{3})?)/); 982 if (matchPrice) { 983 finalPrice = matchPrice[1].trim(); 984 } 985 } 986 optionLabel = finalPrice ? `${nameText} (${finalPrice})` : nameText; 987 } 988 989 $('<option>', { 990 value: methodId, 991 text: optionLabel, 992 selected: $radio.is(':checked') 993 }).appendTo($dropdown); 994 }); 995 996 // Set the correct value based on radio state or fallback 997 if (checkedValue) { 998 $dropdown.val(checkedValue); 999 } else if (!hasCurrentValue && firstValue) { 1000 // Auto-select first option if current selection is no longer available 1001 $dropdown.val(firstValue); 1002 const $firstRadio = $hiddenWrapper.find('input[type="radio"][value="' + firstValue + '"]'); 1003 if ($firstRadio.length) { 1004 $firstRadio.prop('checked', true).trigger('change'); 1005 } 1006 // Also dispatch to Store API as backup for update in cart 1007 const match = String($dropdown.attr('data-package-id') || '').match(/radio-control-(\d+)/); 1008 const packageId = match ? parseInt(match[1], 10) : 0; 1009 if (typeof wp !== 'undefined' && wp.data && wp.data.dispatch('wc/store/cart')) { 1010 wp.data.dispatch('wc/store/cart').selectShippingRate(firstValue, packageId); 1011 } 1012 } 1013 1014 } catch (error) { 1015 logError('Failed to sync dropdown with radios', error); 1016 } 1017 } 1018 1019 function getPackageIndexFromWrapper($wrapper) { 1020 const $radios = $wrapper.find('input[type="radio"]'); 1021 if ($radios.length > 0) { 1022 const name = $radios.first().attr('name'); 1023 const match = String(name || '').match(/radio-control-(\d+)/); 1024 return match ? parseInt(match[1], 10) : 0; 1025 } 1026 return 0; 1027 } 1028 1029 /** 1030 * Get shipping rates by ID for a specific package 1031 */ 1032 function getStoreRatesByIdForPackage(packageIndex) { 1033 try { 1034 if (!isWpDataAvailable()) { 1035 return null; 1036 } 1037 1038 const store = wp.data.select('wc/store/cart'); 1039 if (!store) { 1040 return null; 1041 } 1042 1043 const cart = store.getCartData?.() || null; 1044 if (!cart) { 1045 return null; 1046 } 1047 1048 // Try our extension data first 1049 const extensionRates = getExtensionRates(cart, packageIndex); 1050 if (extensionRates) { 1051 return extensionRates; 1052 } 1053 1054 // Fallback to native shipping rates 1055 return getNativeRates(cart, packageIndex); 1056 1057 } catch (error) { 1058 logError('Failed to get store rates', error); 1059 return null; 1060 } 1061 } 1062 1063 /** 1064 * Get rates from our extension data 1065 */ 1066 function getExtensionRates(cart, packageIndex) { 1067 const ext = cart.extensions?.['woo-shipping-display-mode']; 1068 if (!ext?.available_packages?.[packageIndex]) { 1069 return null; 1070 } 1071 1072 const methods = ext.available_packages[packageIndex].available_methods || []; 1073 const ratesMap = {}; 1074 1075 methods.forEach(method => { 1076 if (method.id && method.label) { 1077 ratesMap[method.id] = { 1078 label: sanitizeString(method.label), 1079 cost: parseFloat(method.cost) || 0 1080 }; 1081 } 1082 }); 1083 1084 return Object.keys(ratesMap).length > 0 ? ratesMap : null; 1085 } 1086 1087 /** 1088 * Get rates from native WooCommerce data 1089 */ 1090 function getNativeRates(cart, packageIndex) { 1091 const pkg = cart.shippingRates?.[packageIndex];
1092 if (!pkg?.rates || !Array.isArray(pkg.rates)) { 1093 return null; 1094 } 1095 1096 const ratesMap = {}; 1097 1098 pkg.rates.forEach(rate => { 1099 const id = rate.rate_id || rate.id; 1100 if (id) { 1101 ratesMap[id] = { 1102 label: sanitizeString(rate.name || rate.label || rate.method_name), 1103 cost: parseFloat(rate.price || rate.cost) || 0 1104 }; 1105 } 1106 }); 1107 1108 return Object.keys(ratesMap).length > 0 ? ratesMap : null; 1109 } 1110 1111 function formatCurrency(amount) { 1112 const cur = (window.wcSettings && window.wcSettings.currency) || {}; 1113 const symbol = cur.symbol || '$'; 1114 const position = cur.symbolPosition || 'left'; 1115 const thousand = cur.thousandSeparator || ','; 1116 const decimal = cur.decimalSeparator || '.'; 1117 const precision = typeof cur.precision === 'number' ? cur.precision : 2; 1118 1119 const n = isNaN(amount) ? 0 : Number(amount); 1120 const fixed = n.toFixed(precision); 1121 const parts = fixed.split('.'); 1122 parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, thousand); 1123 const number = precision > 0 ? parts.join(decimal) : parts[0]; 1124 1125 switch (position) { 1126 case 'left_space': 1127 return symbol + ' ' + number; 1128 case 'right': 1129 return number + symbol; 1130 case 'right_space': 1131 return number + ' ' + symbol; 1132 case 'left': 1133 default: 1134 return symbol + number; 1135 } 1136 } 1137 1138 // ========================= 1139 // UTILITY FUNCTIONS 1140 // ========================= 1141 1142 /** 1143 * Check if WordPress data is available 1144 */ 1145 function isWpDataAvailable() { 1146 return typeof wp !== 'undefined' && wp.data; 1147 } 1148 1149 /** 1150 * Sanitize string input with fallback 1151 */ 1152 function sanitizeString(input, fallback = '') { 1153 if (typeof input !== 'string') { 1154 return fallback; 1155 } 1156 return input.trim() || fallback; 1157 } 1158 1159 /** 1160 * Sanitize attribute value 1161 */ 1162 function sanitizeAttribute(input) { 1163 return sanitizeString(String(input || '')); 1164 } 1165 1166 /** 1167 * Debounce function execution 1168 */ 1169 function debounce(func, wait) { 1170 let timeout; 1171 return function executedFunction(...args) { 1172 const later = () => { 1173 clearTimeout(timeout); 1174 func.apply(this, args); 1175 }; 1176 clearTimeout(timeout); 1177 timeout = setTimeout(later, wait); 1178 }; 1179 } 1180 1181 /** 1182 * Clear all cached timeouts 1183 */ 1184 function clearAllTimeouts() { 1185 WSDM.cache.timeouts.forEach((timeoutId) => { 1186 clearTimeout(timeoutId); 1187 }); 1188 WSDM.cache.timeouts.clear(); 1189 } 1190 1191 /** 1192 * Clean up mutation observers 1193 */ 1194 function cleanupObservers() { 1195 $(`.${WSDM.constants.CLASSES.BLOCK_SELECT}`).each(function() { 1196 const observer = $(this).data('radio-observer'); 1197 if (observer && typeof observer.disconnect === 'function') { 1198 observer.disconnect(); 1199 } 1200 }); 1201 } 1202 1203 /** 1204 * Safe console logging for debug 1205 */ 1206 function logDebug(message) { 1207 if (typeof console !== 'undefined' && console.log && window.wsdm_debug) { 1208 console.log(`WSDM Debug: ${message}`); 1209 } 1210 } 1211 1212 /** 1213 * Safe console logging 1214 */ 1215 function logError(message, error) { 1216 if (typeof console !== 'undefined' && console.warn) { 1217 console.warn(`WSDM: ${message}`, error); 1218 } 1219 } 1220 1221 // ========================= 1222 // PUBLIC API 1223 // ========================= 1224 1225 /** 1226 * Public API for external use 1227 */ 1228 window.wsdmShippingDisplayMode = Object.freeze({ 1229 init: initializeShippingMethods, 1230 cleanup: cleanupObservers, 1231 config: WSDM.config, 1232 debug: { 1233 forceConversion: function() { 1234 logDebug('Force conversion called via API'); 1235 initializeShippingMethods(); 1236 }, 1237 forceSelectMode: function() { 1238 logDebug('Forcing select mode for testing'); 1239 WSDM.config.shipping_format = 'select'; 1240 // Clear conversion state 1241 WSDM.cache.convertedPackages.clear(); 1242 WSDM.cache.conversionInProgress = false;
1243 initializeShippingMethods(); 1244 }, 1245 resetConversionState: function() { 1246 logDebug('Resetting conversion state'); 1247 WSDM.cache.convertedPackages.clear(); 1248 WSDM.cache.conversionInProgress = false; 1249 WSDM.cache.selectChangeInProgress = false; 1250 // Remove converted classes 1251 $('.' + WSDM.constants.CLASSES.PACKAGE_CONVERTED).removeClass(WSDM.constants.CLASSES.PACKAGE_CONVERTED); 1252 $('.' + WSDM.constants.CLASSES.SHIPPING_SELECT).remove(); 1253 $('.wsdm-shipping-wrapper').remove(); 1254 }, 1255 getPackageGroups: function() { 1256 return groupShippingMethodsByPackage(); 1257 }, 1258 getConfig: function() { 1259 return WSDM.config; 1260 }, 1261 getStatus: function() { 1262 const status = { 1263 config: WSDM.config, 1264 shippingMethods: $('.shipping_method').length, 1265 selectDropdowns: $('.' + WSDM.constants.CLASSES.SHIPPING_SELECT).length, 1266 packageGroups: groupShippingMethodsByPackage().length, 1267 blockElements: $('.wp-block-woocommerce-cart, .wp-block-woocommerce-checkout').length 1268 }; 1269 1270 console.log('WSDM Status Report:', status); 1271 return status; 1272 }, 1273 analyzeHTML: function() { 1274 const $methods = $('.shipping_method'); 1275 console.log('=== HTML Structure Analysis ==='); 1276 console.log('Found ' + $methods.length + ' shipping methods'); 1277 1278 $methods.each(function(index) { 1279 const $method = $(this); 1280 console.log('\n--- Method ' + index + ' ---'); 1281 console.log('HTML:', $method.prop('outerHTML')); 1282 console.log('Name:', $method.attr('name')); 1283 console.log('Value:', $method.val()); 1284 console.log('Parent:', $method.parent().prop('tagName'), $method.parent().attr('class')); 1285 console.log('Grandparent:', $method.parent().parent().prop('tagName'), $method.parent().parent().attr('class')); 1286 console.log('Great-grandparent:', $method.parent().parent().parent().prop('tagName'), $method.parent().parent().parent().attr('class')); 1287 }); 1288 1289 return { 1290 methods: $methods.length, 1291 structure: 'Check console for detailed structure' 1292 }; 1293 }, 1294 enableDebug: function() { 1295 window.wsdm_debug = true; 1296 }, 1297 disableDebug: function() { 1298 window.wsdm_debug = false; 1299 } 1300 } 1301 }); 1302 1303})(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.