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 @@
+
+
+
+ {{#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)',