(function ($) { 'use strict'; $(document).ready(function () { var $wrap = $('.studiou-fpp-upload-wrap'); if (!$wrap.length) { return; } // ========================================== // Config + data // ========================================== var cfg = { productId: parseInt($wrap.data('product-id'), 10) || 0, maxFileSize: parseInt($wrap.data('max-file-size'), 10) || 50, maxUploads: parseInt($wrap.data('max-uploads'), 10) || 0, chunkSize: parseInt(studiouWcfppFront.chunkSize, 10) || 1048576 }; var i18n = studiouWcfppFront.i18n || {}; var data = window.studiouFppCardData || {variants: [], batchFiles: []}; var variants = data.variants || []; var $dropzone = $('#studiou-fpp-dropzone'); var $fileInput = $('#studiou-fpp-file-input'); var $cards = $('#studiou-fpp-cards'); var $messages = $wrap.find('.studiou-fpp-messages'); var originalGallery = {}; // ========================================== // Bootstrap // ========================================== initExistingCards(); initDropzone(); initCardDelegation(); initOverviewDelegation(); initLightbox(); initOverviewThumbClicks(); applyHeroImage(data.heroPreviewUrl); // ========================================== // Card construction // ========================================== function initExistingCards() { (data.batchFiles || []).forEach(function (f) { var $card = buildCard({ fileRecordId: f.file_record_id, attachmentId: f.attachment_id, fileName: f.file_name, thumbUrl: f.thumb_url, previewUrl: f.preview_url || f.thumb_url, variationId: f.variation_id || 0, uploading: false }); $cards.append($card); }); refreshMaxReachedState(); } function buildCard(opts) { var $card = $('
'); $card.attr('data-file-record-id', opts.fileRecordId || ''); $card.attr('data-attachment-id', opts.attachmentId || ''); if (opts.uploading) $card.addClass('studiou-fpp-card-uploading'); // Remove-upload button (×) $card.append(''); // Thumb column var $thumb = $('
'); if (opts.thumbUrl) { var $img = $('').attr('src', opts.thumbUrl).attr('alt', opts.fileName || ''); if (opts.previewUrl) { $img.attr('data-preview-url', opts.previewUrl); } $thumb.append($img); } else { $thumb.append('
'); } // Element-level click binding — survives even if something intercepts bubble-phase // or capture-phase on document. Fires at target phase for clicks inside $thumb. bindThumbClick($thumb[0]); var $progress = $('
'); $progress.append('
'); $progress.append('0%'); if (!opts.uploading) { $progress.hide(); } $thumb.append($progress); $card.append($thumb); // Body column var $body = $('
'); $body.append($('
').text(opts.fileName || '')); var $controls = $('
'); $controls.append(buildVariantSelect(opts.variationId || 0)); $controls.append(buildQtyStepper()); $body.append($controls); var $priceRow = $('
'); $priceRow.append(''); $priceRow.append(''); var $enlargeBtn = $('').text(i18n.enlarge || 'Enlarge'); // Direct target-phase binding — primitive, runs regardless of delegation state. $enlargeBtn[0].onclick = onEnlargeClick; $priceRow.append($enlargeBtn); $priceRow.append(''); $body.append($priceRow); $card.append($body); if (!opts.uploading) { updateCardPrice($card); } return $card; } function buildVariantSelect(initialVariationId) { var $select = $(''); $select.append($('').val(0).text(i18n.selectVariant || 'Select variant')); for (var i = 0; i < variants.length; i++) { var v = variants[i]; // ') .val(v.id) .text(label) .attr('data-base-price', v.base_price); if (!v.in_stock) { $opt.prop('disabled', true); } $select.append($opt); } if (initialVariationId) { $select.val(initialVariationId); } return $select; } function buildQtyStepper() { var $stepper = $('
'); $stepper.append(''); $stepper.append(''); $stepper.append(''); return $stepper; } // ========================================== // Card live updates // ========================================== function initCardDelegation() { $cards.on('change', '.studiou-fpp-card-variant', function () { updateCardPrice($(this).closest('.studiou-fpp-card')); }); $cards.on('input change keyup', '.studiou-fpp-qty-input', function () { updateCardPrice($(this).closest('.studiou-fpp-card')); }); $cards.on('click', '.studiou-fpp-qty-plus', function () { var $input = $(this).closest('.studiou-fpp-card-qty').find('.studiou-fpp-qty-input'); $input.val((parseInt($input.val(), 10) || 1) + 1).trigger('change'); }); $cards.on('click', '.studiou-fpp-qty-minus', function () { var $input = $(this).closest('.studiou-fpp-card-qty').find('.studiou-fpp-qty-input'); var v = Math.max(1, (parseInt($input.val(), 10) || 1) - 1); $input.val(v).trigger('change'); }); $cards.on('click', '.studiou-fpp-card-addtocart', function () { onAddToCartClick($(this).closest('.studiou-fpp-card')); }); $cards.on('click', '.studiou-fpp-card-remove', function () { onRemoveUploadClick($(this).closest('.studiou-fpp-card')); }); } function findVariant(variationId) { variationId = parseInt(variationId, 10) || 0; if (!variationId) return null; for (var i = 0; i < variants.length; i++) { if (variants[i].id === variationId) return variants[i]; } return null; } function resolveTier(tiers, qty) { if (!tiers || !tiers.length) return null; var match = null; for (var i = 0; i < tiers.length; i++) { if (tiers[i].from_qty <= qty) match = tiers[i]; else break; } return match; } function updateCardPrice($card) { var $price = $card.find('.studiou-fpp-card-price'); var $badge = $card.find('.studiou-fpp-card-badge'); var $btn = $card.find('.studiou-fpp-card-addtocart'); var variationId = parseInt($card.find('.studiou-fpp-card-variant').val(), 10) || 0; var qty = Math.max(1, parseInt($card.find('.studiou-fpp-qty-input').val(), 10) || 1); var variant = findVariant(variationId); if (!variant || !variant.base_price) { $price.html('—'); $badge.hide(); $btn.prop('disabled', true); return; } var tier = resolveTier(variant.tiers || [], qty); var unit = variant.base_price; if (tier && tier.percent > 0) { unit = variant.base_price * (1 - (tier.percent / 100)); $badge.show().text('−' + formatPercent(tier.percent)); } else { $badge.hide(); } var lineTotal = unit * qty; $price.html(formatPriceHtml(lineTotal)); var uploading = $card.hasClass('studiou-fpp-card-uploading'); $btn.prop('disabled', uploading); } // ========================================== // Upload dropzone // ========================================== function initDropzone() { $dropzone.on('dragover dragenter', function (e) { e.preventDefault(); e.stopPropagation(); $(this).addClass('studiou-fpp-dragover'); }); $dropzone.on('dragleave drop', function (e) { e.preventDefault(); e.stopPropagation(); $(this).removeClass('studiou-fpp-dragover'); }); $dropzone.on('drop', function (e) { var files = e.originalEvent.dataTransfer.files; enqueueFiles(files); }); $dropzone.on('click', function (e) { if ($dropzone.hasClass('studiou-fpp-dropzone-disabled')) return; // File input lives INSIDE the dropzone, so a click on it bubbles up and // re-enters this handler. On mobile the synthetic .trigger('click') also // bubbles, producing infinite recursion ("Maximum call stack size exceeded"). // Ignore clicks that originate on (or bubble from) the input itself. if (e.target === $fileInput[0]) return; // Use the native .click() on the DOM element — it opens the file picker // without dispatching a bubbling jQuery event that would re-trigger us. $fileInput[0].click(); }); $fileInput.on('click', function (e) { // Defense in depth: stop the click from bubbling back to the dropzone. e.stopPropagation(); }); $fileInput.on('change', function () { enqueueFiles(this.files); $fileInput.val(''); }); } var uploadQueue = []; var activeUpload = false; function enqueueFiles(fileList) { if (!fileList || !fileList.length) return; clearMessages(); if (cfg.maxUploads > 0) { var existing = $cards.children('.studiou-fpp-card').length; var available = Math.max(0, cfg.maxUploads - existing - uploadQueue.length); if (available <= 0) { showMessage(formatI18n(i18n.maxUploadsReached, cfg.maxUploads), 'error'); return; } if (fileList.length > available) { showMessage(formatI18n(i18n.maxUploadsReached, cfg.maxUploads), 'error'); } for (var i = 0; i < Math.min(fileList.length, available); i++) { pushFile(fileList[i]); } } else { for (var j = 0; j < fileList.length; j++) { pushFile(fileList[j]); } } refreshMaxReachedState(); drainQueue(); } function pushFile(file) { var maxBytes = cfg.maxFileSize * 1024 * 1024; if (file.size > maxBytes) { showMessage(formatI18n(i18n.fileTooLarge, cfg.maxFileSize), 'error'); return; } var $card = buildCard({ fileRecordId: 0, attachmentId: 0, fileName: file.name, thumbUrl: '', variationId: 0, uploading: true }); $cards.append($card); uploadQueue.push({file: file, $card: $card}); } function drainQueue() { if (activeUpload || !uploadQueue.length) return; activeUpload = true; var next = uploadQueue.shift(); uploadOne(next.file, next.$card, function () { activeUpload = false; drainQueue(); }); } function uploadOne(file, $card, done) { var totalChunks = Math.ceil(file.size / cfg.chunkSize); var uploadId = 'upload_' + Date.now() + '_' + Math.random().toString(36).slice(2, 10); var currentChunk = 0; var $fill = $card.find('.studiou-fpp-card-progress-fill'); var $text = $card.find('.studiou-fpp-card-progress-text'); $card.find('.studiou-fpp-card-progress').show(); function next() { var start = currentChunk * cfg.chunkSize; var end = Math.min(start + cfg.chunkSize, file.size); var blob = file.slice(start, end); var form = new FormData(); form.append('action', 'studiou_wcfpp_upload_chunk'); form.append('nonce', studiouWcfppFront.nonce); form.append('product_id', cfg.productId); form.append('upload_id', uploadId); form.append('chunk_index', currentChunk); form.append('total_chunks', totalChunks); form.append('file_name', file.name); form.append('file_size', file.size); form.append('chunk', blob, 'chunk'); var isLast = (currentChunk === totalChunks - 1); if (isLast) { $fill.css('width', '100%'); $text.text(i18n.processing || 'Processing...'); } $.ajax({ url: studiouWcfppFront.ajaxUrl, type: 'POST', data: form, processData: false, contentType: false, timeout: isLast ? 120000 : 30000, success: function (response) { if (!response.success) { failUpload($card, response.data ? response.data.message : (i18n.uploadError || 'Upload failed.')); done(); return; } if (!response.data.complete) { currentChunk++; var pct = Math.round((currentChunk / totalChunks) * 100); $fill.css('width', pct + '%'); $text.text(pct + '%'); next(); } else { finishUpload($card, response.data); done(); } }, error: function () { failUpload($card, i18n.uploadError || 'Upload failed.'); done(); } }); } next(); } function finishUpload($card, res) { $card.removeClass('studiou-fpp-card-uploading'); $card.find('.studiou-fpp-card-progress').hide(); $card.attr('data-file-record-id', res.file_record_id); $card.attr('data-attachment-id', res.attachment_id); var previewUrl = res.preview_url || res.thumbnail_url; var $img = $card.find('.studiou-fpp-card-thumb img'); if ($img.length) { $img.attr('src', res.thumbnail_url).attr('data-preview-url', previewUrl); } else { $card.find('.studiou-fpp-card-thumb-placeholder').replaceWith( $('').attr('src', res.thumbnail_url) .attr('alt', res.file_name) .attr('data-preview-url', previewUrl) ); } updateCardPrice($card); refreshMaxReachedState(); // If this is now the first/only card, swap the hero gallery image if ($cards.children('.studiou-fpp-card').index($card) === 0) { applyHeroImage(previewUrl); } } function failUpload($card, message) { $card.remove(); showMessage(message, 'error'); refreshMaxReachedState(); } function refreshMaxReachedState() { if (cfg.maxUploads <= 0) return; var reached = $cards.children('.studiou-fpp-card').length >= cfg.maxUploads; $dropzone.toggleClass('studiou-fpp-dropzone-disabled', reached); } // ========================================== // Remove upload (per-card × button) // ========================================== function onRemoveUploadClick($card) { var fileRecordId = parseInt($card.attr('data-file-record-id'), 10) || 0; if (!fileRecordId) { // Still uploading — just cancel locally $card.remove(); refreshMaxReachedState(); return; } $.ajax({ url: studiouWcfppFront.ajaxUrl, type: 'POST', data: { action: 'studiou_wcfpp_remove_upload', nonce: studiouWcfppFront.nonce, file_record_id: fileRecordId }, success: function (response) { if (response.success) { var wasFirst = ($cards.children('.studiou-fpp-card').index($card) === 0); $card.remove(); refreshMaxReachedState(); if (wasFirst) { var $nextFirst = $cards.children('.studiou-fpp-card').first(); if ($nextFirst.length) { var nextImg = $nextFirst.find('.studiou-fpp-card-thumb img').attr('src'); applyHeroImage(nextImg || ''); } else { restoreHeroImage(); } } } else { showMessage(response.data ? response.data.message : (i18n.error || 'Error'), 'error'); } }, error: function () { showMessage(i18n.error || 'Error', 'error'); } }); } // ========================================== // Add to cart // ========================================== function onAddToCartClick($card) { var fileRecordId = parseInt($card.attr('data-file-record-id'), 10) || 0; var variationId = parseInt($card.find('.studiou-fpp-card-variant').val(), 10) || 0; var qty = Math.max(1, parseInt($card.find('.studiou-fpp-qty-input').val(), 10) || 1); if (!fileRecordId) { showMessage(i18n.uploadFile || 'Please upload a file.', 'error'); return; } if (!variationId) { showMessage(i18n.selectVariant || 'Please select a variant.', 'error'); return; } var $btn = $card.find('.studiou-fpp-card-addtocart'); $btn.prop('disabled', true); $.ajax({ url: studiouWcfppFront.ajaxUrl, type: 'POST', data: { action: 'studiou_wcfpp_add_file_to_cart', nonce: studiouWcfppFront.nonce, file_record_id: fileRecordId, variation_id: variationId, quantity: qty }, success: function (response) { if (response.success) { showMessage(i18n.addedToCart || 'Added to cart.', 'success'); if (response.data && response.data.overview_html) { updateOverview(response.data.overview_html); } } else { showMessage(response.data ? response.data.message : (i18n.addToCartError || 'Could not add to cart.'), 'error'); } }, error: function () { showMessage(i18n.addToCartError || 'Could not add to cart.', 'error'); }, complete: function () { $btn.prop('disabled', false); } }); } // ========================================== // Order overview // ========================================== function initOverviewDelegation() { // Remove-line on overview panel (cart-only, upload stays) $(document).on('click', '.studiou-fpp-overview-remove', function (e) { var $link = $(this); var href = $link.attr('href'); // Only intercept if the link has a nonce arg — otherwise let browser navigate if (!href || href.indexOf('studiou-fpp-remove-line') < 0) return; e.preventDefault(); var $line = $link.closest('.studiou-fpp-overview-line'); var key = $line.data('cart-item-key'); if (!key) return; $.ajax({ url: studiouWcfppFront.ajaxUrl, type: 'POST', data: { action: 'studiou_wcfpp_remove_cart_line', nonce: studiouWcfppFront.nonce, cart_item_key: key }, success: function (response) { if (response.success && response.data && response.data.overview_html) { updateOverview(response.data.overview_html); } else if (!response.success) { showMessage(response.data ? response.data.message : (i18n.error || 'Error'), 'error'); } }, error: function () { showMessage(i18n.error || 'Error', 'error'); } }); }); } function updateOverview(html) { var $new = $(html); var $existing = $('#studiou-fpp-overview'); if ($existing.length) { $existing.replaceWith($new); } else { $wrap.after($new); } initOverviewThumbClicks(); $(document).trigger('studiou-fpp:cart-updated'); } // ========================================== // Hero image // ========================================== function applyHeroImage(url) { if (!url) return; var selectors = [ '.woocommerce-product-gallery__image img', '.product .wp-post-image', '.product-image img', '.product-thumbnail img', '.entry-summary img.attachment-woocommerce_thumbnail', '.product-gallery img:first' ]; var $img = null; for (var i = 0; i < selectors.length; i++) { $img = $(selectors[i]).first(); if ($img.length) break; } if (!$img || !$img.length) return; if (!originalGallery.src) { originalGallery.src = $img.attr('src'); originalGallery.srcset = $img.attr('srcset') || ''; originalGallery.sizes = $img.attr('sizes') || ''; var $link = $img.closest('a'); if ($link.length) originalGallery.href = $link.attr('href'); } $img.attr('src', url).removeAttr('srcset').removeAttr('sizes'); var $a = $img.closest('a'); if ($a.length) $a.attr('href', url); } // ========================================== // Lightbox (click thumbnail → enlarged preview) // ========================================== var $lightbox = null; // Attach a target-phase click listener directly on a thumbnail element. // Element-level listeners are the most reliable form of click binding — no // delegation, no capture/bubble surprises. Called from buildCard + finishUpload // (upload cards) and from initOverviewThumbClicks (after every overview render). function bindThumbClick(el) { if (!el || el.__studiouFppBound) return; el.__studiouFppBound = true; el.addEventListener('click', function (ev) { var t = ev.target; if (t && t.closest && t.closest('button, input, select, textarea, a')) return; var card = el.closest ? el.closest('.studiou-fpp-card') : null; if (card && card.classList.contains('studiou-fpp-card-uploading')) return; var img = el.matches && el.matches('.studiou-fpp-overview-thumb') ? el : (el.querySelector ? el.querySelector('img') : null); if (!img) return; var url = img.getAttribute('data-preview-url') || img.getAttribute('src'); if (!url) return; var name = ''; if (card) { var nameEl = card.querySelector('.studiou-fpp-card-name'); name = nameEl ? nameEl.textContent : ''; } else { name = img.getAttribute('data-file-name') || img.getAttribute('alt') || ''; } ev.preventDefault(); ev.stopPropagation(); openLightbox(url, name); }); } // Explicit "Enlarge" button handler — survives any parent-level click interceptor // because clicks on ' + '
' + '' + '
' + '
' + '' ); $('body').append($lightbox); // Enlarge button — primary, reliable opener. Delegated on document so newly // rendered cards and overview lines pick it up automatically. $(document).on('click', '.studiou-fpp-card-enlarge, .studiou-fpp-overview-enlarge', function (e) { onEnlargeClick.call(this, e); }); // Use native capture-phase listener so theme/plugin handlers that stopPropagation // on clicks (common in gallery-lightbox plugins) can't swallow us. Target the // .studiou-fpp-card-thumb wrapper, not just the , so any click inside the // thumbnail — including padding around the image — opens the preview. document.addEventListener('click', function (ev) { var target = ev.target; if (!target || !target.closest) return; // Ignore clicks on interactive controls (remove button, progress text, etc.) if (target.closest('button, input, select, textarea, a')) return; var cardThumb = target.closest('.studiou-fpp-card-thumb'); if (cardThumb) { var card = cardThumb.closest('.studiou-fpp-card'); if (!card || card.classList.contains('studiou-fpp-card-uploading')) return; var img = cardThumb.querySelector('img'); if (!img) return; ev.preventDefault(); ev.stopPropagation(); var nameEl = card.querySelector('.studiou-fpp-card-name'); openLightbox( img.getAttribute('data-preview-url') || img.getAttribute('src'), nameEl ? nameEl.textContent : '' ); return; } var overviewImg = target.closest('.studiou-fpp-overview-thumb'); if (overviewImg) { ev.preventDefault(); ev.stopPropagation(); openLightbox( overviewImg.getAttribute('data-preview-url') || overviewImg.getAttribute('src'), overviewImg.getAttribute('data-file-name') || overviewImg.getAttribute('alt') || '' ); } }, true); // Close: × button, backdrop click, ESC key $lightbox.on('click', '.studiou-fpp-lightbox-close', closeLightbox); $lightbox.on('click', function (e) { if (e.target === this) closeLightbox(); }); $(document).on('keydown.studiouFppLightbox', function (e) { if (e.key === 'Escape' && $lightbox && $lightbox.is(':visible')) { closeLightbox(); } }); } function openLightbox(url, caption) { if (!url) return; // Fallback: if the overlay DOM somehow failed to initialize, at least open the // preview in a new tab so the button is never a no-op. if (!$lightbox || !$lightbox.length) { window.open(url, '_blank', 'noopener'); return; } $lightbox.find('.studiou-fpp-lightbox-img').attr('src', url).attr('alt', caption || ''); $lightbox.find('.studiou-fpp-lightbox-caption').text(caption || ''); $lightbox.css('display', 'flex'); $('body').addClass('studiou-fpp-lightbox-open'); } function closeLightbox() { if (!$lightbox) return; $lightbox.hide(); $lightbox.find('.studiou-fpp-lightbox-img').attr('src', ''); $('body').removeClass('studiou-fpp-lightbox-open'); } function restoreHeroImage() { if (!originalGallery.src) return; var selectors = [ '.woocommerce-product-gallery__image img', '.product .wp-post-image', '.product-image img', '.product-thumbnail img', '.entry-summary img.attachment-woocommerce_thumbnail', '.product-gallery img:first' ]; var $img = null; for (var i = 0; i < selectors.length; i++) { $img = $(selectors[i]).first(); if ($img.length) break; } if (!$img || !$img.length) return; $img.attr('src', originalGallery.src); if (originalGallery.srcset) $img.attr('srcset', originalGallery.srcset); if (originalGallery.sizes) $img.attr('sizes', originalGallery.sizes); if (originalGallery.href) $img.closest('a').attr('href', originalGallery.href); originalGallery = {}; } // ========================================== // Helpers // ========================================== function showMessage(text, type) { var cls = type === 'error' ? 'studiou-fpp-msg-error' : type === 'success' ? 'studiou-fpp-msg-success' : 'studiou-fpp-msg-info'; $messages.html('
' + escHtml(text) + '
'); } function clearMessages() { $messages.empty(); } function escHtml(str) { var div = document.createElement('div'); div.appendChild(document.createTextNode(str == null ? '' : String(str))); return div.innerHTML; } function escAttr(str) { return String(str == null ? '' : str).replace(/"/g, '"'); } function formatI18n(tpl, val) { if (!tpl) return String(val); return tpl.replace('%d', val).replace('%s', val); } function formatPercent(p) { var n = parseFloat(p) || 0; var str = n.toFixed(2).replace(/\.?0+$/, ''); return str + ' %'; } function formatPrice(amount) { return formatPriceHtml(amount); } // Plain-text price (no HTML) — used for