[IMP] website, website_sale: support recently viewed in product snippet

Before this commit the recently viewed products were handled differently
from the dynamic product snippet.

After this commit the dynamic product snippet supports the functionality
of the recently viewed products snippet:
- its template used to show each product
- its filter of displayed products
- its "Add to cart" feature (with the animation)
- its "Forget" feature (only applicable when viewing recently viewed)
Also added "Accessories" and "Recently Sold With" filterings.

Several kinds of filtering can be combined.
- Products can be restricted to a given category
  - Pseudo categories for all products or current category can be used
- Products can be restricted to matching names
- Products can be obtained from one of the following:
  - Newest
  - Latest sold: ordered by descending frequency in last 8 orders
  - Latest viewed
  - Accessories (of the current product)
  - Recently sold with (the current product)
Accessories and recently sold with are only available in product pages.

Also, generic templates have been considered useless and the whole
mechanism allowing them has been removed (this includes the
pre-rendering mechanism for the fields specified in the filter).

task-2453416
See https://github.com/odoo/upgrade/pull/2153

Description of the issue/feature this PR addresses:

Current behavior before PR:

Desired behavior after PR is merged:

--
I confirm I have signed the CLA and read the PR guidelines at www.odoo.com/submit-pr

closes odoo/odoo#65554

Related: odoo/upgrade#2153
Signed-off-by: Jérémy Kersten (jke) <jke@openerp.com>
This commit is contained in:
Odoo's Mergebot
2021-03-26 23:34:42 +01:00
committed by GitHub
23 changed files with 593 additions and 575 deletions
+17 -9
View File
@@ -5,7 +5,6 @@ import datetime
import json
import os
import logging
import pytz
import requests
import werkzeug.urls
import werkzeug.utils
@@ -18,7 +17,8 @@ import odoo
from odoo import http, models, fields, _
from odoo.http import request
from odoo.tools import OrderedSet
from odoo.osv import expression
from odoo.tools import OrderedSet, escape_psql
from odoo.addons.http_routing.models.ir_http import slug, slugify, _guess_mimetype
from odoo.addons.web.controllers.main import Binary
from odoo.addons.portal.controllers.portal import pager as portal_pager
@@ -277,18 +277,26 @@ class Website(Home):
return dynamic_filter and dynamic_filter.render(template_key, limit, search_domain, with_sample) or ''
@http.route('/website/snippet/options_filters', type='json', auth='user', website=True)
def get_dynamic_snippet_filters(self):
def get_dynamic_snippet_filters(self, model_name=None, search_domain=None):
domain = request.website.website_domain()
if search_domain:
domain = expression.AND([domain, search_domain])
if model_name:
domain = expression.AND([
domain,
['|', ('filter_id.model_id', '=', model_name), ('action_server_id.model_id.model', '=', model_name)]
])
dynamic_filter = request.env['website.snippet.filter'].sudo().search_read(
request.website.website_domain(), ['id', 'name', 'limit']
domain, ['id', 'name', 'limit', 'model_name'], order='id asc'
)
return dynamic_filter
@http.route('/website/snippet/filter_templates', type='json', auth='public', website=True)
def get_dynamic_snippet_templates(self, filter_id=False):
# todo: if filter_id.model -> filter template
templates = request.env['ir.ui.view'].sudo().search_read(
[['key', 'ilike', '.dynamic_filter_template_'], ['type', '=', 'qweb']], ['key', 'name']
)
def get_dynamic_snippet_templates(self, filter_name=False):
domain = [['key', 'ilike', '.dynamic_filter_template_'], ['type', '=', 'qweb']]
if filter_name:
domain.append(['key', 'ilike', escape_psql('_%s_' % filter_name)])
templates = request.env['ir.ui.view'].sudo().search_read(domain, ['key', 'name'])
return templates
# ------------------------------------------------------
-64
View File
@@ -715,70 +715,6 @@
</data>
<data>
<!-- Filter for Dynamic Snippet -->
<record id="dynamic_snippet_country_filter" model="ir.filters">
<field name="name">Countries</field>
<field name="model_id">res.country</field>
<field name="user_id" eval="False" />
</record>
<!-- Template for Dynamic Snippet -->
<template id="dynamic_filter_template_image_title_footer" name="Image Title Footer Card">
<t t-foreach="to_generic(records)" t-as="record">
<t t-set="fields" t-value="list(record['fields'].values())"/>
<t t-set="fieldsImg" t-value="list(record['image_fields'].values())"/>
<t t-set="call_to_action_url" t-value="record['call_to_action_url']"/>
<div class="card h-100" t-att-data-url="call_to_action_url">
<div t-if="is_sample" class="h5 o_ribbon_right bg-primary text-uppercase">Sample</div>
<img t-if="len(fieldsImg) > 0" class="card-img-top p-3" loading="lazy" t-att-src="fieldsImg[0]"/>
<div class="card-body">
<div t-if="len(fields) > 0" class="card-title h5">
<t t-raw="fields[0]"/>
</div>
<div t-if="len(fields) > 1" class="card-text">
<t t-raw="fields[1]"/>
</div>
</div>
<div class="card-footer d-flex align-items-center">
<div t-if="len(fields) > 2" class="card-text text-center">
<t t-raw="fields[2]"/>
</div>
<a t-if="call_to_action" class="btn btn-primary ml-auto" t-att-href="call_to_action_url">
<i class="fa fa-fw fa-eye"/>
</a>
</div>
</div>
</t>
</template>
<template id="dynamic_filter_template_header_image_footer_card" name="Header Image Footer Card">
<t t-foreach="to_generic(records)" t-as="record">
<t t-set="fields" t-value="list(record['fields'].values())"/>
<t t-set="fieldsImg" t-value="list(record['image_fields'].values())"/>
<div class="card h-100" t-att-data-url="record['call_to_action_url']">
<div t-if="is_sample" class="h5 o_ribbon_right bg-primary text-uppercase">Sample</div>
<div t-if="len(fields) > 0" class="card-header h5">
<t t-raw="fields[0]"/>
</div>
<div class="card-body">
<img t-if="len(fieldsImg) > 0" class="card-img-top p-3" loading="lazy" t-att-src="fieldsImg[0]"/>
<div t-if="len(fields) > 1" class="card-text">
<t t-raw="fields[1]"/>
</div>
</div>
<div class="card-footer d-flex align-items-center">
<div t-if="len(fields) > 2" class="card-text w-100 text-center">
<t t-raw="fields[2]"/>
</div>
</div>
</div>
</t>
</template>
<!-- Dynamic Snippet Data Source -->
<record id="dynamic_snippet_data_source_country" model="website.snippet.filter">
<field name="field_names">name,code,image_url:image,phone_code:char</field>
<field name="filter_id" ref="website.dynamic_snippet_country_filter"/>
<field name="limit" eval="16"/>
<field name="name">Countries</field>
</record>
<record id="group_multi_website" model="res.groups">
<field name="name">Multi-website</field>
<field name="category_id" ref="base.module_category_hidden"/>
+43 -97
View File
@@ -25,10 +25,15 @@ class WebsiteSnippetFilter(models.Model):
filter_id = fields.Many2one('ir.filters', 'Filter', ondelete='cascade')
limit = fields.Integer(help='The limit is the maximum number of records retrieved', required=True)
website_id = fields.Many2one('website', string='Website', ondelete='cascade')
model_name = fields.Char(string='Model name', compute='_compute_model_name')
@api.model
def escape_falsy_as_empty(self, s):
return escape(s) if s else ''
@api.depends('filter_id', 'action_server_id')
def _compute_model_name(self):
for snippet_filter in self:
if snippet_filter.filter_id:
snippet_filter.model_name = snippet_filter.filter_id.model_id
else: # self.action_server_id
snippet_filter.model_name = snippet_filter.action_server_id.model_id.model
@api.constrains('action_server_id', 'filter_id')
def _check_data_source_is_provided(self):
@@ -63,13 +68,11 @@ class WebsiteSnippetFilter(models.Model):
records = self._prepare_values(limit, search_domain)
is_sample = with_sample and not records
if is_sample:
records = self._prepare_sample()
records = self._prepare_sample(limit)
View = self.env['ir.ui.view'].sudo().with_context(inherit_branding=False)
escaped_records = self._record_to_escaped_values(records)
content = View._render_template(template_key, dict(
records=escaped_records,
records=records,
is_sample=is_sample,
to_generic=self._to_generic,
)).decode('utf-8')
return [ET.tostring(el, encoding='utf-8') for el in ET.fromstring('<root>%s</root>' % content).getchildren()]
@@ -81,6 +84,8 @@ class WebsiteSnippetFilter(models.Model):
if self.filter_id:
filter_sudo = self.filter_id.sudo()
domain = filter_sudo._get_eval_domain()
if 'website_id' in self.env[filter_sudo.model_id]:
domain = expression.AND([domain, self.env['website'].get_current_website().website_domain()])
if 'is_published' in self.env[filter_sudo.model_id]:
domain = expression.AND([domain, [('is_published', '=', True)]])
if search_domain:
@@ -106,78 +111,6 @@ class WebsiteSnippetFilter(models.Model):
_logger.warning("The provided domain %s in 'ir.actions.server' generated a MissingError in '%s'", search_domain, self._name)
return []
def _to_generic(self, records):
"""Converts a list of dictionary records into the structure used by generic templates for rendering:
[{
'fields':
OrderedDict([
('name', 'Afghanistan'),
('code', 'AF'),
]),
'image_fields':
OrderedDict([
('image', '/web/image/res.country/3/image?unique=5d9b44e')
]),
'call_to_action_url': 'some/location'
}, ... , ...]
@param records: records as dictionaries of non-escaped values associated to field names
@return List of Dict matching the rendering structure for generic templates
"""
meta_data = self._get_filter_meta_data()
result = []
for record in records:
data = {
'fields': OrderedDict({}),
'image_fields': OrderedDict({}),
}
for field_name, field_widget in meta_data.items():
value = record[field_name]
if field_widget in ('binary', 'image'):
data['image_fields'][field_name] = value
else:
data['fields'][field_name] = value
data['call_to_action_url'] = record.get('call_to_action_url')
result.append(data)
return result
def _record_to_escaped_values(self, records):
"""Converts a list of dictionary records into rendered values
@param records: records as dictionaries of non-escaped values associated to field names
@return List of Dict containing rendered values of each field
"""
meta_data = self._get_filter_meta_data()
result = []
for record in records:
data = {}
for field_name, field_widget in meta_data.items():
value = record[field_name]
if field_widget in ('binary', 'image'):
data[field_name] = self.escape_falsy_as_empty(value)
elif field_widget == 'monetary':
FieldMonetary = self.env['ir.qweb.field.monetary']
website_currency = self._get_website_currency()
data[field_name] = FieldMonetary.value_to_html(
value,
{'display_currency': website_currency}
)
elif 'ir.qweb.field.%s' % field_widget in self.env:
options = {}
if field_widget == 'html':
options['template_options'] = {}
data[field_name] = self.env['ir.qweb.field.%s' % field_widget].value_to_html(
value, options)
else:
data[field_name] = self.escape_falsy_as_empty(value)
data['call_to_action_url'] = record.get('call_to_action_url')
result.append(data)
return result
def _get_field_name_and_type(self, model, field_name):
"""
Separates the name and the widget type
@@ -189,7 +122,7 @@ class WebsiteSnippetFilter(models.Model):
"""
field_name, _, field_widget = field_name.partition(":")
if not field_widget:
field = model._fields.get(field_name) if model else None
field = model._fields.get(field_name)
if field:
field_type = field.type
elif 'image' in field_name:
@@ -206,15 +139,14 @@ class WebsiteSnippetFilter(models.Model):
@return OrderedDict containing the widget type for each field name
"""
model = self.env[self.filter_id.model_id] if self.filter_id else (
self.action_server_id.model_id if self.action_server_id else None)
model = self.env[self.model_name]
meta_data = OrderedDict({})
for field_name in self.field_names.split(","):
field_name, field_widget = self._get_field_name_and_type(model, field_name)
meta_data[field_name] = field_widget
return meta_data
def _prepare_sample(self, length=4):
def _prepare_sample(self, length=6):
"""
Generates sample data and returns it the right format for render.
@@ -222,17 +154,29 @@ class WebsiteSnippetFilter(models.Model):
@return Array of objets with a value associated to each name in field_names
"""
if not length:
return []
records = self._prepare_sample_records(length)
return self._filter_records_to_values(records, is_sample=True)
def _prepare_sample_records(self, length):
"""
Generates sample records.
@param length: Number of sample records to generate
@return List of of sample records
"""
if not length:
return []
sample = []
model = self.env[self.filter_id.model_id] if self.filter_id else (
self.action_server_id.model_id if self.action_server_id else None)
model = self.env[self.model_name]
sample_data = self._get_hardcoded_sample(model)
for index in range(0, length):
single_sample_data = sample_data[index % len(sample_data)].copy()
self._fill_sample(single_sample_data, index)
sample.append(single_sample_data)
sample.append(model.new(single_sample_data))
return sample
def _fill_sample(self, sample, index):
@@ -243,19 +187,17 @@ class WebsiteSnippetFilter(models.Model):
@param index: Index of the sample within the dataset
"""
meta_data = self._get_filter_meta_data()
model = self.env[self.model_name]
for field_name, field_widget in meta_data.items():
if field_name not in sample:
if field_widget == 'binary':
if field_name not in sample and field_name in model:
if field_widget in ('image', 'binary'):
sample[field_name] = None
elif field_widget == 'image':
sample[field_name] = '/web/image'
elif field_widget == 'monetary':
sample[field_name] = randint(100, 10000) / 10.0
elif field_widget in ('integer', 'float'):
sample[field_name] = index
else:
sample[field_name] = _('Sample %s', index + 1)
sample['call_to_action_url'] = ''
return sample
def _get_hardcoded_sample(self, model):
@@ -268,30 +210,33 @@ class WebsiteSnippetFilter(models.Model):
"""
return [{}]
def _filter_records_to_values(self, records):
def _filter_records_to_values(self, records, is_sample=False):
"""
Extract the fields from the data source 'records' and put them into a dictionary of values
@param records: Model records returned by the filter
@param is_sample: True if conversion if for sample records
@return List of dict associating the field value to each field name
"""
self.ensure_one()
meta_data = self._get_filter_meta_data()
values = []
model = self.env[self.filter_id.model_id]
model = self.env[self.model_name]
Website = self.env['website']
for record in records:
data = {}
for field_name, field_widget in meta_data.items():
field = model._fields.get(field_name)
if field.type == 'binary':
data[field_name] = Website.image_url(record, field_name)
if field and field.type in ('binary', 'image'):
if is_sample:
data[field_name] = record[field_name].decode('utf8') if field_name in record else '/web/image'
else:
data[field_name] = Website.image_url(record, field_name)
elif field_widget == 'monetary':
model_currency = None
if field.type == 'monetary':
if field and field.type == 'monetary':
model_currency = record[record[field_name].currency_field]
elif 'currency_id' in model._fields:
model_currency = record['currency_id']
@@ -309,6 +254,7 @@ class WebsiteSnippetFilter(models.Model):
data[field_name] = record[field_name]
data['call_to_action_url'] = 'website_url' in record and record['website_url']
data['_record'] = record
values.append(data)
return values
@@ -40,7 +40,6 @@ const DynamicSnippet = publicWidget.Widget.extend({
return this._super.apply(this, arguments).then(
() => Promise.all([
this._fetchData(),
this._manageWarningMessageVisibility()
])
);
},
@@ -53,7 +52,6 @@ const DynamicSnippet = publicWidget.Widget.extend({
.then(() => {
this._setupSizeChangedManagement(true);
this._render();
this._toggleVisibility(true);
});
},
/**
@@ -97,6 +95,13 @@ const DynamicSnippet = publicWidget.Widget.extend({
_getSearchDomain: function () {
return [];
},
/**
* Method to be overridden in child components in order to add custom parameters if needed.
* @private
*/
_getRpcParameters: function () {
return {};
},
/**
* Fetches the data.
* @private
@@ -106,13 +111,13 @@ const DynamicSnippet = publicWidget.Widget.extend({
return this._rpc(
{
'route': '/website/snippet/filters',
'params': {
'params': Object.assign({
'filter_id': parseInt(this.$el.get(0).dataset.filterId),
'template_key': this.$el.get(0).dataset.templateKey,
'limit': parseInt(this.$el.get(0).dataset.numberOfRecords),
'search_domain': this._getSearchDomain(),
'with_sample': this.editableMode,
},
}, this._getRpcParameters()),
})
.then(
(data) => {
@@ -126,23 +131,6 @@ const DynamicSnippet = publicWidget.Widget.extend({
});
}
},
/**
*
* @private
*/
_mustMessageWarningBeHidden: function() {
return this._isConfigComplete() || !this.editableMode;
},
/**
*
* @private
*/
_manageWarningMessageVisibility: async function () {
this.$el.find('.missing_option_warning').toggleClass(
'd-none',
this._mustMessageWarningBeHidden()
);
},
/**
* Method to be overridden in child components in order to prepare content
* before rendering.
@@ -178,9 +166,11 @@ const DynamicSnippet = publicWidget.Widget.extend({
* @private
*/
_render: function () {
if (this.data.length) {
if (this.data.length > 0 || this.editableMode) {
this.$el.removeClass('d-none');
this._prepareContent();
} else {
this.$el.addClass('d-none');
this.renderedContent = '';
}
this._renderContent();
@@ -11,7 +11,12 @@ const dynamicSnippetOptions = options.Class.extend({
*/
init: function () {
this._super.apply(this, arguments);
// specify model name in subclasses to filter the list of available model record filters
this.modelNameFilter = undefined;
this.contextualFilterDomain = [];
this.dynamicFilters = {};
// name of the model of the currently selected filter, used to fetch templates
this.currentModelName = undefined;
this.dynamicFilterTemplates = {};
},
/**
@@ -33,7 +38,9 @@ const dynamicSnippetOptions = options.Class.extend({
selectDataAttribute: function (previewMode, widgetValue, params) {
this._super.apply(this, arguments);
if (params.attributeName === 'filterId' && previewMode === false) {
this.$target.get(0).dataset.numberOfRecords = this.dynamicFilters[parseInt(widgetValue)].limit;
const filter = this.dynamicFilters[parseInt(widgetValue)];
this.$target.get(0).dataset.numberOfRecords = filter.limit;
this._filterUpdated(filter);
}
},
@@ -41,13 +48,42 @@ const dynamicSnippetOptions = options.Class.extend({
// Private
//--------------------------------------------------------------------------
/**
*
* @override
* @private
*/
_computeWidgetVisibility: function (widgetName, params) {
if (widgetName === 'filter_opt') {
// Hide if exaclty one is available: show when none to help understand what is missing
return Object.keys(this.dynamicFilters).length !== 1;
}
return this._super.apply(this, arguments);
},
/**
* @override
* @private
* @returns {Promise}
*/
_refreshPublicWidgets: function () {
return this._super.apply(this, arguments).then(() => {
const templateKeys = this.$el ? this.$el.find("we-select[data-attribute-name='templateKey'] we-selection-items we-button") : [];
this.$target.find('.missing_option_warning').toggleClass(
'd-none',
templateKeys.length > 0
);
});
},
/**
* Fetches dynamic filters.
* @private
* @returns {Promise}
*/
_fetchDynamicFilters: function () {
return this._rpc({route: '/website/snippet/options_filters'});
return this._rpc({route: '/website/snippet/options_filters', params: {
model_name: this.modelNameFilter,
search_domain: this.contextualFilterDomain,
}});
},
/**
* Fetch dynamic filters templates.
@@ -55,7 +91,10 @@ const dynamicSnippetOptions = options.Class.extend({
* @returns {Promise}
*/
_fetchDynamicFilterTemplates: function () {
return this._rpc({route: '/website/snippet/filter_templates'});
const filter = this.dynamicFilters[this.$target.get(0).dataset['filterId']];
return filter ? this._rpc({route: '/website/snippet/filter_templates', params: {
filter_name: filter.model_name.replaceAll('.', '_'),
}}) : [];
},
/**
*
@@ -72,9 +111,17 @@ const dynamicSnippetOptions = options.Class.extend({
* @private
*/
_renderDynamicFiltersSelector: async function (uiFragment) {
const dynamicFilters = await this._fetchDynamicFilters();
for (let index in dynamicFilters) {
this.dynamicFilters[dynamicFilters[index].id] = dynamicFilters[index];
if (!Object.keys(this.dynamicFilters).length) {
const dynamicFilters = await this._fetchDynamicFilters();
for (let index in dynamicFilters) {
this.dynamicFilters[dynamicFilters[index].id] = dynamicFilters[index];
}
if (dynamicFilters.length > 0) {
const selectedFilterId = this.$target.get(0).dataset['filterId'];
if (!this.dynamicFilters[selectedFilterId]) {
this.$target.get(0).dataset['filterId'] = dynamicFilters[0].id;
}
}
}
const filtersSelectorEl = uiFragment.querySelector('[data-name="filter_opt"]');
return this._renderSelectUserValueWidgetButtons(filtersSelectorEl, this.dynamicFilters);
@@ -104,6 +151,15 @@ const dynamicSnippetOptions = options.Class.extend({
for (let index in dynamicFilterTemplates) {
this.dynamicFilterTemplates[dynamicFilterTemplates[index].key] = dynamicFilterTemplates[index];
}
if (dynamicFilterTemplates.length > 0) {
const selectedTemplateId = this.$target.get(0).dataset['templateKey'];
if (!this.dynamicFilterTemplates[selectedTemplateId]) {
this.$target.get(0).dataset['templateKey'] = dynamicFilterTemplates[0].key;
this._refreshPublicWidgets();
}
} else {
this._refreshPublicWidgets();
}
const templatesSelectorEl = uiFragment.querySelector('[data-name="template_opt"]');
return this._renderSelectUserValueWidgetButtons(templatesSelectorEl, this.dynamicFilterTemplates);
},
@@ -116,6 +172,23 @@ const dynamicSnippetOptions = options.Class.extend({
_setOptionsDefaultValues: function () {
this._setOptionValue('numberOfElements', 4);
this._setOptionValue('numberOfElementsSmallDevices', 1);
const filterKeys = this.$el.find("we-select[data-attribute-name='filterId'] we-selection-items we-button");
if (filterKeys.length > 0) {
this._setOptionValue('numberOfRecords', this.dynamicFilters[Object.keys(this.dynamicFilters)[0]].limit);
}
const filter = this.dynamicFilters[this.$target.get(0).dataset['filterId']];
this._filterUpdated(filter);
},
/**
* Take the new filter selection into account
* @param filter
* @private
*/
_filterUpdated: function (filter) {
if (filter && this.currentModelName !== filter.model_name) {
this.currentModelName = filter.model_name;
this._rerenderXML();
}
},
/**
* Sets the option value.
@@ -8,4 +8,14 @@
background: radial-gradient($white 50%, transparent 50%);
}
}
.dynamic_snippet_template .card-footer a, .dynamic_snippet_template .card-footer button {
z-index: 2;
}
.dynamic_snippet_template .card-body .card-text {
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
overflow: hidden;
}
}
@@ -4,7 +4,7 @@
<section t-attf-class="#{snippet_name} s_dynamic d-none pt24 pb24">
<div class="container o_not_editable">
<div class="css_non_editable_mode_hidden">
<div class="missing_option_warning alert alert-info rounded-0 fade show d-print-none">
<div class="missing_option_warning alert alert-info rounded-0 fade show d-none d-print-none">
Your Dynamic Snippet will be displayed here... This message is displayed because you did not provided both a filter and a template to use.<br/>
</div>
</div>
@@ -41,14 +41,14 @@
<we-button data-select-data-attribute="16">16</we-button>
</we-select>
<we-title class="mt-2"><t t-esc="grouping_message"/></we-title>
<we-select string="⌙ Normal devices" data-name="number_of_elements_opt" data-attribute-name="numberOfElements" data-no-preview="true"><!-- &emsp; -->
<we-select string="⌙ Desktop" data-name="number_of_elements_opt" data-attribute-name="numberOfElements" data-no-preview="true"><!-- &emsp; -->
<we-button data-select-data-attribute="1">1</we-button>
<we-button data-select-data-attribute="2">2</we-button>
<we-button data-select-data-attribute="3">3</we-button>
<we-button data-select-data-attribute="4">4</we-button>
<we-button data-select-data-attribute="6">6</we-button>
</we-select>
<we-select string="⌙ Small devices" data-name="number_of_elements_small_devices_opt" data-attribute-name="numberOfElementsSmallDevices" data-no-preview="true"><!-- &emsp; -->
<we-select string="⌙ Mobile" data-name="number_of_elements_small_devices_opt" data-attribute-name="numberOfElementsSmallDevices" data-no-preview="true"><!-- &emsp; -->
<we-button data-select-data-attribute="1">1</we-button>
<we-button data-select-data-attribute="2">2</we-button>
<we-button data-select-data-attribute="3">3</we-button>
@@ -10,7 +10,7 @@
<t t-set="snippet_name" t-value="snippet_name"/>
<t t-set="snippet_selector" t-value="snippet_selector"/>
<t t-set="grouping_message">Items per slide</t>
<we-input string="Speed"
<we-input string="Slider Speed"
data-select-data-attribute="1s" data-name="speed_opt" data-attribute-name="carouselInterval" data-no-preview="true"
data-unit="s" data-save-unit="ms" data-step="0.1"/>
<t t-raw="0"/>
@@ -113,7 +113,6 @@
<t t-if="debug" t-snippet="website.s_dynamic_snippet" t-thumbnail="/website/static/src/img/snippets_thumbs/s_dynamic_snippet.svg"/>
<t t-if="debug" t-snippet="website.s_dynamic_snippet_carousel" t-thumbnail="/website/static/src/img/snippets_thumbs/s_dynamic_carousel.svg"/>
<t id="sale_products_hook"/>
<t id="sale_recently_viewed_product_hook"/>
<t id="sale_product_search_section_hook"/>
<t id="blog_posts_hook"/>
<t id="event_local_events_hook"/>
+1 -1
View File
@@ -44,7 +44,7 @@
'data-edit_translations': '1' if edit_translations else None,
'data-view-xmlid': xmlid if editable or translatable else None,
'data-viewid': viewid if editable or translatable else None,
'data-main-object': repr(main_object) if editable or translatable else None,
'data-main-object': repr(main_object),
'data-seo-object': repr(seo_object) if seo_object else None,
'data-oe-company-name': res_company.name,
}"/>
+1
View File
@@ -15,6 +15,7 @@
'security/website_sale.xml',
'data/data.xml',
'data/mail_template_data.xml',
'data/product_snippet_template_data.xml',
'data/digest_data.xml',
'views/product_views.xml',
'views/account_views.xml',
+74 -36
View File
@@ -48,53 +48,91 @@
</data>
<data>
<!-- Filters for Dynamic Filter -->
<record id="dynamic_snippet_newest_products_filter" model="ir.filters">
<field name="name">Newest Products</field>
<field name="model_id">product.product</field>
<field name="user_id" eval="False" />
<field name="domain">[('website_published', '=', True)]</field>
<field name="context">{'display_default_code': False}</field>
<field name="sort">['create_date desc']</field>
</record>
<!-- Action Server for Dynamic Filter -->
<record id="dynamic_snippet_products_action" model="ir.actions.server">
<field name="name">Products Dynamic Snippet</field>
<record id="dynamic_snippet_latest_sold_products_action" model="ir.actions.server">
<field name="name">Recently Sold Products</field>
<field name="model_id" ref="model_product_product"/>
<field name="state">code</field>
<field name="code">
ProductProduct = model.env['product.product']
FieldMonetary = model.env['ir.qweb.field.monetary']
website = request.website.get_current_website()
dynamic_filter = model.env.context.get('dynamic_filter')
limit = model.env.context.get('limit')
search_domain = model.env.context.get('search_domain')
meta_data = dynamic_filter._get_filter_meta_data()
domain = [('website_published', '=', True)] + website.website_domain() + (search_domain or [])
products = ProductProduct.search(domain, limit=limit)
_ = products.mapped('name')
max_nb_chars = 100
res_products = []
for product in products:
res_product = product._read_format(['id', 'name', 'website_url', 'description_sale'])[0]
res_product.update(product._get_combination_info_variant())
if res_product['description_sale'] and len(res_product['description_sale']) > max_nb_chars:
res_product['description_sale'] = "%s ..." % res_product['description_sale'][:max_nb_chars]
data = {}
for field_name in meta_data.keys():
field = ProductProduct._fields.get(field_name)
if field and field.type == 'binary':
data[field_name] = website.image_url(product, field_name)
else:
data[field_name] = res_product[field_name]
data['call_to_action_url'] = product['website_url']
res_products.append(data)
DynamicFilter = model.env['website.snippet.filter']
response = DynamicFilter._get_products('latest_sold', model.env.context)
</field>
</record>
<record id="dynamic_snippet_latest_viewed_products_action" model="ir.actions.server">
<field name="name">Recently Viewed Products</field>
<field name="model_id" ref="model_product_product"/>
<field name="state">code</field>
<field name="code">
DynamicFilter = model.env['website.snippet.filter']
res_products = DynamicFilter._get_products('latest_viewed', model.env.context)
for data in res_products:
data['_latest_viewed'] = True
response = res_products
</field>
</record>
<record id="dynamic_snippet_accessories_action" model="ir.actions.server">
<field name="name">Product Accessories</field>
<field name="model_id" ref="model_product_product"/>
<field name="state">code</field>
<field name="code">
DynamicFilter = model.env['website.snippet.filter']
model.env.context['product_template_id'] = request.params.get('productTemplateId')
response = DynamicFilter._get_products('accessories', model.env.context)
</field>
</record>
<record id="dynamic_snippet_recently_sold_with_action" model="ir.actions.server">
<field name="name">Products Recently Sold With</field>
<field name="model_id" ref="model_product_product"/>
<field name="state">code</field>
<field name="code">
DynamicFilter = model.env['website.snippet.filter']
model.env.context['product_template_id'] = request.params.get('productTemplateId')
response = DynamicFilter._get_products('recently_sold_with', model.env.context)
</field>
</record>
<!-- Dynamic Filter -->
<record id="dynamic_filter_demo_products" model="website.snippet.filter">
<field name="action_server_id" ref="website_sale.dynamic_snippet_products_action"/>
<record id="dynamic_filter_newest_products" model="website.snippet.filter">
<field name="filter_id" ref="website_sale.dynamic_snippet_newest_products_filter"/>
<field name="field_names">display_name,description_sale,image_512,price:monetary</field>
<field name="limit" eval="16"/>
<field name="name">Products</field>
<field name="name">Newest Products</field>
</record>
<record id="dynamic_filter_latest_sold_products" model="website.snippet.filter">
<field name="action_server_id" ref="website_sale.dynamic_snippet_latest_sold_products_action"/>
<field name="field_names">display_name,description_sale,image_512,price:monetary</field>
<field name="limit" eval="16"/>
<field name="name">Recently Sold Products</field>
</record>
<record id="dynamic_filter_latest_viewed_products" model="website.snippet.filter">
<field name="action_server_id" ref="website_sale.dynamic_snippet_latest_viewed_products_action"/>
<field name="field_names">display_name,description_sale,image_512,price:monetary</field>
<field name="limit" eval="16"/>
<field name="name">Recently Viewed Products</field>
</record>
<record id="dynamic_filter_cross_selling_accessories" model="website.snippet.filter">
<field name="action_server_id" ref="website_sale.dynamic_snippet_accessories_action"/>
<field name="field_names">display_name,description_sale,image_512,price:monetary</field>
<field name="limit" eval="16"/>
<field name="name">Accessories for Product</field>
<field name="product_cross_selling">True</field>
</record>
<record id="dynamic_filter_cross_selling_recently_sold_with" model="website.snippet.filter">
<field name="action_server_id" ref="website_sale.dynamic_snippet_recently_sold_with_action"/>
<field name="field_names">display_name,description_sale,image_512,price:monetary</field>
<field name="limit" eval="16"/>
<field name="name">Products Recently Sold With Product</field>
<field name="product_cross_selling">True</field>
</record>
<function model="ir.model.fields" name="formbuilder_whitelist">
<value>sale.order</value>
<value eval="[
@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Templates for Dynamic Snippet -->
<template id="dynamic_filter_template_product_product_add_to_cart" name="Add Product to Cart">
<t t-foreach="records" t-as="data">
<t t-set="record" t-value="data['_record']"/>
<div class="o_carousel_product_card card h-100">
<div t-if="is_sample" class="h5 o_ribbon_right bg-primary text-uppercase">Sample</div>
<input type="hidden" name="product-id" t-att-data-product-id="record.id"/>
<a class="o_carousel_product_img_link" t-att-href="record.website_url">
<img class="o_carousel_product_card_img_top card-img-top" loading="lazy" t-att-src="data['image_512']"
t-att-alt="record.display_name"/>
</a>
<i t-if="data.get('_latest_viewed')" class="fa fa-trash o_carousel_product_remove js_remove"></i>
<div class="o_carousel_product_card_body card-body border-top">
<a t-att-href="record.website_url" class="text-decoration-none">
<div class="h6 card-title mb-0 text-truncate" t-field="record.display_name"/>
</a>
<t t-if="is_view_active('website_sale.product_comment')" t-call="portal_rating.rating_widget_stars_static">
<t t-set="rating_avg" t-value="record.rating_avg"/>
<t t-set="rating_count" t-value="record.rating_count"/>
</t>
</div>
<div class="o_carousel_product_card_footer card-footer d-flex align-items-center">
<div class="d-block font-weight-bold" t-esc="data['price']"
t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
<button type="button" role="button" class="btn btn-primary js_add_cart ml-auto" title="Add to Cart">
<i class="fa fa-fw fa-shopping-cart"/>
</button>
</div>
</div>
</t>
</template>
<template id="dynamic_filter_template_product_product_view_detail" name="Detailed Product">
<t t-foreach="records" t-as="data">
<t t-set="record" t-value="data['_record']"/>
<div class="o_carousel_product_card card h-100" t-att-data-url="record.website_url">
<div t-if="is_sample" class="h5 o_ribbon_right bg-primary text-uppercase">Sample</div>
<a class="o_carousel_product_img_link" t-att-href="record.website_url">
<img class="card-img-top p-3" loading="lazy" t-att-src="data['image_512']" t-att-alt="record.display_name"/>
</a>
<div class="o_carousel_product_card_body card-body">
<div class="card-title h5" t-field="record.display_name"/>
<div class="card-text" t-field="record.description_sale"/>
<t t-if="is_view_active('website_sale.product_comment')" t-call="portal_rating.rating_widget_stars_static">
<t t-set="rating_avg" t-value="record.rating_avg"/>
<t t-set="rating_count" t-value="record.rating_count"/>
</t>
</div>
<div class="o_carousel_product_card_footer card-footer d-flex align-items-center">
<div class="card-text text-center" t-esc="data['price']"
t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
<a class="btn btn-primary ml-auto" t-att-href="record.website_url">
<i class="fa fa-fw fa-eye"/>
</a>
</div>
</div>
</t>
</template>
<template id="dynamic_filter_template_product_product_mini_image" name="Image only">
<t t-foreach="records" t-as="data">
<t t-set="record" t-value="data['_record']"/>
<div class="card h-100" t-att-data-url="record.website_url">
<div t-if="is_sample" class="h5 o_ribbon_right bg-primary text-uppercase">Sample</div>
<a class="o_carousel_product_img_link" t-att-href="record.website_url">
<img class="card-img-top p-3" loading="lazy" t-att-src="data['image_512']" t-att-alt="record.display_name"/>
</a>
</div>
</t>
</template>
<template id="dynamic_filter_template_product_product_mini_price" name="Image with price">
<t t-foreach="records" t-as="data">
<t t-set="record" t-value="data['_record']"/>
<div class="card h-100" t-att-data-url="record.website_url">
<div t-if="is_sample" class="h5 o_ribbon_right bg-primary text-uppercase">Sample</div>
<a class="o_carousel_product_img_link" t-att-href="record.website_url">
<img class="card-img-top p-3" loading="lazy" t-att-src="data['image_512']" t-att-alt="record.display_name"/>
</a>
<div class="card-text text-center" t-esc="data['price']"
t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
</div>
</t>
</template>
<template id="dynamic_filter_template_product_product_mini_name" name="Image with name">
<t t-foreach="records" t-as="data">
<t t-set="record" t-value="data['_record']"/>
<div class="card h-100" t-att-data-url="record.website_url">
<div t-if="is_sample" class="h5 o_ribbon_right bg-primary text-uppercase">Sample</div>
<a class="o_carousel_product_img_link" t-att-href="record.website_url">
<img class="card-img-top p-3" loading="lazy" t-att-src="data['image_512']" t-att-alt="record.display_name"/>
</a>
<div class="h6 card-footer mb-1 px-1 text-center text-truncate" t-field="record.display_name"/>
</div>
</t>
</template>
</data>
</odoo>
@@ -1,11 +1,17 @@
# -*- coding: utf-8 -*-
from collections import Counter
from odoo import models, fields, api, _
from odoo.osv import expression
class WebsiteSnippetFilter(models.Model):
_inherit = 'website.snippet.filter'
product_cross_selling = fields.Boolean(string="About cross selling products", default=False,
help="True only for product filters that require a product_id because they relate to cross selling")
@api.model
def _get_website_currency(self):
pricelist = self.env['website'].get_current_website().get_current_pricelist()
@@ -13,23 +19,31 @@ class WebsiteSnippetFilter(models.Model):
def _get_hardcoded_sample(self, model):
samples = super()._get_hardcoded_sample(model)
if model and model.model == 'product.product':
if model._name == 'product.product':
data = [{
'image_512': '/product/static/img/product_chair.png',
'image_512': b'/product/static/img/product_chair.png',
'display_name': _('Chair'),
'description_sale': _('Sit comfortably'),
}, {
'image_512': '/product/static/img/product_lamp.png',
'image_512': b'/product/static/img/product_lamp.png',
'display_name': _('Lamp'),
'description_sale': _('Lightbulb sold separately'),
}, {
'image_512': '/product/static/img/product_product_20-image.png',
'image_512': b'/product/static/img/product_product_20-image.png',
'display_name': _('Whiteboard'),
'description_sale': _('With three feet'),
}, {
'image_512': '/product/static/img/product_product_27-image.png',
'image_512': b'/product/static/img/product_product_27-image.png',
'display_name': _('Drawer'),
'description_sale': _('On wheels'),
}, {
'image_512': b'/product/static/img/product_product_7-image.png',
'display_name': _('Box'),
'description_sale': _('Reinforced for heavy loads'),
}, {
'image_512': b'/product/static/img/product_product_9-image.png',
'display_name': _('Bin'),
'description_sale': _('Pedal-based opening system'),
}]
merged = []
for index in range(0, max(len(samples), len(data))):
@@ -37,3 +51,107 @@ class WebsiteSnippetFilter(models.Model):
# merge definitions
samples = merged
return samples
def _filter_records_to_values(self, records, is_sample=False):
res_products = super()._filter_records_to_values(records, is_sample)
if self.model_name == 'product.product':
for res_product in res_products:
product = res_product.get('_record')
if not is_sample:
res_product.update(product._get_combination_info_variant())
return res_products
@api.model
def _get_products(self, mode, context):
dynamic_filter = context.get('dynamic_filter')
handler = getattr(self, '_get_products_%s' % mode, self._get_products_latest_sold)
website = self.env['website'].get_current_website()
search_domain = context.get('search_domain')
limit = context.get('limit')
domain = expression.AND([
[('website_published', '=', True)],
website.get_current_website().website_domain(),
search_domain or [],
])
products = handler(website, limit, domain, context)
return dynamic_filter._filter_records_to_values(products, False)
def _get_products_latest_sold(self, website, limit, domain, context):
products = []
sale_orders = self.env['sale.order'].sudo().search([
('website_id', '=', website.id),
('state', 'in', ('sale', 'done')),
], limit=8, order='date_order DESC')
if sale_orders:
sold_products = [p.product_id.id for p in sale_orders.order_line]
products_ids = [id for id, _ in Counter(sold_products).most_common()]
if products_ids:
domain = expression.AND([
domain,
[('id', 'in', products_ids)],
])
products = self.env['product.product'].with_context(display_default_code=False).search(domain)
products = products.sorted(key=lambda p: products_ids.index(p.id))[:limit]
return products
def _get_products_latest_viewed(self, website, limit, domain, context):
products = []
visitor = self.env['website.visitor']._get_visitor_from_request()
if visitor:
excluded_products = website.sale_get_order().order_line.product_id.ids
tracked_products = self.env['website.track'].sudo().read_group(
[('visitor_id', '=', visitor.id), ('product_id', '!=', False), ('product_id.website_published', '=', True), ('product_id', 'not in', excluded_products)],
['product_id', 'visit_datetime:max'], ['product_id'], limit=limit, orderby='visit_datetime DESC')
products_ids = [product['product_id'][0] for product in tracked_products]
if products_ids:
domain = expression.AND([
domain,
[('id', 'in', products_ids)],
])
products = self.env['product.product'].with_context(display_default_code=False).search(domain, limit=limit)
return products
def _get_products_recently_sold_with(self, website, limit, domain, context):
products = []
current_id = context.get('product_template_id')
if current_id:
current_id = int(current_id)
sale_orders = self.env['sale.order'].sudo().search([
('website_id', '=', website.id),
('state', 'in', ('sale', 'done')),
('order_line.product_id.product_tmpl_id', '=', current_id),
], limit=8, order='date_order DESC')
if sale_orders:
current_template = self.env['product.template'].browse(current_id)
excluded_products = website.sale_get_order().order_line.product_id.product_tmpl_id.product_variant_ids.ids
excluded_products.extend(current_template.product_variant_ids.ids)
included_products = []
for sale_order in sale_orders:
included_products.extend(sale_order.order_line.product_id.ids)
products_ids = list(set(included_products) - set(excluded_products))
if products_ids:
domain = expression.AND([
domain,
[('id', 'in', products_ids)],
])
products = self.env['product.product'].with_context(display_default_code=False).search(domain, limit=limit)
return products
def _get_products_accessories(self, website, limit, domain, context):
products = []
current_id = context.get('product_template_id')
if current_id:
current_id = int(current_id)
current_template = self.env['product.template'].browse(current_id)
if current_template.exists():
excluded_products = website.sale_get_order().order_line.product_id.ids
excluded_products.extend(current_template.product_variant_ids.ids)
included_products = current_template.product_variant_ids.accessory_product_ids.filtered('website_published').ids
products_ids = list(set(included_products) - set(excluded_products))
if products_ids:
domain = expression.AND([
domain,
[('id', 'in', products_ids)],
])
products = self.env['product.product'].with_context(display_default_code=False).search(domain, limit=limit)
return products
@@ -1,182 +1,7 @@
odoo.define('website_sale.recently_viewed', function (require) {
var concurrency = require('web.concurrency');
var config = require('web.config');
var core = require('web.core');
var publicWidget = require('web.public.widget');
var utils = require('web.utils');
var wSaleUtils = require('website_sale.utils');
var qweb = core.qweb;
publicWidget.registry.productsRecentlyViewedSnippet = publicWidget.Widget.extend({
selector: '.s_wsale_products_recently_viewed',
xmlDependencies: ['/website_sale/static/src/xml/website_sale_recently_viewed.xml'],
disabledInEditableMode: false,
read_events: {
'click .js_add_cart': '_onAddToCart',
'click .js_remove': '_onRemove',
},
/**
* @constructor
*/
init: function () {
this._super.apply(this, arguments);
this._dp = new concurrency.DropPrevious();
this.uniqueId = _.uniqueId('o_carousel_recently_viewed_products_');
this._onResizeChange = _.debounce(this._addCarousel, 100);
},
/**
* @override
*/
start: function () {
this._dp.add(this._fetch()).then(this._render.bind(this));
$(window).resize(() => {
this._onResizeChange();
});
return this._super.apply(this, arguments);
},
/**
* @override
*/
destroy: function () {
this._super(...arguments);
this.$el.addClass('d-none');
this.$el.find('.slider').html('');
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
*/
_fetch: function () {
return this._rpc({
route: '/shop/products/recently_viewed',
}).then(res => {
var products = res['products'];
// In edit mode, if the current visitor has no recently viewed
// products, use demo data.
if (this.editableMode && (!products || !products.length)) {
return {
'products': [{
id: 0,
website_url: '#',
display_name: 'Product 1',
price: '$ <span class="oe_currency_value">750.00</span>',
}, {
id: 0,
website_url: '#',
display_name: 'Product 2',
price: '$ <span class="oe_currency_value">750.00</span>',
}, {
id: 0,
website_url: '#',
display_name: 'Product 3',
price: '$ <span class="oe_currency_value">750.00</span>',
}, {
id: 0,
website_url: '#',
display_name: 'Product 4',
price: '$ <span class="oe_currency_value">750.00</span>',
}],
};
}
return res;
});
},
/**
* @private
*/
_render: function (res) {
var products = res['products'];
var mobileProducts = [], webProducts = [], productsTemp = [];
_.each(products, function (product) {
if (productsTemp.length === 4) {
webProducts.push(productsTemp);
productsTemp = [];
}
productsTemp.push(product);
mobileProducts.push([product]);
});
if (productsTemp.length) {
webProducts.push(productsTemp);
}
this.mobileCarousel = $(qweb.render('website_sale.productsRecentlyViewed', {
uniqueId: this.uniqueId,
productFrame: 1,
productsGroups: mobileProducts,
}));
this.webCarousel = $(qweb.render('website_sale.productsRecentlyViewed', {
uniqueId: this.uniqueId,
productFrame: 4,
productsGroups: webProducts,
}));
this._addCarousel();
this.$el.toggleClass('d-none', !(products && products.length));
},
/**
* Add the right carousel depending on screen size.
* @private
*/
_addCarousel: function () {
var carousel = config.device.size_class <= config.device.SIZES.SM ? this.mobileCarousel : this.webCarousel;
this.$('.slider').html(carousel).css('display', ''); // Removing display is kept for compatibility (it was hidden before)
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Add product to cart and reload the carousel.
* @private
* @param {Event} ev
*/
_onAddToCart: function (ev) {
var self = this;
var $card = $(ev.currentTarget).closest('.card');
this._rpc({
route: "/shop/cart/update_json",
params: {
product_id: $card.find('input[data-product-id]').data('product-id'),
add_qty: 1
},
}).then(function (data) {
wSaleUtils.updateCartNavBar(data);
var $navButton = $('header .o_wsale_my_cart').first();
var fetch = self._fetch();
var animation = wSaleUtils.animateClone($navButton, $(ev.currentTarget).parents('.o_carousel_product_card'), 25, 40);
Promise.all([fetch, animation]).then(function (values) {
self._render(values[0]);
});
});
},
/**
* Remove product from recently viewed products.
* @private
* @param {Event} ev
*/
_onRemove: function (ev) {
var self = this;
var $card = $(ev.currentTarget).closest('.card');
this._rpc({
route: "/shop/products/recently_viewed_delete",
params: {
product_id: $card.find('input[data-product-id]').data('product-id'),
},
}).then(function (data) {
self._render(data);
});
},
});
publicWidget.registry.productsRecentlyViewedUpdate = publicWidget.Widget.extend({
selector: '#product_detail',
@@ -5,37 +5,19 @@ const config = require('web.config');
const core = require('web.core');
const publicWidget = require('web.public.widget');
const DynamicSnippetCarousel = require('website.s_dynamic_snippet_carousel');
var wSaleUtils = require('website_sale.utils');
const DynamicSnippetProducts = DynamicSnippetCarousel.extend({
selector: '.s_dynamic_snippet_products',
read_events: {
'click .js_add_cart': '_onAddToCart',
'click .js_remove': '_onRemoveFromRecentlyViewed',
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Method to be overridden in child components if additional configuration elements
* are required in order to fetch data.
* @override
* @private
*/
_isConfigComplete: function () {
return this._super.apply(this, arguments) && this.$el.get(0).dataset.productCategoryId !== undefined;
},
/**
*
* @override
* @private
*/
_mustMessageWarningBeHidden: function() {
const isInitialDrop = this.$el.get(0).dataset.templateKey === undefined;
// This snippet has default values obtained after the initial start and render after drop.
// Because of this there is an initial refresh happening right after.
// We want to avoid showing the incomplete config message before this refresh.
// Since the refreshed call will always happen with a defined templateKey,
// if it is not set yet, we know it is the drop call and we can avoid showing the message.
return isInitialDrop || this._super.apply(this, arguments);
},
/**
* Method to be overridden in child components in order to provide a search
* domain if needed.
@@ -44,12 +26,99 @@ const DynamicSnippetProducts = DynamicSnippetCarousel.extend({
*/
_getSearchDomain: function () {
const searchDomain = this._super.apply(this, arguments);
const productCategoryId = parseInt(this.$el.get(0).dataset.productCategoryId);
if (productCategoryId >= 0) {
searchDomain.push(['public_categ_ids', 'child_of', productCategoryId]);
let productCategoryId = this.$el.get(0).dataset.productCategoryId;
if (productCategoryId && productCategoryId !== 'all') {
if (productCategoryId === 'current') {
productCategoryId = undefined;
const productCategoryField = $("#product_details").find(".product_category_id");
if (productCategoryField && productCategoryField.length) {
productCategoryId = parseInt(productCategoryField[0].value);
}
if (!productCategoryId) {
this.trigger_up('main_object_request', {
callback: function (value) {
if (value.model === "product.public.category") {
productCategoryId = value.id;
}
},
});
}
if (!productCategoryId) {
// Try with categories from product, unfortunately the category hierarchy is not matched with this approach
const productTemplateId = $("#product_details").find(".product_template_id");
if (productTemplateId && productTemplateId.length) {
searchDomain.push(['public_categ_ids.product_tmpl_ids', '=', parseInt(productTemplateId[0].value)]);
}
}
}
if (productCategoryId) {
searchDomain.push(['public_categ_ids', 'child_of', parseInt(productCategoryId)]);
}
}
const productNames = this.$el.get(0).dataset.productNames;
if (productNames) {
const nameDomain = [];
for (const productName of productNames.split(',')) {
if (nameDomain.length) {
nameDomain.unshift('|');
}
nameDomain.push(['name', 'ilike', productName]);
}
searchDomain.push(...nameDomain);
}
return searchDomain;
},
/**
* @override
*/
_getRpcParameters: function () {
const productTemplateId = $("#product_details").find(".product_template_id");
return Object.assign(this._super.apply(this, arguments), {
productTemplateId: productTemplateId && productTemplateId.length ? productTemplateId[0].value : undefined,
});
},
/**
* Add product to cart and reload the carousel.
* @private
* @param {Event} ev
*/
_onAddToCart: function (ev) {
var self = this;
var $card = $(ev.currentTarget).closest('.card');
this._rpc({
route: "/shop/cart/update_json",
params: {
product_id: $card.find('input[data-product-id]').data('product-id'),
add_qty: 1
},
}).then(function (data) {
wSaleUtils.updateCartNavBar(data);
var $navButton = $('header .o_wsale_my_cart').first();
var fetch = self._fetchData();
var animation = wSaleUtils.animateClone($navButton, $(ev.currentTarget).parents('.card'), 25, 40);
Promise.all([fetch, animation]).then(function (values) {
self._render();
});
});
},
/**
* Remove product from recently viewed products.
* @private
* @param {Event} ev
*/
_onRemoveFromRecentlyViewed: function (ev) {
var self = this;
var $card = $(ev.currentTarget).closest('.card');
this._rpc({
route: "/shop/products/recently_viewed_delete",
params: {
product_id: $card.find('input[data-product-id]').data('product-id'),
},
}).then(function (data) {
self._fetchData().then(() => self._render());
});
},
});
publicWidget.registry.dynamic_snippet_products = DynamicSnippetProducts;
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="website_sale.s_dynamic_snippet.products" inherit_id="website.s_dynamic_snippet.carousel">
<xpath expr="//div" position="attributes">
<attribute name="data-filter-id" add=""/>
</xpath>
</t>
</templates>
@@ -4,6 +4,8 @@ odoo.define('website_sale.s_dynamic_snippet_products_options', function (require
const options = require('web_editor.snippets.options');
const s_dynamic_snippet_carousel_options = require('website.s_dynamic_snippet_carousel_options');
var wUtils = require('website.utils');
const dynamicSnippetProductsOptions = s_dynamic_snippet_carousel_options.extend({
/**
@@ -12,44 +14,18 @@ const dynamicSnippetProductsOptions = s_dynamic_snippet_carousel_options.extend(
*/
init: function () {
this._super.apply(this, arguments);
this.modelNameFilter = 'product.product';
const productTemplateId = $("input.product_template_id");
this.hasProductTemplateId = productTemplateId.val();
if (!this.hasProductTemplateId) {
this.contextualFilterDomain.push(['product_cross_selling', '=', false]);
}
this.productCategories = {};
},
/**
*
* @override
*/
onBuilt: function () {
this._super.apply(this, arguments);
this._rpc({
model: 'ir.model.data',
method: 'search_read',
kwargs: {
domain: [['module', '=', 'website_sale'], ['model', '=', 'website.snippet.filter']],
fields: ['id', 'res_id'],
}
}).then((data) => {
this.$target.get(0).dataset.filterId = data[0].res_id;
this.$target.get(0).dataset.numberOfRecords = this.dynamicFilters[data[0].res_id].limit;
this._refreshPublicWidgets();
// Refresh is needed because default values are obtained after start()
});
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
*
* @override
* @private
*/
_computeWidgetVisibility: function (widgetName, params) {
if (widgetName === 'filter_opt') {
return false;
}
return this._super.apply(this, arguments);
},
/**
* Fetches product categories.
* @private
@@ -60,7 +36,7 @@ const dynamicSnippetProductsOptions = s_dynamic_snippet_carousel_options.extend(
model: 'product.public.category',
method: 'search_read',
kwargs: {
domain: [],
domain: wUtils.websiteDomain(this),
fields: ['id', 'name'],
}
});
@@ -87,22 +63,6 @@ const dynamicSnippetProductsOptions = s_dynamic_snippet_carousel_options.extend(
const productCategoriesSelectorEl = uiFragment.querySelector('[data-name="product_category_opt"]');
return this._renderSelectUserValueWidgetButtons(productCategoriesSelectorEl, this.productCategories);
},
/**
* Sets default options values.
* @override
* @private
*/
_setOptionsDefaultValues: function () {
this._super.apply(this, arguments);
const templateKeys = this.$el.find("we-select[data-attribute-name='templateKey'] we-selection-items we-button");
if (templateKeys.length > 0) {
this._setOptionValue('templateKey', templateKeys.attr('data-select-data-attribute'));
}
const productCategories = this.$el.find("we-select[data-attribute-name='productCategoryId'] we-selection-items we-button");
if (productCategories.length > 0) {
this._setOptionValue('productCategoryId', productCategories.attr('data-select-data-attribute'));
}
},
});
@@ -1,49 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<!-- Recently Viewed Products Carousel -->
<t t-name="website_sale.productsRecentlyViewed">
<div t-att-id="uniqueId" class="carousel slide o_not_editable" data-interval="false">
<div class="carousel-inner">
<t t-foreach="productsGroups" t-as="products">
<div t-attf-class="carousel-item #{!products_index and 'active' or ''}">
<div class="row">
<t t-foreach="products" t-as="product">
<div t-attf-class="o_carousel_product_card_wrap col-md-#{12 / productFrame}">
<div class="o_carousel_product_card card h-100">
<input type="hidden" name="product-id" t-att-data-product-id="product.id"/>
<a class="o_carousel_product_img_link" t-att-href="product.website_url">
<img class="o_carousel_product_card_img_top card-img-top" t-attf-src="/web/image/product.product/#{product.id}#{productFrame == 1 ? '/image_256' : '/image_512'}" t-att-alt="product.display_name"/>
</a>
<i class="fa fa-trash o_carousel_product_remove js_remove"></i>
<div class="o_carousel_product_card_body card-body border-top">
<a t-att-href="product.website_url" class="text-decoration-none">
<h6 class="card-title mb-0 text-truncate" t-raw="product.display_name"/>
</a>
<t t-if="product.rating" t-raw="product.rating"/>
</div>
<div class="o_carousel_product_card_footer card-footer d-flex align-items-center">
<div class="d-block font-weight-bold" t-raw="product.price"/>
<button type="button" role="button" class="btn btn-primary js_add_cart ml-auto" title="Add to Cart">
<i class="fa fa-fw fa-shopping-cart"/>
</button>
</div>
</div>
</div>
</t>
</div>
</div>
</t>
</div>
<t t-if='productsGroups.length > 1'>
<a class="o_carousel_product_control carousel-control-prev" t-att-href="'#' + uniqueId" role="button" data-slide="prev">
<span class="carousel-control-prev-icon"></span>
<span class="sr-only">Previous</span>
</a>
<a class="o_carousel_product_control carousel-control-next" t-att-href="'#' + uniqueId" role="button" data-slide="next">
<span class="carousel-control-next-icon"></span>
<span class="sr-only">Next</span>
</a>
</t>
</div>
</t>
</templates>
@@ -1,18 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="s_dynamic_snippet_products" name="Dynamic Products">
<template id="s_dynamic_snippet_products" name="Products">
<t t-call="website.s_dynamic_snippet_template">
<t t-set="snippet_name" t-value="'s_dynamic_snippet_products'"/>
</t>
</template>
<template id="s_dynamic_snippet_products_options" inherit_id="website.snippet_options">
<template id="s_dynamic_snippet_template" inherit_id="website.s_dynamic_snippet_template">
<xpath expr="section" position="attributes">
<attribute t-if="snippet_name == 's_dynamic_snippet_products'" name="data-product-category-id">all</attribute>
</xpath>
</template>
<template id="s_dynamic_snippet_products_options" inherit_id="website.snippet_options">
<xpath expr="." position="inside">
<t t-call="website.dynamic_snippet_carousel_options_template">
<t t-set="snippet_name" t-value="'dynamic_snippet_products'"/>
<t t-set="snippet_selector" t-value="'.s_dynamic_snippet_products'"/>
<we-select string="Product Category" data-name="product_category_opt" data-attribute-name="productCategoryId" data-no-preview="true">
<we-button data-select-data-attribute="-1">All Products</we-button>
</t>
</xpath>
</template>
<template id="s_dynamic_snippet_products_template_options" inherit_id="website.s_dynamic_snippet_options_template">
<xpath expr="//we-select[@data-name='filter_opt']" position="after">
<t t-if="snippet_name == 'dynamic_snippet_products'">
<we-select string="Category" data-name="product_category_opt" data-attribute-name="productCategoryId" data-no-preview="true">
<we-button data-select-data-attribute="all">All Products</we-button>
<we-button data-select-data-attribute="current">Current Category or All</we-button>
</we-select>
<we-input string="Product names" class="o_we_large_input" data-name="product_names_opt"
data-attribute-name="productNames" data-no-preview="true" data-select-data-attribute=""
placeholder="e.g. lamp,bin" title="Comma-separated list of parts of product names"/>
</t>
</xpath>
</template>
@@ -1,21 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="s_products_recently_viewed" name="Viewed Products">
<section class="s_wsale_products_recently_viewed o_snippet_invisible pt24 pb24 d-none" style="min-height: 400px;">
<div class="container">
<h3 class="text-center mb32">Recently Viewed Products</h3>
<div class="slider o_not_editable"/>
</div>
</section>
</template>
<template id="snippets" inherit_id="website.snippets" name="e-commerce snippets">
<xpath expr="//t[@id='sale_products_hook']" position="replace">
<t t-snippet="website_sale.s_dynamic_snippet_products" t-thumbnail="/website_sale/static/src/img/snippets_thumbs/s_products_recently_viewed.svg"/>
</xpath>
<xpath expr="//t[@id='sale_recently_viewed_product_hook']" position="replace">
<t t-snippet="website_sale.s_products_recently_viewed" t-thumbnail="/website_sale/static/src/img/snippets_thumbs/s_products_recently_viewed.svg"/>
<t t-snippet="website_sale.s_dynamic_snippet_products" t-thumbnail="/website_sale/static/src/img/snippets_thumbs/s_dynamic_products.svg"/>
</xpath>
<xpath expr="//t[@id='sale_product_search_section_hook']" position="replace">
<t t-snippet="website_sale.s_products_searchbar" t-thumbnail="/website_sale/static/src/img/snippets_thumbs/s_products_searchbar.svg"/>
+3 -6
View File
@@ -599,6 +599,7 @@
<t t-call="website.layout">
<t t-set="additional_title" t-value="product.name" />
<div itemscope="itemscope" itemtype="http://schema.org/Product" id="wrap" class="js_sale">
<div class="oe_structure oe_empty" id="oe_structure_website_sale_product_1" data-editor-message="DROP BUILDING BLOCKS HERE TO MAKE THEM AVAILABLE ACROSS ALL PRODUCTS"/>
<section t-attf-class="container py-2 oe_website_sale #{'discount' if combination_info['has_discounted_price'] else ''}" id="product_detail" t-att-data-view-track="view_track and '1' or '0'">
<div class="row">
<div class="col-md-4">
@@ -638,6 +639,7 @@
<t t-placeholder="select">
<input type="hidden" class="product_id" name="product_id" t-att-value="product_variant.id" />
<input type="hidden" class="product_template_id" name="product_template_id" t-att-value="product.id" />
<input t-if="product.public_categ_ids.ids" type="hidden" class="product_category_id" name="product_category_id" t-att-value="product.public_categ_ids.ids[0]" />
<t t-if="combination" t-call="sale.variants">
<t t-set="ul_class" t-value="'flex-column'" />
<t t-set="parent_combination" t-value="None" />
@@ -676,6 +678,7 @@
</div>
</section>
<div itemprop="description" t-field="product.website_description" class="oe_structure oe_empty mt16" id="product_full_description"/>
<div class="oe_structure oe_empty mt16" id="oe_structure_website_sale_product_2" data-editor-message="DROP BUILDING BLOCKS HERE TO MAKE THEM AVAILABLE ACROSS ALL PRODUCTS"/>
</div>
</t>
</template>
@@ -729,12 +732,6 @@
</xpath>
</template>
<template id="recently_viewed_products_product" inherit_id="website_sale.product" active="False" customize_show="True" name="Recently Viewed Products" priority="16">
<xpath expr="//div[@t-field='product.website_description']" position="after">
<t t-snippet-call="website_sale.s_products_recently_viewed"/>
</xpath>
</template>
<!-- Product options: OpenChatter -->
<template id="product_comment" inherit_id="website_sale.product" active="False" customize_show="True" name="Discussion and Rating" priority="15">
<xpath expr="//div[@t-field='product.website_description']" position="after">