- There was an unexpected problem. If the problem persists, please
-
- contact support
-
- .
-
-
-
-
+
- Dismiss
-
+
+
+
diff --git a/src/feedback/__snapshots__/AlertMessage.test.jsx.snap b/src/feedback/__snapshots__/AlertMessage.test.jsx.snap
index 56f1ffb32..78543e23b 100644
--- a/src/feedback/__snapshots__/AlertMessage.test.jsx.snap
+++ b/src/feedback/__snapshots__/AlertMessage.test.jsx.snap
@@ -3,30 +3,34 @@
exports[`AlertMessage should default its severity when necessary 1`] = `
- Wondrous message!
-
-
-
-
+
- Dismiss
-
+
+
+
@@ -36,34 +40,38 @@ exports[`AlertMessage should default its severity when necessary 1`] = `
exports[`AlertMessage should render a userMessage element 1`] = `
-
- Wondrous message!
-
-
-
-
-
+
- Dismiss
-
+
+
+
@@ -72,31 +80,33 @@ exports[`AlertMessage should render a userMessage element 1`] = `
exports[`AlertMessage should render a userMessage function 1`] = `
-
+
- Wondrous message!
-
-
-
-
+
- Dismiss
-
+
+
+
diff --git a/src/mockIntersectionObserver.js b/src/mockIntersectionObserver.js
new file mode 100644
index 000000000..1b153088b
--- /dev/null
+++ b/src/mockIntersectionObserver.js
@@ -0,0 +1,19 @@
+global.IntersectionObserver = class IntersectionObserver {
+ constructor(callback) {
+ this.callback = callback;
+ this.observedElements = new Set();
+ }
+
+ observe(element) {
+ this.callback([{ isIntersecting: true }]);
+ this.observedElements.add(element);
+ }
+
+ unobserve(element) {
+ this.observedElements.delete(element);
+ }
+
+ disconnect() {
+ this.observedElements.clear();
+ }
+};
diff --git a/src/payment/PaymentPage.test.jsx b/src/payment/PaymentPage.test.jsx
index b2fe28c65..f99de8d37 100644
--- a/src/payment/PaymentPage.test.jsx
+++ b/src/payment/PaymentPage.test.jsx
@@ -1,3 +1,4 @@
+/* eslint-disable max-classes-per-file */
/* eslint-disable react/jsx-no-constructed-context-values */
/* eslint-disable global-require */
import React from 'react';
@@ -22,6 +23,8 @@ import { transformResults } from './data/utils';
import { ENROLLMENT_CODE_PRODUCT_TYPE } from './cart/order-details';
import { MESSAGE_TYPES, addMessage } from '../feedback';
+import '../mockIntersectionObserver';
+
jest.mock('universal-cookie', () => {
class MockCookies {
static result = {
@@ -30,11 +33,16 @@ jest.mock('universal-cookie', () => {
code: 'MXN',
rate: 19.092733,
},
+ tglr_correlation_id: '123a845-3872-5729-1379ab439',
};
get(cookieName) {
return MockCookies.result[cookieName];
}
+
+ set(cookieName) {
+ return MockCookies.result[cookieName];
+ }
}
return MockCookies;
});
diff --git a/src/payment/__snapshots__/PaymentPage.test.jsx.snap b/src/payment/__snapshots__/PaymentPage.test.jsx.snap
index b3f8a5694..6ef2d5857 100644
--- a/src/payment/__snapshots__/PaymentPage.test.jsx.snap
+++ b/src/payment/__snapshots__/PaymentPage.test.jsx.snap
@@ -323,6 +323,7 @@ exports[` Renders correctly in various states should render its d
class="payment-method-button skeleton-pulse"
data-testid="PayPalButton"
disabled=""
+ id="PayPalButton"
type="button"
>
Renders correctly in various states should render the b
diff --git a/src/payment/checkout/payment-form/StripePaymentForm.test.jsx b/src/payment/checkout/payment-form/StripePaymentForm.test.jsx
index be7424aa1..778f20b28 100644
--- a/src/payment/checkout/payment-form/StripePaymentForm.test.jsx
+++ b/src/payment/checkout/payment-form/StripePaymentForm.test.jsx
@@ -16,6 +16,8 @@ import '../../__factories__/userAccount.factory';
import * as mocks from '../stripeMocks';
import { basketSelector } from '../../data/selectors';
+import '../../../mockIntersectionObserver';
+
jest.mock('@edx/frontend-platform/analytics', () => ({
sendTrackEvent: jest.fn(),
}));
diff --git a/src/payment/data/__snapshots__/redux.test.js.snap b/src/payment/data/__snapshots__/redux.test.js.snap
index e62d931f5..d7a3f7c67 100644
--- a/src/payment/data/__snapshots__/redux.test.js.snap
+++ b/src/payment/data/__snapshots__/redux.test.js.snap
@@ -19,6 +19,10 @@ Object {
"clientSecretId": "",
"isClientSecretProcessing": false,
},
+ "pageTracking": Object {
+ "elementIntersections": Array [],
+ "paymentButtonClicks": Array [],
+ },
}
`;
@@ -41,6 +45,10 @@ Object {
"clientSecretId": "",
"isClientSecretProcessing": false,
},
+ "pageTracking": Object {
+ "elementIntersections": Array [],
+ "paymentButtonClicks": Array [],
+ },
}
`;
@@ -63,5 +71,9 @@ Object {
"clientSecretId": "",
"isClientSecretProcessing": false,
},
+ "pageTracking": Object {
+ "elementIntersections": Array [],
+ "paymentButtonClicks": Array [],
+ },
}
`;
diff --git a/src/payment/data/actions.js b/src/payment/data/actions.js
index f3bfb7467..30674560a 100644
--- a/src/payment/data/actions.js
+++ b/src/payment/data/actions.js
@@ -1,4 +1,5 @@
import { createRoutine } from 'redux-saga-routines';
+import { getCorrelationID, tagularEvent } from '../../cohesion/helpers';
// Routines are action + action creator pairs in a series.
// Actions adhere to the flux standard action format.
@@ -74,3 +75,55 @@ export const clientSecretDataReceived = clientSecret => ({
type: CLIENT_SECRET_DATA_RECEIVED,
payload: clientSecret,
});
+
+export const TRACK_PAYMENT_BUTTON_CLICK = 'TRACK_PAYMENT_BUTTON_CLICK';
+
+export const trackPaymentButtonClick = tagularElement => {
+ // Ideally this would happen in a middleware saga for separation of concerns
+ // but due to deadlines/payment MFE will go away, adding a call here.
+ // Note: Click events on the PayPal button and Place Order button differ in the type of event data it's treated as.
+ let payload;
+ if (tagularElement.name === 'paypal') {
+ payload = {
+ correlation: {
+ id: getCorrelationID(),
+ },
+ webElement: tagularElement,
+ };
+ tagularEvent('ElementClicked', payload);
+ } else {
+ payload = {
+ correlation: {
+ id: getCorrelationID(),
+ },
+ metadata: tagularElement,
+ };
+ tagularEvent('ConversionTracked', payload);
+ }
+
+ return {
+ type: TRACK_PAYMENT_BUTTON_CLICK,
+ payload,
+ };
+};
+
+export const TRACK_ELEMENT_INTERSECTION = 'TRACK_ELEMENT_INTERSECTION';
+
+export const trackElementIntersection = tagularElement => {
+ // Ideally this would happen in a middleware saga for separation of concerns
+ // but due to deadlines/payment MFE will go away, adding a call here.
+ // Note: For the coupon code banner, we're using an elementViewed as a click event
+ // ('BUTTON' on coupon Apply click, but it's when the banner is viewed).
+ const viewedEvent = {
+ correlation: {
+ id: getCorrelationID(),
+ },
+ webElement: tagularElement,
+ };
+ tagularEvent('ElementViewed', viewedEvent);
+
+ return {
+ type: TRACK_ELEMENT_INTERSECTION,
+ payload: viewedEvent,
+ };
+};
diff --git a/src/payment/data/reducers.js b/src/payment/data/reducers.js
index 204a08fbf..7d1524ed1 100644
--- a/src/payment/data/reducers.js
+++ b/src/payment/data/reducers.js
@@ -12,6 +12,8 @@ import {
submitPayment,
fetchCaptureKey,
fetchClientSecret,
+ TRACK_PAYMENT_BUTTON_CLICK,
+ TRACK_ELEMENT_INTERSECTION,
} from './actions';
import { DEFAULT_STATUS } from '../checkout/payment-form/flex-microform/constants';
@@ -115,10 +117,35 @@ const clientSecret = (state = clientSecretInitialState, action = null) => {
return state;
};
+const pageTrackingInitialState = {
+ paymentButtonClicks: [],
+ elementIntersections: [],
+};
+
+const pageTracking = (state = pageTrackingInitialState, action = null) => {
+ if (action !== null) {
+ switch (action.type) {
+ case TRACK_PAYMENT_BUTTON_CLICK:
+ return {
+ ...state,
+ paymentButtonClicks: [...state.paymentButtonClicks, action.payload],
+ };
+ case TRACK_ELEMENT_INTERSECTION:
+ return {
+ ...state,
+ elementIntersections: [...state.elementIntersections, action.payload],
+ };
+ default:
+ }
+ }
+ return state;
+};
+
const reducer = combineReducers({
basket,
captureKey,
clientSecret,
+ pageTracking,
});
export default reducer;
diff --git a/src/payment/data/sagas.js b/src/payment/data/sagas.js
index 8ab9c10e7..7633caf01 100644
--- a/src/payment/data/sagas.js
+++ b/src/payment/data/sagas.js
@@ -23,6 +23,7 @@ import {
fetchCaptureKey,
clientSecretProcessing,
fetchClientSecret,
+ trackPaymentButtonClick,
} from './actions';
import { STATUS_LOADING } from '../checkout/payment-form/flex-microform/constants';
@@ -226,7 +227,7 @@ export function* handleSubmitPayment({ payload }) {
return;
}
- const { method, ...paymentArgs } = payload;
+ const { method, tagularElement, ...paymentArgs } = payload;
try {
yield put(basketProcessing(true));
yield put(clearMessages()); // Don't leave messages floating on the page after clicking submit
@@ -235,6 +236,15 @@ export function* handleSubmitPayment({ payload }) {
const basket = yield select(state => ({ ...state.payment.basket }));
yield call(paymentMethodCheckout, basket, paymentArgs);
yield put(submitPayment.success());
+ // RV tracking for successful Stripe Payment
+ if (method === 'stripe') {
+ // Metada for conversion_category and conversion_action:
+ // Sucessful payment = 'Order' and 'Completed'
+ // Failed payment = 'Enrollment' and 'Declined'
+ tagularElement.conversion_category = 'Order';
+ tagularElement.conversion_action = 'Completed';
+ yield put(trackPaymentButtonClick(tagularElement));
+ }
} catch (error) {
// Do not handle errors on user aborted actions
if (!error.aborted) {
@@ -243,6 +253,12 @@ export function* handleSubmitPayment({ payload }) {
if (error.code) {
yield call(handleErrors, { messages: [error] }, true);
} else {
+ // RV tracking for failed Stripe Payment
+ if (method === 'stripe') {
+ tagularElement.conversion_category = 'Enrollment';
+ tagularElement.conversion_action = 'Declined';
+ yield put(trackPaymentButtonClick(tagularElement));
+ }
yield call(handleErrors, error, true);
yield call(handleReduxFormValidationErrors, error);
}
diff --git a/src/payment/payment-methods/paypal/PayPalButton.jsx b/src/payment/payment-methods/paypal/PayPalButton.jsx
index b0ffca12b..bf0dca9bc 100644
--- a/src/payment/payment-methods/paypal/PayPalButton.jsx
+++ b/src/payment/payment-methods/paypal/PayPalButton.jsx
@@ -1,19 +1,65 @@
-import React from 'react';
+import React, { useEffect, useRef } from 'react';
+import { useDispatch } from 'react-redux';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+import { trackElementIntersection } from '../../data/actions';
+import { ElementType, PaymentTitle, IS_FULLY_SHOWN_THRESHOLD_OR_MARGIN } from '../../../cohesion/constants';
import PayPalLogo from './assets/paypal-logo.png';
import messages from './PayPalButton.messages';
-const PayPalButton = ({ intl, isProcessing, ...props }) => (
-
- { isProcessing ? : null }
-
-
-);
+const PayPalButton = ({ intl, isProcessing, ...props }) => {
+ const buttonRef = useRef(null);
+ const dispatch = useDispatch();
+
+ // RV event tracking for PayPal Button
+ useEffect(() => {
+ const observerCallback = (entries) => {
+ entries.forEach(entry => {
+ if (entry.isIntersecting) {
+ const elementId = entry.target?.id;
+ const tagularElement = {
+ title: PaymentTitle,
+ url: window.location.href,
+ pageType: 'checkout',
+ elementType: ElementType.Button,
+ position: elementId,
+ name: 'paypal',
+ text: 'PayPal',
+ };
+ dispatch(trackElementIntersection(tagularElement));
+ }
+ });
+ };
+
+ const observer = new IntersectionObserver(observerCallback, {
+ threshold: IS_FULLY_SHOWN_THRESHOLD_OR_MARGIN,
+ });
+
+ const currentElement = buttonRef.current;
+
+ if (currentElement) {
+ observer.observe(currentElement);
+ }
+
+ return () => {
+ if (currentElement) {
+ observer.unobserve(currentElement);
+ }
+ observer.disconnect();
+ };
+ }, [dispatch]);
+
+ return (
+
+ { isProcessing ? : null }
+
+
+ );
+};
PayPalButton.propTypes = {
intl: intlShape.isRequired,
diff --git a/src/payment/payment-methods/paypal/PayPalButton.test.jsx b/src/payment/payment-methods/paypal/PayPalButton.test.jsx
index c765e09ba..41e8a0fde 100644
--- a/src/payment/payment-methods/paypal/PayPalButton.test.jsx
+++ b/src/payment/payment-methods/paypal/PayPalButton.test.jsx
@@ -1,14 +1,43 @@
import React from 'react';
+import { Provider } from 'react-redux';
+import configureMockStore from 'redux-mock-store';
import { render } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
+import '../../../mockIntersectionObserver';
+
import PayPalButton from './PayPalButton';
-describe('OrderDetails', () => {
+const mockStore = configureMockStore();
+
+describe('PayPalButton', () => {
+ let store;
+ let state;
+
+ beforeEach(() => {
+ state = {
+ userAccount: { email: 'person@example.com' },
+ payment: {
+ basket: {
+ loaded: false,
+ loading: false,
+ products: [],
+ },
+ },
+ i18n: {
+ locale: 'en',
+ },
+ };
+
+ store = mockStore(state);
+ });
+
it('should render the button by default', () => {
const component = (
-
+
+
+
);
const { container: tree } = render(component);
@@ -17,7 +46,9 @@ describe('OrderDetails', () => {
it('should render the button with a spinner when processing', () => {
const component = (
-
+
+
+
);
const { container: tree } = render(component);
diff --git a/src/payment/payment-methods/paypal/__snapshots__/PayPalButton.test.jsx.snap b/src/payment/payment-methods/paypal/__snapshots__/PayPalButton.test.jsx.snap
index 5cb4bdd8c..ec0f837a4 100644
--- a/src/payment/payment-methods/paypal/__snapshots__/PayPalButton.test.jsx.snap
+++ b/src/payment/payment-methods/paypal/__snapshots__/PayPalButton.test.jsx.snap
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`OrderDetails should render the button by default 1`] = `
+exports[`PayPalButton should render the button by default 1`] = `
`;
-exports[`OrderDetails should render the button with a spinner when processing 1`] = `
+exports[`PayPalButton should render the button with a spinner when processing 1`] = `