summaryrefslogtreecommitdiff
path: root/addons/website/static/src/snippets
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/snippets
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/website/static/src/snippets')
-rw-r--r--addons/website/static/src/snippets/s_alert/000.scss30
-rw-r--r--addons/website/static/src/snippets/s_badge/000.scss10
-rw-r--r--addons/website/static/src/snippets/s_badge/000_variables.scss4
-rw-r--r--addons/website/static/src/snippets/s_blockquote/000.scss73
-rw-r--r--addons/website/static/src/snippets/s_blockquote/options.js46
-rw-r--r--addons/website/static/src/snippets/s_btn/000.scss6
-rw-r--r--addons/website/static/src/snippets/s_card/000.scss12
-rw-r--r--addons/website/static/src/snippets/s_chart/000.js142
-rw-r--r--addons/website/static/src/snippets/s_chart/options.js477
-rw-r--r--addons/website/static/src/snippets/s_color_blocks_2/000.scss36
-rw-r--r--addons/website/static/src/snippets/s_company_team/000.scss8
-rw-r--r--addons/website/static/src/snippets/s_comparisons/000.scss21
-rw-r--r--addons/website/static/src/snippets/s_countdown/000.js422
-rw-r--r--addons/website/static/src/snippets/s_countdown/000.xml29
-rw-r--r--addons/website/static/src/snippets/s_countdown/options.js135
-rw-r--r--addons/website/static/src/snippets/s_dynamic_snippet/000.js244
-rw-r--r--addons/website/static/src/snippets/s_dynamic_snippet/000.scss11
-rw-r--r--addons/website/static/src/snippets/s_dynamic_snippet/000.xml20
-rw-r--r--addons/website/static/src/snippets/s_dynamic_snippet/options.js136
-rw-r--r--addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.js46
-rw-r--r--addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.scss11
-rw-r--r--addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.xml35
-rw-r--r--addons/website/static/src/snippets/s_dynamic_snippet_carousel/options.js28
-rw-r--r--addons/website/static/src/snippets/s_facebook_page/000.js56
-rw-r--r--addons/website/static/src/snippets/s_facebook_page/options.js157
-rw-r--r--addons/website/static/src/snippets/s_faq_collapse/000.scss35
-rw-r--r--addons/website/static/src/snippets/s_features_grid/000.scss13
-rw-r--r--addons/website/static/src/snippets/s_google_map/000.js96
-rw-r--r--addons/website/static/src/snippets/s_google_map/000.scss42
-rw-r--r--addons/website/static/src/snippets/s_google_map/img/thumbs/map-blue.jpgbin0 -> 2617 bytes
-rw-r--r--addons/website/static/src/snippets/s_google_map/img/thumbs/map-bw.jpgbin0 -> 4105 bytes
-rw-r--r--addons/website/static/src/snippets/s_google_map/img/thumbs/map-caramello.jpgbin0 -> 2688 bytes
-rw-r--r--addons/website/static/src/snippets/s_google_map/img/thumbs/map-cobalt.jpgbin0 -> 2966 bytes
-rw-r--r--addons/website/static/src/snippets/s_google_map/img/thumbs/map-cupertino.jpgbin0 -> 3443 bytes
-rw-r--r--addons/website/static/src/snippets/s_google_map/img/thumbs/map-default.jpgbin0 -> 3440 bytes
-rw-r--r--addons/website/static/src/snippets/s_google_map/img/thumbs/map-flat.jpgbin0 -> 3109 bytes
-rw-r--r--addons/website/static/src/snippets/s_google_map/img/thumbs/map-lightMono.jpgbin0 -> 4149 bytes
-rw-r--r--addons/website/static/src/snippets/s_google_map/img/thumbs/map-lilla.jpgbin0 -> 2642 bytes
-rw-r--r--addons/website/static/src/snippets/s_google_map/img/thumbs/map-retro.jpgbin0 -> 4055 bytes
-rw-r--r--addons/website/static/src/snippets/s_google_map/options.js56
-rw-r--r--addons/website/static/src/snippets/s_hr/000.scss11
-rw-r--r--addons/website/static/src/snippets/s_image_gallery/000.js180
-rw-r--r--addons/website/static/src/snippets/s_image_gallery/000.scss155
-rw-r--r--addons/website/static/src/snippets/s_image_gallery/000.xml68
-rw-r--r--addons/website/static/src/snippets/s_image_gallery/001.scss281
-rw-r--r--addons/website/static/src/snippets/s_image_gallery/options.js481
-rw-r--r--addons/website/static/src/snippets/s_masonry_block/000.scss93
-rw-r--r--addons/website/static/src/snippets/s_masonry_block/000_variables.scss4
-rw-r--r--addons/website/static/src/snippets/s_masonry_block/001.scss5
-rw-r--r--addons/website/static/src/snippets/s_media_list/000.scss27
-rw-r--r--addons/website/static/src/snippets/s_media_list/001.scss11
-rw-r--r--addons/website/static/src/snippets/s_media_list/options.js50
-rw-r--r--addons/website/static/src/snippets/s_popup/000.js119
-rw-r--r--addons/website/static/src/snippets/s_popup/000.scss99
-rw-r--r--addons/website/static/src/snippets/s_popup/001.scss72
-rw-r--r--addons/website/static/src/snippets/s_popup/options.js114
-rw-r--r--addons/website/static/src/snippets/s_process_steps/000.scss52
-rw-r--r--addons/website/static/src/snippets/s_product_catalog/001.scss29
-rw-r--r--addons/website/static/src/snippets/s_product_catalog/options.js66
-rw-r--r--addons/website/static/src/snippets/s_product_list/000.scss45
-rw-r--r--addons/website/static/src/snippets/s_product_list/000_variables.scss1
-rw-r--r--addons/website/static/src/snippets/s_progress_bar/options.js80
-rw-r--r--addons/website/static/src/snippets/s_quotes_carousel/000.scss22
-rw-r--r--addons/website/static/src/snippets/s_quotes_carousel/001.scss8
-rw-r--r--addons/website/static/src/snippets/s_rating/000.scss55
-rw-r--r--addons/website/static/src/snippets/s_rating/001.scss15
-rw-r--r--addons/website/static/src/snippets/s_rating/options.js151
-rw-r--r--addons/website/static/src/snippets/s_references/000.scss4
-rw-r--r--addons/website/static/src/snippets/s_share/000.js49
-rw-r--r--addons/website/static/src/snippets/s_share/000.scss65
-rw-r--r--addons/website/static/src/snippets/s_showcase/000.scss77
-rw-r--r--addons/website/static/src/snippets/s_showcase/001.scss46
-rw-r--r--addons/website/static/src/snippets/s_showcase/options.js19
-rw-r--r--addons/website/static/src/snippets/s_table_of_content/000.js77
-rw-r--r--addons/website/static/src/snippets/s_table_of_content/000.scss65
-rw-r--r--addons/website/static/src/snippets/s_table_of_content/options.js122
-rw-r--r--addons/website/static/src/snippets/s_tabs/001.scss25
-rw-r--r--addons/website/static/src/snippets/s_tabs/options.js167
-rw-r--r--addons/website/static/src/snippets/s_text_highlight/000.scss7
-rw-r--r--addons/website/static/src/snippets/s_three_columns/000.scss5
-rw-r--r--addons/website/static/src/snippets/s_timeline/000.scss70
-rw-r--r--addons/website/static/src/snippets/s_timeline/options.js32
-rw-r--r--addons/website/static/src/snippets/s_title/000.scss36
83 files changed, 5597 insertions, 0 deletions
diff --git a/addons/website/static/src/snippets/s_alert/000.scss b/addons/website/static/src/snippets/s_alert/000.scss
new file mode 100644
index 00000000..a44d1d2d
--- /dev/null
+++ b/addons/website/static/src/snippets/s_alert/000.scss
@@ -0,0 +1,30 @@
+
+.s_alert {
+ margin: $grid-gutter-width/2 0;
+ border: $alert-border-width solid;
+ border-radius: $alert-border-radius;
+ p, ul, ol {
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+ &_sm {
+ padding: $grid-gutter-width/3;
+ font-size: $font-size-sm;
+ }
+ &_md {
+ padding: $grid-gutter-width/2;
+ font-size: $font-size-base;
+ }
+ &_lg {
+ padding: $grid-gutter-width;
+ font-size: $font-size-lg;
+ }
+ &_icon {
+ float: left;
+ margin-right: 10px;
+ }
+ &_content {
+ overflow: hidden;
+ }
+}
diff --git a/addons/website/static/src/snippets/s_badge/000.scss b/addons/website/static/src/snippets/s_badge/000.scss
new file mode 100644
index 00000000..acdb811e
--- /dev/null
+++ b/addons/website/static/src/snippets/s_badge/000.scss
@@ -0,0 +1,10 @@
+
+.s_badge {
+ padding: $s-badge-padding;
+ margin: $s-badge-margin;
+ border-radius: if($s-badge-border-radius != null, $s-badge-border-radius, $badge-border-radius);
+ font-size: $font-size-sm;
+ .fa {
+ margin: $s-badge-i-margin;
+ }
+}
diff --git a/addons/website/static/src/snippets/s_badge/000_variables.scss b/addons/website/static/src/snippets/s_badge/000_variables.scss
new file mode 100644
index 00000000..b6bc1929
--- /dev/null
+++ b/addons/website/static/src/snippets/s_badge/000_variables.scss
@@ -0,0 +1,4 @@
+$s-badge-border-radius: null;
+$s-badge-padding: .5rem;
+$s-badge-margin: .5rem .5rem .5rem 0;
+$s-badge-i-margin: 0 .3rem 0 0;
diff --git a/addons/website/static/src/snippets/s_blockquote/000.scss b/addons/website/static/src/snippets/s_blockquote/000.scss
new file mode 100644
index 00000000..a41d1f27
--- /dev/null
+++ b/addons/website/static/src/snippets/s_blockquote/000.scss
@@ -0,0 +1,73 @@
+.s_blockquote {
+ // Reset
+ border: 0;
+ padding: 0;
+ .s_blockquote_icon {
+ font-size: $font-size-base;
+ }
+ .s_blockquote_author {
+ opacity: .75;
+ }
+ // Classic
+ &.s_blockquote_classic {
+ .s_blockquote_icon {
+ float: left;
+ border-top-right-radius: 0 !important;
+ border-bottom-right-radius: 0 !important;
+ &.float-right {
+ border-top-left-radius: 0 !important;
+ border-bottom-left-radius: 0 !important;
+ }
+ }
+ .s_blockquote_content {
+ overflow: hidden;
+ padding: $spacer * 1.5;
+ .blockquote-footer {
+ &::before {
+ content: '';
+ }
+ .s_blockquote_avatar {
+ max-height: $spacer * 2.5;
+ }
+ }
+ }
+ }
+ // Cover
+ &.s_blockquote_cover {
+ text-align: center;
+ .s_blockquote_icon {
+ position: relative;
+ z-index: 1;
+ float: none;
+ margin-bottom: -$spacer * 1.5;
+ }
+ p:last-of-type {
+ margin-bottom: $spacer * .5;
+ }
+ .s_blockquote_content, .s_blockquote_filter { // s_blockquote_filter is there for compatibility
+ padding: $spacer * 3 $spacer * 2 $spacer * 2;
+ }
+ // Compatibility
+ .s_blockquote_filter {
+ margin: $spacer * -3 $spacer * -2 $spacer * -2;
+ }
+ .quote_char {
+ margin: $spacer * 2 0 $spacer 0;
+ & ~ .blockquote-footer {
+ padding-bottom: $spacer * 2;
+ }
+ }
+ }
+ // Minimalist
+ &.s_blockquote_minimalist {
+ border-left: 5px solid;
+ border-color: o-color('secondary');
+ .s_blockquote_content {
+ padding: $spacer;
+ @include border-right-radius($border-radius);
+ p:last-of-type {
+ margin-bottom: 0;
+ }
+ }
+ }
+}
diff --git a/addons/website/static/src/snippets/s_blockquote/options.js b/addons/website/static/src/snippets/s_blockquote/options.js
new file mode 100644
index 00000000..46748f15
--- /dev/null
+++ b/addons/website/static/src/snippets/s_blockquote/options.js
@@ -0,0 +1,46 @@
+odoo.define('website.s_blockquote_options', function (require) {
+'use strict';
+
+const options = require('web_editor.snippets.options');
+
+options.registry.Blockquote = options.Class.extend({
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Change blockquote design.
+ *
+ * @see this.selectClass for parameters
+ */
+ display: function (previewMode, widgetValue, params) {
+
+ // Classic
+ this.$target.find('.s_blockquote_avatar').toggleClass('d-none', widgetValue !== 'classic');
+
+ // Cover
+ const $blockquote = this.$target.find('.s_blockquote_content');
+ if (widgetValue === 'cover') {
+ $blockquote.css({"background-image": "url('/web/image/website.s_blockquote_cover_default_image')"});
+ $blockquote.css({"background-position": "50% 50%"});
+ $blockquote.addClass('oe_img_bg');
+ if (!$blockquote.find('.o_we_bg_filter').length) {
+ const bgFilterEl = document.createElement('div');
+ bgFilterEl.classList.add('o_we_bg_filter', 'bg-white-50');
+ $blockquote.prepend(bgFilterEl);
+ }
+ } else {
+ $blockquote.css({"background-image": ""});
+ $blockquote.css({"background-position": ""});
+ $blockquote.removeClass('oe_img_bg');
+ $blockquote.find('.o_we_bg_filter').remove();
+ $blockquote.find('.s_blockquote_filter').contents().unwrap(); // Compatibility
+ }
+
+ // Minimalist
+ this.$target.find('.s_blockquote_icon').toggleClass('d-none', widgetValue === 'minimalist');
+ this.$target.find('footer').toggleClass('d-none', widgetValue === 'minimalist');
+ },
+});
+});
diff --git a/addons/website/static/src/snippets/s_btn/000.scss b/addons/website/static/src/snippets/s_btn/000.scss
new file mode 100644
index 00000000..4e5a3215
--- /dev/null
+++ b/addons/website/static/src/snippets/s_btn/000.scss
@@ -0,0 +1,6 @@
+
+.s_btn {
+ .btn + .btn {
+ margin-left: .75rem;
+ }
+}
diff --git a/addons/website/static/src/snippets/s_card/000.scss b/addons/website/static/src/snippets/s_card/000.scss
new file mode 100644
index 00000000..436d9a70
--- /dev/null
+++ b/addons/website/static/src/snippets/s_card/000.scss
@@ -0,0 +1,12 @@
+
+.s_card {
+ margin: $grid-gutter-width/2 0;
+ .card-body {
+ // color: initial;
+ p, ul, ol {
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+ }
+}
diff --git a/addons/website/static/src/snippets/s_chart/000.js b/addons/website/static/src/snippets/s_chart/000.js
new file mode 100644
index 00000000..2c67bd03
--- /dev/null
+++ b/addons/website/static/src/snippets/s_chart/000.js
@@ -0,0 +1,142 @@
+odoo.define('website.s_chart', function (require) {
+'use strict';
+
+const publicWidget = require('web.public.widget');
+const weUtils = require('web_editor.utils');
+
+const ChartWidget = publicWidget.Widget.extend({
+ selector: '.s_chart',
+ disabledInEditableMode: false,
+ jsLibs: [
+ '/web/static/lib/Chart/Chart.js',
+ ],
+
+ /**
+ * @override
+ * @param {Object} parent
+ * @param {Object} options The default value of the chartbar.
+ */
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+ this.style = window.getComputedStyle(document.documentElement);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ // Convert Theme colors to css color
+ const data = JSON.parse(this.el.dataset.data);
+ data.datasets.forEach(el => {
+ if (Array.isArray(el.backgroundColor)) {
+ el.backgroundColor = el.backgroundColor.map(el => this._convertToCssColor(el));
+ el.borderColor = el.borderColor.map(el => this._convertToCssColor(el));
+ } else {
+ el.backgroundColor = this._convertToCssColor(el.backgroundColor);
+ el.borderColor = this._convertToCssColor(el.borderColor);
+ }
+ el.borderWidth = this.el.dataset.borderWidth;
+ });
+
+ // Make chart data
+ const chartData = {
+ type: this.el.dataset.type,
+ data: data,
+ options: {
+ legend: {
+ display: this.el.dataset.legendPosition !== 'none',
+ position: this.el.dataset.legendPosition,
+ },
+ tooltips: {
+ enabled: this.el.dataset.tooltipDisplay === 'true',
+ },
+ title: {
+ display: !!this.el.dataset.title,
+ text: this.el.dataset.title,
+ },
+ },
+ };
+
+ // Add type specific options
+ if (this.el.dataset.type === 'radar') {
+ chartData.options.scale = {
+ ticks: {
+ beginAtZero: true,
+ }
+ };
+ } else if (['pie', 'doughnut'].includes(this.el.dataset.type)) {
+ chartData.options.tooltips.callbacks = {
+ label: (tooltipItem, data) => {
+ const label = data.datasets[tooltipItem.datasetIndex].label;
+ const secondLabel = data.labels[tooltipItem.index];
+ let final = label;
+ if (label) {
+ if (secondLabel) {
+ final = label + ' - ' + secondLabel;
+ }
+ } else if (secondLabel) {
+ final = secondLabel;
+ }
+ return final + ':' + data.datasets[tooltipItem.datasetIndex].data[tooltipItem.index];
+ },
+ };
+ } else {
+ chartData.options.scales = {
+ xAxes: [{
+ stacked: this.el.dataset.stacked === 'true',
+ ticks: {
+ beginAtZero: true
+ },
+ }],
+ yAxes: [{
+ stacked: this.el.dataset.stacked === 'true',
+ ticks: {
+ beginAtZero: true
+ },
+ }],
+ };
+ }
+
+ // Disable animation in edit mode
+ if (this.editableMode) {
+ chartData.options.animation = {
+ duration: 0,
+ };
+ }
+
+ const canvas = this.el.querySelector('canvas');
+ this.chart = new window.Chart(canvas, chartData);
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ * Discard all library changes to reset the state of the Html.
+ */
+ destroy: function () {
+ if (this.chart) { // The widget can be destroyed before start has completed
+ this.chart.destroy();
+ this.el.querySelectorAll('.chartjs-size-monitor').forEach(el => el.remove());
+ }
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {string} color A css color or theme color string
+ * @returns {string} Css color
+ */
+ _convertToCssColor: function (color) {
+ if (!color) {
+ return 'transparent';
+ }
+ return weUtils.getCSSVariableValue(color, this.style) || color;
+ },
+});
+
+publicWidget.registry.chart = ChartWidget;
+
+return ChartWidget;
+});
diff --git a/addons/website/static/src/snippets/s_chart/options.js b/addons/website/static/src/snippets/s_chart/options.js
new file mode 100644
index 00000000..fe59cedf
--- /dev/null
+++ b/addons/website/static/src/snippets/s_chart/options.js
@@ -0,0 +1,477 @@
+odoo.define('website.s_chart_options', function (require) {
+'use strict';
+
+var core = require('web.core');
+const {ColorpickerWidget} = require('web.Colorpicker');
+var options = require('web_editor.snippets.options');
+const weUtils = require('web_editor.utils');
+
+var _t = core._t;
+
+options.registry.InnerChart = options.Class.extend({
+ custom_events: _.extend({}, options.Class.prototype.custom_events, {
+ 'get_custom_colors': '_onGetCustomColors',
+ }),
+ events: _.extend({}, options.Class.prototype.events, {
+ 'click we-button.add_column': '_onAddColumnClick',
+ 'click we-button.add_row': '_onAddRowClick',
+ 'click we-button.o_we_matrix_remove_col': '_onRemoveColumnClick',
+ 'click we-button.o_we_matrix_remove_row': '_onRemoveRowClick',
+ 'blur we-matrix input': '_onMatrixInputFocusOut',
+ 'focus we-matrix input': '_onMatrixInputFocus',
+ }),
+
+ /**
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this.themeArray = ['o-color-1', 'o-color-2', 'o-color-3', 'o-color-4', 'o-color-5'];
+ this.style = window.getComputedStyle(document.documentElement);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.backSelectEl = this.el.querySelector('[data-name="chart_bg_color_opt"]');
+ this.borderSelectEl = this.el.querySelector('[data-name="chart_border_color_opt"]');
+
+ // Build matrix content
+ this.tableEl = this.el.querySelector('we-matrix table');
+ const data = JSON.parse(this.$target[0].dataset.data);
+ data.labels.forEach(el => {
+ this._addRow(el);
+ });
+ data.datasets.forEach((el, i) => {
+ if (this._isPieChart()) {
+ // Add header colors in case the user changes the type of graph
+ const headerBackgroundColor = this.themeArray[i] || this._randomColor();
+ const headerBorderColor = this.themeArray[i] || this._randomColor();
+ this._addColumn(el.label, el.data, headerBackgroundColor, headerBorderColor, el.backgroundColor, el.borderColor);
+ } else {
+ this._addColumn(el.label, el.data, el.backgroundColor, el.borderColor);
+ }
+ });
+ this._displayRemoveColButton();
+ this._displayRemoveRowButton();
+ this._setDefaultSelectedInput();
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ updateUI: async function () {
+ // Selected input might not be in dom anymore if col/row removed
+ // Done before _super because _computeWidgetState of colorChange
+ if (!this.lastEditableSelectedInput.closest('table') || this.colorPaletteSelectedInput && !this.colorPaletteSelectedInput.closest('table')) {
+ this._setDefaultSelectedInput();
+ }
+
+ await this._super(...arguments);
+
+ // prevent the columns from becoming too small.
+ this.tableEl.classList.toggle('o_we_matrix_five_col', this.tableEl.querySelectorAll('tr:first-child th').length > 5);
+
+ this.backSelectEl.querySelector('we-title').textContent = this._isPieChart() ? _t("Data Color") : _t("Dataset Color");
+ this.borderSelectEl.querySelector('we-title').textContent = this._isPieChart() ? _t("Data Border") : _t("Dataset Border");
+
+ // Dataset/Cell color
+ this.tableEl.querySelectorAll('input').forEach(el => el.style.border = '');
+ const selector = this._isPieChart() ? 'td input' : 'tr:first-child input';
+ this.tableEl.querySelectorAll(selector).forEach(el => {
+ const color = el.dataset.backgroundColor || el.dataset.borderColor;
+ if (color) {
+ el.style.border = '2px solid';
+ el.style.borderColor = ColorpickerWidget.isCSSColor(color) ? color : weUtils.getCSSVariableValue(color, this.style);
+ }
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Set the color on the selected input.
+ */
+ colorChange: async function (previewMode, widgetValue, params) {
+ if (widgetValue) {
+ this.colorPaletteSelectedInput.dataset[params.attributeName] = widgetValue;
+ } else {
+ delete this.colorPaletteSelectedInput.dataset[params.attributeName];
+ }
+ await this._reloadGraph();
+ // To focus back the input that is edited we have to wait for the color
+ // picker to be fully reloaded.
+ await new Promise(resolve => setTimeout(() => {
+ this.lastEditableSelectedInput.focus();
+ resolve();
+ }));
+ },
+ /**
+ * @override
+ */
+ selectDataAttribute: async function (previewMode, widgetValue, params) {
+ await this._super(...arguments);
+ // Data might change if going from or to a pieChart.
+ if (params.attributeName === 'type') {
+ this._setDefaultSelectedInput();
+ await this._reloadGraph();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState: function (methodName, params) {
+ if (methodName === 'colorChange') {
+ return this.colorPaletteSelectedInput && this.colorPaletteSelectedInput.dataset[params.attributeName] || '';
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ _computeWidgetVisibility: function (widgetName, params) {
+ switch (widgetName) {
+ case 'stacked_chart_opt': {
+ return this._getColumnCount() > 1;
+ }
+ case 'chart_bg_color_opt':
+ case 'chart_border_color_opt': {
+ return !!this.colorPaletteSelectedInput;
+ }
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * Sets and reloads the data on the canvas if it has changed.
+ * Used in matrix related method.
+ *
+ * @private
+ */
+ _reloadGraph: async function () {
+ const jsonValue = this._matrixToChartData();
+ if (this.$target[0].dataset.data !== jsonValue) {
+ this.$target[0].dataset.data = jsonValue;
+ await this._refreshPublicWidgets();
+ }
+ },
+ /**
+ * Return a stringifyed chart.js data object from the matrix
+ * Pie charts have one color per data while other charts have one color per dataset.
+ *
+ * @private
+ */
+ _matrixToChartData: function () {
+ const data = {
+ labels: [],
+ datasets: [],
+ };
+ this.tableEl.querySelectorAll('tr:first-child input').forEach(el => {
+ data.datasets.push({
+ label: el.value || '',
+ data: [],
+ backgroundColor: this._isPieChart() ? [] : el.dataset.backgroundColor || '',
+ borderColor: this._isPieChart() ? [] : el.dataset.borderColor || '',
+ });
+ });
+ this.tableEl.querySelectorAll('tr:not(:first-child):not(:last-child)').forEach((el) => {
+ const title = el.querySelector('th input').value || '';
+ data.labels.push(title);
+ el.querySelectorAll('td input').forEach((el, i) => {
+ data.datasets[i].data.push(el.value || 0);
+ if (this._isPieChart()) {
+ data.datasets[i].backgroundColor.push(el.dataset.backgroundColor || '');
+ data.datasets[i].borderColor.push(el.dataset.borderColor || '');
+ }
+ });
+ });
+ return JSON.stringify(data);
+ },
+ /**
+ * Return a td containing a we-button with minus icon
+ *
+ * @param {...string} classes Classes to add to the we-button
+ * @returns {HTMLElement}
+ */
+ _makeDeleteButton: function (...classes) {
+ const rmbuttonEl = options.buildElement('we-button', null, {
+ classes: ['o_we_text_danger', 'o_we_link', 'fa', 'fa-fw', 'fa-minus', ...classes],
+ });
+ const newEl = document.createElement('td');
+ newEl.appendChild(rmbuttonEl);
+ return newEl;
+ },
+ /**
+ * Add a column to the matrix
+ * The th (dataset label) of a column hold the colors for the entire dataset if the graph is not a pie chart
+ * If the graph is a pie chart the color of the td (data) are used.
+ *
+ * @private
+ * @param {String} title The title of the column
+ * @param {Array} values The values of the column input
+ * @param {String} heardeBackgroundColor The background color of the dataset
+ * @param {String} headerBorderColor The border color of the dataset
+ * @param {string[]} cellBackgroundColors The background colors of the datas inputs, random color if missing
+ * @param {string[]} cellBorderColors The border color of the datas inputs, no color if missing
+ */
+ _addColumn: function (title, values, heardeBackgroundColor, headerBorderColor, cellBackgroundColors = [], cellBorderColors = []) {
+ const firstRow = this.tableEl.querySelector('tr:first-child');
+ const headerInput = this._makeCell('th', title, heardeBackgroundColor, headerBorderColor);
+ firstRow.insertBefore(headerInput, firstRow.lastElementChild);
+
+ this.tableEl.querySelectorAll('tr:not(:first-child):not(:last-child)').forEach((el, i) => {
+ const newCell = this._makeCell('td', values ? values[i] : null, cellBackgroundColors[i] || this._randomColor(), cellBorderColors[i - 1]);
+ el.insertBefore(newCell, el.lastElementChild);
+ });
+
+ const lastRow = this.tableEl.querySelector('tr:last-child');
+ const removeButton = this._makeDeleteButton('o_we_matrix_remove_col');
+ lastRow.appendChild(removeButton);
+ },
+ /**
+ * Add a row to the matrix
+ * The background color of the datas are random
+ *
+ * @private
+ * @param {String} tilte The title of the row
+ */
+ _addRow: function (tilte) {
+ const trEl = document.createElement('tr');
+ trEl.appendChild(this._makeCell('th', tilte));
+ this.tableEl.querySelectorAll('tr:first-child input').forEach(() => {
+ trEl.appendChild(this._makeCell('td', null, this._randomColor()));
+ });
+ trEl.appendChild(this._makeDeleteButton('o_we_matrix_remove_row'));
+ const tbody = this.tableEl.querySelector('tbody');
+ tbody.insertBefore(trEl, tbody.lastElementChild);
+ },
+ /**
+ * @private
+ * @param {string} tag tag of the HTML Element (td/th)
+ * @param {string} value The current value of the cell input
+ * @param {string} backgroundColor The background Color of the data on the graph
+ * @param {string} borderColor The border Color of the the data on the graph
+ * @returns {HTMLElement}
+ */
+ _makeCell: function (tag, value, backgroundColor, borderColor) {
+ const newEl = document.createElement(tag);
+ const contentEl = document.createElement('input');
+ contentEl.type = 'text';
+ contentEl.value = value || '';
+ if (backgroundColor) {
+ contentEl.dataset.backgroundColor = backgroundColor;
+ }
+ if (borderColor) {
+ contentEl.dataset.borderColor = borderColor;
+ }
+ newEl.appendChild(contentEl);
+ return newEl;
+ },
+ /**
+ * Display the remove button coresponding to the colIndex
+ *
+ * @private
+ * @param {Int} colIndex Can be undefined, if so the last remove button of the column will be shown
+ */
+ _displayRemoveColButton: function (colIndex) {
+ if (this._getColumnCount() > 1) {
+ this._displayRemoveButton(colIndex, 'o_we_matrix_remove_col');
+ }
+ },
+ /**
+ * Display the remove button coresponding to the rowIndex
+ *
+ * @private
+ * @param {Int} rowIndex Can be undefined, if so the last remove button of the row will be shown
+ */
+ _displayRemoveRowButton: function (rowIndex) {
+ //Nbr of row minus header and button
+ const rowCount = this.tableEl.rows.length - 2;
+ if (rowCount > 1) {
+ this._displayRemoveButton(rowIndex, 'o_we_matrix_remove_row');
+ }
+ },
+ /**
+ * @private
+ * @param {Int} tdIndex Can be undefined, if so the last remove button will be shown
+ * @param {String} btnClass Either o_we_matrix_remove_col or o_we_matrix_remove_row
+ */
+ _displayRemoveButton: function (tdIndex, btnClass) {
+ const removeBtn = this.tableEl.querySelectorAll(`td we-button.${btnClass}`);
+ removeBtn.forEach(el => el.style.display = ''); //hide all
+ const index = tdIndex < removeBtn.length ? tdIndex : removeBtn.length - 1;
+ removeBtn[index].style.display = 'inline-block';
+ },
+ /**
+ * @private
+ * @return {boolean}
+ */
+ _isPieChart: function () {
+ return ['pie', 'doughnut'].includes(this.$target[0].dataset.type);
+ },
+ /**
+ * Return the number of column minus header and button
+ * @private
+ * @return {integer}
+ */
+ _getColumnCount: function () {
+ return this.tableEl.rows[0].cells.length - 2;
+ },
+ /**
+ * Select the first data input
+ *
+ * @private
+ */
+ _setDefaultSelectedInput: function () {
+ this.lastEditableSelectedInput = this.tableEl.querySelector('td input');
+ if (this._isPieChart()) {
+ this.colorPaletteSelectedInput = this.lastEditableSelectedInput;
+ } else {
+ this.colorPaletteSelectedInput = this.tableEl.querySelector('th input');
+ }
+ },
+ /**
+ * Return a random hexadecimal color.
+ *
+ * @private
+ * @return {string}
+ */
+ _randomColor: function () {
+ return '#' + ('00000' + (Math.random() * (1 << 24) | 0).toString(16)).slice(-6).toUpperCase();
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Used by colorPalette to retrieve the custom colors used on the chart
+ * Make an array with all the custom colors used on the chart
+ * and apply it to the onSuccess method provided by the trigger_up.
+ *
+ * @private
+ */
+ _onGetCustomColors: function (ev) {
+ const data = JSON.parse(this.$target[0].dataset.data || '');
+ let customColors = [];
+ data.datasets.forEach(el => {
+ if (this._isPieChart()) {
+ customColors = customColors.concat(el.backgroundColor).concat(el.borderColor);
+ } else {
+ customColors.push(el.backgroundColor);
+ customColors.push(el.borderColor);
+ }
+ });
+ customColors = customColors.filter((el, i, array) => {
+ return !weUtils.getCSSVariableValue(el, this.style) && array.indexOf(el) === i && el !== ''; // unique non class not transparent
+ });
+ ev.data.onSuccess(customColors);
+ },
+ /**
+ * Add a row at the end of the matrix and display it's remove button
+ * Choose the color of the column from the theme array or a random color if they are already used
+ *
+ * @private
+ */
+ _onAddColumnClick: function () {
+ const usedColor = Array.from(this.tableEl.querySelectorAll('tr:first-child input')).map(el => el.dataset.backgroundColor);
+ const color = this.themeArray.filter(el => !usedColor.includes(el))[0] || this._randomColor();
+ this._addColumn(null, null, color, color);
+ this._reloadGraph().then(() => {
+ this._displayRemoveColButton();
+ this.updateUI();
+ });
+ },
+ /**
+ * Add a column at the end of the matrix and display it's remove button
+ *
+ * @private
+ */
+ _onAddRowClick: function () {
+ this._addRow();
+ this._reloadGraph().then(() => {
+ this._displayRemoveRowButton();
+ this.updateUI();
+ });
+ },
+ /**
+ * Remove the column and show the remove button of the next column or the last if no next.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onRemoveColumnClick: function (ev) {
+ const cell = ev.currentTarget.parentElement;
+ const cellIndex = cell.cellIndex;
+ this.tableEl.querySelectorAll('tr').forEach((el) => {
+ el.children[cellIndex].remove();
+ });
+ this._displayRemoveColButton(cellIndex - 1);
+ this._reloadGraph().then(() => {
+ this.updateUI();
+ });
+ },
+ /**
+ * Remove the row and show the remove button of the next row or the last if no next.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onRemoveRowClick: function (ev) {
+ const row = ev.currentTarget.parentElement.parentElement;
+ const rowIndex = row.rowIndex;
+ row.remove();
+ this._displayRemoveRowButton(rowIndex - 1);
+ this._reloadGraph().then(() => {
+ this.updateUI();
+ });
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onMatrixInputFocusOut: function (ev) {
+ // Sometimes, an input is focusout for internal reason (like an undo
+ // recording) then focused again manually in the same JS stack
+ // execution. In that case, the blur should not trigger an option
+ // selection as the user did not leave the input. We thus defer the blur
+ // handling to then check that the target is indeed still blurred before
+ // executing the actual option selection.
+ setTimeout(() => {
+ if (ev.currentTarget === document.activeElement) {
+ return;
+ }
+ this._reloadGraph();
+ });
+ },
+ /**
+ * Set the selected cell/header and display the related remove button
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onMatrixInputFocus: function (ev) {
+ this.lastEditableSelectedInput = ev.target;
+ const col = ev.target.parentElement.cellIndex;
+ const row = ev.target.parentElement.parentElement.rowIndex;
+ if (this._isPieChart()) {
+ this.colorPaletteSelectedInput = ev.target.parentNode.tagName === 'TD' ? ev.target : null;
+ } else {
+ this.colorPaletteSelectedInput = this.tableEl.querySelector(`tr:first-child th:nth-of-type(${col + 1}) input`);
+ }
+ if (col > 0) {
+ this._displayRemoveColButton(col - 1);
+ }
+ if (row > 0) {
+ this._displayRemoveRowButton(row - 1);
+ }
+ this.updateUI();
+ },
+});
+});
diff --git a/addons/website/static/src/snippets/s_color_blocks_2/000.scss b/addons/website/static/src/snippets/s_color_blocks_2/000.scss
new file mode 100644
index 00000000..555cab58
--- /dev/null
+++ b/addons/website/static/src/snippets/s_color_blocks_2/000.scss
@@ -0,0 +1,36 @@
+.s_color_blocks_2 {
+ // Needed to be able to stretch the inner container so that
+ // the snippet works with the 50% and 100% height
+ &.o_half_screen_height, &.o_full_screen_height {
+ > :first-child { // container
+ &, > .row {
+ min-height: inherit;
+ }
+ }
+ }
+ .row {
+ display: flex;
+ flex-flow: row wrap;
+
+ // Fix for safari browser as it 'supports' flex but not with the right
+ // behavior
+ &::before, &::after {
+ width: 0;
+ }
+ }
+ [class*="col-lg-"] {
+ padding: 8% 5%;
+ padding-top: 8vw; // A flex item cannot have % padding top and bottom (even if it works on chrome)
+ padding-bottom: 8vw; // Solution is vw units but we keep 8% as a fallback
+ }
+ @include media-breakpoint-down(md) {
+ [class*="col-lg-"] {
+ flex: 1 1 100%;
+ }
+ }
+
+ img {
+ max-width: 100%;
+ height: auto;
+ }
+}
diff --git a/addons/website/static/src/snippets/s_company_team/000.scss b/addons/website/static/src/snippets/s_company_team/000.scss
new file mode 100644
index 00000000..7947b831
--- /dev/null
+++ b/addons/website/static/src/snippets/s_company_team/000.scss
@@ -0,0 +1,8 @@
+
+.s_company_team {
+ @include media-breakpoint-down(md) {
+ img {
+ max-width: 50%;
+ }
+ }
+}
diff --git a/addons/website/static/src/snippets/s_comparisons/000.scss b/addons/website/static/src/snippets/s_comparisons/000.scss
new file mode 100644
index 00000000..cf1e9218
--- /dev/null
+++ b/addons/website/static/src/snippets/s_comparisons/000.scss
@@ -0,0 +1,21 @@
+
+.s_comparisons {
+ .card-body {
+ .card-title {
+ margin: 0;
+ }
+ .s_comparisons_currency,
+ .s_comparisons_price,
+ .s_comparisons_decimal {
+ display: inline-block;
+ vertical-align: middle;
+ }
+ .s_comparisons_currency,
+ .s_comparisons_decimal {
+ font-size: 80%;
+ }
+ .s_comparisons_price {
+ font-size: 200%;
+ }
+ }
+}
diff --git a/addons/website/static/src/snippets/s_countdown/000.js b/addons/website/static/src/snippets/s_countdown/000.js
new file mode 100644
index 00000000..fcdac7b7
--- /dev/null
+++ b/addons/website/static/src/snippets/s_countdown/000.js
@@ -0,0 +1,422 @@
+odoo.define('website.s_countdown', function (require) {
+'use strict';
+
+const {ColorpickerWidget} = require('web.Colorpicker');
+const core = require('web.core');
+const publicWidget = require('web.public.widget');
+const weUtils = require('web_editor.utils');
+
+const qweb = core.qweb;
+const _t = core._t;
+
+const CountdownWidget = publicWidget.Widget.extend({
+ selector: '.s_countdown',
+ xmlDependencies: ['/website/static/src/snippets/s_countdown/000.xml'],
+ disabledInEditableMode: false,
+ defaultColor: 'rgba(0, 0, 0, 255)',
+
+ /**
+ * @override
+ */
+ start: function () {
+ this.$wrapper = this.$('.s_countdown_canvas_wrapper');
+ this.hereBeforeTimerEnds = false;
+ this.endAction = this.el.dataset.endAction;
+ this.endTime = parseInt(this.el.dataset.endTime);
+ this.size = parseInt(this.el.dataset.size);
+ this.display = this.el.dataset.display;
+
+ this.layout = this.el.dataset.layout;
+ this.layoutBackground = this.el.dataset.layoutBackground;
+ this.progressBarStyle = this.el.dataset.progressBarStyle;
+ this.progressBarWeight = this.el.dataset.progressBarWeight;
+
+ this.textColor = this._ensureCssColor(this.el.dataset.textColor);
+ this.layoutBackgroundColor = this._ensureCssColor(this.el.dataset.layoutBackgroundColor);
+ this.progressBarColor = this._ensureCssColor(this.el.dataset.progressBarColor);
+
+ this.onlyOneUnit = this.display === 'd';
+ this.width = parseInt(this.size);
+ if (this.layout === 'boxes') {
+ this.width /= 1.75;
+ }
+ this._initTimeDiff();
+
+ this._render();
+
+ this.setInterval = setInterval(this._render.bind(this), 1000);
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this.$('.s_countdown_end_redirect_message').remove();
+ this.$('canvas').remove();
+ this.$('.s_countdown_end_message').addClass('d-none');
+ this.$('.s_countdown_text_wrapper').remove();
+ this.$('.s_countdown_canvas_wrapper').removeClass('d-none');
+
+ clearInterval(this.setInterval);
+ this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Ensures the color is an actual css color. In case of a color variable,
+ * the color will be mapped to hexa.
+ *
+ * @private
+ * @param {string} color
+ * @returns {string}
+ */
+ _ensureCssColor: function (color) {
+ if (ColorpickerWidget.isCSSColor(color)) {
+ return color;
+ }
+ return weUtils.getCSSVariableValue(color) || this.defaultColor;
+ },
+ /**
+ * Gets the time difference in seconds between now and countdown due date.
+ *
+ * @private
+ */
+ _getDelta: function () {
+ const currentTimestamp = Date.now() / 1000;
+ return this.endTime - currentTimestamp;
+ },
+ /**
+ * Handles the action that should be executed once the countdown ends.
+ *
+ * @private
+ */
+ _handleEndCountdownAction: function () {
+ if (this.endAction === 'redirect') {
+ const redirectUrl = this.el.dataset.redirectUrl || '/';
+ if (this.hereBeforeTimerEnds) {
+ // Wait a bit, if the landing page has the same publish date
+ setTimeout(() => window.location = redirectUrl, 500);
+ } else {
+ // Show (non editable) msg when user lands on already finished countdown
+ if (!this.$('.s_countdown_end_redirect_message').length) {
+ const $container = this.$('> .container, > .container-fluid, > .o_container_small');
+ $container.append(
+ $(qweb.render('website.s_countdown.end_redirect_message', {
+ redirectUrl: redirectUrl,
+ }))
+ );
+ }
+ }
+ } else if (this.endAction === 'message') {
+ this.$('.s_countdown_end_message').removeClass('d-none');
+ }
+ },
+ /**
+ * Initializes the `diff` object. It will contains every visible time unit
+ * which will each contain its related canvas, total step, label..
+ *
+ * @private
+ */
+ _initTimeDiff: function () {
+ const delta = this._getDelta();
+ this.diff = [];
+ if (this._isUnitVisible('d') && !(this.onlyOneUnit && delta < 86400)) {
+ this.diff.push({
+ canvas: $('<canvas/>', {class: 'o_temp_auto_element'}).appendTo(this.$wrapper)[0],
+ // There is no logical number of unit (total) on which day units
+ // can be compared against, so we use an arbitrary number.
+ total: 15,
+ label: _t("Days"),
+ nbSeconds: 86400,
+ });
+ }
+ if (this._isUnitVisible('h') || (this.onlyOneUnit && delta < 86400 && delta > 3600)) {
+ this.diff.push({
+ canvas: $('<canvas/>', {class: 'o_temp_auto_element'}).appendTo(this.$wrapper)[0],
+ total: 24,
+ label: _t("Hours"),
+ nbSeconds: 3600,
+ });
+ }
+ if (this._isUnitVisible('m') || (this.onlyOneUnit && delta < 3600 && delta > 60)) {
+ this.diff.push({
+ canvas: $('<canvas/>', {class: 'o_temp_auto_element'}).appendTo(this.$wrapper)[0],
+ total: 60,
+ label: _t("Minutes"),
+ nbSeconds: 60,
+ });
+ }
+ if (this._isUnitVisible('s') || (this.onlyOneUnit && delta < 60)) {
+ this.diff.push({
+ canvas: $('<canvas/>', {class: 'o_temp_auto_element'}).appendTo(this.$wrapper)[0],
+ total: 60,
+ label: _t("Seconds"),
+ nbSeconds: 1,
+ });
+ }
+ },
+ /**
+ * Returns weither or not the countdown should be displayed for the given
+ * unit (days, sec..).
+ *
+ * @private
+ * @param {string} unit - either 'd', 'm', 'h', or 's'
+ * @returns {boolean}
+ */
+ _isUnitVisible: function (unit) {
+ return this.display.includes(unit);
+ },
+ /**
+ * Draws the whole countdown, including one countdown for each time unit.
+ *
+ * @private
+ */
+ _render: function () {
+ // If only one unit mode, restart widget on unit change to populate diff
+ if (this.onlyOneUnit && this._getDelta() < this.diff[0].nbSeconds) {
+ this.$('canvas').remove();
+ this._initTimeDiff();
+ }
+ this._updateTimeDiff();
+
+ const hideCountdown = this.isFinished && !this.editableMode && this.$el.hasClass('hide-countdown');
+ if (this.layout === 'text') {
+ this.$('canvas').addClass('d-none');
+ if (!this.$textWrapper) {
+ this.$textWrapper = $('<span/>').attr({
+ class: 's_countdown_text_wrapper d-none',
+ });
+ this.$textWrapper.text(_t("Countdown ends in"));
+ this.$textWrapper.append($('<span/>').attr({
+ class: 's_countdown_text ml-1',
+ }));
+ this.$textWrapper.appendTo(this.$wrapper);
+ }
+
+ this.$textWrapper.toggleClass('d-none', hideCountdown);
+
+ const countdownText = this.diff.map(e => e.nb + ' ' + e.label).join(', ');
+ this.$('.s_countdown_text').text(countdownText.toLowerCase());
+ } else {
+ for (const val of this.diff) {
+ const canvas = val.canvas;
+ const ctx = canvas.getContext("2d");
+ ctx.canvas.width = this.width;
+ ctx.canvas.height = this.size;
+ this._clearCanvas(ctx);
+
+ $(canvas).toggleClass('d-none', hideCountdown);
+ if (hideCountdown) {
+ continue;
+ }
+
+ // Draw canvas elements
+ if (this.layoutBackground !== 'none') {
+ this._drawBgShape(ctx, this.layoutBackground === 'plain');
+ }
+ this._drawText(canvas, val.nb, val.label, this.layoutBackground === 'plain');
+ if (this.progressBarStyle === 'surrounded') {
+ this._drawProgressBarBg(ctx, this.progressBarWeight === 'thin');
+ }
+ if (this.progressBarStyle !== 'none') {
+ this._drawProgressBar(ctx, val.nb, val.total, this.progressBarWeight === 'thin');
+ }
+ $(canvas).toggleClass('mx-2', this.layout === 'boxes');
+ }
+ }
+
+ if (this.isFinished) {
+ clearInterval(this.setInterval);
+ if (!this.editableMode) {
+ this._handleEndCountdownAction();
+ }
+ }
+ },
+ /**
+ * Updates the remaining units into the `diff` object.
+ *
+ * @private
+ */
+ _updateTimeDiff: function () {
+ let delta = this._getDelta();
+ this.isFinished = delta < 0;
+ if (this.isFinished) {
+ for (const unitData of this.diff) {
+ unitData.nb = 0;
+ }
+ return;
+ }
+
+ this.hereBeforeTimerEnds = true;
+ for (const unitData of this.diff) {
+ unitData.nb = Math.floor(delta / unitData.nbSeconds);
+ delta -= unitData.nb * unitData.nbSeconds;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Canvas drawing methods
+ //--------------------------------------------------------------------------
+
+ /**
+ * Erases the canvas.
+ *
+ * @private
+ * @param {RenderingContext} ctx - Context of the canvas
+ */
+ _clearCanvas: function (ctx) {
+ ctx.clearRect(0, 0, this.size, this.size);
+ },
+ /**
+ * Draws a text into the canvas.
+ *
+ * @private
+ * @param {HTMLCanvasElement} canvas
+ * @param {string} textNb - text to display in the center of the canvas, in big
+ * @param {string} textUnit - text to display bellow `textNb` in small
+ * @param {boolean} full - if true, the shape will be drawn up to the progressbar
+ */
+ _drawText: function (canvas, textNb, textUnit, full = false) {
+ const ctx = canvas.getContext("2d");
+ const nbSize = this.size / 4;
+ ctx.font = `${nbSize}px Arial`;
+ ctx.fillStyle = this.textColor;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(textNb, canvas.width / 2, canvas.height / 2);
+
+ const unitSize = this.size / 12;
+ ctx.font = `${unitSize}px Arial`;
+ ctx.fillText(textUnit, canvas.width / 2, canvas.height / 2 + nbSize / 1.5, this.width);
+
+ if (this.layout === 'boxes' && this.layoutBackground !== 'none' && this.progressBarStyle === 'none') {
+ let barWidth = this.size / (this.progressBarWeight === 'thin' ? 31 : 10);
+ if (full) {
+ barWidth = 0;
+ }
+ ctx.beginPath();
+ ctx.moveTo(barWidth, this.size / 2);
+ ctx.lineTo(this.width - barWidth, this.size / 2);
+ ctx.stroke();
+ }
+ },
+ /**
+ * Draws a plain shape into the canvas.
+ *
+ * @private
+ * @param {RenderingContext} ctx - Context of the canvas
+ * @param {boolean} full - if true, the shape will be drawn up to the progressbar
+ */
+ _drawBgShape: function (ctx, full = false) {
+ ctx.fillStyle = this.layoutBackgroundColor;
+ ctx.beginPath();
+ if (this.layout === 'circle') {
+ let rayon = this.size / 2;
+ if (this.progressBarWeight === 'thin') {
+ rayon -= full ? this.size / 29 : this.size / 15;
+ } else {
+ rayon -= full ? 0 : this.size / 10;
+ }
+ ctx.arc(this.size / 2, this.size / 2, rayon, 0, Math.PI * 2);
+ ctx.fill();
+ } else if (this.layout === 'boxes') {
+ let barWidth = this.size / (this.progressBarWeight === 'thin' ? 31 : 10);
+ if (full) {
+ barWidth = 0;
+ }
+
+ ctx.fillStyle = this.layoutBackgroundColor;
+ ctx.rect(barWidth, barWidth, this.width - barWidth * 2, this.size - barWidth * 2);
+ ctx.fill();
+
+ const gradient = ctx.createLinearGradient(0, this.width, 0, 0);
+ gradient.addColorStop(0, '#ffffff24');
+ gradient.addColorStop(1, this.layoutBackgroundColor);
+ ctx.fillStyle = gradient;
+ ctx.rect(barWidth, barWidth, this.width - barWidth * 2, this.size - barWidth * 2);
+ ctx.fill();
+ $(ctx.canvas).css({'border-radius': '8px'});
+ }
+ },
+ /**
+ * Draws a progress bar around the countdown shape.
+ *
+ * @private
+ * @param {RenderingContext} ctx - Context of the canvas
+ * @param {string} nbUnit - how many unit should fill progress bar
+ * @param {string} totalUnit - number of unit to do a complete progress bar
+ * @param {boolean} thinLine - if true, the progress bar will be thiner
+ */
+ _drawProgressBar: function (ctx, nbUnit, totalUnit, thinLine) {
+ ctx.strokeStyle = this.progressBarColor;
+ ctx.lineWidth = thinLine ? this.size / 35 : this.size / 10;
+ if (this.layout === 'circle') {
+ ctx.beginPath();
+ ctx.arc(this.size / 2, this.size / 2, this.size / 2 - this.size / 20, Math.PI / -2, (Math.PI * 2) * (nbUnit / totalUnit) + (Math.PI / -2));
+ ctx.stroke();
+ } else if (this.layout === 'boxes') {
+ ctx.lineWidth *= 2;
+ let pc = nbUnit / totalUnit * 100;
+
+ // Lines: Top(x1,y1,x2,y2) Right(x1,y1,x2,y2) Bottom(x1,y1,x2,y2) Left(x1,y1,x2,y2)
+ const linesCoordFuncs = [
+ (linePc) => [0 + ctx.lineWidth / 2, 0, (this.width - ctx.lineWidth / 2) * linePc / 25 + ctx.lineWidth / 2, 0],
+ (linePc) => [this.width, 0 + ctx.lineWidth / 2, this.width, (this.size - ctx.lineWidth / 2) * linePc / 25 + ctx.lineWidth / 2],
+ (linePc) => [this.width - ((this.width - ctx.lineWidth / 2) * linePc / 25) - ctx.lineWidth / 2, this.size, this.width - ctx.lineWidth / 2, this.size],
+ (linePc) => [0, this.size - ((this.size - ctx.lineWidth / 2) * linePc / 25) - ctx.lineWidth / 2, 0, this.size - ctx.lineWidth / 2],
+ ];
+ while (pc > 0 && linesCoordFuncs.length) {
+ const linePc = Math.min(pc, 25);
+ const lineCoord = (linesCoordFuncs.shift())(linePc);
+ ctx.beginPath();
+ ctx.moveTo(lineCoord[0], lineCoord[1]);
+ ctx.lineTo(lineCoord[2], lineCoord[3]);
+ ctx.stroke();
+ pc -= linePc;
+ }
+ }
+ },
+ /**
+ * Draws a full lighter background progressbar around the shape.
+ *
+ * @private
+ * @param {RenderingContext} ctx - Context of the canvas
+ * @param {boolean} thinLine - if true, the progress bar will be thiner
+ */
+ _drawProgressBarBg: function (ctx, thinLine) {
+ ctx.strokeStyle = this.progressBarColor;
+ ctx.globalAlpha = 0.2;
+ ctx.lineWidth = thinLine ? this.size / 35 : this.size / 10;
+ if (this.layout === 'circle') {
+ ctx.beginPath();
+ ctx.arc(this.size / 2, this.size / 2, this.size / 2 - this.size / 20, 0, Math.PI * 2);
+ ctx.stroke();
+ } else if (this.layout === 'boxes') {
+ ctx.lineWidth *= 2;
+
+ // Lines: Top(x1,y1,x2,y2) Right(x1,y1,x2,y2) Bottom(x1,y1,x2,y2) Left(x1,y1,x2,y2)
+ const points = [
+ [0 + ctx.lineWidth / 2, 0, this.width, 0],
+ [this.width, 0 + ctx.lineWidth / 2, this.width, this.size],
+ [0, this.size, this.width - ctx.lineWidth / 2, this.size],
+ [0, 0, 0, this.size - ctx.lineWidth / 2],
+ ];
+ while (points.length) {
+ const point = points.shift();
+ ctx.beginPath();
+ ctx.moveTo(point[0], point[1]);
+ ctx.lineTo(point[2], point[3]);
+ ctx.stroke();
+ }
+ }
+ ctx.globalAlpha = 1;
+ },
+});
+
+publicWidget.registry.countdown = CountdownWidget;
+
+return CountdownWidget;
+});
diff --git a/addons/website/static/src/snippets/s_countdown/000.xml b/addons/website/static/src/snippets/s_countdown/000.xml
new file mode 100644
index 00000000..07905447
--- /dev/null
+++ b/addons/website/static/src/snippets/s_countdown/000.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+ <t t-name="website.s_countdown.end_redirect_message">
+ <p class="text-center s_countdown_end_redirect_message">Time's up! You can now visit <a class="s_countdown_end_redirect_url" t-attf-href="#{redirectUrl}">this page</a>.</p>
+ </t>
+ <t t-name="website.s_countdown.end_message">
+ <div class="s_countdown_end_message d-none">
+ <div class="text-center alert alert-info css_non_editable_mode_hidden o_not_editable" t-ignore="True" role="status">
+ The following message will become visible <strong>only</strong> once the countdown ends.
+ </div>
+ <div class="oe_structure">
+ <section class="s_picture bg-200 pt48 pb24" data-snippet="s_picture">
+ <div class="container">
+ <h2 style="text-align: center;">Happy Odoo Anniversary!</h2>
+ <p style="text-align: center;">As promised, we will offer 4 free tickets to our next summit.<br/>Visit our Facebook page to know if you are one of the lucky winners.</p>
+ <p><br/></p>
+ <div class="row s_nb_column_fixed">
+ <div class="col-lg-12 pb24">
+ <figure class="figure">
+ <img src="/web/image/website.library_image_18" class="figure-img img-thumbnail mx-auto padding-large" style="width: 50%;" alt="Countdown is over - Firework"/>
+ </figure>
+ </div>
+ </div>
+ </div>
+ </section>
+ </div>
+ </div>
+ </t>
+</templates>
diff --git a/addons/website/static/src/snippets/s_countdown/options.js b/addons/website/static/src/snippets/s_countdown/options.js
new file mode 100644
index 00000000..ee99e0a8
--- /dev/null
+++ b/addons/website/static/src/snippets/s_countdown/options.js
@@ -0,0 +1,135 @@
+odoo.define('website.s_countdown_options', function (require) {
+'use strict';
+
+const core = require('web.core');
+const options = require('web_editor.snippets.options');
+const CountdownWidget = require('website.s_countdown');
+
+const qweb = core.qweb;
+
+options.registry.countdown = options.Class.extend({
+ events: _.extend({}, options.Class.prototype.events || {}, {
+ 'click .toggle-edit-message': '_onToggleEndMessageClick',
+ }),
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Changes the countdown action at zero.
+ *
+ * @see this.selectClass for parameters
+ */
+ endAction: function (previewMode, widgetValue, params) {
+ this.$target[0].dataset.endAction = widgetValue;
+ if (widgetValue === 'message') {
+ if (!this.$target.find('.s_countdown_end_message').length) {
+ const message = this.endMessage || qweb.render('website.s_countdown.end_message');
+ this.$target.append(message);
+ }
+ } else {
+ const $message = this.$target.find('.s_countdown_end_message').detach();
+ if ($message.length) {
+ this.endMessage = $message[0].outerHTML;
+ }
+ }
+ },
+ /**
+ * Changes the countdown style.
+ *
+ * @see this.selectClass for parameters
+ */
+ layout: function (previewMode, widgetValue, params) {
+ switch (widgetValue) {
+ case 'circle':
+ this.$target[0].dataset.progressBarStyle = 'disappear';
+ this.$target[0].dataset.progressBarWeight = 'thin';
+ this.$target[0].dataset.layoutBackground = 'none';
+ break;
+ case 'boxes':
+ this.$target[0].dataset.progressBarStyle = 'none';
+ this.$target[0].dataset.layoutBackground = 'plain';
+ break;
+ case 'clean':
+ this.$target[0].dataset.progressBarStyle = 'none';
+ this.$target[0].dataset.layoutBackground = 'none';
+ break;
+ case 'text':
+ this.$target[0].dataset.progressBarStyle = 'none';
+ this.$target[0].dataset.layoutBackground = 'none';
+ break;
+ }
+ this.$target[0].dataset.layout = widgetValue;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ updateUIVisibility: async function () {
+ await this._super(...arguments);
+ const dataset = this.$target[0].dataset;
+
+ // End Action UI
+ this.$el.find('.toggle-edit-message')
+ .toggleClass('d-none', dataset.endAction !== 'message');
+
+ // End Message UI
+ this.updateUIEndMessage();
+ },
+ /**
+ * @see this.updateUI
+ */
+ updateUIEndMessage: function () {
+ this.$target.find('.s_countdown_canvas_wrapper')
+ .toggleClass("d-none", this.showEndMessage === true && this.$target.hasClass("hide-countdown"));
+ this.$target.find('.s_countdown_end_message')
+ .toggleClass("d-none", !this.showEndMessage);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState: function (methodName, params) {
+ switch (methodName) {
+ case 'endAction':
+ case 'layout':
+ return this.$target[0].dataset[methodName];
+
+ case 'selectDataAttribute': {
+ if (params.colorNames) {
+ // In this case, it is a colorpicker controlling a data
+ // value on the countdown: the default value is determined
+ // by the countdown public widget.
+ params.attributeDefaultValue = CountdownWidget.prototype.defaultColor;
+ }
+ break;
+ }
+ }
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onToggleEndMessageClick: function () {
+ this.showEndMessage = !this.showEndMessage;
+ this.$el.find(".toggle-edit-message")
+ .toggleClass('text-primary', this.showEndMessage);
+ this.updateUIEndMessage();
+ this.trigger_up('cover_update');
+ },
+});
+});
diff --git a/addons/website/static/src/snippets/s_dynamic_snippet/000.js b/addons/website/static/src/snippets/s_dynamic_snippet/000.js
new file mode 100644
index 00000000..d6a3e0ff
--- /dev/null
+++ b/addons/website/static/src/snippets/s_dynamic_snippet/000.js
@@ -0,0 +1,244 @@
+odoo.define('website.s_dynamic_snippet', function (require) {
+'use strict';
+
+const core = require('web.core');
+const config = require('web.config');
+const publicWidget = require('web.public.widget');
+
+const DynamicSnippet = publicWidget.Widget.extend({
+ selector: '.s_dynamic_snippet',
+ xmlDependencies: ['/website/static/src/snippets/s_dynamic_snippet/000.xml'],
+ read_events: {
+ 'click [data-url]': '_onCallToAction',
+ },
+ disabledInEditableMode: false,
+
+ /**
+ *
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ /**
+ * The dynamic filter data source data formatted with the chosen template.
+ * Can be accessed when overriding the _render_content() function in order to generate
+ * a new renderedContent from the original data.
+ *
+ * @type {*|jQuery.fn.init|jQuery|HTMLElement}
+ */
+ this.data = [];
+ this.renderedContent = '';
+ this.isDesplayedAsMobile = config.device.isMobile;
+ this.uniqueId = _.uniqueId('s_dynamic_snippet_');
+ this.template_key = 'website.s_dynamic_snippet.grid';
+ },
+ /**
+ *
+ * @override
+ */
+ willStart: function () {
+ return this._super.apply(this, arguments).then(
+ () => Promise.all([
+ this._fetchData(),
+ this._manageWarningMessageVisibility()
+ ])
+ );
+ },
+ /**
+ *
+ * @override
+ */
+ start: function () {
+ return this._super.apply(this, arguments)
+ .then(() => {
+ this._setupSizeChangedManagement(true);
+ this._render();
+ this._toggleVisibility(true);
+ });
+ },
+ /**
+ *
+ * @override
+ */
+ destroy: function () {
+ this._toggleVisibility(false);
+ this._setupSizeChangedManagement(false);
+ this._clearContent();
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ *
+ * @private
+ */
+ _clearContent: function () {
+ const $dynamicSnippetTemplate = this.$el.find('.dynamic_snippet_template');
+ if ($dynamicSnippetTemplate) {
+ $dynamicSnippetTemplate.html('');
+ }
+ },
+ /**
+ * Method to be overridden in child components if additional configuration elements
+ * are required in order to fetch data.
+ * @private
+ */
+ _isConfigComplete: function () {
+ return this.$el.get(0).dataset.filterId !== undefined && this.$el.get(0).dataset.templateKey !== undefined;
+ },
+ /**
+ * Method to be overridden in child components in order to provide a search
+ * domain if needed.
+ * @private
+ */
+ _getSearchDomain: function () {
+ return [];
+ },
+ /**
+ * Fetches the data.
+ * @private
+ */
+ _fetchData: function () {
+ if (this._isConfigComplete()) {
+ return this._rpc(
+ {
+ 'route': '/website/snippet/filters',
+ 'params': {
+ 'filter_id': parseInt(this.$el.get(0).dataset.filterId),
+ 'template_key': this.$el.get(0).dataset.templateKey,
+ 'limit': parseInt(this.$el.get(0).dataset.numberOfRecords),
+ 'search_domain': this._getSearchDomain()
+ },
+ })
+ .then(
+ (data) => {
+ this.data = data;
+ }
+ );
+ } else {
+ return new Promise((resolve) => {
+ this.data = [];
+ resolve();
+ });
+ }
+ },
+ /**
+ *
+ * @private
+ */
+ _mustMessageWarningBeHidden: function() {
+ return this._isConfigComplete() || !this.editableMode;
+ },
+ /**
+ *
+ * @private
+ */
+ _manageWarningMessageVisibility: async function () {
+ this.$el.find('.missing_option_warning').toggleClass(
+ 'd-none',
+ this._mustMessageWarningBeHidden()
+ );
+ },
+ /**
+ * Method to be overridden in child components in order to prepare content
+ * before rendering.
+ * @private
+ */
+ _prepareContent: function () {
+ if (this.$target[0].dataset.numberOfElements && this.$target[0].dataset.numberOfElementsSmallDevices) {
+ this.renderedContent = core.qweb.render(
+ this.template_key,
+ this._getQWebRenderOptions());
+ } else {
+ this.renderedContent = '';
+ }
+ },
+ /**
+ * Method to be overridden in child components in order to prepare QWeb
+ * options.
+ * @private
+ */
+ _getQWebRenderOptions: function () {
+ return {
+ chunkSize: parseInt(
+ config.device.isMobile
+ ? this.$target[0].dataset.numberOfElementsSmallDevices
+ : this.$target[0].dataset.numberOfElements
+ ),
+ data: this.data,
+ uniqueId: this.uniqueId
+ };
+ },
+ /**
+ *
+ * @private
+ */
+ _render: function () {
+ if (this.data.length) {
+ this._prepareContent();
+ } else {
+ this.renderedContent = '';
+ }
+ this._renderContent();
+ },
+ /**
+ *
+ * @private
+ */
+ _renderContent: function () {
+ this.$el.find('.dynamic_snippet_template').html(this.renderedContent);
+ },
+ /**
+ *
+ * @param {Boolean} enable
+ * @private
+ */
+ _setupSizeChangedManagement: function (enable) {
+ if (enable === true) {
+ config.device.bus.on('size_changed', this, this._onSizeChanged);
+ } else {
+ config.device.bus.off('size_changed', this, this._onSizeChanged);
+ }
+ },
+ /**
+ *
+ * @param visible
+ * @private
+ */
+ _toggleVisibility: function (visible) {
+ this.$el.toggleClass('d-none', !visible);
+ },
+
+ //------------------------------------- -------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Navigates to the call to action url.
+ * @private
+ */
+ _onCallToAction: function (ev) {
+ window.location = $(ev.currentTarget).attr('data-url');
+ },
+ /**
+ * Called when the size has reached a new bootstrap breakpoint.
+ *
+ * @private
+ * @param {number} size as Integer @see web.config.device.SIZES
+ */
+ _onSizeChanged: function (size) {
+ if (this.isDesplayedAsMobile !== config.device.isMobile) {
+ this.isDesplayedAsMobile = config.device.isMobile;
+ this._render();
+ }
+ },
+});
+
+publicWidget.registry.dynamic_snippet = DynamicSnippet;
+
+return DynamicSnippet;
+
+});
diff --git a/addons/website/static/src/snippets/s_dynamic_snippet/000.scss b/addons/website/static/src/snippets/s_dynamic_snippet/000.scss
new file mode 100644
index 00000000..536aba85
--- /dev/null
+++ b/addons/website/static/src/snippets/s_dynamic_snippet/000.scss
@@ -0,0 +1,11 @@
+.s_dynamic {
+ [data-url] {
+ cursor: pointer;
+ }
+ .card-img-top {
+ height: 12rem;
+ }
+ img {
+ object-fit: scale-down;
+ }
+}
diff --git a/addons/website/static/src/snippets/s_dynamic_snippet/000.xml b/addons/website/static/src/snippets/s_dynamic_snippet/000.xml
new file mode 100644
index 00000000..105078e0
--- /dev/null
+++ b/addons/website/static/src/snippets/s_dynamic_snippet/000.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+ <t t-name="website.s_dynamic_snippet.grid">
+ <!-- Content -->
+ <t t-set="colClass" t-value="'col-' + (12 / chunkSize).toString()"/>
+ <t t-set="rowIndexGenerator" t-value="Array.from(Array(Math.ceil(data.length/chunkSize)).keys())"/>
+ <t t-set="colIndexGenerator" t-value="Array.from(Array(chunkSize).keys())"/>
+ <t t-foreach="rowIndexGenerator" t-as="rowIndex">
+ <div class="row my-4">
+ <t t-foreach="colIndexGenerator" t-as="colIndex">
+ <t t-if="(rowIndex * chunkSize + colIndex) &lt; data.length">
+ <div t-attf-class="#{colClass}">
+ <t t-raw="data[rowIndex * chunkSize + colIndex]"/>
+ </div>
+ </t>
+ </t>
+ </div>
+ </t>
+ </t>
+</templates>
diff --git a/addons/website/static/src/snippets/s_dynamic_snippet/options.js b/addons/website/static/src/snippets/s_dynamic_snippet/options.js
new file mode 100644
index 00000000..2cbdcb1c
--- /dev/null
+++ b/addons/website/static/src/snippets/s_dynamic_snippet/options.js
@@ -0,0 +1,136 @@
+odoo.define('website.s_dynamic_snippet_options', function (require) {
+'use strict';
+
+const options = require('web_editor.snippets.options');
+
+const dynamicSnippetOptions = options.Class.extend({
+
+ /**
+ *
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this.dynamicFilters = {};
+ this.dynamicFilterTemplates = {};
+ },
+ /**
+ *
+ * @override
+ */
+ onBuilt: function () {
+ this._setOptionsDefaultValues();
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ *
+ * @see this.selectClass for parameters
+ */
+ selectDataAttribute: function (previewMode, widgetValue, params) {
+ this._super.apply(this, arguments);
+ if (params.attributeName === 'filterId' && previewMode === false) {
+ this.$target.get(0).dataset.numberOfRecords = this.dynamicFilters[parseInt(widgetValue)].limit;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Fetches dynamic filters.
+ * @private
+ * @returns {Promise}
+ */
+ _fetchDynamicFilters: function () {
+ return this._rpc({route: '/website/snippet/options_filters'});
+ },
+ /**
+ * Fetch dynamic filters templates.
+ * @private
+ * @returns {Promise}
+ */
+ _fetchDynamicFilterTemplates: function () {
+ return this._rpc({route: '/website/snippet/filter_templates'});
+ },
+ /**
+ *
+ * @override
+ * @private
+ */
+ _renderCustomXML: async function (uiFragment) {
+ await this._renderDynamicFiltersSelector(uiFragment);
+ await this._renderDynamicFilterTemplatesSelector(uiFragment);
+ },
+ /**
+ * Renders the dynamic filter option selector content into the provided uiFragment.
+ * @param {HTMLElement} uiFragment
+ * @private
+ */
+ _renderDynamicFiltersSelector: async function (uiFragment) {
+ const dynamicFilters = await this._fetchDynamicFilters();
+ for (let index in dynamicFilters) {
+ this.dynamicFilters[dynamicFilters[index].id] = dynamicFilters[index];
+ }
+ const filtersSelectorEl = uiFragment.querySelector('[data-name="filter_opt"]');
+ return this._renderSelectUserValueWidgetButtons(filtersSelectorEl, this.dynamicFilters);
+ },
+ /**
+ * Renders we-buttons into a SelectUserValueWidget element according to provided data.
+ * @param {HTMLElement} selectUserValueWidgetElement the SelectUserValueWidget buttons
+ * have to be created into.
+ * @param {JSON} data
+ * @private
+ */
+ _renderSelectUserValueWidgetButtons: async function (selectUserValueWidgetElement, data) {
+ for (let id in data) {
+ const button = document.createElement('we-button');
+ button.dataset.selectDataAttribute = id;
+ button.innerHTML = data[id].name;
+ selectUserValueWidgetElement.appendChild(button);
+ }
+ },
+ /**
+ * Renders the template option selector content into the provided uiFragment.
+ * @param {HTMLElement} uiFragment
+ * @private
+ */
+ _renderDynamicFilterTemplatesSelector: async function (uiFragment) {
+ const dynamicFilterTemplates = await this._fetchDynamicFilterTemplates();
+ for (let index in dynamicFilterTemplates) {
+ this.dynamicFilterTemplates[dynamicFilterTemplates[index].key] = dynamicFilterTemplates[index];
+ }
+ const templatesSelectorEl = uiFragment.querySelector('[data-name="template_opt"]');
+ return this._renderSelectUserValueWidgetButtons(templatesSelectorEl, this.dynamicFilterTemplates);
+ },
+ /**
+ * Sets default options values.
+ * Method to be overridden in child components in order to set additional
+ * options default values.
+ * @private
+ */
+ _setOptionsDefaultValues: function () {
+ this._setOptionValue('numberOfElements', 4);
+ this._setOptionValue('numberOfElementsSmallDevices', 1);
+ },
+ /**
+ * Sets the option value.
+ * @param optionName
+ * @param value
+ * @private
+ */
+ _setOptionValue: function (optionName, value) {
+ if (this.$target.get(0).dataset[optionName] === undefined) {
+ this.$target.get(0).dataset[optionName] = value;
+ }
+ },
+});
+
+options.registry.dynamic_snippet = dynamicSnippetOptions;
+
+return dynamicSnippetOptions;
+});
diff --git a/addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.js b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.js
new file mode 100644
index 00000000..cf43100a
--- /dev/null
+++ b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.js
@@ -0,0 +1,46 @@
+odoo.define('website.s_dynamic_snippet_carousel', function (require) {
+'use strict';
+
+const config = require('web.config');
+const core = require('web.core');
+const publicWidget = require('web.public.widget');
+const DynamicSnippet = require('website.s_dynamic_snippet');
+
+const DynamicSnippetCarousel = DynamicSnippet.extend({
+ selector: '.s_dynamic_snippet_carousel',
+ xmlDependencies: (DynamicSnippet.prototype.xmlDependencies || []).concat(
+ ['/website/static/src/snippets/s_dynamic_snippet_carousel/000.xml']
+ ),
+
+ /**
+ *
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this.template_key = 'website.s_dynamic_snippet.carousel';
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Method to be overridden in child components in order to prepare QWeb
+ * options
+ * @private
+ */
+ _getQWebRenderParams: function () {
+ return Object.assign(
+ this._super.apply(this, arguments),
+ {
+ interval : parseInt(this.$target[0].dataset.carouselInterval),
+ },
+ );
+ },
+
+});
+publicWidget.registry.dynamic_snippet_carousel = DynamicSnippetCarousel;
+
+return DynamicSnippetCarousel;
+});
diff --git a/addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.scss b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.scss
new file mode 100644
index 00000000..3eceb172
--- /dev/null
+++ b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.scss
@@ -0,0 +1,11 @@
+.s_dynamic {
+ .carousel-control-prev, .carousel-control-next {
+ position: absolute;
+ width: 4rem;
+
+ > span.fa {
+ color: gray('700');
+ background: radial-gradient($white 50%, transparent 50%);
+ }
+ }
+}
diff --git a/addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.xml b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.xml
new file mode 100644
index 00000000..1efb8f22
--- /dev/null
+++ b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.xml
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+ <t t-name="website.s_dynamic_snippet.carousel">
+ <div t-att-id="uniqueId" class="carousel slide" t-att-data-interval="interval">
+ <!-- Content -->
+ <div class="carousel-inner row w-100 mx-auto" role="listbox">
+ <t t-set="colClass" t-value="'col-' + (12 / chunkSize).toString()"/>
+ <t t-set="slideIndexGenerator" t-value="Array.from(Array(Math.ceil(data.length/chunkSize)).keys())"/>
+ <t t-set="itemIndexGenerator" t-value="Array.from(Array(chunkSize).keys())"/>
+ <t t-foreach="slideIndexGenerator" t-as="slideIndex">
+ <div t-attf-class="carousel-item #{slideIndex_first ? 'active' : ''}">
+ <div class="row">
+ <t t-foreach="itemIndexGenerator" t-as="itemIndex">
+ <t t-if="(slideIndex * chunkSize + itemIndex) &lt; data.length">
+ <div t-attf-class="#{colClass}">
+ <t t-raw="data[slideIndex * chunkSize + itemIndex]"/>
+ </div>
+ </t>
+ </t>
+ </div>
+ </div>
+ </t>
+ </div>
+ <!-- Controls -->
+ <a t-attf-href="##{uniqueId}" class="carousel-control-prev" data-slide="prev" role="button" aria-label="Previous" title="Previous">
+ <span class="fa fa-chevron-circle-left fa-2x"/>
+ <span class="sr-only">Previous</span>
+ </a>
+ <a t-attf-href="##{uniqueId}" class="carousel-control-next" data-slide="next" role="button" aria-label="Next" title="Next">
+ <span class="fa fa-chevron-circle-right fa-2x"/>
+ <span class="sr-only">Next</span>
+ </a>
+ </div>
+ </t>
+</templates>
diff --git a/addons/website/static/src/snippets/s_dynamic_snippet_carousel/options.js b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/options.js
new file mode 100644
index 00000000..cb9a3cf5
--- /dev/null
+++ b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/options.js
@@ -0,0 +1,28 @@
+odoo.define('website.s_dynamic_snippet_carousel_options', function (require) {
+'use strict';
+
+const options = require('web_editor.snippets.options');
+const s_dynamic_snippet_options = require('website.s_dynamic_snippet_options');
+
+const dynamicSnippetCarouselOptions = s_dynamic_snippet_options.extend({
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ *
+ * @override
+ * @private
+ */
+ _setOptionsDefaultValues: function () {
+ this._super.apply(this, arguments);
+ this._setOptionValue('carouselInterval', '5000');
+ }
+
+});
+
+options.registry.dynamic_snippet_carousel = dynamicSnippetCarouselOptions;
+
+return dynamicSnippetCarouselOptions;
+});
diff --git a/addons/website/static/src/snippets/s_facebook_page/000.js b/addons/website/static/src/snippets/s_facebook_page/000.js
new file mode 100644
index 00000000..0a88f1b3
--- /dev/null
+++ b/addons/website/static/src/snippets/s_facebook_page/000.js
@@ -0,0 +1,56 @@
+odoo.define('website.s_facebook_page', function (require) {
+'use strict';
+
+var publicWidget = require('web.public.widget');
+var utils = require('web.utils');
+
+const FacebookPageWidget = publicWidget.Widget.extend({
+ selector: '.o_facebook_page',
+ disabledInEditableMode: false,
+
+ /**
+ * @override
+ */
+ start: function () {
+ var def = this._super.apply(this, arguments);
+
+ var params = _.pick(this.$el.data(), 'href', 'height', 'tabs', 'small_header', 'hide_cover', 'show_facepile');
+ if (!params.href) {
+ return def;
+ }
+ params.width = utils.confine(Math.floor(this.$el.width()), 180, 500);
+
+ var src = $.param.querystring('https://www.facebook.com/plugins/page.php', params);
+ this.$iframe = $('<iframe/>', {
+ src: src,
+ class: 'o_temp_auto_element',
+ width: params.width,
+ height: params.height,
+ css: {
+ border: 'none',
+ overflow: 'hidden',
+ },
+ scrolling: 'no',
+ frameborder: '0',
+ allowTransparency: 'true',
+ });
+ this.$el.append(this.$iframe);
+
+ return def;
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._super.apply(this, arguments);
+
+ if (this.$iframe) {
+ this.$iframe.remove();
+ }
+ },
+});
+
+publicWidget.registry.facebookPage = FacebookPageWidget;
+
+return FacebookPageWidget;
+});
diff --git a/addons/website/static/src/snippets/s_facebook_page/options.js b/addons/website/static/src/snippets/s_facebook_page/options.js
new file mode 100644
index 00000000..da6c94dc
--- /dev/null
+++ b/addons/website/static/src/snippets/s_facebook_page/options.js
@@ -0,0 +1,157 @@
+odoo.define('website.s_facebook_page_options', function (require) {
+'use strict';
+
+const options = require('web_editor.snippets.options');
+
+options.registry.facebookPage = options.Class.extend({
+ /**
+ * Initializes the required facebook page data to create the iframe.
+ *
+ * @override
+ */
+ willStart: function () {
+ var defs = [this._super.apply(this, arguments)];
+
+ var defaults = {
+ href: '',
+ height: 215,
+ width: 350,
+ tabs: '',
+ small_header: true,
+ hide_cover: true,
+ show_facepile: false,
+ };
+ this.fbData = _.defaults(_.pick(this.$target.data(), _.keys(defaults)), defaults);
+
+ if (!this.fbData.href) {
+ // Fetches the default url for facebook page from website config
+ var self = this;
+ defs.push(this._rpc({
+ model: 'website',
+ method: 'search_read',
+ args: [[], ['social_facebook']],
+ limit: 1,
+ }).then(function (res) {
+ if (res) {
+ self.fbData.href = res[0].social_facebook || '';
+ }
+ }));
+ }
+
+ return Promise.all(defs).then(() => this._markFbElement()).then(() => this._refreshPublicWidgets());
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Toggles a checkbox option.
+ *
+ * @see this.selectClass for parameters
+ * @param {String} optionName the name of the option to toggle
+ */
+ toggleOption: function (previewMode, widgetValue, params) {
+ let optionName = params.optionName;
+ if (optionName.startsWith('tab.')) {
+ optionName = optionName.replace('tab.', '');
+ if (widgetValue) {
+ this.fbData.tabs = this.fbData.tabs
+ .split(',')
+ .filter(t => t !== '')
+ .concat([optionName])
+ .join(',');
+ } else {
+ this.fbData.tabs = this.fbData.tabs
+ .split(',')
+ .filter(t => t !== optionName)
+ .join(',');
+ }
+ } else {
+ if (optionName === 'show_cover') {
+ this.fbData.hide_cover = !widgetValue;
+ } else {
+ this.fbData[optionName] = widgetValue;
+ }
+ }
+ return this._markFbElement();
+ },
+ /**
+ * Sets the facebook page's URL.
+ *
+ * @see this.selectClass for parameters
+ */
+ pageUrl: function (previewMode, widgetValue, params) {
+ this.fbData.href = widgetValue;
+ return this._markFbElement();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Sets the correct dataAttributes on the facebook iframe and refreshes it.
+ *
+ * @see this.selectClass for parameters
+ */
+ _markFbElement: function () {
+ return this._checkURL().then(() => {
+ // Managing height based on options
+ if (this.fbData.tabs) {
+ this.fbData.height = this.fbData.tabs === 'events' ? 300 : 500;
+ } else if (this.fbData.small_header) {
+ this.fbData.height = this.fbData.show_facepile ? 165 : 70;
+ } else {
+ this.fbData.height = this.fbData.show_facepile ? 225 : 150;
+ }
+ _.each(this.fbData, (value, key) => {
+ this.$target.attr('data-' + key, value);
+ this.$target.data(key, value);
+ });
+ });
+ },
+ /**
+ * @override
+ */
+ _computeWidgetState: function (methodName, params) {
+ const optionName = params.optionName;
+ switch (methodName) {
+ case 'toggleOption': {
+ if (optionName.startsWith('tab.')) {
+ return this.fbData.tabs.split(',').includes(optionName.replace(/^tab./, ''));
+ } else {
+ if (optionName === 'show_cover') {
+ return !this.fbData.hide_cover;
+ }
+ return this.fbData[optionName];
+ }
+ }
+ case 'pageUrl': {
+ return this._checkURL().then(() => this.fbData.href);
+ }
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @private
+ */
+ _checkURL: function () {
+ const defaultURL = 'https://www.facebook.com/Odoo';
+ const match = this.fbData.href.match(/^(?:https?:\/\/)?(?:www\.)?(?:fb|facebook)\.com\/(?:([\w.]+)|[^/?#]+-([0-9]{15,16}))(?:$|[/?# ])/);
+ if (match) {
+ // Check if the page exists on Facebook or not
+ return new Promise((resolve, reject) => $.ajax({
+ url: 'https://graph.facebook.com/' + (match[2] || match[1]) + '/picture',
+ success: () => resolve(),
+ error: () => {
+ this.fbData.href = defaultURL;
+ resolve();
+ },
+ }));
+ }
+ this.fbData.href = defaultURL;
+ return Promise.resolve();
+ },
+});
+});
diff --git a/addons/website/static/src/snippets/s_faq_collapse/000.scss b/addons/website/static/src/snippets/s_faq_collapse/000.scss
new file mode 100644
index 00000000..af236dea
--- /dev/null
+++ b/addons/website/static/src/snippets/s_faq_collapse/000.scss
@@ -0,0 +1,35 @@
+
+.s_faq_collapse {
+ .accordion .card {
+ .card-header {
+ cursor: pointer;
+ display: inline-block;
+ width: 100%;
+ padding: .5em 0;
+ border-radius: 0;
+ outline: none;
+ &:before {
+ content:'\f056';
+ font-family: 'FontAwesome';
+ display: inline-block;
+ margin: 0 .5em 0 .75em;
+ color: $gray-600;
+ }
+ &.collapsed:before {
+ content:'\f055';
+ font-family: 'FontAwesome';
+ }
+ &:hover,
+ &:focus {
+ text-decoration: none;
+ }
+ }
+ .card-body {
+ padding: 1em 2.25em;
+ }
+ }
+ .card-body p:last-child,
+ .card-body ul:last-child {
+ margin-bottom: 0;
+ }
+}
diff --git a/addons/website/static/src/snippets/s_features_grid/000.scss b/addons/website/static/src/snippets/s_features_grid/000.scss
new file mode 100644
index 00000000..d66d72c5
--- /dev/null
+++ b/addons/website/static/src/snippets/s_features_grid/000.scss
@@ -0,0 +1,13 @@
+
+.s_features_grid {
+ &_content {
+ overflow: hidden;
+ p {
+ margin-bottom: 0;
+ }
+ }
+ &_icon {
+ float: left;
+ margin-right: $grid-gutter-width/2;
+ }
+}
diff --git a/addons/website/static/src/snippets/s_google_map/000.js b/addons/website/static/src/snippets/s_google_map/000.js
new file mode 100644
index 00000000..1fa5d3d7
--- /dev/null
+++ b/addons/website/static/src/snippets/s_google_map/000.js
@@ -0,0 +1,96 @@
+odoo.define('website.s_google_map', function (require) {
+'use strict';
+
+const publicWidget = require('web.public.widget');
+
+publicWidget.registry.GoogleMap = publicWidget.Widget.extend({
+ selector: '.s_google_map',
+ disabledInEditableMode: false,
+
+ mapColors: {
+ lightMonoMap: [{"featureType": "administrative.locality", "elementType": "all", "stylers": [{"hue": "#2c2e33"}, {"saturation": 7}, {"lightness": 19}, {"visibility": "on"}]}, {"featureType": "landscape", "elementType": "all", "stylers": [{"hue": "#ffffff"}, {"saturation": -100}, {"lightness": 100}, {"visibility": "simplified"}]}, {"featureType": "poi", "elementType": "all", "stylers": [{"hue": "#ffffff"}, {"saturation": -100}, {"lightness": 100}, {"visibility": "off"}]}, {"featureType": "road", "elementType": "geometry", "stylers": [{"hue": "#bbc0c4"}, {"saturation": -93}, {"lightness": 31}, {"visibility": "simplified"}]}, {"featureType": "road", "elementType": "labels", "stylers": [{"hue": "#bbc0c4"}, {"saturation": -93}, {"lightness": 31}, {"visibility": "on"}]}, {"featureType": "road.arterial", "elementType": "labels", "stylers": [{"hue": "#bbc0c4"}, {"saturation": -93}, {"lightness": -2}, {"visibility": "simplified"}]}, {"featureType": "road.local", "elementType": "geometry", "stylers": [{"hue": "#e9ebed"}, {"saturation": -90}, {"lightness": -8}, {"visibility": "simplified"}]}, {"featureType": "transit", "elementType": "all", "stylers": [{"hue": "#e9ebed"}, {"saturation": 10}, {"lightness": 69}, {"visibility": "on"}]}, {"featureType": "water", "elementType": "all", "stylers": [{"hue": "#e9ebed"}, {"saturation": -78}, {"lightness": 67}, {"visibility": "simplified"}]}],
+ lillaMap: [{elementType: "labels", stylers: [{saturation: -20}]}, {featureType: "poi", elementType: "labels", stylers: [{visibility: "off"}]}, {featureType: 'road.highway', elementType: 'labels', stylers: [{visibility: "off"}]}, {featureType: "road.local", elementType: "labels.icon", stylers: [{visibility: "off"}]}, {featureType: "road.arterial", elementType: "labels.icon", stylers: [{visibility: "off"}]}, {featureType: "road", elementType: "geometry.stroke", stylers: [{visibility: "off"}]}, {featureType: "transit", elementType: "geometry.fill", stylers: [{hue: '#2d313f'}, {visibility: "on"}, {lightness: 5}, {saturation: -20}]}, {featureType: "poi", elementType: "geometry.fill", stylers: [{hue: '#2d313f'}, {visibility: "on"}, {lightness: 5}, {saturation: -20}]}, {featureType: "poi.government", elementType: "geometry.fill", stylers: [{hue: '#2d313f'}, {visibility: "on"}, {lightness: 5}, {saturation: -20}]}, {featureType: "poi.sport_complex", elementType: "geometry.fill", stylers: [{hue: '#2d313f'}, {visibility: "on"}, {lightness: 5}, {saturation: -20}]}, {featureType: "poi.attraction", elementType: "geometry.fill", stylers: [{hue: '#2d313f'}, {visibility: "on"}, {lightness: 5}, {saturation: -20}]}, {featureType: "poi.business", elementType: "geometry.fill", stylers: [{hue: '#2d313f'}, {visibility: "on"}, {lightness: 5}, {saturation: -20}]}, {featureType: "transit", elementType: "geometry.fill", stylers: [{hue: '#2d313f'}, {visibility: "on"}, {lightness: 5}, {saturation: -20}]}, {featureType: "transit.station", elementType: "geometry.fill", stylers: [{hue: '#2d313f'}, {visibility: "on"}, {lightness: 5}, {saturation: -20}]}, {featureType: "landscape", stylers: [{hue: '#2d313f'}, {visibility: "on"}, {lightness: 5}, {saturation: -20}]}, {featureType: "road", elementType: "geometry.fill", stylers: [{hue: '#2d313f'}, {visibility: "on"}, {lightness: 5}, {saturation: -20}]}, {featureType: "road.highway", elementType: "geometry.fill", stylers: [{hue: '#2d313f'}, {visibility: "on"}, {lightness: 5}, {saturation: -20}]}, {featureType: "water", elementType: "geometry", stylers: [{hue: '#2d313f'}, {visibility: "on"}, {lightness: 5}, {saturation: -20}]}],
+ blueMap: [{stylers: [{hue: "#00ffe6"}, {saturation: -20}]}, {featureType: "road", elementType: "geometry", stylers: [{lightness: 100}, {visibility: "simplified"}]}, {featureType: "road", elementType: "labels", stylers: [{visibility: "off"}]}],
+ retroMap: [{"featureType": "administrative", "elementType": "all", "stylers": [{"visibility": "on"}, {"lightness": 33}]}, {"featureType": "landscape", "elementType": "all", "stylers": [{"color": "#f2e5d4"}]}, {"featureType": "poi.park", "elementType": "geometry", "stylers": [{"color": "#c5dac6"}]}, {"featureType": "poi.park", "elementType": "labels", "stylers": [{"visibility": "on"}, {"lightness": 20}]}, {"featureType": "road", "elementType": "all", "stylers": [{"lightness": 20}]}, {"featureType": "road.highway", "elementType": "geometry", "stylers": [{"color": "#c5c6c6"}]}, {"featureType": "road.arterial", "elementType": "geometry", "stylers": [{"color": "#e4d7c6"}]}, {"featureType": "road.local", "elementType": "geometry", "stylers": [{"color": "#fbfaf7"}]}, {"featureType": "water", "elementType": "all", "stylers": [{"visibility": "on"}, {"color": "#acbcc9"}]}],
+ flatMap: [{"stylers": [{"visibility": "off"}]}, {"featureType": "road", "stylers": [{"visibility": "on"}, {"color": "#ffffff"}]}, {"featureType": "road.arterial", "stylers": [{"visibility": "on"}, {"color": "#fee379"}]}, {"featureType": "road.highway", "stylers": [{"visibility": "on"}, {"color": "#fee379"}]}, {"featureType": "landscape", "stylers": [{"visibility": "on"}, {"color": "#f3f4f4"}]}, {"featureType": "water", "stylers": [{"visibility": "on"}, {"color": "#7fc8ed"}]}, {}, {"featureType": "road", "elementType": "labels", "stylers": [{"visibility": "on"}]}, {"featureType": "poi.park", "elementType": "geometry.fill", "stylers": [{"visibility": "on"}, {"color": "#83cead"}]}, {"elementType": "labels", "stylers": [{"visibility": "on"}]}, {"featureType": "landscape.man_made", "elementType": "geometry", "stylers": [{"weight": 0.9}, {"visibility": "off"}]}],
+ cobaltMap: [{"featureType": "all", "elementType": "all", "stylers": [{"invert_lightness": true}, {"saturation": 10}, {"lightness": 30}, {"gamma": 0.5}, {"hue": "#435158"}]}],
+ cupertinoMap: [{"featureType": "water", "elementType": "geometry", "stylers": [{"color": "#a2daf2"}]}, {"featureType": "landscape.man_made", "elementType": "geometry", "stylers": [{"color": "#f7f1df"}]}, {"featureType": "landscape.natural", "elementType": "geometry", "stylers": [{"color": "#d0e3b4"}]}, {"featureType": "landscape.natural.terrain", "elementType": "geometry", "stylers": [{"visibility": "off"}]}, {"featureType": "poi.park", "elementType": "geometry", "stylers": [{"color": "#bde6ab"}]}, {"featureType": "poi", "elementType": "labels", "stylers": [{"visibility": "off"}]}, {"featureType": "poi.medical", "elementType": "geometry", "stylers": [{"color": "#fbd3da"}]}, {"featureType": "poi.business", "stylers": [{"visibility": "off"}]}, {"featureType": "road", "elementType": "geometry.stroke", "stylers": [{"visibility": "off"}]}, {"featureType": "road", "elementType": "labels", "stylers": [{"visibility": "off"}]}, {"featureType": "road.highway", "elementType": "geometry.fill", "stylers": [{"color": "#ffe15f"}]}, {"featureType": "road.highway", "elementType": "geometry.stroke", "stylers": [{"color": "#efd151"}]}, {"featureType": "road.arterial", "elementType": "geometry.fill", "stylers": [{"color": "#ffffff"}]}, {"featureType": "road.local", "elementType": "geometry.fill", "stylers": [{"color": "black"}]}, {"featureType": "transit.station.airport", "elementType": "geometry.fill", "stylers": [{"color": "#cfb2db"}]}],
+ carMap: [{"featureType": "administrative", "stylers": [{"visibility": "off"}]}, {"featureType": "poi", "stylers": [{"visibility": "simplified"}]}, {"featureType": "road", "stylers": [{"visibility": "simplified"}]}, {"featureType": "water", "stylers": [{"visibility": "simplified"}]}, {"featureType": "transit", "stylers": [{"visibility": "simplified"}]}, {"featureType": "landscape", "stylers": [{"visibility": "simplified"}]}, {"featureType": "road.highway", "stylers": [{"visibility": "off"}]}, {"featureType": "road.local", "stylers": [{"visibility": "on"}]}, {"featureType": "road.highway", "elementType": "geometry", "stylers": [{"visibility": "on"}]}, {"featureType": "water", "stylers": [{"color": "#84afa3"}, {"lightness": 52}]}, {"stylers": [{"saturation": -77}]}, {"featureType": "road"}],
+ bwMap: [{stylers: [{hue: "#00ffe6"}, {saturation: -100}]}, {featureType: "road", elementType: "geometry", stylers: [{lightness: 100}, {visibility: "simplified"}]}, {featureType: "road", elementType: "labels", stylers: [{visibility: "off"}]}],
+ },
+
+ /**
+ * @override
+ */
+ async start() {
+ await this._super(...arguments);
+
+ if (typeof google !== 'object' || typeof google.maps !== 'object') {
+ await new Promise(resolve => {
+ this.trigger_up('gmap_api_request', {
+ editableMode: this.editableMode,
+ onSuccess: () => resolve(),
+ });
+ });
+ // The animation will be restarted for all maps as soon as the
+ // google map script has been executed.
+ return;
+ }
+
+ // Define a default map's colors set
+ const std = [];
+ new google.maps.StyledMapType(std, {name: "Std Map"});
+
+ // Default options, will be overwritten by the user
+ const myOptions = {
+ zoom: 12,
+ center: new google.maps.LatLng(50.854975, 4.3753899),
+ mapTypeId: google.maps.MapTypeId.ROADMAP,
+ panControl: false,
+ zoomControl: false,
+ mapTypeControl: false,
+ streetViewControl: false,
+ scrollwheel: false,
+ mapTypeControlOptions: {
+ mapTypeIds: [google.maps.MapTypeId.ROADMAP, 'map_style']
+ }
+ };
+
+ // Render Map
+ const mapC = this.$('.map_container');
+ const map = new google.maps.Map(mapC.get(0), myOptions);
+
+ // Update GPS position
+ const p = this.el.dataset.mapGps.substring(1).slice(0, -1).split(',');
+ const gps = new google.maps.LatLng(p[0], p[1]);
+ map.setCenter(gps);
+
+ // Update Map on screen resize
+ google.maps.event.addDomListener(window, 'resize', () => {
+ map.setCenter(gps);
+ });
+
+ // Create Marker & Infowindow
+ const markerOptions = {
+ map: map,
+ animation: google.maps.Animation.DROP,
+ position: new google.maps.LatLng(p[0], p[1])
+ };
+ if (this.el.dataset.pinStyle === 'flat') {
+ markerOptions.icon = '/website/static/src/img/snippets_thumbs/s_google_map_marker.png';
+ }
+ new google.maps.Marker(markerOptions);
+
+ map.setMapTypeId(google.maps.MapTypeId[this.el.dataset.mapType]); // Update Map Type
+ map.setZoom(parseInt(this.el.dataset.mapZoom)); // Update Map Zoom
+
+ // Update Map Color
+ const mapColorAttr = this.el.dataset.mapColor;
+ if (mapColorAttr) {
+ const mapColor = this.mapColors[mapColorAttr];
+ map.mapTypes.set('map_style', new google.maps.StyledMapType(mapColor, {name: "Styled Map"}));
+ map.setMapTypeId('map_style');
+ }
+ },
+});
+});
diff --git a/addons/website/static/src/snippets/s_google_map/000.scss b/addons/website/static/src/snippets/s_google_map/000.scss
new file mode 100644
index 00000000..9a8df732
--- /dev/null
+++ b/addons/website/static/src/snippets/s_google_map/000.scss
@@ -0,0 +1,42 @@
+
+$s-google-map-desc-bg: theme-color('primary') !default;
+$s-google-map-desc-alpha: 0.80 !default;
+$s-google-map-desc-hover-bg: theme-color('primary') !default;
+$s-google-map-desc-hover-alpha: 0.55 !default;
+
+.s_google_map {
+ position: relative;
+ min-height: 100px;
+
+ .map_container {
+ @include o-position-absolute(0, 0, 0, 0);
+ }
+ .description {
+ @include o-position-absolute(auto, 0, 0, 0);
+ z-index: 99;
+ padding: 0 1em;
+ background: rgba($s-google-map-desc-bg, $s-google-map-desc-alpha);
+ color: color-yiq(rgba($s-google-map-desc-bg, $s-google-map-desc-alpha));
+ transition: background-color 250ms ease;
+
+ font {
+ float: left;
+ margin-top: 20px;
+ margin-bottom: 15px;
+ font-weight: bold;
+ text-transform: uppercase;
+ }
+ span {
+ float: left;
+ text-transform: none;
+ font-weight: normal;
+ margin-top: 20px;
+ margin-left: 10px;
+ }
+ }
+ &:hover .description {
+ background: $s-google-map-desc-hover-bg;
+ background: rgba($s-google-map-desc-hover-bg, $s-google-map-desc-hover-alpha);
+ color: color-yiq(rgba($s-google-map-desc-hover-bg, $s-google-map-desc-hover-alpha));
+ }
+}
diff --git a/addons/website/static/src/snippets/s_google_map/img/thumbs/map-blue.jpg b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-blue.jpg
new file mode 100644
index 00000000..a929e345
--- /dev/null
+++ b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-blue.jpg
Binary files differ
diff --git a/addons/website/static/src/snippets/s_google_map/img/thumbs/map-bw.jpg b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-bw.jpg
new file mode 100644
index 00000000..1dad96b4
--- /dev/null
+++ b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-bw.jpg
Binary files differ
diff --git a/addons/website/static/src/snippets/s_google_map/img/thumbs/map-caramello.jpg b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-caramello.jpg
new file mode 100644
index 00000000..55f1046c
--- /dev/null
+++ b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-caramello.jpg
Binary files differ
diff --git a/addons/website/static/src/snippets/s_google_map/img/thumbs/map-cobalt.jpg b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-cobalt.jpg
new file mode 100644
index 00000000..dc0aa590
--- /dev/null
+++ b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-cobalt.jpg
Binary files differ
diff --git a/addons/website/static/src/snippets/s_google_map/img/thumbs/map-cupertino.jpg b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-cupertino.jpg
new file mode 100644
index 00000000..06c97ae0
--- /dev/null
+++ b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-cupertino.jpg
Binary files differ
diff --git a/addons/website/static/src/snippets/s_google_map/img/thumbs/map-default.jpg b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-default.jpg
new file mode 100644
index 00000000..ea5c8360
--- /dev/null
+++ b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-default.jpg
Binary files differ
diff --git a/addons/website/static/src/snippets/s_google_map/img/thumbs/map-flat.jpg b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-flat.jpg
new file mode 100644
index 00000000..9b4df178
--- /dev/null
+++ b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-flat.jpg
Binary files differ
diff --git a/addons/website/static/src/snippets/s_google_map/img/thumbs/map-lightMono.jpg b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-lightMono.jpg
new file mode 100644
index 00000000..17e23249
--- /dev/null
+++ b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-lightMono.jpg
Binary files differ
diff --git a/addons/website/static/src/snippets/s_google_map/img/thumbs/map-lilla.jpg b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-lilla.jpg
new file mode 100644
index 00000000..62a19e7c
--- /dev/null
+++ b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-lilla.jpg
Binary files differ
diff --git a/addons/website/static/src/snippets/s_google_map/img/thumbs/map-retro.jpg b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-retro.jpg
new file mode 100644
index 00000000..2c36791b
--- /dev/null
+++ b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-retro.jpg
Binary files differ
diff --git a/addons/website/static/src/snippets/s_google_map/options.js b/addons/website/static/src/snippets/s_google_map/options.js
new file mode 100644
index 00000000..6aad46f1
--- /dev/null
+++ b/addons/website/static/src/snippets/s_google_map/options.js
@@ -0,0 +1,56 @@
+odoo.define('options.s_google_map_options', function (require) {
+'use strict';
+
+const {_t} = require('web.core');
+const options = require('web_editor.snippets.options');
+
+options.registry.GoogleMap = options.Class.extend({
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * @see this.selectClass for parameters
+ */
+ resetMapColor(previewMode, widgetValue, params) {
+ this.$target[0].dataset.mapColor = '';
+ },
+ /**
+ * @see this.selectClass for parameters
+ */
+ setFormattedAddress(previewMode, widgetValue, params) {
+ this.$target[0].dataset.pinAddress = params.gmapPlace.formatted_address;
+ },
+ /**
+ * @see this.selectClass for parameters
+ */
+ async showDescription(previewMode, widgetValue, params) {
+ const descriptionEl = this.$target[0].querySelector('.description');
+ if (widgetValue && !descriptionEl) {
+ this.$target.append($(`
+ <div class="description">
+ <font>${_t('Visit us:')}</font>
+ <span>${_t('Our office is located in the northeast of Brussels. TEL (555) 432 2365')}</span>
+ </div>`)
+ );
+ } else if (!widgetValue && descriptionEl) {
+ descriptionEl.remove();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState(methodName, params) {
+ if (methodName === 'showDescription') {
+ return this.$target[0].querySelector('.description') ? 'true' : '';
+ }
+ return this._super(...arguments);
+ },
+});
+});
diff --git a/addons/website/static/src/snippets/s_hr/000.scss b/addons/website/static/src/snippets/s_hr/000.scss
new file mode 100644
index 00000000..410000e2
--- /dev/null
+++ b/addons/website/static/src/snippets/s_hr/000.scss
@@ -0,0 +1,11 @@
+
+.s_hr {
+ line-height: 0;
+ hr {
+ padding: 0;
+ border: 0;
+ border-top: 1px solid currentColor;
+ margin: 0;
+ color: inherit;
+ }
+}
diff --git a/addons/website/static/src/snippets/s_image_gallery/000.js b/addons/website/static/src/snippets/s_image_gallery/000.js
new file mode 100644
index 00000000..9bd0f8e3
--- /dev/null
+++ b/addons/website/static/src/snippets/s_image_gallery/000.js
@@ -0,0 +1,180 @@
+odoo.define('website.s_image_gallery', function (require) {
+'use strict';
+
+var core = require('web.core');
+var publicWidget = require('web.public.widget');
+
+var qweb = core.qweb;
+
+const GalleryWidget = publicWidget.Widget.extend({
+
+ selector: '.s_image_gallery:not(.o_slideshow)',
+ xmlDependencies: ['/website/static/src/snippets/s_image_gallery/000.xml'],
+ events: {
+ 'click img': '_onClickImg',
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when an image is clicked. Opens a dialog to browse all the images
+ * with a bigger size.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onClickImg: function (ev) {
+ var self = this;
+ var $cur = $(ev.currentTarget);
+
+ var $images = $cur.closest('.s_image_gallery').find('img');
+ var size = 0.8;
+ var dimensions = {
+ min_width: Math.round(window.innerWidth * size * 0.9),
+ min_height: Math.round(window.innerHeight * size),
+ max_width: Math.round(window.innerWidth * size * 0.9),
+ max_height: Math.round(window.innerHeight * size),
+ width: Math.round(window.innerWidth * size * 0.9),
+ height: Math.round(window.innerHeight * size)
+ };
+
+ var $img = ($cur.is('img') === true) ? $cur : $cur.closest('img');
+
+ const milliseconds = $cur.closest('.s_image_gallery').data('interval') || false;
+ var $modal = $(qweb.render('website.gallery.slideshow.lightbox', {
+ images: $images.get(),
+ index: $images.index($img),
+ dim: dimensions,
+ interval: milliseconds || 0,
+ id: _.uniqueId('slideshow_'),
+ }));
+ $modal.modal({
+ keyboard: true,
+ backdrop: true,
+ });
+ $modal.on('hidden.bs.modal', function () {
+ $(this).hide();
+ $(this).siblings().filter('.modal-backdrop').remove(); // bootstrap leaves a modal-backdrop
+ $(this).remove();
+ });
+ $modal.find('.modal-content, .modal-body.o_slideshow').css('height', '100%');
+ $modal.appendTo(document.body);
+
+ $modal.one('shown.bs.modal', function () {
+ self.trigger_up('widgets_start_request', {
+ editableMode: false,
+ $target: $modal.find('.modal-body.o_slideshow'),
+ });
+ });
+ },
+});
+
+const GallerySliderWidget = publicWidget.Widget.extend({
+ selector: '.o_slideshow',
+ xmlDependencies: ['/website/static/src/snippets/s_image_gallery/000.xml'],
+ disabledInEditableMode: false,
+
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+ this.$carousel = this.$target.is('.carousel') ? this.$target : this.$target.find('.carousel');
+ this.$indicator = this.$carousel.find('.carousel-indicators');
+ this.$prev = this.$indicator.find('li.o_indicators_left').css('visibility', ''); // force visibility as some databases have it hidden
+ this.$next = this.$indicator.find('li.o_indicators_right').css('visibility', '');
+ var $lis = this.$indicator.find('li[data-slide-to]');
+ let indicatorWidth = this.$indicator.width();
+ if (indicatorWidth === 0) {
+ // An ancestor may be hidden so we try to find it and make it
+ // visible just to take the correct width.
+ const $indicatorParent = this.$indicator.parents().not(':visible').last();
+ if (!$indicatorParent[0].style.display) {
+ $indicatorParent[0].style.display = 'block';
+ indicatorWidth = this.$indicator.width();
+ $indicatorParent[0].style.display = '';
+ }
+ }
+ let nbPerPage = Math.floor(indicatorWidth / $lis.first().outerWidth(true)) - 3; // - navigator - 1 to leave some space
+ var realNbPerPage = nbPerPage || 1;
+ var nbPages = Math.ceil($lis.length / realNbPerPage);
+
+ var index;
+ var page;
+ update();
+
+ function hide() {
+ $lis.each(function (i) {
+ $(this).toggleClass('d-none', i < page * nbPerPage || i >= (page + 1) * nbPerPage);
+ });
+ if (page <= 0) {
+ self.$prev.detach();
+ } else {
+ self.$prev.removeClass('d-none');
+ self.$prev.prependTo(self.$indicator);
+ }
+ if (page >= nbPages - 1) {
+ self.$next.detach();
+ } else {
+ self.$next.removeClass('d-none');
+ self.$next.appendTo(self.$indicator);
+ }
+ }
+
+ function update() {
+ const active = $lis.filter('.active');
+ index = active.length ? $lis.index(active) : 0;
+ page = Math.floor(index / realNbPerPage);
+ hide();
+ }
+
+ this.$carousel.on('slide.bs.carousel.gallery_slider', function () {
+ setTimeout(function () {
+ var $item = self.$carousel.find('.carousel-inner .carousel-item-prev, .carousel-inner .carousel-item-next');
+ var index = $item.index();
+ $lis.removeClass('active')
+ .filter('[data-slide-to="' + index + '"]')
+ .addClass('active');
+ }, 0);
+ });
+ this.$indicator.on('click.gallery_slider', '> li:not([data-slide-to])', function () {
+ page += ($(this).hasClass('o_indicators_left') ? -1 : 1);
+ page = Math.max(0, Math.min(nbPages - 1, page)); // should not be necessary
+ self.$carousel.carousel(page * realNbPerPage);
+ // We dont use hide() before the slide animation in the editor because there is a traceback
+ // TO DO: fix this traceback
+ if (!self.editableMode) {
+ hide();
+ }
+ });
+ this.$carousel.on('slid.bs.carousel.gallery_slider', update);
+
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._super.apply(this, arguments);
+
+ if (!this.$indicator) {
+ return;
+ }
+
+ this.$prev.prependTo(this.$indicator);
+ this.$next.appendTo(this.$indicator);
+ this.$carousel.off('.gallery_slider');
+ this.$indicator.off('.gallery_slider');
+ },
+});
+
+publicWidget.registry.gallery = GalleryWidget;
+publicWidget.registry.gallerySlider = GallerySliderWidget;
+
+return {
+ GalleryWidget: GalleryWidget,
+ GallerySliderWidget: GallerySliderWidget,
+};
+});
diff --git a/addons/website/static/src/snippets/s_image_gallery/000.scss b/addons/website/static/src/snippets/s_image_gallery/000.scss
new file mode 100644
index 00000000..a99bc86f
--- /dev/null
+++ b/addons/website/static/src/snippets/s_image_gallery/000.scss
@@ -0,0 +1,155 @@
+
+.o_gallery:not([data-vcss]) {
+ &.o_grid, &.o_masonry {
+ .img {
+ width: 100%;
+ }
+ }
+ &.o_grid {
+ &.o_spc-none div.row {
+ margin: 0;
+ > div {
+ padding: 0;
+ }
+ }
+ &.o_spc-small div.row {
+ margin: 5px 0;
+ > div {
+ padding: 0 5px;
+ }
+ }
+ &.o_spc-medium div.row {
+ margin: 10px 0;
+ > div {
+ padding: 0 10px;
+ }
+ }
+ &.o_spc-big div.row {
+ margin: 15px 0;
+ > div {
+ padding: 0 15px;
+ }
+ }
+ &.size-auto .row {
+ height: auto;
+ }
+ &.size-small .row {
+ height: 100px;
+ }
+ &.size-medium .row {
+ height: 250px;
+ }
+ &.size-big .row {
+ height: 400px;
+ }
+ &.size-small, &.size-medium, &.size-big {
+ img {
+ height: 100%;
+ }
+ }
+ }
+ &.o_masonry {
+ &.o_spc-none div.col {
+ padding: 0;
+ > img {
+ margin: 0 !important;
+ }
+ }
+ &.o_spc-small div.col {
+ padding: 0 5px;
+ > img {
+ margin: 5px 0 !important;
+ }
+ }
+ &.o_spc-medium div.col {
+ padding: 0 10px;
+ > img {
+ margin: 10px 0 !important;
+ }
+ }
+ &.o_spc-big div.col {
+ padding: 0 15px;
+ > img {
+ margin: 15px 0 !important;
+ }
+ }
+ }
+ &.o_nomode {
+ &.o_spc-none .img {
+ padding: 0;
+ }
+ &.o_spc-small .img {
+ padding: 5px;
+ }
+ &.o_spc-medium .img {
+ padding: 10px;
+ }
+ &.o_spc-big .img {
+ padding: 15px;
+ }
+ }
+ &.o_slideshow {
+ .carousel ul.carousel-indicators li {
+ border: 1px solid #aaa;
+ }
+ > div:first-child {
+ height: 100%;
+ }
+ .carousel {
+ height: 100%;
+
+ .carousel-inner {
+ height: 100%;
+ }
+ .carousel-item.active,
+ .carousel-item-next,
+ .carousel-item-prev {
+ display: flex;
+ align-items: center;
+ height: 100%;
+ padding-bottom: 64px;
+ }
+ img {
+ max-height: 100%;
+ max-width: 100%;
+ margin: auto;
+ }
+ ul.carousel-indicators {
+ height: auto;
+ padding: 0;
+ border-width: 0;
+ position: absolute;
+ bottom: 0;
+ width: 100%;
+ margin-left: 0;
+ left: 0%;
+ > * {
+ list-style-image: none;
+ display: inline-block;
+ width: 40px;
+ height: 40px;
+ line-height: 40px;
+ margin: 2.5px 2.5px 2.5px 2.5px;
+ padding: 0 !important;
+ border: 1px solid #aaa;
+ text-indent: initial;
+ background-size: cover;
+ background-color: #fff;
+ border-radius: 0;
+ vertical-align: bottom;
+ flex: 0 0 40px;
+ &:not(.active) {
+ opacity: 0.8;
+ filter: grayscale(1);
+ }
+ }
+ }
+ }
+ }
+ .carousel-inner .item img {
+ max-width: none;
+ }
+}
+
+// Note: the s_gallery_lightbox is always using the right dom and classes of the
+// most recent version of the snippet as it is generated by JS after page load.
diff --git a/addons/website/static/src/snippets/s_image_gallery/000.xml b/addons/website/static/src/snippets/s_image_gallery/000.xml
new file mode 100644
index 00000000..a50a1af5
--- /dev/null
+++ b/addons/website/static/src/snippets/s_image_gallery/000.xml
@@ -0,0 +1,68 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+ <!--
+ ========================================================================
+ Gallery Slideshow
+
+ This template is used to display a slideshow of images inside a
+ bootstrap carousel.
+
+ ========================================================================
+ -->
+ <t t-name="website.gallery.slideshow">
+ <div t-attf-id="#{id}" class="carousel slide" data-ride="carousel" t-attf-data-interval="#{interval}" style="margin: 0 12px;">
+ <div class="carousel-inner" style="padding: 0;">
+ <t t-foreach="images" t-as="image">
+ <div t-attf-class="carousel-item #{image_index == index and 'active' or None}">
+ <img t-attf-class="#{attrClass || 'img img-fluid d-block'}" t-att-src="image.src" t-att-style="attrStyle" t-att-alt="image.alt" data-name="Image"/>
+ </div>
+ </t>
+ </div>
+
+ <ul class="carousel-indicators">
+ <li class="o_indicators_left text-center d-none" aria-label="Previous" title="Previous">
+ <i class="fa fa-chevron-left"/>
+ </li>
+ <t t-foreach="images" t-as="image">
+ <li t-attf-data-target="##{id}" t-att-data-slide-to="image_index" t-att-class="image_index == index and 'active' or None" t-attf-style="background-image: url(#{image.src})"></li>
+ </t>
+ <li class="o_indicators_right text-center d-none" aria-label="Next" title="Next">
+ <i class="fa fa-chevron-right"/>
+ </li>
+ </ul>
+
+ <a class="carousel-control-prev o_we_no_overlay" t-attf-href="##{id}" data-slide="prev" aria-label="Previous" title="Previous">
+ <span class="fa fa-chevron-left fa-2x text-white"></span>
+ <span class="sr-only">Previous</span>
+ </a>
+ <a class="carousel-control-next o_we_no_overlay" t-attf-href="##{id}" data-slide="next" aria-label="Next" title="Next">
+ <span class="fa fa-chevron-right fa-2x text-white"></span>
+ <span class="sr-only">Next</span>
+ </a>
+ </div>
+ </t>
+
+ <!--
+ ========================================================================
+ Gallery Slideshow LightBox
+
+ This template is used to display a lightbox with a slideshow.
+
+ This template wraps website.gallery.slideshow in a bootstrap modal
+ dialog.
+ ========================================================================
+ -->
+ <t t-name="website.gallery.slideshow.lightbox">
+ <div role="dialog" class="modal o_technical_modal fade s_gallery_lightbox p-0" aria-labbelledby="Image Gallery Dialog">
+ <div class="modal-dialog m-0" role="Picture Gallery"
+ t-attf-style="">
+ <div class="modal-content bg-transparent">
+ <main class="modal-body o_slideshow bg-transparent">
+ <button type="button" class="close text-white" data-dismiss="modal" style="position: absolute; right: 10px; top: 10px;"><span role="img" aria-label="Close">×</span><span class="sr-only">Close</span></button>
+ <t t-call="website.gallery.slideshow"></t>
+ </main>
+ </div>
+ </div>
+ </div>
+ </t>
+</templates>
diff --git a/addons/website/static/src/snippets/s_image_gallery/001.scss b/addons/website/static/src/snippets/s_image_gallery/001.scss
new file mode 100644
index 00000000..b20d0be3
--- /dev/null
+++ b/addons/website/static/src/snippets/s_image_gallery/001.scss
@@ -0,0 +1,281 @@
+
+.s_image_gallery[data-vcss="001"] {
+ &.o_grid, &.o_masonry {
+ .img {
+ width: 100%;
+ }
+ }
+ &.o_grid {
+ &.o_spc-none div.row {
+ margin-bottom: 0px;
+ }
+ &.o_spc-small div.row > div {
+ margin-bottom: $spacer;
+ }
+ &.o_spc-medium div.row > div {
+ margin-bottom: $spacer * 2;
+ }
+ &.o_spc-big div.row > div {
+ margin-bottom: $spacer * 3;
+ }
+ }
+ &.o_masonry {
+ &.o_spc-none div.o_masonry_col {
+ padding: 0;
+ > img {
+ margin: 0 !important;
+ }
+ }
+ &.o_spc-small div.o_masonry_col {
+ padding: 0 ($spacer * .5);
+ > img {
+ margin-bottom: $spacer !important;
+ }
+ }
+ &.o_spc-medium div.o_masonry_col {
+ padding: 0 $spacer;
+ > img {
+ margin-bottom: $spacer * 2 !important;
+ }
+ }
+ &.o_spc-big div.o_masonry_col {
+ padding: 0 ($spacer * 1.5);
+ > img {
+ margin-bottom: $spacer * 3 !important;
+ }
+ }
+ }
+ &.o_nomode {
+ &.o_spc-none .row div {
+ padding-top: 0;
+ padding-bottom: 0;
+ }
+ &.o_spc-small .row div {
+ padding-top: $spacer * .5;
+ padding-bottom: $spacer * .5;
+ }
+ &.o_spc-medium .row div {
+ padding-top: $spacer;
+ padding-bottom: $spacer;
+ }
+ &.o_spc-big .row div {
+ padding-top: $spacer * 1.5;
+ padding-bottom: $spacer * 1.5;
+ }
+ }
+ &:not(.o_slideshow) {
+ img {
+ cursor: pointer;
+ }
+ }
+ &.o_slideshow {
+ .carousel {
+ .carousel-item.active,
+ .carousel-item-next,
+ .carousel-item-prev,
+ .carousel-control-next,
+ .carousel-control-prev {
+ padding-bottom: 64px;
+ }
+ ul.carousel-indicators li {
+ border: 1px solid #aaa;
+ }
+ }
+ ul.carousel-indicators {
+ position: absolute;
+ left: 0%;
+ bottom: 0;
+ width: 100%;
+ height: auto;
+ margin-left: 0;
+ padding: 0;
+ border-width: 0;
+ > * {
+ list-style-image: none;
+ display: inline-block;
+ width: 40px;
+ height: 40px;
+ line-height: 40px;
+ margin: 2.5px 2.5px 2.5px 2.5px;
+ padding: 0;
+ border: 1px solid #aaa;
+ text-indent: initial;
+ background-size: cover;
+ background-color: #fff;
+ background-position: center;
+ border-radius: 0;
+ vertical-align: bottom;
+ flex: 0 0 40px;
+ &:not(.active) {
+ opacity: 0.8;
+ filter: grayscale(1);
+ }
+ }
+ }
+ > .container, > .container-fluid, > .o_container_small {
+ height: 100%;
+ }
+ &.s_image_gallery_cover .carousel-item {
+ > a {
+ width: 100%;
+ height: 100%;
+ }
+ > a > img,
+ > img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+ }
+ &:not(.s_image_gallery_show_indicators) .carousel {
+ ul.carousel-indicators {
+ display: none;
+ }
+ .carousel-item.active,
+ .carousel-item-next,
+ .carousel-item-prev,
+ .carousel-control-next,
+ .carousel-control-prev {
+ padding-bottom: 0px;
+ }
+ }
+ &.s_image_gallery_indicators_arrows_boxed, &.s_image_gallery_indicators_arrows_rounded {
+ .carousel {
+ .carousel-control-prev .fa,
+ .carousel-control-next .fa {
+ text-shadow: none;
+ }
+ }
+ }
+ &.s_image_gallery_indicators_arrows_boxed {
+ .carousel {
+ .carousel-control-prev .fa:before {
+ content: "\f104";
+ padding-right: 2px;
+ }
+ .carousel-control-next .fa:before {
+ content: "\f105";
+ padding-left: 2px;
+ }
+ .carousel-control-prev .fa:before,
+ .carousel-control-next .fa:before {
+ display: block;
+ width: 3rem;
+ height: 3rem;
+ line-height: 3rem;
+ color: black;
+ background: white;
+ font-size: 1.25rem;
+ border: 1px solid $gray-500;
+ }
+ }
+ }
+ &.s_image_gallery_indicators_arrows_rounded {
+ .carousel {
+ .carousel-control-prev .fa:before { content: "\f060"; }
+ .carousel-control-next .fa:before { content: "\f061"; }
+ .carousel-control-prev .fa:before,
+ .carousel-control-next .fa:before {
+ color: black;
+ background: white;
+ font-size: 1.25rem;
+ border-radius: 50%;
+ padding: 1.25rem;
+ border: 1px solid $gray-500;
+ }
+ }
+ }
+ &.s_image_gallery_indicators_rounded {
+ .carousel {
+ ul.carousel-indicators li {
+ border-radius: 50%;
+ }
+ }
+ }
+ &.s_image_gallery_indicators_dots {
+ .carousel {
+ ul.carousel-indicators {
+ height: 40px;
+ margin: auto;
+
+ li {
+ max-width: 8px;
+ max-height: 8px;
+ margin: 0 6px;
+ border-radius: 10px;
+ background-color: $black;
+ background-image: none !important;
+
+ &:not(.active) {
+ opacity: .4;
+ }
+ }
+ }
+ }
+ }
+
+ @extend %image-gallery-slideshow-styles;
+ }
+ .carousel-inner .item img {
+ max-width: none;
+ }
+}
+
+.s_gallery_lightbox {
+ .close {
+ font-size: 2rem;
+ }
+ .modal-dialog {
+ height: 100%;
+ background-color: rgba(0,0,0,0.7);
+ }
+ @include media-breakpoint-up(sm) {
+ .modal-dialog {
+ max-width: 100%;
+ padding: 0;
+ }
+ }
+ ul.carousel-indicators {
+ display: none;
+ }
+
+ .modal-body.o_slideshow {
+ @extend %image-gallery-slideshow-styles;
+ }
+}
+
+%image-gallery-slideshow-styles {
+ &:not(.s_image_gallery_cover) .carousel-item {
+ > a {
+ display: flex;
+ height: 100%;
+ width: 100%;
+ }
+ > a > img,
+ > img {
+ max-height: 100%;
+ max-width: 100%;
+ margin: auto;
+ }
+ }
+ .carousel {
+ height: 100%;
+
+ .carousel-inner {
+ height: 100%;
+ }
+ .carousel-item.active,
+ .carousel-item-next,
+ .carousel-item-prev,
+ .carousel-control-next,
+ .carousel-control-prev {
+ display: flex;
+ align-items: center;
+ height: 100%;
+ }
+ .carousel-control-next .fa,
+ .carousel-control-prev .fa {
+ text-shadow: 0px 0px 3px $gray-800;
+ }
+ }
+}
diff --git a/addons/website/static/src/snippets/s_image_gallery/options.js b/addons/website/static/src/snippets/s_image_gallery/options.js
new file mode 100644
index 00000000..8afb5c1f
--- /dev/null
+++ b/addons/website/static/src/snippets/s_image_gallery/options.js
@@ -0,0 +1,481 @@
+odoo.define('website.s_image_gallery_options', function (require) {
+'use strict';
+
+var core = require('web.core');
+var weWidgets = require('wysiwyg.widgets');
+var options = require('web_editor.snippets.options');
+
+var _t = core._t;
+var qweb = core.qweb;
+
+options.registry.gallery = options.Class.extend({
+ xmlDependencies: ['/website/static/src/snippets/s_image_gallery/000.xml'],
+
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+
+ // Make sure image previews are updated if images are changed
+ this.$target.on('image_changed', 'img', function (ev) {
+ var $img = $(ev.currentTarget);
+ var index = self.$target.find('.carousel-item.active').index();
+ self.$('.carousel:first li[data-target]:eq(' + index + ')')
+ .css('background-image', 'url(' + $img.attr('src') + ')');
+ });
+
+ // When the snippet is empty, an edition button is the default content
+ // TODO find a nicer way to do that to have editor style
+ this.$target.on('click', '.o_add_images', function (e) {
+ e.stopImmediatePropagation();
+ self.addImages(false);
+ });
+
+ this.$target.on('dropped', 'img', function (ev) {
+ self.mode(null, self.getMode());
+ if (!ev.target.height) {
+ $(ev.target).one('load', function () {
+ setTimeout(function () {
+ self.trigger_up('cover_update');
+ });
+ });
+ }
+ });
+
+ const $container = this.$('> .container, > .container-fluid, > .o_container_small');
+ if ($container.find('> *:not(div)').length) {
+ self.mode(null, self.getMode());
+ }
+
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ onBuilt: function () {
+ if (this.$target.find('.o_add_images').length) {
+ this.addImages(false);
+ }
+ // TODO should consider the async parts
+ this._adaptNavigationIDs();
+ },
+ /**
+ * @override
+ */
+ onClone: function () {
+ this._adaptNavigationIDs();
+ },
+ /**
+ * @override
+ */
+ cleanForSave: function () {
+ if (this.$target.hasClass('slideshow')) {
+ this.$target.removeAttr('style');
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Allows to select images to add as part of the snippet.
+ *
+ * @see this.selectClass for parameters
+ */
+ addImages: function (previewMode) {
+ const $images = this.$('img');
+ var $container = this.$('> .container, > .container-fluid, > .o_container_small');
+ var dialog = new weWidgets.MediaDialog(this, {multiImages: true, onlyImages: true, mediaWidth: 1920});
+ var lastImage = _.last(this._getImages());
+ var index = lastImage ? this._getIndex(lastImage) : -1;
+ return new Promise(resolve => {
+ dialog.on('save', this, function (attachments) {
+ for (var i = 0; i < attachments.length; i++) {
+ $('<img/>', {
+ class: $images.length > 0 ? $images[0].className : 'img img-fluid d-block ',
+ src: attachments[i].image_src,
+ 'data-index': ++index,
+ alt: attachments[i].description || '',
+ 'data-name': _t('Image'),
+ style: $images.length > 0 ? $images[0].style.cssText : '',
+ }).appendTo($container);
+ }
+ if (attachments.length > 0) {
+ this.mode('reset', this.getMode());
+ this.trigger_up('cover_update');
+ }
+ });
+ dialog.on('closed', this, () => resolve());
+ dialog.open();
+ });
+ },
+ /**
+ * Allows to change the number of columns when displaying images with a
+ * grid-like layout.
+ *
+ * @see this.selectClass for parameters
+ */
+ columns: function (previewMode, widgetValue, params) {
+ const nbColumns = parseInt(widgetValue || '1');
+ this.$target.attr('data-columns', nbColumns);
+
+ this.mode(previewMode, this.getMode(), {}); // TODO improve
+ },
+ /**
+ * Get the image target's layout mode (slideshow, masonry, grid or nomode).
+ *
+ * @returns {String('slideshow'|'masonry'|'grid'|'nomode')}
+ */
+ getMode: function () {
+ var mode = 'slideshow';
+ if (this.$target.hasClass('o_masonry')) {
+ mode = 'masonry';
+ }
+ if (this.$target.hasClass('o_grid')) {
+ mode = 'grid';
+ }
+ if (this.$target.hasClass('o_nomode')) {
+ mode = 'nomode';
+ }
+ return mode;
+ },
+ /**
+ * Displays the images with the "grid" layout.
+ */
+ grid: function () {
+ var imgs = this._getImages();
+ var $row = $('<div/>', {class: 'row s_nb_column_fixed'});
+ var columns = this._getColumns();
+ var colClass = 'col-lg-' + (12 / columns);
+ var $container = this._replaceContent($row);
+
+ _.each(imgs, function (img, index) {
+ var $img = $(img);
+ var $col = $('<div/>', {class: colClass});
+ $col.append($img).appendTo($row);
+ if ((index + 1) % columns === 0) {
+ $row = $('<div/>', {class: 'row s_nb_column_fixed'});
+ $row.appendTo($container);
+ }
+ });
+ this.$target.css('height', '');
+ },
+ /**
+ * Displays the images with the "masonry" layout.
+ */
+ masonry: function () {
+ var self = this;
+ var imgs = this._getImages();
+ var columns = this._getColumns();
+ var colClass = 'col-lg-' + (12 / columns);
+ var cols = [];
+
+ var $row = $('<div/>', {class: 'row s_nb_column_fixed'});
+ this._replaceContent($row);
+
+ // Create columns
+ for (var c = 0; c < columns; c++) {
+ var $col = $('<div/>', {class: 'o_masonry_col o_snippet_not_selectable ' + colClass});
+ $row.append($col);
+ cols.push($col[0]);
+ }
+
+ // Dispatch images in columns by always putting the next one in the
+ // smallest-height column
+ while (imgs.length) {
+ var min = Infinity;
+ var $lowest;
+ _.each(cols, function (col) {
+ var $col = $(col);
+ var height = $col.is(':empty') ? 0 : $col.find('img').last().offset().top + $col.find('img').last().height() - self.$target.offset().top;
+ if (height < min) {
+ min = height;
+ $lowest = $col;
+ }
+ });
+ $lowest.append(imgs.shift());
+ }
+ },
+ /**
+ * Allows to change the images layout. @see grid, masonry, nomode, slideshow
+ *
+ * @see this.selectClass for parameters
+ */
+ mode: function (previewMode, widgetValue, params) {
+ widgetValue = widgetValue || 'slideshow'; // FIXME should not be needed
+ this.$target.css('height', '');
+ this.$target
+ .removeClass('o_nomode o_masonry o_grid o_slideshow')
+ .addClass('o_' + widgetValue);
+ this[widgetValue]();
+ this.trigger_up('cover_update');
+ this._refreshPublicWidgets();
+ },
+ /**
+ * Displays the images with the standard layout: floating images.
+ */
+ nomode: function () {
+ var $row = $('<div/>', {class: 'row s_nb_column_fixed'});
+ var imgs = this._getImages();
+
+ this._replaceContent($row);
+
+ _.each(imgs, function (img) {
+ var wrapClass = 'col-lg-3';
+ if (img.width >= img.height * 2 || img.width > 600) {
+ wrapClass = 'col-lg-6';
+ }
+ var $wrap = $('<div/>', {class: wrapClass}).append(img);
+ $row.append($wrap);
+ });
+ },
+ /**
+ * Allows to remove all images. Restores the snippet to the way it was when
+ * it was added in the page.
+ *
+ * @see this.selectClass for parameters
+ */
+ removeAllImages: function (previewMode) {
+ var $addImg = $('<div>', {
+ class: 'alert alert-info css_non_editable_mode_hidden text-center',
+ });
+ var $text = $('<span>', {
+ class: 'o_add_images',
+ style: 'cursor: pointer;',
+ text: _t(" Add Images"),
+ });
+ var $icon = $('<i>', {
+ class: ' fa fa-plus-circle',
+ });
+ this._replaceContent($addImg.append($icon).append($text));
+ },
+ /**
+ * Displays the images with a "slideshow" layout.
+ */
+ slideshow: function () {
+ const imageEls = this._getImages();
+ const images = _.map(imageEls, img => ({
+ // Use getAttribute to get the attribute value otherwise .src
+ // returns the absolute url.
+ src: img.getAttribute('src'),
+ alt: img.getAttribute('alt'),
+ }));
+ var currentInterval = this.$target.find('.carousel:first').attr('data-interval');
+ var params = {
+ images: images,
+ index: 0,
+ title: "",
+ interval: currentInterval || 0,
+ id: 'slideshow_' + new Date().getTime(),
+ attrClass: imageEls.length > 0 ? imageEls[0].className : '',
+ attrStyle: imageEls.length > 0 ? imageEls[0].style.cssText : '',
+ },
+ $slideshow = $(qweb.render('website.gallery.slideshow', params));
+ this._replaceContent($slideshow);
+ _.each(this.$('img'), function (img, index) {
+ $(img).attr({contenteditable: true, 'data-index': index});
+ });
+ this.$target.css('height', Math.round(window.innerHeight * 0.7));
+
+ // Apply layout animation
+ this.$target.off('slide.bs.carousel').off('slid.bs.carousel');
+ this.$('li.fa').off('click');
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Handles image removals and image index updates.
+ *
+ * @override
+ */
+ notify: function (name, data) {
+ this._super(...arguments);
+ if (name === 'image_removed') {
+ data.$image.remove(); // Force the removal of the image before reset
+ this.mode('reset', this.getMode());
+ } else if (name === 'image_index_request') {
+ var imgs = this._getImages();
+ var position = _.indexOf(imgs, data.$image[0]);
+ imgs.splice(position, 1);
+ switch (data.position) {
+ case 'first':
+ imgs.unshift(data.$image[0]);
+ break;
+ case 'prev':
+ imgs.splice(position - 1, 0, data.$image[0]);
+ break;
+ case 'next':
+ imgs.splice(position + 1, 0, data.$image[0]);
+ break;
+ case 'last':
+ imgs.push(data.$image[0]);
+ break;
+ }
+ position = imgs.indexOf(data.$image[0]);
+ _.each(imgs, function (img, index) {
+ // Note: there might be more efficient ways to do that but it is
+ // more simple this way and allows compatibility with 10.0 where
+ // indexes were not the same as positions.
+ $(img).attr('data-index', index);
+ });
+ const currentMode = this.getMode();
+ this.mode('reset', currentMode);
+ if (currentMode === 'slideshow') {
+ const $carousel = this.$target.find('.carousel');
+ $carousel.removeClass('slide');
+ $carousel.carousel(position);
+ this.$target.find('.carousel-indicators li').removeClass('active');
+ this.$target.find('.carousel-indicators li[data-slide-to="' + position + '"]').addClass('active');
+ this.trigger_up('activate_snippet', {
+ $snippet: this.$target.find('.carousel-item.active img'),
+ ifInactiveOptions: true,
+ });
+ $carousel.addClass('slide');
+ } else {
+ this.trigger_up('activate_snippet', {
+ $snippet: data.$image,
+ ifInactiveOptions: true,
+ });
+ }
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _adaptNavigationIDs: function () {
+ var uuid = new Date().getTime();
+ this.$target.find('.carousel').attr('id', 'slideshow_' + uuid);
+ _.each(this.$target.find('[data-slide], [data-slide-to]'), function (el) {
+ var $el = $(el);
+ if ($el.attr('data-target')) {
+ $el.attr('data-target', '#slideshow_' + uuid);
+ } else if ($el.attr('href')) {
+ $el.attr('href', '#slideshow_' + uuid);
+ }
+ });
+ },
+ /**
+ * @override
+ */
+ _computeWidgetState: function (methodName, params) {
+ switch (methodName) {
+ case 'mode': {
+ let activeModeName = 'slideshow';
+ for (const modeName of params.possibleValues) {
+ if (this.$target.hasClass(`o_${modeName}`)) {
+ activeModeName = modeName;
+ break;
+ }
+ }
+ this.activeMode = activeModeName;
+ return activeModeName;
+ }
+ case 'columns': {
+ return `${this._getColumns()}`;
+ }
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @private
+ */
+ async _computeWidgetVisibility(widgetName, params) {
+ if (widgetName === 'slideshow_mode_opt') {
+ return false;
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * Returns the images, sorted by index.
+ *
+ * @private
+ * @returns {DOMElement[]}
+ */
+ _getImages: function () {
+ var imgs = this.$('img').get();
+ var self = this;
+ imgs.sort(function (a, b) {
+ return self._getIndex(a) - self._getIndex(b);
+ });
+ return imgs;
+ },
+ /**
+ * Returns the index associated to a given image.
+ *
+ * @private
+ * @param {DOMElement} img
+ * @returns {integer}
+ */
+ _getIndex: function (img) {
+ return img.dataset.index || 0;
+ },
+ /**
+ * Returns the currently selected column option.
+ *
+ * @private
+ * @returns {integer}
+ */
+ _getColumns: function () {
+ return parseInt(this.$target.attr('data-columns')) || 3;
+ },
+ /**
+ * Empties the container, adds the given content and returns the container.
+ *
+ * @private
+ * @param {jQuery} $content
+ * @returns {jQuery} the main container of the snippet
+ */
+ _replaceContent: function ($content) {
+ var $container = this.$('> .container, > .container-fluid, > .o_container_small');
+ $container.empty().append($content);
+ return $container;
+ },
+});
+
+options.registry.gallery_img = options.Class.extend({
+ /**
+ * Rebuilds the whole gallery when one image is removed.
+ *
+ * @override
+ */
+ onRemove: function () {
+ this.trigger_up('option_update', {
+ optionName: 'gallery',
+ name: 'image_removed',
+ data: {
+ $image: this.$target,
+ },
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Allows to change the position of an image (its order in the image set).
+ *
+ * @see this.selectClass for parameters
+ */
+ position: function (previewMode, widgetValue, params) {
+ this.trigger_up('option_update', {
+ optionName: 'gallery',
+ name: 'image_index_request',
+ data: {
+ $image: this.$target,
+ position: widgetValue,
+ },
+ });
+ },
+});
+});
diff --git a/addons/website/static/src/snippets/s_masonry_block/000.scss b/addons/website/static/src/snippets/s_masonry_block/000.scss
new file mode 100644
index 00000000..19e10320
--- /dev/null
+++ b/addons/website/static/src/snippets/s_masonry_block/000.scss
@@ -0,0 +1,93 @@
+.s_masonry_block:not([data-vcss]) {
+ .row, .row > div {
+ display: flex;
+ }
+
+ .row > div {
+ padding-bottom: $grid-gutter-width/2;
+ padding-top: $grid-gutter-width/2;
+ justify-content: center;
+ flex-flow: column wrap;
+ }
+
+ .container-fluid > .row {
+ flex-flow: row nowrap;
+
+ @include media-breakpoint-down(md) {
+ flex-flow: column nowrap;
+ }
+
+ > div.s_masonry_block_pseudo_col {
+ flex: 1 1 auto;
+ padding:0;
+
+ > .row {
+ flex-flow: row wrap;
+ min-height: 100%;
+ margin: 0;
+
+ @include media-breakpoint-down(sm) {
+ flex-flow: column nowrap;
+ }
+
+ > div {
+ min-height: 50%;
+ flex: 1 1 auto;
+ }
+ }
+ }
+ }
+
+ &.s_ratio_2_1 {
+ .row > div {
+ padding-top: $grid-gutter-width;
+ padding-bottom: $grid-gutter-width;
+ }
+ }
+}
+
+html[data-no-flex] .s_masonry_block:not([data-vcss]) {
+ min-height: 340px;
+ > div {
+ height: 100%;
+ }
+
+ .row {
+ height: 100%;
+
+ > div {
+ position: relative;
+ height: 100%;
+ min-height: 170px;
+ padding-top: 0;
+ padding-left: 0;
+ }
+ }
+
+ .content {
+ @include clearfix;
+ }
+
+ @include media-breakpoint-up(md) {
+ .row .row > div {
+ height: 50%;
+ }
+ }
+
+ @include media-breakpoint-up(lg) {
+ height: 0px; // hack to force height chain
+ &.s_ratio_2_1 {
+ position: relative;
+ padding: 0 0 50% 0; // to have 2:1 aspect ratio
+ > div {
+ padding-top: 0;
+ padding-bottom:0;
+ @include o-position-absolute(0, 0, 0, 0);
+ }
+ }
+
+ .content {
+ @include o-position-absolute($s-masonry-block-content-top, $s-masonry-block-content-right, $s-masonry-block-content-bottom, $s-masonry-block-content-left);
+ }
+ }
+}
diff --git a/addons/website/static/src/snippets/s_masonry_block/000_variables.scss b/addons/website/static/src/snippets/s_masonry_block/000_variables.scss
new file mode 100644
index 00000000..2261e63a
--- /dev/null
+++ b/addons/website/static/src/snippets/s_masonry_block/000_variables.scss
@@ -0,0 +1,4 @@
+$s-masonry-block-content-top: 35%;
+$s-masonry-block-content-right: 25%;
+$s-masonry-block-content-bottom: 35%;
+$s-masonry-block-content-left: 25%;
diff --git a/addons/website/static/src/snippets/s_masonry_block/001.scss b/addons/website/static/src/snippets/s_masonry_block/001.scss
new file mode 100644
index 00000000..f5020295
--- /dev/null
+++ b/addons/website/static/src/snippets/s_masonry_block/001.scss
@@ -0,0 +1,5 @@
+.s_masonry_block[data-vcss='001'] .row > div {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+}
diff --git a/addons/website/static/src/snippets/s_media_list/000.scss b/addons/website/static/src/snippets/s_media_list/000.scss
new file mode 100644
index 00000000..6c22e352
--- /dev/null
+++ b/addons/website/static/src/snippets/s_media_list/000.scss
@@ -0,0 +1,27 @@
+
+.s_media_list:not([data-vcss]) {
+ .row {
+ margin: $grid-gutter-width 0;
+ }
+ [class*="col-"] {
+ padding: 0;
+ }
+ .s_media_list_body {
+ padding: $grid-gutter-width;
+ background-color: gray('white');
+ }
+ .s_media_list_options {
+ @include o-position-absolute(auto,0,0,0);
+ display: flex;
+ border-top: 1px solid gray('400');
+ .s_media_list_option {
+ flex: 1 1 auto;
+ padding: $grid-gutter-width/3 0;
+ border-right: 1px solid gray('400');
+ text-align: center;
+ &:last-child {
+ border-right: 0;
+ }
+ }
+ }
+}
diff --git a/addons/website/static/src/snippets/s_media_list/001.scss b/addons/website/static/src/snippets/s_media_list/001.scss
new file mode 100644
index 00000000..aae95e2c
--- /dev/null
+++ b/addons/website/static/src/snippets/s_media_list/001.scss
@@ -0,0 +1,11 @@
+.s_media_list[data-vcss="001"] {
+ .s_media_list_item > .row {
+ overflow: hidden; // To support rounded option
+ }
+ .s_media_list_body {
+ padding: $spacer * 2;
+ }
+ .s_media_list_img {
+ object-fit: cover;
+ }
+}
diff --git a/addons/website/static/src/snippets/s_media_list/options.js b/addons/website/static/src/snippets/s_media_list/options.js
new file mode 100644
index 00000000..ee277fcf
--- /dev/null
+++ b/addons/website/static/src/snippets/s_media_list/options.js
@@ -0,0 +1,50 @@
+odoo.define('website.s_media_list_options', function (require) {
+'use strict';
+
+const options = require('web_editor.snippets.options');
+
+options.registry.MediaItemLayout = options.Class.extend({
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Change the media item layout.
+ *
+ * @see this.selectClass for parameters
+ */
+ layout: function (previewMode, widgetValue, params) {
+ const $image = this.$target.find('.s_media_list_img_wrapper');
+ const $content = this.$target.find('.s_media_list_body');
+
+ for (const possibleValue of params.possibleValues) {
+ $image.removeClass(`col-lg-${possibleValue}`);
+ $content.removeClass(`col-lg-${12 - possibleValue}`);
+ }
+ $image.addClass(`col-lg-${widgetValue}`);
+ $content.addClass(`col-lg-${12 - widgetValue}`);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState(methodName, params) {
+ switch (methodName) {
+ case 'layout': {
+ const $image = this.$target.find('.s_media_list_img_wrapper');
+ for (const possibleValue of params.possibleValues) {
+ if ($image.hasClass(`col-lg-${possibleValue}`)) {
+ return possibleValue;
+ }
+ }
+ }
+ }
+ return this._super(...arguments);
+ },
+});
+});
diff --git a/addons/website/static/src/snippets/s_popup/000.js b/addons/website/static/src/snippets/s_popup/000.js
new file mode 100644
index 00000000..2b11466e
--- /dev/null
+++ b/addons/website/static/src/snippets/s_popup/000.js
@@ -0,0 +1,119 @@
+odoo.define('website.s_popup', function (require) {
+'use strict';
+
+const config = require('web.config');
+const publicWidget = require('web.public.widget');
+const utils = require('web.utils');
+
+const PopupWidget = publicWidget.Widget.extend({
+ selector: '.s_popup',
+ events: {
+ 'click .js_close_popup': '_onCloseClick',
+ 'hide.bs.modal': '_onHideModal',
+ },
+
+ /**
+ * @override
+ */
+ start: function () {
+ this._popupAlreadyShown = !!utils.get_cookie(this.$el.attr('id'));
+ if (!this._popupAlreadyShown) {
+ this._bindPopup();
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._super.apply(this, arguments);
+ $(document).off('mouseleave.open_popup');
+ this.$target.find('.modal').modal('hide');
+ clearTimeout(this.timeout);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _bindPopup: function () {
+ const $main = this.$target.find('.modal');
+
+ let display = $main.data('display');
+ let delay = $main.data('showAfter');
+
+ if (config.device.isMobile) {
+ if (display === 'mouseExit') {
+ display = 'afterDelay';
+ delay = 5000;
+ }
+ this.$('.modal').removeClass('s_popup_middle').addClass('s_popup_bottom');
+ }
+
+ if (display === 'afterDelay') {
+ this.timeout = setTimeout(() => this._showPopup(), delay);
+ } else {
+ $(document).on('mouseleave.open_popup', () => this._showPopup());
+ }
+ },
+ /**
+ * @private
+ */
+ _hidePopup: function () {
+ this.$target.find('.modal').modal('hide');
+ },
+ /**
+ * @private
+ */
+ _showPopup: function () {
+ if (this._popupAlreadyShown) {
+ return;
+ }
+ this.$target.find('.modal').modal('show');
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onCloseClick: function () {
+ this._hidePopup();
+ },
+ /**
+ * @private
+ */
+ _onHideModal: function () {
+ const nbDays = this.$el.find('.modal').data('consentsDuration');
+ utils.set_cookie(this.$el.attr('id'), true, nbDays * 24 * 60 * 60);
+ this._popupAlreadyShown = true;
+ },
+});
+
+publicWidget.registry.popup = PopupWidget;
+
+// Prevent bootstrap to prevent scrolling and to add the strange body
+// padding-right they add if the popup does not use a backdrop (especially
+// important for default cookie bar).
+const _baseSetScrollbar = $.fn.modal.Constructor.prototype._setScrollbar;
+$.fn.modal.Constructor.prototype._setScrollbar = function () {
+ if (this._element.classList.contains('s_popup_no_backdrop')) {
+ return;
+ }
+ return _baseSetScrollbar.apply(this, ...arguments);
+};
+const _baseGetScrollbarWidth = $.fn.modal.Constructor.prototype._getScrollbarWidth;
+$.fn.modal.Constructor.prototype._getScrollbarWidth = function () {
+ if (this._element.classList.contains('s_popup_no_backdrop')) {
+ return 0;
+ }
+ return _baseGetScrollbarWidth.apply(this, ...arguments);
+};
+
+return PopupWidget;
+});
diff --git a/addons/website/static/src/snippets/s_popup/000.scss b/addons/website/static/src/snippets/s_popup/000.scss
new file mode 100644
index 00000000..02738156
--- /dev/null
+++ b/addons/website/static/src/snippets/s_popup/000.scss
@@ -0,0 +1,99 @@
+// s_popup
+.s_popup_main:not([data-vcss]) {
+ .s_popup_content {
+ // keep lower than <p> height (cookies bar)
+ min-height: $o-font-size-base * $o-line-height-base;
+ box-shadow: $modal-content-box-shadow-sm-up;
+ .container {
+ // keep margin when fixed bottom
+ @include make-container();
+ }
+ }
+
+ &.modal:not(.d-none) {
+ display: block !important;
+ }
+
+ $popup-close-size: 1.5rem;
+ .s_popup_close {
+ position: absolute;
+ background: white;
+ height: $popup-close-size;
+ width: $popup-close-size;
+ line-height: $popup-close-size;
+ margin-top: -1 * $popup-close-size / 2;
+ margin-right: -1 * $popup-close-size / 2;
+ border-radius: $popup-close-size / 2;
+ right: 0px;
+ top: 0px;
+ box-shadow: rgba(0,0,0,0.8) 0 0 5px;
+ cursor: pointer;
+ text-align: center;
+ z-index: 1;
+ font-size: $popup-close-size;
+ }
+
+ &.s_popup_center {
+ .s_popup_full {
+ @include o-position-absolute(0, 0, 0, 0);
+ &.modal-dialog {
+ max-width: 100%;
+ padding: 0 !important;
+ margin: 0 !important;
+
+ .modal-content {
+ height: 100%;
+ width: 100%;
+ justify-content: center;
+ }
+ }
+ .s_popup_close {
+ font-size: 60px;
+ margin: 10px;
+ background: none;
+ box-shadow: none;
+ }
+ }
+ }
+
+ &.s_popup_fixed {
+ &.s_popup_fixed_top {
+ .s_popup_content {
+ top: $o-navbar-height;
+ }
+ }
+ &:not(.s_popup_fixed_top) {
+ .s_popup_content {
+ bottom: 0;
+ }
+ }
+ .s_popup_content {
+ z-index: $zindex-modal;
+ position: fixed;
+ right: 20px;
+ }
+ .modal-sm .s_popup_content {
+ width: $modal-sm;
+ }
+ .modal-md .s_popup_content {
+ width: $o-modal-md;
+ }
+ .modal-lg .s_popup_content {
+ width: $o-modal-lg;
+ }
+ .modal-xl .s_popup_content {
+ width: $modal-xl;
+ }
+ .s_popup_full .s_popup_content {
+ right: 0;
+ left: 0;
+ .s_popup_close {
+ box-shadow: none;
+ font-size: 60px;
+ margin: 10px;
+ background: none;
+ }
+
+ }
+ }
+}
diff --git a/addons/website/static/src/snippets/s_popup/001.scss b/addons/website/static/src/snippets/s_popup/001.scss
new file mode 100644
index 00000000..f8ae94ca
--- /dev/null
+++ b/addons/website/static/src/snippets/s_popup/001.scss
@@ -0,0 +1,72 @@
+.s_popup[data-vcss='001'] {
+ .modal-content {
+ min-height: $font-size-lg * 2;
+ max-height: none;
+ border: 0;
+ border-radius: 0;
+ box-shadow: $modal-content-box-shadow-sm-up;
+ }
+
+ .modal-dialog {
+ height: auto;
+ min-height: 100%;
+ }
+
+ // Close icon
+ .s_popup_close {
+ z-index: $zindex-modal;
+ @include o-position-absolute(0, 0);
+ width: $font-size-lg * 2;
+ height: $font-size-lg * 2;
+ line-height: $font-size-lg * 2;
+ @include o-bg-color(color-yiq(o-color('primary')), o-color('primary'), $with-extras: false);
+ box-shadow: $box-shadow-sm;
+ cursor: pointer;
+ font-size: $font-size-lg;
+ text-align: center;
+ }
+
+ // Size option - Full
+ .s_popup_size_full {
+ padding: 0 !important;
+ max-width: 100%;
+
+ > .modal-content {
+ // Use the backdrop color as background-color
+ background-color: transparent;
+ box-shadow: none;
+ border-radius: 0;
+ }
+ }
+
+ // Position option - Middle
+ .s_popup_middle .modal-dialog {
+ align-items: center;
+ }
+
+ // Position option - Top/Bottom
+ .s_popup_top,
+ .s_popup_bottom {
+ .modal-dialog {
+ margin-right: 0;
+ &:not(.s_popup_size_full) {
+ padding: $spacer !important;
+ }
+ }
+ }
+ .s_popup_top .modal-dialog {
+ align-items: flex-start;
+ }
+ .s_popup_bottom .modal-dialog {
+ align-items: flex-end;
+ }
+
+ // No backdrop
+ .s_popup_no_backdrop {
+ pointer-events: none;
+
+ .modal-content {
+ pointer-events: auto;
+ }
+ }
+}
diff --git a/addons/website/static/src/snippets/s_popup/options.js b/addons/website/static/src/snippets/s_popup/options.js
new file mode 100644
index 00000000..f31c00d3
--- /dev/null
+++ b/addons/website/static/src/snippets/s_popup/options.js
@@ -0,0 +1,114 @@
+odoo.define('website.s_popup_options', function (require) {
+'use strict';
+
+const options = require('web_editor.snippets.options');
+
+options.registry.SnippetPopup = options.Class.extend({
+ /**
+ * @override
+ */
+ start: function () {
+ // Note: the link are excluded here so that internal modal buttons do
+ // not close the popup as we want to allow edition of those buttons.
+ this.$target.on('click.SnippetPopup', '.js_close_popup:not(a, .btn)', ev => {
+ ev.stopPropagation();
+ this.onTargetHide();
+ this.trigger_up('snippet_option_visibility_update', {show: false});
+ });
+ this.$target.on('shown.bs.modal.SnippetPopup', () => {
+ this.trigger_up('snippet_option_visibility_update', {show: true});
+ });
+ this.$target.on('hidden.bs.modal.SnippetPopup', () => {
+ this.trigger_up('snippet_option_visibility_update', {show: false});
+ });
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._super(...arguments);
+ this.$target.off('.SnippetPopup');
+ },
+ /**
+ * @override
+ */
+ onBuilt: function () {
+ this._assignUniqueID();
+ },
+ /**
+ * @override
+ */
+ onClone: function () {
+ this._assignUniqueID();
+ },
+ /**
+ * @override
+ */
+ onTargetShow: async function () {
+ this.$target.modal('show');
+ $(document.body).children('.modal-backdrop:last').addClass('d-none');
+ },
+ /**
+ * @override
+ */
+ onTargetHide: async function () {
+ return new Promise(resolve => {
+ const timeoutID = setTimeout(() => {
+ this.$target.off('hidden.bs.modal.popup_on_target_hide');
+ resolve();
+ }, 500);
+ this.$target.one('hidden.bs.modal.popup_on_target_hide', () => {
+ clearTimeout(timeoutID);
+ resolve();
+ });
+ this.$target.modal('hide');
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Moves the snippet in footer to be common to all pages
+ * or inside wrap to be on one page only
+ *
+ * @see this.selectClass for parameters
+ */
+ moveBlock: function (previewMode, widgetValue, params) {
+ const $container = $(widgetValue === 'moveToFooter' ? 'footer' : 'main');
+ this.$target.closest('.s_popup').prependTo($container.find('.oe_structure:o_editable').first());
+ },
+ /**
+ * @see this.selectClass for parameters
+ */
+ setBackdrop(previewMode, widgetValue, params) {
+ const color = widgetValue ? 'var(--black-50)' : '';
+ this.$target[0].style.setProperty('background-color', color, 'important');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Creates a unique ID.
+ *
+ * @private
+ */
+ _assignUniqueID: function () {
+ this.$target.closest('.s_popup').attr('id', 'sPopup' + Date.now());
+ },
+ /**
+ * @override
+ */
+ _computeWidgetState: function (methodName, params) {
+ switch (methodName) {
+ case 'moveBlock':
+ return this.$target.closest('footer').length ? 'moveToFooter' : 'moveToBody';
+ }
+ return this._super(...arguments);
+ },
+});
+});
diff --git a/addons/website/static/src/snippets/s_process_steps/000.scss b/addons/website/static/src/snippets/s_process_steps/000.scss
new file mode 100644
index 00000000..3daae1d9
--- /dev/null
+++ b/addons/website/static/src/snippets/s_process_steps/000.scss
@@ -0,0 +1,52 @@
+.s_process_steps {
+ .s_process_step_icon {
+ margin: $grid-gutter-width 0;
+ span {
+ display: block;
+ overflow: hidden;
+ }
+ .fa {
+ display: block;
+ }
+ }
+ .s_process_step_content {
+ padding: 0 $grid-gutter-width/2;
+ }
+ @include media-breakpoint-up(lg) {
+ overflow-x: hidden;
+ .s_process_step {
+ .s_process_step_icon {
+ position: relative;
+ z-index: 1;
+ span:after {
+ content: '';
+ z-index: -1;
+ border-top: 1px solid gray('500');
+ @include o-position-absolute(50%, 0, 0, auto);
+ }
+ }
+ .s_process_step_icon {
+ span:after {
+ width: 100%;
+ }
+ }
+ &:first-child .s_process_step_icon,
+ &:last-child .s_process_step_icon {
+ span:after {
+ width: 50%;
+ }
+ }
+ &:first-child .s_process_step_icon {
+ .fa:after { right: 0; }
+ .fa.float-right:after { width: 0; }
+ }
+ &:last-child .s_process_step_icon {
+ span:after { left: 0; }
+ .fa {
+ &:after { left: 0; }
+ &.float-left:after { width: 0; }
+ }
+ }
+ }
+ }
+}
diff --git a/addons/website/static/src/snippets/s_product_catalog/001.scss b/addons/website/static/src/snippets/s_product_catalog/001.scss
new file mode 100644
index 00000000..7e0b2350
--- /dev/null
+++ b/addons/website/static/src/snippets/s_product_catalog/001.scss
@@ -0,0 +1,29 @@
+.s_product_catalog[data-vcss='001'] {
+ .s_product_catalog_dish {
+ // Title
+ .s_product_catalog_dish_title {
+ line-height: $headings-line-height;
+ }
+ // Description
+ .s_product_catalog_dish_description {
+ margin-bottom: $spacer;
+ }
+ &:last-child {
+ .s_product_catalog_dish_description {
+ margin-bottom: 0;
+ }
+ }
+ // Dot Leaders
+ .s_product_catalog_dish_dot_leaders {
+ display: flex;
+ flex-grow: 1;
+ align-items: center;
+ &::after {
+ content: '';
+ margin-left: $spacer/2;
+ flex: 1 0 auto;
+ border-bottom: 1px dotted;
+ }
+ }
+ }
+}
diff --git a/addons/website/static/src/snippets/s_product_catalog/options.js b/addons/website/static/src/snippets/s_product_catalog/options.js
new file mode 100644
index 00000000..ef78a364
--- /dev/null
+++ b/addons/website/static/src/snippets/s_product_catalog/options.js
@@ -0,0 +1,66 @@
+
+odoo.define('website.s_product_catalog_options', function (require) {
+'use strict';
+
+const core = require('web.core');
+const options = require('web_editor.snippets.options');
+
+const _t = core._t;
+
+options.registry.ProductCatalog = options.Class.extend({
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Show/hide descriptions.
+ *
+ * @see this.selectClass for parameters
+ */
+ toggleDescription: function (previewMode, widgetValue, params) {
+ const $dishes = this.$('.s_product_catalog_dish');
+ const $name = $dishes.find('.s_product_catalog_dish_name');
+ $name.toggleClass('s_product_catalog_dish_dot_leaders', !widgetValue);
+ if (widgetValue) {
+ _.each($dishes, el => {
+ const $description = $(el).find('.s_product_catalog_dish_description');
+ if ($description.length) {
+ $description.removeClass('d-none');
+ } else {
+ const descriptionEl = document.createElement('p');
+ descriptionEl.classList.add('s_product_catalog_dish_description', 'border-top', 'text-muted', 'pt-1', 'o_default_snippet_text');
+ const iEl = document.createElement('i');
+ iEl.textContent = _t("Add a description here");
+ descriptionEl.appendChild(iEl);
+ el.appendChild(descriptionEl);
+ }
+ });
+ } else {
+ _.each($dishes, el => {
+ const $description = $(el).find('.s_product_catalog_dish_description');
+ if ($description.hasClass('o_default_snippet_text') || $description.find('.o_default_snippet_text').length) {
+ $description.remove();
+ } else {
+ $description.addClass('d-none');
+ }
+ });
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState: function (methodName, params) {
+ if (methodName === 'toggleDescription') {
+ const $description = this.$('.s_product_catalog_dish_description');
+ return $description.length && !$description.hasClass('d-none');
+ }
+ return this._super(...arguments);
+ },
+});
+});
diff --git a/addons/website/static/src/snippets/s_product_list/000.scss b/addons/website/static/src/snippets/s_product_list/000.scss
new file mode 100644
index 00000000..480a58ec
--- /dev/null
+++ b/addons/website/static/src/snippets/s_product_list/000.scss
@@ -0,0 +1,45 @@
+
+.s_product_list {
+ padding-top: 20px;
+
+ > div > .row > div {
+ margin-bottom: 20px; // without this style the columns go directly to the top of the bellow ones.
+
+ height: 200px;
+ text-align: center;
+
+ a {
+ display: block;
+ }
+
+ img {
+ margin: auto;
+ max-height: 130px;
+ @include s-product-list-img-hook;
+ }
+
+ .s_product_list_item_link {
+ @include o-position-absolute($left: 10%, $bottom: 0, $right: 10%);
+
+ > .btn {
+ width: 100%;
+ padding: 5px !important;
+ font-size: 16px;
+
+ @media only screen and (max-width : 1280px) { // FIXME
+ font-size: 12px;
+ }
+
+ .fa {
+ font-size: 18px;
+ padding-right: 5px;
+
+ @media only screen and (max-width : 1024px) { // FIXME
+ display: block;
+ font-size: 25px;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/addons/website/static/src/snippets/s_product_list/000_variables.scss b/addons/website/static/src/snippets/s_product_list/000_variables.scss
new file mode 100644
index 00000000..8ce4e819
--- /dev/null
+++ b/addons/website/static/src/snippets/s_product_list/000_variables.scss
@@ -0,0 +1 @@
+@mixin s-product-list-img-hook {}
diff --git a/addons/website/static/src/snippets/s_progress_bar/options.js b/addons/website/static/src/snippets/s_progress_bar/options.js
new file mode 100644
index 00000000..d3f544a0
--- /dev/null
+++ b/addons/website/static/src/snippets/s_progress_bar/options.js
@@ -0,0 +1,80 @@
+odoo.define('website.s_progress_bar_options', function (require) {
+'use strict';
+
+const core = require('web.core');
+const utils = require('web.utils');
+const options = require('web_editor.snippets.options');
+
+const _t = core._t;
+
+options.registry.progress = options.Class.extend({
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Changes the position of the progressbar text.
+ *
+ * @see this.selectClass for parameters
+ */
+ display: function (previewMode, widgetValue, params) {
+ // retro-compatibility
+ if (this.$target.hasClass('progress')) {
+ this.$target.removeClass('progress');
+ this.$target.find('.progress-bar').wrap($('<div/>', {
+ class: 'progress',
+ }));
+ this.$target.find('.progress-bar span').addClass('s_progress_bar_text');
+ }
+
+ let $text = this.$target.find('.s_progress_bar_text');
+ if (!$text.length) {
+ $text = $('<span/>').addClass('s_progress_bar_text').html(_t('80% Development'));
+ }
+
+ if (widgetValue === 'inline') {
+ $text.appendTo(this.$target.find('.progress-bar'));
+ } else {
+ $text.insertBefore(this.$target.find('.progress'));
+ }
+ },
+ /**
+ * Sets the progress bar value.
+ *
+ * @see this.selectClass for parameters
+ */
+ progressBarValue: function (previewMode, widgetValue, params) {
+ let value = parseInt(widgetValue);
+ value = utils.confine(value, 0, 100);
+ const $progressBar = this.$target.find('.progress-bar');
+ const $progressBarText = this.$target.find('.s_progress_bar_text');
+ // Target precisely the XX% not only XX to not replace wrong element
+ // eg 'Since 1978 we have completed 45%' <- don't replace 1978
+ $progressBarText.text($progressBarText.text().replace(/[0-9]+%/, value + '%'));
+ $progressBar.attr("aria-valuenow", value);
+ $progressBar.css("width", value + "%");
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState: function (methodName, params) {
+ switch (methodName) {
+ case 'display': {
+ const isInline = this.$target.find('.s_progress_bar_text')
+ .parent('.progress-bar').length;
+ return isInline ? 'inline' : 'below';
+ }
+ case 'progressBarValue': {
+ return this.$target.find('.progress-bar').attr('aria-valuenow') + '%';
+ }
+ }
+ return this._super(...arguments);
+ },
+});
+});
diff --git a/addons/website/static/src/snippets/s_quotes_carousel/000.scss b/addons/website/static/src/snippets/s_quotes_carousel/000.scss
new file mode 100644
index 00000000..8da0a16c
--- /dev/null
+++ b/addons/website/static/src/snippets/s_quotes_carousel/000.scss
@@ -0,0 +1,22 @@
+.s_quotes_carousel:not([data-vcss]) {
+ blockquote {
+ padding: $grid-gutter-width;
+ margin-bottom: 0;
+ .s_quotes_carousel_icon {
+ position: absolute;
+ top: 0;
+ left: -3rem;
+ }
+ img {
+ max-width: 40px;
+ margin-right: 5px;
+ border-radius: 50%;
+ }
+ footer {
+ background-color: transparent;
+ &:before {
+ content:"";
+ }
+ }
+ }
+}
diff --git a/addons/website/static/src/snippets/s_quotes_carousel/001.scss b/addons/website/static/src/snippets/s_quotes_carousel/001.scss
new file mode 100644
index 00000000..c26dff12
--- /dev/null
+++ b/addons/website/static/src/snippets/s_quotes_carousel/001.scss
@@ -0,0 +1,8 @@
+.s_quotes_carousel_wrapper[data-vcss='001'] {
+ .s_blockquote {
+ margin-bottom: 0;
+ @include media-breakpoint-down(sm) {
+ width: 100% !important; // TODO add the right class in the xml when it's possible
+ }
+ }
+}
diff --git a/addons/website/static/src/snippets/s_rating/000.scss b/addons/website/static/src/snippets/s_rating/000.scss
new file mode 100644
index 00000000..968ee7d3
--- /dev/null
+++ b/addons/website/static/src/snippets/s_rating/000.scss
@@ -0,0 +1,55 @@
+
+.s_rating:not([data-vcss]) {
+ $star: "\f005";
+ $star-o: "\f006";
+ $circle: "\f111";
+ $circle-o: "\f10c";
+ $heart: "\f004";
+ $heart-o: "\f08a";
+ @mixin s_rating_generate_icons($off, $on) {
+ .fa:before {
+ content: $off;
+ }
+ @for $counter from 5 to 0 {
+ &.s_rating_#{$counter} {
+ .fa:nth-of-type(-n+#{$counter}):before {
+ content: $on;
+ }
+ }
+ }
+ }
+ > .s_rating_stars { @include s_rating_generate_icons($star-o, $star); }
+ > .s_rating_squares { @include s_rating_generate_icons($circle-o, $circle); }
+ > .s_rating_hearts { @include s_rating_generate_icons($heart-o, $heart); }
+ > .s_rating_bar {
+ .fa {
+ display: none;
+ }
+ .s_rating_bar {
+ display: flex;
+ height: $progress-height;
+ background-color: $gray-300;
+ &:before {
+ content: "";
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ @include transition($progress-bar-transition);
+ @include gradient-striped();
+ background-size: $progress-height $progress-height;
+ background-color: theme-color('primary');
+ animation: progress-bar-stripes $progress-bar-animation-timing;
+ }
+ }
+ @for $counter from 5 to 0 {
+ &.s_rating_#{$counter} {
+ .s_rating_bar:before {
+ width: percentage($counter/5);
+ }
+ }
+ }
+ }
+ > .s_rating_1x { .fa { font-size: 1em; }; }
+ > .s_rating_2x { .fa { font-size: 2em; }; }
+ > .s_rating_3x { .fa { font-size: 3em; }; }
+}
diff --git a/addons/website/static/src/snippets/s_rating/001.scss b/addons/website/static/src/snippets/s_rating/001.scss
new file mode 100644
index 00000000..a1fc87c0
--- /dev/null
+++ b/addons/website/static/src/snippets/s_rating/001.scss
@@ -0,0 +1,15 @@
+
+.s_rating[data-vcss="001"] {
+ &.s_rating_inline {
+ display: flex;
+ align-items: center;
+
+ .s_rating_title {
+ margin: 0;
+ margin-right: 0.5em;
+ }
+ .s_rating_icons {
+ margin-left: auto;
+ }
+ }
+}
diff --git a/addons/website/static/src/snippets/s_rating/options.js b/addons/website/static/src/snippets/s_rating/options.js
new file mode 100644
index 00000000..b24d7daf
--- /dev/null
+++ b/addons/website/static/src/snippets/s_rating/options.js
@@ -0,0 +1,151 @@
+odoo.define('website.s_rating_options', function (require) {
+'use strict';
+
+const weWidgets = require('wysiwyg.widgets');
+const options = require('web_editor.snippets.options');
+
+options.registry.Rating = options.Class.extend({
+ /**
+ * @override
+ */
+ start: function () {
+ this.iconType = this.$target[0].dataset.icon;
+ this.faClassActiveCustomIcons = this.$target[0].dataset.activeCustomIcon || '';
+ this.faClassInactiveCustomIcons = this.$target[0].dataset.inactiveCustomIcon || '';
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Displays the selected icon type.
+ *
+ * @see this.selectClass for parameters
+ */
+ setIcons: function (previewMode, widgetValue, params) {
+ this.iconType = widgetValue;
+ this._renderIcons();
+ this.$target[0].dataset.icon = widgetValue;
+ delete this.$target[0].dataset.activeCustomIcon;
+ delete this.$target[0].dataset.inactiveCustomIcon;
+ },
+ /**
+ * Allows to select a font awesome icon with media dialog.
+ *
+ * @see this.selectClass for parameters
+ */
+ customIcon: async function (previewMode, widgetValue, params) {
+ return new Promise(resolve => {
+ const dialog = new weWidgets.MediaDialog(
+ this,
+ {noImages: true, noDocuments: true, noVideos: true, mediaWidth: 1920},
+ $('<i/>')
+ );
+ this._saving = false;
+ dialog.on('save', this, function (attachments) {
+ this._saving = true;
+ const customClass = 'fa ' + attachments.className;
+ const $activeIcons = this.$target.find('.s_rating_active_icons > i');
+ const $inactiveIcons = this.$target.find('.s_rating_inactive_icons > i');
+ const $icons = params.customActiveIcon === 'true' ? $activeIcons : $inactiveIcons;
+ $icons.removeClass().addClass(customClass);
+ this.faClassActiveCustomIcons = $activeIcons.length > 0 ? $activeIcons.attr('class') : customClass;
+ this.faClassInactiveCustomIcons = $inactiveIcons.length > 0 ? $inactiveIcons.attr('class') : customClass;
+ this.$target[0].dataset.activeCustomIcon = this.faClassActiveCustomIcons;
+ this.$target[0].dataset.inactiveCustomIcon = this.faClassInactiveCustomIcons;
+ this.$target[0].dataset.icon = 'custom';
+ this.iconType = 'custom';
+ resolve();
+ });
+ dialog.on('closed', this, function () {
+ if (!this._saving) {
+ resolve();
+ }
+ });
+ dialog.open();
+ });
+ },
+ /**
+ * Sets the number of active icons.
+ *
+ * @see this.selectClass for parameters
+ */
+ activeIconsNumber: function (previewMode, widgetValue, params) {
+ this.nbActiveIcons = parseInt(widgetValue);
+ this._createIcons();
+ },
+ /**
+ * Sets the total number of icons.
+ *
+ * @see this.selectClass for parameters
+ */
+ totalIconsNumber: function (previewMode, widgetValue, params) {
+ this.nbTotalIcons = Math.max(parseInt(widgetValue), 1);
+ this._createIcons();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState: function (methodName, params) {
+ switch (methodName) {
+ case 'setIcons': {
+ return this.$target[0].dataset.icon;
+ }
+ case 'activeIconsNumber': {
+ this.nbActiveIcons = this.$target.find('.s_rating_active_icons > i').length;
+ return this.nbActiveIcons;
+ }
+ case 'totalIconsNumber': {
+ this.nbTotalIcons = this.$target.find('.s_rating_icons i').length;
+ return this.nbTotalIcons;
+ }
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * Creates the icons.
+ *
+ * @private
+ */
+ _createIcons: function () {
+ const $activeIcons = this.$target.find('.s_rating_active_icons');
+ const $inactiveIcons = this.$target.find('.s_rating_inactive_icons');
+ this.$target.find('.s_rating_icons i').remove();
+ for (let i = 0; i < this.nbTotalIcons; i++) {
+ if (i < this.nbActiveIcons) {
+ $activeIcons.append('<i/> ');
+ } else {
+ $inactiveIcons.append('<i/> ');
+ }
+ }
+ this._renderIcons();
+ },
+ /**
+ * Renders icons with selected fonts.
+ *
+ * @private
+ */
+ _renderIcons: function () {
+ const icons = {
+ 'fa-star': 'fa-star-o',
+ 'fa-thumbs-up': 'fa-thumbs-o-up',
+ 'fa-circle': 'fa-circle-o',
+ 'fa-square': 'fa-square-o',
+ 'fa-heart': 'fa-heart-o'
+ };
+ const faClassActiveIcons = (this.iconType === "custom") ? this.faClassActiveCustomIcons : 'fa ' + this.iconType;
+ const faClassInactiveIcons = (this.iconType === "custom") ? this.faClassInactiveCustomIcons : 'fa ' + icons[this.iconType];
+ const $activeIcons = this.$target.find('.s_rating_active_icons > i');
+ const $inactiveIcons = this.$target.find('.s_rating_inactive_icons > i');
+ $activeIcons.removeClass().addClass(faClassActiveIcons);
+ $inactiveIcons.removeClass().addClass(faClassInactiveIcons);
+ },
+});
+});
diff --git a/addons/website/static/src/snippets/s_references/000.scss b/addons/website/static/src/snippets/s_references/000.scss
new file mode 100644
index 00000000..c4a00320
--- /dev/null
+++ b/addons/website/static/src/snippets/s_references/000.scss
@@ -0,0 +1,4 @@
+
+.s_references .img-thumbnail {
+ border: none;
+}
diff --git a/addons/website/static/src/snippets/s_share/000.js b/addons/website/static/src/snippets/s_share/000.js
new file mode 100644
index 00000000..b6d630b1
--- /dev/null
+++ b/addons/website/static/src/snippets/s_share/000.js
@@ -0,0 +1,49 @@
+odoo.define('website.s_share', function (require) {
+'use strict';
+
+const publicWidget = require('web.public.widget');
+
+const ShareWidget = publicWidget.Widget.extend({
+ selector: '.s_share, .oe_share', // oe_share for compatibility
+
+ /**
+ * @override
+ */
+ start: function () {
+ var urlRegex = /(\?(?:|.*&)(?:u|url|body)=)(.*?)(&|#|$)/;
+ var titleRegex = /(\?(?:|.*&)(?:title|text|subject)=)(.*?)(&|#|$)/;
+ var url = encodeURIComponent(window.location.href);
+ var title = encodeURIComponent($('title').text());
+ this.$('a').each(function () {
+ var $a = $(this);
+ $a.attr('href', function (i, href) {
+ return href.replace(urlRegex, function (match, a, b, c) {
+ return a + url + c;
+ }).replace(titleRegex, function (match, a, b, c) {
+ if ($a.hasClass('s_share_whatsapp')) {
+ // WhatsApp does not support the "url" GET parameter.
+ // Instead we need to include the url within the passed "text" parameter, merging everything together.
+ // e.g of output:
+ // https://wa.me/?text=%20OpenWood%20Collection%20Online%20Reveal%20%7C%20My%20Website%20http%3A%2F%2Flocalhost%3A8888%2Fevent%2Fopenwood-collection-online-reveal-2021-06-21-2021-06-23-8%2Fregister
+ // see https://faq.whatsapp.com/general/chats/how-to-use-click-to-chat/ for more details
+ return a + title + url + c;
+ }
+ return a + title + c;
+ });
+ });
+ if ($a.attr('target') && $a.attr('target').match(/_blank/i) && !$a.closest('.o_editable').length) {
+ $a.on('click', function () {
+ window.open(this.href, '', 'menubar=no,toolbar=no,resizable=yes,scrollbars=yes,height=550,width=600');
+ return false;
+ });
+ }
+ });
+
+ return this._super.apply(this, arguments);
+ },
+});
+
+publicWidget.registry.share = ShareWidget;
+
+return ShareWidget;
+});
diff --git a/addons/website/static/src/snippets/s_share/000.scss b/addons/website/static/src/snippets/s_share/000.scss
new file mode 100644
index 00000000..85ab9edd
--- /dev/null
+++ b/addons/website/static/src/snippets/s_share/000.scss
@@ -0,0 +1,65 @@
+
+.s_share {
+ > * {
+ display: inline-block;
+ vertical-align: middle;
+ }
+ .s_share_title {
+ margin: 0 .4rem 0 0;
+ }
+ a {
+ i.fa {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
+ margin: .2rem;
+ }
+ &:not(.no_icon_color) {
+ .s_share_facebook {
+ &, &:hover, &:focus {
+ @extend .text-facebook;
+ }
+ }
+ .s_share_twitter {
+ &, &:hover, &:focus {
+ @extend .text-twitter;
+ }
+ }
+ .s_share_linkedin {
+ &, &:hover, &:focus {
+ @extend .text-linkedin;
+ }
+ }
+ .s_share_google {
+ &, &:hover, &:focus {
+ @extend .text-google-plus;
+ }
+ }
+ .s_share_whatsapp {
+ &, &:hover, &:focus {
+ @extend .text-whatsapp;
+ }
+ }
+ .s_share_pinterest {
+ &, &:hover, &:focus {
+ @extend .text-pinterest;
+ }
+ }
+ .s_share_github {
+ &, &:hover, &:focus {
+ @extend .text-github;
+ }
+ }
+ .s_share_instagram {
+ &, &:hover, &:focus {
+ @extend .text-instagram;
+ }
+ }
+ .s_share_youtube {
+ &, &:hover, &:focus {
+ @extend .text-youtube;
+ }
+ }
+ }
+}
diff --git a/addons/website/static/src/snippets/s_showcase/000.scss b/addons/website/static/src/snippets/s_showcase/000.scss
new file mode 100644
index 00000000..8970e7e2
--- /dev/null
+++ b/addons/website/static/src/snippets/s_showcase/000.scss
@@ -0,0 +1,77 @@
+
+#wrapwrap .s_showcase:not([data-vcss]) {
+ @include media-breakpoint-up(lg) {
+ .container, .container-fluid {
+ position: relative;
+
+ &:before {
+ content: " ";
+ display: block;
+ @include o-position-absolute($left: 50%);
+ height: 100%;
+ border-right: 1px solid gray('200');
+ }
+ }
+ }
+
+ .fa {
+ opacity: 0.5;
+ }
+
+ .text-right{
+ .fa {
+ float: right;
+ margin-left: .5em;
+ }
+ p {
+ float: right;
+ display: block;
+ }
+ }
+ .text-left{
+ .fa {
+ float: left;
+ margin-right: .5em;
+ }
+ p {
+ float: left;
+ }
+ }
+ .row {
+ margin-top: 1em;
+ }
+ .feature p {
+ max-width: 300px;
+ margin-top: 0.6em;
+ clear: both;
+ }
+}
+
+@include media-breakpoint-down(md) {
+ #wrapwrap .s_showcase:not([data-vcss]) {
+ .text-right, .text-left {
+ text-align: center;
+
+ .fa {
+ font-size: 2em;
+ opacity: 0.5;
+ float: none;
+ display: block;
+ position: relative;
+ margin-left: auto;
+ margin-right: auto;
+ }
+ }
+ .feature {
+ margin-bottom: 3em;
+
+ p {
+ float: none;
+ display: block;
+ position: relative;
+ margin-left: auto;
+ margin-right: auto;
+ }
+ }
+ }
+}
diff --git a/addons/website/static/src/snippets/s_showcase/001.scss b/addons/website/static/src/snippets/s_showcase/001.scss
new file mode 100644
index 00000000..f7a8cd3f
--- /dev/null
+++ b/addons/website/static/src/snippets/s_showcase/001.scss
@@ -0,0 +1,46 @@
+.s_showcase[data-vcss='001'] {
+ @include media-breakpoint-up(lg) {
+ // Left-right separator
+ .container, .container-fluid {
+ position: relative;
+
+ &:before {
+ content: " ";
+ display: block;
+ @include o-position-absolute($left: 50%);
+ height: 100%;
+ border-right: 1px solid gray('200');
+ }
+ }
+ // Features
+ .row > div {
+ // Items on left
+ &:nth-child(odd) {
+ text-align: right;
+
+ .s_showcase_icon, p {
+ float: right;
+ }
+
+ .s_showcase_icon {
+ margin-right: 0;
+ margin-left: 15px;
+ }
+ }
+ // Items on right
+ &:nth-child(even) {
+ text-align: left;
+
+ .s_showcase_icon, p {
+ float: left;
+ }
+ }
+ }
+ }
+
+ .s_showcase_icon {
+ // Make the default margin the one for the left aligned icon, as it's what we want on mobile
+ margin-right: 15px;
+ font-size: 36px;
+ }
+}
diff --git a/addons/website/static/src/snippets/s_showcase/options.js b/addons/website/static/src/snippets/s_showcase/options.js
new file mode 100644
index 00000000..b870393d
--- /dev/null
+++ b/addons/website/static/src/snippets/s_showcase/options.js
@@ -0,0 +1,19 @@
+odoo.define('website.s_showcase_options', function (require) {
+'use strict';
+
+const options = require('web_editor.snippets.options');
+
+options.registry.Showcase = options.Class.extend({
+ /**
+ * @override
+ */
+ onMove: function () {
+ const $showcaseCol = this.$target.parent().closest('.row > div');
+ const isLeftCol = $showcaseCol.index() <= 0;
+ const $title = this.$target.children('.s_showcase_title');
+ $title.toggleClass('flex-lg-row-reverse', isLeftCol);
+ $showcaseCol.find('.s_showcase_icon.ml-3').removeClass('ml-3').addClass('ml-lg-3'); // For compatibility with old version
+ $title.find('.s_showcase_icon').toggleClass('mr-lg-0 ml-lg-3', isLeftCol);
+ },
+});
+});
diff --git a/addons/website/static/src/snippets/s_table_of_content/000.js b/addons/website/static/src/snippets/s_table_of_content/000.js
new file mode 100644
index 00000000..fcae4ed7
--- /dev/null
+++ b/addons/website/static/src/snippets/s_table_of_content/000.js
@@ -0,0 +1,77 @@
+odoo.define('website.s_table_of_content', function (require) {
+'use strict';
+
+const publicWidget = require('web.public.widget');
+const {extraMenuUpdateCallbacks} = require('website.content.menu');
+
+const TableOfContent = publicWidget.Widget.extend({
+ selector: 'section .s_table_of_content_navbar_sticky',
+ disabledInEditableMode: false,
+
+ /**
+ * @override
+ */
+ async start() {
+ await this._super(...arguments);
+ this._updateTableOfContentNavbarPosition();
+ extraMenuUpdateCallbacks.push(this._updateTableOfContentNavbarPosition.bind(this));
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ this.$target.css('top', '');
+ this.$target.find('.s_table_of_content_navbar').css('top', '');
+ this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _updateTableOfContentNavbarPosition() {
+ let position = 0;
+ const $fixedElements = $('.o_top_fixed_element');
+ _.each($fixedElements, el => position += $(el).outerHeight());
+ const isHorizontalNavbar = this.$target.hasClass('s_table_of_content_horizontal_navbar');
+ this.$target.css('top', isHorizontalNavbar ? position : '');
+ this.$target.find('.s_table_of_content_navbar').css('top', isHorizontalNavbar ? '' : position + 20);
+ const $mainNavBar = $('#oe_main_menu_navbar');
+ position += $mainNavBar.length ? $mainNavBar.outerHeight() : 0;
+ position += isHorizontalNavbar ? this.$target.outerHeight() : 0;
+ $().getScrollingElement().scrollspy({target: '.s_table_of_content_navbar', method: 'offset', offset: position + 100, alwaysKeepFirstActive: true});
+ },
+});
+
+publicWidget.registry.anchorSlide.include({
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Overridden to add the height of the horizontal sticky navbar at the scroll value
+ * when the link is from the table of content navbar
+ *
+ * @override
+ * @private
+ */
+ _computeExtraOffset() {
+ let extraOffset = this._super(...arguments);
+ if (this.$el.hasClass('table_of_content_link')) {
+ const tableOfContentNavbarEl = this.$el.closest('.s_table_of_content_navbar_sticky.s_table_of_content_horizontal_navbar');
+ if (tableOfContentNavbarEl.length > 0) {
+ extraOffset += $(tableOfContentNavbarEl).outerHeight();
+ }
+ }
+ return extraOffset;
+ },
+});
+
+publicWidget.registry.snippetTableOfContent = TableOfContent;
+
+return TableOfContent;
+});
diff --git a/addons/website/static/src/snippets/s_table_of_content/000.scss b/addons/website/static/src/snippets/s_table_of_content/000.scss
new file mode 100644
index 00000000..670e1365
--- /dev/null
+++ b/addons/website/static/src/snippets/s_table_of_content/000.scss
@@ -0,0 +1,65 @@
+.s_table_of_content:not([data-vcss]) {
+ .s_table_of_content_navbar_wrap {
+ &.s_table_of_content_navbar_sticky {
+ &.s_table_of_content_horizontal_navbar, &.s_table_of_content_vertical_navbar .s_table_of_content_navbar {
+ @include o-position-sticky($top: 0px);
+ }
+ }
+ &:not(.s_table_of_content_navbar_sticky) {
+ &, .s_table_of_content_navbar {
+ top: 0px !important;
+ }
+ }
+ &.s_table_of_content_vertical_navbar .s_table_of_content_navbar {
+ > a.list-group-item-action {
+ background: none;
+ color: inherit;
+ opacity: 0.7;
+ font-weight: $font-weight-normal + 100;
+ padding-left: 3px;
+ transition: padding 0.1s;
+
+ &:before {
+ @include o-position-absolute(10px, auto, 10px, 0);
+ width: 2px;
+ content: "";
+ }
+ &:hover {
+ opacity: 1;
+ }
+ &:focus {
+ background: none;
+ }
+ &.active {
+ background: none;
+ padding-left: 8px;
+ opacity: 1;
+
+ &:before {
+ background-color: theme-color('primary');
+ }
+ }
+ }
+ }
+ &.s_table_of_content_horizontal_navbar {
+ z-index: 1;
+ padding-top: $navbar-padding-y;
+ padding-bottom: $navbar-padding-y;
+ margin-bottom: $spacer * 2;
+
+ .s_table_of_content_navbar {
+ display: inline;
+
+ > a {
+ &.list-group-item-action {
+ width: auto;
+ }
+ &.list-group-item {
+ display: inline-block;
+ margin-bottom: 2px;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/addons/website/static/src/snippets/s_table_of_content/options.js b/addons/website/static/src/snippets/s_table_of_content/options.js
new file mode 100644
index 00000000..3feb0c74
--- /dev/null
+++ b/addons/website/static/src/snippets/s_table_of_content/options.js
@@ -0,0 +1,122 @@
+odoo.define('website.s_table_of_content_options', function (require) {
+'use strict';
+
+const options = require('web_editor.snippets.options');
+
+options.registry.TableOfContent = options.Class.extend({
+ /**
+ * @override
+ */
+ start: function () {
+ this.targetedElements = 'h1, h2';
+ const $headings = this.$target.find(this.targetedElements);
+ if ($headings.length > 0) {
+ this._generateNav();
+ }
+ // Generate the navbar if the content changes
+ const targetNode = this.$target.find('.s_table_of_content_main')[0];
+ const config = {attributes: false, childList: true, subtree: true, characterData: true};
+ this.observer = new MutationObserver(() => this._generateNav());
+ this.observer.observe(targetNode, config);
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ onClone: function () {
+ this._generateNav();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _generateNav: function (ev) {
+ const $nav = this.$target.find('.s_table_of_content_navbar');
+ const $headings = this.$target.find(this.targetedElements);
+ $nav.empty();
+ _.each($headings, el => {
+ const $el = $(el);
+ const id = 'table_of_content_heading_' + _.now() + '_' + _.uniqueId();
+ $('<a>').attr('href', "#" + id)
+ .addClass('table_of_content_link list-group-item list-group-item-action py-2 border-0 rounded-0')
+ .text($el.text())
+ .appendTo($nav);
+ $el.attr('id', id);
+ $el[0].dataset.anchor = 'true';
+ });
+ $nav.find('a:first').addClass('active');
+ },
+});
+
+options.registry.TableOfContentNavbar = options.Class.extend({
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Change the navbar position.
+ *
+ * @see this.selectClass for parameters
+ */
+ navbarPosition: function (previewMode, widgetValue, params) {
+ const $navbar = this.$target;
+ const $mainContent = this.$target.parent().find('.s_table_of_content_main');
+ if (widgetValue === 'top' || widgetValue === 'left') {
+ $navbar.prev().before($navbar);
+ }
+ if (widgetValue === 'left' || widgetValue === 'right') {
+ $navbar.removeClass('s_table_of_content_horizontal_navbar col-lg-12').addClass('s_table_of_content_vertical_navbar col-lg-3');
+ $mainContent.removeClass('col-lg-12').addClass('col-lg-9');
+ $navbar.find('.s_table_of_content_navbar').removeClass('list-group-horizontal-md');
+ }
+ if (widgetValue === 'right') {
+ $navbar.next().after($navbar);
+ }
+ if (widgetValue === 'top') {
+ $navbar.removeClass('s_table_of_content_vertical_navbar col-lg-3').addClass('s_table_of_content_horizontal_navbar col-lg-12');
+ $navbar.find('.s_table_of_content_navbar').addClass('list-group-horizontal-md');
+ $mainContent.removeClass('col-lg-9').addClass('col-lg-12');
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState: function (methodName, params) {
+ switch (methodName) {
+ case 'navbarPosition': {
+ const $navbar = this.$target;
+ if ($navbar.hasClass('s_table_of_content_horizontal_navbar')) {
+ return 'top';
+ } else {
+ const $mainContent = $navbar.parent().find('.s_table_of_content_main');
+ return $navbar.prev().is($mainContent) === true ? 'right' : 'left';
+ }
+ }
+ }
+ return this._super(...arguments);
+ },
+});
+
+options.registry.TableOfContentMainColumns = options.Class.extend({
+ forceNoDeleteButton: true,
+
+ /**
+ * @override
+ */
+ start: function () {
+ const leftPanelEl = this.$overlay.data('$optionsSection')[0];
+ leftPanelEl.querySelector('.oe_snippet_clone').classList.add('d-none'); // TODO improve the way to do that
+ return this._super.apply(this, arguments);
+ },
+});
+});
diff --git a/addons/website/static/src/snippets/s_tabs/001.scss b/addons/website/static/src/snippets/s_tabs/001.scss
new file mode 100644
index 00000000..6e132e91
--- /dev/null
+++ b/addons/website/static/src/snippets/s_tabs/001.scss
@@ -0,0 +1,25 @@
+// Tabs
+.s_tabs[data-vcss="001"] {
+ .s_tabs_content {
+ &.s_tabs_slide_up, &.s_tabs_slide_down, &.s_tabs_slide_left, &.s_tabs_slide_right {
+ > .tab-pane.fade {
+ transition: all 0.2s;
+ }
+ > .tab-pane.fade.show {
+ transform: translateX(0rem) translateY(0rem);
+ }
+ }
+ &.s_tabs_slide_up > .tab-pane.fade {
+ transform: translateY(-1rem);
+ }
+ &.s_tabs_slide_down > .tab-pane.fade {
+ transform: translateY(1rem);
+ }
+ &.s_tabs_slide_left > .tab-pane.fade {
+ transform: translateX(-1rem);
+ }
+ &.s_tabs_slide_right > .tab-pane.fade {
+ transform: translateX(1rem);
+ }
+ }
+}
diff --git a/addons/website/static/src/snippets/s_tabs/options.js b/addons/website/static/src/snippets/s_tabs/options.js
new file mode 100644
index 00000000..b10a7619
--- /dev/null
+++ b/addons/website/static/src/snippets/s_tabs/options.js
@@ -0,0 +1,167 @@
+odoo.define('website.s_tabs_options', function (require) {
+'use strict';
+
+const options = require('web_editor.snippets.options');
+
+options.registry.NavTabs = options.Class.extend({
+ isTopOption: true,
+
+ /**
+ * @override
+ */
+ start: function () {
+ this._findLinksAndPanes();
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ onBuilt: function () {
+ this._generateUniqueIDs();
+ },
+ /**
+ * @override
+ */
+ onClone: function () {
+ this._generateUniqueIDs();
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Creates a new tab and tab-pane.
+ *
+ * @see this.selectClass for parameters
+ */
+ addTab: function (previewMode, widgetValue, params) {
+ var $activeItem = this.$navLinks.filter('.active').parent();
+ var $activePane = this.$tabPanes.filter('.active');
+
+ var $navItem = $activeItem.clone();
+ var $navLink = $navItem.find('.nav-link').removeClass('active show');
+ var $tabPane = $activePane.clone().removeClass('active show');
+ $navItem.insertAfter($activeItem);
+ $tabPane.insertAfter($activePane);
+ this._findLinksAndPanes();
+ this._generateUniqueIDs();
+
+ $navLink.tab('show');
+ },
+ /**
+ * Removes the current active tab and its content.
+ *
+ * @see this.selectClass for parameters
+ */
+ removeTab: function (previewMode, widgetValue, params) {
+ var self = this;
+
+ var $activeLink = this.$navLinks.filter('.active');
+ var $activePane = this.$tabPanes.filter('.active');
+
+ var $next = this.$navLinks.eq((this.$navLinks.index($activeLink) + 1) % this.$navLinks.length);
+
+ return new Promise(resolve => {
+ $next.one('shown.bs.tab', function () {
+ $activeLink.parent().remove();
+ $activePane.remove();
+ self._findLinksAndPanes();
+ resolve();
+ });
+ $next.tab('show');
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetVisibility: async function (widgetName, params) {
+ if (widgetName === 'remove_tab_opt') {
+ return (this.$tabPanes.length > 2);
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @private
+ */
+ _findLinksAndPanes: function () {
+ this.$navLinks = this.$target.find('.nav:first .nav-link');
+ this.$tabPanes = this.$target.find('.tab-content:first .tab-pane');
+ },
+ /**
+ * @private
+ */
+ _generateUniqueIDs: function () {
+ for (var i = 0; i < this.$navLinks.length; i++) {
+ var id = _.now() + '_' + _.uniqueId();
+ var idLink = 'nav_tabs_link_' + id;
+ var idContent = 'nav_tabs_content_' + id;
+ this.$navLinks.eq(i).attr({
+ 'id': idLink,
+ 'href': '#' + idContent,
+ 'aria-controls': idContent,
+ });
+ this.$tabPanes.eq(i).attr({
+ 'id': idContent,
+ 'aria-labelledby': idLink,
+ });
+ }
+ },
+});
+options.registry.NavTabsStyle = options.Class.extend({
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Set the style of the tabs.
+ *
+ * @see this.selectClass for parameters
+ */
+ setStyle: function (previewMode, widgetValue, params) {
+ const $nav = this.$target.find('.s_tabs_nav:first .nav');
+ const isPills = widgetValue === 'pills';
+ $nav.toggleClass('nav-tabs card-header-tabs', !isPills);
+ $nav.toggleClass('nav-pills', isPills);
+ this.$target.find('.s_tabs_nav:first').toggleClass('card-header', !isPills).toggleClass('mb-3', isPills);
+ this.$target.toggleClass('card', !isPills);
+ this.$target.find('.s_tabs_content:first').toggleClass('card-body', !isPills);
+ },
+ /**
+ * Horizontal/vertical nav.
+ *
+ * @see this.selectClass for parameters
+ */
+ setDirection: function (previewMode, widgetValue, params) {
+ const isVertical = widgetValue === 'vertical';
+ this.$target.toggleClass('row s_col_no_resize s_col_no_bgcolor', isVertical);
+ this.$target.find('.s_tabs_nav:first .nav').toggleClass('flex-column', isVertical);
+ this.$target.find('.s_tabs_nav:first > .nav-link').toggleClass('py-2', isVertical);
+ this.$target.find('.s_tabs_nav:first').toggleClass('col-md-3', isVertical);
+ this.$target.find('.s_tabs_content:first').toggleClass('col-md-9', isVertical);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState: function (methodName, params) {
+ switch (methodName) {
+ case 'setStyle':
+ return this.$target.find('.s_tabs_nav:first .nav').hasClass('nav-pills') ? 'pills' : 'tabs';
+ case 'setDirection':
+ return this.$target.find('.s_tabs_nav:first .nav').hasClass('flex-column') ? 'vertical' : 'horizontal';
+ }
+ return this._super(...arguments);
+ },
+});
+});
diff --git a/addons/website/static/src/snippets/s_text_highlight/000.scss b/addons/website/static/src/snippets/s_text_highlight/000.scss
new file mode 100644
index 00000000..913f04bb
--- /dev/null
+++ b/addons/website/static/src/snippets/s_text_highlight/000.scss
@@ -0,0 +1,7 @@
+.s_text_highlight {
+ padding: 1.5rem;
+ border-radius: $border-radius;
+ :last-child {
+ margin-bottom: 0;
+ }
+}
diff --git a/addons/website/static/src/snippets/s_three_columns/000.scss b/addons/website/static/src/snippets/s_three_columns/000.scss
new file mode 100644
index 00000000..24e5224e
--- /dev/null
+++ b/addons/website/static/src/snippets/s_three_columns/000.scss
@@ -0,0 +1,5 @@
+.s_three_columns:not([data-vcss]) {
+ .align-items-stretch > .card {
+ height: 100%;
+ }
+}
diff --git a/addons/website/static/src/snippets/s_timeline/000.scss b/addons/website/static/src/snippets/s_timeline/000.scss
new file mode 100644
index 00000000..4a0ce4df
--- /dev/null
+++ b/addons/website/static/src/snippets/s_timeline/000.scss
@@ -0,0 +1,70 @@
+.s_timeline {
+ .s_timeline_line {
+ position: relative;
+ &:before {
+ content: '';
+ display: block !important; // override portal '#wrap .container' value
+ position: absolute;
+ width: 1px;
+ top: 0px;
+ bottom: 0px;
+ left: 50%;
+ background-color: gray('800');
+ }
+ }
+ .s_timeline_row {
+ align-items: center;
+ .s_timeline_content {
+ align-items: center;
+ justify-content: flex-end;
+ width: 100%;
+ ~ .s_timeline_content {
+ justify-content: flex-start;
+ }
+ }
+ &.flex-row-reverse {
+ .s_timeline_content {
+ flex-direction: row-reverse;
+ }
+ }
+ @include media-breakpoint-up(md) {
+ &.flex-row-reverse {
+ .s_timeline_content {
+ flex-direction: row-reverse;
+ &:not(:last-child) {
+ margin-left: 10%;
+ }
+ }
+ }
+ &:not(.flex-row-reverse) {
+ .s_timeline_content:last-child {
+ margin-left: 10%;
+ }
+ }
+ }
+ }
+ .s_timeline_date {
+ @include media-breakpoint-up(md) {
+ position: absolute;
+ left: 0%;
+ right: 0%;
+ }
+ @include media-breakpoint-down(sm) {
+ position: relative;
+ margin: 20px 0px;
+ }
+ span:not(.fa) {
+ display: inline-block;
+ padding: 5px;
+ }
+ .fa {
+ margin: 0 $grid-gutter-width/2;
+ }
+ text-align: center;
+ }
+ .s_timeline_icon {
+ flex: 0 0 auto;
+ margin: $grid-gutter-width/2;
+ z-index: 1;
+ }
+}
diff --git a/addons/website/static/src/snippets/s_timeline/options.js b/addons/website/static/src/snippets/s_timeline/options.js
new file mode 100644
index 00000000..c1efa23c
--- /dev/null
+++ b/addons/website/static/src/snippets/s_timeline/options.js
@@ -0,0 +1,32 @@
+odoo.define('website.s_timeline_options', function (require) {
+'use strict';
+
+const options = require('web_editor.snippets.options');
+
+options.registry.Timeline = options.Class.extend({
+ /**
+ * @override
+ */
+ start: function () {
+ var $buttons = this.$el.find('we-button');
+ var $overlayArea = this.$overlay.find('.o_overlay_options_wrap');
+ $overlayArea.append($('<div/>').append($buttons));
+
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Moves the card to the right/left.
+ *
+ * @see this.selectClass for parameters
+ */
+ timelineCard: function (previewMode, widgetValue, params) {
+ const $timelineRow = this.$target.closest('.s_timeline_row');
+ $timelineRow.toggleClass('flex-row-reverse flex-row');
+ },
+});
+});
diff --git a/addons/website/static/src/snippets/s_title/000.scss b/addons/website/static/src/snippets/s_title/000.scss
new file mode 100644
index 00000000..a4e76dca
--- /dev/null
+++ b/addons/website/static/src/snippets/s_title/000.scss
@@ -0,0 +1,36 @@
+
+.s_title:not([data-vcss]) {
+ .s_title_boxed {
+ > * {
+ display: inline-block;
+ padding: $grid-gutter-width;
+ border: 1px solid;
+ }
+ }
+ .s_title_lines {
+ overflow: hidden;
+ &:before,
+ &:after {
+ content: "";
+ display: inline-block;
+ vertical-align: middle;
+ width: 100%;
+ border-top: 1px solid;
+ border-top-color: inherit;
+ }
+ &:before { margin: 0 $grid-gutter-width/2 0 -100%; }
+ &:after { margin: 0 -100% 0 $grid-gutter-width/2; }
+ }
+ .s_title_underlined {
+ @extend %o-page-header;
+ }
+ .s_title_small_caps {
+ font-variant: small-caps;
+ }
+ .s_title_transparent {
+ opacity: .5;
+ }
+ .s_title_thin {
+ font-weight: 300;
+ }
+}