diff --git a/addons/web/models/base_document_layout.py b/addons/web/models/base_document_layout.py index 3396e0db805..6693e51126b 100644 --- a/addons/web/models/base_document_layout.py +++ b/addons/web/models/base_document_layout.py @@ -87,7 +87,7 @@ class BaseDocumentLayout(models.TransientModel): wizard_for_image = wizard.with_context(bin_size=False) else: wizard_for_image = wizard - wizard.logo_primary_color, wizard.logo_secondary_color = wizard_for_image._parse_logo_colors() + wizard.logo_primary_color, wizard.logo_secondary_color = wizard.extract_image_primary_secondary_colors(wizard_for_image.logo) @api.depends('report_layout_id', 'logo', 'font', 'primary_color', 'secondary_color', 'report_header', 'report_footer') def _compute_preview(self): @@ -150,7 +150,8 @@ class BaseDocumentLayout(models.TransientModel): if wizard.logo_secondary_color: wizard.secondary_color = wizard.logo_secondary_color - def _parse_logo_colors(self, logo=None, white_threshold=225): + @api.model + def extract_image_primary_secondary_colors(self, logo, white_threshold=225): """ Identifies dominant colors @@ -158,16 +159,13 @@ class BaseDocumentLayout(models.TransientModel): transparent colors and white-ish colors, then calls the averaging method twice to evaluate both primary and secondary colors. - :param logo: alternate logo to process + :param logo: logo to process :param white_threshold: arbitrary value defining the maximum value a color can reach :return colors: hex values of primary and secondary colors """ - self.ensure_one() - logo = logo or self.logo if not logo: return False, False - # The "===" gives different base64 encoding a correct padding logo += b'===' if type(logo) == bytes else '===' try: diff --git a/addons/web/tests/test_base_document_layout.py b/addons/web/tests/test_base_document_layout.py index 0f3f9e8b1dc..fb658fff5fa 100644 --- a/addons/web/tests/test_base_document_layout.py +++ b/addons/web/tests/test_base_document_layout.py @@ -54,8 +54,7 @@ class TestBaseDocumentLayoutHelpers(TransactionCase): if not fname_split[0] in _file_cache: with Image.open(os.path.join(dir_path, fname), 'r') as img: base64_img = image_to_base64(img, 'PNG') - primary, secondary = self.env['base.document.layout'].create( - {})._parse_logo_colors(base64_img) + primary, secondary = self.env['base.document.layout'].extract_image_primary_secondary_colors(base64_img) _img = frozendict({ 'img': base64_img, 'colors': { diff --git a/addons/website/__manifest__.py b/addons/website/__manifest__.py index f2c6ef86037..ea675051beb 100644 --- a/addons/website/__manifest__.py +++ b/addons/website/__manifest__.py @@ -201,6 +201,7 @@ 'website/static/src/xml/website.backend.xml', 'website/static/src/xml/website_widget.xml', 'website/static/src/xml/theme_preview.xml', + 'website/static/src/components/configurator/configurator.xml', ], 'website.test_bundle': [ '/web/static/lib/qweb/qweb2.js', @@ -211,5 +212,15 @@ 'http://test.external.link/javascript2.js', 'http://test.external.link/style2.css', ], + 'website.website_configurator_assets_js': [ + '/web/static/lib/owl/owl.js', + '/website/static/src/components/configurator/configurator.js', + ], + 'website.website_configurator_assets_scss': [ + ('include', 'web.assets_frontend'), + '/website/static/src/scss/configurator.scss', + '/website/static/src/scss/website.theme_install.scss', + '/website/static/src/scss/website.wysiwyg.scss' + ] } } diff --git a/addons/website/controllers/main.py b/addons/website/controllers/main.py index 836b2ea4cf9..6239f0f9f4e 100644 --- a/addons/website/controllers/main.py +++ b/addons/website/controllers/main.py @@ -223,6 +223,16 @@ class Website(Home): } return request.render('website.website_info', values) + @http.route(['/website/configurator/'], type='http', auth="user", website=True) + def website_configurator(self, step=1, **kwargs): + if not request.env.user.has_group('website.group_website_designer'): + raise werkzeug.exceptions.NotFound() + website_id = request.env['website'].get_current_website() + if website_id.configurator_done is False: + return request.render('website.website_configurator', {'lang': request.env.user.lang}) + else: + return request.redirect('/') + @http.route(['/website/social/'], type='http', auth="public", website=True, sitemap=False) def social(self, social, **kwargs): url = getattr(request.website, 'social_%s' % social, False) diff --git a/addons/website/data/ir_asset.xml b/addons/website/data/ir_asset.xml index 67e07c1cea5..0d401d3d202 100644 --- a/addons/website/data/ir_asset.xml +++ b/addons/website/data/ir_asset.xml @@ -22,7 +22,14 @@ web.assets_frontend website/static/src/scss/compatibility/bs3_for_12_0.scss - + + + + website.configurator_tour + website.assets_editor + website/static/src/js/tours/configurator_tour.js + + diff --git a/addons/website/data/website_data.xml b/addons/website/data/website_data.xml index 9b1e727cf18..823bed4a18e 100644 --- a/addons/website/data/website_data.xml +++ b/addons/website/data/website_data.xml @@ -1,72 +1,70 @@ - - - - Home - qweb - website.homepage - - - - -
- - - - + + + Multi-website + + - - Contact Us - qweb - website.contactus - - - -
+ + + Website + / + self + + + + + + + + + + + + + + + + About Us + Info and stats about your company + about_us + 1 + + fa-building + + + Our Services + Description of your services offer + our_services + 3 + + fa-handshake-o + + + Pricing + Design to drive conversion + pricing + 4 + + fa-random + + + Privacy Policy + Explain how you protect privacy + privacy_policy + 5 + + fa-gavel + + + + + News + Perfect for news and useful content + 6 + blog + + fa-rss + + + Success Stories + Share your best case studies + 7 + + fa-star + + + Career + Publish jobs offer and let people apply + 8 + + fa-address-card + + + Shop + Sell more with an eCommerce + 9 + online_store + + fa-shopping-cart + + + Events + Publish on-site and online events + 10 + event + + fa-ticket + + + Forum + Give visitors the information they need + 11 + + fa-users + + + Live Chat + Chat with visitors to improve traction + 12 + + fa-comments + + + eLearning + Share videos & presentations, publicly or for a fee + 13 + elearning + + fa-graduation-cap + + + Stores Locator + A map and a listing of your stores + 15 + + fa-map-marker + + + True / @@ -207,6 +375,7 @@ ('cached' if not request.params else None,) True + Default Main Menu @@ -227,6 +396,7 @@ 60 + My Website @@ -235,22 +405,6 @@ - - - Website - / - self - - - Website Edit Mode - /?enable_editor=1 - self - - - - open - - @@ -714,10 +868,4 @@ - - - Multi-website - - - diff --git a/addons/website/models/__init__.py b/addons/website/models/__init__.py index b55ec43dbf1..0c17686e9e6 100644 --- a/addons/website/models/__init__.py +++ b/addons/website/models/__init__.py @@ -25,3 +25,4 @@ from . import res_lang from . import theme_models from . import website_visitor from . import website_snippet_filter +from . import website_configurator_feature diff --git a/addons/website/models/ir_actions.py b/addons/website/models/ir_actions.py index 305820503c7..24fbc937514 100644 --- a/addons/website/models/ir_actions.py +++ b/addons/website/models/ir_actions.py @@ -60,18 +60,3 @@ class ServerAction(models.Model): """ res = super(ServerAction, self)._run_action_code_multi(eval_context) return eval_context.get('response', res) - - -class IrActionsTodo(models.Model): - _name = 'ir.actions.todo' - _inherit = 'ir.actions.todo' - - def action_launch(self): - res = super().action_launch() # do ensure_one() - - if self.id == self.env.ref('website.theme_install_todo').id: - # Pick a theme consume all ir.actions.todo by default (due to lower sequence). - # Once done, we re-enable the main ir.act.todo: open_menu - self.env.ref('base.open_menu').action_open() - - return res diff --git a/addons/website/models/res_company.py b/addons/website/models/res_company.py index ae1ff1f5696..bd71aed301f 100644 --- a/addons/website/models/res_company.py +++ b/addons/website/models/res_company.py @@ -22,19 +22,6 @@ class Company(models.Model): partner = self.sudo().partner_id return partner and partner.google_map_link(zoom) or None - def _compute_website_theme_onboarding_done(self): - """ The step is marked as done if one theme is installed. """ - # we need the same domain as the existing action - action = self.env["ir.actions.actions"]._for_xml_id("website.theme_install_kanban_action") - domain = literal_eval(action['domain']) - domain.append(('state', '=', 'installed')) - installed_themes_count = self.env['ir.module.module'].sudo().search_count(domain) - for record in self: - record.website_theme_onboarding_done = (installed_themes_count > 0) - - website_theme_onboarding_done = fields.Boolean("Onboarding website theme step done", - compute='_compute_website_theme_onboarding_done') - def _get_public_user(self): self.ensure_one() # We need sudo to be able to see public users from others companies too diff --git a/addons/website/models/website.py b/addons/website/models/website.py index c9fb388c964..7eb43cf6dd3 100644 --- a/addons/website/models/website.py +++ b/addons/website/models/website.py @@ -5,6 +5,7 @@ import base64 import inspect import logging import hashlib +import requests import re @@ -16,11 +17,13 @@ from odoo import api, fields, models, tools from odoo.addons.http_routing.models.ir_http import slugify, _guess_mimetype, url_for from odoo.addons.website.models.ir_http import sitemap_qs2dom from odoo.addons.portal.controllers.portal import pager +from odoo.addons.iap.tools import iap_tools from odoo.exceptions import UserError from odoo.http import request from odoo.modules.module import get_resource_path from odoo.osv.expression import FALSE_DOMAIN from odoo.tools.translate import _ +from odoo.tools import pycompat logger = logging.getLogger(__name__) @@ -35,6 +38,7 @@ DEFAULT_CDN_FILTERS = [ "^/website/image/", ] +DEFAULT_ENDPOINT = 'https://website.api.odoo.com' class Website(models.Model): @@ -63,6 +67,7 @@ class Website(models.Model): default_lang_id = fields.Many2one('res.lang', string="Default Language", default=_default_language, required=True) auto_redirect_lang = fields.Boolean('Autoredirect Language', default=True, help="Should users be redirected to their browser's language") cookies_bar = fields.Boolean('Cookies Bar', help="Display a customizable cookies bar on your website.") + configurator_done = fields.Boolean(help='True if configurator has been completed or ignored') def _default_social_facebook(self): return self.env.ref('base.main_company').social_facebook @@ -241,10 +246,138 @@ class Website(models.Model): attachments_to_unlink.unlink() return super(Website, self).unlink() - def create_and_redirect_to_theme(self): + def create_and_redirect_configurator(self): self._force() - action = self.env.ref('website.theme_install_kanban_action') - return action.read()[0] + action = self.env['ir.actions.actions']._for_xml_id('website.start_configurator_act_url') + return action + + # ---------------------------------------------------------- + # Configurator + # ---------------------------------------------------------- + def _website_api_rpc(self, route, params): + IrConfigParameter = self.env['ir.config_parameter'].sudo() + website_api_endpoint = IrConfigParameter.get_param('website.website_api_endpoint', DEFAULT_ENDPOINT) + endpoint = website_api_endpoint + route + return iap_tools.iap_jsonrpc(endpoint, params=params) + + @api.model + def configurator_init(self): + r = dict() + company_id = self.get_current_website().company_id + r['features'] = self.env['website.configurator.feature'].search_read([], ['name', 'description', 'type', 'icon', 'website_types_preselection']) + r['logo'] = False + if company_id.logo and company_id.logo != company_id._get_logo(): + r['logo'] = company_id.logo.decode('utf-8') + r['industries'] = self._website_api_rpc('/api/website/1/configurator/industries', {'lang': self.env.user.lang})['industries'] + return r + + @api.model + def configurator_recommended_themes(self, industry_name, palette): + domain = [('name', '=like', 'theme%'), ('name', 'not in', ['theme_default', 'theme_common'])] + client_themes = request.env['ir.module.module'].search(domain).mapped('name') + params = { + 'industry_name': industry_name, + 'palette': palette, + 'client_themes': client_themes, + } + return self._website_api_rpc('/api/website/1/configurator/recommended_themes', params) + + @api.model + def configurator_skip(self): + self.configurator_done = True + + @api.model + def configurator_apply(self, **kwargs): + def set_colors(selected_palette): + url = '/website/static/src/scss/options/user_values.scss' + custo = {'color-palettes-name': "'%s'" % selected_palette} + self.env['web_editor.assets'].make_scss_customization(url, custo) + + def set_features(selected_features): + feature_ids = self.env['website.configurator.feature'].browse(selected_features) + pages_views = {} + modules = self.env['ir.module.module'] + for feature_id in feature_ids: + if feature_id.type == 'app' and feature_id.module_id and feature_id.module_id.state != 'installed': + modules += feature_id.module_id + if feature_id.type == 'page' and feature_id.page_view_id: + result = self.env['website'].new_page(name=feature_id.name, add_menu=True, template=feature_id.page_view_id.key) + pages_views[feature_id.iap_page_code] = result['view_id'] + + modules._button_immediate_function(lambda self: self.button_install()) + return pages_views + + def configure_page(page_code, snippet_list, pages_views): + if page_code == 'homepage': + page_view_id = website.homepage_id.view_id + else: + page_view_id = self.env['ir.ui.view'].browse(pages_views[page_code]) + rendered_snippets = [] + for snippet in snippet_list: + try: + view_id = self.env['website'].with_context(website_id=website.id).viewref(snippet) + if view_id: + rendered_snippet = pycompat.to_text(view_id._render()) + rendered_snippets.append(rendered_snippet) + except ValueError as e: + logger.warning(e) + page_view_id.save(value=''.join(rendered_snippets), xpath="(//div[hasclass('oe_structure')])[last()]") + + def set_images(images): + for image in images: + try: + response = requests.get(image['url'], timeout=3) + response.raise_for_status() + self.env['ir.attachment'].create({ + 'name': image['name'], + 'website_id': website.id, + 'key': image['name'], + 'type': 'binary', + 'raw': response.content, + }) + except requests.HTTPError: + logger.warning("Failed to download image '%s'", image['url']) + + website = self.get_current_website() + + url = self.env['ir.module.module'].search([('name', '=', kwargs['theme_name'])]).button_choose_theme() + + website.configurator_done = True + + # Enable tour + tour_asset_id = self.env['ir.asset']._get_related_assets([('name', '=', 'website.configurator_tour')]) + tour_asset_id.sudo().write({'active': True}) + + # logo was generated as base64 url + logo = kwargs.get('logo') + if logo: + website.logo = logo.split(',', 1)[1] + + # palette + palette = kwargs.get('selected_palette') + if palette: + set_colors(palette) + + # modules + pages_views = set_features(kwargs.get('selected_features')) + + # Load suggestion from iap for selected pages + params = { + 'theme': kwargs.get('theme_name'), + 'industry': kwargs.get('industry'), + 'pages': list(pages_views.keys()), + } + custom_resources = self._website_api_rpc('/api/website/1/configurator/custom_resources', params) + + # Update pages + pages = custom_resources.get('pages', {}) + for page_code, snippet_list in pages.items(): + configure_page(page_code, snippet_list, pages_views) + + images = custom_resources.get('images', []) + set_images(images) + + return url # ---------------------------------------------------------- # Page Management diff --git a/addons/website/models/website_configurator_feature.py b/addons/website/models/website_configurator_feature.py new file mode 100644 index 00000000000..28a06798883 --- /dev/null +++ b/addons/website/models/website_configurator_feature.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError + + +class WebsiteConfiguratorFeature(models.Model): + + _name = 'website.configurator.feature' + _description = 'Website Configurator Feature' + _order = 'sequence' + + sequence = fields.Integer() + name = fields.Char(translate=True) + description = fields.Char(translate=True) + icon = fields.Char() + iap_page_code = fields.Char(help='Page code used to tell IAP website_service for which page a snippet list should be generated') + website_types_preselection = fields.Char(help='Comma-separated list of website type for which this feature should be pre-selected') + type = fields.Selection([('page', "Page"), ('app', "App")], compute='_compute_type') + page_view_id = fields.Many2one('ir.ui.view', ondelete='cascade') + module_id = fields.Many2one('ir.module.module', ondelete='cascade') + + @api.depends('module_id', 'page_view_id') + def _compute_type(self): + for record in self: + record.type = 'page' if record.page_view_id else 'app' + + @api.constrains('module_id', 'page_view_id') + def _check_module_xor_page_view(self): + if bool(self.module_id) == bool(self.page_view_id): + raise ValidationError(_("One and only one of the two fields 'page_view_id' and 'module_id' should be set")) diff --git a/addons/website/security/ir.model.access.csv b/addons/website/security/ir.model.access.csv index 6b5c4fa8fb5..154e846bd03 100644 --- a/addons/website/security/ir.model.access.csv +++ b/addons/website/security/ir.model.access.csv @@ -24,3 +24,4 @@ access_theme_website_page,access_theme_website_page,model_theme_website_page,bas access_website_robots,access.website.robots,model_website_robots,website.group_website_designer,1,1,1,0 access_website_dynamic_filter_public,access_website_dynamic_filter,model_website_snippet_filter,,0,0,0,0 access_website_dynamic_filter_system,access_website_dynamic_filter,model_website_snippet_filter,base.group_system,1,1,1,1 +access_website_configurator_feature_designer,access_website_configurator_feature_designer,model_website_configurator_feature,website.group_website_designer,1,0,0,0 diff --git a/addons/website/static/src/components/configurator/configurator.js b/addons/website/static/src/components/configurator/configurator.js new file mode 100644 index 00000000000..c59fdf588f7 --- /dev/null +++ b/addons/website/static/src/components/configurator/configurator.js @@ -0,0 +1,515 @@ +/** @odoo-module **/ + +import rpc from 'web.rpc'; +import utils from 'web.utils'; +import weUtils from 'web_editor.utils'; +import session from 'web.session'; +import {_t, _lt} from 'web.core'; + +const {Component, Store, mount, QWeb} = owl; +const {useDispatch, useStore, useGetters, useRef} = owl.hooks; +const {Router, RouteComponent} = owl.router; +const {whenReady} = owl.utils; + +const MAX_PALETTES = 16; + +const WEBSITE_TYPES = { + 1: {id: 1, label: _lt("a business website"), name: 'business'}, + 2: {id: 2, label: _lt("an online store"), name: 'online_store'}, + 3: {id: 3, label: _lt("a blog"), name: 'blog'}, + 4: {id: 4, label: _lt("an event website"), name: 'event'}, + 5: {id: 5, label: _lt("an elearning platform"), name: 'elearning'} +}; + +const WEBSITE_PURPOSES = { + 1: {id: 1, label: _lt("get leads"), name: 'get_leads'}, + 2: {id: 2, label: _lt("develop the brand"), name: 'develop_brand'}, + 3: {id: 3, label: _lt("sell more"), name: 'sell_more'}, + 4: {id: 4, label: _lt("inform customers"), name: 'inform_customers'} +}; + +const hex2lab = (hex) => { + let r = parseInt(hex.substring(1, 3), 16) / 255, g = parseInt(hex.substring(3, 5), 16) / 255, b = parseInt(hex.substring(5, 7), 16) / 255, x, y, z; + [r, g, b] = [r, g, b].map((x) => (x > 0.04045) ? Math.pow((x + 0.055) / 1.055, 2.4) : x / 12.92); + x = (r * 0.4124 + g * 0.3576 + b * 0.1805) / 0.95047; + y = (r * 0.2126 + g * 0.7152 + b * 0.0722) / 1.00000; + z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.08883; + [x, y, z] = [x, y, z].map((x) => (x > 0.008856) ? Math.pow(x, 1 / 3) : (7.787 * x) + 16 / 116); + return [(116 * y) - 16, 500 * (x - y), 200 * (y - z)]; +}; + +const deltaE = (hexA, hexB) => { + let labA = hex2lab(hexA), labB = hex2lab(hexB); + let deltaL = labA[0] - labB[0], deltaA = labA[1] - labB[1], deltaB = labA[2] - labB[2]; + let c1 = Math.sqrt(labA[1] * labA[1] + labA[2] * labA[2]), c2 = Math.sqrt(labB[1] * labB[1] + labB[2] * labB[2]); + let deltaC = c1 - c2; + let deltaH = deltaA * deltaA + deltaB * deltaB - deltaC * deltaC; + deltaH = deltaH < 0 ? 0 : Math.sqrt(deltaH); + let sc = 1.0 + 0.045 * c1, sh = 1.0 + 0.015 * c1; + let deltaLKlsl = deltaL / (1.0), deltaCkcsc = deltaC / (sc), deltaHkhsh = deltaH / (sh); + let i = deltaLKlsl * deltaLKlsl + deltaCkcsc * deltaCkcsc + deltaHkhsh * deltaHkhsh; + return i < 0 ? 0 : Math.sqrt(i); +}; + +//--------------------------------------------------------- +// Components +//--------------------------------------------------------- + +class SkipButton extends Component { + async skip() { + await skipConfigurator(); + } +} + +SkipButton.template = 'website.Configurator.SkipButton'; + +class WelcomeScreen extends Component { + constructor() { + super(...arguments); + this.dispatch = useDispatch(); + } + + goToDescription() { + this.env.router.navigate({to: 'CONFIGURATOR_DESCRIPTION_SCREEN'}); + } +} + +Object.assign(WelcomeScreen, { + components: {SkipButton}, + template: 'website.Configurator.WelcomeScreen', +}); + +class DescriptionScreen extends Component { + constructor() { + super(...arguments); + this.industrySelection = useRef('industrySelection'); + this.state = useStore((state) => state); + this.labelToName = {}; + this.getters = useGetters(); + this.dispatch = useDispatch(); + } + + mounted() { + this.dispatch('selectWebsitePurpose', undefined); + this.dispatch('selectIndustry', undefined); + $(this.industrySelection.el).autocomplete({ + appendTo: '.o_configurator_industry_wrapper', + delay: 400, + minLength: 1, + source: this.autocompleteSearch.bind(this), + select: this.selectIndustry.bind(this), + classes: { + 'ui-autocomplete': 'custom-ui-autocomplete shadow-lg border-0 o_configurator_show_fast', + } + }); + } + + autocompleteSearch(request, response) { + const lcTerm = request.term.toLowerCase(); + const limit = 15; + const matches = this.state.industries.filter((val) => { + return val.label.startsWith(lcTerm); + }); + let results = matches.slice(0, limit); + this.labelToName = {}; + let labels = results.map((val) => val.label); + if (labels.length < limit) { + let relaxedMatches = this.state.industries.filter((val) => { + return val.label.includes(lcTerm) && !labels.includes(val.label); + }); + relaxedMatches = relaxedMatches.slice(0, limit - labels.length); + results = results.concat(relaxedMatches); + labels = results.map((val) => val.label); + } + results.forEach((r) => { + this.labelToName[r.label] = r.name; + }); + response(labels); + } + + selectIndustry(_, ui) { + this.dispatch('selectIndustry', this.labelToName[ui.item.label]); + this.checkDescriptionCompletion(); + } + + blurIndustrySelection(ev) { + const name = this.labelToName[ev.target.outerText]; + this.dispatch('selectIndustry', name); + if (name === undefined) { + this.industrySelection.el.textContent = ''; + } else { + this.checkDescriptionCompletion(); + } + } + + inputIndustrySelection(ev) { + this.dispatch('selectIndustry', this.labelToName[ev.target.outerText]); + } + + selectWebsiteType(ev) { + const {id} = ev.target.dataset; + this.dispatch('selectWebsiteType', id); + setTimeout(() => { + this.industrySelection.el.focus(); + }); + this.checkDescriptionCompletion(); + } + + selectWebsitePurpose(ev) { + const {id} = ev.target.dataset; + this.dispatch('selectWebsitePurpose', id); + this.checkDescriptionCompletion(); + } + + checkDescriptionCompletion() { + const {selectedType, selectedPurpose, selectedIndustry} = this.state; + if (selectedType && selectedPurpose && selectedIndustry) { + this.env.router.navigate({to: 'CONFIGURATOR_PALETTE_SELECTION_SCREEN'}); + } + } +} + +Object.assign(DescriptionScreen, { + components: {SkipButton}, + template: 'website.Configurator.DescriptionScreen', +}); + +class PaletteSelectionScreen extends Component { + constructor() { + super(...arguments); + this.state = useStore((state) => state); + this.getters = useGetters(); + this.dispatch = useDispatch(); + this.logoInputRef = useRef('logoSelectionInput'); + } + + mounted() { + if (this.state.logo) { + this.updatePalettes(); + } + } + + uploadLogo() { + this.logoInputRef.el.click(); + } + + async changeLogo() { + const logoSelectInput = this.logoInputRef.el; + if (logoSelectInput.files.length === 1) { + const file = logoSelectInput.files[0]; + const data = await utils.getDataURLFromFile(file); + this.dispatch('changeLogo', data); + this.updatePalettes(); + } + } + + async updatePalettes() { + let img = this.state.logo.split(',', 2)[1]; + const [color1, color2] = await rpc.query({ + model: 'base.document.layout', + method: 'extract_image_primary_secondary_colors', + args: [img] + }); + this.dispatch('setRecommendedPalette', color1, color2); + } + + selectPalette(paletteId) { + this.dispatch('selectPalette', paletteId); + this.env.router.navigate({to: 'CONFIGURATOR_FEATURES_SELECTION_SCREEN'}); + } +} + +Object.assign(PaletteSelectionScreen, { + components: {SkipButton}, + template: 'website.Configurator.PaletteSelectionScreen', +}); + +class FeaturesSelectionScreen extends Component { + constructor() { + super(...arguments); + this.state = useStore((state) => state); + this.getters = useGetters(); + this.dispatch = useDispatch(); + } + + async buildWebsite() { + const industryName = this.state.selectedIndustry; + if (!industryName) { + this.env.router.navigate({to: 'CONFIGURATOR_DESCRIPTION_SCREEN'}); + return; + } + const params = { + industry_name: industryName, + palette: this.state.selectedPalette + }; + const themes = await rpc.query({ + model: 'website', + method: 'configurator_recommended_themes', + kwargs: params, + }); + + if (!themes.length) { + await applyConfigurator(this, 'theme_default'); + } else { + this.dispatch('updateRecommendedThemes', themes); + this.env.router.navigate({to: 'CONFIGURATOR_THEME_SELECTION_SCREEN'}); + } + } +} + +Object.assign(FeaturesSelectionScreen, { + components: {SkipButton}, + template: 'website.Configurator.FeatureSelection', +}); + +class ThemeSelectionScreen extends Component { + constructor() { + super(...arguments); + this.state = useStore((state) => state); + this.getters = useGetters(); + this.themeSVGPreviews = [useRef('ThemePreview1'), useRef('ThemePreview2'), useRef('ThemePreview3')]; + } + + mounted() { + this.state.themes.forEach((theme, idx) => { + $(this.themeSVGPreviews[idx].el).append(theme.svg); + }); + } + + async chooseTheme(themeName) { + await applyConfigurator(this, themeName); + } +} + +ThemeSelectionScreen.template = 'website.Configurator.ThemeSelectionScreen'; + +class App extends Component {} + +Object.assign(App, { + components: {RouteComponent}, + template: 'website.Configurator.App', +}); + +//--------------------------------------------------------- +// Routes +//--------------------------------------------------------- + +const ROUTES = [ + {name: 'CONFIGURATOR_WELCOME_SCREEN', path: '/website/configurator/1', component: WelcomeScreen}, + {name: 'CONFIGURATOR_DESCRIPTION_SCREEN', path: '/website/configurator/2', component: DescriptionScreen}, + {name: 'CONFIGURATOR_PALETTE_SELECTION_SCREEN', path: '/website/configurator/3', component: PaletteSelectionScreen}, + {name: 'CONFIGURATOR_FEATURES_SELECTION_SCREEN', path: '/website/configurator/4', component: FeaturesSelectionScreen}, + {name: 'CONFIGURATOR_THEME_SELECTION_SCREEN', path: '/website/configurator/5', component: ThemeSelectionScreen}, + {name: 'CONFIGURATOR_WELCOME_SCREEN_LANG', path: '/{{lang}}/website/configurator/1', component: WelcomeScreen}, +]; + +//--------------------------------------------------------- +// Store +//--------------------------------------------------------- + +const getters = { + getWebsiteTypes() { + return Object.values(WEBSITE_TYPES); + }, + + getSelectedType(_, id) { + return id ? WEBSITE_TYPES[id] : undefined; + }, + + getWebsitePurpose() { + return Object.values(WEBSITE_PURPOSES); + }, + + getSelectedPurpose(_, id) { + return id ? WEBSITE_PURPOSES[id] : undefined; + }, + + getFeatures({state}) { + return Object.values(state.features); + }, + + getPalettes({state}) { + return Object.values(state.palettes); + }, + + getThemeName({state}, idx) { + return state.themes.length > idx ? state.themes[idx].name : undefined; + } +}; + +const actions = { + selectWebsiteType({state}, id) { + Object.values(state.features).forEach((feature) => { + feature.selected = feature.website_types_preselection.includes(WEBSITE_TYPES[id].name); + }); + state.selectedType = id; + }, + selectWebsitePurpose({state}, id) { + state.selectedPurpose = id; + }, + selectIndustry({state}, name) { + state.selectedIndustry = name; + }, + changeLogo({state}, data) { + state.logo = data; + }, + selectPalette({state}, paletteId) { + state.selectedPalette = state.allPalettes[paletteId]; + }, + toggleFeature({state}, featureId) { + const feature = state.features[featureId]; + const websiteType = WEBSITE_TYPES[state.selectedType]; + const forceFeatureActive = websiteType && feature.website_types_preselection.includes(websiteType.name); + feature.selected = !feature.selected || forceFeatureActive; + }, + setRecommendedPalette({state}, color1, color2) { + let palettes = []; + if (color1 && color2) { + Object.values(state.allPalettes).forEach(palette => { + const delta1 = deltaE(color1, palette.color1); + const delta2 = deltaE(color2, palette.color2); + palette.score = (delta1 + delta2) / 2; + }); + palettes = Object.values(state.allPalettes).sort((a, b) => a.score - b.score); + state.recommendedPalette = palettes[0]; + } else { + palettes = Object.values(state.allPalettes); + } + const selectedPalettes = {}; + palettes.slice(1, MAX_PALETTES + 1).forEach((palette) => { + selectedPalettes[palette.id] = palette; + }); + state.palettes = selectedPalettes; + }, + updateRecommendedThemes({state}, themes) { + state.themes = themes.slice(0, 3); + } +}; + +async function getInitialState() { + + // Load values from python and iap + try { + var results = await rpc.query({ + model: 'website', + method: 'configurator_init', + }); + } catch (_e) { + return {}; + } + const r = { + features: {}, + industries: results.industries, + logo: results.logo ? 'data:image/png;base64,' + results.logo : false, + }; + results.features.forEach(feature => { + r.features[feature.id] = Object.assign({}, feature, {selected: false}); + const wtp = r.features[feature.id].website_types_preselection; + r.features[feature.id].website_types_preselection = wtp ? wtp.split(',') : []; + }); + + // Load palettes from the current CSS + const allPalettes = {}, palettes = {}; + const style = window.getComputedStyle(document.documentElement); + const allPaletteNames = weUtils.getCSSVariableValue('palette-names', style).split(' ').map((name) => { + return name.replace(/'/g, ""); + }); + allPaletteNames.forEach((paletteName) => { + const palette = { + id: paletteName + }; + for (let j = 1; j <= 5; j += 1) { + const color = weUtils.getCSSVariableValue(`o-palette-${paletteName}-o-color-${j}`, style); + palette[`color${j}`] = color; + } + let duplicate = false; + for (const validatedPalette of Object.values(allPalettes)) { + if (validatedPalette.color1.toLowerCase() === palette.color1.toLowerCase() && validatedPalette.color2.toLowerCase() === palette.color2.toLowerCase()) { + duplicate = true; + } + } + if (!duplicate) { + allPalettes[paletteName] = palette; + if (Object.keys(palettes).length < MAX_PALETTES) { + palettes[paletteName] = allPalettes[paletteName]; + } + } + }); + + return Object.assign(r, { + selectedType: undefined, + selectedPurpose: undefined, + selectedIndustry: undefined, + selectedPalette: undefined, + recommendedPalette: undefined, + palettes: palettes, + allPalettes: allPalettes, + themes: [], + }); +} + +async function skipConfigurator() { + await rpc.query({ + model: 'website', + method: 'configurator_skip', + }); + window.location = '/web#action=website.theme_install_kanban_action'; +} + +async function applyConfigurator(self, themeName) { + if (!self.state.selectedIndustry) { + self.env.router.navigate({to: 'CONFIGURATOR_DESCRIPTION_SCREEN'}); + return; + } + if (!self.state.selectedPalette) { + self.env.router.navigate({to: 'CONFIGURATOR_PALETTE_SELECTION_SCREEN'}); + return; + } + if (themeName !== undefined) { + $('body').append(self.env.loader); + const selectedFeatures = Object.values(self.state.features).filter((feature) => feature.selected).map((feature) => feature.id); + const data = { + selected_features: selectedFeatures, + logo: self.state.logo, + industry: self.state.selectedIndustry, + selected_palette: self.state.selectedPalette.id, + theme_name: themeName, + }; + const resp = await rpc.query({ + model: 'website', + method: 'configurator_apply', + kwargs: {...data}, + }); + window.location = resp.url; + } +} + +async function makeEnvironment() { + const env = {}; + const router = new Router(env, ROUTES); + await router.start(); + const state = await getInitialState(); + const store = new Store({state, actions, getters}); + await session.is_bound; + const qweb = new QWeb({translateFn: _t}); + const loaderTemplate = await owl.utils.loadFile('/website/static/src/xml/theme_preview.xml'); + const configuratorTemplates = await owl.utils.loadFile('/website/static/src/components/configurator/configurator.xml'); + qweb.addTemplates(loaderTemplate); + qweb.addTemplates(configuratorTemplates); + + env.loader = qweb.renderToString('website.ThemePreview.Loader', { + showTips: true + }); + return Object.assign(env, {router, store, qweb}); +} + +async function setup() { + const env = await makeEnvironment(); + if (!env.store.state.industries) { + await skipConfigurator(); + } else { + mount(App, {target: document.body, env}); + } +} + +whenReady(setup); diff --git a/addons/website/static/src/components/configurator/configurator.xml b/addons/website/static/src/components/configurator/configurator.xml new file mode 100644 index 00000000000..177f894ec6a --- /dev/null +++ b/addons/website/static/src/components/configurator/configurator.xml @@ -0,0 +1,234 @@ + + + + +
+ +
+
+ + +
+
+ +
+
+
+
Ready to build the
+ perfect website? +
+
We'll set you up and running in 4 steps +
+ +
+
+ +
+
+ + +
+
+ +
+
+
+
+ I want +
+ +
+ + + + + +
+
+ for my +
+
+
+ +
+ business + , +
+
+ with the main objective to +
+ + +
+
+
+
+ +
+
+ + +
+
+ +
+
+
+
+
+ Detect from Logo
+
+ + +
+ +
Upload a new image +
+
+
+ +
+
+
+
Detected Colors
+
+
+
+
+
+
+
+
+
+
+ OR +
+
+
+
+ Choose Your Brand Color
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+ +
+ + + +
+
+ +
+
+
+
Add Pages or Features
+
+
+ +
+
+
+ +
+ + +
+

+

+
+
+
+
+
+
+ +
+
+
+ +
+
+ + +
+
+ +
+
+
+
Choose your favorite Theme +
+
+
+
+ +
+
Click to select
+
+
+
+ +
+
+ +
+
Click to select
+
+
+
+ +
+
+ +
+
Click to select
+
+
+
+ +
+
+
+
+
+
+
+ + +
+ +
+
+ + diff --git a/addons/website/static/src/img/odoo_logo.svg b/addons/website/static/src/img/odoo_logo.svg new file mode 100644 index 00000000000..42d98508025 --- /dev/null +++ b/addons/website/static/src/img/odoo_logo.svg @@ -0,0 +1,10 @@ + + + Odoo Logo + + + + diff --git a/addons/website/static/src/js/tours/configurator_tour.js b/addons/website/static/src/js/tours/configurator_tour.js new file mode 100644 index 00000000000..a23e8be3802 --- /dev/null +++ b/addons/website/static/src/js/tours/configurator_tour.js @@ -0,0 +1,48 @@ +odoo.define("website.configurator.tour", function (require) { +"use strict"; + +const wTourUtils = require("website.tour_utils"); +const core = require("web.core"); +const _t = core._t; + +let titleSelector = '#wrap > section:first-child'; +let title = $(titleSelector).find('h1, h2').first(); +if (!title.length) { + titleSelector = titleSelector.replace('section:first-child', 'section:nth-child(2)'); + title = $(titleSelector).find('h1, h2').first(); +} +let isTitleTextImage = $(titleSelector).hasClass('s_text_image'); +titleSelector = titleSelector.concat(` ${title.is('h1') ? 'h1' : 'h2'}`); + +const shapeSelector = '#wrap > section[data-oe-shape-data]'; +const backgroundSelector = '#wrap > section:nth-child(2)'; + +const imageStep = isTitleTextImage ? + wTourUtils.changeImage(titleSelector.replace('h2', 'img')) : wTourUtils.changeBackground(); + +const backgroundColorStep = [wTourUtils.changeBackgroundColor()]; +if (!isTitleTextImage) { + backgroundColorStep.unshift(wTourUtils.clickOnSnippet(backgroundSelector)); +} + +const shapeStep = []; +if ($(shapeSelector).first().length) { + if (!$(backgroundSelector).is($(shapeSelector))) { + shapeStep.push(wTourUtils.clickOnSnippet(shapeSelector)); + } + shapeStep.push(wTourUtils.changeOption('BackgroundShape', 'we-toggler', _t('Background Shape'))); + shapeStep.push(wTourUtils.selectNested('we-select-page', 'BackgroundShape', ':not(.o_we_pager_controls', _t('Background Shape'))); +} + +const steps = [ + wTourUtils.clickOnText(titleSelector), + imageStep, + ...backgroundColorStep, + ...shapeStep, + wTourUtils.changePaddingSize('top'), + wTourUtils.clickOnSave(), +]; + +wTourUtils.registerThemeHomepageTour('configurator_tour', steps); + +}); diff --git a/addons/website/static/src/js/tours/tour_utils.js b/addons/website/static/src/js/tours/tour_utils.js index 84be86fa913..b258f0a803a 100644 --- a/addons/website/static/src/js/tours/tour_utils.js +++ b/addons/website/static/src/js/tours/tour_utils.js @@ -48,7 +48,7 @@ function addMedia(position = "right") { function changeBackground(snippet, position = "bottom") { return { - trigger: ".o_we_customize_panel .o_we_edit_image", + trigger: ".o_we_customize_panel .o_we_bg_success", content: _t("Customize any block through this menu. Try to change the background image of this block."), position: position, run: "click", @@ -94,7 +94,7 @@ function changeIcon(snippet, index = 0, position = "bottom") { function changeImage(snippet, position = "bottom") { return { - trigger: `#wrapwrap .${snippet.id} img`, + trigger: snippet.id ? `#wrapwrap .${snippet.id} img` : snippet, content: _t("Double click on an image to change it with one of your choice."), position: position, run: "dblclick", @@ -160,7 +160,7 @@ function clickOnEdit(position = "bottom") { */ function clickOnSnippet(snippet, position = "bottom") { return { - trigger: `#wrapwrap .${snippet.id}`, + trigger: snippet.id ? `#wrapwrap .${snippet.id}` : snippet, content: _t("Click on a snippet to access its options menu."), position: position, run: "click", @@ -184,7 +184,7 @@ function clickOnSave(position = "bottom") { */ function clickOnText(snippet, element, position = "bottom") { return { - trigger: `#wrapwrap .${snippet.id} ${element}`, + trigger: snippet.id ? `#wrapwrap .${snippet.id} ${element}` : snippet, content: _t("Click on a text to start editing it."), position: position, run: "text", diff --git a/addons/website/static/src/scss/configurator.scss b/addons/website/static/src/scss/configurator.scss new file mode 100644 index 00000000000..ae87b719d15 --- /dev/null +++ b/addons/website/static/src/scss/configurator.scss @@ -0,0 +1,280 @@ +.o_configurator_container { + @include o-position-absolute(0, 0, 0, 0); + + .o_configurator_screen { + + // ==== Animations + @keyframes configuratorFadeIn{ + from {opacity: 0} + to {opacity: 1} + } + + @keyframes theme_screenshot_scroll { + to { transform: translate3d(0, -24%, 0)} + } + + @keyframes theme_screenshot_scroll_small { + to { transform: translate3d(0, -31.5%, 0)} + } + + // ==== General Components + %o_configurator_titles_weight { + font-weight: 300; + + b { + font-weight: 500; + } + } + + .o_configurator_typing_text { + font-size: 28px; + @extend %o_configurator_titles_weight; + + @include media-breakpoint-up(md) { + font-size: 32px; + } + + @include media-breakpoint-up(lg) { + font-size: 36px; + } + + @include media-breakpoint-up(xl) { + font-size: 40px; + } + } + + @for $i from 1 through 6 { + .h#{$i} { + @extend %o_configurator_titles_weight; + } + } + + .o_configurator_hide { + opacity: 0; + } + + .o_configurator_show { + animation: configuratorFadeIn 1s; + } + + .o_configurator_show_fast { + animation: configuratorFadeIn .35s; + } + + .o_configurator_screen_content { + overflow-y: auto; + } + + .o_configurator_logo_wrapper { + @extend %o-preview-alpha-background; + img { + object-fit: contain; + } + } + + // ==== Screens + + &.o_description_screen { + .dropdown, .industry_selection, .custom-ui-autocomplete { + font-weight: 500; + cursor: pointer; + } + + .o_configurator_type_dd.o_step_todo { + min-width: 13ch; + } + + .industry_selection.o_step_todo { + min-width: 17ch; + } + + .o_configurator_purpose_dd.o_step_todo { + min-width: 12ch; + } + + .dropdown { + box-shadow: inset 0 -3px 0 theme-color(primary); + + &.o_step_todo .fa-angle-down { + transform: translateY(-0.26em); + } + + &.o_step_completed { + transition: all 300ms ease; + + .fa-angle-down { + @include o-position-absolute($right: 0); + opacity:0; + } + + &:hover, &.show { + padding-right: 1.3rem!important; + + .fa-angle-down { + transition: all 300ms ease 100ms; + opacity:1; + } + } + } + } + + .dropdown-menu, .custom-ui-autocomplete { + @include border-bottom-radius($border-radius); + font-size: inherit; + } + + .dropdown-item, .fa-angle-down, .ui-menu-item a, .ui-menu-item a.ui-state-active { + font-size: .65em; + font-weight: inherit; + } + + .industry_selection { + border: 1px solid $border-color; + color: theme-color(primary); + + &:focus { + border-color: theme-color(primary); + color: inherit; + outline: none; + } + } + + .custom-ui-autocomplete { + max-height: 300px; // <- Fallback + max-height: 35vh; + + .ui-menu-item { + list-style: none; + + a { + padding: $dropdown-item-padding-y*2 $dropdown-item-padding-x; + font-family: $font-family-base; + text-decoration: none; + line-height: 1.2; + } + } + } + + .dropdown-item, .custom-ui-autocomplete .ui-menu-item a { + &:hover, &:active, &.ui-state-active { + background-color: $dropdown-link-hover-bg; + color: $dropdown-link-color; + } + } + } + + &.o_palette_selection_screen { + .palette_card { + cursor: pointer; + box-shadow: 0 0 0 1px gray('200'); + + .color_sample { + padding-top: 30%; + } + + &:hover { + box-shadow: 0 0 0 1px #FFF, 0 0 0 3px theme-color("primary") + } + } + + .o_configurator_logo_wrapper { + transition: opacity .1s ease; + + &:hover { + transition: opacity .3s ease; + opacity: .1; + } + + img { + height: 120px; + max-width: 90%; + } + } + + .o_configurator_logo_upload { + @include o-hover-text-color(gray('400'), gray('800')); + } + + .palette_selection_or { + @include o-position-absolute(50%); + transform: translateY(-50%); + + @include media-breakpoint-up(lg) { + right: 0; + } + } + } + + &.o_feature_selection_screen { + .card { + cursor: pointer; + } + .o_configurator_feature_status { + @include o-position-absolute($card-spacer-y * 0.5 , $card-spacer-x * 0.5); + } + } + + &.o_theme_selection_screen{ + .button_area, .theme_svg_container { + @include o-position-absolute(0, 0, 0, 0); + } + + .theme_preview_tip { + @include o-position-absolute(0, 0, auto, 0); + } + + .theme_preview, .theme_svg_container svg, .theme_preview_tip { + transform: translate3d(0,0,0); + will-change: transform; + } + + .theme_preview, .theme_preview_tip { + transition: all .3s ease 0s; + } + + .theme_svg_container svg { + width: 100%; + height: auto; + } + + .button_area { + cursor: pointer; + } + + .theme_preview { + padding-top: 65%; + box-shadow: $box-shadow-sm; + + @include media-breakpoint-up(lg) { + padding-top: 200%; + + &:hover { + transform: translate3d(0,-10px,0); + } + + &.small { + padding-top: 180%; + } + } + + &:hover { + border-color: theme-color(primary)!important; + box-shadow: $box-shadow; + + .theme_preview_tip { + transform: translate3d(0,-120%,0); + } + + .theme_svg_container svg { + animation: theme_screenshot_scroll 4s cubic-bezier(0.455, 0.030, 0.515, 0.955) .1s infinite alternate; + } + + &.small .theme_svg_container svg { + animation-name: theme_screenshot_scroll_small; + animation-duration: 5s; + } + } + } + } + } +} diff --git a/addons/website/views/website_templates.xml b/addons/website/views/website_templates.xml index ede833e4951..95f6eaac7ae 100644 --- a/addons/website/views/website_templates.xml +++ b/addons/website/views/website_templates.xml @@ -2418,4 +2418,18 @@ Sitemap: sitemap.xml + + diff --git a/addons/website/views/website_views.xml b/addons/website/views/website_views.xml index 1ffa78546ff..93aa1f48f71 100644 --- a/addons/website/views/website_views.xml +++ b/addons/website/views/website_views.xml @@ -65,11 +65,12 @@
-
+
@@ -519,31 +520,6 @@ - - Website - /web?reload#action=website.theme_install_kanban_action - self - - - - Go to theme - - 0 - - - - - + + + Website Configurator + /website/configurator/1 + self + + + + Start Website Configurator + + 0 +