[IMP] website_sale: let users create and edit custom product ribbons
Previously, there was only one static "Sale" ribbon. Users might want to have many types of ribbons, for different levels of sales, or to indicate that a product is sold out. This commit lets the user create custom ribbons and use them on prodcuts on the shop page. Additionally, setting a ribbon on a product used to require a reload, this is no longer the case. All ribbon modifications are stored in a cache and the RPCs to put them into effect are only done on save. task-1981170 Part of odoo/odoo#47574 Related: odoo/upgrade#1079 Related: odoo/design-themes#219 Signed-off-by: Quentin Smetz (qsm) <qsm@odoo.com>
This commit is contained in:
@@ -959,3 +959,40 @@ header {
|
||||
background-color: rgba(theme-color('primary'), 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
$ribbon-padding: 100px;
|
||||
.o_ribbon {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.o_ribbon_right {
|
||||
@include o-position-absolute($top: 0, $right: 0);
|
||||
padding: 0.5rem $ribbon-padding;
|
||||
// 0.708 is 1 - cos(45deg)
|
||||
// Transforms are applied right-to-left
|
||||
// Cannot use matrix because of the use of % values.
|
||||
transform: translateX(calc(-0.708 * (100% - #{2 * $ribbon-padding}))) rotate(45deg) translateX(calc(100% - #{$ribbon-padding}));
|
||||
transform-origin: top right;
|
||||
}
|
||||
|
||||
.o_ribbon_left {
|
||||
@include o-position-absolute($top: 0, $left: 0);
|
||||
padding: 0.5rem $ribbon-padding;
|
||||
transform: translateX(calc(0.708 * (100% - #{2 * $ribbon-padding}) - 100%)) rotate(-45deg) translateX($ribbon-padding);
|
||||
transform-origin: top right;
|
||||
}
|
||||
|
||||
.o_tag_right {
|
||||
@include o-position-absolute($top: 0, $right: 0);
|
||||
padding: 0.25rem 1rem;
|
||||
}
|
||||
|
||||
.o_tag_left {
|
||||
@include o-position-absolute($top: 0, $left: 0);
|
||||
padding: 0.25rem 1rem;
|
||||
}
|
||||
|
||||
@@ -1,32 +1,20 @@
|
||||
.o_wevent_event .ribbon-wrapper {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
z-index: 5;
|
||||
.o_wevent_event .o_wevent_sponsor {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.o_wevent_event .ribbon {
|
||||
font: bold 13px Sans-Serif;
|
||||
.o_wevent_event .o_ribbon {
|
||||
font: 12px Sans-Serif;
|
||||
color: #404040;
|
||||
text-align: center;
|
||||
-webkit-transform: rotate(45deg);
|
||||
-moz-transform: rotate(45deg);
|
||||
-ms-transform: rotate(45deg);
|
||||
-o-transform: rotate(45deg);
|
||||
position: relative;
|
||||
padding: 5px 0;
|
||||
left: -4px;
|
||||
top: 8px;
|
||||
width: 90px;
|
||||
padding-top: 2px;
|
||||
padding-bottom: 2px;
|
||||
-webkit-box-shadow: 0px 0px 3px rgba(0, 0, 0, 0.3);
|
||||
-moz-box-shadow: 0px 0px 3px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: 0px 0px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.o_wevent_event .ribbon.ribbon_Gold {
|
||||
.o_wevent_event .ribbon_Gold {
|
||||
background-color: #FDE21B;
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#E9CE0C), to(#FDE21B));
|
||||
background-image: -webkit-linear-gradient(top, #E9CE0C, #FDE21B);
|
||||
@@ -35,7 +23,7 @@
|
||||
background-image: -o-linear-gradient(top, #E9CE0C, #FDE21B);
|
||||
}
|
||||
|
||||
.o_wevent_event .ribbon.ribbon_Silver {
|
||||
.o_wevent_event .ribbon_Silver {
|
||||
background-color: #CCCCCC;
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#BBBBBB), to(#CCCCCC));
|
||||
background-image: -webkit-linear-gradient(top, #BBBBBB, #CCCCCC);
|
||||
@@ -44,7 +32,7 @@
|
||||
background-image: -o-linear-gradient(top, #BBBBBB, #CCCCCC);
|
||||
}
|
||||
|
||||
.o_wevent_event .ribbon.ribbon_Bronze {
|
||||
.o_wevent_event .ribbon_Bronze {
|
||||
background-color: #DB9141;
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#C2792A), to(#DB9141));
|
||||
background-image: -webkit-linear-gradient(top, #C2792A, #DB9141);
|
||||
|
||||
@@ -18,21 +18,17 @@
|
||||
<div class="row">
|
||||
<div t-attf-class="col-sm-6 col-lg-#{(len(event.sponsor_ids) > 6) and 2 or (12 // len(event.sponsor_ids))} text-center mb16 oe_sponsor" t-foreach="event.sponsor_ids" t-as="sponsor">
|
||||
<t t-if="sponsor.url">
|
||||
<a t-att-href="sponsor.url" style="position: relative; display: inline-block;" class="bg-white">
|
||||
<a t-att-href="sponsor.url" class="bg-white o_wevent_sponsor">
|
||||
<span t-field="sponsor.image_128"
|
||||
t-options='{"widget": "image", "class": "shadow"}'/>
|
||||
<div class="ribbon-wrapper">
|
||||
<div t-field="sponsor.sponsor_type_id" t-attf-class="ribbon ribbon_#{sponsor.sponsor_type_id.name}"/>
|
||||
</div>
|
||||
<span t-field="sponsor.sponsor_type_id" t-attf-class="o_ribbon o_ribbon_right ribbon_#{sponsor.sponsor_type_id.name}"/>
|
||||
</a>
|
||||
</t>
|
||||
<t t-if="not sponsor.url">
|
||||
<span style="position: relative; display: inline-block;" class="bg-white">
|
||||
<span class="bg-white o_wevent_sponsor">
|
||||
<span t-field="sponsor.image_128"
|
||||
t-options='{"widget": "image", "class": "shadow"}'/>
|
||||
<div class="ribbon-wrapper">
|
||||
<div t-field="sponsor.sponsor_type_id" t-attf-class="ribbon ribbon_#{sponsor.sponsor_type_id.name}"/>
|
||||
</div>
|
||||
<span t-field="sponsor.sponsor_type_id" t-attf-class="o_ribbon o_ribbon_right ribbon_#{sponsor.sponsor_type_id.name}"/>
|
||||
</span>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
@@ -71,7 +71,7 @@ class TableCompute(object):
|
||||
self.table[(pos // ppr) + y2][(pos % ppr) + x2] = False
|
||||
self.table[pos // ppr][pos % ppr] = {
|
||||
'product': p, 'x': x, 'y': y,
|
||||
'class': " ".join(x.html_class for x in p.website_style_ids if x.html_class)
|
||||
'ribbon': p.website_ribbon_id,
|
||||
}
|
||||
if index <= ppg:
|
||||
maxy = max(maxy, y + (pos // ppr))
|
||||
@@ -1054,28 +1054,6 @@ class WebsiteSale(http.Controller):
|
||||
})
|
||||
return "%s?enable_editor=1" % product.product_tmpl_id.website_url
|
||||
|
||||
@http.route(['/shop/change_styles'], type='json', auth='user')
|
||||
def change_styles(self, id, style_id):
|
||||
product = request.env['product.template'].browse(id)
|
||||
|
||||
remove = []
|
||||
active = False
|
||||
style_id = int(style_id)
|
||||
for style in product.website_style_ids:
|
||||
if style.id == style_id:
|
||||
remove.append(style.id)
|
||||
active = True
|
||||
break
|
||||
|
||||
style = request.env['product.style'].browse(style_id)
|
||||
|
||||
if remove:
|
||||
product.write({'website_style_ids': [(3, rid) for rid in remove]})
|
||||
if not active:
|
||||
product.write({'website_style_ids': [(4, style.id)]})
|
||||
|
||||
return not active
|
||||
|
||||
@http.route(['/shop/change_sequence'], type='json', auth='user')
|
||||
def change_sequence(self, id, sequence):
|
||||
product_tmpl = request.env['product.template'].browse(id)
|
||||
|
||||
@@ -18,9 +18,19 @@
|
||||
<field name="state">open</field>
|
||||
</record>
|
||||
|
||||
<record id="website_sale.image_promo" model="product.style">
|
||||
<field name="name">Sale Ribbon</field>
|
||||
<field name="html_class">oe_ribbon_promo</field>
|
||||
<record id="website_sale.sale_ribbon" model="product.ribbon">
|
||||
<field name="html">Sale</field>
|
||||
<field name="html_class">bg-success o_ribbon_left</field>
|
||||
</record>
|
||||
|
||||
<record id="website_sale.sold_out_ribbon" model="product.ribbon">
|
||||
<field name="html">Sold out</field>
|
||||
<field name="html_class">bg-danger o_ribbon_left</field>
|
||||
</record>
|
||||
|
||||
<record id="website_sale.new_ribbon" model="product.ribbon">
|
||||
<field name="html">New!</field>
|
||||
<field name="html_class">bg-primary o_ribbon_left</field>
|
||||
</record>
|
||||
|
||||
<record id="sales_team.salesteam_website_sales" model="crm.team">
|
||||
|
||||
@@ -9,12 +9,14 @@ from odoo.tools.translate import html_translate
|
||||
from odoo.osv import expression
|
||||
|
||||
|
||||
class ProductStyle(models.Model):
|
||||
_name = "product.style"
|
||||
_description = 'Product Style'
|
||||
class ProductRibbon(models.Model):
|
||||
_name = "product.ribbon"
|
||||
_description = 'Product ribbon'
|
||||
|
||||
name = fields.Char(string='Style Name', required=True)
|
||||
html_class = fields.Char(string='HTML Classes')
|
||||
html = fields.Char(string='Ribbon html', required=True)
|
||||
bg_color = fields.Char(string='Ribbon background color', required=False)
|
||||
text_color = fields.Char(string='Ribbon text color', required=False)
|
||||
html_class = fields.Char(string='Ribbon class', required=True, default='')
|
||||
|
||||
|
||||
class ProductPricelist(models.Model):
|
||||
@@ -186,7 +188,7 @@ class ProductTemplate(models.Model):
|
||||
help='Accessories show up when the customer reviews the cart before payment (cross-sell strategy).')
|
||||
website_size_x = fields.Integer('Size X', default=1)
|
||||
website_size_y = fields.Integer('Size Y', default=1)
|
||||
website_style_ids = fields.Many2many('product.style', string='Styles')
|
||||
website_ribbon_id = fields.Many2one('product.ribbon', string='Ribbon')
|
||||
website_sequence = fields.Integer('Website Sequence', help="Determine the display order in the Website E-commerce",
|
||||
default=lambda self: self._default_website_sequence(), copy=False)
|
||||
public_categ_ids = fields.Many2many(
|
||||
|
||||
@@ -6,7 +6,8 @@ access_product_category_pos_manager,product.public.category manager,model_produc
|
||||
access_product_public_category_public,product.category.public,model_product_public_category,,1,0,0,0
|
||||
access_product_pricelist_public,product.pricelist.public,product.model_product_pricelist,,1,0,0,0
|
||||
access_product_pricelist_item_public,product.pricelist.item.public,product.model_product_pricelist_item,,1,0,0,0
|
||||
access_product_style,product.style.public,website_sale.model_product_style,,1,0,0,0
|
||||
access_product_ribbon_public,product.ribbon.public,website_sale.model_product_ribbon,,1,0,0,0
|
||||
access_product_ribbon_sale_manager,product.ribbon.sale_manager,website_sale.model_product_ribbon,sales_team.group_sale_manager,1,1,1,1
|
||||
access_product_attribute_public,product.attribute public,product.model_product_attribute,,1,0,0,0
|
||||
access_product_attribute_value_public,product.attribute value public,product.model_product_attribute_value,,1,0,0,0
|
||||
access_product_product_attribute,product.template.attribute value public,product.model_product_template_attribute_value,,1,0,0,0
|
||||
|
||||
|
@@ -54,6 +54,197 @@ odoo.define('website_sale.editor', function (require) {
|
||||
|
||||
var options = require('web_editor.snippets.options');
|
||||
var publicWidget = require('web.public.widget');
|
||||
const {Class: EditorMenuBar} = require('web_editor.editor');
|
||||
const {qweb} = require('web.core');
|
||||
|
||||
EditorMenuBar.include({
|
||||
custom_events: Object.assign(EditorMenuBar.prototype.custom_events, {
|
||||
get_ribbons: '_onGetRibbons',
|
||||
get_ribbon_classes: '_onGetRibbonClasses',
|
||||
delete_ribbon: '_onDeleteRibbon',
|
||||
set_ribbon: '_onSetRibbon',
|
||||
set_product_ribbon: '_onSetProductRibbon',
|
||||
}),
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async willStart() {
|
||||
const _super = this._super.bind(this);
|
||||
let ribbons = [];
|
||||
if (this._isProductListPage()) {
|
||||
ribbons = await this._rpc({
|
||||
model: 'product.ribbon',
|
||||
method: 'search_read',
|
||||
fields: ['id', 'html', 'bg_color', 'text_color', 'html_class'],
|
||||
});
|
||||
}
|
||||
this.ribbons = Object.fromEntries(ribbons.map(ribbon => [ribbon.id, ribbon]));
|
||||
this.originalRibbons = Object.assign({}, this.ribbons);
|
||||
this.productTemplatesRibbons = [];
|
||||
this.deletedRibbonClasses = '';
|
||||
return _super(...arguments);
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async save() {
|
||||
const _super = this._super.bind(this);
|
||||
await this._saveRibbons();
|
||||
return _super(...arguments);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Saves the ribbons in the database.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async _saveRibbons() {
|
||||
if (!this._isProductListPage()) {
|
||||
return;
|
||||
}
|
||||
const originalIds = Object.keys(this.originalRibbons).map(id => parseInt(id));
|
||||
const currentIds = Object.keys(this.ribbons).map(id => parseInt(id));
|
||||
|
||||
const ribbons = Object.values(this.ribbons);
|
||||
const created = ribbons.filter(ribbon => !originalIds.includes(ribbon.id));
|
||||
const deletedIds = originalIds.filter(id => !currentIds.includes(id));
|
||||
const modified = ribbons.filter(ribbon => {
|
||||
if (created.includes(ribbon)) {
|
||||
return false;
|
||||
}
|
||||
const original = this.originalRibbons[ribbon.id];
|
||||
return Object.entries(ribbon).some(([key, value]) => value !== original[key]);
|
||||
});
|
||||
|
||||
const proms = [];
|
||||
let createdRibbonIds;
|
||||
if (created.length > 0) {
|
||||
proms.push(this._rpc({
|
||||
method: 'create',
|
||||
model: 'product.ribbon',
|
||||
args: [created.map(ribbon => {
|
||||
ribbon = Object.assign({}, ribbon);
|
||||
delete ribbon.id;
|
||||
return ribbon;
|
||||
})],
|
||||
}).then(ids => createdRibbonIds = ids));
|
||||
}
|
||||
|
||||
modified.forEach(ribbon => proms.push(this._rpc({
|
||||
method: 'write',
|
||||
model: 'product.ribbon',
|
||||
args: [[ribbon.id], ribbon],
|
||||
})));
|
||||
|
||||
if (deletedIds.length > 0) {
|
||||
proms.push(this._rpc({
|
||||
method: 'unlink',
|
||||
model: 'product.ribbon',
|
||||
args: [deletedIds],
|
||||
}));
|
||||
}
|
||||
|
||||
await Promise.all(proms);
|
||||
const localToServer = Object.assign(
|
||||
this.ribbons,
|
||||
Object.fromEntries(created.map((ribbon, index) => [ribbon.id, {id: createdRibbonIds[index]}])),
|
||||
{'false': {id: false}},
|
||||
);
|
||||
|
||||
// Building the final template to ribbon-id map
|
||||
const finalTemplateRibbons = this.productTemplatesRibbons.reduce((acc, {templateId, ribbonId}) => {
|
||||
acc[templateId] = ribbonId;
|
||||
return acc;
|
||||
}, {});
|
||||
// Inverting the relationship so that we have all templates that have the same ribbon to reduce RPCs
|
||||
const ribbonTemplates = Object.entries(finalTemplateRibbons).reduce((acc, [templateId, ribbonId]) => {
|
||||
if (!acc[ribbonId]) {
|
||||
acc[ribbonId] = [];
|
||||
}
|
||||
acc[ribbonId].push(parseInt(templateId));
|
||||
return acc;
|
||||
}, {});
|
||||
const setProductTemplateRibbons = Object.entries(ribbonTemplates)
|
||||
// If the ribbonId that the template had no longer exists, remove the ribbon (id = false)
|
||||
.map(([ribbonId, templateIds]) => {
|
||||
const id = currentIds.includes(parseInt(ribbonId)) ? ribbonId : false;
|
||||
return [id, templateIds];
|
||||
}).map(([ribbonId, templateIds]) => this._rpc({
|
||||
method: 'write',
|
||||
model: 'product.template',
|
||||
args: [templateIds, {'website_ribbon_id': localToServer[ribbonId].id}],
|
||||
}));
|
||||
return Promise.all(setProductTemplateRibbons);
|
||||
},
|
||||
/**
|
||||
* Checks whether the current page is the product list.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_isProductListPage() {
|
||||
return $('#products_grid').length !== 0;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns a copy of this.ribbons through a callback.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onGetRibbons(ev) {
|
||||
ev.data.callback(Object.assign({}, this.ribbons));
|
||||
},
|
||||
/**
|
||||
* Returns all ribbon classes, current and deleted, so they can be removed.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onGetRibbonClasses(ev) {
|
||||
const classes = Object.values(this.ribbons).reduce((classes, ribbon) => {
|
||||
return classes + ` ${ribbon.html_class}`;
|
||||
}, '') + this.deletedRibbonClasses;
|
||||
ev.data.callback(classes);
|
||||
},
|
||||
/**
|
||||
* Deletes a ribbon.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onDeleteRibbon(ev) {
|
||||
this.deletedRibbonClasses += ` ${this.ribbons[ev.data.id].html_class}`;
|
||||
delete this.ribbons[ev.data.id];
|
||||
},
|
||||
/**
|
||||
* Sets a ribbon;
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onSetRibbon(ev) {
|
||||
const {ribbon} = ev.data;
|
||||
const previousRibbon = this.ribbons[ribbon.id];
|
||||
if (previousRibbon) {
|
||||
this.deletedRibbonClasses += ` ${previousRibbon.html_class}`;
|
||||
}
|
||||
this.ribbons[ribbon.id] = ribbon;
|
||||
},
|
||||
/**
|
||||
* Sets which ribbon is used by a product template.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onSetProductRibbon(ev) {
|
||||
const {templateId, ribbonId} = ev.data;
|
||||
this.productTemplatesRibbons.push({templateId, ribbonId});
|
||||
},
|
||||
});
|
||||
|
||||
publicWidget.registry.websiteSaleCurrency = publicWidget.Widget.extend({
|
||||
selector: '.oe_website_sale',
|
||||
@@ -154,6 +345,7 @@ options.registry.WebsiteSaleGridLayout = options.Class.extend({
|
||||
});
|
||||
|
||||
options.registry.WebsiteSaleProductsItem = options.Class.extend({
|
||||
xmlDependencies: (options.Class.prototype.xmlDependencies || []).concat(['/website_sale/static/src/xml/website_sale_utils.xml']),
|
||||
events: _.extend({}, options.Class.prototype.events || {}, {
|
||||
'mouseenter .o_wsale_soptions_menu_sizes table': '_onTableMouseEnter',
|
||||
'mouseleave .o_wsale_soptions_menu_sizes table': '_onTableMouseLeave',
|
||||
@@ -164,10 +356,18 @@ options.registry.WebsiteSaleProductsItem = options.Class.extend({
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
willStart: function () {
|
||||
willStart: async function () {
|
||||
const _super = this._super.bind(this);
|
||||
this.ppr = this.$target.closest('[data-ppr]').data('ppr');
|
||||
this.productTemplateID = parseInt(this.$target.find('[data-oe-model="product.template"]').data('oe-id'));
|
||||
|
||||
this.ribbons = await new Promise(resolve => this.trigger_up('get_ribbons', {callback: resolve}));
|
||||
return _super(...arguments);
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
this._resetRibbonDummy();
|
||||
return this._super(...arguments);
|
||||
},
|
||||
/**
|
||||
@@ -177,23 +377,122 @@ options.registry.WebsiteSaleProductsItem = options.Class.extend({
|
||||
var listLayoutEnabled = this.$target.closest('#products_grid').hasClass('o_wsale_layout_list');
|
||||
this.$el.find('.o_wsale_soptions_menu_sizes')
|
||||
.toggleClass('d-none', listLayoutEnabled);
|
||||
// Ribbons may have been edited or deleted in another products' option, need to make sure they're up to date
|
||||
this.rerender = true;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Options
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
selectStyle(previewMode, widgetValue, params) {
|
||||
const proms = [this._super(...arguments)];
|
||||
if (params.cssProperty === 'background-color' && params.colorNames.includes(widgetValue)) {
|
||||
// Reset text-color when choosing a background-color class, so it uses the automatic text-color of the class.
|
||||
proms.push(this.selectStyle(previewMode, '', {applyTo: '.o_wsale_ribbon_dummy', cssProperty: 'color'}));
|
||||
}
|
||||
return Promise.all(proms);
|
||||
},
|
||||
/**
|
||||
* @see this.selectClass for params
|
||||
*/
|
||||
style: function (previewMode, widgetValue, params) {
|
||||
this._rpc({
|
||||
route: '/shop/change_styles',
|
||||
params: {
|
||||
'id': this.productTemplateID,
|
||||
'style_id': params.possibleValues[params.possibleValues.length - 1],
|
||||
},
|
||||
async setRibbon(previewMode, widgetValue, params) {
|
||||
this.$target[0].dataset.ribbonId = widgetValue;
|
||||
this.trigger_up('set_product_ribbon', {
|
||||
templateId: this.productTemplateID,
|
||||
ribbonId: widgetValue || false,
|
||||
});
|
||||
const ribbon = this.ribbons[widgetValue] || {html: '', bg_color: '', text_color: '', html_class: ''};
|
||||
const $ribbons = $(`[data-ribbon-id="${widgetValue}"] .o_ribbon:not(.o_wsale_ribbon_dummy)`);
|
||||
$ribbons.html(ribbon.html);
|
||||
let htmlClasses;
|
||||
this.trigger_up('get_ribbon_classes', {callback: classes => htmlClasses = classes});
|
||||
$ribbons.removeClass(htmlClasses);
|
||||
|
||||
$ribbons.addClass(ribbon.html_class || '');
|
||||
$ribbons.css('color', ribbon.text_color);
|
||||
$ribbons.css('background-color', ribbon.bg_color || '');
|
||||
|
||||
if (!this.ribbons[widgetValue]) {
|
||||
$(`[data-ribbon-id="${widgetValue}"]`).each((index, product) => delete product.dataset.ribbonId);
|
||||
}
|
||||
this._resetRibbonDummy();
|
||||
},
|
||||
/**
|
||||
* @see this.selectClass for params
|
||||
*/
|
||||
editRibbon(previewMode, widgetValue, params) {
|
||||
this.saveMethod = 'modify';
|
||||
this._toggleEditingUI(true);
|
||||
},
|
||||
/**
|
||||
* @see this.selectClass for params
|
||||
*/
|
||||
createRibbon(previewMode, widgetValue, params) {
|
||||
this.saveMethod = 'create';
|
||||
this.$ribbon.html('Ribbon text');
|
||||
this.$ribbon.addClass('bg-primary o_ribbon_left');
|
||||
this._toggleEditingUI(true);
|
||||
this.isCreating = true;
|
||||
},
|
||||
/**
|
||||
* @see this.selectClass for params
|
||||
*/
|
||||
async deleteRibbon(previewMode, widgetValue, params) {
|
||||
if (this.isCreating) {
|
||||
// Ribbon doesn't exist yet, simply discard.
|
||||
this.isCreating = false;
|
||||
this._resetRibbonDummy();
|
||||
return this._toggleEditingUI(false);
|
||||
}
|
||||
const {ribbonId} = this.$target[0].dataset;
|
||||
this.trigger_up('delete_ribbon', {id: ribbonId});
|
||||
this.ribbons = await new Promise(resolve => this.trigger_up('get_ribbons', {callback: resolve}));
|
||||
this.rerender = true;
|
||||
await this.setRibbon(false, ribbonId);
|
||||
},
|
||||
/**
|
||||
* @see this.selectClass for params
|
||||
*/
|
||||
async saveRibbon(previewMode, widgetValue, params) {
|
||||
const text = this.$ribbon.html().trim();
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
const ribbon = {
|
||||
'html': text,
|
||||
'bg_color': this.$ribbon[0].style.backgroundColor,
|
||||
'text_color': this.$ribbon[0].style.color,
|
||||
'html_class': this.$ribbon.attr('class').split(' ')
|
||||
.filter(c => !['d-none', 'o_wsale_ribbon_dummy', 'o_ribbon'].includes(c))
|
||||
.join(' '),
|
||||
};
|
||||
ribbon.id = this.saveMethod === 'modify' ? parseInt(this.$target[0].dataset.ribbonId) : Date.now();
|
||||
this.trigger_up('set_ribbon', {ribbon: ribbon});
|
||||
this.ribbons = await new Promise(resolve => this.trigger_up('get_ribbons', {callback: resolve}));
|
||||
this.rerender = true;
|
||||
await this.setRibbon(false, ribbon.id);
|
||||
},
|
||||
/**
|
||||
* @see this.selectClass for params
|
||||
*/
|
||||
setRibbonHtml(previewMode, widgetValue, params) {
|
||||
this.$ribbon.html(widgetValue);
|
||||
},
|
||||
/**
|
||||
* @see this.selectClass for params
|
||||
*/
|
||||
setRibbonMode(previewMode, widgetValue, params) {
|
||||
this.$ribbon[0].className = this.$ribbon[0].className.replace(/o_(ribbon|tag)_(left|right)/, `o_${widgetValue}_$2`);
|
||||
},
|
||||
/**
|
||||
* @see this.selectClass for params
|
||||
*/
|
||||
setRibbonPosition(previewMode, widgetValue, params) {
|
||||
this.$ribbon[0].className = this.$ribbon[0].className.replace(/o_(ribbon|tag)_(left|right)/, `o_$1_${widgetValue}`);
|
||||
},
|
||||
/**
|
||||
* @see this.selectClass for params
|
||||
@@ -227,6 +526,10 @@ options.registry.WebsiteSaleProductsItem = options.Class.extend({
|
||||
|
||||
// Adapt size array preview to fit ppr
|
||||
$size.find('tr td:nth-child(n + ' + parseInt(this.ppr + 1) + ')').hide();
|
||||
if (this.rerender) {
|
||||
this.rerender = false;
|
||||
return this._rerenderXML();
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
@@ -236,29 +539,76 @@ options.registry.WebsiteSaleProductsItem = options.Class.extend({
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_renderCustomWidgets: async function (uiFragment) {
|
||||
const checkboxes = [];
|
||||
return this._rpc({
|
||||
model: 'product.style',
|
||||
method: 'search_read',
|
||||
}).then(async data => {
|
||||
for (var k in data) {
|
||||
const checkboxWidget = this._registerUserValueWidget('we-checkbox', this, data[k]['name'], {
|
||||
dataAttributes: {
|
||||
'style': data[k]['id'],
|
||||
'selectClass': data[k]['html_class'],
|
||||
},
|
||||
});
|
||||
checkboxes.push(checkboxWidget);
|
||||
await checkboxWidget.appendTo(document.createDocumentFragment());
|
||||
}
|
||||
}).then(() => {
|
||||
const menuEl = uiFragment.querySelector('[name="style"]');
|
||||
for (const checkbox of checkboxes) {
|
||||
menuEl.appendChild(checkbox.el);
|
||||
}
|
||||
async _renderCustomXML(uiFragment) {
|
||||
const $select = $(uiFragment.querySelector('.o_wsale_ribbon_select'));
|
||||
this.ribbons = await new Promise(resolve => this.trigger_up('get_ribbons', {callback: resolve}));
|
||||
if (!this.$ribbon) {
|
||||
this._resetRibbonDummy();
|
||||
}
|
||||
const classes = this.$ribbon[0].className;
|
||||
this.$ribbon[0].className = '';
|
||||
const defaultTextColor = window.getComputedStyle(this.$ribbon[0]).color;
|
||||
this.$ribbon[0].className = classes;
|
||||
Object.values(this.ribbons).forEach(ribbon => {
|
||||
const colorClasses = ribbon.html_class
|
||||
.split(' ')
|
||||
.filter(className => !/^o_(ribbon|tag)_(left|right)$/.test(className))
|
||||
.join(' ');
|
||||
$select.append(qweb.render('website_sale.ribbonSelectItem', {
|
||||
ribbon,
|
||||
colorClasses,
|
||||
isTag: /o_tag_(left|right)/.test(ribbon.html_class),
|
||||
isLeft: /o_(tag|ribbon)_left/.test(ribbon.html_class),
|
||||
textColor: ribbon.text_color || colorClasses ? 'currentColor' : defaultTextColor,
|
||||
}));
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async _computeWidgetState(methodName, params) {
|
||||
const classList = this.$ribbon[0].classList;
|
||||
switch (methodName) {
|
||||
case 'setRibbon':
|
||||
return this.$target.attr('data-ribbon-id') || '';
|
||||
case 'setRibbonHtml':
|
||||
return this.$ribbon.html();
|
||||
case 'setRibbonMode': {
|
||||
if (classList.contains('o_ribbon_left') || classList.contains('o_ribbon_right')) {
|
||||
return 'ribbon';
|
||||
}
|
||||
return 'tag';
|
||||
}
|
||||
case 'setRibbonPosition': {
|
||||
if (classList.contains('o_tag_left') || classList.contains('o_ribbon_left')) {
|
||||
return 'left';
|
||||
}
|
||||
return 'right';
|
||||
}
|
||||
}
|
||||
return this._super(methodName, params);
|
||||
},
|
||||
/**
|
||||
* Toggles the UI mode between select and create/edit mode.
|
||||
*
|
||||
* @private
|
||||
* @param {Boolean} state true to activate editing UI, false to deactivate.
|
||||
*/
|
||||
_toggleEditingUI(state) {
|
||||
this.$el.find('[data-name="ribbon_options"]').toggleClass('d-none', state);
|
||||
this.$el.find('[data-name="ribbon_customize_opt"]').toggleClass('d-none', !state);
|
||||
},
|
||||
/**
|
||||
* Creates a copy of current ribbon to manipulate for edition/creation.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_resetRibbonDummy() {
|
||||
if (this.$ribbon) {
|
||||
this.$ribbon.remove();
|
||||
}
|
||||
this.$ribbon = this.$('.o_ribbon').clone().addClass('d-none o_wsale_ribbon_dummy').appendTo(this.$target);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
|
||||
@@ -26,6 +26,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.o_wsale_color_preview {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
border: 1px solid white;
|
||||
}
|
||||
|
||||
.oe_drop_zone + .s_wsale_products_recently_viewed {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
@@ -132,28 +132,6 @@ $o-wsale-products-layout-grid-gutter-width: min($grid-gutter-width / 2, $o-wsale
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Ribbon for promotions on products
|
||||
.ribbon-wrapper {
|
||||
display: none;
|
||||
width: 85px;
|
||||
height: 88px;
|
||||
z-index: 5;
|
||||
overflow: hidden;
|
||||
@include o-position-absolute($left: 0, $top: 0);
|
||||
}
|
||||
.ribbon {
|
||||
font: bold 15px Sans-Serif;
|
||||
color: white;
|
||||
text-align: center;
|
||||
transform: rotate(-45deg);
|
||||
position: relative;
|
||||
padding: 7px 0;
|
||||
left: -31px;
|
||||
top: 14px;
|
||||
width: 120px;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
// Options relative to where the product card is put
|
||||
@@ -167,11 +145,6 @@ $o-wsale-products-layout-grid-gutter-width: min($grid-gutter-width / 2, $o-wsale
|
||||
@include o-position-absolute(auto, 0, 0, 0); // The wrapper is always relatively positioned
|
||||
}
|
||||
}
|
||||
|
||||
// Enable product promo ribbon
|
||||
&.oe_ribbon_promo .ribbon-wrapper {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
#products_grid {
|
||||
|
||||
@@ -37,4 +37,13 @@
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Products Search Bar autocomplete item -->
|
||||
<we-button t-name="website_sale.ribbonSelectItem" t-att-data-set-ribbon="ribbon.id">
|
||||
<t t-raw="ribbon.html"/>
|
||||
<span t-attf-class="fa fa-#{isTag ? 'tag' : 'bookmark'} ml-auto"></span>
|
||||
<span t-attf-class="fa fa-arrow-#{isLeft ? 'left' : 'right'} ml-1"></span>
|
||||
<span t-attf-class="o_wsale_color_preview #{colorClasses} ml-1" t-attf-style="background-color: #{ribbon.bg_color}"></span>
|
||||
<span t-attf-class="o_wsale_color_preview #{colorClasses} ml-1" t-attf-style="background-color: #{textColor} !important;"></span>
|
||||
</we-button>
|
||||
|
||||
</templates>
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
<field name="public_categ_ids" widget="many2many_tags" string="Categories"/>
|
||||
<field name="alternative_product_ids" widget="many2many_tags" domain="[('id', '!=', active_id), '|', ('company_id', '=', company_id), ('company_id', '=', False)]"/>
|
||||
<field name="accessory_product_ids" widget="many2many_tags"/>
|
||||
<field name="website_style_ids" widget="many2many_tags" groups="base.group_no_one"/>
|
||||
<field name="website_ribbon_id" groups="base.group_no_one"/>
|
||||
</group>
|
||||
</group>
|
||||
<group name="product_template_images" string="Extra Product Media">
|
||||
|
||||
@@ -62,8 +62,30 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div name="style" data-no-preview="true">
|
||||
<!-- Placeholder to be filled in JS -->
|
||||
<we-row data-name="ribbon_options">
|
||||
<we-select string="Ribbon" data-no-preview="true" class="o_wsale_ribbon_select">
|
||||
<we-button data-set-ribbon="" data-name="no_ribbon_opt">None</we-button>
|
||||
<!-- Ribbons are filled in JS -->
|
||||
</we-select>
|
||||
<we-button data-edit-ribbon="" class="fa fa-edit" data-no-preview="true" data-dependencies="!no_ribbon_opt"/>
|
||||
<we-button data-create-ribbon="" class="fa fa-plus text-success" data-no-preview="true"/>
|
||||
</we-row>
|
||||
<div class="d-none" data-name="ribbon_customize_opt">
|
||||
<we-row string="Ribbon">
|
||||
<we-input data-set-ribbon-html="" data-apply-to=".o_wsale_ribbon_dummy" data-no-preview="true"/>
|
||||
<we-button class="fa fa-check" data-save-ribbon="" title="Validate" data-no-preview="true"/>
|
||||
<we-button class="fa fa-trash" data-delete-ribbon="" title="Delete" data-no-preview="true"/>
|
||||
</we-row>
|
||||
<we-colorpicker string="Background Color" title="" data-select-style="" data-apply-to=".o_wsale_ribbon_dummy" data-css-property="background-color" data-color-prefix="bg-" data-no-preview="true"/>
|
||||
<we-colorpicker string="Text Color" title="" data-select-style="" data-apply-to=".o_wsale_ribbon_dummy" data-css-property="color" data-no-preview="true"/>
|
||||
<we-select string="Mode">
|
||||
<we-button data-set-ribbon-mode="ribbon">Slanted</we-button>
|
||||
<we-button data-set-ribbon-mode="tag">Tag</we-button>
|
||||
</we-select>
|
||||
<we-select string="Position">
|
||||
<we-button data-set-ribbon-position="left">Left</we-button>
|
||||
<we-button data-set-ribbon-position="right">Right</we-button>
|
||||
</we-select>
|
||||
</div>
|
||||
|
||||
<div name="reordering" data-no-preview="true">
|
||||
|
||||
@@ -132,9 +132,6 @@
|
||||
itemscope="itemscope" itemtype="http://schema.org/Product">
|
||||
<div class="card-body p-1 oe_product_image">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()" />
|
||||
<div class="ribbon-wrapper">
|
||||
<a href="#" role="button" class="ribbon btn btn-danger">Sale</a>
|
||||
</div>
|
||||
<a t-att-href="product_href" class="d-block h-100" itemprop="url">
|
||||
<span t-field="product.image_1920"
|
||||
t-options="{'widget': 'image', 'preview_image': 'image_1024' if product_image_big else 'image_256'}"
|
||||
@@ -156,6 +153,10 @@
|
||||
</div>
|
||||
<div class="o_wsale_product_btn"/>
|
||||
</div>
|
||||
<t t-set="bg_color" t-value="td_product['ribbon']['bg_color'] or ''"/>
|
||||
<t t-set="text_color" t-value="td_product['ribbon']['text_color']"/>
|
||||
<t t-set="bg_class" t-value="td_product['ribbon']['html_class']"/>
|
||||
<span t-attf-class="o_ribbon #{bg_class}" t-attf-style="#{text_color and ('color: %s; ' % text_color)}#{bg_color and 'background-color:' + bg_color}" t-raw="td_product['ribbon']['html'] or ''"/>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
@@ -232,9 +233,11 @@
|
||||
<t t-foreach="tr_product" t-as="td_product">
|
||||
<t t-if="td_product">
|
||||
<t t-set="product" t-value="td_product['product']" />
|
||||
<!-- We use t-attf-class here to allow easier customization -->
|
||||
<td t-att-colspan="td_product['x'] != 1 and td_product['x']"
|
||||
t-att-rowspan="td_product['y'] != 1 and td_product['y']"
|
||||
t-attf-class="oe_product #{td_product['class']}">
|
||||
t-attf-class="oe_product"
|
||||
t-att-data-ribbon-id="td_product['ribbon'].id">
|
||||
<div t-attf-class="o_wsale_product_grid_wrapper o_wsale_product_grid_wrapper_#{td_product['x']}_#{td_product['y']}">
|
||||
<t t-call="website_sale.products_item">
|
||||
<t t-set="product_image_big" t-value="td_product['x'] + td_product['y'] > 2"/>
|
||||
|
||||
Reference in New Issue
Block a user