odoo18/addons/website_sale/static/src/js/checkout.js

596 lines
21 KiB
JavaScript

import {
LocationSelectorDialog
} from '@delivery/js/location_selector/location_selector_dialog/location_selector_dialog';
import { _t } from '@web/core/l10n/translation';
import { rpc } from '@web/core/network/rpc';
import publicWidget from '@web/legacy/js/public/public_widget';
publicWidget.registry.WebsiteSaleCheckout = publicWidget.Widget.extend({
selector: '#shop_checkout',
events: {
// Addresses
'click .card': '_changeAddress',
'click .js_edit_address': '_preventChangingAddress',
'change #use_delivery_as_billing': '_toggleBillingAddressRow',
// Delivery methods
'click [name="o_delivery_radio"]': '_selectDeliveryMethod',
'click [name="o_pickup_location_selector"]': '_selectPickupLocation',
},
// #=== WIDGET LIFECYCLE ===#
async start() {
this.mainButton = document.querySelector('a[name="website_sale_main_button"]');
this.use_delivery_as_billing_toggle = document.querySelector('#use_delivery_as_billing');
this.billingContainer = this.el.querySelector('#billing_container');
await this._prepareDeliveryMethods();
},
// #=== EVENT HANDLERS ===#
/**
* Set the billing or delivery address on the order and update the corresponding card.
*
* @private
* @param {Event} ev
* @return {void}
*/
async _changeAddress(ev) {
const newAddress = ev.currentTarget;
if (newAddress.classList.contains('bg-primary')) { // 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.updateAddress(addressType, selectedPartnerId);
if (addressType === 'delivery') { // A delivery address is changed.
if (this.use_delivery_as_billing_toggle.checked) {
await this._selectMatchingBillingAddress(selectedPartnerId);
}
// Update the available delivery methods.
document.getElementById('o_delivery_form').innerHTML = await rpc(
'/shop/delivery_methods'
);
await 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.
*
* @private
* @param ev
* @return {void}
*/
async _toggleBillingAddressRow(ev) {
const useDeliveryAsBilling = ev.target.checked;
const addDeliveryAddressButton = this.el.querySelector(
'.o_wsale_add_address[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._selectMatchingBillingAddress(selectedDeliveryAddress.dataset.partnerId);
} else {
this._disableMainButton();
this.billingContainer.classList.remove('d-none'); // Show the billing address row.
}
this._enableMainButton(); // Try to enable the main button.
},
/**
* Cancel the address change to allow the redirect to the edit page to take place.
*
* @private
* @param {Event} ev
* @return {void}
*/
_preventChangingAddress(ev) {
ev.stopPropagation();
},
/**
* Fetch the delivery rate for the selected delivery method and update the displayed amounts.
*
* @private
* @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._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.
*
* @private
* @param {Event} ev
* @return {void}
*/
async _selectPickupLocation(ev) {
const { zipCode, locationId } = ev.currentTarget.dataset;
const deliveryMethodContainer = this._getDeliveryMethodContainer(ev.currentTarget);
this.call('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._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-primary', '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-primary', '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._setDeliveryMethod(radio.dataset.dmId);
this._updateAmountBadge(radio, result);
this._updateCartSummary(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 it's a free delivery (`free_over` field), show 'Free', not '$ 0'.
if (rateData.is_free_delivery) {
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.
* @return {void}
*/
_updateCartSummary(result) {
const amountDelivery = document.querySelector('#order_delivery .monetary_field');
const amountUntaxed = document.querySelector('#order_total_untaxed .monetary_field');
const amountTax = document.querySelector('#order_total_taxes .monetary_field');
const amountTotal = document.querySelectorAll(
'#order_total .monetary_field, #amount_total_summary.monetary_field'
);
amountDelivery.innerHTML = result.amount_delivery;
amountUntaxed.innerHTML = result.amount_untaxed;
amountTax.innerHTML = result.amount_tax;
amountTotal.forEach(total => total.innerHTML = result.amount_total);
},
/**
* 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 deliveryMethodContainer = this._getDeliveryMethodContainer(radio);
radio.disabled = disable;
if (disable) {
deliveryMethodContainer.classList.add('text-muted');
}
else {
deliveryMethodContainer.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.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._updateDeliveryMethod(checkedRadio);
this._enableMainButton();
}
}
// 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._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._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-primary[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-primary[data-address-type="billing"]')
);
return billingAddressSelected || this.use_delivery_as_billing_toggle.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);
},
});
export default publicWidget.registry.WebsiteSaleCheckout;