| @@ -11,33 +11,61 @@ | ||
| 11 | 11 | return; |
| 12 | 12 | } |
| 13 | 13 | $modals.each(function () { |
| 14 | 14 | var $modal = $(this); |
| 15 | - var $openButton = $('.merchant-quick-view-button'); | |
| 16 | 15 | var $closeButton = $modal.find('.merchant-quick-view-close-button'); |
| 16 | + var $navPrev = $modal.find('.merchant-quick-view-nav-prev'); | |
| 17 | + var $navNext = $modal.find('.merchant-quick-view-nav-next'); | |
| 17 | 18 | var $inner = $modal.find('.merchant-quick-view-inner'); |
| 18 | 19 | var $content = $modal.find('.merchant-quick-view-content'); |
| 19 | 20 | var $overlay = $modal.find('.merchant-quick-view-overlay'); |
| 20 | 21 | var isOpen = false; |
| 21 | - $openButton.on('click', function (e) { | |
| 22 | - e.preventDefault(); | |
| 22 | + var currentIndex = -1; | |
| 23 | + var openedViaOwnPushState = false; | |
| 24 | + function updateNavState(productId) { | |
| 25 | + var productIds = self.getProductIds(); | |
| 26 | + currentIndex = productIds.indexOf(productId); | |
| 27 | + if (productIds.length < 2 || -1 === currentIndex) { | |
| 28 | + $navPrev.removeClass('merchant-quick-view-nav-visible'); | |
| 29 | + $navNext.removeClass('merchant-quick-view-nav-visible'); | |
| 30 | + return; | |
| 31 | + } | |
| 32 | + $navPrev.toggleClass('merchant-quick-view-nav-visible', currentIndex > 0); | |
| 33 | + $navNext.toggleClass('merchant-quick-view-nav-visible', currentIndex < productIds.length - 1); | |
| 34 | + } | |
| 35 | + function loadProduct(productId, historyAction) { | |
| 23 | 36 | $content.empty(); |
| 24 | 37 | $modal.addClass('merchant-show'); |
| 25 | 38 | $modal.addClass('merchant-loading'); |
| 26 | 39 | isOpen = true; |
| 40 | + if (window.merchant.setting.sync_url_hash) { | |
| 41 | + if ('push' === historyAction) { | |
| 42 | + history.pushState({ | |
| 43 | + merchantQuickView: productId | |
| 44 | + }, '', '#merchant-quick-view-' + productId); | |
| 45 | + } else if ('replace' === historyAction) { | |
| 46 | + history.replaceState({ | |
| 47 | + merchantQuickView: productId | |
| 48 | + }, '', '#merchant-quick-view-' + productId); | |
| 49 | + } | |
| 50 | + } | |
| 27 | 51 | $.post(window.merchant.setting.ajax_url, { |
| 28 | 52 | action: 'merchant_quick_view_content', |
| 29 | 53 | nonce: window.merchant.setting.nonce, |
| 30 | - product_id: $(this).data('product-id') | |
| 54 | + product_id: productId | |
| 31 | 55 | }, function (response) { |
| 32 | 56 | if (response.success && isOpen) { |
| 33 | 57 | $content.html(response.data); |
| 34 | 58 | $inner.addClass('merchant-show'); |
| 35 | 59 | $modal.removeClass('merchant-loading'); |
| 60 | + updateNavState(productId); | |
| 36 | 61 | var $gallery = $content.find('.woocommerce-product-gallery'); |
| 37 | 62 | wc_single_product_params.zoom_enabled = window.merchant.setting.quick_view_zoom; |
| 38 | 63 | if (typeof $.fn.wc_product_gallery === 'function' && $gallery.length) { |
| 39 | 64 | $gallery.trigger('wc-product-gallery-before-init', [$gallery.get(0), wc_single_product_params]); |
| 65 | + | |
| 66 | + // Force-enable the gallery slider in case the theme/plugin has removed it using `remove_theme_support( 'wc-product-gallery-slider' )` | |
| 67 | + wc_single_product_params['flexslider_enabled'] = '1'; | |
| 40 | 68 | $gallery.wc_product_gallery(wc_single_product_params); |
| 41 | 69 | $gallery.trigger('wc-product-gallery-after-init', [$gallery.get(0), wc_single_product_params]); |
| 42 | 70 | } |
| 43 | 71 | var $variations = $content.find('.variations_form'); |
| @@ -51,29 +79,343 @@ | ||
| 51 | 79 | } |
| 52 | 80 | if (window.botiga && window.botiga.qtyButton) { |
| 53 | 81 | window.botiga.qtyButton.init('quick-view'); |
| 54 | 82 | } |
| 83 | + | |
| 84 | + // Initialize AJAX add to cart if enabled. | |
| 85 | + if (window.merchant.setting.ajax_add_to_cart) { | |
| 86 | + self.initAjaxAddToCart($content, $modal); | |
| 87 | + } | |
| 55 | 88 | } else { |
| 56 | 89 | $content.html(response.data); |
| 57 | 90 | $inner.addClass('merchant-show'); |
| 58 | 91 | $modal.removeClass('merchant-loading'); |
| 92 | + $navPrev.removeClass('merchant-quick-view-nav-visible'); | |
| 93 | + $navNext.removeClass('merchant-quick-view-nav-visible'); | |
| 59 | 94 | } |
| 95 | + window.dispatchEvent(new Event('merchant.quickview.ajax.loaded')); | |
| 60 | 96 | }).fail(function (xhr, textStatus) { |
| 61 | 97 | $content.html(textStatus); |
| 62 | 98 | $inner.addClass('merchant-show'); |
| 63 | 99 | $modal.removeClass('merchant-loading'); |
| 100 | + $navPrev.removeClass('merchant-quick-view-nav-visible'); | |
| 101 | + $navNext.removeClass('merchant-quick-view-nav-visible'); | |
| 64 | 102 | }); |
| 103 | + } | |
| 104 | + $(document).on('click', '.merchant-quick-view-button', function (e) { | |
| 105 | + e.preventDefault(); | |
| 106 | + openedViaOwnPushState = !!window.merchant.setting.sync_url_hash; | |
| 107 | + loadProduct($(this).data('product-id'), 'push'); | |
| 65 | 108 | }); |
| 109 | + $navPrev.on('click', function (e) { | |
| 110 | + e.preventDefault(); | |
| 111 | + var productIds = self.getProductIds(); | |
| 112 | + if (currentIndex > 0) { | |
| 113 | + loadProduct(productIds[currentIndex - 1], 'replace'); | |
| 114 | + } | |
| 115 | + }); | |
| 116 | + $navNext.on('click', function (e) { | |
| 117 | + e.preventDefault(); | |
| 118 | + var productIds = self.getProductIds(); | |
| 119 | + if (currentIndex > -1 && currentIndex < productIds.length - 1) { | |
| 120 | + loadProduct(productIds[currentIndex + 1], 'replace'); | |
| 121 | + } | |
| 122 | + }); | |
| 66 | 123 | $overlay.on('click', function (e) { |
| 67 | 124 | e.preventDefault(); |
| 68 | 125 | $closeButton.trigger('click'); |
| 69 | 126 | }); |
| 127 | + $(document).on('keydown', function (e) { | |
| 128 | + if ('Escape' === e.key && isOpen) { | |
| 129 | + $closeButton.trigger('click'); | |
| 130 | + } | |
| 131 | + }); | |
| 70 | 132 | $closeButton.on('click', function (e) { |
| 71 | 133 | e.preventDefault(); |
| 72 | 134 | isOpen = false; |
| 73 | 135 | $modal.removeClass('merchant-show'); |
| 74 | 136 | $inner.removeClass('merchant-show'); |
| 137 | + if (window.merchant.setting.sync_url_hash) { | |
| 138 | + if (openedViaOwnPushState) { | |
| 139 | + history.back(); | |
| 140 | + } else { | |
| 141 | + history.replaceState(null, '', location.pathname + location.search); | |
| 142 | + } | |
| 143 | + openedViaOwnPushState = false; | |
| 144 | + } | |
| 75 | 145 | }); |
| 146 | + if (window.merchant.setting.sync_url_hash) { | |
| 147 | + window.addEventListener('popstate', function () { | |
| 148 | + var match = location.hash.match(/^#merchant-quick-view-(\d+)$/); | |
| 149 | + if (match) { | |
| 150 | + loadProduct(parseInt(match[1], 10), 'none'); | |
| 151 | + } else if (isOpen) { | |
| 152 | + isOpen = false; | |
| 153 | + $modal.removeClass('merchant-show'); | |
| 154 | + $inner.removeClass('merchant-show'); | |
| 155 | + openedViaOwnPushState = false; | |
| 156 | + } | |
| 157 | + }); | |
| 158 | + var initialMatch = location.hash.match(/^#merchant-quick-view-(\d+)$/); | |
| 159 | + if (initialMatch) { | |
| 160 | + loadProduct(parseInt(initialMatch[1], 10), 'none'); | |
| 161 | + } | |
| 162 | + } | |
| 163 | + }); | |
| 164 | + }, | |
| 165 | + /** | |
| 166 | + * Collect product IDs for every Quick View button currently in the DOM, in document order. | |
| 167 | + * | |
| 168 | + * @return {Array} Ordered, de-duplicated product IDs. | |
| 169 | + */ | |
| 170 | + getProductIds: function getProductIds() { | |
| 171 | + var ids = []; | |
| 172 | + $('.merchant-quick-view-button[data-product-id]').each(function () { | |
| 173 | + var id = $(this).data('product-id'); | |
| 174 | + if (id && ids.indexOf(id) === -1) { | |
| 175 | + ids.push(id); | |
| 176 | + } | |
| 177 | + }); | |
| 178 | + return ids; | |
| 179 | + }, | |
| 180 | + /** | |
| 181 | + * Initialize AJAX add to cart handler. | |
| 182 | + * | |
| 183 | + * @param {jQuery} $content The modal content container. | |
| 184 | + * @param {jQuery} $modal The modal container. | |
| 185 | + */ | |
| 186 | + initAjaxAddToCart: function initAjaxAddToCart($content, $modal) { | |
| 187 | + var self = this; | |
| 188 | + | |
| 189 | + // Remove any existing handlers to prevent duplicates. | |
| 190 | + $content.off('submit', 'form.cart'); | |
| 191 | + | |
| 192 | + // Handle add to cart form submission. | |
| 193 | + $content.on('submit', 'form.cart', function (e) { | |
| 194 | + // Access the native submitter. | |
| 195 | + var submitter = e.originalEvent ? e.originalEvent.submitter : null; | |
| 196 | + var $submitter = $(submitter); | |
| 197 | + var isAddToCart = false; | |
| 198 | + | |
| 199 | + // 1. If triggered by Enter key (no submitter), assume Add to Cart (standard behavior) | |
| 200 | + // Note: You can add extra checks here for document.activeElement if you want to be extra safe for "Enter on Buy Now", | |
| 201 | + // but typically Enter in input fields implies the default action (Add to Cart). | |
| 202 | + if (!submitter) { | |
| 203 | + isAddToCart = true; | |
| 204 | + } | |
| 205 | + // 2. Check if submitter is explicitly the "Add to Cart" button | |
| 206 | + // WooCommerce standard Add to Cart usually has name="add-to-cart" or class="single_add_to_cart_button" | |
| 207 | + else if ($submitter.attr('name') === 'add-to-cart' || $submitter.hasClass('single_add_to_cart_button')) { | |
| 208 | + isAddToCart = true; | |
| 209 | + } | |
| 210 | + | |
| 211 | + // If it's NOT an Add to Cart action (e.g. it's a Buy Now button), allow default submission | |
| 212 | + if (!isAddToCart) { | |
| 213 | + return; | |
| 214 | + } | |
| 215 | + e.preventDefault(); | |
| 216 | + e.stopImmediatePropagation(); // Prevent other handlers from firing. | |
| 217 | + | |
| 218 | + var $form = $(this); | |
| 219 | + var $button = $form.find('button[type="submit"], input[type="submit"]'); | |
| 220 | + var $addToCartButton = $form.find('.single_add_to_cart_button'); | |
| 221 | + | |
| 222 | + // Use add to cart button if available, otherwise use first submit button. | |
| 223 | + if (!$addToCartButton.length) { | |
| 224 | + $addToCartButton = $button.first(); | |
| 225 | + } | |
| 226 | + | |
| 227 | + // Prevent duplicate submissions. | |
| 228 | + if ($addToCartButton.hasClass('loading') || $addToCartButton.prop('disabled')) { | |
| 229 | + return false; | |
| 230 | + } | |
| 231 | + | |
| 232 | + // Get form data. | |
| 233 | + var formData = $form.serializeArray(); | |
| 234 | + var data = { | |
| 235 | + action: 'merchant_quick_view_add_to_cart', | |
| 236 | + nonce: window.merchant.setting.nonce | |
| 237 | + }; | |
| 238 | + | |
| 239 | + // Check if this is a grouped product (has quantity fields with array notation like quantity[14]). | |
| 240 | + var isGroupedProduct = false; | |
| 241 | + $.each(formData, function (i, field) { | |
| 242 | + if (field.name && field.name.indexOf('quantity[') === 0) { | |
| 243 | + isGroupedProduct = true; | |
| 244 | + return false; // Break the loop. | |
| 245 | + } | |
| 246 | + }); | |
| 247 | + | |
| 248 | + // Convert form data to object, but handle quantity specially. | |
| 249 | + $.each(formData, function (i, field) { | |
| 250 | + // Skip standalone quantity field - we'll handle it separately for non-grouped products. | |
| 251 | + if (field.name === 'quantity') { | |
| 252 | + return; | |
| 253 | + } | |
| 254 | + // Skip add-to-cart field - we'll handle product ID separately. | |
| 255 | + if (field.name === 'add-to-cart') { | |
| 256 | + return; | |
| 257 | + } | |
| 258 | + // For grouped products, quantity[ID] fields are added as-is. | |
| 259 | + // Variation attributes are sent as-is (attribute_pa_color, etc.). | |
| 260 | + data[field.name] = field.value; | |
| 261 | + }); | |
| 262 | + | |
| 263 | + // Get product ID - try multiple sources (most reliable first). | |
| 264 | + // 1. Extract from product wrapper div ID (format: product-{id}) - this is always present in the modal. | |
| 265 | + var $productWrapper = $form.closest('[id^="product-"]'); | |
| 266 | + if ($productWrapper.length) { | |
| 267 | + var productIdMatch = $productWrapper.attr('id').match(/product-(\d+)/); | |
| 268 | + if (productIdMatch && productIdMatch[1]) { | |
| 269 | + data.product_id = productIdMatch[1]; | |
| 270 | + } | |
| 271 | + } | |
| 272 | + | |
| 273 | + // 2. Try to get it directly from the form input. | |
| 274 | + if (!data.product_id) { | |
| 275 | + var addToCartInput = $form.find('input[name="add-to-cart"]'); | |
| 276 | + if (addToCartInput.length && addToCartInput.val()) { | |
| 277 | + data.product_id = addToCartInput.val(); | |
| 278 | + } | |
| 279 | + } | |
| 280 | + | |
| 281 | + // Ensure product_id is set, otherwise we can't proceed. | |
| 282 | + if (!data.product_id) { | |
| 283 | + console.error('Quick View: Could not determine product ID'); | |
| 284 | + return false; | |
| 285 | + } | |
| 286 | + | |
| 287 | + // Get variation ID if it's a variable product. | |
| 288 | + if ($form.find('.variations_form').length) { | |
| 289 | + var variationId = $form.find('input[name="variation_id"]').val(); | |
| 290 | + if (variationId) { | |
| 291 | + data.variation_id = parseInt(variationId, 10); | |
| 292 | + } | |
| 293 | + } | |
| 294 | + | |
| 295 | + // Get quantity and ensure it's a number (not a string). | |
| 296 | + // Only add standalone quantity for non-grouped products. | |
| 297 | + // Grouped products use quantity[product_id] format which is already in the data object. | |
| 298 | + if (!isGroupedProduct) { | |
| 299 | + delete data.quantity; // Remove if it was included in serialized data. | |
| 300 | + var quantityInput = $form.find('input[name="quantity"]'); | |
| 301 | + var quantity = quantityInput.length ? parseInt(quantityInput.val(), 10) : 1; | |
| 302 | + if (isNaN(quantity) || quantity < 1) { | |
| 303 | + quantity = 1; | |
| 304 | + } | |
| 305 | + data.quantity = quantity; | |
| 306 | + } else { | |
| 307 | + // For grouped products, ensure no standalone quantity field exists. | |
| 308 | + delete data.quantity; | |
| 309 | + } | |
| 310 | + | |
| 311 | + // Show loading state. | |
| 312 | + var originalText = $addToCartButton.html(); | |
| 313 | + var originalDisabled = $addToCartButton.prop('disabled'); | |
| 314 | + $addToCartButton.addClass('loading').prop('disabled', true); | |
| 315 | + if ($addToCartButton.is('button')) { | |
| 316 | + $addToCartButton.data('original-text', originalText); | |
| 317 | + $addToCartButton.html('<span class="spinner"></span>'); | |
| 318 | + } | |
| 319 | + | |
| 320 | + // Send AJAX request. | |
| 321 | + $.ajax({ | |
| 322 | + type: 'POST', | |
| 323 | + url: window.merchant.setting.ajax_url, | |
| 324 | + data: data, | |
| 325 | + success: function success(response) { | |
| 326 | + self.handleAddToCartSuccess(response, $addToCartButton, originalText, originalDisabled, $content, $modal); | |
| 327 | + }, | |
| 328 | + error: function error(xhr, textStatus, errorThrown) { | |
| 329 | + self.handleAddToCartError(xhr, textStatus, errorThrown, $addToCartButton, originalText, originalDisabled, $content); | |
| 330 | + } | |
| 331 | + }); | |
| 332 | + return false; | |
| 333 | + }); | |
| 334 | + }, | |
| 335 | + /** | |
| 336 | + * Handle successful add to cart response. | |
| 337 | + * | |
| 338 | + * @param {Object} response AJAX response. | |
| 339 | + * @param {jQuery} $button The add to cart button. | |
| 340 | + * @param {string} originalText Original button text. | |
| 341 | + * @param {boolean} originalDisabled Original disabled state. | |
| 342 | + * @param {jQuery} $content The modal content container. | |
| 343 | + * @param {jQuery} $modal The modal container. | |
| 344 | + */ | |
| 345 | + handleAddToCartSuccess: function handleAddToCartSuccess(response, $button, originalText, originalDisabled, $content, $modal) { | |
| 346 | + // Remove loading state. | |
| 347 | + $button.removeClass('loading').prop('disabled', originalDisabled); | |
| 348 | + if ($button.is('button') && $button.data('original-text')) { | |
| 349 | + $button.html($button.data('original-text')); | |
| 350 | + } | |
| 351 | + if (response.success && response.data) { | |
| 352 | + // Update cart fragments. | |
| 353 | + if (response.data.fragments) { | |
| 354 | + $.each(response.data.fragments, function (selector, html) { | |
| 355 | + $(selector).replaceWith(html); | |
| 356 | + }); | |
| 357 | + } | |
| 358 | + | |
| 359 | + // Trigger WooCommerce events. | |
| 360 | + $(document.body).trigger('added_to_cart', [response.data.fragments, response.data.cart_hash, $button, 'quick-view']); | |
| 361 | + $(document.body).trigger('wc_fragment_refresh'); | |
| 362 | + | |
| 363 | + // Show success message. | |
| 364 | + if (response.data.message) { | |
| 365 | + // Remove existing notices. | |
| 366 | + $content.find('.merchant-quick-view-inner .woocommerce-error, .woocommerce-message, .woocommerce-info').remove(); | |
| 367 | + // trigger event for third party integration | |
| 368 | + $(document).trigger('merchant_quick_view_add_to_cart_success', [response.data.message, $content]); | |
| 369 | + } | |
| 370 | + } else { | |
| 371 | + // Handle unexpected response. | |
| 372 | + this.handleAddToCartError(null, 'error', 'Unexpected response format', $button, originalText, originalDisabled, $content); | |
| 373 | + } | |
| 374 | + }, | |
| 375 | + /** | |
| 376 | + * Handle add to cart error response. | |
| 377 | + * | |
| 378 | + * @param {Object} xhr XMLHttpRequest object. | |
| 379 | + * @param {string} textStatus Status text. | |
| 380 | + * @param {string} errorThrown Error thrown. | |
| 381 | + * @param {jQuery} $button The add to cart button. | |
| 382 | + * @param {string} originalText Original button text. | |
| 383 | + * @param {boolean} originalDisabled Original disabled state. | |
| 384 | + * @param {jQuery} $content The modal content container. | |
| 385 | + */ | |
| 386 | + handleAddToCartError: function handleAddToCartError(xhr, textStatus, errorThrown, $button, originalText, originalDisabled, $content) { | |
| 387 | + // Remove loading state. | |
| 388 | + $button.removeClass('loading').prop('disabled', originalDisabled); | |
| 389 | + if ($button.is('button') && $button.data('original-text')) { | |
| 390 | + $button.html($button.data('original-text')); | |
| 391 | + } | |
| 392 | + | |
| 393 | + // Get error message. | |
| 394 | + var errorMessage = ''; | |
| 395 | + if (xhr && xhr.responseJSON && xhr.responseJSON.data && xhr.responseJSON.data.message) { | |
| 396 | + errorMessage = xhr.responseJSON.data.message; | |
| 397 | + } else if (xhr && xhr.responseJSON && xhr.responseJSON.data && xhr.responseJSON.data.notices) { | |
| 398 | + // Extract error from notices. | |
| 399 | + var notices = xhr.responseJSON.data.notices; | |
| 400 | + if (notices.error && notices.error.length > 0) { | |
| 401 | + errorMessage = notices.error[0].notice; | |
| 402 | + } | |
| 403 | + } else { | |
| 404 | + errorMessage = errorThrown || textStatus || 'An error occurred while adding the product to cart.'; | |
| 405 | + } | |
| 406 | + | |
| 407 | + // Remove existing notices. | |
| 408 | + $content.find('.woocommerce-error, .woocommerce-message, .woocommerce-info').remove(); | |
| 409 | + | |
| 410 | + // Add error message. | |
| 411 | + var $error = $('<div class="woocommerce-error" role="alert"></div>').text(errorMessage); | |
| 412 | + $content.find('form.cart').before($error); | |
| 413 | + | |
| 414 | + // Scroll to error if needed. | |
| 415 | + $error[0].scrollIntoView({ | |
| 416 | + behavior: 'smooth', | |
| 417 | + block: 'nearest' | |
| 76 | 418 | }); |
| 77 | 419 | } |
| 78 | 420 | }; |
| 79 | 421 | $(document).ready(function () { |