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
+ Buy Now
+
+
+
+
+
+ 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 @@
+
+
+