Commit 8ef8e2d7 authored by Serhii Yarynovskyi's avatar Serhii Yarynovskyi Committed by Phil Hughes

Add cart abandonment modal

Modal to show to customer if he sits on subscription page
for more than 3 minutes
parent 9daac084
<script>
import StepOrderApp from 'ee/vue_shared/purchase_flow/components/step_order_app.vue';
import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue';
import Modal from './modal.vue';
import Checkout from './checkout.vue';
import OrderSummary from './order_summary.vue';
export default {
components: {
StepOrderApp,
GitlabExperiment,
Modal,
Checkout,
OrderSummary,
},
};
</script>
<template>
<step-order-app>
<template #checkout>
<checkout />
</template>
<template #order-summary>
<order-summary />
</template>
</step-order-app>
<div class="gl-h-full">
<gitlab-experiment name="cart_abandonment_modal">
<template #candidate>
<modal />
</template>
</gitlab-experiment>
<step-order-app class="gl-h-full">
<template #checkout>
<checkout />
</template>
<template #order-summary>
<order-summary />
</template>
</step-order-app>
</div>
</template>
<script>
import { mapState } from 'vuex';
import { GlButton, GlModal } from '@gitlab/ui';
import Tracking from '~/tracking';
import {
MODAL_TIMEOUT,
MODAL_TITLE,
MODAL_CLOSE_BTN,
MODAL_CHAT_SALES_BTN,
MODAL_START_TRIAL_BTN,
MODAL_BODY,
TRACKING_EVENTS,
} from '../constants';
export default {
components: { GlModal, GlButton },
mixins: [Tracking.mixin({ experiment: 'cart_abandonment_modal' })],
data() {
return {
visible: false,
};
},
computed: {
...mapState(['isTrial', 'newTrialRegistrationPath']),
},
mounted() {
setTimeout(() => {
this.visible = true;
this.trackPageAction('modalRendered');
}, MODAL_TIMEOUT);
},
methods: {
trackPageAction(eventName) {
const { action, ...options } = TRACKING_EVENTS[eventName];
this.track(action, { ...options });
},
cancel() {
this.visible = false;
this.trackPageAction('cancel');
},
},
i18n: {
modalTitle: MODAL_TITLE,
modalCloseBtn: MODAL_CLOSE_BTN,
modalChatSalesBtn: MODAL_CHAT_SALES_BTN,
modalStartTrialBtn: MODAL_START_TRIAL_BTN,
modalBody: MODAL_BODY,
},
};
</script>
<template>
<gl-modal
modal-id="subscription-modal"
size="sm"
:visible="visible"
@close="trackPageAction('dismiss')"
>
<template #modal-title>
{{ $options.i18n.modalTitle }}
<gl-emoji data-name="thinking" />
</template>
{{ $options.i18n.modalBody }}
<template #modal-footer>
<gl-button data-testid="modal-close-btn" @click="cancel">
{{ $options.i18n.modalCloseBtn }}
</gl-button>
<gl-button
variant="confirm"
category="secondary"
data-testid="talk-to-sales-btn"
href="https://about.gitlab.com/sales"
@click="trackPageAction('talkToSales')"
>
{{ $options.i18n.modalChatSalesBtn }}
</gl-button>
<gl-button
v-if="!isTrial"
variant="confirm"
data-testid="start-free-trial-btn"
:href="newTrialRegistrationPath"
@click="trackPageAction('startFreeTrial')"
>
{{ $options.i18n.modalStartTrialBtn }}
</gl-button>
</template>
</gl-modal>
</template>
import { s__ } from '~/locale';
const CLICK_BUTTON_ACTION = 'click_button';
const MODAL_RENDERED_ACTION = 'modal_rendered';
export const TAX_RATE = 0;
export const MODAL_TIMEOUT = 180000; // 3 minutes
export const NEW_GROUP = 'new_group';
export const ULTIMATE = 'ultimate';
export const MODAL_TITLE = s__('Subscriptions|Not ready to buy yet?');
export const MODAL_CLOSE_BTN = s__('Subscriptions|Close');
export const MODAL_CHAT_SALES_BTN = s__('Subscriptions|Chat with sales');
export const MODAL_START_TRIAL_BTN = s__('Subscriptions|Start a free trial');
export const MODAL_BODY = s__(
"Subscriptions|We understand. Maybe you have some questions for our sales team, or maybe you'd like to try some of the paid features first. What would you like to do?",
);
export const TRACKING_EVENTS = {
startFreeTrial: { action: CLICK_BUTTON_ACTION, label: 'start_free_trial' },
talkToSales: { action: CLICK_BUTTON_ACTION, label: 'talk_to_sales' },
cancel: { action: CLICK_BUTTON_ACTION, label: 'cancel' },
dismiss: { action: CLICK_BUTTON_ACTION, label: 'dismiss' },
modalRendered: { action: MODAL_RENDERED_ACTION, label: 'modal_rendered' },
};
......@@ -48,11 +48,14 @@ export default ({
newUser,
groupData = '[]',
source,
trial,
newTrialRegistrationPath,
}) => {
const availablePlans = parsePlanData(plansData);
const isNewUser = parseBoolean(newUser);
const groupId = parseInt(namespaceId, 10) || null;
const groups = parseGroupData(groupData);
const isTrial = parseBoolean(trial);
return {
isSetupForCompany: setupForCompany === '' ? !isNewUser : parseBoolean(setupForCompany),
......@@ -80,5 +83,7 @@ export default ({
taxRate: TAX_RATE,
startDate: new Date(Date.now()),
source,
isTrial,
newTrialRegistrationPath,
};
};
......@@ -129,12 +129,6 @@ $subscriptions-full-width-lg: 541px;
.btn {
line-height: $gl-line-height-14;
padding: $gl-padding-8 $gl-padding-12;
width: 100%;
@media(min-width: map-get($grid-breakpoints, lg)) {
width: initial;
}
}
.overview {
......
......@@ -33,7 +33,15 @@ class SubscriptionsController < ApplicationController
end
def new
redirect_unauthenticated_user('checkout')
if current_user
experiment(:cart_abandonment_modal,
namespace: current_user.namespace,
user: current_user,
sticky_to: current_user
).run
else
redirect_unauthenticated_user
end
end
def buy_minutes
......@@ -159,11 +167,9 @@ class SubscriptionsController < ApplicationController
Gitlab::SubscriptionPortal::Client
end
def redirect_unauthenticated_user(from = action_name)
return if current_user
def redirect_unauthenticated_user
store_location_for :user, request.fullpath
redirect_to new_user_registration_path(redirect_from: from)
redirect_to new_user_registration_path(redirect_from: 'checkout')
end
def ci_minutes_plan_data
......
# frozen_string_literal: true
class CartAbandonmentModalExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass
def control_behavior
end
def candidate_behavior
end
end
......@@ -12,7 +12,9 @@ module SubscriptionsHelper
namespace_id: params[:namespace_id],
new_user: new_user?.to_s,
group_data: present_groups(eligible_groups).to_json,
source: params[:source]
source: params[:source],
trial: current_user.namespace.trial?.to_s,
new_trial_registration_path: new_trial_registration_path
}
end
......
---
name: cart_abandonment_modal
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/79033
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/345178
milestone: '14.8'
type: experiment
group: group::conversion
default_enabled: false
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`App component cart_abandonment_modal experiment candidate matches the snapshot 1`] = `
<div
class="gl-h-full"
>
<gl-modal-stub
dismisslabel="Close"
modalclass=""
modalid="subscription-modal"
size="sm"
titletag="h4"
>
We understand. Maybe you have some questions for our sales team, or maybe you'd like to try some of the paid features first. What would you like to do?
</gl-modal-stub>
<step-order-app-stub
class="gl-h-full"
/>
</div>
`;
exports[`App component cart_abandonment_modal experiment control matches the snapshot 1`] = `
<div
class="gl-h-full"
>
<!---->
<step-order-app-stub
class="gl-h-full"
/>
</div>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Modal component matches the snapshot 1`] = `
<b-modal-stub
canceltitle="Cancel"
cancelvariant="secondary"
headerclosecontent="&times;"
headercloselabel="Close"
id="subscription-modal"
ignoreenforcefocusselector=""
lazy="true"
modalclass="gl-modal,"
oktitle="OK"
okvariant="primary"
size="sm"
title=""
titletag="h4"
>
We understand. Maybe you have some questions for our sales team, or maybe you'd like to try some of the paid features first. What would you like to do?
<h4
class="modal-title"
>
Not ready to buy yet?
<gl-emoji
data-name="thinking"
/>
</h4>
<close-button-stub
label="Close"
/>
<gl-button-stub
buttontextclasses=""
category="primary"
data-testid="modal-close-btn"
icon=""
size="medium"
variant="default"
>
Close
</gl-button-stub>
<gl-button-stub
buttontextclasses=""
category="secondary"
data-testid="talk-to-sales-btn"
href="https://about.gitlab.com/sales"
icon=""
size="medium"
variant="confirm"
>
Chat with sales
</gl-button-stub>
<gl-button-stub
buttontextclasses=""
category="primary"
data-testid="start-free-trial-btn"
href="newTrialRegistrationPath"
icon=""
size="medium"
variant="confirm"
>
Start a free trial
</gl-button-stub>
</b-modal-stub>
`;
import { shallowMount } from '@vue/test-utils';
import Component from 'ee/subscriptions/new/components/app.vue';
import Modal from 'ee/subscriptions/new/components/modal.vue';
import { stubExperiments } from 'helpers/experimentation_helper';
import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue';
describe('App component', () => {
let wrapper;
const createComponent = () => {
return shallowMount(Component, {
stubs: { Modal, GitlabExperiment },
});
};
afterEach(() => {
wrapper.destroy();
});
describe('cart_abandonment_modal experiment', () => {
describe('control', () => {
beforeEach(() => {
stubExperiments({ cart_abandonment_modal: 'control' });
wrapper = createComponent();
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('renders the modal', () => {
expect(wrapper.findComponent(Modal).exists()).toBe(false);
});
});
describe('candidate', () => {
beforeEach(() => {
stubExperiments({ cart_abandonment_modal: 'candidate' });
wrapper = createComponent();
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('renders the modal', () => {
expect(wrapper.findComponent(Modal).exists()).toBe(true);
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import { GlModal } from '@gitlab/ui';
import createStore from 'ee/subscriptions/new/store';
import Component from 'ee/subscriptions/new/components/modal.vue';
import { MODAL_TITLE, MODAL_BODY, TRACKING_EVENTS } from 'ee/subscriptions/new/constants';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import installGlEmojiElement from '~/behaviors/gl_emoji';
describe('Modal component', () => {
let wrapper;
let trackingSpy;
const createComponent = (trial = false) => {
return extendedWrapper(
shallowMount(Component, {
store: createStore({ trial, newTrialRegistrationPath: 'newTrialRegistrationPath' }),
stubs: { GlModal },
}),
);
};
const expectTracking = ({ action, ...options } = {}) => {
return expect(trackingSpy).toHaveBeenCalledWith(undefined, action, { ...options });
};
beforeAll(() => {
installGlEmojiElement();
});
beforeEach(() => {
wrapper = createComponent();
trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn);
});
afterEach(() => {
wrapper.destroy();
unmockTracking();
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('renders modal title and body', () => {
const modalText = wrapper.text();
expect(modalText).toContain(MODAL_TITLE);
expect(modalText).toContain(MODAL_BODY);
});
it('tracks when modal-close-btn is clicked', () => {
wrapper.findByTestId('modal-close-btn').vm.$emit('click');
expectTracking(TRACKING_EVENTS.cancel);
});
it('tracks when talk-to-sales-btn is clicked', () => {
wrapper.findByTestId('talk-to-sales-btn').vm.$emit('click');
expectTracking(TRACKING_EVENTS.talkToSales);
});
it('tracks when start-free-trial-btn is clicked', () => {
wrapper.findByTestId('start-free-trial-btn').vm.$emit('click');
expectTracking(TRACKING_EVENTS.startFreeTrial);
});
it('tracks when modal is dismissed', () => {
wrapper.findComponent(GlModal).vm.$emit('close');
expectTracking(TRACKING_EVENTS.dismiss);
});
describe('when user is on trial', () => {
beforeEach(() => {
wrapper = createComponent(true);
});
it('does not render start-free-trial-btn', () => {
expect(wrapper.findByTestId('start-free-trial-btn').exists()).toBe(false);
});
});
});
......@@ -23,6 +23,8 @@ describe('projectsSelector default state', () => {
fullName: 'Full Name',
newUser: 'true',
source: 'some_source',
isTrial: false,
newTrialRegistrationPath: 'newTrialRegistrationPath',
};
const currentDate = new Date('2020-01-07T12:44:08.135Z');
......@@ -183,4 +185,12 @@ describe('projectsSelector default state', () => {
it('sets isConfirmingOrder to false', () => {
expect(state.isConfirmingOrder).toBe(false);
});
it('sets trial to the initial value', () => {
expect(state.isTrial).toBe(false);
});
it('sets newTrialRegistrationPath to the initial value', () => {
expect(state.newTrialRegistrationPath).toBe('newTrialRegistrationPath');
});
});
......@@ -52,6 +52,16 @@ RSpec.describe SubscriptionsHelper do
it { is_expected.to include(namespace_id: group.id.to_s) }
it { is_expected.to include(source: 'some_source') }
it { is_expected.to include(group_data: %Q{[{"id":#{group.id},"account_id":null,"name":"My Namespace","users":2,"guests":1}]}) }
it { is_expected.to include(trial: 'false') }
it { is_expected.to include(new_trial_registration_path: '/-/trial_registrations/new') }
context 'when user is on trial' do
before do
user.namespace.build_gitlab_subscription(trial: true)
end
it { is_expected.to include(trial: 'true') }
end
describe 'new_user' do
where(:referer, :expected_result) do
......
......@@ -34824,6 +34824,21 @@ msgstr ""
msgid "Subscriptions"
msgstr ""
msgid "Subscriptions|Chat with sales"
msgstr ""
msgid "Subscriptions|Close"
msgstr ""
msgid "Subscriptions|Not ready to buy yet?"
msgstr ""
msgid "Subscriptions|Start a free trial"
msgstr ""
msgid "Subscriptions|We understand. Maybe you have some questions for our sales team, or maybe you'd like to try some of the paid features first. What would you like to do?"
msgstr ""
msgid "Subscription|Your subscription for %{strong}%{namespace_name}%{strong_close} has expired and you are now on %{pricing_link_start}the GitLab Free tier%{pricing_link_end}. Don't worry, your data is safe. Get in touch with our support team (%{support_email}). They'll gladly help with your subscription renewal."
msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment