media/js/newsletter/management.es6.js (493 lines of code) (raw):

/* * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import FormUtils from './form-utils.es6'; const FXA_NEWSLETTERS = ['firefox-accounts-journey', 'test-pilot']; const FXA_NEWSLETTERS_LOCALES = ['en', 'de', 'fr']; const UNSUB_UNSUBSCRIBED_ALL = 1; let _form; let _userData; let _newsletterData; let _stringData; /** * Returns true if a given value is found in a given list of <select> options. * @param {Array} options * @param {String} value * @returns {Boolean} */ function _hasOption(options, value) { const index = options.findIndex((option) => option.value === value); if (index !== -1) { return true; } return false; } /** * Sets the value of a <select> input to the given value if found. * @param {Array} options * @param {String} value */ function _setOption(options, value) { const index = options.findIndex((option) => option.value === value); if (index !== -1) { options[index].selected = 'selected'; } } /** * Convenience helper for window.fetch() that returns a Promise. * @param {String} url * @param {String} method * @returns {Promise} */ function _fetch(url, method) { return new window.Promise((resolve, reject) => { window .fetch(url, { method: method, headers: { Accept: 'application/json', 'Content-Type': 'application/json' } }) .then((resp) => { if (resp.status >= 200 && resp.status < 300) { resolve(resp.json()); } else { reject(resp); } }) .catch((e) => { reject(e); }); }); } /** * Main newsletter management form object. */ const NewsletterManagementForm = { meetsRequirements: () => { return ( 'Promise' in window && 'fetch' in window && typeof Array.prototype.includes === 'function' ); }, getPageLocale: (locale) => { return typeof locale !== 'undefined' ? locale : document.getElementsByTagName('html')[0].getAttribute('lang'); }, getPageURL: () => { return window.location.href; }, getFormCountry: () => { return _form.querySelector('select[name="country"]').value; }, getFormLang: () => { return _form.querySelector('#id_lang').value; }, /** * Get an array of checked newsletter IDs from the management form. * @returns {Array} */ getCheckedNewsletters: () => { return Array.from( document.querySelectorAll( '.newsletter-table .newsletter-checkbox:checked' ) ).map((newsletter) => newsletter.value); }, /** * Get Basket URL for querying user data. * @returns {String} */ getUserDataURL: () => { const token = FormUtils.getUserToken(); return `${_form.getAttribute('action')}${token}/`; }, /** * Get Basket URL for unsubscribing to all newsletters. * @returns {String} */ getUnsubscribeURL: () => { const token = FormUtils.getUserToken(); return `${_form.getAttribute('data-unsubscribe-url')}${token}/`; }, /** * Get Basket URL for updating user preferences. * @returns {String} */ getFormActionURL: () => { const token = FormUtils.getUserToken(); return `${_form.getAttribute('action')}${token}/`; }, /** * Get Basket URL for querying newsletter data. * @returns {String} */ getNewsletterDataURL: () => { return _form.getAttribute('data-newsletters-url'); }, /** * Get Bedrock URL for querying localized newsletter strings. * @returns {String} */ getNewsletterStringsURL: () => { return _form.getAttribute('data-strings-url'); }, /** * Is the given locale in the list of supported locales for Mozilla account newsletters? * @param {String} locale * @returns {Boolean} */ isFxALocale: (locale) => { const loc = locale.includes('-') ? locale.split('-')[0] : locale; return FXA_NEWSLETTERS_LOCALES.includes(loc); }, /** * Is the given newsletter ID in the list of Mozilla account newsletters? * @param {String} newsletter * @returns */ isFxANewsletter: (newsletter) => { return FXA_NEWSLETTERS.includes(newsletter); }, /** * Has the "Remove me from all subscriptions" form input been checked? * @returns {Boolean} */ shouldUnsubscribeAll: () => { return document.getElementById('id_remove_all').checked; }, /** * Figure out which newsletters to display. * @param {Object} user - user data including array of subscribed newsletters. * @param {Object} newsletters - all available newsletters. * @param {Object} strings - translated newsletter strings. * @returns {Object} newsletters to display. */ filterNewsletterData: (user, newsletters, strings) => { const finalNewsletters = []; const locale = NewsletterManagementForm.getPageLocale(); const isFxALocale = NewsletterManagementForm.isFxALocale(locale); for (const newsletter in newsletters) { if ( !Object.prototype.hasOwnProperty.call(newsletters, newsletter) ) { continue; } /** * Only include a newsletter if 'active' === true AND 'show' === true * OR if user is already subscribed, OR if they have a Mozilla account * and it's an account related newsletter. */ const obj = newsletters[newsletter]; const shouldDisplayNewsletter = (obj.active && obj.show) || user.newsletters.includes(newsletter) || (user.has_fxa && NewsletterManagementForm.isFxANewsletter(newsletter) && isFxALocale); if (!shouldDisplayNewsletter) { continue; } // Replace default newsletter copy with localized translations. if (Object.prototype.hasOwnProperty.call(strings, newsletter)) { obj.title = strings[newsletter].title; // Localized descriptions are optional. if (strings[newsletter].description) { obj.description = strings[newsletter].description; } } // Is user subscribed to newsletter obj.subscribed = user.newsletters.includes(newsletter); // Store reference to newsletter ID obj.newsletter = newsletter; // Ensure there's always an `indented` property for rendering. if (!obj.indent) { obj.indent = false; } // Localized "Subscribe" label copy obj.subscribeCopy = strings['subscribe-copy'].title; finalNewsletters.push(obj); } return NewsletterManagementForm.sortNewsletterData(finalNewsletters); }, /** * Sort newsletter array either by order field (primary) or title (secondary). * @param {Array} newsletters * @returns {Array} */ sortNewsletterData: (newsletters) => { const keyField = newsletters[0].order ? 'order' : 'title'; return newsletters.sort((a, b) => { if (keyField === 'order') { return a.order - b.order; } else { const titleA = a.title.toLowerCase(); const titleB = b.title.toLowerCase(); if (titleA < titleB) { return -1; } if (titleA > titleB) { return 1; } return 0; } }); }, /** * Fetch JSON object of translated newsletter strings from Bedrock. * @returns {Promise} */ getNewsletterStrings: () => { return new window.Promise((resolve, reject) => { const url = NewsletterManagementForm.getNewsletterStringsURL(); _fetch(url, 'GET') .then((resp) => { resolve(resp); }) .catch((e) => { reject(e); }); }); }, /** * Fetch JSON object of all available newsletters from Basket. * @returns {Promise} */ getNewsletterData: () => { return new window.Promise((resolve, reject) => { const url = NewsletterManagementForm.getNewsletterDataURL(); _fetch(url, 'GET') .then((resp) => { resolve(resp.newsletters); }) .catch((e) => { reject(e); }); }); }, /** * Fetch JSON object of user data from Basket * @returns {Promise} */ getUserData: () => { return new window.Promise((resolve, reject) => { const hasFxA = NewsletterManagementForm.getPageURL().indexOf('fxa') !== -1; const action = NewsletterManagementForm.getUserDataURL(); const url = hasFxA ? action : action + '?fxa=1'; _fetch(url, 'GET') .then((resp) => { // if `has_fxa` not returned from basket, set it from the URL if (!resp.has_fxa) { resp.has_fxa = hasFxA; } resolve(resp); }) .catch((e) => { reject(e); }); }); }, /** * Generate HTML markup for newsletter table row. * @param {Object} newsletter * @param {Number} index * @returns {String} */ renderTableRow: (newsletter, index) => { const checked = newsletter.subscribed ? ' checked=""' : ''; const indent = newsletter.indent ? ' class="indented"' : ''; const title = FormUtils.stripHTML(newsletter.title); const desc = FormUtils.stripHTML(newsletter.description); return `<tr${indent}> <th> <h4>${title}</h4> <p>${desc}</p> </th> <td> <label for="id_form-${index}-subscribed_check">${newsletter.subscribeCopy}</label> <input type="checkbox" class="newsletter-checkbox" name="form-${index}-subscribed_check" id="id_form-${index}-subscribed_check" value="${newsletter.newsletter}"${checked}> </td> </tr>`; }, /** * Render an HTML table column of newsletters. * @param {Array} newsletters */ renderNewsletters: (newsletters) => { const rows = newsletters.map(NewsletterManagementForm.renderTableRow); const table = document.querySelector('.newsletter-table tbody'); rows.reverse().forEach((row) => { table.insertAdjacentHTML('afterbegin', row); }); }, /** * Set initial HTML form values based on user data returned from Basket. * @param {Object} userData */ setFormDefaults: (userData) => { const countryOptions = Array.from( document.getElementById('id_country').options ); const langOptions = Array.from( document.getElementById('id_lang').options ); const pageLocale = NewsletterManagementForm.getPageLocale(); let finalCountry; let finalLang; let pageCountry; let pageLang; let userCountry = userData.country; let userLang = userData.lang; // Try to derive lang and country from page locale to use as fallbacks. if (pageLocale.includes('-')) { const codes = pageLocale.toLowerCase().split('-'); pageLang = codes[0]; pageCountry = codes[1]; } else { pageLang = pageCountry = pageLocale.toLowerCase(); } // Default to English / US if matches are not found in the options lists. pageLang = _hasOption(langOptions, pageLang) ? pageLang : 'en'; pageCountry = _hasOption(countryOptions, pageCountry) ? pageCountry : 'us'; // Use basket supplied lang first, falling back to locale. if (userLang) { userLang = userLang.toLowerCase(); /** * Check if basket supplied lang might be in a different format * to our form options. E.g. we have 'es' on our list, but their * language might be 'es-ES'. Try to find a match for their * current lang in our list and use that. */ if (userLang.includes('-')) { userLang = userLang.split('-')[0]; } finalLang = _hasOption(langOptions, userLang) ? userLang : pageLang; } else { finalLang = pageLang; } // Use basket supplied country first, falling back to locale. if (userCountry) { userCountry = userCountry.toLowerCase(); finalCountry = _hasOption(countryOptions, userCountry) ? userCountry : pageCountry; } else { finalCountry = pageCountry; } // Set user email for display document.getElementById('id_email').innerText = userData.email; // Set language and country selection _setOption(langOptions, finalLang); _setOption(countryOptions, finalCountry); }, /** * Renders a list of form error messages. * @param {Array} errors */ renderErrorMessages: (errors) => { const errorContainer = document.querySelector('.mzp-c-form-errors'); const list = errorContainer.querySelector('.mzp-c-form-errors ul'); // clear any previously displayed errors. FormUtils.clearFormErrors(_form); errors.forEach((error) => { list.insertAdjacentElement('afterbegin', error); }); errorContainer.classList.remove('hidden'); window.scrollTo(0, 0); // Scroll to top of page for error visibility. FormUtils.enableFormFields(_form); }, /** * Redirects for `/newsletter/updated/` page on successful POST. */ onFormSuccess: () => { const url = _form.getAttribute('data-updated-url'); if (FormUtils.isWellFormedURL(url)) { window.location.href = url; } else { NewsletterManagementForm.onDataError(); } }, /** * Redirects for `/newsletter/updated/` page on successful unsubscribe * from all newsletters. The `?unsub` param is required to display the * unsubscribe survey form. */ onUnsubscribeAll: () => { const updated = _form.getAttribute('data-updated-url'); const url = `${updated}?unsub=${UNSUB_UNSUBSCRIBED_ALL}`; if (FormUtils.isWellFormedURL(url)) { window.location.href = url; } else { NewsletterManagementForm.onDataError(); } }, /** * Event handler for GET/POST error message processing. * @param {Object} e */ onDataError: (e) => { const msg = e ? e.statusText : null; const errors = []; errors.push(NewsletterManagementForm.handleFormError(msg)); NewsletterManagementForm.renderErrorMessages(errors); if (!msg && window.console && window.console.error) { console.error(e); // eslint-disable-line no-console } }, /** * Returns a localized error string based on given error message ID. * We use server rendered error strings since we need them even when * fetching string JSON might fail. * @param {String} msg * @param {String} newsletterId (optional) * @returns {String} */ handleFormError: (msg, newsletterId) => { const strings = document.querySelector('.template-error-strings'); let error; switch (msg) { case FormUtils.errorList.NOT_FOUND: error = strings.querySelector('.error-token-not-found'); break; case FormUtils.errorList.EMAIL_INVALID_ERROR: error = strings.querySelector('.error-invalid-email'); break; case FormUtils.errorList.NEWSLETTER_ERROR: error = strings.querySelector('.error-invalid-newsletter'); // replace '%newsletter%' placeholder with actual newsletter ID. if (typeof newsletterId === 'string') { const temp = error.textContent.replace( '%newsletter%', newsletterId ); error.textContent = temp; } break; case FormUtils.errorList.COUNTRY_ERROR: error = strings.querySelector('.error-select-country'); break; case FormUtils.errorList.LANGUAGE_ERROR: error = strings.querySelector('.error-select-lang'); break; default: error = strings.querySelector('.error-try-again-later'); } if (error) { return error.cloneNode(true); } return error; }, /** * Gets all checked newsletters from the form and validates those based on the * given list of all valid newsletters. * @param {Array} newsletterData * @returns {Array} of unexpected newsletter IDs (or an empty array if valid) */ validateNewsletters: (newsletterData) => { const newsletters = NewsletterManagementForm.getCheckedNewsletters(); const data = typeof newsletterData === 'object' ? newsletterData : _newsletterData; return newsletters.filter( (newsletter) => !Object.prototype.hasOwnProperty.call(data, newsletter) ); }, /** * Validates all form fields * @returns {Array} of localized error messages (or an empty array if valid) */ validateFields: () => { const errors = []; // Make sure all checked newsletters have valid IDs const unexpected = NewsletterManagementForm.validateNewsletters(); if (unexpected.length > 0) { errors.push( NewsletterManagementForm.handleFormError( FormUtils.errorList.NEWSLETTER_ERROR, unexpected[0] ) ); } // Check for country selection value. const country = NewsletterManagementForm.getFormCountry(); if (!country) { errors.push( NewsletterManagementForm.handleFormError( FormUtils.errorList.COUNTRY_ERROR ) ); } // Check for language selection value. const lang = NewsletterManagementForm.getFormLang(); if (!lang) { errors.push( NewsletterManagementForm.handleFormError( FormUtils.errorList.LANGUAGE_ERROR ) ); } return errors; }, /** * Builds a query parameter string based on form field inputs. * @returns {String} */ serialize: () => { const country = _form.querySelector('select[name="country"]').value; const lang = _form.querySelector('#id_lang').value; const newsletters = encodeURIComponent( NewsletterManagementForm.getCheckedNewsletters().join(',') ); // Source URL (hidden field) const sourceUrl = encodeURIComponent( _form.querySelector('input[name="source_url"]').value ); return `country=${country}&lang=${lang}&newsletters=${newsletters}&optin=Y&source_url=${sourceUrl}`; }, /** * Handles management form submission. * @param {Object} event */ onSubmit: (e) => { e.preventDefault(); FormUtils.disableFormFields(_form); // Perform client side form field validation. const errors = NewsletterManagementForm.validateFields(); if (errors.length > 0) { NewsletterManagementForm.renderErrorMessages(errors); return; } // Has the user checked "Remove me from all the subscriptions"? const unsubscribeAll = NewsletterManagementForm.shouldUnsubscribeAll(); if (unsubscribeAll) { FormUtils.postToBasket( null, `optout=Y`, NewsletterManagementForm.getUnsubscribeURL(), NewsletterManagementForm.onUnsubscribeAll, NewsletterManagementForm.onDataError ); } else { // Update user data to reflect form changes. FormUtils.postToBasket( null, NewsletterManagementForm.serialize(), NewsletterManagementForm.getFormActionURL(), NewsletterManagementForm.onFormSuccess, NewsletterManagementForm.onDataError ); } }, bindEvents: () => { _form.addEventListener( 'submit', NewsletterManagementForm.onSubmit, false ); }, showUpdateBrowserCopy: () => { document .querySelector('.js-outdated-browser-msg') .classList.add('show'); }, showIntroCopy: () => { const intro = document.querySelector('.js-intro-msg'); // Intro copy is conditional on ?confirm=1, so // check that it exists in the DOM before trying // to display it. if (intro) { intro.classList.add('show'); } }, /** * Perform a client side redirect to the /newsletter/recovery/ page. */ redirectToRecoveryPage: () => { const recoveryUrl = _form.getAttribute('data-recovery-url'); if (FormUtils.isWellFormedURL(recoveryUrl)) { window.location.href = recoveryUrl; } else { NewsletterManagementForm.onDataError(); } }, init: () => { if (!NewsletterManagementForm.meetsRequirements()) { NewsletterManagementForm.showUpdateBrowserCopy(); return window.Promise.reject(); } else { NewsletterManagementForm.showIntroCopy(); } _form = document.querySelector('.newsletter-management-form'); // Look for a valid user token before rendering the page. // If not found, redirect to /newsletter/recovery/. return FormUtils.checkForUserToken() .then(() => { const userData = NewsletterManagementForm.getUserData(); const newsletterData = NewsletterManagementForm.getNewsletterData(); const newsletterStrings = NewsletterManagementForm.getNewsletterStrings(); // Display a loading spinner whilst form data is being fetched. const spinnerTarget = _form.querySelector('.loading-spinner'); spinnerTarget.classList.remove('hidden'); // Fetch all the required data needed to render the form. return window.Promise.all([ userData, newsletterData, newsletterStrings ]) .then((data) => { _userData = data[0]; _newsletterData = data[1]; _stringData = data[2]; const newsletters = NewsletterManagementForm.filterNewsletterData( _userData, _newsletterData, _stringData ); NewsletterManagementForm.setFormDefaults(_userData); NewsletterManagementForm.renderNewsletters(newsletters); NewsletterManagementForm.bindEvents(); // Hide loading spinner spinnerTarget.classList.add('hidden'); // display form fields once we've processed the basket data. document .querySelector('.newsletter-management-form-fields') .classList.add('show'); }) .catch((e) => { spinnerTarget.classList.add('hidden'); NewsletterManagementForm.onDataError(e); }); }) .catch(() => { if (!FormUtils.getUserToken()) { NewsletterManagementForm.redirectToRecoveryPage(); } }); } }; export default NewsletterManagementForm;