[IMP] website: configurator for automatic website generation

On website app  installation and on new website creation a configurator is launched.
The purpose of this configurator is to generate a website that meet the user's needs.

The configurator is composed of 4 steps:

1) Business description: the user is asked to describe its need with its website purpose (dropdown), its industry (autocomplete search) and its objective (dropdown).

2) Logo and palette selection: the user must select a color palette for its website. He can also upload its logo. In this case color palettes recommendations are generated based on the logo's colors.

3) Features selection: the user select the pages and applications he needs.

4) Theme selection: three themes are recommended to the user based on its industry. This screen display a preview of these three themes.

task-id: 2451965
ENT PR: odoo/enterprise#16949
UPG PR: odoo/upgrade#2316

closes odoo/odoo#67537

Signed-off-by: Sébastien Mottet <smottet@users.noreply.github.com>
This commit is contained in:
Sébastien Mottet (oms)
2021-04-02 13:04:03 +00:00
parent 5d8a8841a6
commit e8a5af2e28
20 changed files with 1551 additions and 150 deletions
+4 -6
View File
@@ -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:
@@ -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': {
+11
View File
@@ -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'
]
}
}
+10
View File
@@ -223,6 +223,16 @@ class Website(Home):
}
return request.render('website.website_info', values)
@http.route(['/website/configurator/<int:step>'], 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/<string:social>'], type='http', auth="public", website=True, sitemap=False)
def social(self, social, **kwargs):
url = getattr(request.website, 'social_%s' % social, False)
+8 -1
View File
@@ -22,7 +22,14 @@
<field name="bundle">web.assets_frontend</field>
<field name="glob">website/static/src/scss/compatibility/bs3_for_12_0.scss</field>
<field name="active" eval="False"/>
</record>
</record>
<record id="website.configurator_tour" model="ir.asset">
<field name="name">website.configurator_tour</field>
<field name="bundle">website.assets_editor</field>
<field name="glob">website/static/src/js/tours/configurator_tour.js</field>
<field name="active" eval="False"/>
</record>
</data>
</odoo>
+228 -80
View File
@@ -1,72 +1,70 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- split between ir_ui_view & website_page record to keep external_id on ir_ui_view -->
<record id="homepage" model="ir.ui.view">
<field name="name">Home</field>
<field name="type">qweb</field>
<field name="key">website.homepage</field>
<field name="arch" type="xml">
<t name="Home" priority="29" t-name="website.homepage">
<t t-call="website.layout">
<t t-set="pageName" t-value="'homepage'"/>
<div id="wrap" class="oe_structure oe_empty"/>
</t>
</t>
</field>
</record>
<!-- Group multi website -->
<record id="group_multi_website" model="res.groups">
<field name="name">Multi-website</field>
<field name="category_id" ref="base.module_category_hidden"/>
</record>
<record id="contactus" model="ir.ui.view">
<field name="name">Contact Us</field>
<field name="type">qweb</field>
<field name="key">website.contactus</field>
<field name="arch" type="xml">
<t name="Contact Us" t-name="website.contactus">
<t t-call="website.layout">
<div id="wrap">
<!-- Action Jump on website action -->
<record id="action_website" model="ir.actions.act_url">
<field name="name">Website</field>
<field name="url">/</field>
<field name="target">self</field>
</record>
<!-- Template for home and contactus -->
<template id="website.homepage" name="Home">
<t t-call="website.layout">
<t t-set="pageName" t-value="'homepage'"/>
<div id="wrap" class="oe_structure oe_empty"/>
</t>
</template>
<template id="website.contactus" name="Contact Us">
<t t-call="website.layout">
<div id="wrap">
<div class="oe_structure">
<section class="s_title parallax s_parallax_is_fixed bg-black-50 pt24 pb24" data-vcss="001" data-snippet="s_title" data-scroll-background-ratio="1">
<span class="s_parallax_bg oe_img_bg" style="background-image: url('/web/image/website.s_banner_default_image'); background-position: 50% 0;"/>
<div class="o_we_bg_filter bg-black-50"/>
<div class="container">
<h1>Contact us</h1>
</div>
</section>
</div>
<div class="container mt-4 mb-5">
<div class="row">
<div class="col-lg-7 col-xl-6 mr-lg-auto">
<div class="oe_structure">
<section class="s_title parallax s_parallax_is_fixed bg-black-50 pt24 pb24" data-vcss="001" data-snippet="s_title" data-scroll-background-ratio="1">
<span class="s_parallax_bg oe_img_bg" style="background-image: url('/web/image/website.s_banner_default_image'); background-position: 50% 0;"/>
<div class="o_we_bg_filter bg-black-50"/>
<section class="s_text_block" data-snippet="s_text_block">
<div class="container">
<h1>Contact us</h1>
<div class="row">
<div class="col-lg-12">
<p>
Contact us about anything related to our company or services.<br/>
We'll do our best to get back to you as soon as possible.
</p>
</div>
</div>
</div>
</section>
</div>
<div class="container mt-4 mb-5">
<div class="row">
<div class="col-lg-7 col-xl-6 mr-lg-auto">
<div class="oe_structure">
<section class="s_text_block" data-snippet="s_text_block">
<div class="container">
<div class="row">
<div class="col-lg-12">
<p>
Contact us about anything related to our company or services.<br/>
We'll do our best to get back to you as soon as possible.
</p>
</div>
</div>
</div>
</section>
</div>
<div class="text-center my-3" name="mail_button">
<a role="button" t-attf-href="mailto:{{ res_company.email }}" class="btn btn-primary" id="o_contact_mail">Send us an email</a>
</div>
</div>
<div class="col-lg-4 mt-4 mt-lg-0">
<t t-call="website.company_description"/>
</div>
</div>
<div class="text-center my-3" name="mail_button">
<a role="button" t-attf-href="mailto:{{ res_company.email }}" class="btn btn-primary" id="o_contact_mail">Send us an email</a>
</div>
<div class="oe_structure mt-2"/>
</div>
</t>
</t>
</field>
</record>
</data>
<div class="col-lg-4 mt-4 mt-lg-0">
<t t-call="website.company_description"/>
</div>
</div>
</div>
<div class="oe_structure mt-2"/>
</div>
</t>
</template>
<!-- Template for cookie (optional) -->
<template id="cookie_policy" name="Cookie Policy">
<t t-call="website.layout">
<div id="wrap" class="oe_structure">
@@ -193,7 +191,177 @@
</t>
</template>
<!-- Template configurator (optional) -->
<template id="website.aboutus" name="About Us">
<t t-call="website.layout">
<div id="wrap">
<div class="oe_structure">
<section class="s_title parallax s_parallax_is_fixed bg-black-50 pt24 pb24" data-vcss="001" data-snippet="s_title" data-scroll-background-ratio="1">
<span class="s_parallax_bg oe_img_bg" style="background-image: url('/web/image/website.s_banner_default_image'); background-position: 50% 0;"/>
<div class="o_we_bg_filter bg-black-50"/>
<div class="container">
<h1>About us</h1>
</div>
</section>
</div>
<div class="oe_structure"/>
</div>
</t>
</template>
<template id="website.our_services" name="Our Servicesw">
<t t-call="website.layout">
<div id="wrap">
<div class="oe_structure">
<section class="s_title parallax s_parallax_is_fixed bg-black-50 pt24 pb24" data-vcss="001" data-snippet="s_title" data-scroll-background-ratio="1">
<span class="s_parallax_bg oe_img_bg" style="background-image: url('/web/image/website.s_banner_default_image'); background-position: 50% 0;"/>
<div class="o_we_bg_filter bg-black-50"/>
<div class="container">
<h1>Our Services</h1>
</div>
</section>
</div>
<div class="oe_structure"/>
</div>
</t>
</template>
<template id="pricing" name="Pricing">
<t t-call="website.layout">
<div id="wrap">
<div class="oe_structure">
<section class="s_title parallax s_parallax_is_fixed bg-black-50 pt24 pb24" data-vcss="001" data-snippet="s_title" data-scroll-background-ratio="1">
<span class="s_parallax_bg oe_img_bg" style="background-image: url('/web/image/website.s_banner_default_image'); background-position: 50% 0;"/>
<div class="o_we_bg_filter bg-black-50"/>
<div class="container">
<h1>Pricing</h1>
</div>
</section>
</div>
<div class="oe_structure"/>
</div>
</t>
</template>
<template id="privacy_policy" name="Privacy Policy">
<t t-call="website.layout">
<div id="wrap">
<div class="oe_structure">
<section class="s_title parallax s_parallax_is_fixed bg-black-50 pt24 pb24" data-vcss="001" data-snippet="s_title" data-scroll-background-ratio="1">
<span class="s_parallax_bg oe_img_bg" style="background-image: url('/web/image/website.s_banner_default_image'); background-position: 50% 0;"/>
<div class="o_we_bg_filter bg-black-50"/>
<div class="container">
<h1>Privacy Policy</h1>
</div>
</section>
</div>
<div class="oe_structure"/>
</div>
</t>
</template>
<!-- Configurator page features -->
<record id="feature_page_about_us" model="website.configurator.feature">
<field name="name">About Us</field>
<field name="description">Info and stats about your company</field>
<field name="iap_page_code">about_us</field>
<field name="sequence">1</field>
<field name="page_view_id" ref="aboutus"/>
<field name="icon">fa-building</field>
</record>
<record id="feature_page_our_services" model="website.configurator.feature">
<field name="name">Our Services</field>
<field name="description">Description of your services offer</field>
<field name="iap_page_code">our_services</field>
<field name="sequence">3</field>
<field name="page_view_id" ref="our_services"/>
<field name="icon">fa-handshake-o</field>
</record>
<record id="feature_page_pricing" model="website.configurator.feature">
<field name="name">Pricing</field>
<field name="description">Design to drive conversion</field>
<field name="iap_page_code">pricing</field>
<field name="sequence">4</field>
<field name="page_view_id" ref="pricing"/>
<field name="icon">fa-random</field>
</record>
<record id="feature_page_privacy_policy" model="website.configurator.feature">
<field name="name">Privacy Policy</field>
<field name="description">Explain how you protect privacy</field>
<field name="iap_page_code">privacy_policy</field>
<field name="sequence">5</field>
<field name="page_view_id" ref="privacy_policy"/>
<field name="icon">fa-gavel</field>
</record>
<!-- Configurator apps features -->
<record id="feature_module_news" model="website.configurator.feature">
<field name="name">News</field>
<field name="description">Perfect for news and useful content</field>
<field name="sequence">6</field>
<field name="website_types_preselection">blog</field>
<field name="module_id" ref="base.module_website_blog"/>
<field name="icon">fa-rss</field>
</record>
<record id="feature_module_success_stories" model="website.configurator.feature">
<field name="name">Success Stories</field>
<field name="description">Share your best case studies</field>
<field name="sequence">7</field>
<field name="module_id" ref="base.module_website_blog"/>
<field name="icon">fa-star</field>
</record>
<record id="feature_module_career" model="website.configurator.feature">
<field name="name">Career</field>
<field name="description">Publish jobs offer and let people apply</field>
<field name="sequence">8</field>
<field name="module_id" ref="base.module_website_hr_recruitment"/>
<field name="icon">fa-address-card</field>
</record>
<record id="feature_module_shop" model="website.configurator.feature">
<field name="name">Shop</field>
<field name="description">Sell more with an eCommerce</field>
<field name="sequence">9</field>
<field name="website_types_preselection">online_store</field>
<field name="module_id" ref="base.module_website_sale"/>
<field name="icon">fa-shopping-cart</field>
</record>
<record id="feature_module_event" model="website.configurator.feature">
<field name="name">Events</field>
<field name="description">Publish on-site and online events</field>
<field name="sequence">10</field>
<field name="website_types_preselection">event</field>
<field name="module_id" ref="base.module_website_event_sale"/>
<field name="icon">fa-ticket</field>
</record>
<record id="feature_module_forum" model="website.configurator.feature">
<field name="name">Forum</field>
<field name="description">Give visitors the information they need</field>
<field name="sequence">11</field>
<field name="module_id" ref="base.module_website_forum"/>
<field name="icon">fa-users</field>
</record>
<record id="feature_module_live_chat" model="website.configurator.feature">
<field name="name">Live Chat</field>
<field name="description">Chat with visitors to improve traction</field>
<field name="sequence">12</field>
<field name="module_id" ref="base.module_website_livechat"/>
<field name="icon">fa-comments</field>
</record>
<record id="feature_module_elearning" model="website.configurator.feature">
<field name="name">eLearning</field>
<field name="description">Share videos &#38; presentations, publicly or for a fee</field>
<field name="sequence">13</field>
<field name="website_types_preselection">elearning</field>
<field name="module_id" ref="base.module_website_slides"/>
<field name="icon">fa-graduation-cap</field>
</record>
<record id="feature_module_stores_locator" model="website.configurator.feature">
<field name="name">Stores Locator</field>
<field name="description">A map and a listing of your stores</field>
<field name="sequence">15</field>
<field name="module_id" ref="base.module_website_google_map"/>
<field name="icon">fa-map-marker</field>
</record>
<data noupdate="1">
<!-- Default pagese -->
<record id="homepage_page" model="website.page">
<field name="is_published">True</field>
<field name="url">/</field>
@@ -207,6 +375,7 @@
<field name="cache_key_expr">('cached' if not request.params else None,)</field>
<field name="track">True</field>
</record>
<!-- Default Menu to store module menus for new website -->
<record id="main_menu" model="website.menu">
<field name="name">Default Main Menu</field>
@@ -227,6 +396,7 @@
<field name="sequence" type="int">60</field>
</record>
<!-- Default Website -->
<record id="default_website" model="website">
<field name="name">My Website</field>
<field name="domain"></field>
@@ -235,22 +405,6 @@
<!-- Correct homepage will be set during bootstraping -->
</record>
<!-- Open website on install -->
<record id="action_website" model="ir.actions.act_url">
<field name="name">Website</field>
<field name="url">/</field>
<field name="target">self</field>
</record>
<record id="action_website_edit" model="ir.actions.act_url">
<field name="name">Website Edit Mode</field>
<field name="url">/?enable_editor=1</field>
<field name="target">self</field>
</record>
<record id="base.open_menu" model="ir.actions.todo">
<field name="action_id" ref="action_website_edit"/>
<field name="state">open</field>
</record>
<!-- Pre loaded images -->
<record id="website.business_conference" model="ir.attachment">
<field name="public" eval="True"/>
@@ -714,10 +868,4 @@
</record>
</data>
<data>
<record id="group_multi_website" model="res.groups">
<field name="name">Multi-website</field>
<field name="category_id" ref="base.module_category_hidden"/>
</record>
</data>
</odoo>
+1
View File
@@ -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
-15
View File
@@ -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
-13
View File
@@ -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
+136 -3
View File
@@ -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
@@ -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"))
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
24 access_website_robots access.website.robots model_website_robots website.group_website_designer 1 1 1 0
25 access_website_dynamic_filter_public access_website_dynamic_filter model_website_snippet_filter 0 0 0 0
26 access_website_dynamic_filter_system access_website_dynamic_filter model_website_snippet_filter base.group_system 1 1 1 1
27 access_website_configurator_feature_designer access_website_configurator_feature_designer model_website_configurator_feature website.group_website_designer 1 0 0 0
@@ -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);
@@ -0,0 +1,234 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="website.Configurator.SkipButton" owl="1">
<div class="container-fluid py-2 pb-md-3 text-right pr-lg-5">
<button class="btn btn-link" t-on-click="skip()">Skip and start from scratch</button>
</div>
</t>
<t t-name="website.Configurator.WelcomeScreen" owl="1">
<div class="o_configurator_screen h-100 d-flex flex-column o_welcome_screen">
<div class="container-fluid pt-3 pb-2">
<img class="ml-lg-5" style="height: 31px; width: 99px;" src="/website/static/src/img/odoo_logo.svg" title="Odoo Logo"/>
</div>
<div class="o_configurator_screen_content d-flex h-100">
<div class="container align-self-center o_configurator_show">
<div class="display-4 mb-2">Ready to build the<br class="d-none d-lg-inline"/>
<b>perfect website?</b>
</div>
<div class="lead font-weight-normal mb-4 text-600">We'll set you up and running in <b>4 steps</b>
</div>
<button class="o_configurator_show btn btn-primary btn-lg px-4 py-2" t-on-click="goToDescription()">Let's do it</button>
</div>
</div>
<SkipButton/>
</div>
</t>
<t t-name="website.Configurator.DescriptionScreen" owl="1">
<div class="o_configurator_screen h-100 d-flex flex-column o_description_screen">
<div class="container-fluid pt-3 pb-2">
<img class="ml-lg-5" style="height: 31px; width: 99px;" src="/website/static/src/img/odoo_logo.svg" title="Odoo Logo"/>
</div>
<div class="o_configurator_screen_content d-flex h-100 flex-grow-1">
<div class="container align-self-center">
<div class="o_configurator_typing_text d-inline d-md-block mb-md-2 mb-lg-4 o_configurator_show">
<span>I want </span>
<div t-attf-class="dropdown o_configurator_type_dd d-inline-block {{state.selectedType ? 'o_step_completed' : 'o_step_todo show'}}">
<div class="w-100 px-2" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<a class="d-flex align-items-center">
<i class="text-primary" t-if="state.selectedType">
<t t-esc="getters.getSelectedType(state.selectedType).label" />
</i>
<i class="fa fa-angle-down text-black-50 ml-auto pl-2" title="dropdown_angle_down" role="img"/>
</a>
</div>
<div t-attf-class="dropdown-menu w-100 border-0 shadow-lg {{state.selectedType ? 'o_step_completed' : 'o_step_todo show'}}" role="menu">
<t t-foreach="getters.getWebsiteTypes()" t-as="type" t-key="type.name">
<a t-att-title="type.name" t-att-data-id="type.id" t-on-click="selectWebsiteType" class="dropdown-item o_change_website_type">
<t t-esc="type.label"/>
</a>
</t>
</div>
</div>
<span t-att-class="!state.selectedType ? 'o_configurator_hide' : 'o_configurator_show'"> for my</span>
</div>
<div t-attf-class="o_configurator_typing_text d-inline d-md-block o_configurator_industry mb-md-2 mb-lg-4 {{!state.selectedType ? 'o_configurator_hide' : 'o_configurator_show'}}">
<div class="o_configurator_industry_wrapper position-relative d-inline">
<i t-attf-class="industry_selection d-inline d-md-inline-block rounded bg-100 px-2 px-md-3 mx-2 mx-md-0 {{state.selectedIndustry ? 'o_step_completed' : 'o_step_todo show'}}" contenteditable="True" t-on-blur="blurIndustrySelection" t-on-input="inputIndustrySelection" t-ref="industrySelection"/>
</div>
<span> business</span>
<span t-att-class="!state.selectedIndustry ? 'o_configurator_hide' : 'o_configurator_show'">,</span>
</div>
<div t-attf-class="o_configurator_typing_text d-inline d-md-block mb-md-2 mb-lg-4 {{!state.selectedIndustry ? 'o_configurator_hide' : 'o_configurator_show'}}">
<span>with the main objective to </span>
<div t-attf-class="dropdown d-inline-block o_configurator_purpose_dd {{state.selectedPurpose ? 'o_step_completed' : 'o_step_todo'}}">
<div class="w-100 px-2" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<a class="d-flex align-items-center">
<t t-if="state.selectedPurpose">
<t t-esc="getters.getSelectedPurpose(state.selectedPurpose).label" />
</t>
<i class="fa fa-angle-down text-black-50 ml-auto pl-2" title="dropdown_angle_down" role="img"/>
</a>
</div>
<div class="dropdown-menu w-100 border-0 shadow-lg" role="menu">
<t t-foreach="getters.getWebsitePurpose()" t-as="type" t-key="type.name">
<a t-att-title="type.name" t-att-data-id="type.id" t-on-click="selectWebsitePurpose" class="dropdown-item o_change_website_purpose">
<t t-esc="type.label"/>
</a>
</t>
</div>
</div>
</div>
</div>
</div>
<SkipButton/>
</div>
</t>
<t t-name="website.Configurator.PaletteSelectionScreen" owl="1">
<div class="o_configurator_screen h-100 d-flex flex-column o_palette_selection_screen">
<div class="container-fluid pt-3 pb-2">
<img class="ml-lg-5" style="height: 31px; width: 99px;" src="/website/static/src/img/odoo_logo.svg" title="Odoo Logo"/>
</div>
<div class="o_configurator_screen_content container palette_selection d-flex flex-grow-1 h-100">
<div class="d-flex flex-column flex-lg-row w-100 h-100 h-lg-auto align-self-md-center o_configurator_show">
<div class="w-100 w-lg-25 order-lg-3 my-4 my-md-0 d-flex flex-column">
<div class="h4 text-center">
<b>Detect</b> from Logo</div>
<div class="d-flex flex-column flex-grow-1">
<a href="#" t-on-click="uploadLogo" t-attf-class="o_configurator_logo_upload position-relative btn-link rounded bg-100 overflow-hidden d-flex flex-grow-1 justify-content-center align-items-center text-decoration-none {{state.logo? 'h-50' : ''}}">
<input type="file" class="logo_selection_input" t-on-change="changeLogo" style="display:none" name="logo_selection" t-ref="logoSelectionInput" accept="image/*"/>
<div class="o_configurator_logo_button text-center">
<i t-attf-class="fa fa-cloud-upload {{state.logo? 'fa-4x' : 'fa-6x'}}"></i>
<div class="text-center">Upload <span t-if="state.logo">a new image</span>
</div>
</div>
<div t-if="state.logo" class="o_configurator_logo_wrapper position-absolute d-flex justify-content-center align-items-center w-100 h-100">
<img style="height: 120px" t-attf-src="{{state.logo}}"/>
</div>
</a>
<div t-if="state.recommendedPalette" class="w-75 mx-auto px-2 pt-3" style="max-width: 184px;">
<h6 class="text-center text-success d-block badge mb-0 mt-n2">Detected Colors</h6>
<div t-attf-class="palette_card rounded-pill overflow-hidden d-flex" t-on-click="selectPalette(state.recommendedPalette.id)" t-attf-style="background-color: {{state.recommendedPalette.color3}}">
<div class="color_sample w-100 first" t-attf-style="background-color: {{state.recommendedPalette.color1}}"/>
<div class="color_sample w-100 second" t-attf-style="background-color: {{state.recommendedPalette.color2}}"/>
<div class="color_sample w-100 third" t-attf-style="background-color: {{state.recommendedPalette.color3}}"/>
</div>
</div>
</div>
</div>
<div class="position-relative d-flex justify-content-center order-lg-2 w-100 w-lg-0 py-3 py-lg-0 px-lg-5 mb-4 mb-lg-0">
<div class="border-top w-100"></div>
<b class="palette_selection_or bg-white text-muted w-lg-100 text-center px-3 py-lg-3">OR</b>
<div class="border-left d-none d-lg-inline h-100 mx-auto w-0"></div>
</div>
<div class="w-100 w-lg-auto flex-grow-1 o_configurator_show_fast">
<div class="h4 text-center">
<b>Choose</b> Your Brand Color</div>
<div class="d-flex flex-wrap align-items-end">
<t t-foreach="getters.getPalettes()" t-as="palette" t-key="palette_index">
<div class="w-50 w-md-25 px-2 pt-3">
<div t-attf-class="palette_card rounded-pill overflow-hidden d-flex" t-on-click="selectPalette(palette.id)" t-attf-style="background-color: {{palette.color3}}">
<div class="color_sample w-100 first" t-attf-style="background-color: {{palette.color1}}"/>
<div class="color_sample w-100 second" t-attf-style="background-color: {{palette.color2}}"/>
<div class="color_sample w-100 third" t-attf-style="background-color: {{palette.color3}}"/>
</div>
</div>
</t>
</div>
</div>
</div>
</div>
<SkipButton/>
</div>
</t>
<t t-name="website.Configurator.FeatureSelection" owl="1">
<div class="o_configurator_screen h-100 d-flex flex-column o_feature_selection_screen">
<div class="container-fluid pt-3 pb-2">
<img class="ml-lg-5" style="height: 31px; width: 99px;" src="/website/static/src/img/odoo_logo.svg" title="Odoo Logo"/>
</div>
<div class="o_configurator_screen_content container d-flex flex-grow-1 align-items-center">
<div class="m-auto">
<div class="o_configurator_typing_text o_configurator_show_fast p-2">Add <b class="text-info">Pages</b> or <b class="text-warning">Features</b></div>
<div class="page_feature_selection o_configurator_show">
<div class="w-100 page_feature_selection container d-flex flex-wrap py2 py-lg-3">
<t t-foreach="getters.getFeatures()" t-as="feature" t-key="feature_index">
<div class="p-2 w-100 w-md-50 w-lg-25" t-if="feature.type != 'empty'">
<div t-attf-class="card h-100 {{feature.selected ? 'border-success' : ''}}" t-on-click="dispatch('toggleFeature', feature.id)">
<div class="card-body py-2">
<i t-attf-class="o_configurator_feature_status fa {{feature.selected ? 'fa-check-circle text-success' : 'fa-circle-o text-300'}}" />
<h5 class="card-title d-flex align-items-center">
<i t-attf-class="mr-2 small fa {{feature.icon}} {{feature.type == 'page' ? 'text-info' : 'text-warning' }}"/>
<t t-esc="feature.name"/>
</h5>
<p class="card-text small text-muted" t-esc="feature.description"/>
</div>
</div>
</div>
</t>
</div>
</div>
<div class="text-right">
<button class="btn btn-primary btn-lg ml-3" t-on-click="buildWebsite()">Build my website</button>
</div>
</div>
</div>
<SkipButton/>
</div>
</t>
<t t-name="website.Configurator.ThemeSelectionScreen" owl="1">
<div class="o_configurator_screen h-100 d-flex flex-column o_theme_selection_screen">
<div class="container-fluid pt-3 pb-2">
<img class="ml-lg-5" style="height: 31px; width: 99px;" src="/website/static/src/img/odoo_logo.svg" title="Odoo Logo"/>
</div>
<div class="o_configurator_screen_content d-flex flex-column flex-grow-1 align-items-center">
<div class="m-auto w-100 w-md-75 w-xl-100">
<div class="o_configurator_typing_text text-center mt-4 mb-lg-4">Choose your favorite <b>Theme</b>
</div>
<div class="container">
<div class="row pb-4 pt-5">
<div class="col-12 col-lg-4 d-flex align-items-end mb-4 mb-lg-0">
<t t-if="getters.getThemeName(1)">
<div class="theme_preview border rounded position-relative w-100 small o_configurator_show_fast">
<h6 class="theme_preview_tip text-center text-muted">Click to select</h6>
<div class="theme_svg_container rounded overflow-hidden" t-ref="ThemePreview2"/>
<div class="button_area" t-on-click="chooseTheme(getters.getThemeName(1))"/>
</div>
</t>
</div>
<div class="col-12 col-lg-4 d-flex align-items-end mb-4 mb-lg-0">
<t t-if="getters.getThemeName(0)">
<div class="theme_preview border rounded position-relative w-100 o_configurator_show">
<h6 class="theme_preview_tip text-center text-muted">Click to select</h6>
<div class="theme_svg_container rounded overflow-hidden" t-ref="ThemePreview1"/>
<div class="button_area" t-on-click="chooseTheme(getters.getThemeName(0))"/>
</div>
</t>
</div>
<div class="col-12 col-lg-4 d-flex align-items-end">
<t t-if="getters.getThemeName(2)">
<div class="theme_preview border rounded position-relative w-100 small o_configurator_show_fast">
<h6 class="theme_preview_tip text-center text-muted">Click to select</h6>
<div class="theme_svg_container rounded overflow-hidden" t-ref="ThemePreview3"/>
<div class="button_area" t-on-click="chooseTheme(getters.getThemeName(2))"/>
</div>
</t>
</div>
</div>
</div>
</div>
</div>
</div>
</t>
<t t-name="website.Configurator.App">
<div class="o_configurator_container">
<RouteComponent />
</div>
</t>
</templates>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="621px" height="196px" viewBox="0 0 621 196" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Odoo Logo</title>
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="odoo_logo" fill-rule="nonzero">
<path d="M546,196 C504.579,196 471,162.421 471,121 C471,79.579 504.579,46 546,46 C587.421,46 621,79.579 621,121 C621,162.421 587.421,196 546,196 Z M546,165 C570.3,165 590,145.3 590,121 C590,96.7 570.3,77 546,77 C521.7,77 502,96.7 502,121 C502,145.3 521.7,165 546,165 Z M389,196 C347.579,196 314,162.421 314,121 C314,79.579 347.579,46 389,46 C430.421,46 464,79.579 464,121 C464,162.421 430.421,196 389,196 Z M389,165 C413.3,165 433,145.3 433,121 C433,96.7 413.3,77 389,77 C364.7,77 345,96.7 345,121 C345,145.3 364.7,165 389,165 Z M307,120 C307,161.935 273.408,196 231.991,196 C190.575,196 157,162.005 157,120.07 C157,78.134 187.5,46 231.991,46 C248.433,46 263.638,49.496 275.998,58.58 L276,15.09 C276,6.756 283.27,0 291.5,0 C299.728,0 307,6.762 307,15.09 L307,120 Z M232,165 C256.3,165 276,145.3 276,121 C276,96.7 256.3,77 232,77 C207.7,77 188,96.7 188,121 C188,145.3 207.7,165 232,165 Z" fill="#8F8F8F"></path>
<path d="M75,196 C33.579,196 0,162.421 0,121 C0,79.579 33.579,46 75,46 C116.421,46 150,79.579 150,121 C150,162.421 116.421,196 75,196 Z M75,165 C99.3,165 119,145.3 119,121 C119,96.7 99.3,77 75,77 C50.7,77 31,96.7 31,121 C31,145.3 50.7,165 75,165 Z" fill="#875A7B"></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -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);
});
@@ -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("<b>Customize</b> 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("<b>Double click on an image</b> 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("<b>Click on a snippet</b> 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("<b>Click on a text</b> to start editing it."),
position: position,
run: "text",
@@ -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;
}
}
}
}
}
}
@@ -2418,4 +2418,18 @@ Sitemap: <t t-esc="url_root"/>sitemap.xml
</tr>
</template>
<template id="website_configurator" name="Website Configurator" inherit_id="web.frontend_layout" primary="True">
<xpath expr="//html" position="attributes">
<attribute name="lang"><t t-esc='lang'/></attribute>
</xpath>
<xpath expr="//head/t[@t-call-assets][last()]" position="after">
<t t-call-assets="website.website_configurator_assets_js" lazy_load="True"/>
</xpath>
<xpath expr="//t[@t-call-assets='web.assets_frontend']" position="replace">
<t t-call-assets="website.website_configurator_assets_scss" t-js="false"/>
</xpath>
<xpath expr="//nav" position="replace"/>
<xpath expr="//div[@id='wrapwrap']" position="replace"/>
</template>
</odoo>
+14 -26
View File
@@ -65,11 +65,12 @@
<field name="arch" type="xml">
<xpath expr="//form" position="inside">
<footer>
<button name="create_and_redirect_to_theme" type="object" string="Pick a Theme" class="btn btn-primary" icon="fa-paint-brush"/>
<button name="create_and_redirect_configurator" type="object" string="Create" class="btn btn-primary"/>
<button string="Cancel" class="btn btn-secondary" special="cancel"/>
</footer>
</xpath>
<xpath expr="//notebook" position="replace"/>
<xpath expr="//div[@name='logo']" position="replace"/>
</field>
</record>
@@ -519,31 +520,6 @@
<field name="domain" model="ir.module.module" eval="obj().get_themes_domain()"/>
</record>
<record id="theme_install_act_url" model="ir.actions.act_url">
<field name="name">Website</field>
<field name="url">/web?reload#action=website.theme_install_kanban_action</field>
<field name="target">self</field>
</record>
<record id="theme_install_todo" model="ir.actions.todo">
<field name="name">Go to theme</field>
<field name="action_id" ref="theme_install_act_url"/>
<field name="sequence">0</field>
</record>
<!-- ====== Onboarding =================================================
==================================================================== -->
<template id="onboarding_website_theme_step">
<t t-call="base.onboarding_step">
<t t-set="title">Website Theme</t>
<t t-set="description">Select a theme for your website.</t>
<t t-set="btn_text">Let's start!</t>
<t t-set="method" t-value="'action_open_website_theme_selector'" />
<t t-set="model" t-value="'res.company'" />
<t t-set="done" t-value="company.website_theme_onboarding_done" />
</t>
</template>
<!-- ====== Menu Items =================================================
==================================================================== -->
<menuitem name="Website"
@@ -570,5 +546,17 @@
sequence="20" name="Analytics"
action="website.ir_actions_server_website_google_analytics"/>
<!-- Configurator actions -->
<record id="start_configurator_act_url" model="ir.actions.act_url">
<field name="name">Website Configurator</field>
<field name="url">/website/configurator/1</field>
<field name="target">self</field>
</record>
<record id="website_configurator_todo" model="ir.actions.todo">
<field name="name">Start Website Configurator</field>
<field name="action_id" ref="start_configurator_act_url"/>
<field name="sequence">0</field>
</record>
</data>
</odoo>