mirror of
https://github.com/bringout/oca-ocb-sale.git
synced 2026-04-27 22:32:03 +02:00
19.0 vanilla
This commit is contained in:
parent
79f83631d5
commit
73afc09215
6267 changed files with 1534193 additions and 1130106 deletions
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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) },
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
},
|
||||
|
||||
});
|
||||
|
|
@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
|
|
@ -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")
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
@ -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 });
|
||||
|
|
@ -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 browser’s
|
||||
* 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 });
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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});
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue