frontend.js 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864
  1. (function ($) {
  2. 'use strict';
  3. $(document).ready(function () {
  4. var $wrap = $('.studiou-fpp-upload-wrap');
  5. if (!$wrap.length) {
  6. return;
  7. }
  8. // ==========================================
  9. // Config + data
  10. // ==========================================
  11. var cfg = {
  12. productId: parseInt($wrap.data('product-id'), 10) || 0,
  13. maxFileSize: parseInt($wrap.data('max-file-size'), 10) || 50,
  14. maxUploads: parseInt($wrap.data('max-uploads'), 10) || 0,
  15. chunkSize: parseInt(studiouWcfppFront.chunkSize, 10) || 1048576
  16. };
  17. var i18n = studiouWcfppFront.i18n || {};
  18. var data = window.studiouFppCardData || {variants: [], batchFiles: []};
  19. var variants = data.variants || [];
  20. var $dropzone = $('#studiou-fpp-dropzone');
  21. var $fileInput = $('#studiou-fpp-file-input');
  22. var $cards = $('#studiou-fpp-cards');
  23. var $messages = $wrap.find('.studiou-fpp-messages');
  24. var originalGallery = {};
  25. // ==========================================
  26. // Bootstrap
  27. // ==========================================
  28. initExistingCards();
  29. initDropzone();
  30. initCardDelegation();
  31. initOverviewDelegation();
  32. initLightbox();
  33. initOverviewThumbClicks();
  34. applyHeroImage(data.heroPreviewUrl);
  35. // ==========================================
  36. // Card construction
  37. // ==========================================
  38. function initExistingCards() {
  39. (data.batchFiles || []).forEach(function (f) {
  40. var $card = buildCard({
  41. fileRecordId: f.file_record_id,
  42. attachmentId: f.attachment_id,
  43. fileName: f.file_name,
  44. thumbUrl: f.thumb_url,
  45. previewUrl: f.preview_url || f.thumb_url,
  46. variationId: f.variation_id || 0,
  47. uploading: false
  48. });
  49. $cards.append($card);
  50. });
  51. refreshMaxReachedState();
  52. }
  53. function buildCard(opts) {
  54. var $card = $('<div class="studiou-fpp-card"></div>');
  55. $card.attr('data-file-record-id', opts.fileRecordId || '');
  56. $card.attr('data-attachment-id', opts.attachmentId || '');
  57. if (opts.uploading) $card.addClass('studiou-fpp-card-uploading');
  58. // Remove-upload button (×)
  59. $card.append('<button type="button" class="studiou-fpp-card-remove" title="' +
  60. escAttr(i18n.removeFile || 'Remove') + '" aria-label="' +
  61. escAttr(i18n.removeFile || 'Remove') + '">&times;</button>');
  62. // Thumb column
  63. var $thumb = $('<div class="studiou-fpp-card-thumb"></div>');
  64. if (opts.thumbUrl) {
  65. var $img = $('<img>').attr('src', opts.thumbUrl).attr('alt', opts.fileName || '');
  66. if (opts.previewUrl) {
  67. $img.attr('data-preview-url', opts.previewUrl);
  68. }
  69. $thumb.append($img);
  70. } else {
  71. $thumb.append('<div class="studiou-fpp-card-thumb-placeholder"></div>');
  72. }
  73. // Element-level click binding — survives even if something intercepts bubble-phase
  74. // or capture-phase on document. Fires at target phase for clicks inside $thumb.
  75. bindThumbClick($thumb[0]);
  76. var $progress = $('<div class="studiou-fpp-card-progress"></div>');
  77. $progress.append('<div class="studiou-fpp-card-progress-fill"></div>');
  78. $progress.append('<span class="studiou-fpp-card-progress-text">0%</span>');
  79. if (!opts.uploading) {
  80. $progress.hide();
  81. }
  82. $thumb.append($progress);
  83. $card.append($thumb);
  84. // Body column
  85. var $body = $('<div class="studiou-fpp-card-body"></div>');
  86. $body.append($('<div class="studiou-fpp-card-name"></div>').text(opts.fileName || ''));
  87. var $controls = $('<div class="studiou-fpp-card-controls"></div>');
  88. $controls.append(buildVariantSelect(opts.variationId || 0));
  89. $controls.append(buildQtyStepper());
  90. $body.append($controls);
  91. var $priceRow = $('<div class="studiou-fpp-card-price-row"></div>');
  92. $priceRow.append('<span class="studiou-fpp-card-price">&mdash;</span>');
  93. $priceRow.append('<span class="studiou-fpp-card-badge" style="display:none;"></span>');
  94. var $enlargeBtn = $('<button type="button" class="studiou-fpp-card-enlarge"></button>').text(i18n.enlarge || 'Enlarge');
  95. // Direct target-phase binding — primitive, runs regardless of delegation state.
  96. $enlargeBtn[0].onclick = onEnlargeClick;
  97. $priceRow.append($enlargeBtn);
  98. $priceRow.append('<button type="button" class="button studiou-fpp-card-addtocart">' +
  99. escHtml(i18n.addToCart || 'Add to cart') + '</button>');
  100. $body.append($priceRow);
  101. $card.append($body);
  102. if (!opts.uploading) {
  103. updateCardPrice($card);
  104. }
  105. return $card;
  106. }
  107. function buildVariantSelect(initialVariationId) {
  108. var $select = $('<select class="studiou-fpp-card-variant"></select>');
  109. $select.append($('<option></option>').val(0).text(i18n.selectVariant || 'Select variant'));
  110. for (var i = 0; i < variants.length; i++) {
  111. var v = variants[i];
  112. // <option> text nodes cannot render HTML, so use a plain-text price formatter
  113. var priceLabel = v.base_price ? ' — ' + formatPricePlain(v.base_price) : '';
  114. var label = v.label + priceLabel;
  115. if (!v.in_stock) {
  116. label += ' — ' + (i18n.outOfStock || 'out of stock');
  117. }
  118. var $opt = $('<option></option>')
  119. .val(v.id)
  120. .text(label)
  121. .attr('data-base-price', v.base_price);
  122. if (!v.in_stock) {
  123. $opt.prop('disabled', true);
  124. }
  125. $select.append($opt);
  126. }
  127. if (initialVariationId) {
  128. $select.val(initialVariationId);
  129. }
  130. return $select;
  131. }
  132. function buildQtyStepper() {
  133. var $stepper = $('<div class="studiou-fpp-card-qty"></div>');
  134. $stepper.append('<button type="button" class="studiou-fpp-qty-btn studiou-fpp-qty-minus">&minus;</button>');
  135. $stepper.append('<input type="number" class="studiou-fpp-qty-input" min="1" step="1" value="1">');
  136. $stepper.append('<button type="button" class="studiou-fpp-qty-btn studiou-fpp-qty-plus">+</button>');
  137. return $stepper;
  138. }
  139. // ==========================================
  140. // Card live updates
  141. // ==========================================
  142. function initCardDelegation() {
  143. $cards.on('change', '.studiou-fpp-card-variant', function () {
  144. updateCardPrice($(this).closest('.studiou-fpp-card'));
  145. });
  146. $cards.on('input change keyup', '.studiou-fpp-qty-input', function () {
  147. updateCardPrice($(this).closest('.studiou-fpp-card'));
  148. });
  149. $cards.on('click', '.studiou-fpp-qty-plus', function () {
  150. var $input = $(this).closest('.studiou-fpp-card-qty').find('.studiou-fpp-qty-input');
  151. $input.val((parseInt($input.val(), 10) || 1) + 1).trigger('change');
  152. });
  153. $cards.on('click', '.studiou-fpp-qty-minus', function () {
  154. var $input = $(this).closest('.studiou-fpp-card-qty').find('.studiou-fpp-qty-input');
  155. var v = Math.max(1, (parseInt($input.val(), 10) || 1) - 1);
  156. $input.val(v).trigger('change');
  157. });
  158. $cards.on('click', '.studiou-fpp-card-addtocart', function () {
  159. onAddToCartClick($(this).closest('.studiou-fpp-card'));
  160. });
  161. $cards.on('click', '.studiou-fpp-card-remove', function () {
  162. onRemoveUploadClick($(this).closest('.studiou-fpp-card'));
  163. });
  164. }
  165. function findVariant(variationId) {
  166. variationId = parseInt(variationId, 10) || 0;
  167. if (!variationId) return null;
  168. for (var i = 0; i < variants.length; i++) {
  169. if (variants[i].id === variationId) return variants[i];
  170. }
  171. return null;
  172. }
  173. function resolveTier(tiers, qty) {
  174. if (!tiers || !tiers.length) return null;
  175. var match = null;
  176. for (var i = 0; i < tiers.length; i++) {
  177. if (tiers[i].from_qty <= qty) match = tiers[i];
  178. else break;
  179. }
  180. return match;
  181. }
  182. function updateCardPrice($card) {
  183. var $price = $card.find('.studiou-fpp-card-price');
  184. var $badge = $card.find('.studiou-fpp-card-badge');
  185. var $btn = $card.find('.studiou-fpp-card-addtocart');
  186. var variationId = parseInt($card.find('.studiou-fpp-card-variant').val(), 10) || 0;
  187. var qty = Math.max(1, parseInt($card.find('.studiou-fpp-qty-input').val(), 10) || 1);
  188. var variant = findVariant(variationId);
  189. if (!variant || !variant.base_price) {
  190. $price.html('&mdash;');
  191. $badge.hide();
  192. $btn.prop('disabled', true);
  193. return;
  194. }
  195. var tier = resolveTier(variant.tiers || [], qty);
  196. var unit = variant.base_price;
  197. if (tier && tier.percent > 0) {
  198. unit = variant.base_price * (1 - (tier.percent / 100));
  199. $badge.show().text('−' + formatPercent(tier.percent));
  200. } else {
  201. $badge.hide();
  202. }
  203. var lineTotal = unit * qty;
  204. $price.html(formatPriceHtml(lineTotal));
  205. var uploading = $card.hasClass('studiou-fpp-card-uploading');
  206. $btn.prop('disabled', uploading);
  207. }
  208. // ==========================================
  209. // Upload dropzone
  210. // ==========================================
  211. function initDropzone() {
  212. $dropzone.on('dragover dragenter', function (e) {
  213. e.preventDefault(); e.stopPropagation();
  214. $(this).addClass('studiou-fpp-dragover');
  215. });
  216. $dropzone.on('dragleave drop', function (e) {
  217. e.preventDefault(); e.stopPropagation();
  218. $(this).removeClass('studiou-fpp-dragover');
  219. });
  220. $dropzone.on('drop', function (e) {
  221. var files = e.originalEvent.dataTransfer.files;
  222. enqueueFiles(files);
  223. });
  224. $dropzone.on('click', function (e) {
  225. if ($dropzone.hasClass('studiou-fpp-dropzone-disabled')) return;
  226. // File input lives INSIDE the dropzone, so a click on it bubbles up and
  227. // re-enters this handler. On mobile the synthetic .trigger('click') also
  228. // bubbles, producing infinite recursion ("Maximum call stack size exceeded").
  229. // Ignore clicks that originate on (or bubble from) the input itself.
  230. if (e.target === $fileInput[0]) return;
  231. // Use the native .click() on the DOM element — it opens the file picker
  232. // without dispatching a bubbling jQuery event that would re-trigger us.
  233. $fileInput[0].click();
  234. });
  235. $fileInput.on('click', function (e) {
  236. // Defense in depth: stop the click from bubbling back to the dropzone.
  237. e.stopPropagation();
  238. });
  239. $fileInput.on('change', function () {
  240. enqueueFiles(this.files);
  241. $fileInput.val('');
  242. });
  243. }
  244. var uploadQueue = [];
  245. var activeUpload = false;
  246. function enqueueFiles(fileList) {
  247. if (!fileList || !fileList.length) return;
  248. clearMessages();
  249. if (cfg.maxUploads > 0) {
  250. var existing = $cards.children('.studiou-fpp-card').length;
  251. var available = Math.max(0, cfg.maxUploads - existing - uploadQueue.length);
  252. if (available <= 0) {
  253. showMessage(formatI18n(i18n.maxUploadsReached, cfg.maxUploads), 'error');
  254. return;
  255. }
  256. if (fileList.length > available) {
  257. showMessage(formatI18n(i18n.maxUploadsReached, cfg.maxUploads), 'error');
  258. }
  259. for (var i = 0; i < Math.min(fileList.length, available); i++) {
  260. pushFile(fileList[i]);
  261. }
  262. } else {
  263. for (var j = 0; j < fileList.length; j++) {
  264. pushFile(fileList[j]);
  265. }
  266. }
  267. refreshMaxReachedState();
  268. drainQueue();
  269. }
  270. function pushFile(file) {
  271. var maxBytes = cfg.maxFileSize * 1024 * 1024;
  272. if (file.size > maxBytes) {
  273. showMessage(formatI18n(i18n.fileTooLarge, cfg.maxFileSize), 'error');
  274. return;
  275. }
  276. var $card = buildCard({
  277. fileRecordId: 0,
  278. attachmentId: 0,
  279. fileName: file.name,
  280. thumbUrl: '',
  281. variationId: 0,
  282. uploading: true
  283. });
  284. $cards.append($card);
  285. uploadQueue.push({file: file, $card: $card});
  286. }
  287. function drainQueue() {
  288. if (activeUpload || !uploadQueue.length) return;
  289. activeUpload = true;
  290. var next = uploadQueue.shift();
  291. uploadOne(next.file, next.$card, function () {
  292. activeUpload = false;
  293. drainQueue();
  294. });
  295. }
  296. function uploadOne(file, $card, done) {
  297. var totalChunks = Math.ceil(file.size / cfg.chunkSize);
  298. var uploadId = 'upload_' + Date.now() + '_' + Math.random().toString(36).slice(2, 10);
  299. var currentChunk = 0;
  300. var $fill = $card.find('.studiou-fpp-card-progress-fill');
  301. var $text = $card.find('.studiou-fpp-card-progress-text');
  302. $card.find('.studiou-fpp-card-progress').show();
  303. function next() {
  304. var start = currentChunk * cfg.chunkSize;
  305. var end = Math.min(start + cfg.chunkSize, file.size);
  306. var blob = file.slice(start, end);
  307. var form = new FormData();
  308. form.append('action', 'studiou_wcfpp_upload_chunk');
  309. form.append('nonce', studiouWcfppFront.nonce);
  310. form.append('product_id', cfg.productId);
  311. form.append('upload_id', uploadId);
  312. form.append('chunk_index', currentChunk);
  313. form.append('total_chunks', totalChunks);
  314. form.append('file_name', file.name);
  315. form.append('file_size', file.size);
  316. form.append('chunk', blob, 'chunk');
  317. var isLast = (currentChunk === totalChunks - 1);
  318. if (isLast) {
  319. $fill.css('width', '100%');
  320. $text.text(i18n.processing || 'Processing...');
  321. }
  322. $.ajax({
  323. url: studiouWcfppFront.ajaxUrl,
  324. type: 'POST',
  325. data: form,
  326. processData: false,
  327. contentType: false,
  328. timeout: isLast ? 120000 : 30000,
  329. success: function (response) {
  330. if (!response.success) {
  331. failUpload($card, response.data ? response.data.message : (i18n.uploadError || 'Upload failed.'));
  332. done();
  333. return;
  334. }
  335. if (!response.data.complete) {
  336. currentChunk++;
  337. var pct = Math.round((currentChunk / totalChunks) * 100);
  338. $fill.css('width', pct + '%');
  339. $text.text(pct + '%');
  340. next();
  341. } else {
  342. finishUpload($card, response.data);
  343. done();
  344. }
  345. },
  346. error: function () {
  347. failUpload($card, i18n.uploadError || 'Upload failed.');
  348. done();
  349. }
  350. });
  351. }
  352. next();
  353. }
  354. function finishUpload($card, res) {
  355. $card.removeClass('studiou-fpp-card-uploading');
  356. $card.find('.studiou-fpp-card-progress').hide();
  357. $card.attr('data-file-record-id', res.file_record_id);
  358. $card.attr('data-attachment-id', res.attachment_id);
  359. var previewUrl = res.preview_url || res.thumbnail_url;
  360. var $img = $card.find('.studiou-fpp-card-thumb img');
  361. if ($img.length) {
  362. $img.attr('src', res.thumbnail_url).attr('data-preview-url', previewUrl);
  363. } else {
  364. $card.find('.studiou-fpp-card-thumb-placeholder').replaceWith(
  365. $('<img>').attr('src', res.thumbnail_url)
  366. .attr('alt', res.file_name)
  367. .attr('data-preview-url', previewUrl)
  368. );
  369. }
  370. updateCardPrice($card);
  371. refreshMaxReachedState();
  372. // If this is now the first/only card, swap the hero gallery image
  373. if ($cards.children('.studiou-fpp-card').index($card) === 0) {
  374. applyHeroImage(previewUrl);
  375. }
  376. }
  377. function failUpload($card, message) {
  378. $card.remove();
  379. showMessage(message, 'error');
  380. refreshMaxReachedState();
  381. }
  382. function refreshMaxReachedState() {
  383. if (cfg.maxUploads <= 0) return;
  384. var reached = $cards.children('.studiou-fpp-card').length >= cfg.maxUploads;
  385. $dropzone.toggleClass('studiou-fpp-dropzone-disabled', reached);
  386. }
  387. // ==========================================
  388. // Remove upload (per-card × button)
  389. // ==========================================
  390. function onRemoveUploadClick($card) {
  391. var fileRecordId = parseInt($card.attr('data-file-record-id'), 10) || 0;
  392. if (!fileRecordId) {
  393. // Still uploading — just cancel locally
  394. $card.remove();
  395. refreshMaxReachedState();
  396. return;
  397. }
  398. $.ajax({
  399. url: studiouWcfppFront.ajaxUrl,
  400. type: 'POST',
  401. data: {
  402. action: 'studiou_wcfpp_remove_upload',
  403. nonce: studiouWcfppFront.nonce,
  404. file_record_id: fileRecordId
  405. },
  406. success: function (response) {
  407. if (response.success) {
  408. var wasFirst = ($cards.children('.studiou-fpp-card').index($card) === 0);
  409. $card.remove();
  410. refreshMaxReachedState();
  411. if (wasFirst) {
  412. var $nextFirst = $cards.children('.studiou-fpp-card').first();
  413. if ($nextFirst.length) {
  414. var nextImg = $nextFirst.find('.studiou-fpp-card-thumb img').attr('src');
  415. applyHeroImage(nextImg || '');
  416. } else {
  417. restoreHeroImage();
  418. }
  419. }
  420. } else {
  421. showMessage(response.data ? response.data.message : (i18n.error || 'Error'), 'error');
  422. }
  423. },
  424. error: function () {
  425. showMessage(i18n.error || 'Error', 'error');
  426. }
  427. });
  428. }
  429. // ==========================================
  430. // Add to cart
  431. // ==========================================
  432. function onAddToCartClick($card) {
  433. var fileRecordId = parseInt($card.attr('data-file-record-id'), 10) || 0;
  434. var variationId = parseInt($card.find('.studiou-fpp-card-variant').val(), 10) || 0;
  435. var qty = Math.max(1, parseInt($card.find('.studiou-fpp-qty-input').val(), 10) || 1);
  436. if (!fileRecordId) {
  437. showMessage(i18n.uploadFile || 'Please upload a file.', 'error');
  438. return;
  439. }
  440. if (!variationId) {
  441. showMessage(i18n.selectVariant || 'Please select a variant.', 'error');
  442. return;
  443. }
  444. var $btn = $card.find('.studiou-fpp-card-addtocart');
  445. $btn.prop('disabled', true);
  446. $.ajax({
  447. url: studiouWcfppFront.ajaxUrl,
  448. type: 'POST',
  449. data: {
  450. action: 'studiou_wcfpp_add_file_to_cart',
  451. nonce: studiouWcfppFront.nonce,
  452. file_record_id: fileRecordId,
  453. variation_id: variationId,
  454. quantity: qty
  455. },
  456. success: function (response) {
  457. if (response.success) {
  458. showMessage(i18n.addedToCart || 'Added to cart.', 'success');
  459. if (response.data && response.data.overview_html) {
  460. updateOverview(response.data.overview_html);
  461. }
  462. } else {
  463. showMessage(response.data ? response.data.message : (i18n.addToCartError || 'Could not add to cart.'), 'error');
  464. }
  465. },
  466. error: function () {
  467. showMessage(i18n.addToCartError || 'Could not add to cart.', 'error');
  468. },
  469. complete: function () {
  470. $btn.prop('disabled', false);
  471. }
  472. });
  473. }
  474. // ==========================================
  475. // Order overview
  476. // ==========================================
  477. function initOverviewDelegation() {
  478. // Remove-line on overview panel (cart-only, upload stays)
  479. $(document).on('click', '.studiou-fpp-overview-remove', function (e) {
  480. var $link = $(this);
  481. var href = $link.attr('href');
  482. // Only intercept if the link has a nonce arg — otherwise let browser navigate
  483. if (!href || href.indexOf('studiou-fpp-remove-line') < 0) return;
  484. e.preventDefault();
  485. var $line = $link.closest('.studiou-fpp-overview-line');
  486. var key = $line.data('cart-item-key');
  487. if (!key) return;
  488. $.ajax({
  489. url: studiouWcfppFront.ajaxUrl,
  490. type: 'POST',
  491. data: {
  492. action: 'studiou_wcfpp_remove_cart_line',
  493. nonce: studiouWcfppFront.nonce,
  494. cart_item_key: key
  495. },
  496. success: function (response) {
  497. if (response.success && response.data && response.data.overview_html) {
  498. updateOverview(response.data.overview_html);
  499. } else if (!response.success) {
  500. showMessage(response.data ? response.data.message : (i18n.error || 'Error'), 'error');
  501. }
  502. },
  503. error: function () {
  504. showMessage(i18n.error || 'Error', 'error');
  505. }
  506. });
  507. });
  508. }
  509. function updateOverview(html) {
  510. var $new = $(html);
  511. var $existing = $('#studiou-fpp-overview');
  512. if ($existing.length) {
  513. $existing.replaceWith($new);
  514. } else {
  515. $wrap.after($new);
  516. }
  517. initOverviewThumbClicks();
  518. $(document).trigger('studiou-fpp:cart-updated');
  519. }
  520. // ==========================================
  521. // Hero image
  522. // ==========================================
  523. function applyHeroImage(url) {
  524. if (!url) return;
  525. var selectors = [
  526. '.woocommerce-product-gallery__image img',
  527. '.product .wp-post-image',
  528. '.product-image img',
  529. '.product-thumbnail img',
  530. '.entry-summary img.attachment-woocommerce_thumbnail',
  531. '.product-gallery img:first'
  532. ];
  533. var $img = null;
  534. for (var i = 0; i < selectors.length; i++) {
  535. $img = $(selectors[i]).first();
  536. if ($img.length) break;
  537. }
  538. if (!$img || !$img.length) return;
  539. if (!originalGallery.src) {
  540. originalGallery.src = $img.attr('src');
  541. originalGallery.srcset = $img.attr('srcset') || '';
  542. originalGallery.sizes = $img.attr('sizes') || '';
  543. var $link = $img.closest('a');
  544. if ($link.length) originalGallery.href = $link.attr('href');
  545. }
  546. $img.attr('src', url).removeAttr('srcset').removeAttr('sizes');
  547. var $a = $img.closest('a');
  548. if ($a.length) $a.attr('href', url);
  549. }
  550. // ==========================================
  551. // Lightbox (click thumbnail → enlarged preview)
  552. // ==========================================
  553. var $lightbox = null;
  554. // Attach a target-phase click listener directly on a thumbnail element.
  555. // Element-level listeners are the most reliable form of click binding — no
  556. // delegation, no capture/bubble surprises. Called from buildCard + finishUpload
  557. // (upload cards) and from initOverviewThumbClicks (after every overview render).
  558. function bindThumbClick(el) {
  559. if (!el || el.__studiouFppBound) return;
  560. el.__studiouFppBound = true;
  561. el.addEventListener('click', function (ev) {
  562. var t = ev.target;
  563. if (t && t.closest && t.closest('button, input, select, textarea, a')) return;
  564. var card = el.closest ? el.closest('.studiou-fpp-card') : null;
  565. if (card && card.classList.contains('studiou-fpp-card-uploading')) return;
  566. var img = el.matches && el.matches('.studiou-fpp-overview-thumb')
  567. ? el
  568. : (el.querySelector ? el.querySelector('img') : null);
  569. if (!img) return;
  570. var url = img.getAttribute('data-preview-url') || img.getAttribute('src');
  571. if (!url) return;
  572. var name = '';
  573. if (card) {
  574. var nameEl = card.querySelector('.studiou-fpp-card-name');
  575. name = nameEl ? nameEl.textContent : '';
  576. } else {
  577. name = img.getAttribute('data-file-name') || img.getAttribute('alt') || '';
  578. }
  579. ev.preventDefault();
  580. ev.stopPropagation();
  581. openLightbox(url, name);
  582. });
  583. }
  584. // Explicit "Enlarge" button handler — survives any parent-level click interceptor
  585. // because clicks on <button type="button"> don't match the thumb-image selectors that
  586. // theme lightboxes (iLightBox/Magnific) delegate to.
  587. function onEnlargeClick(ev) {
  588. ev.preventDefault();
  589. ev.stopPropagation();
  590. var btn = this;
  591. var card = btn.closest('.studiou-fpp-card');
  592. var line = btn.closest('.studiou-fpp-overview-line');
  593. var img, name;
  594. if (card) {
  595. img = card.querySelector('.studiou-fpp-card-thumb img');
  596. var nameEl = card.querySelector('.studiou-fpp-card-name');
  597. name = nameEl ? nameEl.textContent : '';
  598. } else if (line) {
  599. img = line.querySelector('.studiou-fpp-overview-thumb');
  600. name = img ? (img.getAttribute('data-file-name') || img.getAttribute('alt') || '') : '';
  601. }
  602. if (!img) return;
  603. var url = img.getAttribute('data-preview-url') || img.getAttribute('src');
  604. if (!url) return;
  605. openLightbox(url, name);
  606. }
  607. // (Re)bind all overview thumbs — called after initial render and after updateOverview.
  608. function initOverviewThumbClicks() {
  609. var nodes = document.querySelectorAll('.studiou-fpp-overview-thumb');
  610. for (var i = 0; i < nodes.length; i++) {
  611. bindThumbClick(nodes[i]);
  612. }
  613. var btns = document.querySelectorAll('.studiou-fpp-overview-enlarge');
  614. for (var j = 0; j < btns.length; j++) {
  615. btns[j].onclick = onEnlargeClick;
  616. }
  617. }
  618. function initLightbox() {
  619. $lightbox = $(
  620. '<div class="studiou-fpp-lightbox" role="dialog" aria-modal="true" style="display:none;">' +
  621. '<button type="button" class="studiou-fpp-lightbox-close" aria-label="' + escAttr(i18n.close || 'Close') + '">&times;</button>' +
  622. '<div class="studiou-fpp-lightbox-inner">' +
  623. '<img class="studiou-fpp-lightbox-img" alt="" />' +
  624. '<div class="studiou-fpp-lightbox-caption"></div>' +
  625. '</div>' +
  626. '</div>'
  627. );
  628. $('body').append($lightbox);
  629. // Enlarge button — primary, reliable opener. Delegated on document so newly
  630. // rendered cards and overview lines pick it up automatically.
  631. $(document).on('click', '.studiou-fpp-card-enlarge, .studiou-fpp-overview-enlarge', function (e) {
  632. onEnlargeClick.call(this, e);
  633. });
  634. // Use native capture-phase listener so theme/plugin handlers that stopPropagation
  635. // on <img> clicks (common in gallery-lightbox plugins) can't swallow us. Target the
  636. // .studiou-fpp-card-thumb wrapper, not just the <img>, so any click inside the
  637. // thumbnail — including padding around the image — opens the preview.
  638. document.addEventListener('click', function (ev) {
  639. var target = ev.target;
  640. if (!target || !target.closest) return;
  641. // Ignore clicks on interactive controls (remove button, progress text, etc.)
  642. if (target.closest('button, input, select, textarea, a')) return;
  643. var cardThumb = target.closest('.studiou-fpp-card-thumb');
  644. if (cardThumb) {
  645. var card = cardThumb.closest('.studiou-fpp-card');
  646. if (!card || card.classList.contains('studiou-fpp-card-uploading')) return;
  647. var img = cardThumb.querySelector('img');
  648. if (!img) return;
  649. ev.preventDefault();
  650. ev.stopPropagation();
  651. var nameEl = card.querySelector('.studiou-fpp-card-name');
  652. openLightbox(
  653. img.getAttribute('data-preview-url') || img.getAttribute('src'),
  654. nameEl ? nameEl.textContent : ''
  655. );
  656. return;
  657. }
  658. var overviewImg = target.closest('.studiou-fpp-overview-thumb');
  659. if (overviewImg) {
  660. ev.preventDefault();
  661. ev.stopPropagation();
  662. openLightbox(
  663. overviewImg.getAttribute('data-preview-url') || overviewImg.getAttribute('src'),
  664. overviewImg.getAttribute('data-file-name') || overviewImg.getAttribute('alt') || ''
  665. );
  666. }
  667. }, true);
  668. // Close: × button, backdrop click, ESC key
  669. $lightbox.on('click', '.studiou-fpp-lightbox-close', closeLightbox);
  670. $lightbox.on('click', function (e) {
  671. if (e.target === this) closeLightbox();
  672. });
  673. $(document).on('keydown.studiouFppLightbox', function (e) {
  674. if (e.key === 'Escape' && $lightbox && $lightbox.is(':visible')) {
  675. closeLightbox();
  676. }
  677. });
  678. }
  679. function openLightbox(url, caption) {
  680. if (!url) return;
  681. // Fallback: if the overlay DOM somehow failed to initialize, at least open the
  682. // preview in a new tab so the button is never a no-op.
  683. if (!$lightbox || !$lightbox.length) {
  684. window.open(url, '_blank', 'noopener');
  685. return;
  686. }
  687. $lightbox.find('.studiou-fpp-lightbox-img').attr('src', url).attr('alt', caption || '');
  688. $lightbox.find('.studiou-fpp-lightbox-caption').text(caption || '');
  689. $lightbox.css('display', 'flex');
  690. $('body').addClass('studiou-fpp-lightbox-open');
  691. }
  692. function closeLightbox() {
  693. if (!$lightbox) return;
  694. $lightbox.hide();
  695. $lightbox.find('.studiou-fpp-lightbox-img').attr('src', '');
  696. $('body').removeClass('studiou-fpp-lightbox-open');
  697. }
  698. function restoreHeroImage() {
  699. if (!originalGallery.src) return;
  700. var selectors = [
  701. '.woocommerce-product-gallery__image img',
  702. '.product .wp-post-image',
  703. '.product-image img',
  704. '.product-thumbnail img',
  705. '.entry-summary img.attachment-woocommerce_thumbnail',
  706. '.product-gallery img:first'
  707. ];
  708. var $img = null;
  709. for (var i = 0; i < selectors.length; i++) {
  710. $img = $(selectors[i]).first();
  711. if ($img.length) break;
  712. }
  713. if (!$img || !$img.length) return;
  714. $img.attr('src', originalGallery.src);
  715. if (originalGallery.srcset) $img.attr('srcset', originalGallery.srcset);
  716. if (originalGallery.sizes) $img.attr('sizes', originalGallery.sizes);
  717. if (originalGallery.href) $img.closest('a').attr('href', originalGallery.href);
  718. originalGallery = {};
  719. }
  720. // ==========================================
  721. // Helpers
  722. // ==========================================
  723. function showMessage(text, type) {
  724. var cls = type === 'error' ? 'studiou-fpp-msg-error'
  725. : type === 'success' ? 'studiou-fpp-msg-success'
  726. : 'studiou-fpp-msg-info';
  727. $messages.html('<div class="studiou-fpp-msg ' + cls + '">' + escHtml(text) + '</div>');
  728. }
  729. function clearMessages() { $messages.empty(); }
  730. function escHtml(str) {
  731. var div = document.createElement('div');
  732. div.appendChild(document.createTextNode(str == null ? '' : String(str)));
  733. return div.innerHTML;
  734. }
  735. function escAttr(str) {
  736. return String(str == null ? '' : str).replace(/"/g, '&quot;');
  737. }
  738. function formatI18n(tpl, val) {
  739. if (!tpl) return String(val);
  740. return tpl.replace('%d', val).replace('%s', val);
  741. }
  742. function formatPercent(p) {
  743. var n = parseFloat(p) || 0;
  744. var str = n.toFixed(2).replace(/\.?0+$/, '');
  745. return str + ' %';
  746. }
  747. function formatPrice(amount) {
  748. return formatPriceHtml(amount);
  749. }
  750. // Plain-text price (no HTML) — used for <option> labels where spans aren't rendered
  751. function formatPricePlain(amount) {
  752. var c = studiouWcfppFront.currency || {};
  753. var decimals = parseInt(c.decimals, 10); if (isNaN(decimals)) decimals = 2;
  754. var decSep = c.decimalSeparator || '.';
  755. var thouSep = c.thousandSeparator || ',';
  756. var fmt = c.priceFormat || '%1$s%2$s';
  757. var symbol = c.symbol || '';
  758. var fixed = parseFloat(amount).toFixed(decimals);
  759. var parts = fixed.split('.');
  760. var intPart = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, thouSep);
  761. var decPart = parts.length > 1 ? parts[1] : '';
  762. var numStr = decPart ? intPart + decSep + decPart : intPart;
  763. // strip any HTML tags that might arrive from priceFormat (defensive)
  764. return fmt.replace('%1$s', symbol).replace('%2$s', numStr).replace(/<[^>]*>/g, '');
  765. }
  766. function formatPriceHtml(amount) {
  767. var c = studiouWcfppFront.currency || {};
  768. var decimals = parseInt(c.decimals, 10); if (isNaN(decimals)) decimals = 2;
  769. var decSep = c.decimalSeparator || '.';
  770. var thouSep = c.thousandSeparator || ',';
  771. var fmt = c.priceFormat || '%1$s%2$s';
  772. var symbol = c.symbol || '';
  773. var fixed = parseFloat(amount).toFixed(decimals);
  774. var parts = fixed.split('.');
  775. var intPart = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, thouSep);
  776. var decPart = parts.length > 1 ? parts[1] : '';
  777. var numStr = decPart ? intPart + decSep + decPart : intPart;
  778. var sym = '<span class="woocommerce-Price-currencySymbol">' + symbol + '</span>';
  779. return '<span class="woocommerce-Price-amount amount">' +
  780. fmt.replace('%1$s', sym).replace('%2$s', numStr) +
  781. '</span>';
  782. }
  783. });
  784. })(jQuery);