diff --git a/app/images/connect_another_service/lockbox.png b/app/images/connect_another_service/lockbox.png new file mode 100644 index 0000000000..59b4f740e8 Binary files /dev/null and b/app/images/connect_another_service/lockbox.png differ diff --git a/app/images/connect_another_service/monitor.png b/app/images/connect_another_service/monitor.png new file mode 100644 index 0000000000..b7a8b681db Binary files /dev/null and b/app/images/connect_another_service/monitor.png differ diff --git a/app/images/connect_another_service/notes.png b/app/images/connect_another_service/notes.png new file mode 100644 index 0000000000..673ef7d6ae Binary files /dev/null and b/app/images/connect_another_service/notes.png differ diff --git a/app/images/connect_another_service/pocket.png b/app/images/connect_another_service/pocket.png new file mode 100644 index 0000000000..7ca9579120 Binary files /dev/null and b/app/images/connect_another_service/pocket.png differ diff --git a/app/images/connect_another_service/screenshots.png b/app/images/connect_another_service/screenshots.png new file mode 100644 index 0000000000..e6c524dbf0 Binary files /dev/null and b/app/images/connect_another_service/screenshots.png differ diff --git a/app/images/open_in_new.svg b/app/images/open_in_new.svg new file mode 100644 index 0000000000..630b4d6bb1 --- /dev/null +++ b/app/images/open_in_new.svg @@ -0,0 +1,5 @@ + + + diff --git a/app/scripts/lib/experiment.js b/app/scripts/lib/experiment.js index 4ed89db66a..67c9cae440 100644 --- a/app/scripts/lib/experiment.js +++ b/app/scripts/lib/experiment.js @@ -21,6 +21,7 @@ const STARTUP_EXPERIMENTS = { * after the app has started. */ const MANUAL_EXPERIMENTS = { + 'connectAnotherService': BaseExperiment, 'emailFirst': BaseExperiment, // For now, the send SMS experiment only needs to log "enrolled", so // no special experiment is created. diff --git a/app/scripts/lib/experiments/grouping-rules/connect-another-service.js b/app/scripts/lib/experiments/grouping-rules/connect-another-service.js new file mode 100644 index 0000000000..f5b69057a2 --- /dev/null +++ b/app/scripts/lib/experiments/grouping-rules/connect-another-service.js @@ -0,0 +1,42 @@ +/* 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 http://mozilla.org/MPL/2.0/. */ + +/** + * Should the user be part of the Connect Another Service + */ +'use strict'; + +const BaseGroupingRule = require('./base'); + +module.exports = class ConnectAnotherServiceGroupingRule extends BaseGroupingRule { + constructor() { + super(); + this.name = 'connectAnotherService'; + this.ROLLOUT_RATE = 0.0; + } + + choose(subject = {}) { + if (! subject.account || ! subject.uniqueUserId || ! subject.userAgent) { + return false; + } + + const { + canSignIn, + isFirefoxAndroid, + isFirefoxIos, + isOtherAndroid, + isOtherIos, + } = subject.userAgent; + + if (this.isTestEmail(subject.account.get('email'))) { + return true; + } else if (! canSignIn && (isFirefoxAndroid || isFirefoxIos || isOtherAndroid || isOtherIos)) { + if (this.bernoulliTrial(this.ROLLOUT_RATE, subject.uniqueUserId)) { + return true; + } + } + + return false; + } +}; diff --git a/app/scripts/lib/experiments/grouping-rules/index.js b/app/scripts/lib/experiments/grouping-rules/index.js index 09e8222f55..d8799d3c63 100644 --- a/app/scripts/lib/experiments/grouping-rules/index.js +++ b/app/scripts/lib/experiments/grouping-rules/index.js @@ -18,6 +18,7 @@ const experimentGroupingRules = [ require('./send-sms-install-link'), require('./sentry'), require('./token-code'), + require('./connect-another-service'), ].map(ExperimentGroupingRule => new ExperimentGroupingRule()); class ExperimentChoiceIndex { diff --git a/app/scripts/lib/router.js b/app/scripts/lib/router.js index 1f0bb2a3ba..73f8fb1f5d 100644 --- a/app/scripts/lib/router.js +++ b/app/scripts/lib/router.js @@ -24,6 +24,7 @@ import CompleteSignUpView from '../views/complete_sign_up'; import ConfirmResetPasswordView from '../views/confirm_reset_password'; import ConfirmView from '../views/confirm'; import ConnectAnotherDeviceView from '../views/connect_another_device'; +import ConnectAnotherServiceView from '../views/connect_another_service'; import CookiesDisabledView from '../views/cookies_disabled'; import DeleteAccountView from '../views/settings/delete_account'; import DisplayNameView from '../views/settings/display_name'; @@ -108,6 +109,7 @@ const Router = Backbone.Router.extend({ 'confirm_signin(/)': createViewHandler(ConfirmView, { type: VerificationReasons.SIGN_IN }), 'connect_another_device(/)': createViewHandler(ConnectAnotherDeviceView), 'connect_another_device/why(/)': createChildViewHandler(WhyConnectAnotherDeviceView, ConnectAnotherDeviceView), + 'connect_another_service(/)': createViewHandler(ConnectAnotherServiceView), 'cookies_disabled(/)': createViewHandler(CookiesDisabledView), 'force_auth(/)': createViewHandler(ForceAuthView), 'legal(/)': createViewHandler('legal'), diff --git a/app/scripts/templates/connect_another_service.mustache b/app/scripts/templates/connect_another_service.mustache new file mode 100644 index 0000000000..3ed7923d4f --- /dev/null +++ b/app/scripts/templates/connect_another_service.mustache @@ -0,0 +1,31 @@ +
+

{{#t}}Connect another service{{/t}}

+ + {{#showSuccessMessage}} + {{#isSignUp}} +
{{#t}}Email verified{{/t}}
+ {{/isSignUp}} + {{#isSignIn}} +
{{#t}}Sign-in confirmed{{/t}}
+ {{/isSignIn}} + {{/showSuccessMessage}} + +

Do more with your Firefox Account with these new services.

+ + {{#connect-services}} +
+
+
+
+
+
{{name}}
+
{{description}}
+
+ + {{/connect-services}} +
diff --git a/app/scripts/views/connect_another_device.js b/app/scripts/views/connect_another_device.js index 8da83f68c2..bd9e84b62a 100644 --- a/app/scripts/views/connect_another_device.js +++ b/app/scripts/views/connect_another_device.js @@ -13,6 +13,7 @@ define(function (require, exports, module) { const Cocktail = require('cocktail'); const ConnectAnotherDeviceMixin = require('./mixins/connect-another-device-mixin'); + const ConnectAnotherServiceExperimentMixin = require('./mixins/connect-another-service-experiment-mixin'); const ExperimentMixin = require('./mixins/experiment-mixin'); const FlowEventsMixin = require('./mixins/flow-events-mixin'); const FormView = require('./form'); @@ -59,6 +60,13 @@ define(function (require, exports, module) { if (country) { return this.replaceCurrentPageWithSmsScreen(account, country, this._showSuccessMessage()); } + + // Check to see if user is in the ConnectAnotherService Experiment and + // navigate to page. + if (this.isInConnectAnotherServiceExperiment()) { + return this.replaceCurrentPageWithAppsScreen(account, this._showSuccessMessage()); + } + }); } @@ -271,7 +279,8 @@ define(function (require, exports, module) { }), SyncAuthMixin, UserAgentMixin, - VerificationReasonMixin + VerificationReasonMixin, + ConnectAnotherServiceExperimentMixin, ); module.exports = ConnectAnotherDeviceView; diff --git a/app/scripts/views/connect_another_service.js b/app/scripts/views/connect_another_service.js new file mode 100644 index 0000000000..a83acff389 --- /dev/null +++ b/app/scripts/views/connect_another_service.js @@ -0,0 +1,115 @@ +/* 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 http://mozilla.org/MPL/2.0/. */ + +import Cocktail from 'cocktail'; +import BaseView from './base'; +import Template from 'templates/connect_another_service.mustache'; +import UserAgentMixin from '../lib/user-agent-mixin'; +import VerificationReasonMixin from './mixins/verification-reason-mixin'; + +const SERVICES = [ + { + description: 'A Secure Notepad App.', + image: 'notes', + links: { + android: 'https://play.google.com/store/apps/details?id=org.mozilla.testpilot.notes&hl=en', + }, + name: 'Notes', + }, + { + description: 'Save articles, videos and stories from any publication, page or app.', + image: 'pocket', + links: { + android: 'https://getpocket.com/ff_signin?s=pocket&t=login', + ios: 'https://getpocket.com/ff_signin?s=pocket&t=login' + }, + name: 'Pocket', + }, + { + description: 'Take your passwords everywhere with Firefox Lockbox.', + image: 'lockbox', + links: { + ios: 'https://itunes.apple.com/us/app/firefox-lockbox/id1314000270?mt=8', + }, + name: 'Lockbox', + }, + { + description: 'Detects threats against your online accounts.', + image: 'monitor', + links: { + website: 'https://monitor.firefox.com/', + }, + name: 'Monitor', + }, + { + description: 'Screenshots made simple.', + image: 'screenshots', + links: { + website: 'https://screenshots.firefox.com/', + }, + name: 'Screenshots', + } +]; + + +const View = BaseView.extend({ + className: 'connect-another-service', + template: Template, + + events: { + 'click .open-link': '_logLinkMetrics', + }, + + setInitialContext(context) { + const services = this._filterServices(); + const isSignIn = this.isSignIn(); + const isSignUp = this.isSignUp(); + const showSuccessMessage = this._showSuccessMessage(); + + context.set({ + 'connect-services': services, + isSignIn, + isSignUp, + showSuccessMessage + }); + }, + + _filterServices() { + const userAgent = this.getUserAgent(); + + const supportedServices = SERVICES.filter((service) => { + if (service.links.android && (userAgent.isFirefoxAndroid() || userAgent.isAndroid())) { + service.link = service.links.android; + return service; + } else if (service.links.ios && (userAgent.isFirefoxIos() || userAgent.isIos())) { + service.link = service.links.ios; + return service; + } else if (service.links.website) { + service.link = service.links.website; + return service; + } + }); + + return supportedServices; + }, + + _showSuccessMessage() { + return !! this.model.get('showSuccessMessage') || + !! this.getSearchParam('showSuccessMessage'); + }, + + _logLinkMetrics(event) { + const service = this.$(event.currentTarget).attr('data-service'); + this.logViewEvent(`clicked.${service}`); + }, +}); + + +Cocktail.mixin( + View, + UserAgentMixin, + VerificationReasonMixin, +); + +module.exports = View; diff --git a/app/scripts/views/mixins/connect-another-device-mixin.js b/app/scripts/views/mixins/connect-another-device-mixin.js index b0b2537e6f..9f5c17114c 100644 --- a/app/scripts/views/mixins/connect-another-device-mixin.js +++ b/app/scripts/views/mixins/connect-another-device-mixin.js @@ -84,6 +84,16 @@ define(function(require, exports, module) { this.replaceCurrentPage('sms', { account, country, showSuccessMessage, type }); }, + /** + * Replace the current page with the connect app screen. + * + * @param {Object} account + * @param {Boolean} showSuccessMessage + */ + replaceCurrentPageWithAppsScreen (account, showSuccessMessage) { + this.replaceCurrentPage('connect_another_service', { account, showSuccessMessage }); + }, + /** * Get the country to send an sms to if `account` is eligible for SMS? * diff --git a/app/scripts/views/mixins/connect-another-service-experiment-mixin.js b/app/scripts/views/mixins/connect-another-service-experiment-mixin.js new file mode 100644 index 0000000000..45c8254967 --- /dev/null +++ b/app/scripts/views/mixins/connect-another-service-experiment-mixin.js @@ -0,0 +1,67 @@ +/* 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 http://mozilla.org/MPL/2.0/. */ + +/** + * An ConnectAnotherServiceExperimentMixin factory. + * + * @mixin ConnectAnotherServiceExperimentMixin + */ +'use strict'; + +const ExperimentMixin = require('./experiment-mixin'); +const UserAgentMixin = require('../../lib/user-agent-mixin'); +const EXPERIMENT_NAME = 'connectAnotherService'; + +/** + * Creates the mixin + * + * @returns {Object} mixin + */ +module.exports = { + dependsOn: [ + ExperimentMixin, + UserAgentMixin + ], + + beforeRender() { + if (this.isInConnectAnotherServiceExperiment()) { + const experimentGroup = this.getConnectAnotherServiceExperimentGroup(); + this.createExperiment(EXPERIMENT_NAME, experimentGroup); + } + }, + + /** + * Get the experiment group + * + * @returns {String} + */ + getConnectAnotherServiceExperimentGroup() { + return this.getExperimentGroup(EXPERIMENT_NAME, this._getExperimentSubject()); + }, + + + /** + * Is the user in the experiment? + * + * @returns {Boolean} + */ + isInConnectAnotherServiceExperiment() { + return this.isInExperiment(EXPERIMENT_NAME, this._getExperimentSubject()); + }, + + /** + * Get the experiment choice subject + * + * @returns {Object} + * @private + */ + _getExperimentSubject() { + const subject = { + account: this.model.get('account'), + clientId: this.relier.get('clientId'), + userAgent: this.getContext() + }; + return subject; + } +}; diff --git a/app/styles/_modules.scss b/app/styles/_modules.scss index 1248a99969..dd1d1832db 100644 --- a/app/styles/_modules.scss +++ b/app/styles/_modules.scss @@ -1,6 +1,7 @@ @import '../../node_modules/jquery-modal/jquery.modal'; @import 'modules/iframe'; @import 'modules/branding'; +@import 'modules/connect-another-service'; @import 'modules/tooltip'; @import 'modules/input-row'; @import 'modules/select-row'; diff --git a/app/styles/modules/_connect-another-service.scss b/app/styles/modules/_connect-another-service.scss new file mode 100644 index 0000000000..0533e67f5f --- /dev/null +++ b/app/styles/modules/_connect-another-service.scss @@ -0,0 +1,68 @@ +.connect-another-service { + .connect-service { + display: flex; + flex-direction: row; + margin-bottom: 10px; + + .image-container { + align-self: center; + + .image { + background-image: url('/images/icon-device-laptop.svg'); + background-repeat: no-repeat; + background-size: cover; + height: 42px; + width: 42px; + + &[data='Lockbox'] { + background-image: url('/images/connect_another_service/lockbox.png'); + } + + &[data='Pocket'] { + background-image: url('/images/connect_another_service/pocket.png'); + } + + &[data='Notes'] { + background-image: url('/images/connect_another_service/notes.png'); + } + + &[data='Monitor'] { + background-image: url('/images/connect_another_service/monitor.png'); + } + + &[data='Screenshots'] { + background-image: url('/images/connect_another_service/screenshots.png'); + } + } + } + + .content { + align-self: center; + line-height: 1.3; + margin-left: 10px; + margin-right: 10px; + text-align: left; + width: 80%; + + .service-name { + font-size: 18px; + } + + .service-description { + font-size: 12px; + } + } + + .service-links { + align-self: center; + justify-content: flex-end; + + .open-icon { + background-image: image-url('open_in_new.svg'); + background-repeat: no-repeat; + height: 24px; + width: 24px; + } + } + } +} diff --git a/app/tests/spec/lib/experiments/grouping-rules/index.js b/app/tests/spec/lib/experiments/grouping-rules/index.js index 71d1984fd5..89801f35ac 100644 --- a/app/tests/spec/lib/experiments/grouping-rules/index.js +++ b/app/tests/spec/lib/experiments/grouping-rules/index.js @@ -12,7 +12,7 @@ define(function (require, exports, module) { describe('lib/experiments/grouping-rules/index', () => { it('EXPERIMENT_NAMES is exported', () => { - assert.lengthOf(ExperimentGroupingRules.EXPERIMENT_NAMES, 7); + assert.lengthOf(ExperimentGroupingRules.EXPERIMENT_NAMES, 8); }); describe('choose', () => { diff --git a/server/lib/routes/get-frontend.js b/server/lib/routes/get-frontend.js index b98d6771bb..f0e008e942 100644 --- a/server/lib/routes/get-frontend.js +++ b/server/lib/routes/get-frontend.js @@ -19,6 +19,7 @@ module.exports = function () { 'confirm_signin', 'connect_another_device', 'connect_another_device/why', + 'connect_another_service', 'cookies_disabled', 'force_auth', 'legal', diff --git a/tests/functional.js b/tests/functional.js index fe72cfa64d..7b879f4dda 100644 --- a/tests/functional.js +++ b/tests/functional.js @@ -19,6 +19,7 @@ module.exports = [ 'tests/functional/complete_sign_in.js', 'tests/functional/complete_sign_up.js', 'tests/functional/connect_another_device.js', + 'tests/functional/connect_another_service.js', 'tests/functional/send_sms.js', 'tests/functional/sync_sign_up.js', 'tests/functional/sync_v2_sign_up.js', diff --git a/tests/functional/connect_another_service.js b/tests/functional/connect_another_service.js new file mode 100644 index 0000000000..97f6285a3d --- /dev/null +++ b/tests/functional/connect_another_service.js @@ -0,0 +1,117 @@ +/* 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 http://mozilla.org/MPL/2.0/. */ + +'use strict'; + +const {registerSuite} = intern.getInterface('object'); +const TestHelpers = require('../lib/helpers'); +const FunctionalHelpers = require('./lib/helpers'); +const UA_STRINGS = require('./lib/ua-strings'); +const selectors = require('./lib/selectors'); + +const config = intern._config; + +const SIGNUP_FENNEC_URL = `${config.fxaContentRoot}signup?context=fx_fennec_v1&service=sync`; +const SIGNUP_DESKTOP_URL = `${config.fxaContentRoot}signup?context=fx_desktop_v3&service=sync`; +const CHANNEL_COMMAND_CAN_LINK_ACCOUNT = 'fxaccounts:can_link_account'; + +const { + clearBrowserState, + click, + fillOutSignUp, + openPage, + openVerificationLinkInSameTab, + respondToWebChannelMessage, + testElementExists, +} = FunctionalHelpers; + +let email; +const PASSWORD = '12345678'; + +registerSuite('connect_another_service', { + beforeEach: function () { + email = TestHelpers.createEmail('sync{id}'); + + return this.remote.then(clearBrowserState()); + }, + tests: { + + 'signup Fx Desktop, verify in Fennec': function () { + // should navigate to signin and have the email prefilled + return this.remote + .then(openPage(SIGNUP_DESKTOP_URL, selectors.SIGNUP.HEADER, { + forceUA: UA_STRINGS['desktop_firefox'], + })) + .then(respondToWebChannelMessage(CHANNEL_COMMAND_CAN_LINK_ACCOUNT, {ok: true})) + // this tests needs to signup so that we can check if the email gets prefilled + .then(fillOutSignUp(email, PASSWORD)) + .then(testElementExists(selectors.CHOOSE_WHAT_TO_SYNC.HEADER)) + .then(click(selectors.CHOOSE_WHAT_TO_SYNC.SUBMIT)) + + .then(testElementExists(selectors.CONFIRM_SIGNUP.HEADER)) + + // clear browser state to synthesize verifying in an Android browser. + .then(clearBrowserState()) + + .then(openVerificationLinkInSameTab(email, 0, { + query: { + forceExperiment: 'connectAnotherService', + forceExperimentGroup: 'treatment', + forceUA: UA_STRINGS['android_firefox'] + } + })) + .then(testElementExists(selectors.CONNECT_ANOTHER_SERVICE.HEADER)); + }, + + 'signup Fx Desktop, verify in Fx for iOS': function () { + return this.remote + .then(openPage(SIGNUP_DESKTOP_URL, selectors.SIGNUP.HEADER, { + forceUA: UA_STRINGS['desktop_firefox'] + })) + .then(respondToWebChannelMessage(CHANNEL_COMMAND_CAN_LINK_ACCOUNT, {ok: true})) + .then(fillOutSignUp(email, PASSWORD)) + .then(testElementExists(selectors.CHOOSE_WHAT_TO_SYNC.HEADER)) + .then(click(selectors.CHOOSE_WHAT_TO_SYNC.SUBMIT)) + + .then(testElementExists(selectors.CONFIRM_SIGNUP.HEADER)) + + // clear browser state to synthesize verifying in an Android browser. + .then(clearBrowserState()) + + .then(openVerificationLinkInSameTab(email, 0, { + query: { + forceExperiment: 'connectAnotherService', + forceExperimentGroup: 'treatment', + forceUA: UA_STRINGS['ios_firefox'] + } + })) + .then(testElementExists(selectors.CONNECT_ANOTHER_SERVICE.HEADER)); + }, + + 'signup in Fennec, verify same browser': function () { + // should have both links to mobile apps + return this.remote + .then(openPage(SIGNUP_FENNEC_URL, selectors.SIGNUP.HEADER, { + query: { + forceUA: UA_STRINGS['android_firefox'] + } + })) + .then(respondToWebChannelMessage(CHANNEL_COMMAND_CAN_LINK_ACCOUNT, {ok: true})) + .then(fillOutSignUp(email, PASSWORD)) + .then(testElementExists(selectors.CHOOSE_WHAT_TO_SYNC.HEADER)) + .then(click(selectors.CHOOSE_WHAT_TO_SYNC.SUBMIT)) + + .then(testElementExists(selectors.CONFIRM_SIGNUP.HEADER)) + + .then(openVerificationLinkInSameTab(email, 0, { + query: { + forceExperiment: 'connectAnotherService', + forceExperimentGroup: 'treatment', + forceUA: UA_STRINGS['android_firefox'] + } + })) + .then(testElementExists(selectors.CONNECT_ANOTHER_SERVICE.HEADER)); + } + } +}); diff --git a/tests/functional/lib/selectors.js b/tests/functional/lib/selectors.js index 7ba3949fba..524d52a530 100644 --- a/tests/functional/lib/selectors.js +++ b/tests/functional/lib/selectors.js @@ -100,6 +100,9 @@ module.exports = { CLOSE: '.connect-another-device button[type="submit"]', HEADER: '#fxa-why-connect-another-device-header', }, + CONNECT_ANOTHER_SERVICE: { + HEADER: '#fxa-connect-another-service-header' + }, EMAIL: { ADDRESS_LABEL: '#emails .address', ADD_BUTTON: '.email-add:not(.disabled)',