summaryrefslogtreecommitdiff
path: root/addons/website/static/src/js/content
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/website/static/src/js/content
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/website/static/src/js/content')
-rw-r--r--addons/website/static/src/js/content/compatibility.js38
-rw-r--r--addons/website/static/src/js/content/menu.js642
-rw-r--r--addons/website/static/src/js/content/ripple_effect.js72
-rw-r--r--addons/website/static/src/js/content/snippets.animation.js1092
-rw-r--r--addons/website/static/src/js/content/website_root.js350
-rw-r--r--addons/website/static/src/js/content/website_root_instance.js26
6 files changed, 2220 insertions, 0 deletions
diff --git a/addons/website/static/src/js/content/compatibility.js b/addons/website/static/src/js/content/compatibility.js
new file mode 100644
index 00000000..f4148802
--- /dev/null
+++ b/addons/website/static/src/js/content/compatibility.js
@@ -0,0 +1,38 @@
+odoo.define('website.content.compatibility', function (require) {
+'use strict';
+
+/**
+ * Tweaks the website rendering so that the old browsers correctly render the
+ * content too.
+ */
+
+require('web.dom_ready');
+
+// Check the browser and its version and add the info as an attribute of the
+// HTML element so that css selectors can match it
+var browser = _.findKey($.browser, function (v) { return v === true; });
+if ($.browser.mozilla && +$.browser.version.replace(/^([0-9]+\.[0-9]+).*/, '\$1') < 20) {
+ browser = 'msie';
+}
+browser += (',' + $.browser.version);
+var mobileRegex = /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i;
+if (mobileRegex.test(window.navigator.userAgent.toLowerCase())) {
+ browser += ',mobile';
+}
+document.documentElement.setAttribute('data-browser', browser);
+
+// Check if flex is supported and add the info as an attribute of the HTML
+// element so that css selectors can match it (only if not supported)
+var htmlStyle = document.documentElement.style;
+var isFlexSupported = (('flexWrap' in htmlStyle)
+ || ('WebkitFlexWrap' in htmlStyle)
+ || ('msFlexWrap' in htmlStyle));
+if (!isFlexSupported) {
+ document.documentElement.setAttribute('data-no-flex', '');
+}
+
+return {
+ browser: browser,
+ isFlexSupported: isFlexSupported,
+};
+});
diff --git a/addons/website/static/src/js/content/menu.js b/addons/website/static/src/js/content/menu.js
new file mode 100644
index 00000000..71f74ab5
--- /dev/null
+++ b/addons/website/static/src/js/content/menu.js
@@ -0,0 +1,642 @@
+odoo.define('website.content.menu', function (require) {
+'use strict';
+
+const config = require('web.config');
+var dom = require('web.dom');
+var publicWidget = require('web.public.widget');
+var wUtils = require('website.utils');
+var animations = require('website.content.snippets.animation');
+
+const extraMenuUpdateCallbacks = [];
+
+const BaseAnimatedHeader = animations.Animation.extend({
+ disabledInEditableMode: false,
+ effects: [{
+ startEvents: 'scroll',
+ update: '_updateHeaderOnScroll',
+ }, {
+ startEvents: 'resize',
+ update: '_updateHeaderOnResize',
+ }],
+
+ /**
+ * @constructor
+ */
+ init: function () {
+ this._super(...arguments);
+ this.fixedHeader = false;
+ this.scrolledPoint = 0;
+ this.hasScrolled = false;
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$main = this.$el.next('main');
+ this.isOverlayHeader = !!this.$el.closest('.o_header_overlay, .o_header_overlay_theme').length;
+ this.$dropdowns = this.$el.find('.dropdown, .dropdown-menu');
+ this.$navbarCollapses = this.$el.find('.navbar-collapse');
+
+ // While scrolling through navbar menus on medium devices, body should not be scrolled with it
+ this.$navbarCollapses.on('show.bs.collapse.BaseAnimatedHeader', function () {
+ if (config.device.size_class <= config.device.SIZES.SM) {
+ $(document.body).addClass('overflow-hidden');
+ }
+ }).on('hide.bs.collapse.BaseAnimatedHeader', function () {
+ $(document.body).removeClass('overflow-hidden');
+ });
+
+ // We can rely on transitionend which is well supported but not on
+ // transitionstart, so we listen to a custom odoo event.
+ this._transitionCount = 0;
+ this.$el.on('odoo-transitionstart.BaseAnimatedHeader', () => this._adaptToHeaderChangeLoop(1));
+ this.$el.on('transitionend.BaseAnimatedHeader', () => this._adaptToHeaderChangeLoop(-1));
+
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._toggleFixedHeader(false);
+ this.$el.removeClass('o_header_affixed o_header_is_scrolled o_header_no_transition');
+ this.$navbarCollapses.off('.BaseAnimatedHeader');
+ this.$el.off('.BaseAnimatedHeader');
+ this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _adaptFixedHeaderPosition() {
+ dom.compensateScrollbar(this.el, this.fixedHeader, false, 'right');
+ },
+ /**
+ * @private
+ */
+ _adaptToHeaderChange: function () {
+ this._updateMainPaddingTop();
+ this.el.classList.toggle('o_top_fixed_element', this.fixedHeader && this._isShown());
+
+ for (const callback of extraMenuUpdateCallbacks) {
+ callback();
+ }
+ },
+ /**
+ * @private
+ * @param {integer} [addCount=0]
+ */
+ _adaptToHeaderChangeLoop: function (addCount = 0) {
+ this._adaptToHeaderChange();
+
+ this._transitionCount += addCount;
+ this._transitionCount = Math.max(0, this._transitionCount);
+
+ // As long as we detected a transition start without its related
+ // transition end, keep updating the main padding top.
+ if (this._transitionCount > 0) {
+ window.requestAnimationFrame(() => this._adaptToHeaderChangeLoop());
+
+ // The normal case would be to have the transitionend event to be
+ // fired but we cannot rely on it, so we use a timeout as fallback.
+ if (addCount !== 0) {
+ clearTimeout(this._changeLoopTimer);
+ this._changeLoopTimer = setTimeout(() => {
+ this._adaptToHeaderChangeLoop(-this._transitionCount);
+ }, 500);
+ }
+ } else {
+ // When we detected all transitionend events, we need to stop the
+ // setTimeout fallback.
+ clearTimeout(this._changeLoopTimer);
+ }
+ },
+ /**
+ * @private
+ */
+ _computeTopGap() {
+ return 0;
+ },
+ /**
+ * @private
+ */
+ _isShown() {
+ return true;
+ },
+ /**
+ * @private
+ * @param {boolean} [useFixed=true]
+ */
+ _toggleFixedHeader: function (useFixed = true) {
+ this.fixedHeader = useFixed;
+ this._adaptToHeaderChange();
+ this.el.classList.toggle('o_header_affixed', useFixed);
+ this._adaptFixedHeaderPosition();
+ },
+ /**
+ * @private
+ */
+ _updateMainPaddingTop: function () {
+ this.headerHeight = this.$el.outerHeight();
+ this.topGap = this._computeTopGap();
+
+ if (this.isOverlayHeader) {
+ return;
+ }
+ this.$main.css('padding-top', this.fixedHeader ? this.headerHeight : '');
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the window is scrolled
+ *
+ * @private
+ * @param {integer} scroll
+ */
+ _updateHeaderOnScroll: function (scroll) {
+ // Disable css transition if refresh with scrollTop > 0
+ if (!this.hasScrolled) {
+ this.hasScrolled = true;
+ if (scroll > 0) {
+ this.$el.addClass('o_header_no_transition');
+ }
+ } else {
+ this.$el.removeClass('o_header_no_transition');
+ }
+
+ // Indicates the page is scrolled, the logo size is changed.
+ const headerIsScrolled = (scroll > this.scrolledPoint);
+ if (this.headerIsScrolled !== headerIsScrolled) {
+ this.el.classList.toggle('o_header_is_scrolled', headerIsScrolled);
+ this.$el.trigger('odoo-transitionstart');
+ this.headerIsScrolled = headerIsScrolled;
+ }
+
+ // Close opened menus
+ this.$dropdowns.removeClass('show');
+ this.$navbarCollapses.removeClass('show').attr('aria-expanded', false);
+ },
+ /**
+ * Called when the window is resized
+ *
+ * @private
+ */
+ _updateHeaderOnResize: function () {
+ this._adaptFixedHeaderPosition();
+ if (document.body.classList.contains('overflow-hidden')
+ && config.device.size_class > config.device.SIZES.SM) {
+ document.body.classList.remove('overflow-hidden');
+ this.$el.find('.navbar-collapse').removeClass('show');
+ }
+ },
+});
+
+publicWidget.registry.StandardAffixedHeader = BaseAnimatedHeader.extend({
+ selector: 'header.o_header_standard:not(.o_header_sidebar)',
+
+ /**
+ * @constructor
+ */
+ init: function () {
+ this._super(...arguments);
+ this.fixedHeaderShow = false;
+ this.scrolledPoint = 300;
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.headerHeight = this.$el.outerHeight();
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _isShown() {
+ return !this.fixedHeader || this.fixedHeaderShow;
+ },
+ /**
+ * Called when the window is scrolled
+ *
+ * @private
+ * @param {integer} scroll
+ */
+ _updateHeaderOnScroll: function (scroll) {
+ this._super(...arguments);
+
+ const mainPosScrolled = (scroll > this.headerHeight + this.topGap);
+ const reachPosScrolled = (scroll > this.scrolledPoint + this.topGap);
+
+ // Switch between static/fixed position of the header
+ if (this.fixedHeader !== mainPosScrolled) {
+ this.$el.css('transform', mainPosScrolled ? 'translate(0, -100%)' : '');
+ void this.$el[0].offsetWidth; // Force a paint refresh
+ this._toggleFixedHeader(mainPosScrolled);
+ }
+ // Show/hide header
+ if (this.fixedHeaderShow !== reachPosScrolled) {
+ this.$el.css('transform', reachPosScrolled ? `translate(0, -${this.topGap}px)` : 'translate(0, -100%)');
+ this.fixedHeaderShow = reachPosScrolled;
+ this._adaptToHeaderChange();
+ }
+ },
+});
+
+publicWidget.registry.FixedHeader = BaseAnimatedHeader.extend({
+ selector: 'header.o_header_fixed',
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _updateHeaderOnScroll: function (scroll) {
+ this._super(...arguments);
+ // Need to be 'unfixed' when the window is not scrolled so that the
+ // transparent menu option still works.
+ if (scroll > (this.scrolledPoint + this.topGap)) {
+ if (!this.$el.hasClass('o_header_affixed')) {
+ this.$el.css('transform', `translate(0, -${this.topGap}px)`);
+ void this.$el[0].offsetWidth; // Force a paint refresh
+ this._toggleFixedHeader(true);
+ }
+ } else {
+ this._toggleFixedHeader(false);
+ void this.$el[0].offsetWidth; // Force a paint refresh
+ this.$el.css('transform', '');
+ }
+ },
+});
+
+const BaseDisappearingHeader = publicWidget.registry.FixedHeader.extend({
+ /**
+ * @override
+ */
+ init: function () {
+ this._super(...arguments);
+ this.scrollingDownwards = true;
+ this.hiddenHeader = false;
+ this.position = 0;
+ this.atTop = true;
+ this.checkPoint = 0;
+ this.scrollOffsetLimit = 200;
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._showHeader();
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _hideHeader: function () {
+ this.$el.trigger('odoo-transitionstart');
+ },
+ /**
+ * @override
+ */
+ _isShown() {
+ return !this.fixedHeader || !this.hiddenHeader;
+ },
+ /**
+ * @private
+ */
+ _showHeader: function () {
+ this.$el.trigger('odoo-transitionstart');
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _updateHeaderOnScroll: function (scroll) {
+ this._super(...arguments);
+
+ const scrollingDownwards = (scroll > this.position);
+ const atTop = (scroll <= 0);
+ if (scrollingDownwards !== this.scrollingDownwards) {
+ this.checkPoint = scroll;
+ }
+
+ this.scrollingDownwards = scrollingDownwards;
+ this.position = scroll;
+ this.atTop = atTop;
+
+ if (scrollingDownwards) {
+ if (!this.hiddenHeader && scroll - this.checkPoint > (this.scrollOffsetLimit + this.topGap)) {
+ this.hiddenHeader = true;
+ this._hideHeader();
+ }
+ } else {
+ if (this.hiddenHeader && scroll - this.checkPoint < -(this.scrollOffsetLimit + this.topGap) / 2) {
+ this.hiddenHeader = false;
+ this._showHeader();
+ }
+ }
+
+ if (atTop && !this.atTop) {
+ // Force reshowing the invisible-on-scroll sections when reaching
+ // the top again
+ this._showHeader();
+ }
+ },
+});
+
+publicWidget.registry.DisappearingHeader = BaseDisappearingHeader.extend({
+ selector: 'header.o_header_disappears',
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _hideHeader: function () {
+ this._super(...arguments);
+ this.$el.css('transform', 'translate(0, -100%)');
+ },
+ /**
+ * @override
+ */
+ _showHeader: function () {
+ this._super(...arguments);
+ this.$el.css('transform', this.atTop ? '' : `translate(0, -${this.topGap}px)`);
+ },
+});
+
+publicWidget.registry.FadeOutHeader = BaseDisappearingHeader.extend({
+ selector: 'header.o_header_fade_out',
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _hideHeader: function () {
+ this._super(...arguments);
+ this.$el.stop(false, true).fadeOut();
+ },
+ /**
+ * @override
+ */
+ _showHeader: function () {
+ this._super(...arguments);
+ this.$el.css('transform', this.atTop ? '' : `translate(0, -${this.topGap}px)`);
+ this.$el.stop(false, true).fadeIn();
+ },
+});
+
+/**
+ * Auto adapt the header layout so that elements are not wrapped on a new line.
+ */
+publicWidget.registry.autohideMenu = publicWidget.Widget.extend({
+ selector: 'header#top',
+ disabledInEditableMode: false,
+
+ /**
+ * @override
+ */
+ async start() {
+ await this._super(...arguments);
+ this.$topMenu = this.$('#top_menu');
+ this.noAutohide = this.$el.is('.o_no_autohide_menu');
+ if (!this.noAutohide) {
+ await wUtils.onceAllImagesLoaded(this.$('.navbar'), this.$('.o_mega_menu, .o_offcanvas_logo_container, .dropdown-menu .o_lang_flag'));
+
+ // The previous code will make sure we wait for images to be fully
+ // loaded before initializing the auto more menu. But in some cases,
+ // it is not enough, we also have to wait for fonts or even extra
+ // scripts. Those will have no impact on the feature in most cases
+ // though, so we will only update the auto more menu at that time,
+ // no wait for it to initialize the feature.
+ var $window = $(window);
+ $window.on('load.autohideMenu', function () {
+ $window.trigger('resize');
+ });
+
+ dom.initAutoMoreMenu(this.$topMenu, {unfoldable: '.divider, .divider ~ li, .o_no_autohide_item'});
+ }
+ this.$topMenu.removeClass('o_menu_loading');
+ this.$topMenu.trigger('menu_loaded');
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ this._super(...arguments);
+ if (!this.noAutohide && this.$topMenu) {
+ $(window).off('.autohideMenu');
+ dom.destroyAutoMoreMenu(this.$topMenu);
+ }
+ },
+});
+
+/**
+ * Note: this works well with the affixMenu... by chance (menuDirection is
+ * called after alphabetically).
+ *
+ * @todo check bootstrap v4: maybe handled automatically now ?
+ */
+publicWidget.registry.menuDirection = publicWidget.Widget.extend({
+ selector: 'header .navbar .nav',
+ disabledInEditableMode: false,
+ events: {
+ 'show.bs.dropdown': '_onDropdownShow',
+ },
+
+ /**
+ * @override
+ */
+ start: function () {
+ this.defaultAlignment = this.$el.is('.ml-auto, .ml-auto ~ *') ? 'right' : 'left';
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {string} alignment - either 'left' or 'right'
+ * @param {integer} liOffset
+ * @param {integer} liWidth
+ * @param {integer} menuWidth
+ * @param {integer} pageWidth
+ * @returns {boolean}
+ */
+ _checkOpening: function (alignment, liOffset, liWidth, menuWidth, pageWidth) {
+ if (alignment === 'left') {
+ // Check if ok to open the dropdown to the right (no window overflow)
+ return (liOffset + menuWidth <= pageWidth);
+ } else {
+ // Check if ok to open the dropdown to the left (no window overflow)
+ return (liOffset + liWidth - menuWidth >= 0);
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onDropdownShow: function (ev) {
+ var $li = $(ev.target);
+ var $menu = $li.children('.dropdown-menu');
+ var liOffset = $li.offset().left;
+ var liWidth = $li.outerWidth();
+ var menuWidth = $menu.outerWidth();
+ var pageWidth = $('#wrapwrap').outerWidth();
+
+ $menu.removeClass('dropdown-menu-left dropdown-menu-right');
+
+ var alignment = this.defaultAlignment;
+ if ($li.nextAll(':visible').length === 0) {
+ // The dropdown is the last menu item, open to the left
+ alignment = 'right';
+ }
+
+ // If can't open in the current direction because it would overflow the
+ // page, change the direction. But if the other direction would do the
+ // same, change back the direction.
+ for (var i = 0; i < 2; i++) {
+ if (!this._checkOpening(alignment, liOffset, liWidth, menuWidth, pageWidth)) {
+ alignment = (alignment === 'left' ? 'right' : 'left');
+ }
+ }
+
+ $menu.addClass('dropdown-menu-' + alignment);
+ },
+});
+
+publicWidget.registry.hoverableDropdown = animations.Animation.extend({
+ selector: 'header.o_hoverable_dropdown',
+ disabledInEditableMode: false,
+ effects: [{
+ startEvents: 'resize',
+ update: '_dropdownHover',
+ }],
+ events: {
+ 'mouseenter .dropdown': '_onMouseEnter',
+ 'mouseleave .dropdown': '_onMouseLeave',
+ },
+
+ /**
+ * @override
+ */
+ start: function () {
+ this.$dropdownMenus = this.$el.find('.dropdown-menu');
+ this.$dropdownToggles = this.$el.find('.dropdown-toggle');
+ this._dropdownHover();
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _dropdownHover: function () {
+ if (config.device.size_class > config.device.SIZES.SM) {
+ this.$dropdownMenus.css('margin-top', '0');
+ this.$dropdownMenus.css('top', 'unset');
+ } else {
+ this.$dropdownMenus.css('margin-top', '');
+ this.$dropdownMenus.css('top', '');
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onMouseEnter: function (ev) {
+ if (config.device.size_class <= config.device.SIZES.SM) {
+ return;
+ }
+
+ const $dropdown = $(ev.currentTarget);
+ $dropdown.addClass('show');
+ $dropdown.find(this.$dropdownToggles).attr('aria-expanded', 'true');
+ $dropdown.find(this.$dropdownMenus).addClass('show');
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onMouseLeave: function (ev) {
+ if (config.device.size_class <= config.device.SIZES.SM) {
+ return;
+ }
+
+ const $dropdown = $(ev.currentTarget);
+ $dropdown.removeClass('show');
+ $dropdown.find(this.$dropdownToggles).attr('aria-expanded', 'false');
+ $dropdown.find(this.$dropdownMenus).removeClass('show');
+ },
+});
+
+publicWidget.registry.HeaderMainCollapse = publicWidget.Widget.extend({
+ selector: 'header#top',
+ events: {
+ 'show.bs.collapse #top_menu_collapse': '_onCollapseShow',
+ 'hidden.bs.collapse #top_menu_collapse': '_onCollapseHidden',
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onCollapseShow() {
+ this.el.classList.add('o_top_menu_collapse_shown');
+ },
+ /**
+ * @private
+ */
+ _onCollapseHidden() {
+ this.el.classList.remove('o_top_menu_collapse_shown');
+ },
+});
+
+return {
+ extraMenuUpdateCallbacks: extraMenuUpdateCallbacks,
+};
+});
diff --git a/addons/website/static/src/js/content/ripple_effect.js b/addons/website/static/src/js/content/ripple_effect.js
new file mode 100644
index 00000000..2e61d5b7
--- /dev/null
+++ b/addons/website/static/src/js/content/ripple_effect.js
@@ -0,0 +1,72 @@
+odoo.define('website.ripple_effect', function (require) {
+'use strict';
+
+const publicWidget = require('web.public.widget');
+
+publicWidget.registry.RippleEffect = publicWidget.Widget.extend({
+ selector: '.btn, .dropdown-toggle, .dropdown-item',
+ events: {
+ 'click': '_onClick',
+ },
+ duration: 350,
+
+ /**
+ * @override
+ */
+ start: async function () {
+ this.diameter = Math.max(this.$el.outerWidth(), this.$el.outerHeight());
+ this.offsetX = this.$el.offset().left;
+ this.offsetY = this.$el.offset().top;
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._super(...arguments);
+ if (this.rippleEl) {
+ this.rippleEl.remove();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {boolean} [toggle]
+ */
+ _toggleRippleEffect: function (toggle) {
+ this.el.classList.toggle('o_js_ripple_effect', toggle);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onClick: function (ev) {
+ if (!this.rippleEl) {
+ this.rippleEl = document.createElement('span');
+ this.rippleEl.classList.add('o_ripple_item');
+ this.rippleEl.style.animationDuration = `${this.duration}ms`;
+ this.rippleEl.style.width = `${this.diameter}px`;
+ this.rippleEl.style.height = `${this.diameter}px`;
+ this.el.appendChild(this.rippleEl);
+ }
+
+ clearTimeout(this.timeoutID);
+ this._toggleRippleEffect(false);
+
+ this.rippleEl.style.top = `${ev.pageY - this.offsetY - this.diameter / 2}px`;
+ this.rippleEl.style.left = `${ev.pageX - this.offsetX - this.diameter / 2}px`;
+
+ this._toggleRippleEffect(true);
+ this.timeoutID = setTimeout(() => this._toggleRippleEffect(false), this.duration);
+ },
+});
+});
diff --git a/addons/website/static/src/js/content/snippets.animation.js b/addons/website/static/src/js/content/snippets.animation.js
new file mode 100644
index 00000000..5eb08630
--- /dev/null
+++ b/addons/website/static/src/js/content/snippets.animation.js
@@ -0,0 +1,1092 @@
+odoo.define('website.content.snippets.animation', function (require) {
+'use strict';
+
+/**
+ * Provides a way to start JS code for snippets' initialization and animations.
+ */
+
+var Class = require('web.Class');
+var config = require('web.config');
+var core = require('web.core');
+const dom = require('web.dom');
+var mixins = require('web.mixins');
+var publicWidget = require('web.public.widget');
+var utils = require('web.utils');
+
+var qweb = core.qweb;
+
+// Initialize fallbacks for the use of requestAnimationFrame,
+// cancelAnimationFrame and performance.now()
+window.requestAnimationFrame = window.requestAnimationFrame
+ || window.webkitRequestAnimationFrame
+ || window.mozRequestAnimationFrame
+ || window.msRequestAnimationFrame
+ || window.oRequestAnimationFrame;
+window.cancelAnimationFrame = window.cancelAnimationFrame
+ || window.webkitCancelAnimationFrame
+ || window.mozCancelAnimationFrame
+ || window.msCancelAnimationFrame
+ || window.oCancelAnimationFrame;
+if (!window.performance || !window.performance.now) {
+ window.performance = {
+ now: function () {
+ return Date.now();
+ }
+ };
+}
+
+/**
+ * Add the notion of edit mode to public widgets.
+ */
+publicWidget.Widget.include({
+ /**
+ * Indicates if the widget should not be instantiated in edit. The default
+ * is true, indeed most (all?) defined widgets only want to initialize
+ * events and states which should not be active in edit mode (this is
+ * especially true for non-website widgets).
+ *
+ * @type {boolean}
+ */
+ disabledInEditableMode: true,
+ /**
+ * Acts as @see Widget.events except that the events are only binded if the
+ * Widget instance is instanciated in edit mode. The property is not
+ * considered if @see disabledInEditableMode is false.
+ */
+ edit_events: null,
+ /**
+ * Acts as @see Widget.events except that the events are only binded if the
+ * Widget instance is instanciated in readonly mode. The property only
+ * makes sense if @see disabledInEditableMode is false, you should simply
+ * use @see Widget.events otherwise.
+ */
+ read_events: null,
+
+ /**
+ * Initializes the events that will need to be binded according to the
+ * given mode.
+ *
+ * @constructor
+ * @param {Object} parent
+ * @param {Object} [options]
+ * @param {boolean} [options.editableMode=false]
+ * true if the page is in edition mode
+ */
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+
+ this.editableMode = this.options.editableMode || false;
+ var extraEvents = this.editableMode ? this.edit_events : this.read_events;
+ if (extraEvents) {
+ this.events = _.extend({}, this.events || {}, extraEvents);
+ }
+ },
+});
+
+/**
+ * In charge of handling one animation loop using the requestAnimationFrame
+ * feature. This is used by the `Animation` class below and should not be called
+ * directly by an end developer.
+ *
+ * This uses a simple API: it can be started, stopped, played and paused.
+ */
+var AnimationEffect = Class.extend(mixins.ParentedMixin, {
+ /**
+ * @constructor
+ * @param {Object} parent
+ * @param {function} updateCallback - the animation update callback
+ * @param {string} [startEvents=scroll]
+ * space separated list of events which starts the animation loop
+ * @param {jQuery|DOMElement} [$startTarget=window]
+ * the element(s) on which the startEvents are listened
+ * @param {Object} [options]
+ * @param {function} [options.getStateCallback]
+ * a function which returns a value which represents the state of the
+ * animation, i.e. for two same value, no refreshing of the animation
+ * is needed. Can be used for optimization. If the $startTarget is
+ * the window element, this defaults to returning the current
+ * scoll offset of the window or the size of the window for the
+ * scroll and resize events respectively.
+ * @param {string} [options.endEvents]
+ * space separated list of events which pause the animation loop. If
+ * not given, the animation is stopped after a while (if no
+ * startEvents is received again)
+ * @param {jQuery|DOMElement} [options.$endTarget=$startTarget]
+ * the element(s) on which the endEvents are listened
+ */
+ init: function (parent, updateCallback, startEvents, $startTarget, options) {
+ mixins.ParentedMixin.init.call(this);
+ this.setParent(parent);
+
+ options = options || {};
+ this._minFrameTime = 1000 / (options.maxFPS || 100);
+
+ // Initialize the animation startEvents, startTarget, endEvents, endTarget and callbacks
+ this._updateCallback = updateCallback;
+ this.startEvents = startEvents || 'scroll';
+ const mainScrollingElement = $().getScrollingElement()[0];
+ const mainScrollingTarget = mainScrollingElement === document.documentElement ? window : mainScrollingElement;
+ this.$startTarget = $($startTarget ? $startTarget : this.startEvents === 'scroll' ? mainScrollingTarget : window);
+ if (options.getStateCallback) {
+ this._getStateCallback = options.getStateCallback;
+ } else if (this.startEvents === 'scroll' && this.$startTarget[0] === mainScrollingTarget) {
+ const $scrollable = this.$startTarget;
+ this._getStateCallback = function () {
+ return $scrollable.scrollTop();
+ };
+ } else if (this.startEvents === 'resize' && this.$startTarget[0] === window) {
+ this._getStateCallback = function () {
+ return {
+ width: window.innerWidth,
+ height: window.innerHeight,
+ };
+ };
+ } else {
+ this._getStateCallback = function () {
+ return undefined;
+ };
+ }
+ this.endEvents = options.endEvents || false;
+ this.$endTarget = options.$endTarget ? $(options.$endTarget) : this.$startTarget;
+
+ this._updateCallback = this._updateCallback.bind(parent);
+ this._getStateCallback = this._getStateCallback.bind(parent);
+
+ // Add a namespace to events using the generated uid
+ this._uid = '_animationEffect' + _.uniqueId();
+ this.startEvents = _processEvents(this.startEvents, this._uid);
+ if (this.endEvents) {
+ this.endEvents = _processEvents(this.endEvents, this._uid);
+ }
+
+ function _processEvents(events, namespace) {
+ events = events.split(' ');
+ return _.each(events, function (e, index) {
+ events[index] += ('.' + namespace);
+ }).join(' ');
+ }
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ mixins.ParentedMixin.destroy.call(this);
+ this.stop();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Initializes when the animation must be played and paused and initializes
+ * the animation first frame.
+ */
+ start: function () {
+ // Initialize the animation first frame
+ this._paused = false;
+ this._rafID = window.requestAnimationFrame((function (t) {
+ this._update(t);
+ this._paused = true;
+ }).bind(this));
+
+ // Initialize the animation play/pause events
+ if (this.endEvents) {
+ /**
+ * If there are endEvents, the animation should begin playing when
+ * the startEvents are triggered on the $startTarget and pause when
+ * the endEvents are triggered on the $endTarget.
+ */
+ this.$startTarget.on(this.startEvents, (function (e) {
+ if (this._paused) {
+ _.defer(this.play.bind(this, e));
+ }
+ }).bind(this));
+ this.$endTarget.on(this.endEvents, (function () {
+ if (!this._paused) {
+ _.defer(this.pause.bind(this));
+ }
+ }).bind(this));
+ } else {
+ /**
+ * Else, if there is no endEvents, the animation should begin playing
+ * when the startEvents are *continuously* triggered on the
+ * $startTarget or fully played once. To achieve this, the animation
+ * begins playing and is scheduled to pause after 2 seconds. If the
+ * startEvents are triggered during that time, this is not paused
+ * for another 2 seconds. This allows to describe an "effect"
+ * animation (which lasts less than 2 seconds) or an animation which
+ * must be playing *during* an event (scroll, mousemove, resize,
+ * repeated clicks, ...).
+ */
+ var pauseTimer = null;
+ this.$startTarget.on(this.startEvents, _.throttle((function (e) {
+ this.play(e);
+
+ clearTimeout(pauseTimer);
+ pauseTimer = _.delay((function () {
+ this.pause();
+ pauseTimer = null;
+ }).bind(this), 2000);
+ }).bind(this), 250, {trailing: false}));
+ }
+ },
+ /**
+ * Pauses the animation and destroys the attached events which trigger the
+ * animation to be played or paused.
+ */
+ stop: function () {
+ this.$startTarget.off(this.startEvents);
+ if (this.endEvents) {
+ this.$endTarget.off(this.endEvents);
+ }
+ this.pause();
+ },
+ /**
+ * Forces the requestAnimationFrame loop to start.
+ *
+ * @param {Event} e - the event which triggered the animation to play
+ */
+ play: function (e) {
+ this._newEvent = e;
+ if (!this._paused) {
+ return;
+ }
+ this._paused = false;
+ this._rafID = window.requestAnimationFrame(this._update.bind(this));
+ this._lastUpdateTimestamp = undefined;
+ },
+ /**
+ * Forces the requestAnimationFrame loop to stop.
+ */
+ pause: function () {
+ if (this._paused) {
+ return;
+ }
+ this._paused = true;
+ window.cancelAnimationFrame(this._rafID);
+ this._lastUpdateTimestamp = undefined;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Callback which is repeatedly called by the requestAnimationFrame loop.
+ * It controls the max fps at which the animation is running and initializes
+ * the values that the update callback needs to describe the animation
+ * (state, elapsedTime, triggered event).
+ *
+ * @private
+ * @param {DOMHighResTimeStamp} timestamp
+ */
+ _update: function (timestamp) {
+ if (this._paused) {
+ return;
+ }
+ this._rafID = window.requestAnimationFrame(this._update.bind(this));
+
+ // Check the elapsed time since the last update callback call.
+ // Consider it 0 if there is no info of last timestamp and leave this
+ // _update call if it was called too soon (would overflow the set max FPS).
+ var elapsedTime = 0;
+ if (this._lastUpdateTimestamp) {
+ elapsedTime = timestamp - this._lastUpdateTimestamp;
+ if (elapsedTime < this._minFrameTime) {
+ return;
+ }
+ }
+
+ // Check the new animation state thanks to the get state callback and
+ // store its new value. If the state is the same as the previous one,
+ // leave this _update call, except if there is an event which triggered
+ // the "play" method again.
+ var animationState = this._getStateCallback(elapsedTime, this._newEvent);
+ if (!this._newEvent
+ && animationState !== undefined
+ && _.isEqual(animationState, this._animationLastState)) {
+ return;
+ }
+ this._animationLastState = animationState;
+
+ // Call the update callback with frame parameters
+ this._updateCallback(this._animationLastState, elapsedTime, this._newEvent);
+ this._lastUpdateTimestamp = timestamp; // Save the timestamp at which the update callback was really called
+ this._newEvent = undefined; // Forget the event which triggered the last "play" call
+ },
+});
+
+/**
+ * Also register AnimationEffect automatically (@see effects, _prepareEffects).
+ */
+var Animation = publicWidget.Widget.extend({
+ /**
+ * The max FPS at which all the automatic animation effects will be
+ * running by default.
+ */
+ maxFPS: 100,
+ /**
+ * @see this._prepareEffects
+ *
+ * @type {Object[]}
+ * @type {string} startEvents
+ * The names of the events which trigger the effect to begin playing.
+ * @type {string} [startTarget]
+ * A selector to find the target where to listen for the start events
+ * (if no selector, the window target will be used). If the whole
+ * $target of the animation should be used, use the 'selector' string.
+ * @type {string} [endEvents]
+ * The name of the events which trigger the end of the effect (if none
+ * is defined, the animation will stop after a while
+ * @see AnimationEffect.start).
+ * @type {string} [endTarget]
+ * A selector to find the target where to listen for the end events
+ * (if no selector, the startTarget will be used). If the whole
+ * $target of the animation should be used, use the 'selector' string.
+ * @type {string} update
+ * A string which refers to a method which will be used as the update
+ * callback for the effect. It receives 3 arguments: the animation
+ * state, the elapsedTime since last update and the event which
+ * triggered the animation (undefined if just a new update call
+ * without trigger).
+ * @type {string} [getState]
+ * The animation state is undefined by default, the scroll offset for
+ * the particular {startEvents: 'scroll'} effect and an object with
+ * width and height for the particular {startEvents: 'resize'} effect.
+ * There is the possibility to define the getState callback of the
+ * animation effect with this key. This allows to improve performance
+ * even further in some cases.
+ */
+ effects: [],
+
+ /**
+ * Initializes the animation. The method should not be called directly as
+ * called automatically on animation instantiation and on restart.
+ *
+ * Also, prepares animation's effects and start them if any.
+ *
+ * @override
+ */
+ start: function () {
+ this._prepareEffects();
+ _.each(this._animationEffects, function (effect) {
+ effect.start();
+ });
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Registers `AnimationEffect` instances.
+ *
+ * This can be done by extending this method and calling the @see _addEffect
+ * method in it or, better, by filling the @see effects property.
+ *
+ * @private
+ */
+ _prepareEffects: function () {
+ this._animationEffects = [];
+
+ var self = this;
+ _.each(this.effects, function (desc) {
+ self._addEffect(self[desc.update], desc.startEvents, _findTarget(desc.startTarget), {
+ getStateCallback: desc.getState && self[desc.getState],
+ endEvents: desc.endEvents || undefined,
+ $endTarget: _findTarget(desc.endTarget),
+ maxFPS: self.maxFPS,
+ });
+
+ // Return the DOM element matching the selector in the form
+ // described above.
+ function _findTarget(selector) {
+ if (selector) {
+ if (selector === 'selector') {
+ return self.$target;
+ }
+ return self.$(selector);
+ }
+ return undefined;
+ }
+ });
+ },
+ /**
+ * Registers a new `AnimationEffect` according to given parameters.
+ *
+ * @private
+ * @see AnimationEffect.init
+ */
+ _addEffect: function (updateCallback, startEvents, $startTarget, options) {
+ this._animationEffects.push(
+ new AnimationEffect(this, updateCallback, startEvents, $startTarget, options)
+ );
+ },
+});
+
+//::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+
+var registry = publicWidget.registry;
+
+registry.slider = publicWidget.Widget.extend({
+ selector: '.carousel',
+ disabledInEditableMode: false,
+ edit_events: {
+ 'content_changed': '_onContentChanged',
+ },
+
+ /**
+ * @override
+ */
+ start: function () {
+ this.$('img').on('load.slider', () => this._computeHeights());
+ this._computeHeights();
+ // Initialize carousel and pause if in edit mode.
+ this.$target.carousel(this.editableMode ? 'pause' : undefined);
+ $(window).on('resize.slider', _.debounce(() => this._computeHeights(), 250));
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._super.apply(this, arguments);
+ this.$('img').off('.slider');
+ this.$target.carousel('pause');
+ this.$target.removeData('bs.carousel');
+ _.each(this.$('.carousel-item'), function (el) {
+ $(el).css('min-height', '');
+ });
+ $(window).off('.slider');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _computeHeights: function () {
+ var maxHeight = 0;
+ var $items = this.$('.carousel-item');
+ $items.css('min-height', '');
+ _.each($items, function (el) {
+ var $item = $(el);
+ var isActive = $item.hasClass('active');
+ $item.addClass('active');
+ var height = $item.outerHeight();
+ if (height > maxHeight) {
+ maxHeight = height;
+ }
+ $item.toggleClass('active', isActive);
+ });
+ $items.css('min-height', maxHeight);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onContentChanged: function (ev) {
+ this._computeHeights();
+ },
+});
+
+registry.Parallax = Animation.extend({
+ selector: '.parallax',
+ disabledInEditableMode: false,
+ effects: [{
+ startEvents: 'scroll',
+ update: '_onWindowScroll',
+ }],
+
+ /**
+ * @override
+ */
+ start: function () {
+ this._rebuild();
+ $(window).on('resize.animation_parallax', _.debounce(this._rebuild.bind(this), 500));
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._super.apply(this, arguments);
+ $(window).off('.animation_parallax');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Prepares the background element which will scroll at a different speed
+ * according to the viewport dimensions and other snippet parameters.
+ *
+ * @private
+ */
+ _rebuild: function () {
+ // Add/find bg DOM element to hold the parallax bg (support old v10.0 parallax)
+ this.$bg = this.$('> .s_parallax_bg');
+
+ // Get parallax speed
+ this.speed = parseFloat(this.$target.attr('data-scroll-background-ratio') || 0);
+
+ // Reset offset if parallax effect will not be performed and leave
+ var noParallaxSpeed = (this.speed === 0 || this.speed === 1);
+ if (noParallaxSpeed) {
+ this.$bg.css({
+ transform: '',
+ top: '',
+ bottom: '',
+ });
+ return;
+ }
+
+ // Initialize parallax data according to snippet and viewport dimensions
+ this.viewport = document.body.clientHeight - $('#wrapwrap').position().top;
+ this.visibleArea = [this.$target.offset().top];
+ this.visibleArea.push(this.visibleArea[0] + this.$target.innerHeight() + this.viewport);
+ this.ratio = this.speed * (this.viewport / 10);
+
+ // Provide a "safe-area" to limit parallax
+ const absoluteRatio = Math.abs(this.ratio);
+ this.$bg.css({
+ top: -absoluteRatio,
+ bottom: -absoluteRatio,
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Effects
+ //--------------------------------------------------------------------------
+
+ /**
+ * Describes how to update the snippet when the window scrolls.
+ *
+ * @private
+ * @param {integer} scrollOffset
+ */
+ _onWindowScroll: function (scrollOffset) {
+ // Speed == 0 is no effect and speed == 1 is handled by CSS only
+ if (this.speed === 0 || this.speed === 1) {
+ return;
+ }
+
+ // Perform translation if the element is visible only
+ var vpEndOffset = scrollOffset + this.viewport;
+ if (vpEndOffset >= this.visibleArea[0]
+ && vpEndOffset <= this.visibleArea[1]) {
+ this.$bg.css('transform', 'translateY(' + _getNormalizedPosition.call(this, vpEndOffset) + 'px)');
+ }
+
+ function _getNormalizedPosition(pos) {
+ // Normalize scroll in a 1 to 0 range
+ var r = (pos - this.visibleArea[1]) / (this.visibleArea[0] - this.visibleArea[1]);
+ // Normalize accordingly to current options
+ return Math.round(this.ratio * (2 * r - 1));
+ }
+ },
+});
+
+registry.mediaVideo = publicWidget.Widget.extend({
+ selector: '.media_iframe_video',
+
+ /**
+ * @override
+ */
+ start: function () {
+ // TODO: this code should be refactored to make more sense and be better
+ // integrated with Odoo (this refactoring should be done in master).
+
+ var def = this._super.apply(this, arguments);
+ if (this.$target.children('iframe').length) {
+ // There already is an <iframe/>, do nothing
+ return def;
+ }
+
+ // Bug fix / compatibility: empty the <div/> element as all information
+ // to rebuild the iframe should have been saved on the <div/> element
+ this.$target.empty();
+
+ // Add extra content for size / edition
+ this.$target.append(
+ '<div class="css_editable_mode_display">&nbsp;</div>' +
+ '<div class="media_iframe_video_size">&nbsp;</div>'
+ );
+
+ // Rebuild the iframe. Depending on version / compatibility / instance,
+ // the src is saved in the 'data-src' attribute or the
+ // 'data-oe-expression' one (the latter is used as a workaround in 10.0
+ // system but should obviously be reviewed in master).
+ this.$target.append($('<iframe/>', {
+ src: _.escape(this.$target.data('oe-expression') || this.$target.data('src')),
+ frameborder: '0',
+ allowfullscreen: 'allowfullscreen',
+ sandbox: 'allow-scripts allow-same-origin', // https://www.html5rocks.com/en/tutorials/security/sandboxed-iframes/
+ }));
+
+ return def;
+ },
+});
+
+registry.backgroundVideo = publicWidget.Widget.extend({
+ selector: '.o_background_video',
+ xmlDependencies: ['/website/static/src/xml/website.background.video.xml'],
+ disabledInEditableMode: false,
+
+ /**
+ * @override
+ */
+ start: function () {
+ var proms = [this._super(...arguments)];
+
+ this.videoSrc = this.el.dataset.bgVideoSrc;
+ this.iframeID = _.uniqueId('o_bg_video_iframe_');
+
+ this.isYoutubeVideo = this.videoSrc.indexOf('youtube') >= 0;
+ this.isMobileEnv = config.device.size_class <= config.device.SIZES.LG && config.device.touch;
+ if (this.isYoutubeVideo && this.isMobileEnv) {
+ this.videoSrc = this.videoSrc + "&enablejsapi=1";
+
+ if (!window.YT) {
+ var oldOnYoutubeIframeAPIReady = window.onYouTubeIframeAPIReady;
+ proms.push(new Promise(resolve => {
+ window.onYouTubeIframeAPIReady = () => {
+ if (oldOnYoutubeIframeAPIReady) {
+ oldOnYoutubeIframeAPIReady();
+ }
+ return resolve();
+ };
+ }));
+ $('<script/>', {
+ src: 'https://www.youtube.com/iframe_api',
+ }).appendTo('head');
+ }
+ }
+
+ var throttledUpdate = _.throttle(() => this._adjustIframe(), 50);
+
+ var $dropdownMenu = this.$el.closest('.dropdown-menu');
+ if ($dropdownMenu.length) {
+ this.$dropdownParent = $dropdownMenu.parent();
+ this.$dropdownParent.on('shown.bs.dropdown.backgroundVideo', throttledUpdate);
+ }
+
+ $(window).on('resize.' + this.iframeID, throttledUpdate);
+
+ return Promise.all(proms).then(() => this._appendBgVideo());
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._super.apply(this, arguments);
+
+ if (this.$dropdownParent) {
+ this.$dropdownParent.off('.backgroundVideo');
+ }
+
+ $(window).off('resize.' + this.iframeID);
+
+ if (this.$bgVideoContainer) {
+ this.$bgVideoContainer.remove();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Adjusts iframe sizes and position so that it fills the container and so
+ * that it is centered in it.
+ *
+ * @private
+ */
+ _adjustIframe: function () {
+ if (!this.$iframe) {
+ return;
+ }
+
+ this.$iframe.removeClass('show');
+
+ // Adjust the iframe
+ var wrapperWidth = this.$target.innerWidth();
+ var wrapperHeight = this.$target.innerHeight();
+ var relativeRatio = (wrapperWidth / wrapperHeight) / (16 / 9);
+ var style = {};
+ if (relativeRatio >= 1.0) {
+ style['width'] = '100%';
+ style['height'] = (relativeRatio * 100) + '%';
+ style['left'] = '0';
+ style['top'] = (-(relativeRatio - 1.0) / 2 * 100) + '%';
+ } else {
+ style['width'] = ((1 / relativeRatio) * 100) + '%';
+ style['height'] = '100%';
+ style['left'] = (-((1 / relativeRatio) - 1.0) / 2 * 100) + '%';
+ style['top'] = '0';
+ }
+ this.$iframe.css(style);
+
+ void this.$iframe[0].offsetWidth; // Force style addition
+ this.$iframe.addClass('show');
+ },
+ /**
+ * Append background video related elements to the target.
+ *
+ * @private
+ */
+ _appendBgVideo: function () {
+ var $oldContainer = this.$bgVideoContainer || this.$('> .o_bg_video_container');
+ this.$bgVideoContainer = $(qweb.render('website.background.video', {
+ videoSrc: this.videoSrc,
+ iframeID: this.iframeID,
+ }));
+ this.$iframe = this.$bgVideoContainer.find('.o_bg_video_iframe');
+ this.$iframe.one('load', () => {
+ this.$bgVideoContainer.find('.o_bg_video_loading').remove();
+ });
+ this.$bgVideoContainer.prependTo(this.$target);
+ $oldContainer.remove();
+
+ this._adjustIframe();
+
+ // YouTube does not allow to auto-play video in mobile devices, so we
+ // have to play the video manually.
+ if (this.isMobileEnv && this.isYoutubeVideo) {
+ new window.YT.Player(this.iframeID, {
+ events: {
+ onReady: ev => ev.target.playVideo(),
+ }
+ });
+ }
+ },
+});
+
+registry.socialShare = publicWidget.Widget.extend({
+ selector: '.oe_social_share',
+ xmlDependencies: ['/website/static/src/xml/website.share.xml'],
+ events: {
+ 'mouseenter': '_onMouseEnter',
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _bindSocialEvent: function () {
+ this.$('.oe_social_facebook').click($.proxy(this._renderSocial, this, 'facebook'));
+ this.$('.oe_social_twitter').click($.proxy(this._renderSocial, this, 'twitter'));
+ this.$('.oe_social_linkedin').click($.proxy(this._renderSocial, this, 'linkedin'));
+ },
+ /**
+ * @private
+ */
+ _render: function () {
+ this.$el.popover({
+ content: qweb.render('website.social_hover', {medias: this.socialList}),
+ placement: 'bottom',
+ container: this.$el,
+ html: true,
+ trigger: 'manual',
+ animation: false,
+ }).popover("show");
+
+ this.$el.off('mouseleave.socialShare').on('mouseleave.socialShare', function () {
+ var self = this;
+ setTimeout(function () {
+ if (!$(".popover:hover").length) {
+ $(self).popover('dispose');
+ }
+ }, 200);
+ });
+ },
+ /**
+ * @private
+ */
+ _renderSocial: function (social) {
+ var url = this.$el.data('urlshare') || document.URL.split(/[?#]/)[0];
+ url = encodeURIComponent(url);
+ var title = document.title.split(" | ")[0]; // get the page title without the company name
+ var hashtags = ' #' + document.title.split(" | ")[1].replace(' ', '') + ' ' + this.hashtags; // company name without spaces (for hashtag)
+ var socialNetworks = {
+ 'facebook': 'https://www.facebook.com/sharer/sharer.php?u=' + url,
+ 'twitter': 'https://twitter.com/intent/tweet?original_referer=' + url + '&text=' + encodeURIComponent(title + hashtags + ' - ') + url,
+ 'linkedin': 'https://www.linkedin.com/sharing/share-offsite/?url=' + url,
+ };
+ if (!_.contains(_.keys(socialNetworks), social)) {
+ return;
+ }
+ var wHeight = 500;
+ var wWidth = 500;
+ window.open(socialNetworks[social], '', 'menubar=no, toolbar=no, resizable=yes, scrollbar=yes, height=' + wHeight + ',width=' + wWidth);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the user hovers the animation element -> open the social
+ * links popover.
+ *
+ * @private
+ */
+ _onMouseEnter: function () {
+ var social = this.$el.data('social');
+ this.socialList = social ? social.split(',') : ['facebook', 'twitter', 'linkedin'];
+ this.hashtags = this.$el.data('hashtags') || '';
+
+ this._render();
+ this._bindSocialEvent();
+ },
+});
+
+registry.anchorSlide = publicWidget.Widget.extend({
+ selector: 'a[href^="/"][href*="#"], a[href^="#"]',
+ events: {
+ 'click': '_onAnimateClick',
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {jQuery} $el the element to scroll to.
+ * @param {string} [scrollValue='true'] scroll value
+ * @returns {Promise}
+ */
+ async _scrollTo($el, scrollValue = 'true') {
+ return dom.scrollTo($el[0], {
+ duration: scrollValue === 'true' ? 500 : 0,
+ extraOffset: this._computeExtraOffset(),
+ });
+ },
+ /**
+ * @private
+ */
+ _computeExtraOffset() {
+ return 0;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onAnimateClick: function (ev) {
+ if (this.$target[0].pathname !== window.location.pathname) {
+ return;
+ }
+ var hash = this.$target[0].hash;
+ if (!utils.isValidAnchor(hash)) {
+ return;
+ }
+ var $anchor = $(hash);
+ const scrollValue = $anchor.attr('data-anchor');
+ if (!$anchor.length || !scrollValue) {
+ return;
+ }
+ ev.preventDefault();
+ this._scrollTo($anchor, scrollValue);
+ },
+});
+
+registry.FullScreenHeight = publicWidget.Widget.extend({
+ selector: '.o_full_screen_height',
+ disabledInEditableMode: false,
+
+ /**
+ * @override
+ */
+ start() {
+ if (this.$el.outerHeight() > this._computeIdealHeight()) {
+ // Only initialize if taller than the ideal height as some extra css
+ // rules may alter the full-screen-height class behavior in some
+ // cases (blog...).
+ this._adaptSize();
+ $(window).on('resize.FullScreenHeight', _.debounce(() => this._adaptSize(), 250));
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ this._super(...arguments);
+ $(window).off('.FullScreenHeight');
+ this.el.style.setProperty('min-height', '');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _adaptSize() {
+ const height = this._computeIdealHeight();
+ this.el.style.setProperty('min-height', `${height}px`, 'important');
+ },
+ /**
+ * @private
+ */
+ _computeIdealHeight() {
+ const windowHeight = $(window).outerHeight();
+ // Doing it that way allows to considerer fixed headers, hidden headers,
+ // connected users, ...
+ const firstContentEl = $('#wrapwrap > main > :first-child')[0]; // first child to consider the padding-top of main
+ const mainTopPos = firstContentEl.getBoundingClientRect().top + dom.closestScrollable(firstContentEl.parentNode).scrollTop;
+ return (windowHeight - mainTopPos);
+ },
+});
+
+registry.ScrollButton = registry.anchorSlide.extend({
+ selector: '.o_scroll_button',
+
+ /**
+ * @override
+ */
+ _onAnimateClick: function (ev) {
+ ev.preventDefault();
+ const $nextElement = this.$el.closest('section').next();
+ if ($nextElement.length) {
+ this._scrollTo($nextElement);
+ }
+ },
+});
+
+registry.FooterSlideout = publicWidget.Widget.extend({
+ selector: '#wrapwrap:has(.o_footer_slideout)',
+ disabledInEditableMode: false,
+
+ /**
+ * @override
+ */
+ async start() {
+ const $main = this.$('> main');
+ const slideoutEffect = $main.outerHeight() >= $(window).outerHeight();
+ this.el.classList.toggle('o_footer_effect_enable', slideoutEffect);
+
+ // Add a pixel div over the footer, after in the DOM, so that the
+ // height of the footer is understood by Firefox sticky implementation
+ // (which it seems to not understand because of the combination of 3
+ // items: the footer is the last :visible element in the #wrapwrap, the
+ // #wrapwrap uses flex layout and the #wrapwrap is the element with a
+ // scrollbar).
+ // TODO check if the hack is still needed by future browsers.
+ this.__pixelEl = document.createElement('div');
+ this.__pixelEl.style.width = `1px`;
+ this.__pixelEl.style.height = `1px`;
+ this.__pixelEl.style.marginTop = `-1px`;
+ this.el.appendChild(this.__pixelEl);
+
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ this._super(...arguments);
+ this.el.classList.remove('o_footer_effect_enable');
+ this.__pixelEl.remove();
+ },
+});
+
+registry.HeaderHamburgerFull = publicWidget.Widget.extend({
+ selector: 'header:has(.o_header_hamburger_full_toggler):not(:has(.o_offcanvas_menu_toggler))',
+ events: {
+ 'click .o_header_hamburger_full_toggler': '_onToggleClick',
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onToggleClick() {
+ document.body.classList.add('overflow-hidden');
+ setTimeout(() => $(window).trigger('scroll'), 100);
+ },
+});
+
+registry.BottomFixedElement = publicWidget.Widget.extend({
+ selector: '#wrapwrap',
+
+ /**
+ * @override
+ */
+ async start() {
+ this.$scrollingElement = $().getScrollingElement();
+ this.__hideBottomFixedElements = _.debounce(() => this._hideBottomFixedElements(), 500);
+ this.$scrollingElement.on('scroll.bottom_fixed_element', this.__hideBottomFixedElements);
+ $(window).on('resize.bottom_fixed_element', this.__hideBottomFixedElements);
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ this._super(...arguments);
+ this.$scrollingElement.off('.bottom_fixed_element');
+ $(window).off('.bottom_fixed_element');
+ $('.o_bottom_fixed_element').removeClass('o_bottom_fixed_element_hidden');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Hides the elements that are fixed at the bottom of the screen if the
+ * scroll reaches the bottom of the page and if the elements hide a button.
+ *
+ * @private
+ */
+ _hideBottomFixedElements() {
+ // Note: check in the whole DOM instead of #wrapwrap as unfortunately
+ // some things are still put outside of the #wrapwrap (like the livechat
+ // button which is the main reason of this code).
+ const $bottomFixedElements = $('.o_bottom_fixed_element');
+ if (!$bottomFixedElements.length) {
+ return;
+ }
+
+ $bottomFixedElements.removeClass('o_bottom_fixed_element_hidden');
+ if ((this.$scrollingElement[0].offsetHeight + this.$scrollingElement[0].scrollTop) >= (this.$scrollingElement[0].scrollHeight - 2)) {
+ const buttonEls = [...this.$('.btn:visible')];
+ for (const el of $bottomFixedElements) {
+ if (buttonEls.some(button => dom.areColliding(button, el))) {
+ el.classList.add('o_bottom_fixed_element_hidden');
+ }
+ }
+ }
+ },
+});
+
+return {
+ Widget: publicWidget.Widget,
+ Animation: Animation,
+ registry: registry,
+
+ Class: Animation, // Deprecated
+};
+});
diff --git a/addons/website/static/src/js/content/website_root.js b/addons/website/static/src/js/content/website_root.js
new file mode 100644
index 00000000..c2844a49
--- /dev/null
+++ b/addons/website/static/src/js/content/website_root.js
@@ -0,0 +1,350 @@
+odoo.define('website.root', function (require) {
+'use strict';
+
+const ajax = require('web.ajax');
+const {_t} = require('web.core');
+var Dialog = require('web.Dialog');
+const KeyboardNavigationMixin = require('web.KeyboardNavigationMixin');
+const session = require('web.session');
+var publicRootData = require('web.public.root');
+require("web.zoomodoo");
+
+var websiteRootRegistry = publicRootData.publicRootRegistry;
+
+var WebsiteRoot = publicRootData.PublicRoot.extend(KeyboardNavigationMixin, {
+ events: _.extend({}, KeyboardNavigationMixin.events, publicRootData.PublicRoot.prototype.events || {}, {
+ 'click .js_change_lang': '_onLangChangeClick',
+ 'click .js_publish_management .js_publish_btn': '_onPublishBtnClick',
+ 'click .js_multi_website_switch': '_onWebsiteSwitch',
+ 'shown.bs.modal': '_onModalShown',
+ }),
+ custom_events: _.extend({}, publicRootData.PublicRoot.prototype.custom_events || {}, {
+ 'gmap_api_request': '_onGMapAPIRequest',
+ 'gmap_api_key_request': '_onGMapAPIKeyRequest',
+ 'ready_to_clean_for_save': '_onWidgetsStopRequest',
+ 'seo_object_request': '_onSeoObjectRequest',
+ }),
+
+ /**
+ * @override
+ */
+ init() {
+ this.isFullscreen = false;
+ KeyboardNavigationMixin.init.call(this, {
+ autoAccessKeys: false,
+ });
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ KeyboardNavigationMixin.start.call(this);
+ // Compatibility lang change ?
+ if (!this.$('.js_change_lang').length) {
+ var $links = this.$('.js_language_selector a:not([data-oe-id])');
+ var m = $(_.min($links, function (l) {
+ return $(l).attr('href').length;
+ })).attr('href');
+ $links.each(function () {
+ var $link = $(this);
+ var t = $link.attr('href');
+ var l = (t === m) ? "default" : t.split('/')[1];
+ $link.data('lang', l).addClass('js_change_lang');
+ });
+ }
+
+ // Enable magnify on zommable img
+ this.$('.zoomable img[data-zoom]').zoomOdoo();
+
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ KeyboardNavigationMixin.destroy.call(this);
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _getContext: function (context) {
+ var html = document.documentElement;
+ return _.extend({
+ 'website_id': html.getAttribute('data-website-id') | 0,
+ }, this._super.apply(this, arguments));
+ },
+ /**
+ * @override
+ */
+ _getExtraContext: function (context) {
+ var html = document.documentElement;
+ return _.extend({
+ 'editable': !!(html.dataset.editable || $('[data-oe-model]').length), // temporary hack, this should be done in python
+ 'translatable': !!html.dataset.translatable,
+ 'edit_translations': !!html.dataset.edit_translations,
+ }, this._super.apply(this, arguments));
+ },
+ /**
+ * @private
+ * @param {boolean} [refetch=false]
+ */
+ async _getGMapAPIKey(refetch) {
+ if (refetch || !this._gmapAPIKeyProm) {
+ this._gmapAPIKeyProm = new Promise(async resolve => {
+ const data = await this._rpc({
+ route: '/website/google_maps_api_key',
+ });
+ resolve(JSON.parse(data).google_maps_api_key || '');
+ });
+ }
+ return this._gmapAPIKeyProm;
+ },
+ /**
+ * @override
+ */
+ _getPublicWidgetsRegistry: function (options) {
+ var registry = this._super.apply(this, arguments);
+ if (options.editableMode) {
+ return _.pick(registry, function (PublicWidget) {
+ return !PublicWidget.prototype.disabledInEditableMode;
+ });
+ }
+ return registry;
+ },
+ /**
+ * @private
+ * @param {boolean} [editableMode=false]
+ * @param {boolean} [refetch=false]
+ */
+ async _loadGMapAPI(editableMode, refetch) {
+ // Note: only need refetch to reload a configured key and load the
+ // library. If the library was loaded with a correct key and that the
+ // key changes meanwhile... it will not work but we can agree the user
+ // can bother to reload the page at that moment.
+ if (refetch || !this._gmapAPILoading) {
+ this._gmapAPILoading = new Promise(async resolve => {
+ const key = await this._getGMapAPIKey(refetch);
+
+ window.odoo_gmap_api_post_load = (async function odoo_gmap_api_post_load() {
+ await this._startWidgets(undefined, {editableMode: editableMode});
+ resolve(key);
+ }).bind(this);
+
+ if (!key) {
+ if (!editableMode && session.is_admin) {
+ this.displayNotification({
+ type: 'warning',
+ sticky: true,
+ message:
+ $('<div/>').append(
+ $('<span/>', {text: _t("Cannot load google map.")}),
+ $('<br/>'),
+ $('<a/>', {
+ href: "/web#action=website.action_website_configuration",
+ text: _t("Check your configuration."),
+ }),
+ )[0].outerHTML,
+ });
+ }
+ resolve(false);
+ this._gmapAPILoading = false;
+ return;
+ }
+ await ajax.loadJS(`https://maps.googleapis.com/maps/api/js?v=3.exp&libraries=places&callback=odoo_gmap_api_post_load&key=${key}`);
+ });
+ }
+ return this._gmapAPILoading;
+ },
+ /**
+ * Toggles the fullscreen mode.
+ *
+ * @private
+ * @param {boolean} state toggle fullscreen on/off (true/false)
+ */
+ _toggleFullscreen(state) {
+ this.isFullscreen = state;
+ document.body.classList.add('o_fullscreen_transition');
+ document.body.classList.toggle('o_fullscreen', this.isFullscreen);
+ document.body.style.overflowX = 'hidden';
+ let resizing = true;
+ window.requestAnimationFrame(function resizeFunction() {
+ window.dispatchEvent(new Event('resize'));
+ if (resizing) {
+ window.requestAnimationFrame(resizeFunction);
+ }
+ });
+ let stopResizing;
+ const onTransitionEnd = ev => {
+ if (ev.target === document.body && ev.propertyName === 'padding-top') {
+ stopResizing();
+ }
+ };
+ stopResizing = () => {
+ resizing = false;
+ document.body.style.overflowX = '';
+ document.body.removeEventListener('transitionend', onTransitionEnd);
+ document.body.classList.remove('o_fullscreen_transition');
+ };
+ document.body.addEventListener('transitionend', onTransitionEnd);
+ // Safeguard in case the transitionend event doesn't trigger for whatever reason.
+ window.setTimeout(() => stopResizing(), 500);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _onWidgetsStartRequest: function (ev) {
+ ev.data.options = _.clone(ev.data.options || {});
+ ev.data.options.editableMode = ev.data.editableMode;
+ this._super.apply(this, arguments);
+ },
+ /**
+ * @todo review
+ * @private
+ */
+ _onLangChangeClick: function (ev) {
+ ev.preventDefault();
+
+ var $target = $(ev.currentTarget);
+ // retrieve the hash before the redirect
+ var redirect = {
+ lang: $target.data('url_code'),
+ url: encodeURIComponent($target.attr('href').replace(/[&?]edit_translations[^&?]+/, '')),
+ hash: encodeURIComponent(window.location.hash)
+ };
+ window.location.href = _.str.sprintf("/website/lang/%(lang)s?r=%(url)s%(hash)s", redirect);
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ async _onGMapAPIRequest(ev) {
+ ev.stopPropagation();
+ const apiKey = await this._loadGMapAPI(ev.data.editableMode, ev.data.refetch);
+ ev.data.onSuccess(apiKey);
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ async _onGMapAPIKeyRequest(ev) {
+ ev.stopPropagation();
+ const apiKey = await this._getGMapAPIKey(ev.data.refetch);
+ ev.data.onSuccess(apiKey);
+ },
+ /**
+ /**
+ * Checks information about the page SEO object.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSeoObjectRequest: function (ev) {
+ var res = this._unslugHtmlDataObject('seo-object');
+ ev.data.callback(res);
+ },
+ /**
+ * Returns a model/id object constructed from html data attribute.
+ *
+ * @private
+ * @param {string} dataAttr
+ * @returns {Object} an object with 2 keys: model and id, or null
+ * if not found
+ */
+ _unslugHtmlDataObject: function (dataAttr) {
+ var repr = $('html').data(dataAttr);
+ var match = repr && repr.match(/(.+)\((\d+),(.*)\)/);
+ if (!match) {
+ return null;
+ }
+ return {
+ model: match[1],
+ id: match[2] | 0,
+ };
+ },
+ /**
+ * @todo review
+ * @private
+ */
+ _onPublishBtnClick: function (ev) {
+ ev.preventDefault();
+ if (document.body.classList.contains('editor_enable')) {
+ return;
+ }
+
+ var self = this;
+ var $data = $(ev.currentTarget).parents(".js_publish_management:first");
+ this._rpc({
+ route: $data.data('controller') || '/website/publish',
+ params: {
+ id: +$data.data('id'),
+ object: $data.data('object'),
+ },
+ })
+ .then(function (result) {
+ $data.toggleClass("css_unpublished css_published");
+ $data.find('input').prop("checked", result);
+ $data.parents("[data-publish]").attr("data-publish", +result ? 'on' : 'off');
+ if (result) {
+ self.displayNotification({
+ type: 'success',
+ message: $data.data('description') ?
+ _.str.sprintf(_t("You've published your %s."), $data.data('description')) :
+ _t("Published with success."),
+ });
+ }
+ });
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onWebsiteSwitch: function (ev) {
+ var websiteId = ev.currentTarget.getAttribute('website-id');
+ var websiteDomain = ev.currentTarget.getAttribute('domain');
+ let url = `/website/force/${websiteId}`;
+ if (websiteDomain && window.location.hostname !== websiteDomain) {
+ url = websiteDomain + url;
+ }
+ const path = window.location.pathname + window.location.search + window.location.hash;
+ window.location.href = $.param.querystring(url, {'path': path});
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onModalShown: function (ev) {
+ $(ev.target).addClass('modal_shown');
+ },
+ /**
+ * @override
+ */
+ _onKeyDown(ev) {
+ if (!session.user_id) {
+ return;
+ }
+ // If document.body doesn't contain the element, it was probably removed as a consequence of pressing Esc.
+ // we don't want to toggle fullscreen as the removal (eg, closing a modal) is the intended action.
+ if (ev.keyCode !== $.ui.keyCode.ESCAPE || !document.body.contains(ev.target) || ev.target.closest('.modal')) {
+ return KeyboardNavigationMixin._onKeyDown.apply(this, arguments);
+ }
+ this._toggleFullscreen(!this.isFullscreen);
+ },
+});
+
+return {
+ WebsiteRoot: WebsiteRoot,
+ websiteRootRegistry: websiteRootRegistry,
+};
+});
diff --git a/addons/website/static/src/js/content/website_root_instance.js b/addons/website/static/src/js/content/website_root_instance.js
new file mode 100644
index 00000000..fbbefb03
--- /dev/null
+++ b/addons/website/static/src/js/content/website_root_instance.js
@@ -0,0 +1,26 @@
+odoo.define('root.widget', function (require) {
+'use strict';
+
+const AbstractService = require('web.AbstractService');
+const env = require('web.public_env');
+var lazyloader = require('web.public.lazyloader');
+var websiteRootData = require('website.root');
+
+/**
+ * Configure Owl with the public env
+ */
+owl.config.mode = env.isDebug() ? "dev" : "prod";
+owl.Component.env = env;
+
+/**
+ * Deploy services in the env
+ */
+AbstractService.prototype.deployServices(env);
+
+var websiteRoot = new websiteRootData.WebsiteRoot(null);
+return lazyloader.allScriptsLoaded.then(function () {
+ return websiteRoot.attachTo(document.body).then(function () {
+ return websiteRoot;
+ });
+});
+});