(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