diff --git a/addons/website_sale/__manifest__.py b/addons/website_sale/__manifest__.py
index c6a84b95274..6300e3be315 100644
--- a/addons/website_sale/__manifest__.py
+++ b/addons/website_sale/__manifest__.py
@@ -61,6 +61,8 @@
'website_sale/static/src/js/website_sale_category_link.js',
'website_sale/static/src/xml/website_sale_image_viewer.xml',
'website_sale/static/src/js/components/website_sale_image_viewer.js',
+ 'website_sale/static/src/xml/website_sale_reorder_modal.xml',
+ 'website_sale/static/src/js/website_sale_reorder.js',
],
'web._assets_primary_variables': [
'website_sale/static/src/scss/primary_variables.scss',
diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py
index ab44e1a8771..32e1964a317 100644
--- a/addons/website_sale/controllers/main.py
+++ b/addons/website_sale/controllers/main.py
@@ -21,6 +21,7 @@ from odoo.exceptions import AccessError, MissingError, ValidationError
from odoo.addons.portal.controllers.portal import _build_url_w_params
from odoo.addons.website.controllers import main
from odoo.addons.website.controllers.form import WebsiteForm
+from odoo.addons.sale.controllers import portal
from odoo.osv import expression
from odoo.tools import lazy
from odoo.tools.json import scriptsafe as json_scriptsafe
@@ -836,6 +837,12 @@ class WebsiteSale(http.Controller):
return request.website.sale_get_order().cart_quantity
return request.session['website_sale_cart_quantity']
+ @http.route(['/shop/cart/clear'], type='json', auth="public", website=True)
+ def clear_cart(self):
+ order = request.website.sale_get_order()
+ for line in order.order_line:
+ line.unlink()
+
# ------------------------------------------------------
# Checkout
# ------------------------------------------------------
@@ -1531,3 +1538,46 @@ class PaymentPortal(payment_portal.PaymentPortal):
self._validate_transaction_for_order(tx_sudo, order_id)
return tx_sudo._get_processing_values()
+
+
+class CustomerPortal(portal.CustomerPortal):
+ def _sale_reorder_get_line_context(self):
+ return {}
+
+ @http.route('/my/orders/reorder_modal_content', type='json', auth='public', website=True)
+ def _get_saleorder_reorder_content_modal(self, order_id, access_token):
+ try:
+ sale_order = self._document_check_access('sale.order', order_id, access_token=access_token)
+ except (AccessError, MissingError):
+ return request.redirect('/my')
+
+ pricelist = request.env['website'].get_current_website().get_current_pricelist()
+ currency = pricelist.currency_id
+ result = {
+ 'currency': {
+ 'symbol': currency.symbol,
+ 'decimal_places': currency.decimal_places,
+ 'position': currency.position,
+ },
+ 'products': [],
+ }
+ for line in sale_order.order_line:
+ if line.display_type:
+ continue
+ res = {
+ 'product_template_id': line.product_id.product_tmpl_id.id,
+ 'product_id': line.product_id.id,
+ 'type': line.product_id.type,
+ 'name': line.product_id.name,
+ 'description_sale': line.product_id.description_sale,
+ 'qty': line.product_uom_qty,
+ 'add_to_cart_allowed': line.with_user(request.env.user).sudo()._is_reorder_allowed(),
+ 'has_image': bool(line.product_id.image_128),
+ }
+ if res['add_to_cart_allowed']:
+ res['combinationInfo'] = line.product_id.product_tmpl_id.with_context(**self._sale_reorder_get_line_context())\
+ ._get_combination_info([], res['product_id'], res['qty'], pricelist)
+ else:
+ res['combinationInfo'] = {}
+ result['products'].append(res)
+ return result
diff --git a/addons/website_sale/controllers/variant.py b/addons/website_sale/controllers/variant.py
index 692b8363903..0464359b986 100644
--- a/addons/website_sale/controllers/variant.py
+++ b/addons/website_sale/controllers/variant.py
@@ -18,7 +18,7 @@ class WebsiteSaleVariantController(VariantController):
if request.website.google_analytics_key:
combination['product_tracking_info'] = request.env['product.template'].get_google_analytics_data(combination)
- if request.website.product_page_image_width != 'none':
+ if request.website.product_page_image_width != 'none' and not request.env.context.get('website_sale_no_images', False):
carousel_view = request.env['ir.ui.view']._render_template('website_sale.shop_product_images', values={
'product': request.env['product.template'].browse(combination['product_template_id']),
'product_variant': request.env['product.product'].browse(combination['product_id']),
diff --git a/addons/website_sale/models/res_config_settings.py b/addons/website_sale/models/res_config_settings.py
index 69c2cfc5ddf..b671117dc81 100644
--- a/addons/website_sale/models/res_config_settings.py
+++ b/addons/website_sale/models/res_config_settings.py
@@ -61,6 +61,7 @@ class ResConfigSettings(models.TransientModel):
readonly=False, required=True)
website_sale_prevent_zero_price_sale = fields.Boolean(string="Prevent Sale of Zero Priced Product", related='website_id.prevent_zero_price_sale', readonly=False)
website_sale_contact_us_button_url = fields.Char(string="Button URL", related='website_id.contact_us_button_url', readonly=False)
+ website_sale_enabled_portal_reorder_button = fields.Boolean(string="Re-order From Portal", related='website_id.enabled_portal_reorder_button', readonly=False)
@api.depends('website_id')
def _compute_terms_url(self):
diff --git a/addons/website_sale/models/sale_order.py b/addons/website_sale/models/sale_order.py
index e2e1def1e36..daac3ed40c5 100644
--- a/addons/website_sale/models/sale_order.py
+++ b/addons/website_sale/models/sale_order.py
@@ -409,3 +409,7 @@ class SaleOrder(models.Model):
if clear:
self.shop_warning = ''
return warn
+
+ def _is_reorder_allowed(self):
+ self.ensure_one()
+ return self.state == 'sale' and any(line._is_reorder_allowed() for line in self.order_line)
diff --git a/addons/website_sale/models/sale_order_line.py b/addons/website_sale/models/sale_order_line.py
index 598b41c9170..5a94e1ed1a5 100644
--- a/addons/website_sale/models/sale_order_line.py
+++ b/addons/website_sale/models/sale_order_line.py
@@ -58,3 +58,11 @@ class SaleOrderLine(models.Model):
def _show_in_cart(self):
self.ensure_one()
return True
+
+ def _is_reorder_allowed(self):
+ self.ensure_one()
+ # TODO: when website_sale_subscription is merged, this should be overridden by both
+ # website_sale_subscription and website_sale_renting instead.
+ if 'temporal_type' in self and self.temporal_type:
+ return False
+ return self.product_id._is_add_to_cart_allowed()
diff --git a/addons/website_sale/models/website.py b/addons/website_sale/models/website.py
index 1e9df3ade54..cdb198735ab 100644
--- a/addons/website_sale/models/website.py
+++ b/addons/website_sale/models/website.py
@@ -105,6 +105,7 @@ class Website(models.Model):
prevent_zero_price_sale_text = fields.Char(string="Text to show instead of price", translate=True,
default="Not Available For Sale")
contact_us_button_url = fields.Char(string="Contact Us Button URL", translate=True, default="/contactus")
+ enabled_portal_reorder_button = fields.Boolean(string="Re-order From Portal")
@api.depends('all_pricelist_ids')
def _compute_pricelist_ids(self):
diff --git a/addons/website_sale/static/src/js/tours/tour_utils.js b/addons/website_sale/static/src/js/tours/tour_utils.js
index 621f9f1de62..f7dc8b95bc7 100644
--- a/addons/website_sale/static/src/js/tours/tour_utils.js
+++ b/addons/website_sale/static/src/js/tours/tour_utils.js
@@ -14,10 +14,16 @@ odoo.define("website_sale.tour_utils", function (require) {
};
}
- function assertCartContains(productName, backend = false) {
+ function assertCartContains({productName, backend, notContains = false} = {}) {
+ let trigger = `a:contains(${productName})`;
+
+ if (notContains) {
+ trigger = `:not(${trigger})`;
+ }
return {
content: `Checking if ${productName} is in the cart`,
- trigger: `${backend ? "iframe" : ""} a:contains(${productName})`,
+ trigger: `${backend ? "iframe" : ""} ${trigger}`,
+ run: () => {}
};
}
diff --git a/addons/website_sale/static/src/js/website_sale_reorder.js b/addons/website_sale/static/src/js/website_sale_reorder.js
new file mode 100644
index 00000000000..6087001f0ef
--- /dev/null
+++ b/addons/website_sale/static/src/js/website_sale_reorder.js
@@ -0,0 +1,227 @@
+/** @odoo-module **/
+
+import { debounce as debounceFn } from "@web/core/utils/timing";
+import publicWidget from "web.public.widget";
+import { localization as l10n } from "@web/core/l10n/localization";
+import { ComponentWrapper } from "web.OwlCompatibility";
+import { intersperse, nbsp } from "@web/core/utils/strings";
+import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
+
+/**
+ * Inserts "thousands" separators in the provided number.
+ *
+ * @private
+ * @param {string} string representing integer number
+ * @param {string} [thousandsSep=","] the separator to insert
+ * @param {number[]} [grouping=[]]
+ * array of relative offsets at which to insert `thousandsSep`.
+ * See `strings.intersperse` method.
+ * @returns {string}
+ */
+function insertThousandsSep(number, thousandsSep = ",", grouping = []) {
+ const negative = number[0] === "-";
+ number = negative ? number.slice(1) : number;
+ return (negative ? "-" : "") + intersperse(number, grouping, thousandsSep);
+}
+
+export function formatFloat(value, digits = 2) {
+ if (value === false) {
+ return "";
+ }
+ const grouping = l10n.grouping;
+ const thousandsSep = l10n.thousandsSep;
+ const decimalPoint = l10n.decimalPoint;
+ let precision = digits;
+ const formatted = (value || 0).toFixed(precision).split(".");
+ formatted[0] = insertThousandsSep(formatted[0], thousandsSep, grouping);
+ return formatted[1] ? formatted.join(decimalPoint) : formatted[0];
+}
+
+export function formatMonetary(value, currency) {
+ // Monetary fields want to display nothing when the value is unset.
+ // You wouldn't want a value of 0 euro if nothing has been provided.
+ if (value === false) {
+ return "";
+ }
+
+ const digits = (currency && currency.decimal_places) || 2;
+
+ let formattedValue = formatFloat(value, digits);
+
+ if (!currency) {
+ return formattedValue;
+ }
+ const formatted = [currency.symbol, formattedValue];
+ if (currency.position === "after") {
+ formatted.reverse();
+ }
+ return formatted.join(nbsp);
+}
+
+// Widget responsible for openingn the modal (giving out the sale order id)
+
+publicWidget.registry.SaleOrderPortalReorderWidget = publicWidget.Widget.extend({
+ selector: ".o_portal_sidebar",
+ events: {
+ "click .o_wsale_reorder_button": "_onReorder",
+ },
+
+ _onReorder(ev) {
+ const orderId = parseInt(ev.currentTarget.dataset.saleOrderId);
+ const urlSearchParams = new URLSearchParams(window.location.search);
+ if (!orderId || !urlSearchParams.has("access_token")) {
+ return;
+ }
+ // Open the modal
+ const dialogWrapper = new ComponentWrapper(this, ReorderDialogWrapper, {
+ orderId: orderId,
+ accessToken: urlSearchParams.get("access_token"),
+ });
+ dialogWrapper.mount(document.body);
+ },
+});
+
+import { useService } from "@web/core/utils/hooks";
+import { Dialog } from "@web/core/dialog/dialog";
+
+const { Component, onRendered, onWillStart, xml } = owl;
+
+// Reorder Dialog
+
+export class ReorderDialogWrapper extends Component {
+ setup() {
+ this.dialogService = useService("dialog");
+
+ onRendered(() => {
+ this.dialogService.add(ReorderDialog, this.props);
+ });
+ }
+}
+ReorderDialogWrapper.template = xml``;
+
+export class ReorderConfirmationDialog extends ConfirmationDialog {}
+ReorderConfirmationDialog.template = "website_sale.ReorderConfirmationDialog";
+
+export class ReorderDialog extends Component {
+ setup() {
+ this.rpc = useService("rpc");
+ this.orm = useService("orm");
+ this.dialogService = useService("dialog");
+ this.formatMonetary = formatMonetary;
+
+ onWillStart(this.onWillStartHandler.bind(this));
+ }
+
+ async onWillStartHandler() {
+ // Cart Qty should not change while the dialog is opened.
+ this.cartQty = parseInt(sessionStorage.getItem("website_sale_cart_quantity"));
+ if (!this.cartQty) {
+ this.cartQty = await this.rpc("/shop/cart/quantity");
+ }
+ // Get required information about the order
+ this.content = await this.rpc("/my/orders/reorder_modal_content", {
+ order_id: this.props.orderId,
+ access_token: this.props.accessToken,
+ });
+ // Get required information about each products
+ for (const product of this.content.products) {
+ product.debouncedLoadProductCombinationInfo = debounceFn(() => {
+ this.loadProductCombinationInfo(product).then(this.render.bind(this));
+ }, 200);
+ }
+ }
+
+ get total() {
+ return this.content.products.reduce((total, product) => {
+ if (product.add_to_cart_allowed) {
+ total += product.combinationInfo.price * product.qty;
+ }
+ return total;
+ }, 0);
+ }
+
+ get hasBuyableProducts() {
+ return this.content.products.some((product) => product.add_to_cart_allowed);
+ }
+
+ async loadProductCombinationInfo(product) {
+ product.combinationInfo = await this.rpc("/sale/get_combination_info_website", {
+ product_template_id: product.product_template_id,
+ product_id: product.product_id,
+ combination: [],
+ add_qty: product.qty,
+ pricelist_id: false,
+ context: {
+ website_sale_no_images: true,
+ },
+ });
+ }
+
+ getWarningForProduct(product) {
+ if (!product.add_to_cart_allowed) {
+ return this.env._t("This product is not available for purchase.");
+ }
+ return false;
+ }
+
+ changeProductQty(product, newQty) {
+ const productNewQty = Math.max(0, newQty);
+ const qtyChanged = productNewQty !== product.qty;
+ product.qty = productNewQty;
+ this.render(true);
+ if (!qtyChanged) {
+ return;
+ }
+ product.debouncedLoadProductCombinationInfo();
+ }
+
+ onChangeProductQtyInput(ev, product) {
+ const newQty = parseFloat(ev.target.value) || product.qty;
+ this.changeProductQty(product, newQty);
+ }
+
+ async confirmReorder(ev) {
+ if (this.confirmed) {
+ return;
+ }
+ this.confirmed = true;
+ const onConfirm = async () => {
+ await this.addProductsToCart();
+ window.location = "/shop/cart";
+ };
+ if (this.cartQty) {
+ // Open confirmation modal
+ this.dialogService.add(ReorderConfirmationDialog, {
+ body: this.env._t("Do you wish to clear your cart before adding products to it?"),
+ confirm: async () => {
+ await this.rpc("/shop/cart/clear");
+ await onConfirm();
+ },
+ cancel: onConfirm,
+ });
+ } else {
+ await onConfirm();
+ }
+ }
+
+ async addProductsToCart() {
+ for (const product of this.content.products) {
+ if (!product.add_to_cart_allowed) {
+ continue;
+ }
+ await this.rpc("/shop/cart/update_json", {
+ product_id: product.product_id,
+ add_qty: product.qty,
+ });
+ }
+ }
+}
+ReorderDialog.props = {
+ close: Function,
+ orderId: Number,
+ accessToken: String,
+};
+ReorderDialog.components = {
+ Dialog,
+};
+ReorderDialog.template = "website_sale.ReorderModal";
diff --git a/addons/website_sale/static/src/xml/website_sale_reorder_modal.xml b/addons/website_sale/static/src/xml/website_sale_reorder_modal.xml
new file mode 100644
index 00000000000..080e19535c7
--- /dev/null
+++ b/addons/website_sale/static/src/xml/website_sale_reorder_modal.xml
@@ -0,0 +1,98 @@
+
+