[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:
Samuel Degueldre
2020-06-10 11:44:33 +00:00
parent 016ed1a00f
commit c058f19bfa
14 changed files with 502 additions and 127 deletions
@@ -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>
+1 -23
View File
@@ -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)
+13 -3
View File
@@ -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">
+8 -6
View File
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
6 access_product_public_category_public product.category.public model_product_public_category 1 0 0 0
7 access_product_pricelist_public product.pricelist.public product.model_product_pricelist 1 0 0 0
8 access_product_pricelist_item_public product.pricelist.item.public product.model_product_pricelist_item 1 0 0 0
9 access_product_style access_product_ribbon_public product.style.public product.ribbon.public website_sale.model_product_style website_sale.model_product_ribbon 1 0 0 0
10 access_product_ribbon_sale_manager product.ribbon.sale_manager website_sale.model_product_ribbon sales_team.group_sale_manager 1 1 1 1
11 access_product_attribute_public product.attribute public product.model_product_attribute 1 0 0 0
12 access_product_attribute_value_public product.attribute value public product.model_product_attribute_value 1 0 0 0
13 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>
+1 -1
View File
@@ -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">
+24 -2
View File
@@ -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">
+7 -4
View File
@@ -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'] &gt; 2"/>