feat(02-product-actions-fixes): Phase 02 complete — customization, price label, structure fix

Plan 02-03: Customization save + success modal (5/5 AC)
- 26-field squaremeter POST payload (verbose PL dim, qty_alt/qty_alth)
- Chain POST /module/ps_shoppingcart/ajax -> Bootstrap #blockcart-modal
- Critical fix: moved {/block} so inline script actually renders
- __p02p02InFlight re-entrancy guard

Plan 02-04: Live cena per-sqm label obok "Dodaj do koszyka" (5/5 AC)
- .p02p04-total-price label, gorna .current-price static
- Separate __p02p04Bound + setInterval reconciliation
- Poll-retry prestashop.on registration

Plan 02-05: Struktura materialu w POST payload (4/4 AC)
- Enumerate [name^="group["] spoza formy, doklej do payload
- Fix: group_5 select w .product-bar-box nie trafial do koszyka

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 00:55:05 +02:00
parent 7ac795ba3f
commit ac03f807c1
13 changed files with 1583 additions and 64 deletions

View File

@@ -1038,8 +1038,14 @@ document.addEventListener('click', function(e) {
e.stopImmediatePropagation();
e.stopPropagation();
// Re-entrancy guard: zapobiega podwojnemu POST gdy custom.js I inline mirror
// oba zarejestrowaly handler (np. race w idempotency guard).
if (window.__p02p02InFlight) return;
window.__p02p02InFlight = true;
// Walidacja: piece musi byc skonfigurowany.
if (!$('#checkbox-piece').is(':checked')) {
window.__p02p02InFlight = false;
$.fancybox({
minWidth: 800,
maxWidth: 1000,
@@ -1064,7 +1070,75 @@ document.addEventListener('click', function(e) {
$btn.prop('disabled', true).addClass('loading');
var qty = parseInt($('#quantity_wanted').val(), 10) || 1;
var payload = $form.serialize() + '&qty=' + encodeURIComponent(qty) + '&add=1&action=update';
// Phase 02 Plan 02-03 Task 2: inject squaremeter fields for customization save.
// Tlo: Plan 02-01 override totalpriceinfospecific na no-op wylaczyl squaremeter JS
// -> hidden inputs (discretion, dim, calculated_total, product_total_price_calc,
// qty_alt, qty_alth) pozostaja zerowe. Hook hookActionObjectCartUpdateBefore
// gate'owany jest `discretion=on` + wymaga wartosci numerycznych >0 zeby zapisac
// customization. Populujemy pola z piece-width/piece-height (user input w cm,
// konwertujemy na m dla kompatybilnosci z OLD flow). dim uzywa formatu VERBOSE
// jaki produkuje squaremeter JS (widoczny pozniej w "Szczegoly" koszyka).
var pwRaw = parseInt($('#piece-width').val(), 10) || 100;
var phRaw = parseInt($('#piece-height').val(), 10) || 100;
var wM = (pwRaw / 100).toFixed(1);
var hM = (phRaw / 100).toFixed(1);
var areaM2 = (parseFloat(wM) * parseFloat(hM)).toFixed(4);
var basePrice = parseFloat($('#product_base_price').val())
|| parseFloat($('#product_fixed_price').val())
|| parseFloat($('meta[property="product:price:amount"]').attr('content'))
|| parseFloat(($('.product-prices .current-price, .current-price').first().text() || '').replace(/[^\d.,]/g, '').replace(',', '.'))
|| 0;
var totalPriceCalc = Math.round(basePrice * parseFloat(areaM2));
$('#discretion').prop('checked', true);
if ($('#product_total_price_calc').length) $('#product_total_price_calc').val(totalPriceCalc);
if ($('#calculated_total').length) $('#calculated_total').val(areaM2);
if ($('#converted_ea').length) $('#converted_ea').val(areaM2);
if ($('#grand_calculated_total').length) $('#grand_calculated_total').val('');
if ($('#extrafeevalue').length) $('#extrafeevalue').val('0');
if ($('#wastevalue').length) $('#wastevalue').val('0');
if ($('#quantity_wanted_alt').length) $('#quantity_wanted_alt').val(wM);
if ($('#quantity_wanted_alth').length) $('#quantity_wanted_alth').val(hM);
var isRefl = parseInt($('#product_is_reflection').val(), 10) || 0;
var cropPosX = parseInt($('#product_crop_pos_x').val(), 10) || 0;
var cropPosY = parseInt($('#product_crop_pos_y').val(), 10) || 0;
var bgTop = $('#piece_bg_top').val() || 0;
var bgLeft = $('#piece_bg_left').val() || 0;
var dimStr = 'Szerokość ' + wM + ' m, Wysokość ' + hM + ' m, ' +
parseFloat(areaM2).toFixed(2) + ' m2, Pozycja ' + cropPosX + ' ' + cropPosY +
' <span> ,Pozycja tła ' + bgTop + ' ' + bgLeft + ' ,Odbicie ' + isRefl + ' </span>';
if ($('#dim').length) $('#dim').val(dimStr);
// Explicit sqFields appended po form.serialize() -> PHP $_POST last-wins
// defense-in-depth gdyby form nie mial tych inputow (shared partial variability).
var sqFields = 'discretion=on' +
'&dim=' + encodeURIComponent(dimStr) +
'&converted_ea=' + encodeURIComponent(areaM2) +
'&calculated_total=' + encodeURIComponent(areaM2) +
'&grand_calculated_total=' +
'&extrafeevalue=0' +
'&wastevalue=0' +
'&product_total_price_calc=' + encodeURIComponent(totalPriceCalc) +
'&qty_alt=' + encodeURIComponent(wM) +
'&qty_alth=' + encodeURIComponent(hM);
// Plan 02-05: PS attribute group inputy mogą byc POZA forma #add-to-cart-or-refresh
// (np. #group_5 "Tekstura materiału" w .product-bar-box). $form.serialize() ich nie
// łapie → PS zapisuje wrong attribute combination. Enumeruj i dołącz do payload.
var externalGroups = '';
$('[name^="group["]').each(function() {
var $el = $(this);
if ($el.closest('#add-to-cart-or-refresh').length) return; // juz w $form.serialize()
var t = ($el.attr('type') || '').toLowerCase();
if ((t === 'radio' || t === 'checkbox') && !$el.prop('checked')) return;
var n = $el.attr('name');
var v = $el.val();
if (v === undefined || v === null || v === '') return;
externalGroups += '&' + encodeURIComponent(n) + '=' + encodeURIComponent(v);
});
var payload = $form.serialize() + '&qty=' + encodeURIComponent(qty) + externalGroups + '&' + sqFields + '&add=1&action=update';
var actionUrl = $form.attr('action') || window.location.href;
$.ajax({
@@ -1092,31 +1166,56 @@ document.addEventListener('click', function(e) {
return;
}
// Success: emit updatedCart + manual blockcart refresh.
// Success: emit updatedCart (PS core hook bus).
if (window.prestashop && typeof prestashop.emit === 'function') {
prestashop.emit('updatedCart', { resp: resp, reason: { linkAction: 'add-to-cart' } });
}
$(document).trigger('updatedCart', [resp]);
// Manual blockcart refresh (nowy layout nie ma auto-listener na emit).
// Endpoint ps_shoppingcart module renderuje caly blockcart HTML.
if (window.prestashop && prestashop.urls && prestashop.urls.pages && prestashop.urls.pages.cart) {
$.get(prestashop.urls.pages.cart, { action: 'refresh', ajax: 1 }, null, 'json')
.done(function(cartResp) {
if (cartResp && cartResp.preview) {
$('.blockcart').replaceWith(cartResp.preview);
} else if (resp.cart) {
// Fallback: podmien counter minimalnie
$('.cart-products-count').text(resp.cart.products_count || resp.cart.totals && resp.cart.totals.total || '');
}
})
.fail(function() {
// Last-resort fallback: przeladuj stronice aby PS core odbudowal koszyk.
// Tylko jako fallback — normalnie nie powinno sie odpalic.
});
}
// Phase 02 Plan 02-03 Task 3: fetch success modal + blockcart preview
// w jednym POST do /module/ps_shoppingcart/ajax (endpoint zwraca
// {preview, modal}). Zastepuje stary $.get do urls.pages.cart (ktory
// czasem nie renderowal modalu w NEW layout).
$.ajax({
url: '/module/ps_shoppingcart/ajax',
type: 'POST',
data: {
action: 'add-to-cart',
id_product: resp.id_product || '',
id_product_attribute: resp.id_product_attribute || '',
id_customization: resp.id_customization || 0
},
dataType: 'json'
}).done(function(mr) {
if (mr && mr.preview) {
$('.blockcart').replaceWith(mr.preview);
}
if (mr && mr.modal) {
$('#blockcart-modal').remove();
$('.modal-backdrop').remove();
$('body').append(mr.modal);
var $modal = $('#blockcart-modal');
if (typeof $modal.modal === 'function') {
$modal.modal('show');
} else {
// Fallback dla brakujacego Bootstrap modal plugin.
$modal.addClass('show in').css('display', 'block').attr('aria-hidden', 'false');
$('body').addClass('modal-open').append('<div class="modal-backdrop fade in show"></div>');
$modal.on('click.blockcartClose', '[data-dismiss=modal], .close', function() {
$modal.removeClass('show in').css('display', 'none').remove();
$('body').removeClass('modal-open');
$('.modal-backdrop').remove();
});
}
}
}).fail(function() {
// Fallback minimal counter update gdy modal endpoint fail.
if (resp.cart) {
$('.cart-products-count').text((resp.cart.products_count) || '');
}
});
// Success feedback: subtelne pulsowanie przycisku.
// Success feedback: subtelne pulsowanie przycisku (fallback gdy modal nie renderuje).
$btn.addClass('added-flash');
setTimeout(function () { $btn.removeClass('added-flash'); }, 1200);
},
@@ -1131,7 +1230,100 @@ document.addEventListener('click', function(e) {
},
complete: function() {
$btn.prop('disabled', false).removeClass('loading');
window.__p02p02InFlight = false;
}
});
}, true); // useCapture=true — kluczowe, odpala przed bubble-phase PS core handlers.
}
// ============================================================================
// Phase 02 Plan 02-04: live cena per-sqm w UI.
// ============================================================================
// Zmiana #piece-width / #piece-height -> natychmiastowe przeliczenie
// .product-prices .current-price (pure JS, zero HTTP). Formula identyczna
// z Plan 02-03 add-to-cart handler: basePrice x area_m^2. basePrice czytane
// z meta[property="product:price:amount"] (fallback: .current-price text).
// Guard `window.__p02p04Bound` niezalezny od __p02p02Bound — stale-cache
// custom.js nie blokuje inline mirror rejestracji w product.tpl.
// ============================================================================
if (!window.__p02p04Bound) {
window.__p02p04Bound = true;
// Zapewnia istnienie labelki obok przycisku "Dodaj do koszyka".
// Górna cena .current-price ("Od XXX zł / m²") pozostaje STATYCZNA.
window.__p02p04EnsureLabel = function() {
var $label = $('.p02p04-total-price');
if ($label.length) return $label;
// Wstaw PRZED .add (kontener buttona) wewnatrz .product-quantity
var $add = $('.product-quantity .add').first();
if (!$add.length) return $();
$label = $('<span class="p02p04-total-price" aria-live="polite" style="display:inline-block;margin-right:16px;font-weight:700;font-size:1.25rem;vertical-align:middle;"></span>');
$add.before($label);
return $label;
};
window.__p02p04RecalcPrice = function() {
if (!document.querySelector('.product-variants-data--new')) return;
var pwRaw = parseInt(($('#piece-width').val() || 0), 10) || 0;
var phRaw = parseInt(($('#piece-height').val() || 0), 10) || 0;
var $label = window.__p02p04EnsureLabel();
if (pwRaw <= 0 || phRaw <= 0) {
if ($label.length) $label.text('');
return;
}
var areaM2 = (pwRaw / 100) * (phRaw / 100);
var basePrice = parseFloat($('meta[property="product:price:amount"]').attr('content'))
|| parseFloat(($('.product-prices .current-price, .current-price').first()
.text() || '').replace(/[^\d.,]/g, '').replace(',', '.'))
|| 0;
if (basePrice <= 0) return;
var total = basePrice * areaM2;
var formatted = total.toFixed(2).replace('.', ',') + ' zł';
if ($label.length) $label.text(formatted);
};
$(document).on('input change keyup', '#piece-width, #piece-height', function() {
clearTimeout(window.__p02p04RecalcT);
window.__p02p04RecalcT = setTimeout(window.__p02p04RecalcPrice, 100);
});
// Initial render — interval re-apply przez 5s:
// (a) #piece-width/#piece-height stubs moga byc wstrzykiwane late (Plan 02-01),
// (b) squaremeter init overwrituje .current-price PO naszym pierwszym recalc.
// Prosty interval ktory reaplikuje recalc co 500ms przez 5s zapewnia stabilny
// final state. Po 5s user i tak ma pelna reaktywnosc na zmiany dimensions.
window.__p02p04TryInitial = function() {
var attempts = 0;
var iv = setInterval(function() {
attempts++;
window.__p02p04RecalcPrice();
if (attempts >= 10) clearInterval(iv);
}, 500);
};
// Uruchom synchronicznie — jQuery ready w kontekscie inline Smarty block
// nie firuje konsekwentnie; recalc early-returnuje jesli inputy jeszcze nie
// sa w DOM, wiec interval-based retry bezpiecznie pokrywa ready state.
window.__p02p04TryInitial();
// prestashop.on() uses $(prestashop).on — wymaga ze window.prestashop istnieje.
// W inline mirror kontekst Smarty, prestashop bundle laduje sie later. Poll az pojawi sie.
(function bindUpdatedProduct() {
if (window.prestashop && typeof prestashop.on === 'function') {
prestashop.on('updatedProduct', function() {
setTimeout(window.__p02p04RecalcPrice, 50);
});
} else {
setTimeout(bindUpdatedProduct, 200);
}
})();
$(document).on('updatedProduct', function() {
setTimeout(window.__p02p04RecalcPrice, 50);
});
}

View File

@@ -753,7 +753,6 @@
</div>
</div>
</section>
{/block}
{* =========================================================================
Phase 02 Plan 02-02: inline add-to-cart handler (cache-buster).
@@ -782,7 +781,11 @@
e.stopImmediatePropagation();
e.stopPropagation();
if (window.__p02p02InFlight) return;
window.__p02p02InFlight = true;
if (!jQuery('#checkbox-piece').is(':checked')) {
window.__p02p02InFlight = false;
jQuery.fancybox({
minWidth: 800, maxWidth: 1000, padding: 30, height: 100,
content: 'Proszę wybrać rozmiar i wycinek tapety przed dodaniem jej do koszyka.'
@@ -802,7 +805,65 @@
$btn.prop('disabled', true).addClass('loading');
var qty = parseInt(jQuery('#quantity_wanted').val(), 10) || 1;
var payload = $form.serialize() + '&qty=' + encodeURIComponent(qty) + '&add=1&action=update';
// Phase 02 Plan 02-03 Task 2: inject squaremeter fields (patrz custom.js komentarz).
var pwRaw = parseInt(jQuery('#piece-width').val(), 10) || 100;
var phRaw = parseInt(jQuery('#piece-height').val(), 10) || 100;
var wM = (pwRaw / 100).toFixed(1);
var hM = (phRaw / 100).toFixed(1);
var areaM2 = (parseFloat(wM) * parseFloat(hM)).toFixed(4);
var basePrice = parseFloat(jQuery('#product_base_price').val())
|| parseFloat(jQuery('#product_fixed_price').val())
|| parseFloat(jQuery('meta[property="product:price:amount"]').attr('content'))
|| parseFloat((jQuery('.product-prices .current-price, .current-price').first().text() || '').replace(/[^\d.,]/g, '').replace(',', '.'))
|| 0;
var totalPriceCalc = Math.round(basePrice * parseFloat(areaM2));
jQuery('#discretion').prop('checked', true);
if (jQuery('#product_total_price_calc').length) jQuery('#product_total_price_calc').val(totalPriceCalc);
if (jQuery('#calculated_total').length) jQuery('#calculated_total').val(areaM2);
if (jQuery('#converted_ea').length) jQuery('#converted_ea').val(areaM2);
if (jQuery('#grand_calculated_total').length) jQuery('#grand_calculated_total').val('');
if (jQuery('#extrafeevalue').length) jQuery('#extrafeevalue').val('0');
if (jQuery('#wastevalue').length) jQuery('#wastevalue').val('0');
if (jQuery('#quantity_wanted_alt').length) jQuery('#quantity_wanted_alt').val(wM);
if (jQuery('#quantity_wanted_alth').length) jQuery('#quantity_wanted_alth').val(hM);
var isRefl = parseInt(jQuery('#product_is_reflection').val(), 10) || 0;
var cropPosX = parseInt(jQuery('#product_crop_pos_x').val(), 10) || 0;
var cropPosY = parseInt(jQuery('#product_crop_pos_y').val(), 10) || 0;
var bgTop = jQuery('#piece_bg_top').val() || 0;
var bgLeft = jQuery('#piece_bg_left').val() || 0;
var dimStr = 'Szerokość ' + wM + ' m, Wysokość ' + hM + ' m, ' +
parseFloat(areaM2).toFixed(2) + ' m2, Pozycja ' + cropPosX + ' ' + cropPosY +
' <span> ,Pozycja tła ' + bgTop + ' ' + bgLeft + ' ,Odbicie ' + isRefl + ' </span>';
if (jQuery('#dim').length) jQuery('#dim').val(dimStr);
var sqFields = 'discretion=on' +
'&dim=' + encodeURIComponent(dimStr) +
'&converted_ea=' + encodeURIComponent(areaM2) +
'&calculated_total=' + encodeURIComponent(areaM2) +
'&grand_calculated_total=' +
'&extrafeevalue=0' +
'&wastevalue=0' +
'&product_total_price_calc=' + encodeURIComponent(totalPriceCalc) +
'&qty_alt=' + encodeURIComponent(wM) +
'&qty_alth=' + encodeURIComponent(hM);
// Plan 02-05: PS attribute groups POZA formą #add-to-cart-or-refresh (np. #group_5
// "Tekstura materiału" w .product-bar-box). Enumeruj i dołącz do payload.
var externalGroups = '';
jQuery('[name^="group["]').each(function() {
var $el = jQuery(this);
if ($el.closest('#add-to-cart-or-refresh').length) return;
var t = ($el.attr('type') || '').toLowerCase();
if ((t === 'radio' || t === 'checkbox') && !$el.prop('checked')) return;
var n = $el.attr('name');
var v = $el.val();
if (v === undefined || v === null || v === '') return;
externalGroups += '&' + encodeURIComponent(n) + '=' + encodeURIComponent(v);
});
var payload = $form.serialize() + '&qty=' + encodeURIComponent(qty) + externalGroups + '&' + sqFields + '&add=1&action=update';
var actionUrl = $form.attr('action') || window.location.href;
jQuery.ajax({
@@ -831,14 +892,41 @@
}
jQuery(document).trigger('updatedCart', [resp]);
if (window.prestashop && prestashop.urls && prestashop.urls.pages && prestashop.urls.pages.cart) {
jQuery.get(prestashop.urls.pages.cart, { action: 'refresh', ajax: 1 }, null, 'json')
.done(function(cartResp) {
if (cartResp && cartResp.preview) {
jQuery('.blockcart').replaceWith(cartResp.preview);
}
});
}
// Phase 02 Plan 02-03 Task 3: fetch success modal + preview (patrz custom.js).
jQuery.ajax({
url: '/module/ps_shoppingcart/ajax',
type: 'POST',
data: {
action: 'add-to-cart',
id_product: resp.id_product || '',
id_product_attribute: resp.id_product_attribute || '',
id_customization: resp.id_customization || 0
},
dataType: 'json'
}).done(function(mr) {
if (mr && mr.preview) {
jQuery('.blockcart').replaceWith(mr.preview);
}
if (mr && mr.modal) {
jQuery('#blockcart-modal').remove();
jQuery('.modal-backdrop').remove();
jQuery('body').append(mr.modal);
var $modal = jQuery('#blockcart-modal');
if (typeof $modal.modal === 'function') {
$modal.modal('show');
} else {
$modal.addClass('show in').css('display', 'block').attr('aria-hidden', 'false');
jQuery('body').addClass('modal-open').append('<div class="modal-backdrop fade in show"></div>');
$modal.on('click.blockcartClose', '[data-dismiss=modal], .close', function() {
$modal.removeClass('show in').css('display', 'none').remove();
jQuery('body').removeClass('modal-open');
jQuery('.modal-backdrop').remove();
});
}
}
}).fail(function() {
if (resp.cart) { jQuery('.cart-products-count').text(resp.cart.products_count || ''); }
});
$btn.addClass('added-flash');
setTimeout(function() { $btn.removeClass('added-flash'); }, 1200);
@@ -851,10 +939,92 @@
},
complete: function() {
$btn.prop('disabled', false).removeClass('loading');
window.__p02p02InFlight = false;
}
});
}, true);
})();
/* =========================================================================
Phase 02 Plan 02-04: live cena per-sqm (inline mirror).
Separate IIFE + __p02p04Bound guard — niezalezny od __p02p02Bound, zeby
stale-cache custom.js (bez Plan 02-04) nie blokowalo tej rejestracji.
Kod identyczny z custom.js __p02p04 block (jQuery zamiast $).
========================================================================= */
(function() {
if (window.__p02p04Bound) return;
window.__p02p04Bound = true;
window.__p02p04EnsureLabel = function() {
var $label = jQuery('.p02p04-total-price');
if ($label.length) return $label;
var $add = jQuery('.product-quantity .add').first();
if (!$add.length) return jQuery();
$label = jQuery('<span class="p02p04-total-price" aria-live="polite" style="display:inline-block;margin-right:16px;font-weight:700;font-size:1.25rem;vertical-align:middle;"></span>');
$add.before($label);
return $label;
};
window.__p02p04RecalcPrice = function() {
if (!document.querySelector('.product-variants-data--new')) return;
var pwRaw = parseInt((jQuery('#piece-width').val() || 0), 10) || 0;
var phRaw = parseInt((jQuery('#piece-height').val() || 0), 10) || 0;
var $label = window.__p02p04EnsureLabel();
if (pwRaw <= 0 || phRaw <= 0) {
if ($label.length) $label.text('');
return;
}
var areaM2 = (pwRaw / 100) * (phRaw / 100);
var basePrice = parseFloat(jQuery('meta[property="product:price:amount"]').attr('content'))
|| parseFloat((jQuery('.product-prices .current-price, .current-price').first()
.text() || '').replace(/[^\d.,]/g, '').replace(',', '.'))
|| 0;
if (basePrice <= 0) return;
var total = basePrice * areaM2;
var formatted = total.toFixed(2).replace('.', ',') + ' zł';
if ($label.length) $label.text(formatted);
};
jQuery(document).on('input change keyup', '#piece-width, #piece-height', function() {
clearTimeout(window.__p02p04RecalcT);
window.__p02p04RecalcT = setTimeout(window.__p02p04RecalcPrice, 100);
});
// Initial render interval re-apply przez 5s (squaremeter init overwrituje
// .current-price po naszym pierwszym recalc). Patrz custom.js komentarz.
window.__p02p04TryInitial = function() {
var attempts = 0;
var iv = setInterval(function() {
attempts++;
window.__p02p04RecalcPrice();
if (attempts >= 10) clearInterval(iv);
}, 500);
};
// Uruchom synchronicznie jQuery ready w kontekscie inline Smarty block
// nie firuje konsekwentnie. Recalc early-return gdy inputy brak.
window.__p02p04TryInitial();
(function bindUpdatedProduct() {
if (window.prestashop && typeof prestashop.on === 'function') {
prestashop.on('updatedProduct', function() {
setTimeout(window.__p02p04RecalcPrice, 50);
});
} else {
setTimeout(bindUpdatedProduct, 200);
}
})();
jQuery(document).on('updatedProduct', function() {
setTimeout(window.__p02p04RecalcPrice, 50);
});
})();
</script>
{/block}{* /block name=content — Plan 02-03 fix: moved to wrap inline script so it renders in {extends} template *}
{/if}