diff options
| author | Samuel Degueldre <sad@odoo.com> | 2020-03-11 07:36:03 +0000 |
|---|---|---|
| committer | Samuel Degueldre <sad@odoo.com> | 2020-06-10 11:44:33 +0000 |
| commit | c058f19bfa33b510addfed314aa27d7e5c7fdb18 (patch) | |
| tree | dc27563d414038ff848da560bf481de01d2aaa2e /addons | |
| parent | 016ed1a00f7a098ad8423235c82cee083bfa7e4d (diff) | |
[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>
Diffstat (limited to 'addons')
| -rw-r--r-- | addons/website/static/src/scss/website.scss | 37 | ||||
| -rw-r--r-- | addons/website_event_track/static/src/css/website_event_track.css | 32 | ||||
| -rw-r--r-- | addons/website_event_track/views/event_track_templates.xml | 12 | ||||
| -rw-r--r-- | addons/website_sale/controllers/main.py | 24 | ||||
| -rw-r--r-- | addons/website_sale/data/data.xml | 16 | ||||
| -rw-r--r-- | addons/website_sale/models/product.py | 14 | ||||
| -rw-r--r-- | addons/website_sale/security/ir.model.access.csv | 3 | ||||
| -rw-r--r-- | addons/website_sale/static/src/js/website_sale.editor.js | 408 | ||||
| -rw-r--r-- | addons/website_sale/static/src/scss/website_sale.editor.scss | 6 | ||||
| -rw-r--r-- | addons/website_sale/static/src/scss/website_sale.scss | 27 | ||||
| -rw-r--r-- | addons/website_sale/static/src/xml/website_sale_utils.xml | 9 | ||||
| -rw-r--r-- | addons/website_sale/views/product_views.xml | 2 | ||||
| -rw-r--r-- | addons/website_sale/views/snippets.xml | 26 | ||||
| -rw-r--r-- | addons/website_sale/views/templates.xml | 11 |
14 files changed, 501 insertions, 126 deletions
diff --git a/addons/website/static/src/scss/website.scss b/addons/website/static/src/scss/website.scss index 520102c1f7c..321f7792112 100644 --- a/addons/website/static/src/scss/website.scss +++ b/addons/website/static/src/scss/website.scss @@ -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; +} diff --git a/addons/website_event_track/static/src/css/website_event_track.css b/addons/website_event_track/static/src/css/website_event_track.css index 865dc812980..0940ceea082 100644 --- a/addons/website_event_track/static/src/css/website_event_track.css +++ b/addons/website_event_track/static/src/css/website_event_track.css @@ -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); diff --git a/addons/website_event_track/views/event_track_templates.xml b/addons/website_event_track/views/event_track_templates.xml index c9003a9e3bf..020b25c1923 100644 --- a/addons/website_event_track/views/event_track_templates.xml +++ b/addons/website_event_track/views/event_track_templates.xml @@ -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> diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py index f3901529aa7..6aa56b87e99 100644 --- a/addons/website_sale/controllers/main.py +++ b/addons/website_sale/controllers/main.py @@ -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) diff --git a/addons/website_sale/data/data.xml b/addons/website_sale/data/data.xml index e110dd493e6..c2fddedad54 100644 --- a/addons/website_sale/data/data.xml +++ b/addons/website_sale/data/data.xml @@ -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"> diff --git a/addons/website_sale/models/product.py b/addons/website_sale/models/product.py index effe4749833..57398dc3130 100644 --- a/addons/website_sale/models/product.py +++ b/addons/website_sale/models/product.py @@ -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( diff --git a/addons/website_sale/security/ir.model.access.csv b/addons/website_sale/security/ir.model.access.csv index 0e3f19844c8..479a8838622 100644 --- a/addons/website_sale/security/ir.model.access.csv +++ b/addons/website_sale/security/ir.model.access.csv @@ -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 diff --git a/addons/website_sale/static/src/js/website_sale.editor.js b/addons/website_sale/static/src/js/website_sale.editor.js index e9188bbad7d..4b1a7946ec9 100644 --- a/addons/website_sale/static/src/js/website_sale.editor.js +++ b/addons/website_sale/static/src/js/website_sale.editor.js @@ -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,6 +377,8 @@ 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; }, //-------------------------------------------------------------------------- @@ -184,16 +386,113 @@ options.registry.WebsiteSaleProductsItem = options.Class.extend({ //-------------------------------------------------------------------------- /** + * @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,28 +539,75 @@ 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()); + 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'; } - }).then(() => { - const menuEl = uiFragment.querySelector('[name="style"]'); - for (const checkbox of checkboxes) { - menuEl.appendChild(checkbox.el); + 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); }, //-------------------------------------------------------------------------- diff --git a/addons/website_sale/static/src/scss/website_sale.editor.scss b/addons/website_sale/static/src/scss/website_sale.editor.scss index 729bde03af2..658ed4f5e42 100644 --- a/addons/website_sale/static/src/scss/website_sale.editor.scss +++ b/addons/website_sale/static/src/scss/website_sale.editor.scss @@ -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; } diff --git a/addons/website_sale/static/src/scss/website_sale.scss b/addons/website_sale/static/src/scss/website_sale.scss index cb9b44b8ed4..42b2006a1e0 100644 --- a/addons/website_sale/static/src/scss/website_sale.scss +++ b/addons/website_sale/static/src/scss/website_sale.scss @@ -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 { diff --git a/addons/website_sale/static/src/xml/website_sale_utils.xml b/addons/website_sale/static/src/xml/website_sale_utils.xml index f1cf6a40c6a..8a470613325 100644 --- a/addons/website_sale/static/src/xml/website_sale_utils.xml +++ b/addons/website_sale/static/src/xml/website_sale_utils.xml @@ -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> diff --git a/addons/website_sale/views/product_views.xml b/addons/website_sale/views/product_views.xml index 2a7e1c81d94..f8387d9710b 100644 --- a/addons/website_sale/views/product_views.xml +++ b/addons/website_sale/views/product_views.xml @@ -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"> diff --git a/addons/website_sale/views/snippets.xml b/addons/website_sale/views/snippets.xml index 41810ec0ff2..3a3dec8dd97 100644 --- a/addons/website_sale/views/snippets.xml +++ b/addons/website_sale/views/snippets.xml @@ -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"> diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml index 9a0cba9ea4f..e814f8570b8 100644 --- a/addons/website_sale/views/templates.xml +++ b/addons/website_sale/views/templates.xml @@ -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"/> |
