From 1a22a2ba6f00e4682d335f683193d798bd38d87d Mon Sep 17 00:00:00 2001 From: julianajlk Date: Wed, 16 Oct 2024 11:52:32 -0400 Subject: [PATCH 01/25] feat: Add cohesion config to htmlWebpackPlugin options --- cohesion.config.js | 38 ++++++++++++++++++++++++++++++++++++++ webpack.prod.config.js | 3 +++ 2 files changed, 41 insertions(+) create mode 100644 cohesion.config.js diff --git a/cohesion.config.js b/cohesion.config.js new file mode 100644 index 000000000..b306f5aba --- /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: 'https://www.edx.org', + 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/webpack.prod.config.js b/webpack.prod.config.js index 97b353564..c7675bd47 100644 --- a/webpack.prod.config.js +++ b/webpack.prod.config.js @@ -10,6 +10,8 @@ const { getBaseConfig } = require('@openedx/frontend-build'); */ const config = getBaseConfig('webpack-prod'); +// eslint-disable-next-line import/extensions +const cohesionConfig = require('./cohesion.config.js'); /* eslint-disable no-param-reassign */ config.plugins.forEach((plugin) => { @@ -29,6 +31,7 @@ config.plugins.forEach((plugin) => { } plugin.userOptions.preconnect = preconnectDomains; + plugin.options.cohesionConfig = cohesionConfig; } }); From 69c4447bcc61cff5399579e2060a1251123d2f30 Mon Sep 17 00:00:00 2001 From: julianajlk Date: Wed, 16 Oct 2024 11:53:25 -0400 Subject: [PATCH 02/25] feat: Add cohesion snippet to index.html --- public/index.html | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) 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) { %> + + <% } %>
From 920a38fe17e61ac8b1b8afdf4555b591fe97baa6 Mon Sep 17 00:00:00 2001 From: julianajlk Date: Thu, 17 Oct 2024 13:39:38 -0400 Subject: [PATCH 03/25] feat: Add trackPaymentButtonClick on PayPal button --- src/payment/checkout/Checkout.jsx | 18 ++++++++++++++++-- src/payment/data/actions.js | 24 ++++++++++++++++++++++++ src/payment/data/reducers.js | 21 +++++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/payment/checkout/Checkout.jsx b/src/payment/checkout/Checkout.jsx index e6205eae1..6c69a52ec 100644 --- a/src/payment/checkout/Checkout.jsx +++ b/src/payment/checkout/Checkout.jsx @@ -16,7 +16,11 @@ import { paymentSelector, updateClientSecretSelector, } from '../data/selectors'; -import { fetchClientSecret, submitPayment } from '../data/actions'; +import { + fetchClientSecret, + submitPayment, + trackPaymentButtonClick, +} from '../data/actions'; import AcceptedCardLogos from './assets/accepted-card-logos.png'; import PaymentForm from './payment-form/PaymentForm'; @@ -63,6 +67,9 @@ class Checkout extends React.Component { ); this.props.submitPayment({ method: 'paypal' }); + + // Red Ventures Cohesion Tagular Event Tracking + this.props.trackPaymentButtonClick('PayPal'); }; // eslint-disable-next-line react/no-unused-class-component-methods @@ -330,6 +337,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, @@ -355,9 +363,15 @@ Checkout.defaultProps = { isPaypalRedirect: false, }; +const mapDispatchToProps = (dispatch) => ({ + fetchClientSecret: () => dispatch(fetchClientSecret), + submitPayment: (data) => dispatch(submitPayment(data)), + trackPaymentButtonClick: (buttonName) => dispatch(trackPaymentButtonClick(buttonName)), +}); + 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/data/actions.js b/src/payment/data/actions.js index f3bfb7467..343ecb5e5 100644 --- a/src/payment/data/actions.js +++ b/src/payment/data/actions.js @@ -1,4 +1,6 @@ import { createRoutine } from 'redux-saga-routines'; +import EventMap from '../../cohesion/constants'; +import tagularEvent from '../../cohesion/helpers'; // Routines are action + action creator pairs in a series. // Actions adhere to the flux standard action format. @@ -74,3 +76,25 @@ export const clientSecretDataReceived = clientSecret => ({ type: CLIENT_SECRET_DATA_RECEIVED, payload: clientSecret, }); + +export const TRACK_PAYMENT_BUTTON_CLICK = 'TRACK_PAYMENT_BUTTON_CLICK'; + +export const trackPaymentButtonClick = buttonName => { + const payload = { + text: buttonName, + name: buttonName.toLowerCase(), + title: 'Payment | edX', + url: 'https://payment.edx.org', + pageType: 'checkout', + elementType: 'BUTTON', + }; + + // 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 + tagularEvent(EventMap.ElementClicked, payload); + + return { + type: TRACK_PAYMENT_BUTTON_CLICK, + payload, + }; +}; diff --git a/src/payment/data/reducers.js b/src/payment/data/reducers.js index 204a08fbf..406a5a4a0 100644 --- a/src/payment/data/reducers.js +++ b/src/payment/data/reducers.js @@ -12,6 +12,7 @@ import { submitPayment, fetchCaptureKey, fetchClientSecret, + TRACK_PAYMENT_BUTTON_CLICK, } from './actions'; import { DEFAULT_STATUS } from '../checkout/payment-form/flex-microform/constants'; @@ -115,10 +116,30 @@ const clientSecret = (state = clientSecretInitialState, action = null) => { return state; }; +const pageTrackingInitialState = { + paymentButtonClicks: [], +}; + +const pageTracking = (state = pageTrackingInitialState, action = null) => { + if (action !== null) { + switch (action.type) { + case TRACK_PAYMENT_BUTTON_CLICK: + return { + ...state, + paymentButtonClicks: [...state.paymentButtonClicks, action.payload], + }; + + default: + } + } + return state; +}; + const reducer = combineReducers({ basket, captureKey, clientSecret, + pageTracking, }); export default reducer; From 3e1c23362163b479e94a21d8af32babba2579611 Mon Sep 17 00:00:00 2001 From: julianajlk Date: Fri, 18 Oct 2024 11:17:42 -0400 Subject: [PATCH 04/25] feat: Add cohesion directory with helper functions --- src/cohesion/constants.js | 12 +++++++ src/cohesion/helpers.js | 33 ++++++++++++++++++ src/cohesion/hooks.js | 71 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 src/cohesion/constants.js create mode 100644 src/cohesion/helpers.js create mode 100644 src/cohesion/hooks.js diff --git a/src/cohesion/constants.js b/src/cohesion/constants.js new file mode 100644 index 000000000..72f450b7c --- /dev/null +++ b/src/cohesion/constants.js @@ -0,0 +1,12 @@ +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', +}; + +export default EventMap; diff --git a/src/cohesion/helpers.js b/src/cohesion/helpers.js new file mode 100644 index 000000000..98578ea10 --- /dev/null +++ b/src/cohesion/helpers.js @@ -0,0 +1,33 @@ +import EventMap from './constants'; + +/** + * 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.'); + } +}; + +export function pageTrackingObject(pageType) { + return { + title: window.document.title, + url: window.location.href, + pageType, + referrer: window.document.referrer, + }; +} diff --git a/src/cohesion/hooks.js b/src/cohesion/hooks.js new file mode 100644 index 000000000..8a266a3c1 --- /dev/null +++ b/src/cohesion/hooks.js @@ -0,0 +1,71 @@ +import { + useCallback, + useEffect, + useRef, + useState, +} from 'react'; + +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; + +const defaultOptions = { + threshold: IS_FULLY_SHOWN_THRESHOLD_OR_MARGIN, + root: DOCUMENT_ROOT_NODE, +}; + +/** +Hook to track if an element is intersecting with a root (null represents ). +@param callback The callback to be invoked when an item intersects. +@param options The options for IntersectionObserver. + */ +export const useIntersectionObserver = (callback, options = defaultOptions) => { + const ref = useRef(null); + + useEffect(() => { + if (!ref.current) { return; } + const refCurrent = ref.current; + + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + callback(entry); + } + }); + }, options); + + if (ref.current) { + observer.observe(refCurrent); + } + + // eslint-disable-next-line consistent-return + return () => { + if (refCurrent) { + observer.unobserve(refCurrent); + } + }; + }, [callback, options]); + + return ref; +}; + +/** +Hook to track an element's intersection but only trigger callback once per element. +@param callback The callback to be invoked once the item is shown. +@param options Intersection observer options. + */ +export const useSingleCallIntersectionObserver = (callback, options = defaultOptions) => { + const [hasBeenShown, setHasBeenShown] = useState(false); + + const handleVisibilityChange = useCallback((entry) => { + if (!hasBeenShown) { + callback(entry); + setHasBeenShown(true); + } + }, [callback, hasBeenShown]); + + return useIntersectionObserver(handleVisibilityChange, options); +}; + +export default useSingleCallIntersectionObserver; +export const useIsShowing = useIntersectionObserver; From 187958f25365572153b5645ce0e89a0b07f102de Mon Sep 17 00:00:00 2001 From: julianajlk Date: Tue, 22 Oct 2024 00:53:14 -0400 Subject: [PATCH 05/25] feat: Add tracking data for elementClicked and elementViewed to Checkout buttons --- src/payment/checkout/Checkout.jsx | 157 +++++++++++++++++++++++++++--- 1 file changed, 146 insertions(+), 11 deletions(-) diff --git a/src/payment/checkout/Checkout.jsx b/src/payment/checkout/Checkout.jsx index 6c69a52ec..aad592c11 100644 --- a/src/payment/checkout/Checkout.jsx +++ b/src/payment/checkout/Checkout.jsx @@ -9,8 +9,16 @@ import { intlShape, } from '@edx/frontend-platform/i18n'; import { sendTrackEvent } from '@edx/frontend-platform/analytics'; +import PayPalLogo from '../payment-methods/paypal/assets/paypal-logo.png'; +import { + DOCUMENT_ROOT_NODE, + IS_FULLY_SHOWN_THRESHOLD_OR_MARGIN, + ElementType, + PaymentTitle, +} from '../../cohesion/constants'; import messages from './Checkout.messages'; +import messagesPayPal from '../payment-methods/paypal/PayPalButton.messages'; import { basketSelector, paymentSelector, @@ -19,6 +27,7 @@ import { import { fetchClientSecret, submitPayment, + trackElementIntersection, trackPaymentButtonClick, } from '../data/actions'; import AcceptedCardLogos from './assets/accepted-card-logos.png'; @@ -26,12 +35,18 @@ import AcceptedCardLogos from './assets/accepted-card-logos.png'; import PaymentForm from './payment-form/PaymentForm'; import StripePaymentForm from './payment-form/StripePaymentForm'; import FreeCheckoutOrderButton from './FreeCheckoutOrderButton'; -import { PayPalButton } from '../payment-methods/paypal'; import { ORDER_TYPES } from '../data/constants'; +import { hyphenateForTagular } from '../../cohesion/helpers'; +import { BaseTagularVariant } from '../../cohesion/dataTranslationMatrices'; class Checkout extends React.Component { constructor(props) { super(props); + this.paypalButtonRef = React.createRef(); + this.paypalButtonClicked = false; + this.placeOrderButtonClicked = false; + this.observer = null; + this.hasBeenShown = {}; this.state = { hasRedirectedToPaypal: false, }; @@ -39,8 +54,61 @@ class Checkout extends React.Component { componentDidMount() { this.props.fetchClientSecret(); + this.setupObservers(); + } + + componentWillUnmount() { + this.cleanupObservers(); } + setupObservers = () => { + const options = { + threshold: IS_FULLY_SHOWN_THRESHOLD_OR_MARGIN, + root: DOCUMENT_ROOT_NODE, + }; + + this.observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + this.handleIntersectionObserver(entry); + } + }); + }, options); + + // Observe each entry + if (this.paypalButtonRef.current) { + this.observer.observe(this.paypalButtonRef.current); + } + // TODO: PlaceOrderButton only comes into view after the Stripe credit card form is rendered. + // Need to add this observe on componentDidUptate plus cleanup. + }; + + cleanupObservers = () => { + if (this.observer) { + this.observer.unobserve(this.paypalButtonRef.current); + this.observer.disconnect(); + } + }; + + handleIntersectionObserver = (entry) => { + const elementId = entry.target?.id; + + // Single call behavior + if (!this.hasBeenShown[elementId]) { + const tagularElement = { + title: PaymentTitle, + url: window.location.href, + pageType: 'checkout', + elementType: ElementType.Button, + position: elementId, + name: ElementType.Button, + ...(elementId === 'PayPalButton' ? { text: 'PayPal' } : {}), + }; + this.props.trackElementIntersection(tagularElement); + this.hasBeenShown[elementId] = true; + } + }; + handleRedirectToPaypal = () => { const { loading, isBasketProcessing, isPaypalRedirect } = this.props; const { hasRedirectedToPaypal } = this.state; @@ -53,6 +121,7 @@ class Checkout extends React.Component { }; handleSubmitPayPal = () => { + const paymentMethod = 'PayPal'; // TO DO: after event parity, track data should be // sent only if the payment is processed, not on click // Check for ApplePay and Free Basket as well @@ -61,15 +130,27 @@ class Checkout extends React.Component { { type: 'click', category: 'checkout', - paymentMethod: 'PayPal', + paymentMethod, stripeEnabled: this.props.enableStripePaymentProcessor, }, ); - this.props.submitPayment({ method: 'paypal' }); + // Red Ventures Cohesion Tagular Event Tracking for PayPal + if (!this.paypalButtonClicked) { + this.paypalButtonClicked = true; + const tagularElement = { + title: PaymentTitle, + url: window.location.href, + pageType: 'checkout', + elementType: ElementType.Button, + text: paymentMethod, + name: paymentMethod.toLowerCase(), + }; + + this.props.trackPaymentButtonClick(tagularElement); + } - // Red Ventures Cohesion Tagular Event Tracking - this.props.trackPaymentButtonClick('PayPal'); + this.props.submitPayment({ method: paymentMethod.toLowerCase() }); }; // eslint-disable-next-line react/no-unused-class-component-methods @@ -115,7 +196,20 @@ class Checkout extends React.Component { }; handleSubmitStripe = (formData) => { - this.props.submitPayment({ method: 'stripe', ...formData }); + // Red Ventures Cohesion Tagular Event Tracking for Stripe + const tagularElement = { + title: PaymentTitle, + url: window.location.href, + pageType: 'checkout', + elementType: ElementType.Button, + timestamp: Date.now(), + productList: this.getProductList(), + }; + + if (!this.placeOrderButtonClicked) { + this.placeOrderButtonClicked = true; + } + this.props.submitPayment({ method: 'stripe', tagularElement, ...formData }); }; handleSubmitStripeButtonClick = (stripeSelectedPaymentMethod) => { @@ -138,6 +232,30 @@ class Checkout extends React.Component { ); }; + getProductList = () => { + const { products } = this.props; + const productList = []; + if (products || products.length !== 0) { + products.forEach(product => { + productList.push({ + variant: BaseTagularVariant.Courses, + brand: this.getPartnerName(product), // School or Partner name + name: product.title, // Course(s) title + }); + }); + } + return productList; + }; + + getPartnerName = (product) => { + // Assumes a Program/Bulk Purchase has the same Partner for all courses + try { + return hyphenateForTagular(product.courseKey?.split(':')[1].split('+')[0]); + } catch { + return ''; + } + }; + renderBillingFormSkeleton() { return ( <> @@ -272,13 +390,23 @@ class Checkout extends React.Component { /> - + id="PayPalButton" + > + { payPalIsSubmitting ? : null } + {intl.formatMessage(messagesPayPal['payment.type.paypal'])} + {/* Apple Pay temporarily disabled per REV-927 - https://github.com/openedx/frontend-app-payment/pull/256 */}

@@ -338,6 +466,7 @@ Checkout.propTypes = { fetchClientSecret: PropTypes.func.isRequired, submitPayment: PropTypes.func.isRequired, trackPaymentButtonClick: PropTypes.func.isRequired, + trackElementIntersection: PropTypes.func.isRequired, isFreeBasket: PropTypes.bool, submitting: PropTypes.bool, isBasketProcessing: PropTypes.bool, @@ -347,6 +476,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 = { @@ -361,12 +494,14 @@ Checkout.defaultProps = { stripe: null, clientSecretId: null, isPaypalRedirect: false, + products: [], }; const mapDispatchToProps = (dispatch) => ({ - fetchClientSecret: () => dispatch(fetchClientSecret), + fetchClientSecret: () => dispatch(fetchClientSecret()), submitPayment: (data) => dispatch(submitPayment(data)), - trackPaymentButtonClick: (buttonName) => dispatch(trackPaymentButtonClick(buttonName)), + trackPaymentButtonClick: (metadata) => dispatch(trackPaymentButtonClick(metadata)), + trackElementIntersection: (entry) => dispatch(trackElementIntersection(entry)), }); const mapStateToProps = (state) => ({ From 0d629dd719753b595121e8502ef948fb19614f58 Mon Sep 17 00:00:00 2001 From: julianajlk Date: Tue, 22 Oct 2024 00:54:00 -0400 Subject: [PATCH 06/25] feat: Add tracking to AlertMessage for coupon code banner --- src/feedback/AlertMessage.jsx | 63 ++++++++++++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 4 deletions(-) diff --git a/src/feedback/AlertMessage.jsx b/src/feedback/AlertMessage.jsx index 05ec0cd5b..d72e6adb1 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, useState, +} 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,54 @@ const AlertMessage = (props) => { id, messageType, userMessage, closeHandler, data, } = props; + const alertRef = useRef(null); + const dispatch = useDispatch(); + const [hasBeenShown, setHasBeenShown] = useState({}); + + // 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')) { + // Single call behavior + const elementId = entry.target?.id; + if (!hasBeenShown[elementId]) { + const tagularElement = { + title: PaymentTitle, + url: entry.target?.baseURI, + pageType: 'checkout', + elementType: ElementType.Button, + name: 'promotional-code', + text: 'Apply', + }; + dispatch(trackElementIntersection(tagularElement)); + setHasBeenShown(prevState => ({ + ...prevState, + [elementId]: true, + })); + } + } + }); + }; + + 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, hasBeenShown, dispatch]); + const statusAlertProps = { variant: ALERT_TYPES.WARNING, onClose: useCallback(() => { closeHandler(id); }, [closeHandler, id]), @@ -43,9 +96,11 @@ const AlertMessage = (props) => { } return ( - - {statusAlertProps.dialog} - +
+ + {statusAlertProps.dialog} + +
); }; From 9baf8fdb6fdb9979e44600bacaccb939ed8f7b24 Mon Sep 17 00:00:00 2001 From: julianajlk Date: Tue, 22 Oct 2024 00:56:10 -0400 Subject: [PATCH 07/25] refactor: Modify contants and actions/reducers to fit changes done in AlertMessage and Checkout --- src/cohesion/constants.js | 19 +++++++++++++++++-- src/cohesion/helpers.js | 2 +- src/cohesion/hooks.js | 20 ++++++-------------- src/payment/data/actions.js | 30 +++++++++++++++++------------- src/payment/data/reducers.js | 8 +++++++- 5 files changed, 48 insertions(+), 31 deletions(-) diff --git a/src/cohesion/constants.js b/src/cohesion/constants.js index 72f450b7c..bf350a4c1 100644 --- a/src/cohesion/constants.js +++ b/src/cohesion/constants.js @@ -1,4 +1,12 @@ -const EventMap = { +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', @@ -9,4 +17,11 @@ const EventMap = { FormViewed: 'redventures.usertracking.v3.FormViewed', }; -export default EventMap; +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/helpers.js b/src/cohesion/helpers.js index 98578ea10..40b3af6ec 100644 --- a/src/cohesion/helpers.js +++ b/src/cohesion/helpers.js @@ -1,4 +1,4 @@ -import EventMap from './constants'; +import { EventMap } from './constants'; /** * Submit ('beam') an event via Tagular to Make diff --git a/src/cohesion/hooks.js b/src/cohesion/hooks.js index 8a266a3c1..79f25c5dc 100644 --- a/src/cohesion/hooks.js +++ b/src/cohesion/hooks.js @@ -1,27 +1,19 @@ import { useCallback, useEffect, - useRef, useState, } from 'react'; - -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; - -const defaultOptions = { - threshold: IS_FULLY_SHOWN_THRESHOLD_OR_MARGIN, - root: DOCUMENT_ROOT_NODE, -}; +import { + defaultOptions, +} from './constants'; /** Hook to track if an element is intersecting with a root (null represents ). +@param ref The ref element being tracked. @param callback The callback to be invoked when an item intersects. @param options The options for IntersectionObserver. */ -export const useIntersectionObserver = (callback, options = defaultOptions) => { - const ref = useRef(null); - +export const useIntersectionObserver = (ref, callback, options = defaultOptions) => { useEffect(() => { if (!ref.current) { return; } const refCurrent = ref.current; @@ -44,7 +36,7 @@ export const useIntersectionObserver = (callback, options = defaultOptions) => { observer.unobserve(refCurrent); } }; - }, [callback, options]); + }, [ref, callback, options]); return ref; }; diff --git a/src/payment/data/actions.js b/src/payment/data/actions.js index 343ecb5e5..71b4e47c6 100644 --- a/src/payment/data/actions.js +++ b/src/payment/data/actions.js @@ -1,6 +1,6 @@ import { createRoutine } from 'redux-saga-routines'; -import EventMap from '../../cohesion/constants'; -import tagularEvent from '../../cohesion/helpers'; +import { EventMap } from '../../cohesion/constants'; +import { tagularEvent } from '../../cohesion/helpers'; // Routines are action + action creator pairs in a series. // Actions adhere to the flux standard action format. @@ -79,22 +79,26 @@ export const clientSecretDataReceived = clientSecret => ({ export const TRACK_PAYMENT_BUTTON_CLICK = 'TRACK_PAYMENT_BUTTON_CLICK'; -export const trackPaymentButtonClick = buttonName => { - const payload = { - text: buttonName, - name: buttonName.toLowerCase(), - title: 'Payment | edX', - url: 'https://payment.edx.org', - pageType: 'checkout', - elementType: 'BUTTON', +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 + tagularEvent(EventMap.ElementClicked, tagularElement); + + return { + type: TRACK_PAYMENT_BUTTON_CLICK, + payload: tagularElement, }; +}; +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 - tagularEvent(EventMap.ElementClicked, payload); + tagularEvent(EventMap.ElementViewed, tagularElement); return { - type: TRACK_PAYMENT_BUTTON_CLICK, - payload, + type: TRACK_ELEMENT_INTERSECTION, + payload: tagularElement, }; }; diff --git a/src/payment/data/reducers.js b/src/payment/data/reducers.js index 406a5a4a0..7d1524ed1 100644 --- a/src/payment/data/reducers.js +++ b/src/payment/data/reducers.js @@ -13,6 +13,7 @@ import { fetchCaptureKey, fetchClientSecret, TRACK_PAYMENT_BUTTON_CLICK, + TRACK_ELEMENT_INTERSECTION, } from './actions'; import { DEFAULT_STATUS } from '../checkout/payment-form/flex-microform/constants'; @@ -118,6 +119,7 @@ const clientSecret = (state = clientSecretInitialState, action = null) => { const pageTrackingInitialState = { paymentButtonClicks: [], + elementIntersections: [], }; const pageTracking = (state = pageTrackingInitialState, action = null) => { @@ -128,7 +130,11 @@ const pageTracking = (state = pageTrackingInitialState, action = null) => { ...state, paymentButtonClicks: [...state.paymentButtonClicks, action.payload], }; - + case TRACK_ELEMENT_INTERSECTION: + return { + ...state, + elementIntersections: [...state.elementIntersections, action.payload], + }; default: } } From d7d74ac07b47fc1fa699bdca1e4b97d9ae56ed9a Mon Sep 17 00:00:00 2001 From: julianajlk Date: Thu, 31 Oct 2024 15:24:07 -0400 Subject: [PATCH 08/25] refactor: Add RV tracking to handleSubmitPayment saga on success/fail payment --- src/payment/data/sagas.js | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) 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); } From 406b2d11a82f5475fd3cc0be6f335fb8d17d1113 Mon Sep 17 00:00:00 2001 From: julianajlk Date: Fri, 1 Nov 2024 13:49:22 -0400 Subject: [PATCH 09/25] fix: Add hyphenateForTagular helper --- src/cohesion/helpers.js | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/cohesion/helpers.js b/src/cohesion/helpers.js index 40b3af6ec..cd51e0a12 100644 --- a/src/cohesion/helpers.js +++ b/src/cohesion/helpers.js @@ -2,7 +2,7 @@ import { EventMap } from './constants'; /** * Submit ('beam') an event via Tagular to Make -* @param eventName Schema Name of the Event + * @param eventName Schema Name of the Event * @param eventData The data required by the schema */ export const tagularEvent = (eventName, eventData) => { @@ -31,3 +31,19 @@ export function pageTrackingObject(pageType) { referrer: window.document.referrer, }; } + +/** + * 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, '-'); +} From 4638b7edf8da409a0e6284575419b17432470785 Mon Sep 17 00:00:00 2001 From: julianajlk Date: Fri, 1 Nov 2024 13:56:15 -0400 Subject: [PATCH 10/25] feat: Add dataTranslationMatrices file --- src/cohesion/dataTranslationMatrices.js | 65 +++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/cohesion/dataTranslationMatrices.js 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]; +} From 952a594edb3cea101dd961a0a7d3fc5474d92726 Mon Sep 17 00:00:00 2001 From: julianajlk Date: Tue, 22 Oct 2024 22:38:28 -0400 Subject: [PATCH 11/25] fix: Delete hooks.js file not needed --- src/cohesion/hooks.js | 63 ------------------------------------------- 1 file changed, 63 deletions(-) delete mode 100644 src/cohesion/hooks.js diff --git a/src/cohesion/hooks.js b/src/cohesion/hooks.js deleted file mode 100644 index 79f25c5dc..000000000 --- a/src/cohesion/hooks.js +++ /dev/null @@ -1,63 +0,0 @@ -import { - useCallback, - useEffect, - useState, -} from 'react'; -import { - defaultOptions, -} from './constants'; - -/** -Hook to track if an element is intersecting with a root (null represents ). -@param ref The ref element being tracked. -@param callback The callback to be invoked when an item intersects. -@param options The options for IntersectionObserver. - */ -export const useIntersectionObserver = (ref, callback, options = defaultOptions) => { - useEffect(() => { - if (!ref.current) { return; } - const refCurrent = ref.current; - - const observer = new IntersectionObserver((entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - callback(entry); - } - }); - }, options); - - if (ref.current) { - observer.observe(refCurrent); - } - - // eslint-disable-next-line consistent-return - return () => { - if (refCurrent) { - observer.unobserve(refCurrent); - } - }; - }, [ref, callback, options]); - - return ref; -}; - -/** -Hook to track an element's intersection but only trigger callback once per element. -@param callback The callback to be invoked once the item is shown. -@param options Intersection observer options. - */ -export const useSingleCallIntersectionObserver = (callback, options = defaultOptions) => { - const [hasBeenShown, setHasBeenShown] = useState(false); - - const handleVisibilityChange = useCallback((entry) => { - if (!hasBeenShown) { - callback(entry); - setHasBeenShown(true); - } - }, [callback, hasBeenShown]); - - return useIntersectionObserver(handleVisibilityChange, options); -}; - -export default useSingleCallIntersectionObserver; -export const useIsShowing = useIntersectionObserver; From 630c38252d617b70ad27231dcd5c5aa6444f7946 Mon Sep 17 00:00:00 2001 From: julianajlk Date: Fri, 1 Nov 2024 16:56:07 -0400 Subject: [PATCH 12/25] refactor: Remove uniqueness from events in AlertMessage --- src/feedback/AlertMessage.jsx | 31 +++++++++++-------------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/src/feedback/AlertMessage.jsx b/src/feedback/AlertMessage.jsx index d72e6adb1..31f585bb2 100644 --- a/src/feedback/AlertMessage.jsx +++ b/src/feedback/AlertMessage.jsx @@ -1,5 +1,5 @@ import React, { - useCallback, useEffect, useRef, useState, + useCallback, useEffect, useRef, } from 'react'; import { useDispatch } from 'react-redux'; import PropTypes from 'prop-types'; @@ -24,30 +24,21 @@ const AlertMessage = (props) => { const alertRef = useRef(null); const dispatch = useDispatch(); - const [hasBeenShown, setHasBeenShown] = useState({}); // 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')) { - // Single call behavior - const elementId = entry.target?.id; - if (!hasBeenShown[elementId]) { - const tagularElement = { - title: PaymentTitle, - url: entry.target?.baseURI, - pageType: 'checkout', - elementType: ElementType.Button, - name: 'promotional-code', - text: 'Apply', - }; - dispatch(trackElementIntersection(tagularElement)); - setHasBeenShown(prevState => ({ - ...prevState, - [elementId]: true, - })); - } + const tagularElement = { + title: PaymentTitle, + url: entry.target?.baseURI, + pageType: 'checkout', + elementType: ElementType.Button, + name: 'promotional-code', + text: 'Apply', + }; + dispatch(trackElementIntersection(tagularElement)); } }); }; @@ -68,7 +59,7 @@ const AlertMessage = (props) => { } observer.disconnect(); }; - }, [messageType, userMessage, hasBeenShown, dispatch]); + }, [messageType, userMessage, dispatch]); const statusAlertProps = { variant: ALERT_TYPES.WARNING, From 73172038e1cd5c6a2064dd5bba21c83923bfd11f Mon Sep 17 00:00:00 2001 From: julianajlk Date: Fri, 1 Nov 2024 16:56:46 -0400 Subject: [PATCH 13/25] refactor: Remove uniqueness from events in Checkout --- src/payment/checkout/Checkout.jsx | 54 ++++++++++++------------------- 1 file changed, 20 insertions(+), 34 deletions(-) diff --git a/src/payment/checkout/Checkout.jsx b/src/payment/checkout/Checkout.jsx index aad592c11..5e6f48fc6 100644 --- a/src/payment/checkout/Checkout.jsx +++ b/src/payment/checkout/Checkout.jsx @@ -43,10 +43,7 @@ class Checkout extends React.Component { constructor(props) { super(props); this.paypalButtonRef = React.createRef(); - this.paypalButtonClicked = false; - this.placeOrderButtonClicked = false; this.observer = null; - this.hasBeenShown = {}; this.state = { hasRedirectedToPaypal: false, }; @@ -92,21 +89,16 @@ class Checkout extends React.Component { handleIntersectionObserver = (entry) => { const elementId = entry.target?.id; - - // Single call behavior - if (!this.hasBeenShown[elementId]) { - const tagularElement = { - title: PaymentTitle, - url: window.location.href, - pageType: 'checkout', - elementType: ElementType.Button, - position: elementId, - name: ElementType.Button, - ...(elementId === 'PayPalButton' ? { text: 'PayPal' } : {}), - }; - this.props.trackElementIntersection(tagularElement); - this.hasBeenShown[elementId] = true; - } + const tagularElement = { + title: PaymentTitle, + url: window.location.href, + pageType: 'checkout', + elementType: ElementType.Button, + position: elementId, + ...(elementId === 'PayPalButton' ? { name: 'paypal' } : {}), + ...(elementId === 'PayPalButton' ? { text: 'PayPal' } : {}), + }; + this.props.trackElementIntersection(tagularElement); }; handleRedirectToPaypal = () => { @@ -136,19 +128,16 @@ class Checkout extends React.Component { ); // Red Ventures Cohesion Tagular Event Tracking for PayPal - if (!this.paypalButtonClicked) { - this.paypalButtonClicked = true; - const tagularElement = { - title: PaymentTitle, - url: window.location.href, - pageType: 'checkout', - elementType: ElementType.Button, - text: paymentMethod, - name: paymentMethod.toLowerCase(), - }; - - this.props.trackPaymentButtonClick(tagularElement); - } + const tagularElement = { + title: PaymentTitle, + url: window.location.href, + pageType: 'checkout', + elementType: ElementType.Button, + text: paymentMethod, + name: paymentMethod.toLowerCase(), + }; + + this.props.trackPaymentButtonClick(tagularElement); this.props.submitPayment({ method: paymentMethod.toLowerCase() }); }; @@ -206,9 +195,6 @@ class Checkout extends React.Component { productList: this.getProductList(), }; - if (!this.placeOrderButtonClicked) { - this.placeOrderButtonClicked = true; - } this.props.submitPayment({ method: 'stripe', tagularElement, ...formData }); }; From 43af0717ef05daca7a962c7c5a33968dc471bbb8 Mon Sep 17 00:00:00 2001 From: julianajlk Date: Mon, 4 Nov 2024 14:46:28 -0500 Subject: [PATCH 14/25] feat: Att getCorrelationID helper --- src/cohesion/helpers.js | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/cohesion/helpers.js b/src/cohesion/helpers.js index cd51e0a12..9f9de344d 100644 --- a/src/cohesion/helpers.js +++ b/src/cohesion/helpers.js @@ -1,7 +1,35 @@ +import Cookies from 'universal-cookie'; +import { v4 as uuidv4 } from 'uuid'; import { EventMap } from './constants'; /** - * Submit ('beam') an event via Tagular to Make + * 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 */ From 4ef7f834c376acce8066644f45b69deb840436e7 Mon Sep 17 00:00:00 2001 From: julianajlk Date: Mon, 4 Nov 2024 14:50:10 -0500 Subject: [PATCH 15/25] feat: Add correlationID to action creators for elementViewed and clicked --- src/payment/data/actions.js | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/payment/data/actions.js b/src/payment/data/actions.js index 71b4e47c6..d9f72cfb3 100644 --- a/src/payment/data/actions.js +++ b/src/payment/data/actions.js @@ -1,6 +1,6 @@ import { createRoutine } from 'redux-saga-routines'; import { EventMap } from '../../cohesion/constants'; -import { tagularEvent } from '../../cohesion/helpers'; +import { getCorrelationID, tagularEvent } from '../../cohesion/helpers'; // Routines are action + action creator pairs in a series. // Actions adhere to the flux standard action format. @@ -82,11 +82,17 @@ 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 - tagularEvent(EventMap.ElementClicked, tagularElement); + const conversionEvent = { + correlation: { + id: getCorrelationID(), + }, + metadata: tagularElement, + }; + tagularEvent(EventMap.ElementClicked, conversionEvent); return { type: TRACK_PAYMENT_BUTTON_CLICK, - payload: tagularElement, + payload: conversionEvent, }; }; @@ -94,11 +100,18 @@ 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 - tagularEvent(EventMap.ElementViewed, tagularElement); + // 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), + // so only add the correlation ID if this is a viewed from the coupon application click + const viewedEvent = { + ...(tagularElement.name === 'promotional-code' ? { correlation: { id: getCorrelationID() } } : null), + metadata: tagularElement, + }; + tagularEvent(EventMap.ElementViewed, viewedEvent); return { type: TRACK_ELEMENT_INTERSECTION, - payload: tagularElement, + payload: viewedEvent, }; }; From c60523879c3e94716c1a0802efd86c7f14bccfac Mon Sep 17 00:00:00 2001 From: julianajlk Date: Mon, 4 Nov 2024 14:54:48 -0500 Subject: [PATCH 16/25] chore: Install uuid dependency --- package-lock.json | 23 ++++++++++++++++++----- package.json | 3 ++- 2 files changed, 20 insertions(+), 6 deletions(-) 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", From a537505d3367b4995e576513d2e1f308f81338c5 Mon Sep 17 00:00:00 2001 From: julianajlk Date: Mon, 4 Nov 2024 19:33:40 -0500 Subject: [PATCH 17/25] fix: Remove IntersectionObserver and add PayPalButton back in parent Checkout --- src/payment/checkout/Checkout.jsx | 75 ++----------------------------- 1 file changed, 4 insertions(+), 71 deletions(-) diff --git a/src/payment/checkout/Checkout.jsx b/src/payment/checkout/Checkout.jsx index 5e6f48fc6..d346794df 100644 --- a/src/payment/checkout/Checkout.jsx +++ b/src/payment/checkout/Checkout.jsx @@ -9,16 +9,12 @@ import { intlShape, } from '@edx/frontend-platform/i18n'; import { sendTrackEvent } from '@edx/frontend-platform/analytics'; -import PayPalLogo from '../payment-methods/paypal/assets/paypal-logo.png'; import { - DOCUMENT_ROOT_NODE, - IS_FULLY_SHOWN_THRESHOLD_OR_MARGIN, ElementType, PaymentTitle, } from '../../cohesion/constants'; import messages from './Checkout.messages'; -import messagesPayPal from '../payment-methods/paypal/PayPalButton.messages'; import { basketSelector, paymentSelector, @@ -27,7 +23,6 @@ import { import { fetchClientSecret, submitPayment, - trackElementIntersection, trackPaymentButtonClick, } from '../data/actions'; import AcceptedCardLogos from './assets/accepted-card-logos.png'; @@ -35,6 +30,7 @@ import AcceptedCardLogos from './assets/accepted-card-logos.png'; import PaymentForm from './payment-form/PaymentForm'; import StripePaymentForm from './payment-form/StripePaymentForm'; import FreeCheckoutOrderButton from './FreeCheckoutOrderButton'; +import { PayPalButton } from '../payment-methods/paypal'; import { ORDER_TYPES } from '../data/constants'; import { hyphenateForTagular } from '../../cohesion/helpers'; import { BaseTagularVariant } from '../../cohesion/dataTranslationMatrices'; @@ -42,8 +38,6 @@ import { BaseTagularVariant } from '../../cohesion/dataTranslationMatrices'; class Checkout extends React.Component { constructor(props) { super(props); - this.paypalButtonRef = React.createRef(); - this.observer = null; this.state = { hasRedirectedToPaypal: false, }; @@ -51,56 +45,8 @@ class Checkout extends React.Component { componentDidMount() { this.props.fetchClientSecret(); - this.setupObservers(); } - componentWillUnmount() { - this.cleanupObservers(); - } - - setupObservers = () => { - const options = { - threshold: IS_FULLY_SHOWN_THRESHOLD_OR_MARGIN, - root: DOCUMENT_ROOT_NODE, - }; - - this.observer = new IntersectionObserver((entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - this.handleIntersectionObserver(entry); - } - }); - }, options); - - // Observe each entry - if (this.paypalButtonRef.current) { - this.observer.observe(this.paypalButtonRef.current); - } - // TODO: PlaceOrderButton only comes into view after the Stripe credit card form is rendered. - // Need to add this observe on componentDidUptate plus cleanup. - }; - - cleanupObservers = () => { - if (this.observer) { - this.observer.unobserve(this.paypalButtonRef.current); - this.observer.disconnect(); - } - }; - - handleIntersectionObserver = (entry) => { - const elementId = entry.target?.id; - const tagularElement = { - title: PaymentTitle, - url: window.location.href, - pageType: 'checkout', - elementType: ElementType.Button, - position: elementId, - ...(elementId === 'PayPalButton' ? { name: 'paypal' } : {}), - ...(elementId === 'PayPalButton' ? { text: 'PayPal' } : {}), - }; - this.props.trackElementIntersection(tagularElement); - }; - handleRedirectToPaypal = () => { const { loading, isBasketProcessing, isPaypalRedirect } = this.props; const { hasRedirectedToPaypal } = this.state; @@ -375,25 +321,14 @@ class Checkout extends React.Component { alt={intl.formatMessage(messages['payment.page.method.type.credit'])} /> - - {/* Rendering the PayPal button directly instead of using the PayPalButton functional component due - to issues with passing in the element ref (forwardRef on functional component) */} - - + /> {/* Apple Pay temporarily disabled per REV-927 - https://github.com/openedx/frontend-app-payment/pull/256 */}

@@ -452,7 +387,6 @@ Checkout.propTypes = { fetchClientSecret: PropTypes.func.isRequired, submitPayment: PropTypes.func.isRequired, trackPaymentButtonClick: PropTypes.func.isRequired, - trackElementIntersection: PropTypes.func.isRequired, isFreeBasket: PropTypes.bool, submitting: PropTypes.bool, isBasketProcessing: PropTypes.bool, @@ -487,7 +421,6 @@ const mapDispatchToProps = (dispatch) => ({ fetchClientSecret: () => dispatch(fetchClientSecret()), submitPayment: (data) => dispatch(submitPayment(data)), trackPaymentButtonClick: (metadata) => dispatch(trackPaymentButtonClick(metadata)), - trackElementIntersection: (entry) => dispatch(trackElementIntersection(entry)), }); const mapStateToProps = (state) => ({ From cf6e794d8612172013b5334e29c3b1f625189587 Mon Sep 17 00:00:00 2001 From: julianajlk Date: Mon, 4 Nov 2024 19:34:38 -0500 Subject: [PATCH 18/25] feat: Add IntersectionObserver to PayPalButton and PlaceOrderButton --- .../payment-form/PlaceOrderButton.jsx | 47 ++++++++++++- .../payment-methods/paypal/PayPalButton.jsx | 66 ++++++++++++++++--- 2 files changed, 101 insertions(+), 12 deletions(-) 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/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, From b76579fd0294fb47b3ce6ebe387aa0106ac1bd2f Mon Sep 17 00:00:00 2001 From: julianajlk Date: Thu, 31 Oct 2024 16:46:48 -0400 Subject: [PATCH 19/25] test: Update existing tests --- src/feedback/AlertList.test.jsx | 2 + src/feedback/AlertMessage.test.jsx | 78 +++++++++++++------ src/mockIntersectionObserver.js | 19 +++++ src/payment/PaymentPage.test.jsx | 3 + src/payment/checkout/Checkout.test.jsx | 2 + .../CardHolderInformation.test.jsx | 1 + .../payment-form/PaymentForm.test.jsx | 2 + .../payment-form/StripePaymentForm.test.jsx | 2 + .../paypal/PayPalButton.test.jsx | 37 ++++++++- 9 files changed, 121 insertions(+), 25 deletions(-) create mode 100644 src/mockIntersectionObserver.js 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.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/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..c44fe4203 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 = { 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/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/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); From de33c824e20f07005051e0c0a44771ec6abbf984 Mon Sep 17 00:00:00 2001 From: julianajlk Date: Thu, 31 Oct 2024 16:06:32 -0400 Subject: [PATCH 20/25] test: Update snapshots --- .../__snapshots__/AlertList.test.jsx.snap | 254 ++++++++++-------- .../__snapshots__/AlertMessage.test.jsx.snap | 128 +++++---- .../__snapshots__/PaymentPage.test.jsx.snap | 5 + .../data/__snapshots__/redux.test.js.snap | 12 + .../__snapshots__/PayPalButton.test.jsx.snap | 4 +- 5 files changed, 227 insertions(+), 176 deletions(-) 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/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