19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:32:12 +01:00
parent 79f83631d5
commit 73afc09215
6267 changed files with 1534193 additions and 1130106 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Before After
Before After

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="70" height="70" viewBox="0 0 70 70"><defs><path id="a" d="M4 0h61c4 0 5 1 5 5v60c0 4-1 5-5 5H4c-3 0-4-1-4-5V5c0-4 1-5 4-5z"/><linearGradient id="c" x1="100%" x2="0%" y1="0%" y2="100%"><stop offset="0%" stop-color="#B06161"/><stop offset="45.785%" stop-color="#984E4E"/><stop offset="100%" stop-color="#7C3838"/></linearGradient><path id="d" d="M24.592 27.281l3.283 14.7L31 41.796V39h19v2.042h1.908c1.441 0 1.441 1.834.088 1.998L28.36 44l.942 3h21.801c.942 0 .942 2 0 2H27.417l-5.65-24h-1.884v1c0 .667-.313 1-.941 1-.628 0-.942-.333-.942-1v-2c.062-.667.376-1 .942-1h3.767c.486 0 .8.333.941 1l.942 3.281zM49.423 55a2.497 2.497 0 0 1-2.493-2.5c0-1.38 1.116-2.5 2.493-2.5a2.497 2.497 0 0 1 2.494 2.5c0 1.38-1.117 2.5-2.494 2.5zm-19.95 0a2.497 2.497 0 0 1-2.494-2.5c0-1.38 1.116-2.5 2.494-2.5a2.497 2.497 0 0 1 2.493 2.5c0 1.38-1.116 2.5-2.493 2.5zM31 35h19v3H31v-3zm0-5h19v4H31v-4zm0-5h19v4H31v-4zm1 1v2h17v-2H32z"/><path id="e" d="M24.592 25.281l3.283 14.7L31 39.796V37h19v2.042h1.908c1.441 0 1.441 1.834.088 1.998L28.36 42l.942 3h21.801c.942 0 .942 2 0 2H27.417l-5.65-24h-1.884v1c0 .667-.313 1-.941 1-.628 0-.942-.333-.942-1v-2c.062-.667.376-1 .942-1h3.767c.486 0 .8.333.941 1l.942 3.281zM49.423 53a2.497 2.497 0 0 1-2.493-2.5c0-1.38 1.116-2.5 2.493-2.5a2.497 2.497 0 0 1 2.494 2.5c0 1.38-1.117 2.5-2.494 2.5zm-19.95 0a2.497 2.497 0 0 1-2.494-2.5c0-1.38 1.116-2.5 2.494-2.5a2.497 2.497 0 0 1 2.493 2.5c0 1.38-1.116 2.5-2.493 2.5zM31 33h19v3H31v-3zm0-5h19v4H31v-4zm0-5h19v4H31v-4zm1 1v2h17v-2H32z"/></defs><g fill="none" fill-rule="evenodd"><mask id="b" fill="#fff"><use xlink:href="#a"/></mask><g mask="url(#b)"><path fill="url(#c)" d="M0 0H70V70H0z"/><path fill="#FFF" fill-opacity=".383" d="M4 1h61c2.667 0 4.333.667 5 2V0H0v3c.667-1.333 2-2 4-2z"/><path fill="#393939" d="M35.869 69H4c-2 0-4-1-4-4V38.32l18.528-17.185h4.459l2.385 7.467L31 23h19v16l2.5 1.5-9.773 4.8H51V47l-.637 1.385 1.485 2.626-.388.927L35.869 69z" opacity=".324"/><path fill="#000" fill-opacity=".383" d="M4 69h61c2.667 0 4.333-1 5-3v4H0v-4c.667 2 2 3 4 3z"/><use fill="#000" fill-rule="nonzero" opacity=".3" xlink:href="#d"/><use fill="#FFF" fill-rule="nonzero" xlink:href="#e"/></g></g></svg>
<svg width="50" height="50" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M15.724 6.397C16.377 4.94 17.852 4 19.481 4h11.037c1.63 0 3.104.94 3.757 2.397L37.236 13H41.9c2.46 0 4.367 2.099 4.07 4.481l-3.106 25C42.613 44.49 40.866 46 38.793 46H11.207c-2.074 0-3.82-1.51-4.07-3.519l-3.107-25C3.734 15.1 5.64 13 8.1 13h4.663l2.961-6.603ZM32.917 13H17.082c0-.56.123-1.134.39-1.691l.956-2C19.102 7.9 20.551 7 22.144 7h5.711c1.593 0 3.042.9 3.716 2.308l.957 2c.266.558.39 1.132.39 1.692Z" fill="#088BF5"/><path fill-rule="evenodd" clip-rule="evenodd" d="M8.514 45.016a3.963 3.963 0 0 1-1.377-2.535l-3.107-25C3.734 15.1 5.64 13 8.1 13h4.663l2.961-6.603C16.377 4.94 17.852 4 19.481 4h11.037c1.63 0 3.104.94 3.757 2.397l2.59 5.777C35.5 28.256 23.848 41.405 8.515 45.016ZM17.082 13h15.835c0-.56-.123-1.134-.39-1.691l-.956-2C30.897 7.9 29.448 7 27.855 7h-5.711c-1.593 0-3.042.9-3.716 2.308l-.956 2a3.904 3.904 0 0 0-.39 1.692Z" fill="#2EBCFA"/><path d="M17.142 20.864a4 4 0 0 1 4.899-2.828l11.59 3.106a4 4 0 0 1 2.83 4.899l-3.107 11.59a4 4 0 0 1-4.899 2.83l-11.59-3.107a4 4 0 0 1-2.83-4.899l3.107-11.59Z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -0,0 +1,85 @@
import { rpc } from '@web/core/network/rpc';
import { isEmail } from '@web/core/utils/strings';
import { patch } from '@web/core/utils/patch';
import { patchDynamicContent } from '@web/public/utils';
import { WebsiteSale } from '@website_sale/interactions/website_sale';
patch(WebsiteSale.prototype, {
setup() {
super.setup();
patchDynamicContent(this.dynamicContent, {
'#product_stock_notification_message': {
't-on-click': this.onClickProductStockNotificationMessage.bind(this),
},
'#product_stock_notification_form_submit_button': {
't-on-click': this.onClickSubmitProductStockNotificationForm.bind(this),
},
});
},
onClickProductStockNotificationMessage(ev) {
const partnerEmail = document.querySelector('#wsale_user_email').value;
const emailInputEl = document.querySelector('#stock_notification_input');
emailInputEl.value = partnerEmail;
this._handleClickStockNotificationMessage(ev);
},
onClickSubmitProductStockNotificationForm(ev) {
const formEl = ev.currentTarget.closest('#stock_notification_form');
const productId = parseInt(formEl.querySelector('input[name="product_id"]').value);
this._handleClickSubmitStockNotificationForm(ev, productId);
},
_handleClickStockNotificationMessage(ev) {
ev.currentTarget.classList.add('d-none');
ev.currentTarget.parentElement.querySelector('#stock_notification_form').classList.remove('d-none');
},
async _handleClickSubmitStockNotificationForm(ev, productId) {
const stockNotificationEl = ev.currentTarget.closest('#stock_notification_div');
const formEl = stockNotificationEl.querySelector('#stock_notification_form');
const email = stockNotificationEl.querySelector('#stock_notification_input').value.trim();
if (!isEmail(email)) {
return this._displayEmailIncorrectMessage(stockNotificationEl);
}
try {
await this.waitFor(rpc(
'/shop/add/stock_notification', { product_id: productId, email }
));
} catch {
this._displayEmailIncorrectMessage(stockNotificationEl);
return;
}
const message = stockNotificationEl.querySelector('#stock_notification_success_message');
message.classList.remove('d-none');
formEl.classList.add('d-none');
},
_displayEmailIncorrectMessage(stockNotificationEl) {
const incorrectIconEl = stockNotificationEl.querySelector('#stock_notification_input_incorrect');
incorrectIconEl.classList.remove('d-none');
},
/**
* Adds the stock checking to the regular _onChangeCombination method
* @override
*/
_onChangeCombination(ev, parent, combination) {
super._onChangeCombination(...arguments);
this._onChangeCombinationStock(...arguments);
},
/**
* Recomputes the combination after adding a product to the cart
*/
async onClickAdd(ev) {
const quantity = await this.waitFor(super.onClickAdd(...arguments));
if (this.el.querySelector('div.availability_messages')) {
await this.waitFor(this._getCombinationInfo(ev));
}
return quantity;
},
});

View file

@ -0,0 +1,36 @@
import { patch } from '@web/core/utils/patch';
import {
ComboConfiguratorDialog
} from '@sale/js/combo_configurator_dialog/combo_configurator_dialog';
patch(ComboConfiguratorDialog.prototype, {
async selectComboItem(comboId, comboItem) {
if (!comboItem.product.isQuantityAllowed(this.state.quantity)) {
return;
}
super.selectComboItem(...arguments);
},
async setQuantity(quantity) {
if (!this.isComboQuantityAllowed(quantity)) {
quantity = Math.min(
...this._selectedComboItems
.map(comboItem => comboItem.product.free_qty)
.filter(freeQty => freeQty !== undefined)
);
}
return super.setQuantity(quantity);
},
/**
* Check whether the provided combo quantity can be added to the cart.
*
* @param {Number} quantity The quantity to check.
* @return {Boolean} Whether the combo quantity can be added to the cart.
*/
isComboQuantityAllowed(quantity) {
return this._selectedComboItems.every(
comboItem => comboItem.product.isQuantityAllowed(quantity)
);
},
});

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-inherit="sale.ComboConfiguratorDialog" t-inherit-mode="extension">
<ProductCard position="attributes">
<attribute name="quantity">state.quantity</attribute>
</ProductCard>
<QuantityButtons position="attributes">
<attribute name="isPlusButtonDisabled">
!isComboQuantityAllowed(state.quantity + 1)
</attribute>
</QuantityButtons>
<button name="website_sale_combo_configurator_continue_button" position="attributes">
<attribute
name="t-att-disabled"
add="!isComboQuantityAllowed(state.quantity)"
separator=" || "
/>
</button>
<button name="website_sale_combo_configurator_checkout_button" position="attributes">
<attribute
name="t-att-disabled"
add="!isComboQuantityAllowed(state.quantity)"
separator=" || "
/>
</button>
</t>
</templates>

View file

@ -0,0 +1,23 @@
import { patch } from '@web/core/utils/patch';
import { ProductProduct } from '@sale/js/models/product_product';
patch(ProductProduct.prototype, {
/**
* @param {number} free_qty
* @param args Super's parameter list.
*/
setup({free_qty, ...args}) {
super.setup(args);
this.free_qty = free_qty;
},
/**
* Check whether the provided quantity can be added to the cart.
*
* @param {Number} quantity The quantity to check.
* @return {Boolean} Whether the product quantity can be added to the cart.
*/
isQuantityAllowed(quantity) {
return this.free_qty === undefined || this.free_qty >= quantity;
},
});

View file

@ -0,0 +1,20 @@
import { patch } from '@web/core/utils/patch';
import { Product } from '@sale/js/product/product';
patch(Product, {
props: {
...Product.props,
free_qty: { type: Number, optional: true },
},
});
patch(Product.prototype, {
/**
* Check whether this product is out of stock.
*
* @return {Boolean} - Whether this product is out of stock.
*/
isOutOfStock() {
return !this.env.isQuantityAllowed(this.props, 1);
},
});

View file

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-inherit="sale.Product" t-inherit-mode="extension">
<QuantityButtons position="attributes">
<attribute name="t-if" add="!isOutOfStock()" separator=" &amp;&amp; "/>
<attribute name="isPlusButtonDisabled">
!env.isQuantityAllowed(props, props.quantity + 1)
</attribute>
</QuantityButtons>
<QuantityButtons position="after">
<t t-call="website_sale_stock.out_of_stock"/>
</QuantityButtons>
<button name="sale_product_configurator_add_button" position="attributes">
<attribute name="t-if" add="!isOutOfStock()" separator=" &amp;&amp; "/>
</button>
<button name="sale_product_configurator_add_button" position="after">
<t t-call="website_sale_stock.out_of_stock"/>
</button>
</t>
<t t-name="website_sale_stock.out_of_stock">
<div
t-if="isOutOfStock()"
class="text-danger fw-bold"
>
<i class="fa fa-times me-1"/>
Out of stock
</div>
</t>
</templates>

View file

@ -0,0 +1,17 @@
import { _t } from '@web/core/l10n/translation';
import { patch } from '@web/core/utils/patch';
import { ProductCard } from '@sale/js/product_card/product_card';
patch(ProductCard, {
props: {
...ProductCard.props,
quantity: { type: Number, optional: true },
},
});
patch(ProductCard.prototype, {
setup() {
super.setup(...arguments);
this.allQuantitySelectedTooltip = _t("All available quantity selected");
},
});

View file

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-inherit="sale.ProductCard" t-inherit-mode="extension">
<article position="attributes">
<attribute
name="t-attf-class"
remove="cursor-pointer"
add="{{
props.product.isQuantityAllowed(props.quantity)
? 'cursor-pointer' : 'unselectable-card border'
}}"
separator=" "
/>
<attribute
name="t-att-tabindex"
add="props.product.isQuantityAllowed(props.quantity) ? '0' : '-1'"
/>
</article>
<h6 name="product_card_title" position="after">
<span
t-if="!props.product.isQuantityAllowed(props.quantity)"
class="badge text-bg-danger"
>
<i class="fa fa-circle me-2"/>
<span>Out of Stock</span>
</span>
<span
t-elif="props.isSelected &amp;&amp; !props.product.isQuantityAllowed(props.quantity + 1)"
class="text-warning"
>
<i
class="fa fa-warning"
data-toggle="tooltip"
data-trigger="click hover focus"
t-att-title="allQuantitySelectedTooltip"
/>
</span>
</h6>
<img name="product_card_image" position="attributes">
<attribute
name="t-att-class"
add="{'opacity-50': !props.product.isQuantityAllowed(props.quantity)}"
separator=" "
/>
</img>
<h6 name="product_card_title" position="attributes">
<attribute
name="t-att-class"
add="{'opacity-50': !props.product.isQuantityAllowed(props.quantity)}"
separator=" "
/>
</h6>
<xpath expr="//div[hasclass('text-muted')]" position="attributes">
<attribute
name="t-att-class"
add="{'d-none': !props.product.isQuantityAllowed(props.quantity)}"
separator=" "
/>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,43 @@
import { patch } from '@web/core/utils/patch';
import { useSubEnv } from '@odoo/owl';
import {
ProductConfiguratorDialog
} from '@sale/js/product_configurator_dialog/product_configurator_dialog';
patch(ProductConfiguratorDialog.prototype, {
setup() {
super.setup(...arguments);
useSubEnv({
isQuantityAllowed: this._isQuantityAllowed.bind(this),
});
},
async _setQuantity(productTmplId, quantity) {
const product = this._findProduct(productTmplId);
if (!this._isQuantityAllowed(product, quantity)) {
quantity = product.free_qty;
}
return super._setQuantity(productTmplId, quantity);
},
/**
* Check whether the provided product quantity can be added to the cart.
*
* @param {Object} product - The provided product.
* @param {Number} quantity - The new quantity of the product.
* @return {Boolean} - Whether the provided product quantity can be added to the cart.
*/
_isQuantityAllowed(product, quantity) {
return !('free_qty' in product) || product.free_qty >= quantity;
},
/**
* Check whether all selected product quantities can be added to the cart.
*
* @return {Boolean} - Whether all selected product quantities can be added to the cart.
*/
areQuantitiesAllowed() {
return this.state.products.every(p => this._isQuantityAllowed(p, p.quantity));
},
});

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-inherit="sale.ProductConfiguratorDialog" t-inherit-mode="extension">
<button name="website_sale_product_configurator_continue_button" position="attributes">
<attribute name="t-att-disabled" add="!areQuantitiesAllowed()" separator=" || "/>
</button>
<button name="website_sale_product_configurator_checkout_button" position="attributes">
<attribute name="t-att-disabled" add="!areQuantitiesAllowed()" separator=" || "/>
</button>
</t>
</templates>

View file

@ -0,0 +1,26 @@
import configuratorTourUtils from '@sale/js/tours/combo_configurator_tour_utils';
function assertQuantityNotAvailable(productName) {
return {
content: `Assert that the requested quantity isn't available for ${productName}`,
trigger: `
${configuratorTourUtils.comboItemSelector(productName, ['unselectable-card'])}
span:contains("Out of Stock")
`,
};
}
function assertAllQuantitySelected(productName) {
return {
content: `Assert that all available quantity has been selected for ${productName}`,
trigger: `
${configuratorTourUtils.comboItemSelector(productName)}
i[title="All available quantity selected"]
`,
};
}
export default {
assertQuantityNotAvailable,
assertAllQuantitySelected,
};

View file

@ -0,0 +1,44 @@
import configuratorTourUtils from '@sale/js/tours/product_configurator_tour_utils';
function assertProductOutOfStock(productName) {
return [
{
content: `Assert that ${productName} is out of stock`,
trigger: `
${configuratorTourUtils.productSelector(productName)}
td.o_sale_product_configurator_qty:contains("Out of stock")
`,
},
{
content: `Assert that ${productName} has no quantity`,
trigger: `
${configuratorTourUtils.productSelector(productName)}
td.o_sale_product_configurator_qty:not(:has(input[name="sale_quantity"]))
`,
},
];
}
function assertOptionalProductOutOfStock(productName) {
return [
{
content: `Assert that ${productName} is out of stock`,
trigger: `
${configuratorTourUtils.optionalProductSelector(productName)}
td.o_sale_product_configurator_price:contains("Out of stock")
`,
},
{
content: `Assert that ${productName} has no "Add" button`,
trigger: `
${configuratorTourUtils.optionalProductSelector(productName)}
td.o_sale_product_configurator_price:not(:has(button:contains("Add")))
`,
},
];
}
export default {
assertProductOutOfStock,
assertOptionalProductOutOfStock,
};

View file

@ -1,26 +1,27 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import tour from 'web_tour.tour';
import wTourUtils from 'website.tour_utils';
tour.register('website_sale_stock_reorder_from_portal', {
test: true,
registry.category("web_tour.tours").add('website_sale_stock_reorder_from_portal', {
url: '/my/orders',
},
[
steps: () => [
{
content: 'Select first order',
trigger: '.o_portal_my_doc_table a:first',
run: "click",
expectUnloadPage: true,
},
{
content: "Reorder Again",
trigger: '.o_wsale_reorder_button',
run: "click",
expectUnloadPage: true,
},
wTourUtils.clickOnElement('Reorder Again', '.o_wsale_reorder_button'),
{
content: "Check that there is one out of stock product",
trigger: "#o_wsale_reorder_body div.text-warning span:contains('This product is out of stock.')",
trigger: "div.alert-warning:contains('unavailable_product has not been added to your cart since it is not available.')",
},
{
content: "Check that there is one product that does not have enough stock",
trigger: "#o_wsale_reorder_body div.text-warning:contains('You ask for 2.0 Units but only 1.0 are available.')",
trigger: "div.o_cart_product i.fa.fa-warning[title='You ask for 2 products but only 1 is available.']",
},
]
);
});

View file

@ -1,14 +1,10 @@
odoo.define('website_sale_stock.VariantMixin', function (require) {
'use strict';
import VariantMixin from '@website_sale/js/variant_mixin';
import { renderToFragment } from '@web/core/utils/render';
import { formatFloat } from '@web/core/utils/numbers';
import { setElementContent } from '@web/core/utils/html';
const {Markup} = require('web.utils');
const field_utils = require('web.field_utils');
var VariantMixin = require('sale.VariantMixin');
var publicWidget = require('web.public.widget');
var core = require('web.core');
var QWeb = core.qweb;
require('website_sale.website_sale');
import { markup } from "@odoo/owl";
/**
* Addition to the variant_mixin._onChangeCombination
@ -22,45 +18,53 @@ require('website_sale.website_sale');
* and only for the main product.
*
* @param {MouseEvent} ev
* @param {$.Element} $parent
* @param {Element} parent
* @param {Array} combination
*/
VariantMixin._onChangeCombinationStock = function (ev, $parent, combination) {
let product_id = 0;
// needed for list view of variants
if ($parent.find('input.product_id:checked').length) {
product_id = $parent.find('input.product_id:checked').val();
} else {
product_id = $parent.find('.product_id').val();
}
const isMainProduct = combination.product_id &&
($parent.is('.js_main_product') || $parent.is('.main_product')) &&
combination.product_id === parseInt(product_id);
if (!this.isWebsite || !isMainProduct) {
VariantMixin._onChangeCombinationStock = async function (ev, parent, combination) {
const has_max_combo_quantity = 'max_combo_quantity' in combination
if (!combination.is_storable && !has_max_combo_quantity) {
return;
}
const $addQtyInput = $parent.find('input[name="add_qty"]');
let qty = $addQtyInput.val();
let ctaWrapper = $parent[0].querySelector('#o_wsale_cta_wrapper');
if (!parent.matches('.js_main_product') || !combination.product_id) {
// if we're not on product page or the product is dynamic
return;
}
const addQtyInput = parent.querySelector('input[name="add_qty"]');
const qty = parseFloat(addQtyInput?.value) || 1;
const ctaWrapper = parent.querySelector('#o_wsale_cta_wrapper');
ctaWrapper.classList.replace('d-none', 'd-flex');
ctaWrapper.classList.remove('out_of_stock');
if (combination.product_type === 'product' && !combination.allow_out_of_stock_order) {
combination.free_qty -= parseInt(combination.cart_qty);
$addQtyInput.data('max', combination.free_qty || 1);
if (!combination.allow_out_of_stock_order) {
const unavailableQty = await this.waitFor(VariantMixin._getUnavailableQty(combination));
combination.free_qty -= unavailableQty;
if (combination.free_qty < 0) {
combination.free_qty = 0;
}
if (qty > combination.free_qty) {
qty = combination.free_qty || 1;
$addQtyInput.val(qty);
if (addQtyInput) {
addQtyInput.dataset.max = combination.free_qty || 1;
if (qty > combination.free_qty) {
addQtyInput.value = addQtyInput.dataset.max;
}
}
if (combination.free_qty < 1) {
ctaWrapper.classList.replace('d-flex', 'd-none');
ctaWrapper.classList.add('out_of_stock');
}
} else if (has_max_combo_quantity) {
if (addQtyInput) {
addQtyInput.dataset.max = combination.max_combo_quantity || 1;
if (qty > combination.max_combo_quantity) {
addQtyInput.value = addQtyInput.dataset.max;
}
}
if (combination.max_combo_quantity < 1) {
ctaWrapper.classList.replace('d-flex', 'd-none');
ctaWrapper.classList.add('out_of_stock');
}
}
// needed xml-side for formatting of remaining qty
@ -72,44 +76,26 @@ VariantMixin._onChangeCombinationStock = function (ev, $parent, combination) {
0,
Math.ceil(-Math.log10(combination.uom_rounding))
);
return field_utils.format.float(qty, {digits: [false, decimals]});
return formatFloat(qty, {digits: [false, decimals]});
}
}
$('.oe_website_sale')
.find('.availability_message_' + combination.product_template)
.remove();
combination.has_out_of_stock_message = $(combination.out_of_stock_message).text() !== '';
combination.out_of_stock_message = Markup(combination.out_of_stock_message);
const $message = $(QWeb.render(
'website_sale_stock.product_availability',
combination
document.querySelector('.oe_website_sale')
.querySelectorAll('.availability_message_' + combination.product_template)
.forEach(el => el.remove());
if (combination.out_of_stock_message) {
combination.out_of_stock_message = markup(combination.out_of_stock_message);
const outOfStockMessage = document.createElement('div');
setElementContent(outOfStockMessage, combination.out_of_stock_message);
combination.has_out_of_stock_message = !!outOfStockMessage.textContent.trim();
}
this.el.querySelector('div.availability_messages').append(renderToFragment(
'website_sale_stock.product_availability', combination
));
$('div.availability_messages').html($message);
};
publicWidget.registry.WebsiteSale.include({
/**
* Adds the stock checking to the regular _onChangeCombination method
* @override
*/
_onChangeCombination: function () {
this._super.apply(this, arguments);
VariantMixin._onChangeCombinationStock.apply(this, arguments);
},
/**
* Recomputes the combination after adding a product to the cart
* @override
*/
_onClickAdd(ev) {
return this._super.apply(this, arguments).then(() => {
if ($('div.availability_messages').length) {
this._getCombinationInfo(ev);
}
});
}
});
VariantMixin._getUnavailableQty = async function (combination) {
return parseInt(combination.cart_qty);
};
return VariantMixin;
});
export default VariantMixin;

View file

@ -1,63 +0,0 @@
/** @odoo-module alias=website_sale_stock.website_sale**/
import { WebsiteSale } from 'website_sale.website_sale';
import { is_email } from 'web.utils';
WebsiteSale.include({
events: Object.assign({}, WebsiteSale.prototype.events, {
'click #product_stock_notification_message': '_onClickProductStockNotificationMessage',
'click #product_stock_notification_form_submit_button': '_onClickSubmitProductStockNotificationForm',
}),
_onClickProductStockNotificationMessage: function (ev) {
const partnerEmail = document.querySelector('#wsale_user_email').value;
const emailInputEl = document.querySelector('#stock_notification_input');
emailInputEl.value = partnerEmail;
this._handleClickStockNotificationMessage(ev);
},
_onClickSubmitProductStockNotificationForm: function (ev) {
const formEl = ev.currentTarget.closest('#stock_notification_form');
const productId = parseInt(formEl.querySelector('input[name="product_id"]').value);
this._handleClickSubmitStockNotificationForm(ev, productId);
},
_handleClickStockNotificationMessage(ev) {
ev.currentTarget.classList.add('d-none');
ev.currentTarget.parentElement.querySelector('#stock_notification_form').classList.remove('d-none');
},
_handleClickSubmitStockNotificationForm(ev, productId) {
const stockNotificationEl = ev.currentTarget.closest('#stock_notification_div');
const formEl = stockNotificationEl.querySelector('#stock_notification_form');
const email = stockNotificationEl.querySelector('#stock_notification_input').value.trim();
if (!is_email(email)) {
return this._displayEmailIncorrectMessage(stockNotificationEl);
}
this._rpc({
route: "/shop/add/stock_notification",
params: {
product_id: productId,
email,
},
}).then((data) => {
const message = stockNotificationEl.querySelector('#stock_notification_success_message');
message.classList.remove('d-none');
formEl.classList.add('d-none');
}).catch((error) => {
this._displayEmailIncorrectMessage(stockNotificationEl);
});
},
_displayEmailIncorrectMessage(stockNotificationEl) {
const incorrectIconEl = stockNotificationEl.querySelector('#stock_notification_input_incorrect');
incorrectIconEl.classList.remove('d-none');
}
});
export default WebsiteSale;

View file

@ -1,79 +0,0 @@
/** @odoo-module **/
import { ReorderDialog } from "@website_sale/js/website_sale_reorder";
import { patch } from "@web/core/utils/patch";
import { sprintf } from "@web/core/utils/strings";
patch(ReorderDialog.prototype, "website_sale_stock_reorder", {
/**
* @override
*/
async onWillStartHandler() {
const res = await this._super(...arguments);
for (const product of this.content.products) {
this.stockCheckCombinationInfo(product);
}
return res;
},
/**
* @override
*/
async loadProductCombinationInfo(product) {
await this._super(...arguments);
},
stockCheckCombinationInfo(product) {
// Products that should have a max quantity available should be limited by default.
if (product.combinationInfo.allow_out_of_stock_order || product.type !== "product") {
return;
}
product.max_quantity_available = product.combinationInfo.free_qty;
if (!product.max_quantity_available) {
product.add_to_cart_allowed = false;
}
if (product.max_quantity_available < product.qty) {
product.qty_warning = sprintf(
this.env._t("You ask for %s Units but only %s are available."),
product.qty.toFixed(1),
product.max_quantity_available.toFixed(1)
);
product.qty = product.max_quantity_available;
product.stock_warning = true;
} else if (product.combinationInfo.cart_qty) {
product.qty_warning = sprintf(
this.env._t("You already have %s Units in your cart."),
product.combinationInfo.cart_qty.toFixed(1)
);
}
},
/**
* @override
*/
getWarningForProduct(product) {
if (product.hasOwnProperty("max_quantity_available") && !product.max_quantity_available) {
return this.env._t("This product is out of stock.");
}
return this._super(...arguments);
},
/**
* @override
*/
changeProductQty(product, newQty) {
if (product.max_quantity_available && newQty > product.max_quantity_available) {
product.qty_warning = sprintf(
this.env._t("You ask for %s Units but only %s are available."),
newQty.toFixed(1),
product.max_quantity_available.toFixed(1)
);
product.stock_warning = true;
newQty = product.max_quantity_available;
} else if (product.stock_warning) {
product.qty_warning = false;
product.stock_warning = false;
}
this._super(product, newQty);
},
});

View file

@ -2,61 +2,74 @@
<templates>
<t t-name="website_sale_stock.product_availability">
<t t-if="product_type == 'product' and !prevent_zero_price_sale">
<div t-if="free_qty lte 0 and !cart_qty" t-attf-class="availability_message_#{product_template} mb-1">
<div id="out_of_stock_message">
<t t-if='has_out_of_stock_message' t-out='out_of_stock_message'/>
<div
t-if="is_storable and !prevent_zero_price_sale"
id="product_stock_availability"
>
<div t-if="free_qty lte 0 and !cart_qty" t-attf-class="availability_message_#{product_template} d-flex flex-column gap-2">
<div id="out_of_stock_message" class="mb-3">
<t t-if="has_out_of_stock_message">
<div class="d-flex align-items-center badge text-start text-bg-danger text-wrap">
<i class="fa fa-circle text-danger me-2"/>
<span>
<t t-out="out_of_stock_message"/>
</span>
</div>
</t>
<t t-elif="!allow_out_of_stock_order">
<div class="text-danger fw-bold">
<i class="fa fa-times me-1"/>
Out of Stock
<div class="badge text-bg-danger">
<i class="fa fa-circle text-danger me-2"/>Out of Stock
</div>
</t>
</div>
<div id="stock_notification_div" t-if="!allow_out_of_stock_order">
<div class="btn btn-link px-0" t-if="!has_stock_notification"
id="product_stock_notification_message">
<i class="fa fa-envelope-o me-1"/>
Get notified when back in stock
<i class="fa fa-envelope-o me-2"/>Get notified when back in stock
</div>
<div id="stock_notification_form" class="d-none">
<div class="input-group">
<input class="form-control"
id="stock_notification_input" name="email"
type="text" placeholder="youremail@gmail.com" t-value="stock_notification_email if stock_notification_email else None"/>
type="text" placeholder="youremail@gmail.com" t-att-value="stock_notification_email? stock_notification_email: ''"/>
<input name="product_id" type="hidden" t-att-value="product_id"/>
<div id="product_stock_notification_form_submit_button" class="btn btn-secondary">
<div id="product_stock_notification_form_submit_button" class="btn btn-primary">
<i class="fa fa-paper-plane"/>
</div>
<div id="stock_notification_input_incorrect" class="btn d-none">
<i class="fa fa-times text-danger"/>
Invalid email
</div>
</div>
<div id="stock_notification_input_incorrect" class="d-none form-text text-danger">
Invalid email
</div>
</div>
<div id="stock_notification_success_message"
t-att-class="has_stock_notification ? '' : 'd-none'">
<div class="text-muted">
<i class="fa fa-bell"/>
We'll notify you once the product is back in stock.
<div class="py-2 small text-muted">
<i class="fa fa-bell me-2"/>We'll notify you once the product is back in stock
</div>
</div>
</div>
</div>
<div id="threshold_message" t-elif="show_availability and free_qty lte available_threshold" t-attf-class="availability_message_#{product_template} text-warning fw-bold">
Only <t t-esc="formatQuantity(free_qty)"/> <t t-esc="uom_name" /> left in stock.
<div
id="threshold_message"
t-elif="show_availability and free_qty lte available_threshold"
t-attf-class="availability_message_#{product_template} my-3 badge text-bg-warning"
>
<i class="fa fa-circle text-warning me-2" role="presentation"/>
<t t-out="formatQuantity(free_qty)"/> <t t-out="uom_name" /> in stock
<span
id="already_in_cart_message"
t-if="!allow_out_of_stock_order and show_availability and cart_qty"
t-attf-class="availability_message_#{product_template}"
>
<t t-if="!free_qty">
You already added all the available product in your cart
</t>
<t t-else="">
(<t t-out="cart_qty" /> in your cart)
</t>
</span>
</div>
<div id="already_in_cart_message" t-if="!allow_out_of_stock_order and show_availability and cart_qty" t-attf-class="availability_message_#{product_template} text-warning mt8">
<t t-if='!free_qty'>
You already added all the available product in your cart.
</t>
<t t-else=''>
You already added <t t-esc="cart_qty" /> <t t-esc="uom_name" /> in your cart.
</t>
</div>
</t>
</div>
</t>
</templates>

View file

@ -0,0 +1,41 @@
import { registry } from '@web/core/registry';
import configuratorTourUtils from '@sale/js/tours/combo_configurator_tour_utils';
import * as wsTourUtils from '@website_sale/js/tours/tour_utils';
import stockConfiguratorTourUtils from '@website_sale_stock/js/tours/combo_configurator_tour_utils';
registry
.category('web_tour.tours')
.add('website_sale_stock_combo_configurator', {
url: '/shop?search=Combo product',
steps: () => [
...wsTourUtils.addToCart({ productName: "Combo product", search: false, expectUnloadPage: true }),
configuratorTourUtils.assertQuantity(1),
// Assert that it's impossible to add less than 1 product.
configuratorTourUtils.setQuantity(0),
configuratorTourUtils.assertQuantity(1),
{
content: "Verify that the quantity decrease button is disabled",
trigger: `
.sale-combo-configurator-dialog
button[name=sale_quantity_button_minus]:disabled
`,
},
// Assert that an error is shown if the requested quantity isn't available.
configuratorTourUtils.setQuantity(3),
stockConfiguratorTourUtils.assertQuantityNotAvailable("Test product"),
// Assert that a warning is shown if all available quantity is selected.
configuratorTourUtils.setQuantity(2),
configuratorTourUtils.selectComboItem("Test product"),
stockConfiguratorTourUtils.assertAllQuantitySelected("Test product"),
// Assert that it's impossible to add more products than available.
configuratorTourUtils.setQuantity(3),
configuratorTourUtils.assertQuantity(2),
{
content: "Verify that the quantity increase button is disabled",
trigger: `
.sale-combo-configurator-dialog
button[name=sale_quantity_button_plus]:disabled
`,
},
],
});

View file

@ -0,0 +1,53 @@
import { registry } from "@web/core/registry";
registry.category("web_tour.tours").add('website_sale_stock_message_after_close_onfigurator_modal_with_optional_products', {
// This tour relies on a data created from the python test.
url: '/shop?search=Product With Optional (TEST)',
steps: () => [{
content: "Select Customizable Desk",
trigger: '.oe_product_cart a:contains("Product With Optional (TEST)")',
run: "click",
expectUnloadPage: true,
}, {
content: "Check that the stock quantity is displayed and correct",
trigger: '#threshold_message:contains("30")',
}, {
content: "Add to cart",
trigger: '#add_to_cart',
run: "click",
},
{
trigger: 'table.o_sale_product_configurator_table',
},
{
content: "Continue shoppping",
trigger: 'button[name="website_sale_product_configurator_continue_button"]',
run: 'click',
}, {
content: "Check that the stock quantity is displayed and correct after adding to cart",
trigger: '#threshold_message:contains("29")',
}
]
});
registry.category("web_tour.tours").add('website_sale_stock_message_after_close_onfigurator_modal_without_optional_products', {
// This tour relies on a data created from the python test.
url: '/shop?search=Product Without Optional (TEST)',
steps: () => [{
content: "Select Office Lamp",
trigger: '.oe_product_cart a:contains("Product Without Optional (TEST)")',
run: "click",
expectUnloadPage: true,
}, {
content: "Check that the stock quantity is displayed and correct",
trigger: '#threshold_message:contains("30")',
}, {
content: "Add to cart",
trigger: '#add_to_cart',
run: "click",
}, {
content: "Check that the stock quantity is displayed and correct after adding to cart",
trigger: '#threshold_message:contains("29")',
}
]
});

View file

@ -1,48 +1,47 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import tour from 'web_tour.tour';
tour.register('website_sale_stock_multilang', {
test: true,
registry.category("web_tour.tours").add('website_sale_stock_multilang', {
url: '/fr/shop?search=unavailable',
},
[{
content: "Open unavailable product page",
trigger: 'a[content="unavailable_product"]',
}, {
content: "Check out of stock message",
trigger: '#out_of_stock_message:contains("Hors-stock")',
run: () => {}, // This is a check.
}, {
content: "Check price",
trigger: 'span:contains("123,45")',
run: () => {}, // This is a check.
}, {
content: "Open language selector",
trigger: '.js_language_selector button',
}, {
content: "Switch to English",
trigger: '.js_change_lang[data-url_code="en"]',
}, {
content: "Check out of stock message",
trigger: '#out_of_stock_message:contains("Out of stock")',
run: () => {}, // This is a check.
}, {
content: "Check price",
trigger: 'span:contains("123.45")',
run: () => {}, // This is a check.
}, {
content: "Open language selector",
trigger: '.js_language_selector button',
}, {
content: "Switch to French",
trigger: '.js_change_lang[data-url_code="fr"]',
}, {
content: "Check out of stock message",
trigger: '#out_of_stock_message:contains("Hors-stock")',
run: () => {}, // This is a check.
}, {
content: "Check price",
trigger: 'span:contains("123,45")',
run: () => {}, // This is a check.
}]);
steps: () => [{
content: "Open unavailable product page",
trigger: 'a:contains("unavailable_product")',
run: "click",
expectUnloadPage: true,
}, {
content: "Check out of stock message",
trigger: '#out_of_stock_message:contains("Hors-stock")',
}, {
content: "Check price",
trigger: 'span:contains("123,45")',
}, {
content: "Open language selector",
trigger: '.js_language_selector button',
run: "click",
}, {
content: "Switch to English",
trigger: '.js_change_lang[data-url_code="en"]',
run: "click",
expectUnloadPage: true,
}, {
content: "Check out of stock message",
trigger: '#out_of_stock_message:contains("Out of stock")',
}, {
content: "Check price",
trigger: 'span:contains("123.45")',
}, {
content: "Open language selector",
trigger: '.js_language_selector button',
run: "click",
}, {
content: "Switch to French",
trigger: '.js_change_lang[data-url_code="fr"]',
run: "click",
expectUnloadPage: true,
}, {
content: "Check out of stock message",
trigger: '#out_of_stock_message:contains("Hors-stock")',
}, {
content: "Check price",
trigger: 'span:contains("123,45")',
}],
});

View file

@ -0,0 +1,52 @@
import { registry } from '@web/core/registry';
import configuratorTourUtils from '@sale/js/tours/product_configurator_tour_utils';
import * as wsTourUtils from '@website_sale/js/tours/tour_utils';
import stockConfiguratorTourUtils from '@website_sale_stock/js/tours/product_configurator_tour_utils';
registry
.category('web_tour.tours')
.add('website_sale_stock_product_configurator', {
url: '/shop?search=Main product',
steps: () => [
...wsTourUtils.addToCart({ productName: "Main product", search: false, expectUnloadPage: true }),
configuratorTourUtils.assertProductQuantity("Main product", 1),
// Assert that it's impossible to add less than 1 product (only for the main product).
configuratorTourUtils.setProductQuantity("Main product", 0),
configuratorTourUtils.assertProductQuantity("Main product", 1),
{
content: "check that decrease button is disabled",
trigger: `.modal button[name=sale_quantity_button_minus]:disabled`,
},
// Assert that it's impossible to add more products than available.
configuratorTourUtils.setProductQuantity("Main product", 20),
configuratorTourUtils.assertProductQuantity("Main product", 10),
{
content: "check that increase button is disabled",
trigger: `.modal button[name=sale_quantity_button_plus]:disabled`,
},
// Assert that the "Out of stock" variant of the optional product can't be sold.
...stockConfiguratorTourUtils.assertOptionalProductOutOfStock(
"Optional product (Out of stock)"
),
// Add the "Out of stock" variant by selecting the "In stock" variant, adding it, and
// selecting the "Out of stock" variant again.
configuratorTourUtils.selectAttribute("Optional product", "Stock", "In stock"),
configuratorTourUtils.addOptionalProduct("Optional product (In stock)"),
configuratorTourUtils.selectAttribute("Optional product", "Stock", "Out of stock"),
// Assert that the "Out of stock" variant of the optional product still can't be sold.
...stockConfiguratorTourUtils.assertProductOutOfStock("Optional product (Out of stock)"),
configuratorTourUtils.assertFooterButtonsDisabled(),
// Remove the "Out of stock" variant.
configuratorTourUtils.removeOptionalProduct("Optional product"),
{
content: "Proceed to checkout",
trigger: 'button:contains(Go to Checkout)',
run: 'click',
expectUnloadPage: true,
},
{
content: "Verify the quantity in the cart",
trigger: 'div.o_cart_product input.quantity[value="10"]',
},
],
});

View file

@ -1,32 +1,32 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import tour from 'web_tour.tour';
tour.register('back_in_stock_notification_product', {
test: true,
registry.category("web_tour.tours").add('back_in_stock_notification_product', {
url: '/shop?search=Macbook%20Pro',
},
[
steps: () => [
{
content: "Open product page",
trigger: 'a:contains("Macbook Pro")',
run: "click",
expectUnloadPage: true,
},
{
content: "Click on 'Be notified when back in stock'",
trigger: '#product_stock_notification_message',
run: "click",
},
{
content: "Fill email form",
trigger: 'div[id="stock_notification_form"] input[name="email"]',
run: 'text test@test.test',
run: "edit test@test.test",
},
{
content: "Click on the button",
trigger: '#product_stock_notification_form_submit_button',
run: "click",
},
{
content: "Success Message",
trigger: '#stock_notification_success_message',
},
],
);
});