[IMP] website, *: migrate Universal Analytics to Google Analytics 4

*: website_sale, website_sale_delivery

The old analytics snippet (isogram) is replaced by the newer gtags
snippet. Virtual page views and ecommerce transactions are now tracked
using gtags. The e-commerce data format is updated to correspond with
the new GA4 format.

task-2500769

closes odoo/odoo#72233

Signed-off-by: Romain Derie (rde) <rde@odoo.com>
Co-authored-by: Romain Derie <rde@odoo.com>
Co-authored-by: Tom De Caluwé <tdc@odoo.com>
This commit is contained in:
Romain Derie
2021-09-03 15:45:08 +00:00
co-authored by Tom De Caluwé
parent f19a4b99aa
commit 78bc86cbec
20 changed files with 310 additions and 94 deletions
@@ -61,10 +61,10 @@
</div>
<div t-name="website.ga_dialog_content">
Your Tracking ID: <input type="text" name="ga_analytics_key" placeholder="UA-XXXXXXXX-Y" t-att-value="ga_analytics_key" style="width: 100%"></input>
<a href="https://www.odoo.com/documentation/14.0/applications/websites/website/optimize/google_analytics.html" target="_blank">
Your Measurement ID: <input type="text" name="ga_analytics_key" placeholder="G-XXXXXXXXXX" t-att-value="ga_analytics_key" style="width: 100%"></input>
<a href="https://support.google.com/analytics/answer/9304153" target="_blank">
<i class="fa fa-arrow-right"/>
How to get my Tracking ID
How to get my Measurement ID
</a>
<br/><br/>
Your Client ID: <input type="text" name="ga_client_id" t-att-value="ga_key" style="width: 100%"></input>
@@ -9,14 +9,10 @@
consent in your country.
</p>
<p>
For session cookies, authentification and analytics*,
For session cookies, authentification and analytics,
<b>you do not need to ask for the consent</b> (see e.g. Opinion
04/2012 on Cookie Consent Exemption by the EU Art.29 WP).
</p>
<p>
* provided that your analytics is anonymized, which is not the
case by default with Google Analytics.
</p>
</main>
</t>
</templates>
@@ -284,8 +284,8 @@
</div>
<div class="content-group" attrs="{'invisible': [('has_google_analytics', '=', False)]}">
<div class="row mt16">
<label class="col-lg-3 o_light_label" string="Tracking ID" for="google_analytics_key"/>
<field name="google_analytics_key" placeholder="UA-XXXXXXXX-Y"
<label class="col-lg-3 o_light_label" string="Measurement ID" for="google_analytics_key"/>
<field name="google_analytics_key" placeholder="G-XXXXXXXXXX"
attrs="{'required': [('has_google_analytics', '=', True)]}"/>
</div>
</div>
@@ -293,7 +293,7 @@
<a href="https://www.odoo.com/documentation/14.0/applications/websites/website/optimize/google_analytics.html"
class="oe_link" target="_blank">
<i class="fa fa-arrow-right"/>
How to get my Tracking ID
How to get my Measurement ID
</a>
</div>
</div>
+9 -9
View File
@@ -146,15 +146,15 @@
</xpath>
<xpath expr="//div[@id='wrapwrap']" position="after">
<script id='tracking_code' t-if="website and website.google_analytics_key and not editable">
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','//www.google-analytics.com/analytics.js','ga');
ga('create', '<t t-esc="website.google_analytics_key"/>'.trim(), 'auto');
ga('send','pageview');
</script>
<t t-if="website and website.google_analytics_key and not editable">
<script id="tracking_code" async="1" t-attf-src="https://www.googletagmanager.com/gtag/js?id={{ website.google_analytics_key }}"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '<t t-esc="website.google_analytics_key"/>');
</script>
</t>
</xpath>
<!-- Page options -->
+13 -23
View File
@@ -1095,7 +1095,10 @@ class WebsiteSale(http.Controller):
sale_order_id = request.session.get('sale_last_order_id')
if sale_order_id:
order = request.env['sale.order'].sudo().browse(sale_order_id)
return request.render("website_sale.confirmation", {'order': order})
return request.render("website_sale.confirmation", {
'order': order,
'order_tracking_info': self.order_2_return_dict(order),
})
else:
return request.redirect('/shop')
@@ -1109,16 +1112,6 @@ class WebsiteSale(http.Controller):
else:
return request.redirect('/shop')
@http.route(['/shop/tracking_last_order'], type='json', auth="public")
def tracking_cart(self, **post):
""" return data about order in JSON needed for google analytics"""
ret = {}
sale_order_id = request.session.get('sale_last_order_id')
if sale_order_id:
order = request.env['sale.order'].sudo().browse(sale_order_id)
ret = self.order_2_return_dict(order)
return ret
# ------------------------------------------------------
# Edit
# ------------------------------------------------------
@@ -1163,10 +1156,9 @@ class WebsiteSale(http.Controller):
for line in order_lines:
product = line.product_id
ret.append({
'id': line.order_id.id,
'sku': product.barcode or product.id,
'name': product.name or '-',
'category': product.categ_id.name or '-',
'item_id': product.barcode or product.id,
'item_name': product.name or '-',
'item_category': product.categ_id.name or '-',
'price': line.price_unit,
'quantity': line.product_uom_qty,
})
@@ -1175,14 +1167,12 @@ class WebsiteSale(http.Controller):
def order_2_return_dict(self, order):
""" Returns the tracking_cart dict of the order for Google analytics basically defined to be inherited """
return {
'transaction': {
'id': order.id,
'affiliation': order.company_id.name,
'revenue': order.amount_total,
'tax': order.amount_tax,
'currency': order.currency_id.name
},
'lines': self.order_lines_2_google_api(order.order_line)
'transaction_id': order.id,
'affiliation': order.company_id.name,
'value': order.amount_total,
'tax': order.amount_tax,
'currency': order.currency_id.name,
'items': self.order_lines_2_google_api(order.order_line),
}
@http.route(['/shop/country_infos/<model("res.country"):country>'], type='json', auth="public", methods=['POST'], website=True)
+11 -8
View File
@@ -5,6 +5,7 @@ from odoo.http import request
from odoo.addons.sale.controllers.variant import VariantController
class WebsiteSaleVariantController(VariantController):
@http.route(['/sale/get_combination_info_website'], type='json', auth="public", methods=['POST'], website=True)
def get_combination_info_website(self, product_template_id, product_id, combination, add_qty, **kw):
@@ -12,15 +13,17 @@ class WebsiteSaleVariantController(VariantController):
This route is called in JS by appending _website to the base route.
"""
kw.pop('pricelist_id')
res = self.get_combination_info(product_template_id, product_id, combination, add_qty, request.website.get_current_pricelist(), **kw)
combination = self.get_combination_info(product_template_id, product_id, combination, add_qty, request.website.get_current_pricelist(), **kw)
carousel_view = request.env['ir.ui.view']._render_template('website_sale.shop_product_carousel',
values={
'product': request.env['product.template'].browse(res['product_template_id']),
'product_variant': request.env['product.product'].browse(res['product_id']),
})
res['carousel'] = carousel_view
return res
if request.website.google_analytics_key:
combination['product_tracking_info'] = request.env['product.template'].get_google_analytics_data(combination)
carousel_view = request.env['ir.ui.view']._render_template('website_sale.shop_product_carousel', values={
'product': request.env['product.template'].browse(combination['product_template_id']),
'product_variant': request.env['product.product'].browse(combination['product_id']),
})
combination['carousel'] = carousel_view
return combination
@http.route(auth="public")
def create_product_variant(self, product_template_id, product_template_attribute_value_ids, **kwargs):
@@ -405,3 +405,14 @@ class ProductTemplate(models.Model):
slugs = [slug(category) for category in product.public_categ_ids]
data['category_url'] = '/shop/category/%s' % ','.join(slugs)
return results_data
@api.model
def get_google_analytics_data(self, combination):
product = self.env['product.product'].browse(combination['product_id'])
return {
'item_id': product.barcode or product.id,
'item_name': combination['display_name'],
'item_category': product.categ_id.name or '-',
'currency': product.currency_id.name,
'price': combination['list_price'],
}
@@ -29,6 +29,17 @@ VariantMixin._onChangeCombination = function (ev, $parent, combination) {
$pricePerUom.parents(".o_base_unit_price_wrapper").addClass("d-none");
}
}
// Triggers a new JS event with the correct payload, which is then handled
// by the google analytics tracking code.
// Indeed, every time another variant is selected, a new view_item event
// needs to be tracked by google analytics.
if ('product_tracking_info' in combination) {
const $product = $('#product_detail');
$product.data('product-tracking-info', combination['product_tracking_info']);
$product.trigger('view_item_event', combination['product_tracking_info']);
}
originalOnChangeCombination.apply(this, [ev, $parent, combination]);
};
@@ -148,14 +148,13 @@ odoo.define('website_sale.website_sale', function (require) {
var core = require('web.core');
var config = require('web.config');
var publicWidget = require('web.public.widget');
var VariantMixin = require('sale.VariantMixin');
var VariantMixin = require('website_sale.VariantMixin');
var wSaleUtils = require('website_sale.utils');
const cartHandlerMixin = wSaleUtils.cartHandlerMixin;
require("web.zoomodoo");
const {extraMenuUpdateCallbacks} = require('website.content.menu');
const dom = require('web.dom');
publicWidget.registry.WebsiteSale = publicWidget.Widget.extend(VariantMixin, cartHandlerMixin, {
selector: '.oe_website_sale',
events: _.extend({}, VariantMixin.events || {}, {
@@ -533,9 +532,16 @@ publicWidget.registry.WebsiteSale = publicWidget.Widget.extend(VariantMixin, car
* @returns {Promise}
*/
_submitForm: function () {
let params = this.rootProduct;
params.add_qty = params.quantity;
const params = this.rootProduct;
const $product = $('#product_detail');
const productTrackingInfo = $product.data('product-tracking-info');
if (productTrackingInfo) {
productTrackingInfo.quantity = params.quantity;
$product.trigger('add_to_cart_event', [productTrackingInfo]);
}
params.add_qty = params.quantity;
params.product_custom_attribute_values = JSON.stringify(params.product_custom_attribute_values);
params.no_variant_attribute_values = JSON.stringify(params.no_variant_attribute_values);
return this.addToCart(params);
@@ -961,6 +967,15 @@ publicWidget.registry.websiteSaleProductPageReviews = publicWidget.Widget.extend
this.$target.find('.o_portal_chatter_composer').css('top', dom.scrollFixedOffset() + 20);
},
});
return {
WebsiteSale: publicWidget.registry.WebsiteSale,
WebsiteSaleLayout: publicWidget.registry.WebsiteSaleLayout,
websiteSaleCart: publicWidget.registry.websiteSaleCart,
WebsiteSaleCarouselProduct: publicWidget.registry.websiteSaleCarouselProduct,
WebsiteSaleProductPageReviews: publicWidget.registry.websiteSaleProductPageReviews,
};
});
odoo.define('website_sale.price_range_option', function (require) {
@@ -10,6 +10,8 @@ publicWidget.registry.websiteSaleTracking = publicWidget.Widget.extend({
'click div.oe_cart a[href^="/web?redirect"][href$="/shop/checkout"]': '_onCustomerSignin',
'click form[action="/shop/confirm_order"] a.a-submit': '_onOrder',
'click form[target="_self"] button[type=submit]': '_onOrderPayment',
'view_item_event': '_onViewItem',
'add_to_cart_event': '_onAddToCart',
},
/**
@@ -18,32 +20,13 @@ publicWidget.registry.websiteSaleTracking = publicWidget.Widget.extend({
start: function () {
var self = this;
// Watching a product
if (this.$el.is('#product_detail')) {
var productID = this.$('input[name="product_id"]').attr('value');
this._vpv('/stats/ecom/product_view/' + productID);
}
// ...
if (this.$('div.oe_website_sale_tx_status').length) {
this._trackGA('require', 'ecommerce');
var orderID = this.$('div.oe_website_sale_tx_status').data('order-id');
const $confirmation = this.$('div.oe_website_sale_tx_status');
if ($confirmation.length) {
const orderID = $confirmation.data('order-id');
const json = $confirmation.data('order-tracking-info');
this._vpv('/stats/ecom/order_confirmed/' + orderID);
this._rpc({
route: '/shop/tracking_last_order/',
}).then(function (o) {
self._trackGA('ecommerce:clear');
if (o.transaction && o.lines) {
self._trackGA('ecommerce:addTransaction', o.transaction);
_.forEach(o.lines, function (line) {
self._trackGA('ecommerce:addItem', line);
});
}
self._trackGA('ecommerce:send');
});
self._trackGA('event', 'purchase', json);
}
return this._super.apply(this, arguments);
@@ -57,16 +40,15 @@ publicWidget.registry.websiteSaleTracking = publicWidget.Widget.extend({
* @private
*/
_trackGA: function () {
var websiteGA = window.ga || function () {};
const websiteGA = window.gtag || function () {};
websiteGA.apply(this, arguments);
},
/**
* @private
*/
_vpv: function (page) { //virtual page view
this._trackGA('send', 'pageview', {
'page': page,
'title': document.title,
this._trackGA('event', 'page_view', {
'page_path': page,
});
},
@@ -74,6 +56,30 @@ publicWidget.registry.websiteSaleTracking = publicWidget.Widget.extend({
// Handlers
//--------------------------------------------------------------------------
/**
* @private
*/
_onViewItem(event, productTrackingInfo) {
const trackingInfo = {
'currency': productTrackingInfo['currency'],
'value': productTrackingInfo['price'],
'items': [productTrackingInfo],
};
this._trackGA('event', 'view_item', trackingInfo);
},
/**
* @private
*/
_onAddToCart(event, ...productsTrackingInfo) {
const trackingInfo = {
'currency': productsTrackingInfo[0]['currency'],
'value': productsTrackingInfo.reduce((acc, val) => acc + val['price'] * val['quantity'], 0),
'items': productsTrackingInfo,
};
this._trackGA('event', 'add_to_cart', trackingInfo);
},
/**
* @private
*/
@@ -110,4 +116,7 @@ publicWidget.registry.websiteSaleTracking = publicWidget.Widget.extend({
this._vpv('/stats/ecom/order_payment/' + method);
},
});
return publicWidget.registry.websiteSaleTracking;
});
@@ -0,0 +1,96 @@
odoo.define('website_sale.google_analytics', function (require) {
'use strict';
const tour = require("web_tour.tour");
const websiteSaleTracking = require('website_sale.tracking');
const WebsiteSale = require('website_sale.website_sale').WebsiteSale;
let itemId;
tour.register('google_analytics_view_item', {
test: true,
url: '/shop?search=Customizable Desk',
},
[
{
content: "select customizable desk",
trigger: '.oe_product_cart a:contains("Customizable Desk")',
},
{
content: "check view_item events",
trigger: '#product_detail',
run: () => {
// If we don't explicitly wait for the getCombinationInfo ajax
// call, the tour fails when running in phantomjs. The actual
// test is executed in a separate tour stage, triggered when
// the ajax call is ready.
WebsiteSale.getCombinationInfoPromise().then(() => {
$('body').addClass('combination_info_ready');
});
}
},
{
trigger: 'body.combination_info_ready',
run: () => {
const events = websiteSaleTracking.getEvents('view_item');
$('body').removeClass('combination_info_ready');
if (events.length !== 1) {
console.error('No view item was generated');
} else {
itemId = events[0]['item_id'];
}
}
},
{
content: 'select another variant',
trigger: 'ul.js_add_cart_variants ul.list-inline li:has(label.active) + li:has(label) input',
},
{
content: "check view_item events",
trigger: '#product_detail',
run: () => {
WebsiteSale.getCombinationInfoPromise().then(() => {
$('body').addClass('combination_info_ready');
});
}
},
{
trigger: 'body.combination_info_ready',
run: () => {
const events = websiteSaleTracking.getEvents('view_item');
$('body').removeClass('combination_info_ready');
if (events.length !== 2) {
console.error('No second view event was generated');
} else if (itemId === events[1]['item_id']) {
console.error('The second variant has the same id as the first one');
}
}
},
]);
tour.register('google_analytics_add_to_cart', {
test: true,
url: '/shop?search=Acoustic Bloc Screens',
},
[
{
content: "select Acoustic Bloc Screens",
trigger: '.oe_product_cart a:contains("Acoustic Bloc Screens")',
},
{
content: "click add to cart button on product page",
trigger: '#add_to_cart',
},
{
content: 'check add to cart event',
trigger: 'a:has(.my_cart_quantity:containsExact(1))',
run: () => {
const events = websiteSaleTracking.getEvents('add_to_cart');
if (events.length !== 1) {
console.error('No add to cart event was generated');
}
},
},
]);
});
@@ -0,0 +1,42 @@
odoo.define('website_sale.tour_mock_tracking', function (require) {
const publicWidget = require('web.public.widget');
const websiteSaleTracking = require('website_sale.tracking');
require('website_sale.website_sale');
const events = {
view_item: [],
add_to_cart: [],
remove_from_cart: [],
};
let promise;
publicWidget.registry.WebsiteSale.include({
_getCombinationInfo() {
promise = this._super(...arguments);
return promise;
},
});
publicWidget.registry.WebsiteSale.getCombinationInfoPromise = function () {
let result = promise;
promise = undefined;
return result;
};
websiteSaleTracking.include({
_onViewItem(event, data) {
events.view_item.push(data);
},
_onAddToCart(event, data) {
events.add_to_cart.push(data);
},
});
websiteSaleTracking.getEvents = function (eventName) {
return events[eventName];
};
});
@@ -99,6 +99,11 @@ class TestUi(HttpCaseWithUserDemo):
self.start_tour("/", 'website_sale_tour')
def test_05_google_analytics_tracking(self):
self.env['website'].browse(1).write({'google_analytics_key': 'G-XXXXXXXXXXX'})
self.start_tour("/shop", 'google_analytics_view_item')
self.start_tour("/shop", 'google_analytics_add_to_cart')
@odoo.tests.tagged('post_install', '-at_install')
class TestWebsiteSaleCheckoutAddress(TransactionCaseWithUserDemo):
+4 -2
View File
@@ -568,7 +568,9 @@
<t t-set="additional_title" t-value="product.name" />
<div itemscope="itemscope" itemtype="http://schema.org/Product" id="wrap" class="js_sale">
<div class="oe_structure oe_empty oe_structure_not_nearest" id="oe_structure_website_sale_product_1" data-editor-message="DROP BUILDING BLOCKS HERE TO MAKE THEM AVAILABLE ACROSS ALL PRODUCTS"/>
<section t-attf-class="container py-4 oe_website_sale #{'discount' if combination_info['has_discounted_price'] else ''}" id="product_detail" t-att-data-view-track="view_track and '1' or '0'">
<section t-attf-class="container py-4 oe_website_sale #{'discount' if combination_info['has_discounted_price'] else ''}" id="product_detail"
t-att-data-view-track="view_track and '1' or '0'"
t-att-data-product-tracking-info="json.dumps(request.env['product.template'].get_google_analytics_data(combination_info))">
<div class="row">
<div class="col-lg-6">
<ol class="breadcrumb mb-2">
@@ -1814,7 +1816,7 @@
</template>
<template id="payment_confirmation_status">
<div class="oe_website_sale_tx_status mt-3" t-att-data-order-id="order.id">
<div class="oe_website_sale_tx_status mt-3" t-att-data-order-id="order.id" t-att-data-order-tracking-info="json.dumps(order_tracking_info)">
<t t-set="payment_tx_id" t-value="order.get_portal_last_transaction()"/>
<div t-attf-class="card #{
(payment_tx_id.state == 'pending' and 'bg-info') or
@@ -305,8 +305,14 @@ publicWidget.registry.ProductComparison = publicWidget.Widget.extend(cartHandler
this.getCartHandlerOptions(ev);
// Override product image container for animation.
this.$itemImgContainer = this.$('#o_comparelist_table tr').first().find('td').eq(cellIndex);
const productId = parseInt($form.find('input[type="hidden"][name="product_id"]').first().val());
const $inputProduct = $form.find('input[type="hidden"][name="product_id"]').first();
const productId = parseInt($inputProduct.val());
if (productId) {
const productTrackingInfo = $inputProduct.data('product-tracking-info');
if (productTrackingInfo) {
productTrackingInfo.quantity = 1;
$inputProduct.trigger('add_to_cart_event', [productTrackingInfo]);
}
return this.addToCart({product_id: productId, add_qty: 1});
}
},
@@ -136,7 +136,8 @@
<form action="/shop/cart/update" method="post" class="text-center">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()" />
<input name="product_id" t-att-value="product.id" type="hidden"/>
<input name="product_id" t-att-value="product.id" type="hidden"
t-att-data-product-tracking-info="json.dumps(request.env['product.template'].get_google_analytics_data(combination_info))"/>
<a role="button" class="btn btn-primary btn-block a-submit" href="#"><i class="fa fa-shopping-cart mr-2"></i>Add to Cart</a>
</form>
</div>
@@ -77,9 +77,9 @@ class WebsiteSaleDelivery(WebsiteSale):
def order_2_return_dict(self, order):
""" Returns the tracking_cart dict of the order for Google analytics """
ret = super(WebsiteSaleDelivery, self).order_2_return_dict(order)
for line in order.order_line:
if line.is_delivery:
ret['transaction']['shipping'] = line.price_unit
delivery_line = order.order_line.filtered('is_delivery')
if delivery_line:
ret['shipping'] = delivery_line.price_unit
return ret
def _get_shop_payment_values(self, order, **kwargs):
@@ -80,6 +80,28 @@ publicWidget.registry.WebsiteSale.include({
* @param {Boolean} goToShop Triggers a page refresh to the url "shop/cart"
*/
_onModalSubmit: function (goToShop) {
const $product = $('#product_detail');
let currency;
if ($product.length) {
currency = $product.data('product-tracking-info')['currency'];
} else {
// Add to cart from /shop page
currency = this.$('[itemprop="priceCurrency"]').first().text();
}
const productsTrackingInfo = [];
this.$('.js_product.in_cart').each((i, el) => {
productsTrackingInfo.push({
'item_id': el.getElementsByClassName('product_id')[0].value,
'item_name': el.getElementsByClassName('product_display_name')[0].textContent,
'quantity': el.getElementsByClassName('js_quantity')[0].value,
'currency': currency,
'price': el.getElementsByClassName('oe_price')[0].getElementsByClassName('oe_currency_value')[0].textContent,
});
});
if (productsTrackingInfo) {
this.$el.trigger('add_to_cart_event', productsTrackingInfo);
}
this.optionalProductsModal.getAndCreateSelectedProducts()
.then((products) => {
const productAndOptions = JSON.stringify(products);
@@ -173,12 +173,18 @@ publicWidget.registry.ProductWishlist = publicWidget.Widget.extend(VariantMixin,
/**
* @private
*/
_addToCart: function (productID, qty_id) {
_addToCart: function (productID, qty) {
const $tr = this.$(`tr[data-product-id="${productID}"]`);
const productTrackingInfo = $tr.data('product-tracking-info');
if (productTrackingInfo) {
productTrackingInfo.quantity = qty;
$tr.trigger('add_to_cart_event', [productTrackingInfo]);
}
return this._rpc({
route: "/shop/cart/update_json",
params: {
product_id: parseInt(productID, 10),
add_qty: parseInt(qty_id, 10),
add_qty: parseInt(qty, 10),
display: false,
},
}).then(function (resp) {
@@ -163,7 +163,9 @@
<table class="table table-bordered table-striped table-hover text-center mt16 table-comparator " style="table-layout:auto" id="o_comparelist_table">
<body>
<t t-foreach="wishes" t-as="wish">
<tr t-att-data-wish-id='wish.id' t-att-data-product-id='wish.product_id.id'>
<t t-set="combination_info" t-value="wish.product_id._get_combination_info_variant()"/>
<tr t-att-data-wish-id='wish.id' t-att-data-product-id='wish.product_id.id'
t-att-data-product-tracking-info="json.dumps(request.env['product.template'].get_google_analytics_data(combination_info))">
<td class='td-img align-middle'>
<a t-att-href="wish.product_id.website_url">
<img t-attf-src="/web/image/product.product/#{wish.product_id.id}/image_128" class="img img-fluid" style="margin:auto;" alt="Product image"/>
@@ -175,7 +177,6 @@
<button type="button" class="btn btn-link o_wish_rm no-decoration"><small><i class='fa fa-trash-o'></i> Remove</small></button>
</td>
<td class="align-middle">
<t t-set="combination_info" t-value="wish.product_id._get_combination_info_variant()"/>
<t t-esc="combination_info['price']" t-options="{'widget': 'monetary', 'display_currency': website.pricelist_id.currency_id}"/>
<small t-if="wish.product_id.base_unit_price" class="cart_product_base_unit_price d-block text-muted" groups="website_sale.group_show_uom_price">
<t t-call='website_sale.base_unit_price'><t t-set='product' t-value='wish.product_id' /></t>