diff --git a/addons/web_editor/static/src/js/editor/snippets.options.js b/addons/web_editor/static/src/js/editor/snippets.options.js
index 5d41c04cc4c..e05cd78553b 100644
--- a/addons/web_editor/static/src/js/editor/snippets.options.js
+++ b/addons/web_editor/static/src/js/editor/snippets.options.js
@@ -1956,11 +1956,11 @@ const ListUserValueWidget = UserValueWidget.extend({
/**
* @private
* @param {string || integer} id
- * @param {string} [text]
+ * @param {string} [value]
* @param {Object} [recordData] key, values that will be added to the
* element's dataset
*/
- _addItemToTable(id, text = _t("Item"), recordData) {
+ _addItemToTable(id, value = this.el.dataset.defaultValue || _t("Item"), recordData) {
const trEl = document.createElement('tr');
if (!this.el.dataset.unsortable) {
const draggableEl = document.createElement('we-button');
@@ -1971,9 +1971,9 @@ const ListUserValueWidget = UserValueWidget.extend({
trEl.appendChild(draggableTdEl);
}
const inputEl = document.createElement('input');
- inputEl.type = 'text';
- if (text) {
- inputEl.value = text;
+ inputEl.type = this.el.dataset.inputType || 'text';
+ if (value) {
+ inputEl.value = value;
}
if (id) {
inputEl.name = id;
@@ -2033,7 +2033,7 @@ const ListUserValueWidget = UserValueWidget.extend({
* @private
*/
_notifyCurrentState() {
- const values = [...this.listTable.querySelectorAll('input')].map(el => {
+ const values = [...this.listTable.querySelectorAll('.o_we_list_record_name input')].map(el => {
const id = this.isCustom ? el.value : el.name;
const idInt = parseInt(id);
return Object.assign({
diff --git a/addons/web_editor/static/src/scss/wysiwyg_snippets.scss b/addons/web_editor/static/src/scss/wysiwyg_snippets.scss
index d71b02d1ea0..59dcd9af1f0 100644
--- a/addons/web_editor/static/src/scss/wysiwyg_snippets.scss
+++ b/addons/web_editor/static/src/scss/wysiwyg_snippets.scss
@@ -1360,6 +1360,7 @@ body.editor_enable.editor_has_snippets {
margin-bottom: $o-we-sidebar-content-field-spacing / 2;
table {
+ @include o-input-number-no-arrows();
table-layout: auto;
width: 100%;
diff --git a/addons/website/controllers/main.py b/addons/website/controllers/main.py
index 0a940466aa8..29b7aaf778c 100644
--- a/addons/website/controllers/main.py
+++ b/addons/website/controllers/main.py
@@ -340,6 +340,14 @@ class Website(Home):
t['numOfElFetch'] = attribs.get('data-number-of-elements-fetch')
return templates
+ @http.route('/website/get_current_currency', type='json', auth="public", website=True)
+ def get_current_currency(self, **kwargs):
+ return {
+ 'id': request.website.company_id.currency_id.id,
+ 'symbol': request.website.company_id.currency_id.symbol,
+ 'position': request.website.company_id.currency_id.position,
+ }
+
# ------------------------------------------------------
# Edit
# ------------------------------------------------------
diff --git a/addons/website/static/src/img/snippets_thumbs/s_donation.svg b/addons/website/static/src/img/snippets_thumbs/s_donation.svg
new file mode 100644
index 00000000000..8995ab98e44
--- /dev/null
+++ b/addons/website/static/src/img/snippets_thumbs/s_donation.svg
@@ -0,0 +1,39 @@
+
\ No newline at end of file
diff --git a/addons/website/static/src/img/snippets_thumbs/s_donation_button.svg b/addons/website/static/src/img/snippets_thumbs/s_donation_button.svg
new file mode 100644
index 00000000000..f30edf1b8cb
--- /dev/null
+++ b/addons/website/static/src/img/snippets_thumbs/s_donation_button.svg
@@ -0,0 +1,18 @@
+
diff --git a/addons/website/static/src/scss/primary_variables.scss b/addons/website/static/src/scss/primary_variables.scss
index 2b85ef6056b..dba19881c71 100644
--- a/addons/website/static/src/scss/primary_variables.scss
+++ b/addons/website/static/src/scss/primary_variables.scss
@@ -2057,3 +2057,18 @@ $o-theme-font-configs: (
background-image: $-gradient;
}
};
+
+@mixin o-input-number-no-arrows() {
+ // Remove arrows/spinners from input type number
+ // => Chrome, Safari, Edge, Opera
+ input::-webkit-outer-spin-button,
+ input::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+ }
+
+ // => Firefox
+ input[type=number] {
+ -moz-appearance: textfield;
+ }
+};
diff --git a/addons/website/views/snippets/snippets.xml b/addons/website/views/snippets/snippets.xml
index b833c016c45..7b98ce1e6ff 100644
--- a/addons/website/views/snippets/snippets.xml
+++ b/addons/website/views/snippets/snippets.xml
@@ -123,6 +123,7 @@
+
@@ -152,6 +153,7 @@
cite
+
@@ -167,9 +169,11 @@
+
+
@@ -525,7 +529,7 @@
t-att-data-selector="so_snippet_addition_selector"
data-drop-in=":not(p).oe_structure:not(.oe_structure_solo), :not(.o_mega_menu):not(p)[data-oe-type=html], :not(p).oe_structure.oe_structure_solo:not(:has(> section, > div))"/>
- blockquote, .s_card:not(.s_timeline_card), .s_alert, .o_facebook_page, .s_share, .s_rating, .s_hr, .s_google_map, .s_map, .s_countdown, .s_chart, .s_text_highlight, .s_progress_bar, .s_badge, .s_embed_code
+ blockquote, .s_card:not(.s_timeline_card), .s_alert, .o_facebook_page, .s_share, .s_rating, .s_hr, .s_google_map, .s_map, .s_countdown, .s_chart, .s_text_highlight, .s_progress_bar, .s_badge, .s_embed_code, .s_donation
+
+
+
+
+
+
+
+
+
+
gift1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/website_payment/static/src/snippets/s_donation/000.js b/addons/website_payment/static/src/snippets/s_donation/000.js
new file mode 100644
index 00000000000..067d3f68aff
--- /dev/null
+++ b/addons/website_payment/static/src/snippets/s_donation/000.js
@@ -0,0 +1,114 @@
+/** @odoo-module **/
+
+import {_t} from 'web.core';
+import publicWidget from 'web.public.widget';
+
+publicWidget.registry.DonationSnippet = publicWidget.Widget.extend({
+ selector: '.s_donation',
+ disabledInEditableMode: false,
+ events: {
+ 'click .s_donation_btn': '_onClickPrefilledButton',
+ 'input #s_donation_range_slider': '_onInputRangeSlider',
+ },
+
+ /**
+ * @override
+ */
+ async start() {
+ await this._super(...arguments);
+ this.$rangeSlider = this.$('#s_donation_range_slider');
+ if (this.$rangeSlider.length) {
+ this.$rangeSlider.val(this.$target[0].dataset.defaultAmount);
+ this._setBubble(this.$rangeSlider);
+ }
+ await this._displayCurrencies();
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ this.$target.find('.s_donation_currency').remove();
+ this._deselectPrefilledButtons();
+ this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _deselectPrefilledButtons() {
+ this.$('.s_donation_btn').removeClass('active');
+ },
+ /**
+ * @private
+ * @param {jQuery} $range
+ */
+ _setBubble($range) {
+ const $bubble = this.$('.s_range_bubble');
+ const val = $range.val();
+ const min = $range[0].min || 0;
+ const max = $range[0].max || 100;
+ const newVal = Number(((val - min) * 100) / (max - min));
+ const tipOffsetLow = 8 - (newVal * 0.16); // the range thumb size is 16px*16px. The '8' and the '0.16' are related to that 16px (50% and 1% of 16px)
+ $bubble.contents().filter(function () {
+ return this.nodeType === 3;
+ }).replaceWith(val);
+
+ // Sorta magic numbers based on size of the native UI thumb (source: https://css-tricks.com/value-bubbles-for-range-inputs/)
+ $bubble[0].style.left = `calc(${newVal}% + (${tipOffsetLow}px))`;
+ },
+ /**
+ * @private
+ */
+ _displayCurrencies() {
+ return this._rpc({
+ route: '/website/get_current_currency',
+ }).then((result) => {
+ this.currency = result;
+ this.$('.s_donation_currency').remove();
+ const $prefilledButtons = this.$('.s_donation_btn, .s_range_bubble');
+ _.each($prefilledButtons, button => {
+ const before = result.position === "before";
+ const $currencySymbol = document.createElement('span');
+ $currencySymbol.innerText = result.symbol;
+ $currencySymbol.classList.add('s_donation_currency', before ? "pr-1" : "pl-1");
+ if (before) {
+ $(button).prepend($currencySymbol);
+ } else {
+ $(button).append($currencySymbol);
+ }
+ });
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onClickPrefilledButton(ev) {
+ const $button = $(ev.currentTarget);
+ this._deselectPrefilledButtons();
+ $button.addClass('active');
+ if (this.$rangeSlider.length) {
+ this.$rangeSlider.val($button[0].dataset.donationValue);
+ this._setBubble(this.$rangeSlider);
+ }
+ },
+ /**
+ * @private
+ */
+ _onInputRangeSlider(ev) {
+ this._deselectPrefilledButtons();
+ this._setBubble($(ev.currentTarget));
+ },
+});
+
+export default {
+ DonationSnippet: publicWidget.registry.DonationSnippet,
+};
diff --git a/addons/website_payment/static/src/snippets/s_donation/000.scss b/addons/website_payment/static/src/snippets/s_donation/000.scss
new file mode 100644
index 00000000000..2fde7bb4830
--- /dev/null
+++ b/addons/website_payment/static/src/snippets/s_donation/000.scss
@@ -0,0 +1,56 @@
+.s_donation:not([data-vcss]) {
+ @include o-input-number-no-arrows();
+
+ .s_donation_btn {
+ transition: background 0.2s;
+
+ &:focus {
+ box-shadow: none !important;
+ }
+ }
+ #s_donation_amount_input {
+ border: none;
+ outline: none;
+ max-width: 145px;
+ background-color: transparent;
+ font-size: inherit;
+ color: inherit;
+
+ &::placeholder {
+ opacity: 0.6;
+ color: inherit;
+ }
+ }
+ .s_donation_range_slider_wrap {
+ // not at 100% to prevent the bubble overflowing the viewport
+ // to the right (with small device when set with max value)
+ width: 90%;
+
+ #s_donation_range_slider {
+ margin-bottom: 50px;
+ }
+ .s_range_bubble {
+ position: absolute;
+ left: 0%;
+ margin-top: 28px;
+ padding: 0.5rem 1rem;
+ color: color-yiq(o-color('primary'));
+ background: theme-color('primary');
+ font-size: 1.15rem;
+ border-radius: 0.3rem;
+ transform: translateX(-50%);
+
+ &::after {
+ content: "";
+ position: absolute;
+ top: -5px;
+ left: 50%;
+ width: 0;
+ border-style: solid;
+ border-color: theme-color('primary') transparent;
+ border-width: 0 5px 5px;
+ margin-left: -5px;
+ }
+ }
+ }
+}
diff --git a/addons/website_payment/static/src/snippets/s_donation/000.xml b/addons/website_payment/static/src/snippets/s_donation/000.xml
new file mode 100644
index 00000000000..e0493534d66
--- /dev/null
+++ b/addons/website_payment/static/src/snippets/s_donation/000.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/website_payment/static/src/snippets/s_donation/options.js b/addons/website_payment/static/src/snippets/s_donation/options.js
new file mode 100644
index 00000000000..d1901d0fda3
--- /dev/null
+++ b/addons/website_payment/static/src/snippets/s_donation/options.js
@@ -0,0 +1,303 @@
+/** @odoo-module **/
+
+import {_t, qweb} from 'web.core';
+import options from 'web_editor.snippets.options';
+
+options.registry.Donation = options.Class.extend({
+ xmlDependencies: ['/website_payment/static/src/snippets/s_donation/000.xml'],
+
+ /**
+ * @override
+ */
+ start() {
+ this.defaultDescription = _t("Add a description here");
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ onBuilt() {
+ this._rebuildPrefilledOptions();
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ cleanForSave() {
+ if (!this.$target[0].dataset.descriptions) {
+ this._updateDescriptions();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ async updateUI() {
+ await this._super(...arguments);
+ this._buildDescriptionsList();
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Show/hide options in the page.
+ *
+ * @see this.selectClass for parameters
+ */
+ displayOptions(previewMode, widgetValue, params) {
+ this.$target[0].dataset.displayOptions = widgetValue;
+ this._rebuildPrefilledOptions();
+ },
+ /**
+ * Add/remove prefilled buttons.
+ *
+ * @see this.selectClass for parameters
+ */
+ togglePrefilledOptions(previewMode, widgetValue, params) {
+ this.$target[0].dataset.prefilledOptions = widgetValue;
+ this.$el.find('.o_we_prefilled_options_list').toggleClass('d-none', !widgetValue);
+ this._rebuildPrefilledOptions();
+ },
+ /**
+ * Add/remove description of prefilled buttons.
+ *
+ * @see this.selectClass for parameters
+ */
+ toggleOptionDescription(previewMode, widgetValue, params) {
+ this.$target[0].dataset.descriptions = widgetValue;
+ this.renderListItems(false, this._buildPrefilledOptionsList());
+ },
+ /**
+ * Select an amount input
+ *
+ * @see this.selectClass for parameters
+ */
+ selectAmountInput(previewMode, widgetValue, params) {
+ this.$target[0].dataset.customAmount = widgetValue;
+ this._rebuildPrefilledOptions();
+ },
+ /**
+ * Apply the we-list on the target and rebuild the input(s)
+ *
+ * @see this.selectClass for parameters
+ */
+ renderListItems(previewMode, value, params) {
+ const valueList = JSON.parse(value);
+ const donationAmounts = [];
+ delete this.$target[0].dataset.donationAmounts;
+ _.each(valueList, value => {
+ donationAmounts.push(value.display_name);
+ });
+ this.$target[0].dataset.donationAmounts = JSON.stringify(donationAmounts);
+ this._rebuildPrefilledOptions();
+ },
+ /**
+ * Redraws the target whenever the list changes
+ *
+ * @see this.selectClass for parameters
+ */
+ listChanged(previewMode, value, params) {
+ this._updateDescriptions();
+ this._rebuildPrefilledOptions();
+ },
+ /**
+ * @see this.selectClass for parameters
+ */
+ setMinimumAmount(previewMode, widgetValue, params) {
+ this.$target[0].dataset.minimumAmount = widgetValue;
+ const $rangeSlider = this.$('#s_donation_range_slider');
+ const $amountInput = this.$('#s_donation_amount_input');
+ if ($rangeSlider.length) {
+ $rangeSlider[0].min = widgetValue;
+ } else if ($amountInput.length) {
+ $amountInput[0].min = widgetValue;
+ }
+ },
+ /**
+ * @see this.selectClass for parameters
+ */
+ setMaximumAmount(previewMode, widgetValue, params) {
+ this.$target[0].dataset.maximumAmount = widgetValue;
+ const $rangeSlider = this.$('#s_donation_range_slider');
+ const $amountInput = this.$('#s_donation_amount_input');
+ if ($rangeSlider.length) {
+ $rangeSlider[0].max = widgetValue;
+ } else if ($amountInput.length) {
+ $amountInput[0].max = widgetValue;
+ }
+ },
+ /**
+ * @see this.selectClass for parameters
+ */
+ setSliderStep(previewMode, widgetValue, params) {
+ this.$target[0].dataset.sliderStep = widgetValue;
+ const $rangeSlider = this.$('#s_donation_range_slider');
+ if ($rangeSlider.length) {
+ $rangeSlider[0].step = widgetValue;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState(methodName, params) {
+ switch (methodName) {
+ case 'displayOptions': {
+ return this.$target[0].dataset.displayOptions;
+ }
+ case 'togglePrefilledOptions': {
+ return this.$target[0].dataset.prefilledOptions;
+ }
+ case 'toggleOptionDescription': {
+ return this.$target[0].dataset.descriptions;
+ }
+ case 'selectAmountInput': {
+ return this.$target[0].dataset.customAmount;
+ }
+ case 'renderListItems': {
+ return this._buildPrefilledOptionsList();
+ }
+ case 'setMinimumAmount': {
+ return this.$target[0].dataset.minimumAmount;
+ }
+ case 'setMaximumAmount': {
+ return this.$target[0].dataset.maximumAmount;
+ }
+ case 'setSliderStep': {
+ return this.$target[0].dataset.sliderStep;
+ }
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ _renderCustomXML(uiFragment) {
+ const list = document.createElement('we-list');
+ list.dataset.dependencies = "pre_filled_opt";
+ list.dataset.addItemTitle = _t("Add new pre-filled option");
+ list.dataset.renderListItems = '';
+ list.dataset.unsortable = 'true';
+ list.dataset.inputType = 'number';
+ list.dataset.defaultValue = 50;
+ list.dataset.listChanged = '';
+ $(uiFragment).find('we-checkbox[data-name="pre_filled_opt"]').after(list);
+ },
+ /**
+ * Build the prefilled options list in the editor panel
+ *
+ * @private
+ */
+ _buildPrefilledOptionsList() {
+ const amounts = JSON.parse(this.$target[0].dataset.donationAmounts);
+ let valueList = amounts.map(amount => {
+ return {
+ id: amount,
+ display_name: amount,
+ };
+ });
+ return JSON.stringify(valueList);
+ },
+ /**
+ * Add descriptions in the prefilled options list of the
+ * editor panel.
+ *
+ * @private
+ */
+ _buildDescriptionsList() {
+ if (this.$target[0].dataset.descriptions) {
+ const $descriptions = this.$target.find('#s_donation_description_inputs > input');
+ const $tableEl = this.$el.find('we-list table');
+ _.each($tableEl.find('tr'), (trEl, i) => {
+ const $inputAmount = $(trEl).find('td').first();
+ $inputAmount.addClass('w-25');
+ const tdEl = document.createElement('td');
+ const inputEl = document.createElement('input');
+ inputEl.type = 'text';
+ inputEl.value = $descriptions[i] ? $descriptions[i].value : this.defaultDescription;
+ tdEl.classList.add('w-auto');
+ tdEl.appendChild(inputEl);
+ $(tdEl).insertAfter($inputAmount);
+ });
+ this._updateDescriptions();
+ }
+ },
+ /**
+ * Update descriptions in the input hidden.
+ *
+ * @private
+ */
+ _updateDescriptions() {
+ const descriptionInputs = this.$target.find('#s_donation_description_inputs');
+ descriptionInputs.empty();
+ const descriptions = this.$el.find('we-list input[type=text]');
+ _.each(descriptions, description => {
+ const inputEl = document.createElement('input');
+ inputEl.type = 'hidden';
+ inputEl.classList.add('o_translatable_input_hidden', 'd-block', 'mb-1', 'w-100');
+ inputEl.name = 'donation_descriptions';
+ inputEl.value = description.value;
+ descriptionInputs[0].appendChild(inputEl);
+ });
+ },
+ /**
+ * Rebuild options in the DOM.
+ *
+ * @private
+ */
+ _rebuildPrefilledOptions() {
+ const rebuild = this.$target[0].dataset.displayOptions;
+ this.$target.find('.s_donation_prefilled_buttons').remove();
+ const layout = this.$target[0].dataset.customAmount;
+ const $slider = this.$target.find('.s_donation_range_slider_wrap');
+ if (layout !== "slider" || !rebuild) {
+ $slider.remove();
+ }
+ if (rebuild) {
+ if (layout === "slider" && !$slider.length) {
+ const sliderTemplate = $(qweb.render('website_payment.donation.slider', {
+ minimum_amount: this.$target[0].dataset.minimumAmount,
+ maximum_amount: this.$target[0].dataset.maximumAmount,
+ slider_step: this.$target[0].dataset.sliderStep,
+ }));
+ this.$target.find('.s_donation_donate_btn').before(sliderTemplate);
+ }
+ const prefilledOptions = this.$target[0].dataset.prefilledOptions;
+ let donationAmounts = 0;
+ let showDescriptions = false;
+ if (prefilledOptions) {
+ donationAmounts = JSON.parse(this.$target[0].dataset.donationAmounts);
+ showDescriptions = this.$target[0].dataset.descriptions;
+ if (showDescriptions) {
+ const $descriptions = this.$target.find('#s_donation_description_inputs > input');
+ donationAmounts = donationAmounts.map((amount, i) => {
+ return {
+ value: amount,
+ description: $descriptions[i] ? $descriptions[i].value : this.defaultDescription,
+ };
+ });
+ }
+ }
+ const $prefilledButtons = $(qweb.render(`website_payment.donation.prefilledButtons${showDescriptions ? 'Descriptions' : ''}`, {
+ prefilled_buttons: donationAmounts,
+ custom_input: layout === "freeAmount",
+ minimum_amount: this.$target[0].dataset.minimumAmount,
+ }));
+ this.$target.find('#s_donation_description_inputs').after($prefilledButtons);
+ }
+ },
+});
+
+export default {
+ Donation: options.registry.Donation,
+};
diff --git a/addons/website_payment/views/snippets/s_donation.xml b/addons/website_payment/views/snippets/s_donation.xml
new file mode 100644
index 00000000000..a6ac7193015
--- /dev/null
+++ b/addons/website_payment/views/snippets/s_donation.xml
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
Make a Donation
+
Small or large, your contribution is essential.
+
+
+
+
+
+

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Input
+ Slider
+ None
+
+
+
+
+
+
+
+
+
+
+ Donation 000 JS
+ web.assets_frontend
+ website_payment/static/src/snippets/s_donation/000.js
+
+
+
+ Donation 000 SCSS
+ web.assets_frontend
+ website_payment/static/src/snippets/s_donation/000.scss
+
+
+
diff --git a/addons/website_payment/views/snippets/snippets.xml b/addons/website_payment/views/snippets/snippets.xml
new file mode 100644
index 00000000000..264d708d24c
--- /dev/null
+++ b/addons/website_payment/views/snippets/snippets.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py
index 2c87750f7a2..d83024fdd46 100644
--- a/addons/website_sale/controllers/main.py
+++ b/addons/website_sale/controllers/main.py
@@ -136,6 +136,13 @@ class Website(main.Website):
if view_key in ('website_sale.products_list_view', 'website_sale.add_grid_or_list_option'):
request.session.pop('website_sale_shop_layout_mode', None)
+ @http.route()
+ def get_current_currency(self, **kwargs):
+ return {
+ 'id': request.website.currency_id.id,
+ 'symbol': request.website.currency_id.symbol,
+ 'position': request.website.currency_id.position,
+ }
class WebsiteSale(http.Controller):