diff --git a/audit-ci.json b/audit-ci.json index 1c0c37993..9ef402b2d 100644 --- a/audit-ci.json +++ b/audit-ci.json @@ -11,7 +11,8 @@ "GHSA-qw6h-vgh9-j6wx", "GHSA-9wv6-86v2-598j", "GHSA-m6fv-jmcg-4jfg", - "GHSA-cm22-4g7w-348p" + "GHSA-cm22-4g7w-348p", + "GHSA-c7qv-q95q-8v27" ], "moderate": true } diff --git a/cohesion.config.js b/cohesion.config.js new file mode 100644 index 000000000..d45608a85 --- /dev/null +++ b/cohesion.config.js @@ -0,0 +1,38 @@ +const cohesionConfig = { + name: 'edx', + slug: 'edx', + domain: 'edx.org', + domainLabel: 'edx', + domainExtension: '.org', + domainLabelWithExtension: 'edx.org', + postTypeGql: '', + homepageGql: '', + siteUrl: process.env.MARKETING_SITE_BASE_URL, + cmsUrl: process.env.NEXT_PUBLIC_WORDPRESS_URL || '', + cmsUser: process.env.WP_USER || '', + cmsPwd: process.env.WP_PWD || '', + logoUrl: '', + studyMatchUrl: '', + voyagerUrl: '/discover', + identityToken: '', + gaCid: '', + gaSid: '', + gaMid: '', + defaultDegree: '', + defaultCategory: '', + defaultSubject: '', + tagularApiKey: '', + tagularSourceKey: 'src_2euJfAVNt6Z9kQz4e9t1SQBtm8x', + tagularWriteKey: 'wk_2euJfDkJVTtEVzsC8BPOb0g9dVj', + tagularCookieDomain: 'edx.org', + tagularDomainWhitelist: JSON.stringify([ + 'edx.org', + ]), + monarchSourceId: '', + monarchToken: '', + newRelicAppID: '', + newRelicVoyagerAppID: '', + cookieLawId: '', +}; + +module.exports = cohesionConfig; diff --git a/package-lock.json b/package-lock.json index d0168321c..c44c859e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,7 +47,8 @@ "redux-thunk": "^2.4.1", "regenerator-runtime": "^0.13.9", "reselect": "^4.1.6", - "universal-cookie": "^4.0.4" + "universal-cookie": "^4.0.4", + "uuid": "^11.0.2" }, "devDependencies": { "@edx/browserslist-config": "^1.2.0", @@ -4260,6 +4261,18 @@ "node": ">=10" } }, + "node_modules/@openedx/paragon/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@pmmmwh/react-refresh-webpack-plugin": { "version": "0.5.11", "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.11.tgz", @@ -19079,15 +19092,15 @@ } }, "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.2.tgz", + "integrity": "sha512-14FfcOJmqdjbBPdDjFQyk/SdT4NySW4eM0zcG+HqbHP5jzuH56xO3J1DGhgs/cEMCfwYi3HQI1gnTO62iaG+tQ==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/v8-to-istanbul": { diff --git a/package.json b/package.json index 262de5087..3021ebd77 100755 --- a/package.json +++ b/package.json @@ -68,7 +68,8 @@ "redux-thunk": "^2.4.1", "regenerator-runtime": "^0.13.9", "reselect": "^4.1.6", - "universal-cookie": "^4.0.4" + "universal-cookie": "^4.0.4", + "uuid": "^11.0.2" }, "devDependencies": { "@edx/browserslist-config": "^1.2.0", diff --git a/public/index.html b/public/index.html index 1b8ff47f7..59c3499cd 100755 --- a/public/index.html +++ b/public/index.html @@ -18,6 +18,33 @@ <% if (htmlWebpackPlugin.options.OPTIMIZELY_PROJECT_ID) { %> <% } %> + + <% /* NOTE: Adding Red Ventures related cohesion/tagular code for the launch of the new marketing website. */ %> + <% if (htmlWebpackPlugin.options.cohesionConfig) { %> + + <% } %>
diff --git a/src/cohesion/constants.js b/src/cohesion/constants.js new file mode 100644 index 000000000..7957703b5 --- /dev/null +++ b/src/cohesion/constants.js @@ -0,0 +1,28 @@ +export const ElementType = { + Link: 'LINK', + Entry: 'ENTRY', + Button: 'BUTTON', +}; + +export const PaymentTitle = 'Payment | edX'; + +export const EventMap = { + ProductClicked: 'redventures.ecommerce.v1.ProductClicked', + ProductLoaded: 'redventures.ecommerce.v1.ProductLoaded', + ProductViewed: 'redventures.ecommerce.v1.ProductViewed', + ElementClicked: 'redventures.usertracking.v3.ElementClicked', + ElementViewed: 'redventures.usertracking.v3.ElementViewed', + FieldSelected: 'redventures.usertracking.v3.FieldSelected', + FormSubmitted: 'redventures.usertracking.v3.FormSubmitted', + FormViewed: 'redventures.usertracking.v3.FormViewed', + ConversionTracked: 'core.conversions.ConversionTracked.v2', +}; + +export const IS_FULLY_SHOWN_THRESHOLD_OR_MARGIN = 1.0; +export const IS_SINGLE_PX_SHOWN_THRESHOLD_OR_MARGIN = 0.0; +export const DOCUMENT_ROOT_NODE = null; + +export const defaultOptions = { + threshold: IS_FULLY_SHOWN_THRESHOLD_OR_MARGIN, + root: DOCUMENT_ROOT_NODE, +}; diff --git a/src/cohesion/dataTranslationMatrices.js b/src/cohesion/dataTranslationMatrices.js new file mode 100644 index 000000000..c17466873 --- /dev/null +++ b/src/cohesion/dataTranslationMatrices.js @@ -0,0 +1,65 @@ +const DEFAULT_LOOKUP_VALUE = '*'; + +// enums cause noo-shadow errors in prospectus +export const BaseTagularVariant = { + Courses: 'courses', +}; + +const TagularVariant = { + // Include base/x-ref things + ...BaseTagularVariant, + // Supplied from Data Team + XSeries: 'certificates-xseries', + ProfessionalCertificate: 'certificates-prof-cert', + ExecEd: 'certificates-exec-ed', + MicroBachelors: 'certificates-micro-bachelors', + MicroMasters: 'certificates-micro-masters', + Bachelors: 'degrees-bachelors', + Masters: 'degrees-masters', + Doctorate: 'degrees-doctorate', + Bootcamps: 'bootcamps', + // Not Final + Certificates: 'degrees-certificates', + Licenses: 'degrees-licenses', + // Special Values + All: 'all-products/mixed', + Unknown: BaseTagularVariant.Courses, +}; + +const typeToVariant = { + [DEFAULT_LOOKUP_VALUE]: TagularVariant.Unknown, // missing value + // type_attr Slugs + bachelors: TagularVariant.Bachelors, + masters: TagularVariant.Masters, + microbachelors: TagularVariant.MicroBachelors, + micromasters: TagularVariant.MicroMasters, + 'professional-certificate': TagularVariant.ProfessionalCertificate, + // 'professional-program-wl': TagularVariant.Unknown, Whitelabel Programs are no more. + xseries: TagularVariant.XSeries, + doctorate: TagularVariant.Doctorate, + license: TagularVariant.Licenses, + certificate: TagularVariant.Certificates, + // type_attr Display Names + Bachelors: TagularVariant.Bachelors, + Masters: TagularVariant.Masters, + MicroBachelors: TagularVariant.MicroBachelors, + MicroMasters: TagularVariant.MicroMasters, + 'Professional Certificate': TagularVariant.ProfessionalCertificate, + // 'Professional Program': TagularVariant.Unknown, Whitelabel Programs are no more. + XSeries: TagularVariant.XSeries, + Doctorate: TagularVariant.Doctorate, + License: TagularVariant.Licenses, + Certificate: TagularVariant.Certificates, + // course_type Slugs + 'executive-education-2u': TagularVariant.ExecEd, + 'bootcamp-2u': TagularVariant.Bootcamps, + // Skipped as it was a note in the doc: 'Anything else': TagularVariant.Courses, + // course_type Display Name + 'Executive Education': TagularVariant.ExecEd, + 'Boot Camp': TagularVariant.Bootcamps, + Course: TagularVariant.Courses, +}; + +export default function translateVariant(x) { + return typeToVariant[x] || typeToVariant[DEFAULT_LOOKUP_VALUE]; +} diff --git a/src/cohesion/helpers.js b/src/cohesion/helpers.js new file mode 100644 index 000000000..c4a418c5d --- /dev/null +++ b/src/cohesion/helpers.js @@ -0,0 +1,68 @@ +import Cookies from 'universal-cookie'; +import { v4 as uuidv4 } from 'uuid'; +import { EventMap } from './constants'; + +/** + * Fetch or Create a Tagular CorrelationID. This also refreshes the cookie's expiry. + */ +export const getCorrelationID = () => { + const COOKIE_NAME = 'tglr_correlation_id'; + const PARAM_NAME = 'correlationId'; + + function getQueryParameter(name) { + const params = new URLSearchParams(window.location.search); + + return params.get(name); + } + + let paramId = getQueryParameter(PARAM_NAME) || new Cookies().get(COOKIE_NAME); + + if (!paramId) { + paramId = uuidv4(); + } + + const expirationDate = new Date(); + expirationDate.setMinutes(expirationDate.getMinutes() + 30); // 30 mins expiration from now + new Cookies().set(COOKIE_NAME, paramId, { expires: expirationDate }); + + return paramId; +}; + +/** + * Submit ('beam') an event via Tagular to Make. + * @param eventName Schema Name of the Event + * @param eventData The data required by the schema + */ +export const tagularEvent = (eventName, eventData) => { + // if tagular is available, try sending given event with event data + if (typeof window !== 'undefined' && window.tagular) { + try { + window.tagular('beam', eventName, { + '@type': EventMap[eventName], + ...eventData, + }); + } catch (error) { + // eslint-disable-next-line no-console + console.warn(`Tagular event ${eventName} not sent.`, error); + } + } else { + // eslint-disable-next-line no-console + console.warn('Tagular not available on page.'); + } +}; + +/** + * Make Near Slugs from Plain Strings for ease of eventing. + * @example + * "Computer Science" => "computer-science" + * "Humanities & Arts" => "humanities-&-arts" + * "Someone added a space " => "someone-added-a-space" + * + * @param x Input String + */ +export function hyphenateForTagular(x) { + return x + .trim() + .toLowerCase() + .replace(/[^\w&]/g, '-'); +} diff --git a/src/feedback/AlertList.test.jsx b/src/feedback/AlertList.test.jsx index 3372fdc43..0d2076fb5 100644 --- a/src/feedback/AlertList.test.jsx +++ b/src/feedback/AlertList.test.jsx @@ -9,6 +9,8 @@ import createRootReducer from '../data/reducers'; import { addMessage } from './data/actions'; import { MESSAGE_TYPES } from './data/constants'; +import '../mockIntersectionObserver'; + jest.mock('@edx/frontend-platform/logging', () => ({ logError: jest.fn(), })); diff --git a/src/feedback/AlertMessage.jsx b/src/feedback/AlertMessage.jsx index 05ec0cd5b..31f585bb2 100644 --- a/src/feedback/AlertMessage.jsx +++ b/src/feedback/AlertMessage.jsx @@ -1,7 +1,12 @@ -import React, { useCallback } from 'react'; +import React, { + useCallback, useEffect, useRef, +} from 'react'; +import { useDispatch } from 'react-redux'; import PropTypes from 'prop-types'; import { Alert } from '@openedx/paragon'; import { ALERT_TYPES, MESSAGE_TYPES } from './data/constants'; +import { trackElementIntersection } from '../payment/data/actions'; +import { ElementType, PaymentTitle, IS_FULLY_SHOWN_THRESHOLD_OR_MARGIN } from '../cohesion/constants'; // Put in a message type, get an alert type. const severityMap = { @@ -17,6 +22,45 @@ const AlertMessage = (props) => { id, messageType, userMessage, closeHandler, data, } = props; + const alertRef = useRef(null); + const dispatch = useDispatch(); + + // RV promo banner tracking for successful coupon application + useEffect(() => { + const observerCallback = (entries) => { + entries.forEach(entry => { + if (entry.isIntersecting && messageType === 'success' && userMessage.includes('added to basket')) { + const tagularElement = { + title: PaymentTitle, + url: entry.target?.baseURI, + pageType: 'checkout', + elementType: ElementType.Button, + name: 'promotional-code', + text: 'Apply', + }; + dispatch(trackElementIntersection(tagularElement)); + } + }); + }; + + const observer = new IntersectionObserver(observerCallback, { + threshold: IS_FULLY_SHOWN_THRESHOLD_OR_MARGIN, + }); + + const currentElement = alertRef.current; + + if (currentElement) { + observer.observe(currentElement); + } + + return () => { + if (currentElement) { + observer.unobserve(currentElement); + } + observer.disconnect(); + }; + }, [messageType, userMessage, dispatch]); + const statusAlertProps = { variant: ALERT_TYPES.WARNING, onClose: useCallback(() => { closeHandler(id); }, [closeHandler, id]), @@ -43,9 +87,11 @@ const AlertMessage = (props) => { } return ( - - {statusAlertProps.dialog} - +
+ + {statusAlertProps.dialog} + +
); }; diff --git a/src/feedback/AlertMessage.test.jsx b/src/feedback/AlertMessage.test.jsx index 978c7b067..07df05158 100644 --- a/src/feedback/AlertMessage.test.jsx +++ b/src/feedback/AlertMessage.test.jsx @@ -1,24 +1,52 @@ import React from 'react'; +import { Provider } from 'react-redux'; +import configureMockStore from 'redux-mock-store'; import { fireEvent, render } from '@testing-library/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import AlertMessage from './AlertMessage'; import { MESSAGE_TYPES } from './data/constants'; +import '../mockIntersectionObserver'; + +const mockStore = configureMockStore(); + describe('AlertMessage', () => { // The AlertList test covers most of AlertMessage testing. + 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 handle closing', () => { const closeHandlerMock = jest.fn(); const component = ( - + + + ); @@ -34,12 +62,14 @@ describe('AlertMessage', () => { const component = ( - + + + ); @@ -51,11 +81,13 @@ describe('AlertMessage', () => { it('should render a userMessage function', () => { const component = ( - 'Wondrous message!'} - closeHandler={jest.fn()} - /> + + 'Wondrous message!'} + closeHandler={jest.fn()} + /> + ); @@ -66,11 +98,13 @@ describe('AlertMessage', () => { it('should render a userMessage element', () => { const component = ( - Wondrous message!} - closeHandler={jest.fn()} - /> + + Wondrous message!} + closeHandler={jest.fn()} + /> + ); diff --git a/src/feedback/__snapshots__/AlertList.test.jsx.snap b/src/feedback/__snapshots__/AlertList.test.jsx.snap index b8a050b91..c3cd368df 100644 --- a/src/feedback/__snapshots__/AlertList.test.jsx.snap +++ b/src/feedback/__snapshots__/AlertList.test.jsx.snap @@ -5,178 +5,202 @@ exports[`AlertList should be null by default 1`] = `
`; exports[`AlertList should render messages of each type 1`] = `
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`] = `
@@ -36,34 +40,38 @@ exports[`AlertMessage should default its severity when necessary 1`] = ` exports[`AlertMessage should render a userMessage element 1`] = `
@@ -72,31 +80,33 @@ exports[`AlertMessage should render a userMessage element 1`] = ` exports[`AlertMessage should render a userMessage function 1`] = `
- 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 - - {/* Apple Pay temporarily disabled per REV-927 - https://github.com/openedx/frontend-app-payment/pull/256 */}

@@ -330,6 +385,7 @@ Checkout.propTypes = { loaded: PropTypes.bool, fetchClientSecret: PropTypes.func.isRequired, submitPayment: PropTypes.func.isRequired, + trackPaymentButtonClick: PropTypes.func.isRequired, isFreeBasket: PropTypes.bool, submitting: PropTypes.bool, isBasketProcessing: PropTypes.bool, @@ -339,6 +395,10 @@ Checkout.propTypes = { stripe: PropTypes.object, // eslint-disable-line react/forbid-prop-types clientSecretId: PropTypes.string, isPaypalRedirect: PropTypes.bool, + products: PropTypes.arrayOf(PropTypes.shape({ + courseKey: PropTypes.string, + title: PropTypes.string, + })), }; Checkout.defaultProps = { @@ -353,11 +413,18 @@ Checkout.defaultProps = { stripe: null, clientSecretId: null, isPaypalRedirect: false, + products: [], }; +const mapDispatchToProps = (dispatch) => ({ + fetchClientSecret: () => dispatch(fetchClientSecret()), + submitPayment: (data) => dispatch(submitPayment(data)), + trackPaymentButtonClick: (metadata) => dispatch(trackPaymentButtonClick(metadata)), +}); + const mapStateToProps = (state) => ({ ...paymentSelector(state), ...updateClientSecretSelector(state), }); -export default connect(mapStateToProps, { fetchClientSecret, submitPayment })(injectIntl(Checkout)); +export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(Checkout)); diff --git a/src/payment/checkout/Checkout.test.jsx b/src/payment/checkout/Checkout.test.jsx index f153eda09..18af0a8b1 100644 --- a/src/payment/checkout/Checkout.test.jsx +++ b/src/payment/checkout/Checkout.test.jsx @@ -14,6 +14,8 @@ import '../__factories__/userAccount.factory'; import { transformResults } from '../data/utils'; import { getPerformanceProperties } from '../performanceEventing'; +import '../../mockIntersectionObserver'; + const validateRequiredFieldsMock = jest.spyOn(formValidators, 'validateRequiredFields'); const validateCardDetailsMock = jest.spyOn(formValidators, 'validateCardDetails'); diff --git a/src/payment/checkout/payment-form/CardHolderInformation.test.jsx b/src/payment/checkout/payment-form/CardHolderInformation.test.jsx index 07481b214..81d480c8e 100644 --- a/src/payment/checkout/payment-form/CardHolderInformation.test.jsx +++ b/src/payment/checkout/payment-form/CardHolderInformation.test.jsx @@ -16,6 +16,7 @@ import createRootReducer from '../../../data/reducers'; import { getCountryStatesMap, isPostalCodeRequired } from './utils/form-validators'; import '../../__factories__/userAccount.factory'; +import '../../../mockIntersectionObserver'; jest.mock('@edx/frontend-platform/analytics', () => ({ sendTrackEvent: jest.fn(), diff --git a/src/payment/checkout/payment-form/PaymentForm.test.jsx b/src/payment/checkout/payment-form/PaymentForm.test.jsx index 1478cc7a2..b0fcf7129 100644 --- a/src/payment/checkout/payment-form/PaymentForm.test.jsx +++ b/src/payment/checkout/payment-form/PaymentForm.test.jsx @@ -12,7 +12,9 @@ import { fireEvent, render, screen } from '@testing-library/react'; import PaymentForm from './PaymentForm'; import * as formValidators from './utils/form-validators'; import createRootReducer from '../../../data/reducers'; + import '../../__factories__/userAccount.factory'; +import '../../../mockIntersectionObserver'; jest.mock('@edx/frontend-platform/analytics', () => ({ sendTrackEvent: jest.fn(), diff --git a/src/payment/checkout/payment-form/PlaceOrderButton.jsx b/src/payment/checkout/payment-form/PlaceOrderButton.jsx index 22215bac9..9d44fea2e 100644 --- a/src/payment/checkout/payment-form/PlaceOrderButton.jsx +++ b/src/payment/checkout/payment-form/PlaceOrderButton.jsx @@ -1,7 +1,10 @@ -import React from 'react'; +import React, { useEffect, useRef } from 'react'; +import { useDispatch } from 'react-redux'; import PropTypes from 'prop-types'; import { injectIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; import { StatefulButton } from '@openedx/paragon'; +import { trackElementIntersection } from '../../data/actions'; +import { ElementType, PaymentTitle, IS_FULLY_SHOWN_THRESHOLD_OR_MARGIN } from '../../../cohesion/constants'; const PlaceOrderButton = ({ showLoadingButton, onSubmitButtonClick, stripeSelectedPaymentMethod, disabled, isProcessing, @@ -12,9 +15,49 @@ const PlaceOrderButton = ({ // istanbul ignore if if (isProcessing) { submitButtonState = 'processing'; } + const buttonRef = useRef(null); + const dispatch = useDispatch(); + + // RV event tracking for Place Order Button + useEffect(() => { + const observerCallback = (entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + const tagularElement = { + title: PaymentTitle, + url: window.location.href, + pageType: 'checkout', + elementType: ElementType.Button, + position: 'placeOrderButton', + name: 'stripe', + text: 'Stripe', + }; + 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 (
-
+
{ showLoadingButton ? (
 
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 }) => ( - -); +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 ( + + ); +}; 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`] = `