diff --git a/addons/website/static/src/img/snippets_thumbs/s_add_to_cart.svg b/addons/website/static/src/img/snippets_thumbs/s_add_to_cart.svg new file mode 100644 index 00000000000..b8a26620da6 --- /dev/null +++ b/addons/website/static/src/img/snippets_thumbs/s_add_to_cart.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + diff --git a/addons/website/static/src/js/tours/tour_utils.js b/addons/website/static/src/js/tours/tour_utils.js index 5334edec3e7..f94e0a69958 100644 --- a/addons/website/static/src/js/tours/tour_utils.js +++ b/addons/website/static/src/js/tours/tour_utils.js @@ -13,6 +13,17 @@ function addMedia(position = "right") { run: "click", }; } +function assertPathName(pathName, trigger) { + return { + content: `Check if we have been redirected to ${pathName}`, + trigger: trigger, + run: () => { + if (!window.location.pathname.startsWith(pathName)) { + console.error(`We should be on ${pathName}.`); + } + } + }; +} function changeBackground(snippet, position = "bottom") { return { @@ -129,6 +140,19 @@ function clickOnEdit(position = "bottom") { }; } +/** + * Simple click on an element in the page. + * @param {*} elementName + * @param {*} selector + */ +function clickOnElement(elementName, selector) { + return { + content: `Clicking on the ${elementName}`, + trigger: selector, + run: 'click' + }; +} + /** * Simple click on a snippet in the edition area * @param {*} snippet @@ -314,8 +338,31 @@ function registerBackendAndFrontend(name, options, steps) { }, tourSteps); } +/** + * Selects an element inside a we-select, if the we-select is from a m2o widget, searches for it. + * + * @param widgetName {string} The widget's data-name + * @param elementName {string} the element to search + * @param searchNeeded {Boolean} if the widget is a m2o widget and a search is needed + */ +function selectElementInWeSelectWidget(widgetName, elementName, searchNeeded = false) { + const steps = [clickOnElement(`${widgetName} toggler`, `we-select[data-name=${widgetName}] we-toggler`)]; + + if (searchNeeded) { + steps.push({ + content: `Inputing ${elementName} in m2o widget search`, + trigger: `we-select[data-name=${widgetName}] div.o_we_m2o_search input`, + run: `text ${elementName}` + }); + } + steps.push(clickOnElement(`${elementName} in the ${widgetName} widget`, + `we-select[data-name=${widgetName}] we-button:contains(${elementName})`)); + return steps; +} + return { addMedia, + assertPathName, changeBackground, changeBackgroundColor, changeColumnSize, @@ -324,6 +371,7 @@ return { changeOption, changePaddingSize, clickOnEdit, + clickOnElement, clickOnSave, clickOnSnippet, clickOnText, @@ -339,5 +387,6 @@ return { clickOnExtraMenuItem, registerEditionTour, registerBackendAndFrontend, + selectElementInWeSelectWidget, }; }); diff --git a/addons/website/views/snippets/snippets.xml b/addons/website/views/snippets/snippets.xml index 6c64ab6d0a3..0122351c84f 100644 --- a/addons/website/views/snippets/snippets.xml +++ b/addons/website/views/snippets/snippets.xml @@ -158,6 +158,7 @@ + @@ -179,6 +180,7 @@ + @@ -563,7 +565,7 @@ - blockquote, .s_card:not(.s_timeline_card), .s_alert, .o_facebook_page, .s_share, .s_social_media, .s_rating, .s_hr, .s_google_map, .s_map, .s_countdown, .s_chart, .s_text_highlight, .s_progress_bar, .s_badge, .s_embed_code, .s_donation + blockquote, .s_card:not(.s_timeline_card), .s_alert, .o_facebook_page, .s_share, .s_social_media, .s_rating, .s_hr, .s_google_map, .s_map, .s_countdown, .s_chart, .s_text_highlight, .s_progress_bar, .s_badge, .s_embed_code, .s_donation, .s_add_to_cart
{ } - } + }; } return { + assertCartContains, + assertProductPrice, goToCart, selectPriceList, - assertProductPrice }; }); diff --git a/addons/website_sale/static/src/js/website_sale_utils.js b/addons/website_sale/static/src/js/website_sale_utils.js index 75f8ae80a4b..7112122b549 100644 --- a/addons/website_sale/static/src/js/website_sale_utils.js +++ b/addons/website_sale/static/src/js/website_sale_utils.js @@ -56,6 +56,7 @@ function animateClone($cart, $elem, offsetTop, offsetLeft) { $(this).removeClass("o_red_highlight").dequeue(); }); return new Promise(function (resolve, reject) { + if(!$elem) resolve(); var $imgtodrag = $elem.find('img').eq(0); if ($imgtodrag.length) { var $imgclone = $imgtodrag.clone() diff --git a/addons/website_sale/static/src/snippets/s_add_to_cart/000.js b/addons/website_sale/static/src/snippets/s_add_to_cart/000.js new file mode 100644 index 00000000000..487393a4a98 --- /dev/null +++ b/addons/website_sale/static/src/snippets/s_add_to_cart/000.js @@ -0,0 +1,49 @@ +/** @odoo-module **/ + +import publicWidget from 'web.public.widget'; +import { cartHandlerMixin } from 'website_sale.utils'; +import { WebsiteSale } from 'website_sale.website_sale'; +import { _t } from 'web.core'; + +publicWidget.registry.AddToCartSnippet = WebsiteSale.extend(cartHandlerMixin, { + selector: '.s_add_to_cart_btn', + events: { + 'click': '_onClickAddToCartButton', + }, + + _onClickAddToCartButton: async function (ev) { + const dataset = ev.currentTarget.dataset; + + const visitorChoice = dataset.visitorChoice === 'true'; + const action = dataset.action; + const productId = parseInt(dataset.productVariantId); + + if (!productId) { + return; + } + + if (visitorChoice) { + this._handleAdd($(ev.currentTarget.closest('div'))); + } else { + const isAddToCartAllowed = await this._rpc({ + route: `/shop/product/is_add_to_cart_allowed`, + params: { + product_id: productId, + }, + }); + if (!isAddToCartAllowed) { + this.displayNotification({ + title: 'User Error', + message: _t('This product does not exist therefore it cannot be added to cart.'), + type: 'warning' + }); + return; + } + this.isBuyNow = action === 'buy_now'; + this.stayOnPageOption = !this.isBuyNow; + this.addToCart({product_id: productId, add_qty: 1}); + } + }, +}); + +export default publicWidget.registry.AddToCartSnippet; diff --git a/addons/website_sale/static/src/snippets/s_add_to_cart/options.js b/addons/website_sale/static/src/snippets/s_add_to_cart/options.js new file mode 100644 index 00000000000..df241ebdd85 --- /dev/null +++ b/addons/website_sale/static/src/snippets/s_add_to_cart/options.js @@ -0,0 +1,258 @@ +/** @odoo-module **/ + +import options from 'web_editor.snippets.options'; +import { _t } from 'web.core'; + +const Many2oneUserValueWidget = options.userValueWidgetsRegistry['we-many2one']; + +const Many2oneDefaultMessageWidget = Many2oneUserValueWidget.extend({ + + // defaultMessage: default message to display when no records are selected + configAttributes: [...Many2oneUserValueWidget.prototype.configAttributes, 'defaultMessage'], + + /** + * @override + */ + async setValue(value, methodName) { + await this._super(...arguments); + + if (value === '') { + this.menuTogglerEl.textContent = this.options.defaultMessage; + } + }, +}); + +options.userValueWidgetsRegistry['we-many2one-default-message'] = Many2oneDefaultMessageWidget; + +options.registry.AddToCart = options.Class.extend({ + events: _.extend({}, options.Class.prototype.events || {}, { + 'click .reset-variant-picker': '_onClickResetVariantPicker', + 'click .reset-product-picker': '_onClickResetProductPicker', + }), + + async updateUI() { + if (this.rerender) { + this.rerender = false; + await this._rerenderXML(); + return; + } + return this._super.apply(this, arguments); + }, + + _setButtonDisabled: function (isDisabled) { + const buttonEl = this._buttonEl(); + + if (isDisabled) { + buttonEl.classList.add('disabled'); + } else { + buttonEl.classList.remove('disabled'); + } + }, + + async setProductTemplate(previewMode, widgetValue, params) { + this.$target[0].dataset.productTemplate = widgetValue; + this._resetVariantChoice(); + this._resetAction(); + this._setButtonDisabled(false); + + await this._fetchVariants(widgetValue); + this.rerender = true; + this._updateButton(); + + }, + + setProductVariant(previewMode, widgetValue, params) { + this.$target[0].dataset.productVariant = widgetValue; + this._updateButton(); + }, + + setAction(previewMode, widgetValue, params) { + this.$target[0].dataset.action = widgetValue; + this._updateButton(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + _onClickResetVariantPicker() { + this._resetVariantChoice(); + this._resetAction(); + this._updateButton(); + }, + + _onClickResetProductPicker() { + this._resetProductChoice(); + this._resetVariantChoice(); + this._resetAction(); + this._updateButton(); + }, + + /** + * Fetches the variants ids from the server + */ + async _fetchVariants(productTemplateId) { + const response = await this._rpc({ + model: 'product.product', + method: 'search_read', + domain: [ + ["product_tmpl_id", "=", parseInt(productTemplateId)], + ], + }); + this.$target[0].dataset.variants = response.map(variant => variant.id); + }, + + + _resetProductChoice() { + this.$target[0].dataset.productTemplate = ''; + this._buttonEl().classList.add('disabled'); + }, + + + _resetVariantChoice() { + this.$target[0].dataset.productVariant = ''; + }, + + _resetAction: function () { + this.$target[0].dataset.action = "add_to_cart"; + }, + + /** + * Returns an array of variant ids from the dom + */ + _variantIds() { + return this.$target[0].dataset.variants.split(',').map(stringId => parseInt(stringId)); + }, + + _buttonEl() { + const buttonEl = this.$target[0].querySelector('.s_add_to_cart_btn'); + // In case the button was deleted somehow, we rebuild it. + if (!buttonEl) { + return this._buildButtonEl(); + } + return buttonEl; + }, + + _buildButtonEl() { + const buttonEl = document.createElement('button'); + buttonEl.classList.add("s_add_to_cart_btn", "btn", "btn-secondary", "mb-2"); + this.$target[0].append(buttonEl); + return buttonEl; + }, + + /** + * Updates the button's html + */ + _updateButton() { + const variantIds = this._variantIds(); + const buttonEl = this._buttonEl(); + + let productVariantId = variantIds[0]; + buttonEl.dataset.visitorChoice = false; + + if (variantIds.length > 1) { + // If there is more than 1 variant, that means that there are variants for the product template + // and we check if there is one selected and assign it. If not, visitorChoice is set to true + if (this.$target[0].dataset.productVariant) { + productVariantId = this.$target[0].dataset.productVariant; + } else { + buttonEl.dataset.visitorChoice = true; + } + } + buttonEl.dataset.productVariantId = productVariantId; + buttonEl.dataset.action = this.$target[0].dataset.action; + this._updateButtonContent(); + this._createHiddenFormInput(productVariantId); + }, + + _updateButtonContent() { + let iconEl = document.createElement('i'); + const buttonContent = { + add_to_cart: {classList: "fa fa-cart-plus me-2", text: _t("Add to Cart")}, + buy_now: {classList: "fa fa-credit-card me-2", text: _t("Buy now")}, + }; + let buttonContentElement = buttonContent[this.$target[0].dataset.action]; + + iconEl.classList = buttonContentElement.classList; + + this._buttonEl().replaceChildren(iconEl, buttonContentElement.text); + }, + /** + * Because sale_product_configurator._handleAdd() requires a hidden input to retrieve the productId, + * this method creates a hidden input in the form of the button to make the modal behaviour possible. + */ + _createHiddenFormInput(productVariantId) { + const inputEl = this._buttonEl().querySelector('input[type="hidden"][name="product_id"]'); + if (inputEl) { + // If the input already exists, we change its value + inputEl.setAttribute('value', productVariantId); + } else { + // Otherwise, we create the input element + let inputEl = document.createElement('input'); + inputEl.setAttribute('type', 'hidden'); + inputEl.setAttribute('name', 'product_id'); + inputEl.setAttribute('value', productVariantId); + this._buttonEl().append(inputEl); + } + }, + + /** + * Called when the template is chosen and that we want to update the m2o variant widget with the right variants. + */ + async _renderCustomXML(uiFragment) { + if (this.$target[0].dataset.productTemplate) { + // That means that a template was selected and we want to update the content of the variant picker based on the template id + const productVariantPickerEl = uiFragment.querySelector('we-many2one-default-message[data-name="product_variant_picker_opt"]'); + productVariantPickerEl.dataset.domain = `[["product_tmpl_id", "=", ${this.$target[0].dataset.productTemplate}]]`; + } + }, + + /** + * @override + */ + _computeWidgetState(methodName, params) { + switch (methodName) { + case 'setProductTemplate': { + return this.$target[0].dataset.productTemplate || ''; + } + case 'setProductVariant': { + return this.$target[0].dataset.productVariant || ''; + } + case 'setAction': { + return this.$target[0].dataset.action; + } + } + return this._super(...arguments); + }, + + /** + * @override + */ + async _computeWidgetVisibility(widgetName, params) { + switch (widgetName) { + case 'product_variant_picker_opt': { + return this.$target[0].dataset.productTemplate && this._variantIds().length > 1; + } + case 'product_variant_reset_opt': { + return this.$target[0].dataset.productVariant; + } + + case 'product_template_reset_opt': { + return this.$target[0].dataset.productTemplate; + } + case 'action_picker_opt': { + if (this.$target[0].dataset.productTemplate) { + if (this._variantIds().length > 1) { + return this.$target[0].dataset.productVariant; + } + return true; + } + return false; + } + } + return this._super(...arguments); + }, +}); +export default { + AddToCart: options.registry.AddToCart, +}; diff --git a/addons/website_sale/static/tests/tours/website_sale_add_to_cart_snippet_tour.js b/addons/website_sale/static/tests/tours/website_sale_add_to_cart_snippet_tour.js new file mode 100644 index 00000000000..0185fe39c96 --- /dev/null +++ b/addons/website_sale/static/tests/tours/website_sale_add_to_cart_snippet_tour.js @@ -0,0 +1,54 @@ +/** @odoo-module **/ + +import wsTourUtils from 'website_sale.tour_utils'; +import wTourUtils from 'website.tour_utils'; + +function editAddToCartSnippet() { + return [ + wTourUtils.clickOnEdit(), + wTourUtils.clickOnSnippet({id: 's_add_to_cart'}) + ] +} + +wTourUtils.registerEditionTour('add_to_cart_snippet_tour', { + url: '/', + edition: true, + test: true, + }, + [ + wTourUtils.dragNDrop({name: 'Add to Cart Button'}), + + // Basic product with no variants + wTourUtils.clickOnSnippet({id: 's_add_to_cart'}), + ...wTourUtils.selectElementInWeSelectWidget('product_template_picker_opt', 'Acoustic Bloc Screens', true), + ...wTourUtils.clickOnSave(), + wTourUtils.clickOnElement('add to cart button', 'iframe .s_add_to_cart_btn'), + + // Product with 2 variants with visitor choice (will open modal) + ...editAddToCartSnippet(), + ...wTourUtils.selectElementInWeSelectWidget('product_template_picker_opt', 'Conference Chair', true), + ...wTourUtils.clickOnSave(), + wTourUtils.clickOnElement('add to cart button', 'iframe .s_add_to_cart_btn'), + wTourUtils.clickOnElement('continue shopping', 'iframe span:contains(Continue Shopping)'), + + // Product with 2 variants with a variant selected + ...editAddToCartSnippet(), + ...wTourUtils.selectElementInWeSelectWidget('product_template_picker_opt', 'Conference Chair', true), + ...wTourUtils.selectElementInWeSelectWidget('product_variant_picker_opt', 'Conference Chair (Aluminium)'), + ...wTourUtils.clickOnSave(), + wTourUtils.clickOnElement('add to cart button', 'iframe .s_add_to_cart_btn'), + + // Basic product with no variants and action=buy now + ...editAddToCartSnippet(), + ...wTourUtils.selectElementInWeSelectWidget('product_template_picker_opt', 'Acoustic Bloc Screens', true), + ...wTourUtils.selectElementInWeSelectWidget('action_picker_opt', 'Buy Now'), + ...wTourUtils.clickOnSave(), + wTourUtils.clickOnElement('add to cart button', 'iframe .s_add_to_cart_btn'), + wTourUtils.assertPathName('/shop/payment', 'button[name=o_payment_submit_button]'), + + wsTourUtils.goToCart({quantity: 4, backend: false}), + wsTourUtils.assertCartContains('Acoustic Bloc Screens', false), + wsTourUtils.assertCartContains('Conference Chair (Steel)', false), + wsTourUtils.assertCartContains('Conference Chair (Aluminium)', false), + ], +); diff --git a/addons/website_sale/tests/__init__.py b/addons/website_sale/tests/__init__.py index 6daadf34331..a08a4f6615e 100644 --- a/addons/website_sale/tests/__init__.py +++ b/addons/website_sale/tests/__init__.py @@ -4,6 +4,7 @@ from . import test_customize from . import test_sale_process from . import test_sitemap +from . import test_website_sale_add_to_cart_snippet from . import test_website_sale_cart_abandoned from . import test_website_sale_cart_recovery from . import test_website_sale_cart diff --git a/addons/website_sale/tests/test_website_sale_add_to_cart_snippet.py b/addons/website_sale/tests/test_website_sale_add_to_cart_snippet.py new file mode 100644 index 00000000000..14f11c1bd0c --- /dev/null +++ b/addons/website_sale/tests/test_website_sale_add_to_cart_snippet.py @@ -0,0 +1,9 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import HttpCase, tagged + + +@tagged('post_install', '-at_install') +class TestAddToCartSnippet(HttpCase): + def test_configure_product(self): + self.start_tour("/", 'add_to_cart_snippet_tour', login="admin") diff --git a/addons/website_sale/views/snippets/s_add_to_cart.xml b/addons/website_sale/views/snippets/s_add_to_cart.xml new file mode 100644 index 00000000000..2be4c3065cf --- /dev/null +++ b/addons/website_sale/views/snippets/s_add_to_cart.xml @@ -0,0 +1,50 @@ + + + + + + Add to Cart 000 JS + web.assets_frontend + website_sale/static/src/snippets/s_add_to_cart/000.js + + diff --git a/addons/website_sale/views/snippets/snippets.xml b/addons/website_sale/views/snippets/snippets.xml index 2048e1991a7..7f74f118ceb 100644 --- a/addons/website_sale/views/snippets/snippets.xml +++ b/addons/website_sale/views/snippets/snippets.xml @@ -5,6 +5,9 @@ + + +