19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:32:12 +01:00
parent 79f83631d5
commit 73afc09215
6267 changed files with 1534193 additions and 1130106 deletions

View file

@ -0,0 +1,28 @@
import { CustomerAddress } from '@portal/interactions/address';
import { patch } from '@web/core/utils/patch';
patch(CustomerAddress.prototype, {
// /shop/address
setup() {
super.setup();
// There is two main buttons in the DOM for mobile or desktop. User can switch from one mode
// to the other by rotating their tablet.
this.submitButtons = document.getElementsByName("website_sale_main_button");
if (this.submitButtons) {
this._boundSaveAddress = this.saveAddress.bind(this);
this.submitButtons.forEach(
submitButton => submitButton.addEventListener('click', this._boundSaveAddress)
);
}
},
destroy() {
if (this.submitButtons) {
this.submitButtons.forEach(
submitButton => submitButton.removeEventListener('click', this._boundSaveAddress)
);
}
super.destroy();
},
});

View file

@ -0,0 +1,119 @@
import { Interaction } from "@web/public/interaction";
import { registry } from "@web/core/registry";
import { SIZES, utils as uiUtils } from "@web/core/ui/ui_service";
export class CarouselProduct extends Interaction {
static selector = "#o-carousel-product";
dynamicContent = {
_root: {
"t-on-slide.bs.carousel.noUpdate": this.onSlideCarouselProduct,
"t-att-style": () => ({
"top": this.top,
}),
},
_window: {
"t-on-resize.noUpdate": this.throttled(this.onSlideCarouselProduct),
},
".carousel-indicators": {
"t-att-style": () => ({
"justify-content": this.indicatorJustify,
}),
},
".o_carousel_product_indicators": {
"t-on-wheel.prevent": this.onMouseWheel,
},
};
setup() {
this.top = undefined;
this.indicatorJustify = "start";
}
start() {
this.updateCarouselPosition();
this.registerCleanup(this.services.website_menus.registerCallback(this.updateCarouselPosition.bind(this)));
if (this.el.querySelector(".carousel-indicators")) {
this.updateJustifyContent();
}
}
updateCarouselPosition() {
let size = 5;
for (const el of document.querySelectorAll(".o_top_fixed_element")) {
const style = window.getComputedStyle(el);
size += el.getBoundingClientRect().height + parseFloat(style.marginTop) + parseFloat(style.marginBottom);
}
this.top = size;
}
/**
* Center the selected indicator to scroll the indicators list when it
* overflows.
*
* @param {Event} ev
*/
onSlideCarouselProduct(ev) {
const isReversed = this.el.style["flex-direction"] === "column-reverse";
const isLeftIndicators = this.el.classList.contains("o_carousel_product_left_indicators");
const indicatorsDivEl = this.el.querySelector(isLeftIndicators ? ".o_carousel_product_indicators" : ".carousel-indicators");
if (!indicatorsDivEl) {
return;
}
const isVertical = isLeftIndicators && !isReversed;
const currentIndicatorEl = ev?.relatedTarget || this.el.querySelector("li.active");
let indicatorIndex = currentIndicatorEl ? [...currentIndicatorEl.parentElement.children].findIndex(el => el === currentIndicatorEl) : -1;
const indicatorEl = indicatorsDivEl.querySelector(`[data-bs-slide-to="${indicatorIndex}"]`);
const indicatorsDivRect = indicatorsDivEl.getBoundingClientRect();
const indicatorsDivStyle = window.getComputedStyle(indicatorsDivEl);
const indicatorsDivSize = isVertical ? indicatorsDivRect.height + parseFloat(indicatorsDivStyle.marginTop) + parseFloat(indicatorsDivStyle.marginBottom) : indicatorsDivRect.width + parseFloat(indicatorsDivStyle.marginLeft) + parseFloat(indicatorsDivStyle.marginRight);
const indicatorRect = indicatorEl.getBoundingClientRect();
const indicatorStyle = window.getComputedStyle(indicatorEl);
const indicatorSize = isVertical ? indicatorRect.height : indicatorRect.width;
const indicatorPosition = isVertical ? indicatorRect.top - indicatorsDivRect.top - parseFloat(indicatorStyle.marginTop) : indicatorRect.left - indicatorsDivRect.left - parseFloat(indicatorStyle.marginLeft);
const scrollSize = isVertical ? indicatorsDivEl.scrollHeight : indicatorsDivEl.scrollWidth;
let indicatorsPositionDiff = (indicatorPosition + (indicatorSize / 2)) - (indicatorsDivSize / 2);
indicatorsPositionDiff = Math.min(indicatorsPositionDiff, scrollSize - indicatorsDivSize);
this.updateJustifyContent();
const indicatorsPositionX = isVertical ? "0" : "-" + indicatorsPositionDiff;
const indicatorsPositionY = isVertical ? "-" + indicatorsPositionDiff : "0";
const translate3D = indicatorsPositionDiff > 0 ? "translate3d(" + indicatorsPositionX + "px," + indicatorsPositionY + "px,0)" : "";
indicatorsDivEl.style.setProperty("transform", translate3D);
}
updateJustifyContent() {
this.indicatorJustify = "start";
if (uiUtils.getSize() <= SIZES.MD) {
const indicatorsDivEl = this.el.querySelector(".carousel-indicators");
const firstIndicatorEl = indicatorsDivEl.firstElementChild;
const lastIndicatorEl = indicatorsDivEl.lastElementChild;
const { left: lastIndicatorLeft } = lastIndicatorEl.getBoundingClientRect();
if (lastIndicatorLeft + firstIndicatorEl.offsetWidth < indicatorsDivEl.offsetWidth) {
this.indicatorJustify = "center";
}
}
this.updateContent();
}
/**
* @param {MouseEvent} ev
*/
onMouseWheel(ev) {
const bsCarousel = window.Carousel.getOrCreateInstance(this.el);
if (ev.deltaY > 0) {
bsCarousel.next();
} else {
bsCarousel.prev();
}
}
}
registry
.category("public.interactions")
.add("website_sale.carousel_product", CarouselProduct);
registry
.category("public.interactions.edit")
.add("website_sale.carousel_product", {
Interaction: CarouselProduct,
});

View file

@ -0,0 +1,91 @@
import { Interaction } from '@web/public/interaction';
import { browser } from '@web/core/browser/browser';
import { registry } from '@web/core/registry';
import { rpc } from '@web/core/network/rpc';
import { redirect } from '@web/core/utils/urls';
import wSaleUtils from '@website_sale/js/website_sale_utils';
export class CartLine extends Interaction {
static selector = '.o_cart_product';
dynamicContent = {
'.css_quantity > input.js_quantity': {
't-on-change.withTarget': this.locked(this.debounced(this.changeQuantity, 500)),
},
'.css_quantity > a': {
't-on-click.prevent.withTarget': this.locked(this.incOrDecQuantity),
},
'.js_delete_product': { 't-on-click.prevent': this.locked(this.deleteProduct) },
};
/**
* @param {Event} ev
* @param {HTMLElement} currentTargetEl
*/
async changeQuantity(ev, currentTargetEl) {
await this._changeQuantity(currentTargetEl);
}
/**
* @param {Event} ev
* @param {HTMLElement} currentTargetEl
*/
async incOrDecQuantity(ev, currentTargetEl) {
const input = currentTargetEl.closest('.css_quantity').querySelector('input.js_quantity');
const maxQuantity = parseFloat(input.dataset.max || Infinity);
const oldQuantity = parseFloat(input.value || 0);
const newQuantity = currentTargetEl.querySelector('i').classList.contains('oi-minus')
? Math.min(Math.max(oldQuantity - 1, 0), maxQuantity)
: Math.min(oldQuantity + 1, maxQuantity);
if (oldQuantity !== newQuantity) {
input.value = newQuantity;
await this._changeQuantity(input);
}
}
/**
* @param {Event} ev
*/
async deleteProduct(ev) {
const input = ev.currentTarget.closest('.o_cart_product')
.querySelector('.css_quantity > input.js_quantity');
input.value = 0;
await this._changeQuantity(input);
}
async _changeQuantity(input) {
let quantity = parseInt(input.value || 0);
if (isNaN(quantity)) quantity = 1;
const lineId = parseInt(input.dataset.lineId);
const data = await this.waitFor(rpc('/shop/cart/update', {
line_id: lineId,
product_id: parseInt(input.dataset.productId),
quantity: quantity,
}));
if (!data.cart_quantity) {
// Ensure the last cart removal is recorded.
browser.sessionStorage.setItem('website_sale_cart_quantity', 0);
return redirect('/shop/cart');
}
input.value = data.quantity;
this.el.querySelectorAll(`.js_quantity[data-line-id="${lineId}"]`).forEach(input =>
input.value = data.quantity
);
const cart = this.el.closest('#shop_cart');
// `updateCartNavBar` regenerates the cart lines and `updateQuickReorderSidebar`
// regenerates the quick reorder products, so we need to stop and start interactions
// to make sure the regenerated cart lines and reorder products are properly handled.
this.services['public.interactions'].stopInteractions(cart);
wSaleUtils.updateCartNavBar(data);
wSaleUtils.updateQuickReorderSidebar(data);
this.services['public.interactions'].startInteractions(cart);
wSaleUtils.showWarning(data.warning);
// Propagate the change to the express checkout forms.
this.env.bus.trigger('cart_amount_changed', [data.amount, data.minor_amount]);
}
}
registry
.category('public.interactions')
.add('website_sale.cart_line', CartLine);

View file

@ -0,0 +1,28 @@
import { Interaction } from '@web/public/interaction';
import { registry } from '@web/core/registry';
export class CartSuggestion extends Interaction {
static selector = '[name="suggested_product"]';
dynamicContent = {
'button.js_add_suggested_products': { 't-on-click': this.addSuggestedProduct },
};
/**
* @param {Event} ev
*/
addSuggestedProduct(ev) {
const dataset = ev.currentTarget.dataset;
this.services['cart'].add({
productTemplateId: parseInt(dataset.productTemplateId),
productId: parseInt(dataset.productId),
isCombo: dataset.productType === 'combo',
}, {
isBuyNow: true,
showQuantity: Boolean(dataset.showQuantity),
});
}
}
registry
.category('public.interactions')
.add('website_sale.cart_suggestion', CartSuggestion);

View file

@ -0,0 +1,649 @@
import { Interaction } from '@web/public/interaction';
import { registry } from '@web/core/registry';
import { _t } from '@web/core/l10n/translation';
import { rpc } from '@web/core/network/rpc';
import {
LocationSelectorDialog
} from '@delivery/js/location_selector/location_selector_dialog/location_selector_dialog';
export class Checkout extends Interaction {
static selector = '#shop_checkout';
dynamicContent = {
// Addresses
'.card': { 't-on-click': this.changeAddress },
// Cancel the address change to allow the redirect to the edit page to take place.
'.js_edit_address': { 't-on-click.stop': () => {} },
'#use_delivery_as_billing': { 't-on-change': this.toggleBillingAddressRow },
// Delivery methods
'[name="o_delivery_radio"]': { 't-on-click': this.selectDeliveryMethod },
'[name="o_pickup_location_selector"]': { 't-on-click': this.selectPickupLocation },
};
setup() {
// There are two main buttons in the DOM (one for mobile and one for desktop).
// We need to get the one that's actually displayed.
this.mainButton = Array.from(document.getElementsByName('website_sale_main_button'))
.find(button => button.offsetParent !== null);
this.useDeliveryAsBillingToggle = document.querySelector('#use_delivery_as_billing');
this.billingContainer = this.el.querySelector('#billing_container');
this.addBillingAddressBtn = this.el.querySelector('.o_add_billing_address_btn');
}
async willStart() {
await this.waitFor(this._prepareDeliveryMethods());
}
async start() {
// Monitor when the page is restored from the bfcache.
const boundOnNavigationBack = this._onNavigationBack.bind(this);
window.addEventListener("pageshow", boundOnNavigationBack);
this.registerCleanup(() => window.removeEventListener("pageshow", boundOnNavigationBack));
}
/**
* Reload the page when the page is restored from the bfcache.
*
* @param {PageTransitionEvent} event - The pageshow event.
* @private
*/
_onNavigationBack(event) {
if (event.persisted) {
window.location.reload();
}
}
/**
* Set the billing or delivery address on the order and update the corresponding card.
*
* @param {Event} ev
* @return {void}
*/
async changeAddress(ev) {
const newAddress = ev.currentTarget;
if (newAddress.classList.contains('bg-400')) { // If the card is already selected.
return;
}
const addressType = newAddress.dataset.addressType;
// Remove the highlighting from the previously selected address card.
const previousAddress = this._getSelectedAddress(addressType);
this._tuneDownAddressCard(previousAddress);
// Highlight the newly selected address card.
this._highlightAddressCard(newAddress);
const selectedPartnerId = newAddress.dataset.partnerId;
await this.waitFor(this.updateAddress(addressType, selectedPartnerId));
// A delivery address is changed.
if (addressType === 'delivery' || this.billingContainer.dataset.deliveryAddressDisabled) {
if (this.billingContainer.dataset.deliveryAddressDisabled) {
// If a delivery address is disabled in the settings, use a billing address as
// a delivery one.
await this.waitFor(this.updateAddress('delivery', selectedPartnerId));
}
if (this.useDeliveryAsBillingToggle?.checked) {
await this.waitFor(this._selectMatchingBillingAddress(selectedPartnerId));
}
const deliveryFormHtml = await this.waitFor(rpc('/shop/delivery_methods'));
// The delivery methods are regenerated below, so we need to stop and start interactions
// to make sure the regenerated delivery methods are properly handled.
this.services['public.interactions'].stopInteractions(this.el);
// Update the available delivery methods.
document.getElementById('o_delivery_form').innerHTML = deliveryFormHtml;
this.services['public.interactions'].startInteractions(this.el);
await this.waitFor(this._prepareDeliveryMethods());
}
this._enableMainButton(); // Try to enable the main button.
}
/**
* Show/hide the billing address row when the user toggles the 'use delivery as billing' input.
*
* The URLs of the "create address" buttons are updated to propagate the value of the input.
*
* @param ev
* @return {void}
*/
async toggleBillingAddressRow(ev) {
const useDeliveryAsBilling = ev.target.checked;
const addDeliveryAddressButton = this.el.querySelector(
'.o_address_card_add_new[data-address-type="delivery"]'
);
if (addDeliveryAddressButton) { // If `Add address` button for delivery.
// Update the `use_delivery_as_billing` query param for a new delivery address URL.
const addDeliveryUrl = new URL(addDeliveryAddressButton.href);
addDeliveryUrl.searchParams.set(
'use_delivery_as_billing', encodeURIComponent(useDeliveryAsBilling)
);
addDeliveryAddressButton.href = addDeliveryUrl.toString();
}
// Toggle the billing address row.
if (useDeliveryAsBilling) {
this.billingContainer.classList.add('d-none'); // Hide the billing address row.
const selectedDeliveryAddress = this._getSelectedAddress('delivery');
await this.waitFor(
this._selectMatchingBillingAddress(selectedDeliveryAddress.dataset.partnerId)
);
} else {
this._disableMainButton();
this.billingContainer.classList.remove('d-none'); // Show the billing address row.
}
this.addBillingAddressBtn.classList.toggle('d-none', useDeliveryAsBilling);
this._enableMainButton(); // Try to enable the main button.
}
/**
* Fetch the delivery rate for the selected delivery method and update the displayed amounts.
*
* @param {Event} ev
* @return {void}
*/
async selectDeliveryMethod(ev) {
const checkedRadio = ev.currentTarget;
if (checkedRadio.disabled) { // The delivery rate request failed.
return; // Failing delivery methods cannot be selected.
}
// Disable the main button while fetching delivery rates.
this._disableMainButton();
// Hide and reset the order location name and address if defined.
this._hidePickupLocation();
// Fetch delivery rates and update the cart summary and the price badge accordingly.
await this.waitFor(this._updateDeliveryMethod(checkedRadio));
// Re-enable the main button after delivery rates have been fetched.
this._enableMainButton();
// Show a button to open the location selector if required for the selected delivery method.
await this._showPickupLocation(checkedRadio);
}
/**
* Fetch and display the closest pickup locations based on the zip code.
*
* @param {Event} ev
* @return {void}
*/
async selectPickupLocation(ev) {
const { zipCode, locationId } = ev.currentTarget.dataset;
const deliveryMethodContainer = this._getDeliveryMethodContainer(ev.currentTarget);
this.services.dialog.add(LocationSelectorDialog, {
zipCode: zipCode,
selectedLocationId: locationId,
isFrontend: true,
save: async location => {
const jsonLocation = JSON.stringify(location);
// Assign the selected pickup location to the order.
await this.waitFor(this._setPickupLocation(jsonLocation));
// Show and set the order location details.
this._updatePickupLocation(deliveryMethodContainer, location, jsonLocation);
this._enableMainButton();
},
});
}
// #=== DOM MANIPULATION ===#
/**
* Update the pickup location address elements and the 'edit' button's values.
*
* @private
* @param deliveryMethodContainer - The container element of the delivery method.
* @param location - The selected location as an object.
* @param jsonLocation - The selected location as an JSON string.
* @return {void}
*/
_updatePickupLocation(deliveryMethodContainer, location, jsonLocation) {
const pickupLocation = deliveryMethodContainer.querySelector('[name="o_pickup_location"]');
pickupLocation.querySelector('[name="o_pickup_location_name"]').innerText = location.name;
pickupLocation.querySelector(
'[name="o_pickup_location_address"]'
).innerText = `${location.street} ${location.zip_code} ${location.city}`;
const editPickupLocationButton = pickupLocation.querySelector(
'span[name="o_pickup_location_selector"]'
);
editPickupLocationButton.dataset.locationId = location.id;
editPickupLocationButton.dataset.zipCode = location.zip_code;
editPickupLocationButton.dataset.pickupLocationData = jsonLocation;
pickupLocation.querySelector(
'[name="o_pickup_location_details"]'
).classList.remove('d-none');
// Remove the button.
pickupLocation.querySelector('button[name="o_pickup_location_selector"]')?.remove();
}
/**
* Remove the highlighting from the address card.
*
* @private
* @param card - The card element of the selected address.
* @return {void}
*/
_tuneDownAddressCard(card) {
if (!card) return;
card.classList.remove('bg-400', 'border', 'border-primary');
}
/**
* Highlight the address card.
*
* @private
* @param card - The card element of the selected address.
* @return {void}
*/
_highlightAddressCard(card) {
if (!card) return;
card.classList.add('bg-400', 'border', 'border-primary');
}
/**
* Disable the main button.
*
* @private
* @return {void}
*/
_disableMainButton() {
this.mainButton?.classList.add('disabled');
}
/**
* Enable the main button if all conditions are satisfied.
*
* @private
* @return {void}
*/
_enableMainButton() {
if (this._canEnableMainButton()) {
this.mainButton?.classList.remove('disabled');
}
}
/**
* Return whether a delivery method and a billing address are selected.
*
* @private
* @return {boolean}
*/
_canEnableMainButton(){
return this._isDeliveryMethodReady() && this._isBillingAddressSelected();
}
/**
* Hide the pickup location.
*
* @private
* @return {void}
*/
_hidePickupLocation() {
const pickupLocations = document.querySelectorAll(
'[name="o_pickup_location"]:not(.d-none)'
);
pickupLocations.forEach(pickupLocation =>
pickupLocation.classList.add('d-none') // Hide the whole div.
);
}
/**
* Set the delivery method on the order and update the price badge and cart summary.
*
* @private
* @param {HTMLInputElement} radio - The radio button linked to the delivery method.
* @return {void}
*/
async _updateDeliveryMethod(radio) {
this._showLoadingBadge(radio);
const result = await this.waitFor(this._setDeliveryMethod(radio.dataset.dmId));
this._updateAmountBadge(radio, result);
this._updateCartSummaries(result);
}
/**
* Display a loading spinner on the delivery price badge.
*
* @private
* @param {HTMLInputElement} radio - The radio button linked to the delivery method.
* @return {void}
*/
_showLoadingBadge(radio) {
const deliveryPriceBadge = this._getDeliveryPriceBadge(radio);
this._clearElement(deliveryPriceBadge);
deliveryPriceBadge.appendChild(this._createLoadingElement());
}
/**
* Update the delivery price badge with the delivery rate.
*
* If the rate is zero, the price badge displays "Free" instead.
*
* @private
* @param {HTMLInputElement} radio - The radio button linked to the delivery method.
* @param {Object} rateData - The delivery rate data.
* @return {void}
*/
_updateAmountBadge(radio, rateData) {
const deliveryPriceBadge = this._getDeliveryPriceBadge(radio);
if (rateData.success) {
if (rateData.compute_price_after_delivery) {
// Inform the customer that the price will be computed after delivery.
deliveryPriceBadge.textContent = _t("Computed after delivery");
} else if (rateData.is_free_delivery) {
// If it's a free delivery (`free_over` field), show 'Free', not '$ 0'.
deliveryPriceBadge.textContent = _t("Free");
} else {
deliveryPriceBadge.innerHTML = rateData.amount_delivery;
}
this._toggleDeliveryMethodRadio(radio);
} else {
deliveryPriceBadge.textContent = rateData.error_message;
this._toggleDeliveryMethodRadio(radio, true);
}
}
/**
* Update the order summary table with the delivery rate of the selected delivery method.
*
* @private
* @param {Object} result - The order summary values.
* @param {Object} targetEl - Specific cart summary to update.
* @return {void}
*/
_updateCartSummary(result, targetEl) {
const amountDelivery = targetEl.querySelector(
'tr[name="o_order_delivery"] .monetary_field'
);
const amountUntaxed = targetEl.querySelector(
'tr[name="o_order_total_untaxed"] .monetary_field'
);
const amountTax = targetEl.querySelector('tr[name="o_order_total_taxes"] .monetary_field');
const amountTotal = targetEl.parentElement.querySelectorAll(
'tr[name="o_order_total"] .monetary_field, #amount_total_summary.monetary_field'
);
// When no dm is set and a price span is hidden, hide the message and show the price span.
if (amountDelivery.classList.contains('d-none')) {
amountDelivery.querySelector('span[name="o_message_no_dm_set"]')?.classList.add('d-none');
amountDelivery.classList.remove('d-none');
}
amountDelivery.innerHTML = result.amount_delivery;
amountUntaxed.innerHTML = result.amount_untaxed;
amountTax.innerHTML = result.amount_tax;
amountTotal.forEach(total => total.innerHTML = result.amount_total);
}
/**
* Update the order summary table with the delivery rate of the selected delivery method.
*
* @private
* @param {Object} result - The order summary values.
* @return {void}
*/
_updateCartSummaries(result) {
const parentElements = document.querySelectorAll(
'#o_cart_summary_offcanvas, div.o_total_card'
);
parentElements.forEach(el => this._updateCartSummary(result, el));
}
/**
* Enable or disable radio selection for a delivery method.
*
* @private
* @param {HTMLInputElement} radio - The radio button linked to the delivery method.
* @param {Boolean} disable - Whether the radio should be disabled.
*/
_toggleDeliveryMethodRadio(radio, disable=false) {
const deliveryPriceBadge = this._getDeliveryPriceBadge(radio);
radio.disabled = disable;
if (disable) {
deliveryPriceBadge.classList.add('text-muted');
}
else {
deliveryPriceBadge.classList.remove('text-muted');
}
}
/**
* Remove all children of the provided element from the DOM.
*
* @private
* @param {Element} el - The element to clear.
* @return {void}
*/
_clearElement(el) {
while (el.firstChild) {
el.removeChild(el.lastChild);
}
}
// #=== ADDRESS FLOW ===#
/**
* Select the billing address matching the currently selected delivery address.
*
* @private
* @param selectedPartnerId - The partner id of the selected delivery address.
* @return {void}
*/
async _selectMatchingBillingAddress(selectedPartnerId) {
const previousAddress = this._getSelectedAddress('billing');
this._tuneDownAddressCard(previousAddress);
await this.waitFor(this.updateAddress('billing', selectedPartnerId));
const billingAddress = this.el.querySelector(
`.card[data-partner-id="${selectedPartnerId}"][data-address-type="billing"]`
);
this._highlightAddressCard(billingAddress);
}
/**
* Set the billing or delivery address on the order.
*
* @param addressType - The type of the address to set: 'delivery' or 'billing'.
* @param partnerId - The partner id of the address to set.
* @return {void}
*/
async updateAddress(addressType, partnerId) {
await rpc('/shop/update_address', {address_type: addressType, partner_id: partnerId});
}
// #=== DELIVERY FLOW ===#
/**
* Change the delivery method to the one whose radio is selected and fetch all delivery rates.
*
* @private
* @return {void}
*/
async _prepareDeliveryMethods() {
// Load the radios from the DOM here to update them if the template is re-rendered.
this.dmRadios = Array.from(document.querySelectorAll('input[name="o_delivery_radio"]'));
if (this.dmRadios.length > 0) {
const checkedRadio = document.querySelector('input[name="o_delivery_radio"]:checked');
this._disableMainButton();
if (checkedRadio) {
await this.waitFor(this._updateDeliveryMethod(checkedRadio));
this._enableMainButton();
await this._showPickupLocation(checkedRadio);
}
}
// Asynchronously fetch delivery rates to mitigate delays from third-party APIs
await Promise.all(this.dmRadios.filter(radio => !radio.checked).map(async radio => {
this._showLoadingBadge((radio));
const rateData = await this.waitFor(this._getDeliveryRate(radio));
this._updateAmountBadge(radio, rateData);
}));
}
/**
* Check if the delivery method is selected and if the pickup point is selected if needed.
*
* @private
* @return {boolean} Whether the delivery method is ready.
*/
_isDeliveryMethodReady() {
if (this.dmRadios.length === 0) { // No delivery method is available.
return true; // Ignore the check.
}
const checkedRadio = document.querySelector('input[name="o_delivery_radio"]:checked');
return checkedRadio
&& !checkedRadio.disabled
&& !this._isPickupLocationMissing(checkedRadio);
}
/**
* Get the delivery rate of the delivery method linked to the provided radio.
*
* @private
* @param {HTMLInputElement} radio - The radio button linked to the delivery method.
* @return {Object} The delivery rate data.
*/
async _getDeliveryRate(radio) {
return await rpc('/shop/get_delivery_rate', {'dm_id': radio.dataset.dmId});
}
/**
* Set the delivery method on the order and return the result values.
*
* @private
* @param {Integer} dmId - The id of selected delivery method.
* @return {Object} The result values.
*/
async _setDeliveryMethod(dmId) {
return await rpc('/shop/set_delivery_method', {'dm_id': dmId});
}
/**
* Show the pickup location information or the button to open the location selector.
*
* @private
* @param {HTMLInputElement} radio - The radio button linked to the delivery method.
* @return {void}
*/
async _showPickupLocation(radio) {
if (!radio.dataset.isPickupLocationRequired || radio.disabled) {
return; // Fetching the delivery rate failed.
}
const deliveryMethodContainer = this._getDeliveryMethodContainer(radio);
const pickupLocation = deliveryMethodContainer.querySelector('[name="o_pickup_location"]');
const editPickupLocationButton = pickupLocation.querySelector(
'span[name="o_pickup_location_selector"]'
);
if (editPickupLocationButton.dataset.pickupLocationData) {
await this.waitFor(
this._setPickupLocation(editPickupLocationButton.dataset.pickupLocationData)
);
}
pickupLocation.classList.remove('d-none'); // Show the whole div.
}
/**
* Set the pickup location on the order.
*
* @private
* @param {String} pickupLocationData - The pickup location's data to set.
* @return {void}
*/
async _setPickupLocation(pickupLocationData) {
await rpc('/website_sale/set_pickup_location', {pickup_location_data: pickupLocationData});
}
// #=== GETTERS & SETTERS ===#
/** Determine and return the selected address who card has the class rowAddrClass.
*
* @private
* @param addressType - The type of the address: 'billing' or 'delivery'.
* @return {Element}
*/
_getSelectedAddress(addressType) {
return this.el.querySelector(`.card.bg-400[data-address-type="${addressType}"]`);
}
/**
* Return whether the "use delivery as billing" toggle is checked or a billing address is
* selected.
*
* @private
* @return {boolean} - Whether a billing address is selected.
*/
_isBillingAddressSelected() {
const billingAddressSelected = Boolean(
this.el.querySelector('.card.bg-400[data-address-type="billing"]')
);
return billingAddressSelected || this.useDeliveryAsBillingToggle?.checked;
}
/**
* Create and return an element representing a loading spinner.
*
* @private
* @return {Element} The created element.
*/
_createLoadingElement() {
const loadingElement = document.createElement('i');
loadingElement.classList.add('fa', 'fa-circle-o-notch', 'fa-spin', 'center');
return loadingElement;
}
/**
* Return the delivery price badge element of the delivery method linked to the provided radio.
*
* @private
* @param {HTMLInputElement} radio - The radio button linked to the delivery method.
* @return {Element} The delivery price badge element of the linked delivery method.
*/
_getDeliveryPriceBadge(radio) {
const deliveryMethodContainer = this._getDeliveryMethodContainer(radio);
return deliveryMethodContainer.querySelector('.o_wsale_delivery_price_badge');
}
/**
* Return the container element of the delivery method linked to the provided element.
*
* @private
* @param {Element} el - The element linked to the delivery method.
* @return {Element} The container element of the linked delivery method.
*/
_getDeliveryMethodContainer(el) {
return el.closest('[name="o_delivery_method"]');
}
/**
* Return whether a pickup location is required but not selected.
*
* @private
* @param {HTMLInputElement} radio - The radio button linked to the delivery method.
* @return {boolean} Whether a required pickup location is missing.
*/
_isPickupLocationMissing(radio) {
const deliveryMethodContainer = this._getDeliveryMethodContainer(radio);
if (!this._isPickupLocationRequired(radio)) return false;
return !deliveryMethodContainer.querySelector(
'span[name="o_pickup_location_selector"]'
).dataset.locationId;
}
/**
* Return whether a pickup is required for the delivery method linked to the provided radio.
*
* @private
* @param {HTMLInputElement} radio - The radio button linked to the delivery method.
* @return {bool} Whether a pickup is needed.
*/
_isPickupLocationRequired(radio) {
return Boolean(radio.dataset.isPickupLocationRequired);
}
}
registry
.category('public.interactions')
.add('website_sale.checkout', Checkout);

View file

@ -0,0 +1,21 @@
import { patch } from '@web/core/utils/patch';
import { patchDynamicContent } from '@web/public/utils';
import { Form } from '@website/snippets/s_website_form/form';
patch(Form.prototype, {
setup() {
super.setup();
// Only tie checkout-specific forms (with data-force_action="shop.sale.order") to the
// cart summary button. Other forms (e.g., custom form snippets added by users) should
// only respond to their own submit buttons, not block checkout progression.
if (this.el.dataset.force_action === 'shop.sale.order') {
this.dynamicSelectors = {
...this.dynamicSelectors,
_submitbuttons: () => document.querySelectorAll('[name="website_sale_main_button"]'),
};
patchDynamicContent(this.dynamicContent, {
_submitbuttons: { 't-on-click.prevent.stop': this.locked(this.send.bind(this), true) },
});
}
},
});

View file

@ -0,0 +1,32 @@
import { Interaction } from '@web/public/interaction';
import { registry } from '@web/core/registry';
export class OffCanvas extends Interaction {
static selector = '#o_wsale_offcanvas';
dynamicContent = {
_root: {
't-on-show.bs.offcanvas': this.toggleFilters,
't-on-hidden.bs.offcanvas': this.toggleFilters,
},
};
/**
* Unfold active filters, fold inactive ones
*
* @param {Event} ev
*/
toggleFilters(ev) {
for (const btn of this.el.querySelectorAll('button[data-status]')) {
if (
btn.classList.contains('collapsed') && btn.dataset.status === 'active'
|| !btn.classList.contains('collapsed') && btn.dataset.status === 'inactive'
) {
btn.click();
}
}
}
}
registry
.category('public.interactions')
.add('website_sale.off_canvas', OffCanvas);

View file

@ -0,0 +1,38 @@
import { patch } from '@web/core/utils/patch';
import { PaymentButton } from '@payment/interactions/payment_button';
patch(PaymentButton.prototype, {
/**
* Verify that the payment button is ready to be enabled.
*
* The conditions are that:
* - a delivery carrier is selected and ready (the price is computed) if deliveries are enabled;
* - the "Terms and Conditions" checkbox is ticked if it is present.
*
* @override method from @payment/interactions/payment_button
* @return {boolean}
*/
_canSubmit() {
return super._canSubmit() && this._isTcCheckboxReady();
},
/**
* Check if the "Terms and Conditions" checkbox is ticked, if present.
*
* @private
* @return {boolean}
*/
_isTcCheckboxReady() {
// Find the one T&C checkbox that is not hidden, either the desktop or the mobile one.
const checkboxes = document.querySelectorAll('#website_sale_tc_checkbox');
const visibleCheckbox = Array.from(checkboxes).find(el => el.offsetParent !== null);
if (!visibleCheckbox) { // The checkbox is not present.
return true; // Ignore the check.
}
return visibleCheckbox.checked;
},
});

View file

@ -0,0 +1,25 @@
import { patch } from '@web/core/utils/patch';
import { PaymentForm } from '@payment/interactions/payment_form';
patch(PaymentForm.prototype, {
/**
* Create an event listener for the payment submit buttons located outside the payment form.
*
* Buttons that are inside the payment form are ignored as they are already handled by the
* payment form.
*
* @override
*/
setup() {
super.setup();
const submitButtons = document.querySelectorAll('button[name="o_payment_submit_button"]');
submitButtons.forEach(submitButton => {
if (!this.el.contains(submitButton)) { // The button is outside the payment form.
submitButton.addEventListener('click', ev => this.submitForm(ev));
}
});
}
});

View file

@ -0,0 +1,14 @@
import { patch } from "@web/core/utils/patch";
import { Popup } from "@website/interactions/popup/popup";
patch(Popup.prototype, {
/**
* @override
*/
canBtnPrimaryClosePopup(primaryBtnEl) {
return (
super.canBtnPrimaryClosePopup(...arguments)
&& !primaryBtnEl.classList.contains("js_add_cart")
);
},
});

View file

@ -0,0 +1,34 @@
import { Interaction } from '@web/public/interaction';
import { redirect } from '@web/core/utils/urls';
import { registry } from '@web/core/registry';
export class PriceRange extends Interaction {
static selector = '#o_wsale_price_range_option';
dynamicContent = {
'input[type="range"]': { 't-on-newRangeValue': this.onPriceRangeSelected },
};
/**
* @param {Event} ev
*/
onPriceRangeSelected(ev) {
const range = ev.currentTarget;
const url = new URL(range.dataset.url, window.location.origin);
const searchParams = url.searchParams;
if (parseFloat(range.min) !== range.valueLow) {
searchParams.set("min_price", range.valueLow);
}
if (parseFloat(range.max) !== range.valueHigh) {
searchParams.set("max_price", range.valueHigh);
}
const product_list_div = document.querySelector('.o_wsale_products_grid_table_wrapper');
if (product_list_div) {
product_list_div.classList.add('opacity-50');
}
redirect(`${url.pathname}?${searchParams.toString()}`);
}
}
registry
.category('public.interactions')
.add('website_sale.price_range', PriceRange);

View file

@ -0,0 +1,159 @@
/** @odoo-module **/
import { Interaction } from "@web/public/interaction";
import { registry } from "@web/core/registry";
/**
* Interaction that sets the height of images as a CSS custom property
* on the product grid element. Used for responsive product grid layouts on mobile devices.
*/
export class ProductGridLayout extends Interaction {
static selector = "#o-grid-product";
dynamicContent = {
_window: {
"t-on-resize": this.debounced(this.onResize, 100),
},
_root: {
"t-att-class": () => ({
"o_grid_product_ready": this.isGridReady,
}),
"t-att-style": () => ({
"--o-wsale-js-grid-product-height": this.gridHeight || "auto",
}),
},
};
setup() {
this.gridHeight = null;
this.maxHeight = 0;
this.isGridReady = false;
this.loadedImages = new Set();
this.imagesEls = this.el.querySelectorAll('.product_detail_img');
this.isAutoRatioMode = this.el.classList.contains('o_grid_uses_ratio_auto') &&
this.el.classList.contains('o_grid_uses_ratio_mobile_auto');
}
start() {
if (this.imagesEls.length === 0) {
return;
}
if (this.imagesEls.length === 1 || !this.isAutoRatioMode) {
this.handleStandardMode();
} else {
// Multiple images in auto ratio mode: use tallest
this.handleAutoRatioMode();
}
this.updateContent();
}
/**
* Handle standard mode - use first image height
*/
handleStandardMode() {
const firstImage = this.imagesEls[0];
if (firstImage.complete && firstImage.naturalHeight !== 0) {
this.calculateImageHeight();
} else {
this.addListener(firstImage, 'load', this.calculateImageHeight);
}
}
/**
* Calculate and store the image height (standard mode)
*/
calculateImageHeight() {
const firstImage = this.imagesEls[0];
if (!firstImage) return;
const height = firstImage.offsetHeight;
this.isGridReady = Boolean(height);
this.gridHeight = height ? `${height}px` : null;
}
/**
* Handle auto ratio mode - wait for all images and use tallest
*/
handleAutoRatioMode() {
// Set 5-second timeout
const timeoutId = this.waitForTimeout(() => {
this.finalizeAutoRatioCalculation();
}, 5000);
this.imagesEls.forEach(imgEl => {
if (imgEl.complete && imgEl.naturalHeight !== 0) {
this.processLoadedImage(imgEl);
} else {
this.addListener(imgEl, 'load', () => {
this.processLoadedImage(imgEl);
// If all images are loaded, finalize early
if (this.loadedImages.size === this.imagesEls.length) {
clearTimeout(timeoutId);
this.finalizeAutoRatioCalculation();
}
});
}
});
// If all images were already loaded, finalize immediately
if (this.loadedImages.size === this.imagesEls.length) {
clearTimeout(timeoutId);
this.finalizeAutoRatioCalculation();
}
}
/**
* Process a loaded image and track its height
*/
processLoadedImage(imgEl) {
this.loadedImages.add(imgEl);
const height = imgEl.offsetHeight;
if (height > this.maxHeight) {
this.maxHeight = height;
}
}
/**
* Finalize calculation for auto ratio mode
*/
finalizeAutoRatioCalculation() {
this.isGridReady = true;
this.gridHeight = this.maxHeight ? `${this.maxHeight}px` : null;
}
/**
* On page resize, recalculate the image height (mobile only)
*/
onResize() {
if (!this.env.isSmall) {
return;
}
if (this.isAutoRatioMode) {
// Recalculate max height from all loaded images
this.maxHeight = 0;
this.loadedImages.forEach(imgEl => {
const height = imgEl.offsetHeight;
if (height > this.maxHeight) {
this.maxHeight = height;
}
});
this.gridHeight = this.maxHeight ? `${this.maxHeight}px` : null;
} else {
this.calculateImageHeight();
}
}
}
registry
.category("public.interactions")
.add("website.website_sale_product_grid_layout", ProductGridLayout);
registry
.category("public.interactions.edit")
.add("website.website_sale_product_grid_layout", { Interaction: ProductGridLayout });

View file

@ -0,0 +1,180 @@
import { Interaction } from '@web/public/interaction';
import { registry } from '@web/core/registry';
export class ProductVariantPreview extends Interaction {
static selector = "#o_wsale_products_grid";
dynamicContent = {
_window: {
"t-on-resize": this.debounced(this.updateVariantPreview, 250),
},
};
setup() {
// Class `gap-1` on parent adds 4px margin for each ptav.
this.margin = 4;
this.updateVariantPreview();
}
/**
* Hide all attribute values from view to be able to recompute correctly how many elements are
* to be shown.
*
* @private
*
* @returns {void}
*/
_resetDisplay(attributePreviewer) {
for (const child of attributePreviewer.children) {
child.classList.add('d-none');
}
}
/**
* Update the count of hidden PTAVs with the correct number and make it visible.
*
* @private
* @param {Element} currentPTAV
* @param {Number} remainingSpace
*
* @returns {void}
*/
_showHiddenPTAVsElement(
attributePreviewerValues, currentPTAV, remainingSpace, displayedPTAVCount
) {
const {
ptavCount,
offsetWidthPTAVS,
hiddenCountSpan,
hiddenCountSpanWidth,
} = attributePreviewerValues;
while (currentPTAV && hiddenCountSpanWidth >= remainingSpace) {
currentPTAV.classList.add("d-none");
displayedPTAVCount--;
remainingSpace += offsetWidthPTAVS.get(currentPTAV);
currentPTAV = currentPTAV.previousElementSibling;
}
const hiddenPTAVCount = ptavCount - displayedPTAVCount;
hiddenCountSpan.firstElementChild.textContent = `+${hiddenPTAVCount}`;
hiddenCountSpan.classList.remove("d-none");
}
/**
* For each ptav check if there is enough space to add on the parent element and update the
* hidden PTAVs count accordingly, with the truncated elements from the backend.
*
* @private
*
* @returns {void}
*/
_updateVariantPreview(attributePreviewer, attributePreviewerValues) {
const { containerWidth, ptavs, ptavCount, offsetWidthPTAVS } = attributePreviewerValues;
this._resetDisplay(attributePreviewer);
let usedWidth = 0;
let displayedPTAVCount = 0;
for (const ptav of ptavs) {
ptav.classList.remove('d-none');
usedWidth += offsetWidthPTAVS.get(ptav) + this.margin;
displayedPTAVCount++;
const remainingSpace = containerWidth - usedWidth;
const isLastPTAV = ptav === ptavs[ptavs.length - 1];
const hasHiddenPtavs = isLastPTAV && ptavCount > displayedPTAVCount;
if (usedWidth >= containerWidth || hasHiddenPtavs) {
this._showHiddenPTAVsElement(
attributePreviewerValues, ptav, remainingSpace, displayedPTAVCount,
);
break;
}
}
}
/**
* Triggered on the parent element of the '.o_wsale_attribute_previewer' elements to run the
* interaction once instead of multiple times depending on how many elements or products exist
* on the page.
*
* Schedules and batches updates for all active '.o_wsale_attribute_previewer' elements
* to refresh their variant previews efficiently.
*
* Uses `requestAnimationFrame` to ensure that updates occur in sync with the browsers
* rendering cycle, preventing redundant or frequent recalculations (trigger by offsetWidth).
*/
updateVariantPreview() {
const attributePreviewers = this.el.querySelectorAll(".o_wsale_attribute_previewer");
const updateAllVariantPreview = this.protectSyncAfterAsync(() => {
const attributePreviewerValues = new Map();
// ---- Phase 1: Initiate the values needed for each attribute previewer ---------------
// Split into two sub-loops to avoid a forced reflow per product:
// ---- Phase 1a: all DOM writes (resetDisplay, textContent, classList) ----------------
for (const attributePreviewer of attributePreviewers) {
this._resetDisplay(attributePreviewer);
const ptavs = attributePreviewer.querySelectorAll(".o_product_variant_preview");
// Set the hiddenCountSpan to the maximum number of ptavs there is to assume
// the worst case space it needs.
const hiddenCountSpan = attributePreviewer.querySelector(
"span[name='hidden_ptavs_count']");
const ptavCount = ptavs.length + Number(
attributePreviewer.dataset.hiddenPtavCount ?? 0);
hiddenCountSpan.firstElementChild.textContent = `+${ptavCount}`;
hiddenCountSpan.classList.remove("d-none");
attributePreviewerValues.set(
attributePreviewer,
{
ptavs,
hiddenCountSpan,
ptavCount,
offsetWidthPTAVS: new Map(),
hiddenCountSpanWidth: 0,
},
);
}
// ---- Phase 1b: all reads (single reflow for all products) ----------------
// All writes above are now complete so offsetWidth flushes layout only once,
// regardless of how many products are on the page.
for (const attributePreviewer of attributePreviewers) {
const currentValues = attributePreviewerValues.get(attributePreviewer)
currentValues.containerWidth = attributePreviewer.offsetWidth;
}
// ---- Phase 2: Display all PTAVs to get the correct width (pure writes) --------------
for (const attributePreviewer of attributePreviewers) {
const currentValues = attributePreviewerValues.get(attributePreviewer);
for (const ptav of currentValues.ptavs) {
ptav.classList.remove("d-none");
}
}
// ---- Phase 3: bulk offsetWidth reads ------------------------------------------------
// A recalculation of the styles is triggered every time offsetWidth is called.
// Get all offsetWidths in one step to avoid recalculation for each element separately.
for (const attributePreviewer of attributePreviewers) {
const currentValues = attributePreviewerValues.get(attributePreviewer);
for (const ptav of currentValues.ptavs) {
currentValues.offsetWidthPTAVS.set(ptav, ptav.offsetWidth);
}
currentValues.hiddenCountSpanWidth = (
currentValues.hiddenCountSpan.offsetWidth + this.margin * 2
);
}
// ---- Phase 4: apply display logic (pure writes) -------------------------------------
for (const attributePreviewer of attributePreviewers) {
this._updateVariantPreview(
attributePreviewer, attributePreviewerValues.get(attributePreviewer)
);
}
});
requestAnimationFrame(updateAllVariantPreview);
}
}
registry
.category('public.interactions')
.add('website_sale.product_variant_preview', ProductVariantPreview);
registry
.category("public.interactions.edit")
.add("website.product_variant_preview", { Interaction: ProductVariantPreview });

View file

@ -0,0 +1,84 @@
import { Interaction } from '@web/public/interaction';
import { registry } from '@web/core/registry';
export class ProductVariantPreviewImageHover extends Interaction {
static selector = '.oe_product_cart.o_has_variations';
dynamicContent = {
'.o_product_variant_preview': {
't-on-mouseenter': this._mouseEnter,
't-on-mouseleave': this._mouseLeave,
't-on-click': this._onClick,
},
};
setup() {
this.productImg = this.el.querySelector('.oe_product_image_img_wrapper_primary img');
this.originalImgSrc = this.productImg.getAttribute('src');
}
/**
* Display the variant image on hover.
*
* @private
* @param {Event} ev
*
* @returns {void}
*/
_mouseEnter(ev) {
if (!this.env.isSmall) {
const variantImageSrc = ev.target.dataset.variantImage;
if (!variantImageSrc) {
return;
}
this._setImgSrc(variantImageSrc);
}
}
/**
* Reset the product image when mouse no longer hovers on the ptav.
*
* @private
*
* @returns {void}
*/
_mouseLeave() {
if (!this.env.isSmall) {
this._setImgSrc(this.originalImgSrc);
}
}
/**
* Set the image source of the product to the given image source
*
* @param {string} imageSrc
*/
_setImgSrc(imageSrc) {
this.productImg.src = imageSrc;
}
/**
* On mobile, when ptav is clicked simulate on hover behavior and change product image
* to variant image.
* The href of product card is changed to match that of the selected variant.
*
* @param {Event} ev
* @returns
*/
_onClick(ev) {
if (this.env.isSmall) {
ev.preventDefault();
const targetElement = ev.target.closest('.o_product_variant_preview');
const productCard = ev.target.closest('.oe_product_cart');
productCard.querySelector('.oe_product_image_link').href = targetElement.href;
const variantImageSrc = targetElement.dataset.variantImage;
if (!variantImageSrc) {
return;
}
this._setImgSrc(variantImageSrc);
}
}
}
registry
.category('public.interactions')
.add('website_sale.product_variant_preview_image_hover', ProductVariantPreviewImageHover);

View file

@ -0,0 +1,28 @@
import { Interaction } from '@web/public/interaction';
import { registry } from '@web/core/registry';
export class ProductAccordion extends Interaction {
static selector = '#product_accordion';
setup() {
this._updateAccordionActiveItem();
}
/**
* Open the first accordion item by default.
*/
_updateAccordionActiveItem() {
const firstAccordionItemEl = this.el.querySelector('.accordion-item');
if (!firstAccordionItemEl) return;
const firstAccordionItemButtonEl = firstAccordionItemEl.querySelector('.accordion-button');
firstAccordionItemButtonEl.classList.remove('collapsed');
firstAccordionItemButtonEl.setAttribute('aria-expanded', 'true');
firstAccordionItemEl.querySelector('.accordion-collapse').classList.add('show');
this.el.classList.remove('o_accordion_not_initialized');
}
}
registry
.category('public.interactions')
.add('website_sale.product_accordion', ProductAccordion);

View file

@ -0,0 +1,42 @@
import { Interaction } from '@web/public/interaction';
import { registry } from '@web/core/registry';
export class ProductStickyCol extends Interaction {
static selector = '.o_wsale_product_sticky_col';
dynamicContent = {
_root: {
't-att-style': () => ({
'opacity': '1',
'top': `${this.position || 16}px`,
}),
}
};
setup() {
this.position = 16;
}
start() {
this._adaptToHeaderChange();
this.registerCleanup(
this.services.website_menus.registerCallback(this._adaptToHeaderChange.bind(this))
);
}
_adaptToHeaderChange() {
let position = 16; // Add 1rem equivalent in px to provide a visual gap by default
for (const el of document.querySelectorAll('.o_top_fixed_element')) {
position += el.offsetHeight;
}
if (this.position !== position) {
this.position = position;
this.updateContent();
}
}
}
registry
.category('public.interactions')
.add('website_sale.product_sticky_col', ProductStickyCol);

View file

@ -0,0 +1,27 @@
import { Interaction } from '@web/public/interaction';
import { registry } from '@web/core/registry';
export class ProductTileSecondaryImage extends Interaction {
static selector = '.oe_product_image_link_has_secondary';
dynamicContent = {
_root: {
"t-att-class": () => ({ "o_product_tile_scrolled": this.isSecondImgInView }),
"t-on-scroll": (ev) => this.onScroll(ev),
}
};
setup() {
this.isSecondImgInView = false;
}
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
onScroll(ev) {
this.isSecondImgInView = ev.target.scrollLeft > ev.target.scrollWidth * 0.25;
}
}
registry
.category("public.interactions")
.add("website.website_sale_product_tile_secondary_image", ProductTileSecondaryImage);

View file

@ -0,0 +1,177 @@
import { ProductCombo } from '@sale/js/models/product_combo';
import { serializeComboItem } from '@sale/js/sale_utils';
import { serializeDateTime } from '@web/core/l10n/dates';
import { rpc } from '@web/core/network/rpc';
import { registry } from '@web/core/registry';
import { Interaction } from '@web/public/interaction';
import wSaleUtils from '@website_sale/js/website_sale_utils';
export class QuickReorder extends Interaction {
static selector = '#quick_reorder_sidebar';
dynamicContent = {
'.o_wsale_quick_reorder_qty_input': {
't-on-input': this.updateQuantityAndPrice,
't-on-keydown': this.triggerReorderOnEnter,
},
'.o_wsale_quick_reorder_product_button': { 't-on-click': this.reorderProduct },
};
/**
* Update the total price and enable/disable the add button based on the quantity input.
*
* @param {Event} ev
* @return {void}
*/
async updateQuantityAndPrice(ev) {
const qtyInput = ev.currentTarget;
const priceUnit = parseFloat(qtyInput.dataset.priceUnit);
const digits = parseInt(qtyInput.dataset.currencyDigits, 10);
const qty = parseInt(qtyInput.value, 10) || 0;
this._updateAddButton(qtyInput, qty);
if (qty > 0) {
this._updateTotalPrice(qtyInput, qty, priceUnit, digits);
}
}
/**
* Update the add button state based on quantity.
*
* @private
* @param {Element} qtyInput - The quantity input element.
* @param {number} qty - The quantity value.
* @return {void}
*/
_updateAddButton(qtyInput, qty) {
const addButton = qtyInput.closest('.o_wsale_quick_reorder_line').querySelector(
'.o_wsale_quick_reorder_product_button'
);
const isDisabled = qty <= 0;
addButton.classList.toggle('disabled', isDisabled);
if (qty > 0) {
addButton.dataset.quantity = String(qty);
}
}
/**
* Update the total price display for the related line.
*
* @private
* @param {Element} qtyInput - The quantity input element.
* @param {number} qty - The quantity.
* @param {number} priceUnit - The unit price.
* @param {number} digits - The number of decimal digits for the currency.
* @return {void}
*/
_updateTotalPrice(qtyInput, qty, priceUnit, digits) {
const priceEl = qtyInput.closest('.o_wsale_quick_reorder_line').querySelector(
'.o_wsale_quick_reorder_product_price .oe_currency_value'
);
if (priceEl) {
const totalPrice = (qty * priceUnit).toFixed(digits);
priceEl.textContent = totalPrice;
}
}
/**
* Trigger the reorder action when Enter key is pressed on quantity input.
*
* @param {Event} ev
* @return {void}
*/
async triggerReorderOnEnter(ev) {
if (ev.key !== 'Enter') return;
const addButton = ev.currentTarget.closest('.o_wsale_quick_reorder_line').querySelector(
'.o_wsale_quick_reorder_product_button'
);
if (addButton && !addButton.classList.contains('disabled')) {
addButton.click();
}
}
/**
* Reorder the product and update the page's content.
*
* @param {Event} ev
* @return {void}
*/
async reorderProduct(ev) {
// Extract product data from the button dataset.
const addButtonDataset = ev.currentTarget.dataset;
const productTemplateId = parseInt(addButtonDataset.productTemplateId, 10);
const productId = parseInt(addButtonDataset.productId, 10);
let quantity = parseInt(addButtonDataset.quantity);
const isCombo = addButtonDataset.productType === 'combo';
const selectedComboItems = JSON.parse(addButtonDataset.selectedComboItems || '[]');
// Capture the button index before DOM updates.
const allButtons = document.querySelectorAll('.o_wsale_quick_reorder_product_button');
const currentButtonIndex = Array.from(allButtons).indexOf(ev.currentTarget);
// Process combo products if applicable.
let linkedProducts = [];
if (isCombo) {
const { quantity: updatedQty, combos } = await rpc(
'/website_sale/combo_configurator/get_data',
{
product_tmpl_id: productTemplateId,
quantity: quantity,
date: serializeDateTime(luxon.DateTime.now()),
selected_combo_items: selectedComboItems,
}
);
quantity = updatedQty;
linkedProducts = combos
.map(combo => new ProductCombo(combo).selectedComboItem)
.filter(Boolean)
.map(comboItem => ({
product_template_id: comboItem.product.product_tmpl_id,
parent_product_template_id: productTemplateId,
quantity: quantity,
...serializeComboItem(comboItem),
}));
}
const data = await this.waitFor(rpc('/shop/cart/quick_add', {
product_template_id: productTemplateId,
product_id: productId,
quantity: quantity,
...(isCombo && { linked_products: linkedProducts }),
}));
// Add the product to the cart and update the DOM.
const cart = document.getElementById('shop_cart');
// `updateCartNavBar` regenerates the cart lines and `updateQuickReorderSidebar`
// regenerates the quick reorder products, so we need to stop and start interactions to
// make sure the regenerated reorder products and cart lines are properly handled.
this.services['public.interactions'].stopInteractions(cart);
wSaleUtils.updateCartNavBar(data);
wSaleUtils.updateQuickReorderSidebar(data);
this.services['public.interactions'].startInteractions(cart);
// Move the focus to the next quantity input.
this._focusNextQuantityInput(currentButtonIndex);
}
/**
* Moves the focus to the next quantity input.
*
* @param {HTMLElement} buttonIndex - The index of the reorder button that was clicked before
* DOM updates.
* @return {void}
*/
_focusNextQuantityInput(buttonIndex) {
const allQuantityInputs = document.querySelectorAll('.o_wsale_quick_reorder_qty_input');
const nextInput = allQuantityInputs[buttonIndex];
if (nextInput) {
nextInput.focus();
nextInput.select();
}
}
}
registry
.category('public.interactions')
.add('website_sale.quick_reorder', QuickReorder);

View file

@ -0,0 +1,41 @@
import { cookie } from '@web/core/browser/cookie';
import { rpc } from '@web/core/network/rpc';
import { Interaction } from '@web/public/interaction';
import { registry } from '@web/core/registry';
export class RecentlyViewedProducts extends Interaction {
static selector = '.o_wsale_product_page';
dynamicContent = {
'input.product_id[name="product_id"]': {
't-on-change.withTarget': this.debounced(this.onProductChange, 500),
},
};
/**
* Mark the product as viewed.
*
* @param {Event} ev
* @param {HTMLElement} currentTargetEl
*/
async onProductChange(ev, currentTargetEl) {
if (!parseInt(this.el.querySelector('#product_detail').dataset.viewTrack)) {
return; // Product not tracked.
}
const productId = parseInt(currentTargetEl.value);
const cookieName = 'seen_product_id_' + productId;
if (cookie.get(cookieName)) {
return; // Product already tracked in the last 30 min.
}
if (this.el.querySelector('.js_product.css_not_available')) {
return; // Product not available.
}
await this.waitFor(rpc('/shop/products/recently_viewed_update', {
product_id: productId,
}));
cookie.set(cookieName, productId, 30 * 60, 'optional');
}
}
registry
.category('public.interactions')
.add('website_sale.recently_viewed_products', RecentlyViewedProducts);

View file

@ -0,0 +1,63 @@
import { browser } from '@web/core/browser/browser';
import { rpc } from '@web/core/network/rpc';
import { registry } from '@web/core/registry';
import { redirect } from '@web/core/utils/urls';
import { Interaction } from '@web/public/interaction';
export class SaleOrderPortalReorder extends Interaction {
static selector = '#sale_order_sidebar_button';
dynamicContent = {
'button#reorder_sidebar_button': { 't-on-click': this.onReorder },
};
/**
* Handles the reorder functionality when the reorder button is clicked.
* Does the reorder by calling the `/my/orders/reorder` endpoint with the order ID and
* access token.
*
* @param {Event} ev - The event triggered when the reorder button is clicked.
*/
async onReorder(ev) {
this.orderId = parseInt(ev.currentTarget.dataset.saleOrderId);
this.accessToken = new URLSearchParams(window.location.search).get('access_token');
if (!this.orderId) return;
await this._doReorder();
}
async _doReorder() {
try {
const values = await this.waitFor(rpc('/my/orders/reorder', {
order_id: this.orderId,
access_token: this.accessToken,
}));
// Sync cart quantity in session storage when adding reorder products from backend,
// since `website_sale_cart_quantity` updates only via the cart service.
browser.sessionStorage.setItem('website_sale_cart_quantity', values.cart_quantity);
this._trackProducts(values.tracking_info);
redirect('/shop/cart');
} catch (error) {
console.error("Error during reordering:", error);
}
}
/**
* Track the products added to the cart.
*
* @private
* @param {Object[]} trackingInfo - A list of product tracking information.
*
* @returns {void}
*/
_trackProducts(trackingInfo) {
document.querySelector('.oe_website_sale').dispatchEvent(
new CustomEvent('add_to_cart_event', {'detail': trackingInfo})
);
}
}
registry
.category('public.interactions')
.add('website_sale.portal_reorder', SaleOrderPortalReorder);

View file

@ -0,0 +1,16 @@
import { Interaction } from '@web/public/interaction';
import { registry } from '@web/core/registry';
export class SearchModal extends Interaction {
static selector = '#o_wsale_search_modal';
start() {
this.el.addEventListener('shown.bs.modal', (ev) =>
ev.target.querySelector('.oe_search_box').focus()
);
}
}
registry
.category('public.interactions')
.add('website_sale.search_modal', SearchModal);

View file

@ -0,0 +1,51 @@
import { Interaction } from "@web/public/interaction";
import { registry } from "@web/core/registry";
export class WebsiteSaleStickyObject extends Interaction {
static selector = ".o_wsale_sticky_object";
dynamicContent = {
_root: {
"t-att-style": () => ({
"top": `${this.position || 16}px`,
}),
}
};
setup() {
this.position = 16;
}
start() {
this._adaptToHeaderChange();
this.registerCleanup(this.services.website_menus.registerCallback(this._adaptToHeaderChange.bind(this)));
}
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
*/
_adaptToHeaderChange() {
let position = 16; // Add 1rem equivalent in px to provide a visual gap by default
for (const el of this.el.ownerDocument.querySelectorAll(".o_top_fixed_element")) {
position += el.offsetHeight;
}
if (this.position !== position) {
this.position = position;
this.updateContent();
}
}
}
registry
.category("public.interactions")
.add("website.website_sale_product_sticky_col", WebsiteSaleStickyObject);
registry
.category("public.interactions.edit")
.add("website.website_sale_product_sticky_col", { Interaction: WebsiteSaleStickyObject});

View file

@ -0,0 +1,29 @@
import { Interaction } from '@web/public/interaction';
import { registry } from '@web/core/registry';
export class TermsAndConditionsCheckbox extends Interaction {
static selector = 'div[name="website_sale_terms_and_conditions_checkbox"]';
dynamicContent = {
'#website_sale_tc_checkbox': { 't-on-change': this.onClickTcCheckbox },
};
setup() {
this.checkbox = this.el.querySelector('#website_sale_tc_checkbox');
}
/**
* Enable/disable the payment button when the "Terms and Conditions" checkbox is
* checked/unchecked.
*
* @return {void}
*/
onClickTcCheckbox() {
this.env.bus.trigger(
this.checkbox.checked ? 'enablePaymentButton' : 'disablePaymentButton'
);
}
}
registry
.category('public.interactions')
.add('website_sale.terms_and_conditions_checkbox', TermsAndConditionsCheckbox);

View file

@ -0,0 +1,99 @@
import { Interaction } from '@web/public/interaction';
import { registry } from '@web/core/registry';
export class Tracking extends Interaction {
static selector = '.oe_website_sale';
dynamicContent = {
'form a.a-submit': { 't-on-click': this.onAddProductToCart },
'a[href^="/shop/checkout"]': { 't-on-click': this.onCheckoutStart },
'a[href^="/web/login?redirect"][href*="/shop/checkout"]': {
't-on-click': this.onCustomerSignin,
},
'a[href="/shop/payment"]': { 't-on-click': this.onOrder },
'button[name="o_payment_submit_button"]': { 't-on-click': this.onOrderPayment },
_root: {
't-on-view_item_event': (ev) => this.onViewItem(ev),
't-on-add_to_cart_event': (ev) => this.onAddToCart(ev),
},
};
setup() {
const confirmation = this.el.querySelector('div[name="order_confirmation"]');
if (confirmation) {
this._vpv('/stats/ecom/order_confirmed/' + confirmation.dataset.orderId);
this._trackGa('event', 'purchase', confirmation.dataset.orderTrackingInfo);
}
}
/**
* @private
*/
_trackGa() {
const websiteGA = window.gtag || (() => {});
websiteGA.apply(this, arguments);
}
/**
* Virtual page view
*
* @private
*/
_vpv(page) {
this._trackGa('event', 'page_view', { 'page_path': page });
}
onViewItem(event) {
const productTrackingInfo = event.detail;
const trackingInfo = {
'currency': productTrackingInfo['currency'],
'value': productTrackingInfo['price'],
'items': [productTrackingInfo],
};
this._trackGa('event', 'view_item', trackingInfo);
}
onAddToCart(event) {
const productsTrackingInfo = event.detail;
const trackingInfo = {
'currency': productsTrackingInfo[0]['currency'],
'value': productsTrackingInfo.reduce(
(acc, val) => acc + val['price'] * val['quantity'], 0
),
'items': productsTrackingInfo,
};
this._trackGa('event', 'add_to_cart', trackingInfo);
}
onAddProductToCart() {
const productId = this.el.querySelector('input[name="product_id"]')?.getAttribute('value');
if (productId) {
this._vpv('/stats/ecom/product_add_to_cart/' + productId);
}
}
onCheckoutStart() {
this._vpv('/stats/ecom/customer_checkout');
}
onCustomerSignin() {
this._vpv('/stats/ecom/customer_signin');
}
onOrder() {
if (document.querySelector('header#top [href="/web/login"]')) {
this._vpv('/stats/ecom/customer_signup');
}
this._vpv('/stats/ecom/order_checkout');
}
onOrderPayment() {
const paymentMethod = this.el.querySelector(
'#payment_method input[name="o_payment_radio"]:checked'
)?.parentElement?.querySelector('.o_payment_option_label')?.textContent;
this._vpv('/stats/ecom/order_payment/' + paymentMethod);
}
}
registry
.category('public.interactions')
.add('website_sale.tracking', Tracking);

View file

@ -0,0 +1,632 @@
import { Interaction } from '@web/public/interaction';
import { registry } from '@web/core/registry';
import { hasTouch, isBrowserFirefox } from '@web/core/browser/feature_detection';
import { redirect, url } from '@web/core/utils/urls';
import { uniqueId } from '@web/core/utils/functions';
import { markup } from '@odoo/owl';
import wSaleUtils from '@website_sale/js/website_sale_utils';
import { ProductImageViewer } from '@website_sale/js/components/website_sale_image_viewer';
import VariantMixin from '@website_sale/js/variant_mixin';
export class WebsiteSale extends Interaction {
static selector = '.oe_website_sale';
dynamicContent = {
'.js_main_product input[name="add_qty"]': { 't-on-change': this.onChangeAddQuantity },
'a.js_add_cart_json': { 't-on-click.prevent': this.onChangeQuantity },
'form.js_attributes input, form.js_attributes select': {
't-on-change.prevent': this.onChangeAttribute,
},
'.o_wsale_products_searchbar_form': { 't-on-submit': this.onSubmitSaleSearch },
'#add_to_cart, .o_we_buy_now, #products_grid .o_wsale_product_btn .a-submit': {
't-on-click.prevent': this.onClickAdd,
},
'.js_main_product [data-attribute-exclusions]': { 't-on-change': this.onChangeVariant },
'.o_product_page_reviews_link': { 't-on-click': this.onClickReviewsLink },
'.o_wsale_filmstrip_wrapper': {
't-on-mousedown': this.onMouseDown,
't-on-mouseleave': this.onMouseLeave,
't-on-mouseup': this.onMouseUp,
't-on-mousemove': this.onMouseMove,
't-on-click': this.onClickHandler,
},
'form[name="o_wsale_confirm_order"]': {
't-on-submit': this.locked(this.onClickConfirmOrder),
},
'.o_wsale_attribute_search_bar': { 't-on-input': this.searchAttributeValues },
'.o_wsale_view_more_btn': { 't-on-click': this.onToggleViewMoreLabel },
'.css_attribute_color input': { 't-on-change': this.onChangeColorAttribute },
'label[name="o_wsale_attribute_image_selector"] input': {
't-on-change': this.onChangeImageAttribute,
},
'.o_variant_pills': { 't-on-click': this.onChangePillsAttribute },
};
setup() {
this.isWebsite = true;
this.filmStripStartX = 0;
this.filmStripIsDown = false;
this.filmStripScrollLeft = 0;
this.filmStripMoved = false;
this.imageRatio = this.el.dataset.imageRatio;
}
start() {
this._applySearch();
// This has to be triggered to compute the "out of stock" feature and the hash variant changes
this.triggerVariantChange(this.el);
this._startZoom();
// Triggered when selecting a variant of a product in a carousel element
window.addEventListener('hashchange', (ev) => {
this._applySearch();
this.triggerVariantChange(this.el);
});
// This allows conditional styling for the filmstrip
const filmstripContainer = this.el.querySelector('#o_wsale_categories_filmstrip');
const filmstripWrapper = this.el.querySelector('.o_wsale_filmstrip_wrapper');
const isFilmstripScrollable = filmstripWrapper
? filmstripWrapper.scrollWidth > filmstripWrapper.clientWidth
: false;
if (isBrowserFirefox() || hasTouch() || !isFilmstripScrollable) {
filmstripContainer?.classList.add('o_wsale_filmstrip_fancy_disabled');
}
}
destroy() {
this._cleanupZoom();
}
onMouseDown(ev) {
this.filmStripIsDown = true;
this.filmStripStartX = ev.pageX - ev.currentTarget.offsetLeft;
this.filmStripScrollLeft = ev.currentTarget.scrollLeft;
this.filmStripMoved = false;
}
onMouseLeave(ev) {
if (!this.filmStripIsDown) {
return;
}
ev.currentTarget.classList.remove('activeDrag');
this.filmStripIsDown = false
}
onMouseUp(ev) {
this.filmStripIsDown = false;
ev.currentTarget.classList.remove('activeDrag');
}
onMouseMove(ev) {
if (!this.filmStripIsDown) return;
ev.preventDefault();
ev.currentTarget.classList.add('activeDrag');
this.filmStripMoved = true;
const x = ev.pageX - ev.currentTarget.offsetLeft;
const walk = (x - this.filmStripStartX) * 2;
ev.currentTarget.scrollLeft = this.filmStripScrollLeft - walk;
}
onClickHandler(ev) {
if (this.filmStripMoved) {
ev.stopPropagation();
ev.preventDefault();
}
}
_applySearch() {
let params = new URLSearchParams(window.location.search);
let attributeValues = params.get('attribute_values')
if (!attributeValues) {
// TODO remove in 20 (or later): hash support of attribute values
params = new URLSearchParams(window.location.hash.substring(1));
attributeValues = params.get('attribute_values')
}
if (attributeValues) {
const attributeValueIds = attributeValues.split(',');
const inputs = document.querySelectorAll(
'input.js_variant_change, select.js_variant_change option'
);
let combinationChanged = false;
inputs.forEach((element) => {
if (attributeValueIds.includes(element.dataset.attributeValueId)) {
if (element.tagName === 'INPUT' && !element.checked) {
element.checked = true;
combinationChanged = true;
} else if (element.tagName === 'OPTION' && !element.selected) {
element.selected = true;
combinationChanged = true;
}
}
});
if (combinationChanged) {
this._changeAttribute(
'.css_attribute_color, [name="o_wsale_attribute_image_selector"], .o_variant_pills'
);
}
}
}
/**
* Sets the url hash from the selected product options.
*/
_setUrlHash() {
const inputs = document.querySelectorAll(
'input.js_variant_change:checked, select.js_variant_change option:checked'
);
let attributeIds = [];
inputs.forEach((element) => attributeIds.push(element.dataset.attributeValueId));
if (attributeIds.length > 0) {
const params = new URLSearchParams(window.location.search);
params.set('attribute_values', attributeIds.join(','))
// Avoid adding new entries in session history by replacing the current one
history.replaceState(null, '', url(window.location.pathname, Object.fromEntries(params)));
}
}
/**
* Set the checked values active.
*
* @param {String} selector - The selector matching the attributes to change.
*/
_changeAttribute(selector) {
this.el.querySelectorAll(selector).forEach((el) => {
const input = el.querySelector('input');
const isActive = input?.checked;
el.classList.toggle('active', isActive);
if (isActive) input.dispatchEvent(new Event('change', { bubbles: true }));
});
}
_getProductImageLayout() {
return document.querySelector("#product_detail_main").dataset.image_layout;
}
_getProductImageWidth() {
return document.querySelector("#product_detail_main").dataset.image_width;
}
_getProductImageContainerSelector() {
return {
'carousel': "#o-carousel-product",
'grid': "#o-grid-product",
}[this._getProductImageLayout()];
}
_isEditorEnabled() {
return document.body.classList.contains("editor_enable");
}
_startZoom() {
const salePage = document.querySelector(".o_wsale_product_page");
if (!salePage || this._getProductImageWidth() === "none") {
return;
}
this._cleanupZoom();
this.zoomCleanup = [];
// Zoom on click
if (salePage.dataset.ecomZoomClick) {
// In this case we want all the images not just the ones that are "zoomables"
const images = this.el.querySelectorAll('.product_detail_img');
for (const [idx, image] of images.entries()) {
const handler = () => {
this.services.dialog.add(ProductImageViewer, {
selectedImageIdx: idx,
images,
imageRatio: this.imageRatio,
});
};
image.addEventListener("click", handler);
this.zoomCleanup.push(() => {
image.removeEventListener("click", handler);
});
}
}
}
_cleanupZoom() {
if (!this.zoomCleanup || !this.zoomCleanup.length) {
return;
}
for (const cleanup of this.zoomCleanup) {
cleanup();
}
this.zoomCleanup = undefined;
}
/**
* On website, we display a carousel instead of only one image
*/
_updateProductImage(productContainer, newImages) {
let images = productContainer.querySelector(this._getProductImageContainerSelector());
// When using the web editor, don't reload this or the images won't
// be able to be edited depending on if this is done loading before
// or after the editor is ready.
if (images && !this._isEditorEnabled() && newImages ) {
images.insertAdjacentHTML('beforebegin', markup(newImages));
images.remove();
// Re-query the latest images.
images = productContainer.querySelector(this._getProductImageContainerSelector());
// Update the sharable image (only work for Pinterest).
const shareImageSrc = images.querySelector('img').src;
document.querySelector('meta[property="og:image"]')
.setAttribute('content', shareImageSrc);
if (images.id === 'o-carousel-product') {
window.Carousel.getOrCreateInstance(images).to(0);
}
this._startZoom();
}
}
/**
* @param {MouseEvent} ev
*/
async onClickAdd(ev) {
const el = ev.currentTarget;
if (this.el.querySelector('.js_add_cart_variants')?.children?.length) {
await this.waitFor(this._getCombinationInfo(ev));
if (!ev.target.closest('.js_product').classList.contains('.css_not_available')) {
return this._addToCart(el);
}
} else {
return this._addToCart(el);
}
}
/**
* @param {HTMLElement} el
*/
async _addToCart(el) {
const form = wSaleUtils.getClosestProductForm(el);
this._updateRootProduct(form);
const isBuyNow = el.classList.contains('o_we_buy_now');
const isConfigured = el.parentElement.id === 'add_to_cart_wrap';
const showQuantity = Boolean(el.dataset.showQuantity);
return this.services['cart'].add(this.rootProduct, {
isBuyNow: isBuyNow,
isConfigured: isConfigured,
showQuantity: showQuantity,
});
}
/**
* Event handler to increase or decrease quantity from the product page.
*
* @param {MouseEvent} ev
*/
onChangeQuantity(ev) {
const input = ev.currentTarget.closest('.input-group').querySelector('input');
const min = parseFloat(input.dataset.min || 0);
const max = parseFloat(input.dataset.max || Infinity);
const previousQty = parseFloat(input.value || 0);
const quantity = (
ev.currentTarget.name === 'remove_one' ? -1 : 1
) + previousQty;
const newQty = quantity > min ? (quantity < max ? quantity : max) : min;
if (newQty !== previousQty) {
input.value = newQty;
// Trigger `onChangeAddQuantity`.
input.dispatchEvent(new Event('change', { bubbles: true }));
}
}
/**
* Search attribute values based on the input text.
*
* @param {Event} ev
*/
searchAttributeValues(ev) {
const input = ev.target;
const searchValue = input.value.toLowerCase();
document.querySelectorAll(`#${input.dataset.containerId} .form-check`).forEach(item => {
const labelText = item.querySelector('.form-check-label').textContent.toLowerCase();
item.style.display = labelText.includes(searchValue) ? '' : 'none'
});
}
/**
* Toggle the button text between "View More" and "View Less"
*
* @param {MouseEvent} ev
*/
onToggleViewMoreLabel(ev) {
const button = ev.target;
const isExpanded = button.getAttribute('aria-expanded') === 'true';
button.innerHTML = isExpanded ? "View Less" : "View More";
}
/**
* When the quantity is changed, we need to query the new price of the product.
* Based on the pricelist, the price might change when quantity exceeds a certain amount.
*
* @param {MouseEvent} ev
*/
onChangeAddQuantity(ev) {
const parent = wSaleUtils.getClosestProductForm(ev.currentTarget);
if (parent) this.triggerVariantChange(parent);
}
/**
* @param {Event} ev
*/
onChangeAttribute(ev) {
const productGrid = this.el.querySelector('.o_wsale_products_grid_table_wrapper');
if (productGrid) {
productGrid.classList.add('opacity-50');
}
const form = wSaleUtils.getClosestProductForm(ev.currentTarget);
const filters = form.querySelectorAll('input:checked, select');
const attributeValues = new Map();
const tags = new Set();
for (const filter of filters) {
if (filter.value) {
if (filter.name === 'attribute_value') {
// Group attribute value ids by attribute id.
const [attributeId, attributeValueId] = filter.value.split('-');
const valueIds = attributeValues.get(attributeId) ?? new Set();
valueIds.add(attributeValueId);
attributeValues.set(attributeId, valueIds);
} else if (filter.name === 'tags') {
tags.add(filter.value);
}
}
}
const url = new URL(form.action);
const searchParams = url.searchParams;
// Aggregate all attribute values belonging to the same attribute into a single
// `attribute_values` search param.
for (const entry of attributeValues.entries()) {
searchParams.append('attribute_values', `${entry[0]}-${[...entry[1]].join(',')}`);
}
// Aggregate all tags into a single `tags` search param.
if (tags.size) {
searchParams.set('tags', [...tags].join(','));
}
redirect(`${url.pathname}?${searchParams.toString()}`);
}
/**
* @param {Event} ev
*/
onSubmitSaleSearch(ev) {
if (!this.el.querySelector('.dropdown_sorty_by')) return;
const form = ev.currentTarget;
if (!ev.defaultPrevented && !form.matches('.disabled')) {
ev.preventDefault();
const url = new URL(form.action);
const searchParams = url.searchParams;
if (form.querySelector('[name=noFuzzy]')?.value === 'true') {
searchParams.set('noFuzzy', 'true');
}
const input = form.querySelector('input.search-query');
searchParams.set(input.name, input.value);
redirect(`${url.pathname}?${searchParams.toString()}`);
}
}
/**
* Toggles the disabled class on the parent element and the "add to cart" and "buy now" buttons
* depending on whether the current combination is possible.
*
* @param {Element} parent
* @param {boolean} isCombinationPossible
*/
_toggleDisable(parent, isCombinationPossible) {
parent.classList.toggle('css_not_available', !isCombinationPossible);
parent.querySelector('#add_to_cart')?.classList?.toggle('disabled', !isCombinationPossible);
parent.querySelector('.o_we_buy_now')?.classList?.toggle('disabled', !isCombinationPossible);
}
/**
* When the variant is changed, this method will recompute:
* - Whether the selected combination is possible,
* - The extra price, if applicable,
* - The total price,
* - The display name of the product (e.g. "Customizable desk (White, Steel)"),
* - Whether a "custom value" input should be shown,
*
* "Custom value" changes are ignored since they don't change the combination.
*
* @param {MouseEvent} ev
*/
onChangeVariant(ev) {
// Write the properties of the form elements in the DOM to prevent the current selection
// from being lost when activating the web editor.
const parent = ev.currentTarget.closest('.js_product');
parent.querySelectorAll('input').forEach(
el => el.checked ? el.setAttribute('checked', true) : el.removeAttribute('checked')
);
parent.querySelectorAll('select option').forEach(
el => el.selected ? el.setAttribute('selected', true) : el.removeAttribute('selected')
);
this._setUrlHash();
if (!parent.dataset.uniqueId) {
parent.dataset.uniqueId = uniqueId();
}
this._throttledGetCombinationInfo(this, parent.dataset.uniqueId)(ev);
}
onClickReviewsLink() {
Collapse.getOrCreateInstance(
document.querySelector('#o_product_page_reviews_content')
).show();
}
/**
* Prevent multiple clicks on the confirm button when the form is submitted.
*/
onClickConfirmOrder(ev) {
const button = ev.currentTarget.querySelector('button[type="submit"]');
button.disabled = true;
// TODO(loti): "random" timeout seems brittle.
this.waitForTimeout(() => button.disabled = false, 5000);
}
/**
* Highlight selected color
*
* @param {MouseEvent} ev
*/
onChangeColorAttribute(ev) {
const eventTarget = ev.target;
const parent = eventTarget.closest('.js_product');
parent.querySelectorAll('.css_attribute_color').forEach(
el => el.classList.toggle('active', el.matches(':has(input:checked)'))
);
const attrValueEl = eventTarget.closest('.variant_attribute')
?.querySelector('.attribute_value');
if (attrValueEl) {
attrValueEl.innerText = eventTarget.dataset.valueName;
}
}
/**
* Highlight selected image
*
* @param {MouseEvent} ev
*/
onChangeImageAttribute(ev) {
const parent = ev.target.closest('.js_product');
const images = parent.querySelectorAll('label[name="o_wsale_attribute_image_selector"]');
images.forEach(el => el.classList.remove('active'));
images.forEach(el => {
const input = el.querySelector('input');
if (input && input.checked) {
el.classList.add('active');
}
});
const attrValueEl = ev.target
.closest('[name="variant_attribute"]')?.querySelector('[name="attribute_value"]');
if (attrValueEl) {
attrValueEl.innerText = ev.target.dataset.valueName;
}
}
onChangePillsAttribute(ev) {
const radio = ev.target.closest('.o_variant_pills').querySelector('input');
radio.click(); // Trigger onChangeVariant.
const parent = ev.target.closest('.js_product');
parent.querySelectorAll('.o_variant_pills').forEach(el => {
if (el.matches(':has(input:checked)')) {
el.classList.add(
'active', 'border-primary', 'text-primary-emphasis', 'bg-primary-subtle'
);
} else {
el.classList.remove(
'active', 'border-primary', 'text-primary-emphasis', 'bg-primary-subtle'
);
}
});
}
// -------------------------------------
// Utils
// -------------------------------------
/**
* Update the root product during based on the form elements.
*
* @param {HTMLFormElement} form - The form in which the product is.
*/
_updateRootProduct(form) {
const productId = parseInt(
form.querySelector('input[type="hidden"][name="product_id"]')?.value
);
const productEl = form.closest('.js_product') ?? form;
const quantity = parseFloat(productEl.querySelector('input[name="add_qty"]')?.value);
const uomId = this._getUoMId(form);
const isCombo = form.querySelector(
'input[type="hidden"][name="product_type"]'
)?.value === 'combo';
this.rootProduct = {
...(productId ? {productId: productId} : {}),
productTemplateId: parseInt(form.querySelector(
'input[type="hidden"][name="product_template_id"]',
).value),
...(quantity ? {quantity: quantity} : {}),
...(uomId ? {uomId: uomId} : {}),
ptavs: this._getSelectedPTAV(form),
productCustomAttributeValues: this._getCustomPTAVValues(form),
noVariantAttributeValues: this._getSelectedNoVariantPTAV(form),
...(isCombo ? {isCombo: isCombo} : {}),
};
}
/**
* Return the selected stored PTAV(s) of in the provided form.
*
* @param {HTMLFormElement} form - The form in which the product is.
*
* @returns {Number[]} - The selected stored attribute(s), as a list of
* `product.template.attribute.value` ids.
*/
_getSelectedPTAV(form) {
const selectedPTAVElements = form.querySelectorAll([
'.js_product input.js_variant_change:not(.no_variant):checked',
'.js_product select.js_variant_change:not(.no_variant)'
].join(','));
let selectedPTAV = [];
for(const el of selectedPTAVElements) {
selectedPTAV.push(parseInt(el.value));
}
return selectedPTAV;
}
/**
* Return the custom PTAV(s) values in the provided form.
*
* @param {HTMLFormElement} form - The form in which the product is.
*
* @returns {{id: number, value: string}[]} An array of objects where each object contains:
* - `custom_product_template_attribute_value_id`: The ID of the custom attribute.
* - `custom_value`: The value assigned to the custom attribute.
*/
_getCustomPTAVValues(form) {
const customPTAVsValuesElements = form.querySelectorAll('.variant_custom_value');
let customPTAVsValues = [];
for(const el of customPTAVsValuesElements) {
customPTAVsValues.push({
'custom_product_template_attribute_value_id': parseInt(
el.dataset.customProductTemplateAttributeValueId
),
'custom_value': el.value,
});
}
return customPTAVsValues;
}
/**
* Return the selected non-stored PTAV(s) of the product in the provided form.
*
* @param {HTMLFormElement} form - The form in which the product is.
*
* @returns {Number[]} - The selected non-stored attribute(s), as a list of
* `product.template.attribute.value` ids.
*/
_getSelectedNoVariantPTAV(form) {
const selectedNoVariantPTAVElements = form.querySelectorAll([
'input.no_variant.js_variant_change:checked',
'select.no_variant.js_variant_change',
].join(','));
let selectedNoVariantPTAV = [];
for(const el of selectedNoVariantPTAVElements) {
selectedNoVariantPTAV.push(parseInt(el.value));
}
return selectedNoVariantPTAV;
}
}
// TODO(loti): temporary hack. VariantMixin will be dropped.
Object.assign(WebsiteSale.prototype, VariantMixin);
registry.category('public.interactions').add('website_sale.website_sale', WebsiteSale);