diff --git a/bedrock/newsletter/templates/newsletter/firefox-confirm.html b/bedrock/newsletter/templates/newsletter/firefox-confirm.html new file mode 100644 index 00000000000..f8a8eef9113 --- /dev/null +++ b/bedrock/newsletter/templates/newsletter/firefox-confirm.html @@ -0,0 +1,69 @@ +{# + 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/. +#} + +{% extends 'base-protocol.html' %} + +{% block page_title %}Firefox Newsletter{% endblock page_title %} + +{% block page_css %} + {{ css_bundle('newsletter-firefox-confirm') }} +{% endblock %} + +{% if LANG == 'de' %} + {% set headline_text = 'Bleib mit Mozilla über unseren Firefox News-Newsletter auf dem Laufenden' %} + {% set tagline_text = 'Wenn du abonnierst, erhältst du aktuelle Produkt-Updates, Expert*innentipps und wichtige News von Mozilla.' %} + {% set cta_text = 'Abonnieren' %} + {% set thanks_text = 'Danke, dass Sie den Newsletter abonniert haben! Ihr Newsletter-Abonnement wurde bestätigt.' %} +{% elif LANG == 'fr' %} + {% set headline_text = 'Restez en lien avec Mozilla grâce à la newsletter Firefox News' %} + {% set tagline_text = 'En vous abonnant, vous recevrez les dernières mises à jour de nos produits, des conseils d’experts et des actualités importantes concernant Mozilla.' %} + {% set cta_text = 'Je m’abonne' %} + {% set thanks_text = 'Merci pour votre inscription ! Votre abonnement à la newsletter Firefox News est maintenant confirmé.' %} +{% else %} + {% set headline_text = 'Stay connected with Mozilla, courtesy of our Firefox News newsletter' %} + {% set tagline_text = 'By subscribing, you’ll receive the latest product updates, expert tips, and important news from Mozilla—ensuring you stay safe and informed online.' %} + {% set cta_text = 'Subscribe' %} + {% set thanks_text = 'Thanks for Subscribing! Your newsletter subscription has been confirmed.' %} +{% endif %} + +{% block content %} +
+
+

{{ headline_text }}

+
+ +
+ + + +

{{ tagline_text }}

+ + +

+ {{ ftl('newsletter-form-we-will-only-send-firefox-v2') }} +

+
+ +
+{% endblock %} + +{% block js %} + {{ js_bundle('newsletter-firefox-confirm') }} +{% endblock %} diff --git a/bedrock/newsletter/urls.py b/bedrock/newsletter/urls.py index 301c62e5222..076827887e6 100644 --- a/bedrock/newsletter/urls.py +++ b/bedrock/newsletter/urls.py @@ -51,6 +51,8 @@ ), name="newsletter.firefox", ), + path("newsletter/firefox/confirm//", views.firefox_confirm, name="newsletter.firefox.confirm"), + path("newsletter/firefox/confirm/", views.firefox_confirm, name="newsletter.firefox.confirm.no-token"), page("newsletter/developer/", "newsletter/developer.html", ftl_files=["mozorg/newsletters"]), page("newsletter/fxa-error/", "newsletter/fxa-error.html", ftl_files=["mozorg/newsletters"]), page("newsletter/family/", "newsletter/family.html", ftl_files=["mozorg/newsletters"], active_locales=["en-US"]), diff --git a/bedrock/newsletter/views.py b/bedrock/newsletter/views.py index cabdeea51b5..cd03d37e2c9 100644 --- a/bedrock/newsletter/views.py +++ b/bedrock/newsletter/views.py @@ -284,3 +284,19 @@ def newsletter_subscribe(request): return l10n_utils.render(request, "newsletter/index.html", ctx, ftl_files=FTL_FILES) return l10n_utils.render(request, "newsletter/index.html", ftl_files=FTL_FILES) + + +def firefox_confirm(request, token=None): + locale = l10n_utils.get_locale(request) + + context = { + "action": f"{settings.BASKET_URL}/news/subscribe/", + "active_locales": ["en-US", "en-GB", "en-CA", "de", "fr"], + "ftl_files": ["mozorg/newsletters"], + "newsletter_lang": locale.split("-")[0], + "newsletters": "mozilla-and-you", + "recovery_url": reverse("newsletter.recovery"), + "source_url": reverse("newsletter.firefox.confirm.no-token"), + } + + return l10n_utils.render(request, "newsletter/firefox-confirm.html", context) diff --git a/media/css/newsletter/newsletter-firefox-confirm.scss b/media/css/newsletter/newsletter-firefox-confirm.scss new file mode 100644 index 00000000000..d8656cdc27a --- /dev/null +++ b/media/css/newsletter/newsletter-firefox-confirm.scss @@ -0,0 +1,41 @@ +// 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/. + +$font-path: '/media/protocol/fonts'; +$image-path: '/media/protocol/img'; + +@import '~@mozilla-protocol/core/protocol/css/includes/lib'; +@import '~@mozilla-protocol/core/protocol/css/components/forms/form'; +@import '~@mozilla-protocol/core/protocol/css/components/forms/field'; +@import '~@mozilla-protocol/core/protocol/css/components/forms/button-container'; + +main { + min-height: 500px; +} + +.c-confirm-form { + margin-top: $layout-lg; +} + +.c-confirm-form-tagline { + @include text-body-xl; +} + +.c-confirm-small { + margin-top: $spacing-lg; +} + +.c-confirm-form-thanks { + margin-top: $layout-lg; + @include text-body-xl; +} + +.c-confirm-form-errors { + max-width: 400px; + margin: 0 auto $spacing-xl; +} + +.c-confirm-error-msg { + margin-bottom: 0; +} diff --git a/media/js/newsletter/confirm-init.es6.js b/media/js/newsletter/confirm-init.es6.js new file mode 100644 index 00000000000..3b266d6899e --- /dev/null +++ b/media/js/newsletter/confirm-init.es6.js @@ -0,0 +1,9 @@ +/* + * 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 ConfirmForm from './confirm.es6'; + +ConfirmForm.init(); diff --git a/media/js/newsletter/confirm.es6.js b/media/js/newsletter/confirm.es6.js new file mode 100644 index 00000000000..98e45c4353c --- /dev/null +++ b/media/js/newsletter/confirm.es6.js @@ -0,0 +1,108 @@ +/* + * 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'; + +let _form; + +const ConfirmationForm = { + meetsRequirements: () => { + return 'Promise' in window; + }, + + handleFormError: (msg) => { + FormUtils.enableFormFields(_form); + _form.querySelector('.mzp-c-form-errors').classList.remove('hidden'); + + if (msg && msg === FormUtils.errorList.TOKEN_INVALID) { + _form + .querySelector('.error-invalid-token') + .classList.remove('hidden'); + } else if (msg && msg === FormUtils.errorList.UPDATE_BROWSER) { + _form + .querySelector('.error-update-browser') + .classList.remove('hidden'); + } else { + _form + .querySelector('.error-try-again-later') + .classList.remove('hidden'); + } + }, + + handleFormSuccess: () => { + _form.classList.add('hidden'); + document + .querySelector('.c-confirm-form-thanks') + .classList.remove('hidden'); + }, + + redirectToRecoveryPage: () => { + const recoveryUrl = _form.getAttribute('data-recovery-url'); + + if (FormUtils.isWellFormedURL(recoveryUrl)) { + window.location.href = recoveryUrl; + } else { + ConfirmationForm.handleFormError(); + } + }, + + getFormActionURL: () => { + return _form.getAttribute('action'); + }, + + serialize: () => { + const params = FormUtils.serialize(_form); + const token = FormUtils.getUserToken(); + + if (params && token) { + return `${params}&token=${token}`; + } + + return ''; + }, + + subscribe: (e) => { + const url = ConfirmationForm.getFormActionURL(); + + e.preventDefault(); + e.stopPropagation(); + + // Disable form fields until POST has completed. + FormUtils.disableFormFields(_form); + + // Clear any prior messages that might have been displayed. + FormUtils.clearFormErrors(_form); + + const params = ConfirmationForm.serialize(); + + FormUtils.postToBasket( + null, + params, + url, + ConfirmationForm.handleFormSuccess, + ConfirmationForm.handleFormError + ); + }, + + init: () => { + _form = document.getElementById('confirmation-form'); + + if (!ConfirmationForm.meetsRequirements()) { + ConfirmationForm.handleFormError('Update your browser'); + return; + } + + _form.addEventListener('submit', ConfirmationForm.subscribe, false); + + // Look for a valid user token before rendering the page. + // If not found, redirect to /newsletter/recovery/. + return FormUtils.checkForUserToken().catch( + ConfirmationForm.redirectToRecoveryPage + ); + } +}; + +export default ConfirmationForm; diff --git a/media/static-bundles.json b/media/static-bundles.json index 124976501b1..ad5804f5d16 100644 --- a/media/static-bundles.json +++ b/media/static-bundles.json @@ -606,6 +606,12 @@ ], "name": "newsletter-firefox" }, + { + "files": [ + "css/newsletter/newsletter-firefox-confirm.scss" + ], + "name": "newsletter-firefox-confirm" + }, { "files": [ "css/mozorg/mpl-2-0.scss" @@ -1362,6 +1368,12 @@ ], "name": "newsletter-firefox-experiment" }, + { + "files": [ + "js/newsletter/confirm-init.es6.js" + ], + "name": "newsletter-firefox-confirm" + }, { "files": [ "js/firefox/features/features-article.es6.js" diff --git a/tests/unit/spec/newsletter/confirm.js b/tests/unit/spec/newsletter/confirm.js new file mode 100644 index 00000000000..cca2c139628 --- /dev/null +++ b/tests/unit/spec/newsletter/confirm.js @@ -0,0 +1,207 @@ +/* + * 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 '../../../../media/js/newsletter/form-utils.es6'; +import ConfirmationForm from '../../../../media/js/newsletter/confirm.es6'; + +const TOKEN_MOCK = 'a1a2a3a4-abc1-12ab-a123-12345a12345b'; + +describe('ConfirmationForm', function () { + beforeEach(async function () { + const form = `
+
+ + + + + +
+ +
`; + document.body.insertAdjacentHTML('beforeend', form); + }); + + afterEach(function () { + const form = document.getElementById('confirm-form-container'); + form.parentNode.removeChild(form); + }); + + describe('form submission', function () { + let xhr; + let xhrRequests = []; + + beforeEach(function () { + xhr = sinon.useFakeXMLHttpRequest(); + xhr.onCreate = (req) => { + xhrRequests.push(req); + }; + }); + + afterEach(function () { + xhr.restore(); + xhrRequests = []; + }); + + it('should handle success', function () { + spyOn(FormUtils, 'getURLToken').and.returnValue(TOKEN_MOCK); + spyOn(FormUtils, 'getUserToken').and.returnValue(TOKEN_MOCK); + spyOn(ConfirmationForm, 'handleFormSuccess').and.callThrough(); + + return ConfirmationForm.init().then(() => { + document.querySelector('.c-confirm-form-submit').click(); + xhrRequests[0].respond( + 200, + { 'Content-Type': 'application/json' }, + '{"status": "ok"}' + ); + + expect(xhrRequests[0].url).toEqual( + 'https://basket.mozilla.org/news/subscribe/' + ); + expect(xhrRequests[0].requestBody).toEqual( + 'newsletters=mozilla-and-you&source_url=https%3A%2F%2Fwww.mozilla.org%2Fen-US%2Fnewsletter%2Ffirefox%2Fconfirm%2F&lang=en&token=a1a2a3a4-abc1-12ab-a123-12345a12345b' + ); + expect(ConfirmationForm.handleFormSuccess).toHaveBeenCalled(); + expect( + document + .querySelector('.c-confirm-form') + .classList.contains('hidden') + ).toBeTrue(); + expect( + document + .querySelector('.c-confirm-form-thanks') + .classList.contains('hidden') + ).toBeFalse(); + }); + }); + + it('should handle invalid token', function () { + spyOn(FormUtils, 'getURLToken').and.returnValue(TOKEN_MOCK); + spyOn(FormUtils, 'getUserToken').and.returnValue(TOKEN_MOCK); + spyOn(ConfirmationForm, 'handleFormError').and.callThrough(); + + return ConfirmationForm.init() + .then() + .then(() => { + document.querySelector('.c-confirm-form-submit').click(); + xhrRequests[0].respond( + 400, + { 'Content-Type': 'application/json' }, + '{"status": "error", "desc": "Invalid basket token"}' + ); + + expect(ConfirmationForm.handleFormError).toHaveBeenCalled(); + expect( + document + .querySelector('.c-confirm-form-errors') + .classList.contains('hidden') + ).toBeFalse(); + expect( + document + .querySelector('.error-invalid-token') + .classList.contains('hidden') + ).toBeFalse(); + }); + }); + + it('should handle unknown error', function () { + spyOn(FormUtils, 'getURLToken').and.returnValue(TOKEN_MOCK); + spyOn(FormUtils, 'getUserToken').and.returnValue(TOKEN_MOCK); + spyOn(ConfirmationForm, 'handleFormError').and.callThrough(); + + return ConfirmationForm.init().then(() => { + document.querySelector('.c-confirm-form-submit').click(); + xhrRequests[0].respond( + 400, + { 'Content-Type': 'application/json' }, + '{"status": "error", "desc": "Unknown non-helpful error"}' + ); + + expect(ConfirmationForm.handleFormError).toHaveBeenCalled(); + expect( + document + .querySelector('.c-confirm-form-errors') + .classList.contains('hidden') + ).toBeFalse(); + expect( + document + .querySelector('.error-try-again-later') + .classList.contains('hidden') + ).toBeFalse(); + }); + }); + + it('should handle failure', function () { + spyOn(FormUtils, 'getURLToken').and.returnValue(TOKEN_MOCK); + spyOn(FormUtils, 'getUserToken').and.returnValue(TOKEN_MOCK); + spyOn(ConfirmationForm, 'handleFormError').and.callThrough(); + + return ConfirmationForm.init().then(() => { + document.querySelector('.c-confirm-form-submit').click(); + xhrRequests[0].respond( + 500, + { 'Content-Type': 'application/json' }, + null + ); + + expect(ConfirmationForm.handleFormError).toHaveBeenCalled(); + expect( + document + .querySelector('.c-confirm-form-errors') + .classList.contains('hidden') + ).toBeFalse(); + expect( + document + .querySelector('.error-try-again-later') + .classList.contains('hidden') + ).toBeFalse(); + }); + }); + + it('should handle an outdated browser', function () { + spyOn(ConfirmationForm, 'handleFormError').and.callThrough(); + spyOn(ConfirmationForm, 'meetsRequirements').and.returnValue(false); + + ConfirmationForm.init(); + + expect(ConfirmationForm.handleFormError).toHaveBeenCalled(); + expect( + document + .querySelector('.c-confirm-form-errors') + .classList.contains('hidden') + ).toBeFalse(); + expect( + document + .querySelector('.error-update-browser') + .classList.contains('hidden') + ).toBeFalse(); + }); + + it('should redirect to /newsletter/recovery/ page if token is missing', function () { + spyOn(FormUtils, 'getURLToken').and.returnValue(''); + spyOn(FormUtils, 'getUserToken').and.returnValue(''); + spyOn(ConfirmationForm, 'redirectToRecoveryPage'); + + return ConfirmationForm.init().then(() => { + expect( + ConfirmationForm.redirectToRecoveryPage + ).toHaveBeenCalled(); + }); + }); + }); +});