From e440a5c60ac0352e17114e6d848053de22c8df0b Mon Sep 17 00:00:00 2001 From: sponglord Date: Mon, 11 Mar 2024 12:12:19 +0100 Subject: [PATCH] Feature/using checkoutanalytics (#2234) * First use of checkoutanlytics endpoint (for logging onSubmit & createFromAction events) * Added comments and fixed type * Commenting out "target" property until API accepts it * Fixing unit tests * Fixing sonarcloud gripe * Tightening up types * Adding test for event queue * Updated test description * Enhancing test * Adding collect-id unit test * Adding TelemetryEvent type * Increasing test coverage * Adding test * Adding Storage.test * Further checks to ensure checkoutanalytics calls fail silently * Aligning fallback storage solution with normal, window based, Storage API * Some adjustments now actual endpoint is accepting requests * Analytics path read from constant and passed to relevant components * Removing unused components * Fixed unit test * Added comments on the shape of objects for the /checkoutanalytics endpoint * Unit test for when checkoutanalytics url is wrong * Remove constant for mismatching threeDSServerTransID (it is no longer a situation we catch) * Use await when calling collectId * Remove analyticsContext from CoreOptions * Adding createAnalyticsAction function in Analytics to make it simpler for other components to submit analytics * Renaming CAEventsQueue to EventsQueue * Clarifying the "modular" nature of Analytics and EventsQueue * Fixing return type * Pass in containerWidth with initial analytics call * Drop addAnalyticsAction from Analytics module. Tidy up Analytics' types * Switch to v3 of the endpoint * Fix unit test (set new analytics version) * Changes reflecting discussion on final API tweaks * Create analytics-action for when PM selected or mounted. Send in sessionId in initial call * Commented out logs and removed unused code * Moved initial analytics setup to UIElement now that it (potentially) needs to pass on the session.id * Added type * Fixed unit test * Temporarily don't pass isStoredPaymentMethod (waiting for API fix) * Adding isStoredPaymentMethod to analytics event action now the b/e supports it * Adding getter for Analytics' enabled prop so 'do-not-track' can be added as checkoutAttemptId value, if required * fixing e2e tests * remove .only from e2e test * Only load analytics pixel once * comments added * Aligning Dropin & comps so that a 'mounted' event is always sent, from the same place, for both implementations * Renamed initial Analytics call from "send" to "setUp" * Create timer to send events after a set period of time * Setup analytics after render has been called. Make call to submit the "mounted" event from BaseElement c.f. UIElement * DropinComponent sends 'rendered' analytics-action * For clarity UIElement uses switch in submitAnalytics function * Added comment about Dropin also being able to pass a list of paymentMethods to analytics * Use single "rendered" event to describe component mounting, dropin pm list rendering & dropin pm selection * Changing analytics terminology - generic term is events with specific types being: info, log, error * Changing analytics terminology - info events are collected into an an array named "info" (as opposed to the "logs" & "errors" arrays) * Fixing unit tests * Also debounce errors * Fixed unit test * Extends feature/using_checkoutanalytics. First draft: adding focus/blur events for Credit card fields * Second draft: adding error analytics events for Credit card fields * Second draft: adding error analytics events for Credit card fields * Second draft: adding error analytics events for Credit card fields * Using UIElement.submitAnalytics as a gateway for all analytics events (to set event type and create final analytics worthy objects) * Added comment about onFocus/onBlur callbacks now working for non-SFs * Changed constant ANALYTICS_UNFOCUS_STR to have value "unfocus" * Non dropdown fields for Address also have focus/blur analytics * Reduce the analytics info event timer to 5 secs when in development mode * Aligning some analytics values with what the endpoint expects * Fixing typo * Clauses added so unit tests pass * Adding analytics for when an instant PM button is pressed * Fixed TS error * Removing unused code * Keep object sent to onFocus & onBlur callbacks in the form expected by v5 users * Moving logic to create different types of Analytics events into the Analytics module * Improving types * Redeclare BaseElement.this._node *before* we render (was causing an issue in CustomCard) * Checkoutanalytics mvp with 3DS2 events (#2531) * Add 3DS2 analytics events for data sent & iframe loaded. Plus error event when paymentData missing in 3DS2 * Using UIElement.submitAnalytics for 3DS2 * Fixed clause (had been changed to test error) * Move app specific analytics logic to a separate "processing" function. This separates concerns and makes testing easier * core.ts uses sendAnalytics function * Comments added regarding what properties are added to what analytics objects * Redeclare BaseElement.this._node *before* we render (was causing an issue in CustomCard) * Card has onConfigSuccess function to send analytics info event (type="configured") when SFS have all configured * Adding analytics logs for when 3DS2 fingerprint or challenge complete * Also pass isStoredPaymentMethod and brand with "configured" analytics events for storedCards * Added unit tests for Card & GooglePay testing the shape of the analytics objects they generate * Added unit tests for 3DS2 errors - testing the shape of the analytics objects they generate * Fixing type * Jumping through hoops for sonarcloud * Jumping through hoops for sonarcloud * Removing console.log * Specifying type. Move card related, "is it a stored card" logic, out of UIElement * Fixing linting * Fixing linting, tightening up onSubmitAnalytics type * After SDKs meeting: removing target prop from "submit" log events * Added "focus" & "unfocus" info events to Custom Card comp * Feature/using checkoutanalytics mvp iDeal (#2549) * Adding selected info event for issuerList buttons * IssuerList handles analytics for both dropdown & issuerList buttons * Detect and send analytics event when issuerList's dropdown is expanded * Detect and send analytics event (debounced) when issuerList search functionality is used * Moved debounce function to own util file * Fixes for unit tests * Made debounce for search a constant * Adding new unit tests * Removing child comp's submitAnalytics function (since all it does is call super). Removing unused param. * Feature/using checkoutanalytics mvp qr codes (#2550) * Detect and send analytics event when qrCode's "Copy" btn is pressed * Fixing unit test fail * Removing child comp's submitAnalytics function (since all it does is call super) * Add platform prop to all subsequent analytics calls * All events have a unique id * Fixing type * Fixing unit tests * Fixing issue where analytics calls were still being sent despite checkoutAttemptId call failing * Added some tsconfig & linting exclusions * Failed attempt to retrieve checkoutAttemptId returns rejected promise * Refactoring Analytics.test to run in a way that more accurately mimics how analytics events are created * Feature/using checkoutanalytics mapping validation errors (#2583) * Mapping validation error codes to those expected by the endpoint * Fixing unit tests * Changing logic that dictates when we jump focus from PAN to expiryDate * Feature/using checkoutanalytics_with_base64 refactor (#2586) * Extending branch feature_using_checkoutanalytics to add 3DS2 analytic-actions (for data sent & iframe loaded) * commenting out console logs * Extending branch feature_using_checkoutanalytics_with_3DS2_events to add base64 refactor code (more errors detected in decode process) * Changing analytics terminology - generic term is events with specific types being: info, log, error * Changing analytics terminology - generic term is events with specific types being: info, log, error * Fixing unit tests * Fixing unit tests * Fixed unit test * Adding util to determine if base64 decoding has lead to an error object. Tightened up types * Changing message for actionHandled when challenge iframe loaded * Updating card rules for Discover and Diners, just for transparency on what the rules are within securedFields * Fixing e2e tests --- .changeset/selfish-socks-fix.md | 5 + .../e2e/tests/vouchers/boleto/boleto.test.js | 4 +- .../AchInput/components/AchSFInput.tsx | 3 +- .../lib/src/components/ApplePay/ApplePay.tsx | 18 +- packages/lib/src/components/BaseElement.ts | 46 +++-- .../components/Card/Card.Analytics.test.tsx | 162 +++++++++++++++ packages/lib/src/components/Card/Card.tsx | 83 +++++++- .../Card/components/CardInput/CardInput.tsx | 52 +++-- .../components/CardFieldsWrapper.tsx | 13 +- .../CardInput/components/CardHolderName.tsx | 15 +- .../components/KCPAuthentication.tsx | 2 + .../components/CardInput/components/types.ts | 4 + .../Card/components/CardInput/handlers.ts | 2 +- .../Card/components/CardInput/types.ts | 7 +- packages/lib/src/components/Card/types.ts | 11 +- .../Dropin/components/DropinComponent.tsx | 12 +- .../src/components/Giftcard/Giftcard.test.tsx | 2 +- .../components/GooglePay/GooglePay.test.ts | 36 ++++ .../src/components/GooglePay/GooglePay.tsx | 6 + .../MealVoucherFR/MealVoucherFR.test.tsx | 2 +- .../SecuredFields/SecuredFields.tsx | 16 +- .../ThreeDS2/ThreeDS2Challenge.test.tsx | 53 +++++ .../components/ThreeDS2/ThreeDS2Challenge.tsx | 25 ++- .../ThreeDS2DeviceFingerprint.test.tsx | 54 +++++ .../ThreeDS2/ThreeDS2DeviceFingerprint.tsx | 44 ++++- .../components/Challenge/DoChallenge3DS2.tsx | 21 +- .../Challenge/PrepareChallenge3DS2.tsx | 42 +++- .../ThreeDS2/components/Challenge/types.ts | 3 + .../DeviceFingerprint/DoFingerprint3DS2.tsx | 7 +- .../PrepareFingerprint3DS2.test.tsx | 2 +- .../PrepareFingerprint3DS2.tsx | 50 +++-- .../components/DeviceFingerprint/types.ts | 7 +- .../components/Form/ThreeDS2Form.test.tsx | 2 +- .../ThreeDS2/components/Form/ThreeDS2Form.tsx | 2 + .../ThreeDS2/components/utils.test.ts | 3 +- .../components/ThreeDS2/components/utils.ts | 52 +++-- .../lib/src/components/ThreeDS2/config.ts | 12 ++ packages/lib/src/components/ThreeDS2/types.ts | 6 + packages/lib/src/components/UIElement.tsx | 50 ++++- packages/lib/src/components/WeChat/WeChat.ts | 1 + .../helpers/IssuerListContainer.tsx | 1 + .../components/helpers/QRLoaderContainer.tsx | 1 + .../components/internal/Address/Address.tsx | 6 +- .../Address/components/AddressSearch.tsx | 2 +- .../Address/components/FieldContainer.tsx | 2 + .../src/components/internal/Address/types.ts | 11 +- .../src/components/internal/Address/utils.ts | 9 - .../internal/FormFields/Field/Field.tsx | 4 +- .../internal/FormFields/Select/Select.tsx | 4 +- .../internal/FormFields/Select/types.ts | 8 +- .../internal/IssuerList/IssuerList.test.tsx | 76 +++++++ .../internal/IssuerList/IssuerList.tsx | 31 ++- .../components/internal/IssuerList/types.ts | 2 + .../components/internal/QRLoader/QRLoader.tsx | 5 + .../src/components/internal/QRLoader/types.ts | 2 + .../SFP/SecuredFieldsProvider.ts | 2 +- .../lib/CSF/extensions/createSecuredFields.ts | 9 +- .../SecuredFields/lib/CSF/utils/cardType.ts | 4 +- .../lib/configuration/constants.ts | 4 +- .../internal/SecuredFields/utils.ts | 34 ++-- .../SocialSecurityNumberBrazil.tsx | 14 +- packages/lib/src/components/types.ts | 29 ++- .../lib/src/core/Analytics/Analytics.test.ts | 185 ++++++++++++++---- packages/lib/src/core/Analytics/Analytics.ts | 165 ++++++++++------ .../src/core/Analytics/EventsQueue.test.ts | 36 ++-- .../lib/src/core/Analytics/EventsQueue.ts | 79 ++++++-- .../core/Analytics/analyticsPreProcessor.ts | 121 ++++++++++++ packages/lib/src/core/Analytics/constants.ts | 95 +++++++++ packages/lib/src/core/Analytics/types.ts | 61 +++++- packages/lib/src/core/Analytics/utils.ts | 55 ++++++ .../lib/src/core/Environment/Environment.ts | 15 ++ packages/lib/src/core/Environment/index.ts | 2 +- packages/lib/src/core/Errors/types.ts | 5 + packages/lib/src/core/Errors/utils.ts | 11 ++ .../PaymentAction/actionTypes.ts | 1 + .../Services/analytics/collect-id.test.ts | 107 ++++++++++ .../src/core/Services/analytics/collect-id.ts | 53 +++-- .../src/core/Services/analytics/log-event.ts | 28 +-- .../Services/analytics/post-telemetry.test.ts | 54 ----- .../core/Services/analytics/post-telemetry.ts | 44 ----- .../lib/src/core/Services/analytics/types.ts | 25 ++- packages/lib/src/core/Services/http.ts | 2 +- packages/lib/src/core/core.ts | 19 +- .../lib/src/utils/Formatters/formatters.ts | 4 + packages/lib/src/utils/Storage.test.ts | 74 +++++++ packages/lib/src/utils/Storage.ts | 19 +- packages/lib/src/utils/base64.ts | 33 +++- packages/lib/src/utils/debounce.ts | 9 + packages/lib/src/utils/textUtils.ts | 3 + packages/lib/tsconfig.json | 2 +- packages/playground/src/pages/Cards/Cards.js | 6 +- .../playground/src/pages/Dropin/session.js | 11 +- .../SecuredFields/securedFields.config.js | 4 +- 93 files changed, 2083 insertions(+), 442 deletions(-) create mode 100644 .changeset/selfish-socks-fix.md create mode 100644 packages/lib/src/components/Card/Card.Analytics.test.tsx create mode 100644 packages/lib/src/components/ThreeDS2/ThreeDS2Challenge.test.tsx create mode 100644 packages/lib/src/components/ThreeDS2/ThreeDS2DeviceFingerprint.test.tsx create mode 100644 packages/lib/src/core/Analytics/analyticsPreProcessor.ts create mode 100644 packages/lib/src/core/Analytics/constants.ts create mode 100644 packages/lib/src/core/Analytics/utils.ts create mode 100644 packages/lib/src/core/Services/analytics/collect-id.test.ts delete mode 100644 packages/lib/src/core/Services/analytics/post-telemetry.test.ts delete mode 100644 packages/lib/src/core/Services/analytics/post-telemetry.ts create mode 100644 packages/lib/src/utils/Storage.test.ts create mode 100644 packages/lib/src/utils/debounce.ts create mode 100644 packages/lib/src/utils/textUtils.ts diff --git a/.changeset/selfish-socks-fix.md b/.changeset/selfish-socks-fix.md new file mode 100644 index 0000000000..695fa49771 --- /dev/null +++ b/.changeset/selfish-socks-fix.md @@ -0,0 +1,5 @@ +--- +'@adyen/adyen-web': minor +--- + +Starting using /checkoutanalytics endpoint to retrieve "checkoutAttemptId" log "submit" and "action-handled" events diff --git a/packages/e2e/tests/vouchers/boleto/boleto.test.js b/packages/e2e/tests/vouchers/boleto/boleto.test.js index 761304da35..328280174b 100644 --- a/packages/e2e/tests/vouchers/boleto/boleto.test.js +++ b/packages/e2e/tests/vouchers/boleto/boleto.test.js @@ -20,9 +20,7 @@ const mockData = { houseNumberOrName: '123', city: 'Sao Paulo', postalCode: '11111555', - stateOrProvince: 'SP', - firstName: 'N/A', - lastName: 'N/A' + stateOrProvince: 'SP' }, shopperEmail: 'shopper@adyen.nl' }; diff --git a/packages/lib/src/components/Ach/components/AchInput/components/AchSFInput.tsx b/packages/lib/src/components/Ach/components/AchInput/components/AchSFInput.tsx index 0ed3a6ab6b..b4f3163af4 100644 --- a/packages/lib/src/components/Ach/components/AchInput/components/AchSFInput.tsx +++ b/packages/lib/src/components/Ach/components/AchInput/components/AchSFInput.tsx @@ -3,9 +3,10 @@ import classNames from 'classnames'; import styles from '../AchInput.module.scss'; import Field from '../../../../internal/FormFields/Field'; import DataSfSpan from '../../../../Card/components/CardInput/components/DataSfSpan'; +import { capitalizeFirstLetter } from '../../../../../utils/Formatters/formatters'; const AchSFInput = ({ id, dataInfo, className = '', label, focused, filled, errorMessage = '', isValid = false, onFocusField, dir }) => { - const capitalisedId = id.charAt(0).toUpperCase() + id.slice(1); + const capitalisedId = capitalizeFirstLetter(id); const encryptedIdStr = `encrypted${capitalisedId}`; return ( diff --git a/packages/lib/src/components/ApplePay/ApplePay.tsx b/packages/lib/src/components/ApplePay/ApplePay.tsx index e8a6eecf7e..b1cbfe4e00 100644 --- a/packages/lib/src/components/ApplePay/ApplePay.tsx +++ b/packages/lib/src/components/ApplePay/ApplePay.tsx @@ -10,6 +10,8 @@ import { preparePaymentRequest } from './payment-request'; import { resolveSupportedVersion, mapBrands } from './utils'; import { ApplePayElementProps, ApplePayElementData, ApplePaySessionRequest, OnAuthorizedCallback } from './types'; import AdyenCheckoutError from '../../core/Errors/AdyenCheckoutError'; +import { ANALYTICS_INSTANT_PAYMENT_BUTTON, ANALYTICS_SELECTED_STR } from '../../core/Analytics/constants'; +import { DecodeObject } from '../types'; const latestSupportedVersion = 14; @@ -53,6 +55,11 @@ class ApplePayElement extends UIElement { } submit() { + // Analytics + if (this.props.isInstantPayment) { + this.submitAnalytics({ type: ANALYTICS_SELECTED_STR, target: ANALYTICS_INSTANT_PAYMENT_BUTTON }); + } + return this.startSession(this.props.onAuthorized); } @@ -104,10 +111,13 @@ class ApplePayElement extends UIElement { try { const response = await httpPost(options, request); - const decodedData = base64.decode(response.data); - if (!decodedData) reject('Could not decode Apple Pay session'); - const session = JSON.parse(decodedData as string); - resolve(session); + const decodedData: DecodeObject = base64.decode(response.data); + if (!decodedData.success) { + reject('Could not decode Apple Pay session'); + } else { + const session = JSON.parse(decodedData.data); + resolve(session); + } } catch (e) { reject('Could not get Apple Pay session'); } diff --git a/packages/lib/src/components/BaseElement.ts b/packages/lib/src/components/BaseElement.ts index 7d888f6dea..ef163654a5 100644 --- a/packages/lib/src/components/BaseElement.ts +++ b/packages/lib/src/components/BaseElement.ts @@ -6,6 +6,8 @@ import Core from '../core'; import { BaseElementProps, PaymentData } from './types'; import { RiskData } from '../core/RiskModule/RiskModule'; import { Resources } from '../core/Context/Resources'; +import { AnalyticsInitialEvent, SendAnalyticsObject } from '../core/Analytics/types'; +import { ANALYTICS_RENDERED_STR } from '../core/Analytics/constants'; class BaseElement

{ public readonly _id = `${this.constructor['type']}-${uuid()}`; @@ -46,6 +48,16 @@ class BaseElement

{ return {}; } + /* eslint-disable-next-line */ + protected setUpAnalytics(setUpAnalyticsObj: AnalyticsInitialEvent) { + return null; + } + + /* eslint-disable-next-line */ + protected submitAnalytics(analyticsObj?: SendAnalyticsObject) { + return null; + } + protected setState(newState: object): void { this.state = { ...this.state, ...newState }; } @@ -56,8 +68,8 @@ class BaseElement

{ */ get data(): PaymentData | RiskData { const clientData = getProp(this.props, 'modules.risk.data'); - const useAnalytics = !!getProp(this.props, 'modules.analytics.props.enabled'); - const checkoutAttemptId = useAnalytics ? getProp(this.props, 'modules.analytics.checkoutAttemptId') : 'do-not-track'; + const useAnalytics = !!getProp(this.props, 'modules.analytics.getEnabled')?.(); + const checkoutAttemptId = useAnalytics ? getProp(this.props, 'modules.analytics.getCheckoutAttemptId')?.() : 'do-not-track'; const order = this.state.order || this.props.order; const componentData = this.formatData(); @@ -98,24 +110,36 @@ class BaseElement

{ throw new Error('Component could not mount. Root node was not found.'); } + const setupAnalytics = !this._node; + if (this._node) { this.unmount(); // new, if this._node exists then we are "remounting" so we first need to unmount if it's not already been done - } else { - // Set up analytics, once - if (this.props.modules && this.props.modules.analytics && !this.props.isDropin) { - this.props.modules.analytics.send({ - containerWidth: this._node && this._node.offsetWidth, - component: this.constructor['analyticsType'] ?? this.constructor['type'], - flavor: 'components' - }); - } } this._node = node; + this._component = this.render(); render(this._component, node); + // Set up analytics (once, since this._node is currently undefined) now that we have mounted and rendered + if (setupAnalytics) { + if (this.props.modules && this.props.modules.analytics) { + this.setUpAnalytics({ + containerWidth: node && (node as HTMLElement).offsetWidth, + component: !this.props.isDropin ? this.constructor['analyticsType'] ?? this.constructor['type'] : 'dropin', + flavor: !this.props.isDropin ? 'components' : 'dropin' + }).then(() => { + // Once the initial analytics set up call has been made... + // ...create an analytics event declaring that the component has been rendered + // (The dropin will do this itself from DropinComponent once the PM list has rendered) + if (!this.props.isDropin) { + this.submitAnalytics({ type: ANALYTICS_RENDERED_STR }); + } + }); + } + } + return this; } diff --git a/packages/lib/src/components/Card/Card.Analytics.test.tsx b/packages/lib/src/components/Card/Card.Analytics.test.tsx new file mode 100644 index 0000000000..6e0e6bb66e --- /dev/null +++ b/packages/lib/src/components/Card/Card.Analytics.test.tsx @@ -0,0 +1,162 @@ +import { CardElement } from './Card'; +import Analytics from '../../core/Analytics'; + +const analyticsModule = Analytics({ analytics: {}, loadingContext: '', locale: '', clientKey: '' }); + +let card; + +import { + ANALYTICS_CONFIGURED_STR, + ANALYTICS_EVENT_INFO, + ANALYTICS_EVENT_LOG, + ANALYTICS_FOCUS_STR, + ANALYTICS_RENDERED_STR, + ANALYTICS_SUBMIT_STR, + ANALYTICS_UNFOCUS_STR, + ANALYTICS_VALIDATION_ERROR_STR +} from '../../core/Analytics/constants'; + +describe('Card: calls that generate "info" analytics should produce objects with the expected shapes ', () => { + beforeEach(() => { + console.log = jest.fn(() => {}); + + card = new CardElement({ + modules: { + analytics: analyticsModule + } + }); + + analyticsModule.createAnalyticsEvent = jest.fn(() => null); + }); + + test('Analytics should produce an "info" event, of type "rendered", for a card PM', () => { + card.submitAnalytics({ + type: ANALYTICS_RENDERED_STR + }); + + expect(analyticsModule.createAnalyticsEvent).toHaveBeenCalledWith({ + event: ANALYTICS_EVENT_INFO, + data: { component: card.constructor['type'], type: ANALYTICS_RENDERED_STR } + }); + }); + + test('Analytics should produce an "info" event, of type "rendered", for a storedCard PM', () => { + card.submitAnalytics({ + type: ANALYTICS_RENDERED_STR, + isStoredPaymentMethod: true, + brand: 'mc' + }); + + expect(analyticsModule.createAnalyticsEvent).toHaveBeenCalledWith({ + event: ANALYTICS_EVENT_INFO, + data: { component: card.constructor['type'], type: ANALYTICS_RENDERED_STR, isStoredPaymentMethod: true, brand: 'mc' } + }); + }); + + test('Analytics should produce an "info" event, of type "configured", for a card PM', () => { + card.submitAnalytics({ + type: ANALYTICS_CONFIGURED_STR + }); + + expect(analyticsModule.createAnalyticsEvent).toHaveBeenCalledWith({ + event: ANALYTICS_EVENT_INFO, + data: { component: card.constructor['type'], type: ANALYTICS_CONFIGURED_STR } + }); + }); + + test('Analytics should produce an "info" event, of type "configured", for a storedCard PM', () => { + card.submitAnalytics({ + type: ANALYTICS_CONFIGURED_STR, + isStoredPaymentMethod: true, + brand: 'mc' + }); + + expect(analyticsModule.createAnalyticsEvent).toHaveBeenCalledWith({ + event: ANALYTICS_EVENT_INFO, + data: { component: card.constructor['type'], type: ANALYTICS_CONFIGURED_STR, isStoredPaymentMethod: true, brand: 'mc' } + }); + }); + + test('Analytics should produce an "info" event, of type "focus" with the target value correctly formed', () => { + card.onFocus({ + fieldType: 'encryptedCardNumber', + event: { + action: 'focus', + focus: true, + numChars: 0, + fieldType: 'encryptedCardNumber', + rootNode: {}, + type: 'card', + currentFocusObject: 'encryptedCardNumber' + } + }); + + expect(analyticsModule.createAnalyticsEvent).toHaveBeenCalledWith({ + event: ANALYTICS_EVENT_INFO, + data: { component: card.constructor['type'], type: ANALYTICS_FOCUS_STR, target: 'card_number' } + }); + }); + + test('Analytics should produce an "info" event, of type "unfocus" with the target value correctly formed', () => { + card.onBlur({ + fieldType: 'encryptedCardNumber', + event: { + action: 'focus', + focus: false, + numChars: 1, + fieldType: 'encryptedCardNumber', + rootNode: {}, + type: 'card', + currentFocusObject: null + } + }); + + expect(analyticsModule.createAnalyticsEvent).toHaveBeenCalledWith({ + event: ANALYTICS_EVENT_INFO, + data: { component: card.constructor['type'], type: ANALYTICS_UNFOCUS_STR, target: 'card_number' } + }); + }); + + test('Analytics should produce an "info" event, of type "validationError", with the expected properties', () => { + card.onErrorAnalytics({ + fieldType: 'encryptedCardNumber', + errorCode: 'error.va.sf-cc-num.04' + }); + + expect(analyticsModule.createAnalyticsEvent).toHaveBeenCalledWith({ + event: ANALYTICS_EVENT_INFO, + data: { + component: card.constructor['type'], + type: ANALYTICS_VALIDATION_ERROR_STR, + target: 'card_number', + validationErrorCode: 'error.va.sf-cc-num.04', + validationErrorMessage: 'card-number-not-filled-correctly' + } + }); + }); +}); + +describe('Card: calls that generate "log" analytics should produce objects with the expected shapes ', () => { + beforeEach(() => { + console.log = jest.fn(() => {}); + + card = new CardElement({ + modules: { + analytics: analyticsModule + } + }); + }); + + test('Analytics should produce an "log" event, of type "submit", for a card PM', () => { + card.submitAnalytics({ type: ANALYTICS_SUBMIT_STR }); + + expect(analyticsModule.createAnalyticsEvent).toHaveBeenCalledWith({ + event: ANALYTICS_EVENT_LOG, + data: { + component: card.constructor['type'], + type: ANALYTICS_SUBMIT_STR, + message: 'Shopper clicked pay' + } + }); + }); +}); diff --git a/packages/lib/src/components/Card/Card.tsx b/packages/lib/src/components/Card/Card.tsx index fde82b9a39..b4fda70a17 100644 --- a/packages/lib/src/components/Card/Card.tsx +++ b/packages/lib/src/components/Card/Card.tsx @@ -5,15 +5,27 @@ import CoreProvider from '../../core/Context/CoreProvider'; import collectBrowserInfo from '../../utils/browserInfo'; import { BinLookupResponse, CardElementData, CardElementProps } from './types'; import triggerBinLookUp from '../internal/SecuredFields/binLookup/triggerBinLookUp'; -import { CbObjOnBinLookup } from '../internal/SecuredFields/lib/types'; -import { reject } from '../internal/SecuredFields/utils'; +import { CbObjOnBinLookup, CbObjOnConfigSuccess, CbObjOnFocus } from '../internal/SecuredFields/lib/types'; +import { fieldTypeToSnakeCase, reject } from '../internal/SecuredFields/utils'; import { hasValidInstallmentsObject } from './components/CardInput/utils'; import createClickToPayService from '../internal/ClickToPay/services/create-clicktopay-service'; import { ClickToPayCheckoutPayload, IClickToPayService } from '../internal/ClickToPay/services/types'; import ClickToPayWrapper from './components/ClickToPayWrapper'; -import { PayButtonFunctionProps, UIElementStatus } from '../types'; +import { ComponentFocusObject, PayButtonFunctionProps, UIElementStatus } from '../types'; import SRPanelProvider from '../../core/Errors/SRPanelProvider'; import PayButton from '../internal/PayButton'; +import { + ANALYTICS_FOCUS_STR, + ANALYTICS_CONFIGURED_STR, + ANALYTICS_UNFOCUS_STR, + ANALYTICS_VALIDATION_ERROR_STR, + ANALYTICS_RENDERED_STR +} from '../../core/Analytics/constants'; +import { ALL_SECURED_FIELDS } from '../internal/SecuredFields/lib/configuration/constants'; +import { FieldErrorAnalyticsObject, SendAnalyticsObject } from '../../core/Analytics/types'; +import { hasOwnProperty } from '../../utils/hasOwnProperty'; +import { ERROR_CODES } from '../../core/Errors/constants'; +import { getErrorMessageFromCode } from '../../core/Errors/utils'; export class CardElement extends UIElement { public static type = 'scheme'; @@ -165,6 +177,67 @@ export class CardElement extends UIElement { } } + protected submitAnalytics(analyticsObj: SendAnalyticsObject) { + const { type } = analyticsObj; + + if (type === ANALYTICS_RENDERED_STR || type === ANALYTICS_CONFIGURED_STR) { + // Check if it's a storedCard + if (this.constructor['type'] === 'scheme') { + if (hasOwnProperty(this.props, 'supportedShopperInteractions')) { + analyticsObj.isStoredPaymentMethod = true; + analyticsObj.brand = this.props.brand; + } + } + } + + super.submitAnalytics(analyticsObj); + } + + private onConfigSuccess = (obj: CbObjOnConfigSuccess) => { + this.submitAnalytics({ + type: ANALYTICS_CONFIGURED_STR + }); + + this.props.onConfigSuccess?.(obj); + }; + + private onFocus = (obj: ComponentFocusObject) => { + this.submitAnalytics({ + type: ANALYTICS_FOCUS_STR, + target: fieldTypeToSnakeCase(obj.fieldType) + }); + + // Call merchant defined callback + if (ALL_SECURED_FIELDS.includes(obj.fieldType)) { + this.props.onFocus?.(obj.event as CbObjOnFocus); + } else { + this.props.onFocus?.(obj); + } + }; + + private onBlur = (obj: ComponentFocusObject) => { + this.submitAnalytics({ + type: ANALYTICS_UNFOCUS_STR, + target: fieldTypeToSnakeCase(obj.fieldType) + }); + + // Call merchant defined callback + if (ALL_SECURED_FIELDS.includes(obj.fieldType)) { + this.props.onBlur?.(obj.event as CbObjOnFocus); + } else { + this.props.onBlur?.(obj); + } + }; + + private onErrorAnalytics = (obj: FieldErrorAnalyticsObject) => { + this.submitAnalytics({ + type: ANALYTICS_VALIDATION_ERROR_STR, + target: fieldTypeToSnakeCase(obj.fieldType), + validationErrorCode: obj.errorCode, + validationErrorMessage: getErrorMessageFromCode(obj.errorCode, ERROR_CODES) + }); + }; + public onBinValue = triggerBinLookUp(this); get storePaymentMethodPayload() { @@ -267,6 +340,10 @@ export class CardElement extends UIElement { brandsIcons={this.brands} isPayButtonPrimaryVariant={isCardPrimaryInput} resources={this.resources} + onFocus={this.onFocus} + onBlur={this.onBlur} + onErrorAnalytics={this.onErrorAnalytics} + onConfigSuccess={this.onConfigSuccess} /> ); } diff --git a/packages/lib/src/components/Card/components/CardInput/CardInput.tsx b/packages/lib/src/components/Card/components/CardInput/CardInput.tsx index 382d0d21d2..cdc08c98e3 100644 --- a/packages/lib/src/components/Card/components/CardInput/CardInput.tsx +++ b/packages/lib/src/components/Card/components/CardInput/CardInput.tsx @@ -6,7 +6,7 @@ import defaultProps from './defaultProps'; import defaultStyles from './defaultStyles'; import './CardInput.scss'; import { AddressModeOptions, CardInputDataState, CardInputErrorState, CardInputProps, CardInputRef, CardInputValidState } from './types'; -import { CVC_POLICY_REQUIRED, DATE_POLICY_REQUIRED } from '../../../internal/SecuredFields/lib/configuration/constants'; +import { CVC_POLICY_REQUIRED, DATE_POLICY_REQUIRED, ENCRYPTED_CARD_NUMBER } from '../../../internal/SecuredFields/lib/configuration/constants'; import { BinLookupResponse } from '../../types'; import { cardInputFormatters, cardInputValidationRules, getRuleByNameAndMode } from './validate'; import CIExtensions from '../../../internal/SecuredFields/binLookup/extensions'; @@ -30,6 +30,8 @@ import { SetSRMessagesReturnFn } from '../../../../core/Errors/SRPanelProvider'; import useImage from '../../../../core/Context/useImage'; import { getArrayDifferences } from '../../../../utils/arrayUtils'; import FormInstruction from '../../../internal/FormInstruction'; +import { CbObjOnFocus } from '../../../internal/SecuredFields/lib/types'; +import { FieldErrorAnalyticsObject } from '../../../../core/Analytics/types'; const CardInput: FunctionalComponent = props => { const sfp = useRef(null); @@ -139,8 +141,16 @@ const CardInput: FunctionalComponent = props => { /** * HANDLERS */ - // SecuredField-only handler - const handleFocus = getFocusHandler(setFocusedElement, props.onFocus, props.onBlur); + // Handlers for focus & blur on all fields. Can be renamed to onFieldFocus once the onFocusField is renamed in Field.tsx + const onFieldFocusAnalytics = (who: string, e: Event | CbObjOnFocus) => { + props.onFocus({ fieldType: who, event: e }); + }; + const onFieldBlurAnalytics = (who: string, e: Event | CbObjOnFocus) => { + props.onBlur({ fieldType: who, event: e }); + }; + + // Make SecuredFields aware of the focus & blur handlers + const handleFocus = getFocusHandler(setFocusedElement, onFieldFocusAnalytics, onFieldBlurAnalytics); const retrieveLayout = (): string[] => { return getLayout({ @@ -191,19 +201,17 @@ const CardInput: FunctionalComponent = props => { } /** - * If PAN has just become valid: decide if we can shift focus to the next field. + * Decide if we can shift focus to the expiryDate field. * - * We can if the config prop, autoFocus, is true AND we have a panLength value from binLookup AND one of the following scenarios is true: - * - If encryptedCardNumber was invalid but now is valid - * [scenario: shopper has typed in a number and field is now valid] - * - If encryptedCardNumber was valid and still is valid and we're handling an onBrand event (triggered by binLookup which has happened after the handleOnFieldValid event) - * [scenario: shopper has pasted in a full, valid, number] + * We can if... the config prop, autoFocus, is true AND we have a panLength value from binLookup + * AND we are responding to a handleOnFieldValid message about the PAN that says it is valid */ if ( props.autoFocus && hasPanLengthRef.current > 0 && - ((!valid.encryptedCardNumber && sfState.valid?.encryptedCardNumber) || - (valid.encryptedCardNumber && sfState.valid.encryptedCardNumber && eventDetails?.event === 'handleOnBrand')) + eventDetails?.event === 'handleOnFieldValid' && + eventDetails?.fieldType === ENCRYPTED_CARD_NUMBER && + sfState.valid.encryptedCardNumber ) { doPanAutoJump(); } @@ -395,8 +403,10 @@ const CardInput: FunctionalComponent = props => { // Use the error code to look up whether error is actually a blur based one (most are but some SF ones aren't) const isBlurBasedError = lookupBlurBasedErrors(latestErrorMsg.errorCode); - // Only add blur based errors to the error panel - doing this step prevents the non-blur based errors from being read out twice - // (once from the aria-live, error panel & once from the aria-describedby element) + /** + * ONLY ADD BLUR BASED ERRORS TO THE ERROR PANEL - doing this step prevents the non-blur based errors from being read out twice + * (once from the aria-live, error panel & once from the aria-describedby element) + */ const latestSRError = isBlurBasedError ? latestErrorMsg.errorMessage : null; // console.log('### CardInput2::componentDidUpdate:: #2 (not validating) single error:: latestSRError', latestSRError); setSRMessagesFromStrings(latestSRError); @@ -411,6 +421,19 @@ const CardInput: FunctionalComponent = props => { break; } + // Analytics + if (currentErrorsSortedByLayout) { + const newErrors = getArrayDifferences(currentErrorsSortedByLayout, previousSortedErrors, 'field'); + newErrors?.forEach(errorItem => { + const aObj: FieldErrorAnalyticsObject = { + fieldType: errorItem.field, + errorCode: errorItem.errorCode + }; + + props.onErrorAnalytics(aObj); + }); + } + props.onChange({ data, valid, @@ -497,6 +520,9 @@ const CardInput: FunctionalComponent = props => { addressSearchDebounceMs={props.addressSearchDebounceMs} // iOSFocusedField={iOSFocusedField} + // + onFieldFocusAnalytics={onFieldFocusAnalytics} + onFieldBlurAnalytics={onFieldBlurAnalytics} /> )} diff --git a/packages/lib/src/components/Card/components/CardInput/components/CardFieldsWrapper.tsx b/packages/lib/src/components/Card/components/CardInput/components/CardFieldsWrapper.tsx index 2460d99ccb..dd313ddd40 100644 --- a/packages/lib/src/components/Card/components/CardInput/components/CardFieldsWrapper.tsx +++ b/packages/lib/src/components/Card/components/CardInput/components/CardFieldsWrapper.tsx @@ -66,7 +66,10 @@ export const CardFieldsWrapper = ({ showBrandsUnderCardNumber, // iOSFocusedField, - disclaimerMessage + disclaimerMessage, + // + onFieldFocusAnalytics, + onFieldBlurAnalytics }) => { const cardHolderField = ( ); @@ -120,6 +125,8 @@ export const CardFieldsWrapper = ({ onBlur={handleChangeFor('taxNumber', 'blur')} onInput={handleChangeFor('taxNumber', 'input')} disabled={iOSFocusedField && iOSFocusedField !== 'kcpTaxNumberOrDOB'} + onFieldFocusAnalytics={onFieldFocusAnalytics} + onFieldBlurAnalytics={onFieldBlurAnalytics} /> )} @@ -133,6 +140,8 @@ export const CardFieldsWrapper = ({ data={socialSecurityNumber} required={true} disabled={iOSFocusedField && iOSFocusedField !== 'socialSecurityNumber'} + onFieldFocusAnalytics={onFieldFocusAnalytics} + onFieldBlurAnalytics={onFieldBlurAnalytics} /> )} @@ -163,6 +172,8 @@ export const CardFieldsWrapper = ({ onAddressLookup={onAddressLookup} onAddressSelected={onAddressSelected} addressSearchDebounceMs={addressSearchDebounceMs} + onFieldFocusAnalytics={onFieldFocusAnalytics} + onFieldBlurAnalytics={onFieldBlurAnalytics} /> )} diff --git a/packages/lib/src/components/Card/components/CardInput/components/CardHolderName.tsx b/packages/lib/src/components/Card/components/CardInput/components/CardHolderName.tsx index 114856ab96..8beb4fd5b5 100644 --- a/packages/lib/src/components/Card/components/CardInput/components/CardHolderName.tsx +++ b/packages/lib/src/components/Card/components/CardInput/components/CardHolderName.tsx @@ -5,7 +5,18 @@ import { CardHolderNameProps } from './types'; import styles from '../CardInput.module.scss'; import InputText from '../../../../internal/FormFields/InputText'; -export default function CardHolderName({ onBlur, onInput, placeholder, value, required, error = false, isValid, disabled }: CardHolderNameProps) { +export default function CardHolderName({ + onBlur, + onInput, + placeholder, + value, + required, + error = false, + isValid, + disabled, + onFieldFocusAnalytics, + onFieldBlurAnalytics +}: CardHolderNameProps) { const { i18n } = useCoreContext(); return ( @@ -16,6 +27,8 @@ export default function CardHolderName({ onBlur, onInput, placeholder, value, re isValid={!!isValid} name={'holderName'} i18n={i18n} + onFocus={e => onFieldFocusAnalytics('holderName', e)} + onBlur={e => onFieldBlurAnalytics('holderName', e)} > props.onFieldFocusAnalytics('taxNumber', e)} + onBlur={e => props.onFieldBlurAnalytics('taxNumber', e)} > void; + onFieldBlurAnalytics?: (who: string, event: Event) => void; } export interface CardNumberProps { @@ -131,6 +133,8 @@ export interface KCPProps { isValid: boolean; value: string; disabled?: boolean; + onFieldFocusAnalytics?: (who: string, event: Event) => void; + onFieldBlurAnalytics?: (who: string, event: Event) => void; } export type RtnType_ParamBooleanFn = (tn) => boolean; diff --git a/packages/lib/src/components/Card/components/CardInput/handlers.ts b/packages/lib/src/components/Card/components/CardInput/handlers.ts index 57408cd420..5ed0dcd751 100644 --- a/packages/lib/src/components/Card/components/CardInput/handlers.ts +++ b/packages/lib/src/components/Card/components/CardInput/handlers.ts @@ -30,7 +30,7 @@ export const getFocusHandler = (setFocusedElement, onFocus, onBlur) => { // Return Handler fn: return (e: CbObjOnFocus) => { setFocusedElement(e.currentFocusObject); - e.focus === true ? onFocus(e) : onBlur(e); + e.focus === true ? onFocus(e.fieldType, e) : onBlur(e.fieldType, e); }; }; diff --git a/packages/lib/src/components/Card/components/CardInput/types.ts b/packages/lib/src/components/Card/components/CardInput/types.ts index 47ba43463b..6e5d42ea01 100644 --- a/packages/lib/src/components/Card/components/CardInput/types.ts +++ b/packages/lib/src/components/Card/components/CardInput/types.ts @@ -9,11 +9,11 @@ import { AddressSchema } from '../../../internal/Address/types'; import { CbObjOnError, StylesObject } from '../../../internal/SecuredFields/lib/types'; import { Resources } from '../../../../core/Context/Resources'; import { SRPanel } from '../../../../core/Errors/SRPanel'; -import Analytics from '../../../../core/Analytics'; import RiskElement from '../../../../core/RiskModule'; -import { ComponentMethodsRef } from '../../../types'; +import { AnalyticsModule, ComponentMethodsRef } from '../../../types'; import { DisclaimerMsgObject } from '../../../internal/DisclaimerMessage/DisclaimerMessage'; import { OnAddressLookupType, OnAddressSelectedType } from '../../../internal/Address/components/AddressSearch'; +import { FieldErrorAnalyticsObject } from '../../../../core/Analytics/types'; export interface CardInputValidState { holderName?: boolean; @@ -90,7 +90,7 @@ export interface CardInputProps { minimumExpiryDate?: string; modules?: { srPanel: SRPanel; - analytics: Analytics; + analytics: AnalyticsModule; risk: RiskElement; resources: Resources; }; @@ -129,6 +129,7 @@ export interface CardInputProps { type?: string; maskSecurityCode?: boolean; disclaimerMessage?: DisclaimerMsgObject; + onErrorAnalytics?: (obj: FieldErrorAnalyticsObject) => {}; } export interface CardInputState { diff --git a/packages/lib/src/components/Card/types.ts b/packages/lib/src/components/Card/types.ts index 1145dc7b4e..6a68be802a 100644 --- a/packages/lib/src/components/Card/types.ts +++ b/packages/lib/src/components/Card/types.ts @@ -1,4 +1,4 @@ -import { UIElementProps } from '../types'; +import { ComponentFocusObject, UIElementProps } from '../types'; import { AddressData, BrowserInfo } from '../../types'; import { CbObjOnBinValue, @@ -125,9 +125,14 @@ export interface CardElementProps extends UIElementProps { onError?: (event: CbObjOnError) => void; /** - * Called when a field gains or loses focus. + * Called when a field gains focus. */ - onFocus?: (event: CbObjOnFocus) => void; + onFocus?: (event: CbObjOnFocus | ComponentFocusObject) => void; + + /** + * Called when a field gains loses focus. + */ + onBlur?: (event: CbObjOnFocus | ComponentFocusObject) => void; /** * Provides the BIN Number of the card (up to 6 digits), called as the user types in the PAN. diff --git a/packages/lib/src/components/Dropin/components/DropinComponent.tsx b/packages/lib/src/components/Dropin/components/DropinComponent.tsx index 9acd2c87bb..1ca0cefac3 100644 --- a/packages/lib/src/components/Dropin/components/DropinComponent.tsx +++ b/packages/lib/src/components/Dropin/components/DropinComponent.tsx @@ -5,6 +5,7 @@ import getOrderStatus from '../../../core/Services/order-status'; import { DropinComponentProps, DropinComponentState, DropinStatusProps, onOrderCancelData } from '../types'; import './DropinComponent.scss'; import { UIElementStatus } from '../../types'; +import { ANALYTICS_RENDERED_STR } from '../../../core/Analytics/constants'; export class DropinComponent extends Component { public state: DropinComponentState = { @@ -31,14 +32,7 @@ export class DropinComponent extends Component e.props.type), - component: 'dropin', - flavor: 'dropin' - }); - } + this.props.modules?.analytics.sendAnalytics('dropin', { type: ANALYTICS_RENDERED_STR }); } ); @@ -74,6 +68,8 @@ export class DropinComponent extends Component { const user = userEvent.setup(); const baseProps = { - modules: { resources }, + modules: { resources, analytics: { sendAnalytics: () => {} } }, amount: { value: 1000, currency: 'EUR' }, name: 'My Test Gift Card', type: 'giftcard', diff --git a/packages/lib/src/components/GooglePay/GooglePay.test.ts b/packages/lib/src/components/GooglePay/GooglePay.test.ts index a3676a1d1a..79939532b2 100644 --- a/packages/lib/src/components/GooglePay/GooglePay.test.ts +++ b/packages/lib/src/components/GooglePay/GooglePay.test.ts @@ -1,4 +1,8 @@ import GooglePay from './GooglePay'; +import Analytics from '../../core/Analytics'; +import { ANALYTICS_EVENT_INFO, ANALYTICS_SELECTED_STR } from '../../core/Analytics/constants'; + +const analyticsModule = Analytics({ analytics: {}, loadingContext: '', locale: '', clientKey: '' }); describe('GooglePay', () => { describe('get data', () => { @@ -66,4 +70,36 @@ describe('GooglePay', () => { expect(gpay.props.configuration.authJwt).toEqual('jwt.code'); }); }); + + describe('GooglePay: calls that generate "info" analytics should produce objects with the expected shapes ', () => { + let gpay; + beforeEach(() => { + console.log = jest.fn(() => {}); + + gpay = new GooglePay({ + type: 'googlepay', + isInstantPayment: true, + modules: { + analytics: analyticsModule + } + }); + + analyticsModule.createAnalyticsEvent = jest.fn(obj => { + console.log('### analyticsPreProcessor.test:::: obj=', obj); + }); + }); + + test('Analytics should produce an "info" event, of type "selected", for GooglePay as an instant PM', () => { + gpay.submit(); + + expect(analyticsModule.createAnalyticsEvent).toHaveBeenCalledWith({ + event: ANALYTICS_EVENT_INFO, + data: { + component: gpay.props.type, + type: ANALYTICS_SELECTED_STR, + target: 'instant_payment_button' + } + }); + }); + }); }); diff --git a/packages/lib/src/components/GooglePay/GooglePay.tsx b/packages/lib/src/components/GooglePay/GooglePay.tsx index 2bbce671c8..0f99410067 100644 --- a/packages/lib/src/components/GooglePay/GooglePay.tsx +++ b/packages/lib/src/components/GooglePay/GooglePay.tsx @@ -7,6 +7,7 @@ import { GooglePayProps } from './types'; import { mapBrands, getGooglePayLocale } from './utils'; import collectBrowserInfo from '../../utils/browserInfo'; import AdyenCheckoutError from '../../core/Errors/AdyenCheckoutError'; +import { ANALYTICS_INSTANT_PAYMENT_BUTTON, ANALYTICS_SELECTED_STR } from '../../core/Analytics/constants'; class GooglePay extends UIElement { public static type = 'paywithgoogle'; @@ -45,6 +46,11 @@ class GooglePay extends UIElement { } public submit = () => { + // Analytics + if (this.props.isInstantPayment) { + this.submitAnalytics({ type: ANALYTICS_SELECTED_STR, target: ANALYTICS_INSTANT_PAYMENT_BUTTON }); + } + const { onAuthorized = () => {} } = this.props; return new Promise((resolve, reject) => this.props.onClick(resolve, reject)) diff --git a/packages/lib/src/components/MealVoucherFR/MealVoucherFR.test.tsx b/packages/lib/src/components/MealVoucherFR/MealVoucherFR.test.tsx index 308f35eb98..f4474b0982 100644 --- a/packages/lib/src/components/MealVoucherFR/MealVoucherFR.test.tsx +++ b/packages/lib/src/components/MealVoucherFR/MealVoucherFR.test.tsx @@ -8,7 +8,7 @@ describe('MealVoucherFR', () => { const user = userEvent.setup(); const baseProps = { - modules: { resources }, + modules: { resources, analytics: { sendAnalytics: () => {} } }, amount: { value: 1000, currency: 'EUR' }, name: 'MealVoucher', showPayButton: true, diff --git a/packages/lib/src/components/SecuredFields/SecuredFields.tsx b/packages/lib/src/components/SecuredFields/SecuredFields.tsx index 54605ac607..22bd9c53ef 100644 --- a/packages/lib/src/components/SecuredFields/SecuredFields.tsx +++ b/packages/lib/src/components/SecuredFields/SecuredFields.tsx @@ -4,9 +4,10 @@ import SecuredFields from './SecuredFieldsInput'; import CoreProvider from '../../core/Context/CoreProvider'; import collectBrowserInfo from '../../utils/browserInfo'; import triggerBinLookUp from '../internal/SecuredFields/binLookup/triggerBinLookUp'; -import { CbObjOnBinLookup } from '../internal/SecuredFields/lib/types'; +import { CbObjOnBinLookup, CbObjOnFocus } from '../internal/SecuredFields/lib/types'; import { BrandObject } from '../Card/types'; -import { getCardImageUrl } from '../internal/SecuredFields/utils'; +import { fieldTypeToSnakeCase, getCardImageUrl } from '../internal/SecuredFields/utils'; +import { ANALYTICS_FOCUS_STR, ANALYTICS_UNFOCUS_STR } from '../../core/Analytics/constants'; export class SecuredFieldsElement extends UIElement { public static type = 'scheme'; @@ -89,6 +90,16 @@ export class SecuredFieldsElement extends UIElement { return collectBrowserInfo(); } + private onFocus = (obj: CbObjOnFocus) => { + this.submitAnalytics({ + type: obj.focus === true ? ANALYTICS_FOCUS_STR : ANALYTICS_UNFOCUS_STR, + target: fieldTypeToSnakeCase(obj.fieldType) + }); + + // Call merchant defined callback + this.props.onFocus?.(obj); + }; + render() { return ( @@ -103,6 +114,7 @@ export class SecuredFieldsElement extends UIElement { onBinValue={this.onBinValue} implementationType={'custom'} resources={this.resources} + onFocus={this.onFocus} /> ); diff --git a/packages/lib/src/components/ThreeDS2/ThreeDS2Challenge.test.tsx b/packages/lib/src/components/ThreeDS2/ThreeDS2Challenge.test.tsx new file mode 100644 index 0000000000..229d1c2a8c --- /dev/null +++ b/packages/lib/src/components/ThreeDS2/ThreeDS2Challenge.test.tsx @@ -0,0 +1,53 @@ +import { ThreeDS2Challenge } from './index'; +import Analytics from '../../core/Analytics'; +import { + ANALYTICS_API_ERROR, + ANALYTICS_ERROR_CODE_ACTION_IS_MISSING_PAYMENT_DATA, + ANALYTICS_EVENT_ERROR, + ANALYTICS_RENDERED_STR +} from '../../core/Analytics/constants'; +import { THREEDS2_CHALLENGE_ERROR, THREEDS2_ERROR } from './config'; + +const analyticsModule = Analytics({ analytics: {}, loadingContext: '', locale: '', clientKey: '' }); + +describe('ThreeDS2Challenge: calls that generate analytics should produce objects with the expected shapes ', () => { + let challenge; + beforeEach(() => { + console.log = jest.fn(() => {}); + + challenge = new ThreeDS2Challenge({ + onActionHandled: () => {}, + modules: { + analytics: analyticsModule + }, + onError: () => {} + }); + + analyticsModule.createAnalyticsEvent = jest.fn(obj => { + console.log('### analyticsPreProcessor.test:::: obj=', obj); + }); + }); + + test('A call to ThreeDS2Challenge.submitAnalytics with an object with type "rendered" should not lead to an analytics event', () => { + challenge.submitAnalytics({ type: ANALYTICS_RENDERED_STR }); + + expect(analyticsModule.createAnalyticsEvent).not.toHaveBeenCalled(); + }); + + test('ThreeDS2Challenge instantiated without paymentData should generate an analytics error', () => { + const view = challenge.render(); + + expect(analyticsModule.createAnalyticsEvent).toHaveBeenCalledWith({ + event: ANALYTICS_EVENT_ERROR, + data: { + component: challenge.constructor['type'], + type: THREEDS2_ERROR, + errorType: ANALYTICS_API_ERROR, + message: `${THREEDS2_CHALLENGE_ERROR}: Missing 'paymentData' property from threeDS2 action`, + code: ANALYTICS_ERROR_CODE_ACTION_IS_MISSING_PAYMENT_DATA + } + }); + + expect(view).toBe(null); + }); +}); diff --git a/packages/lib/src/components/ThreeDS2/ThreeDS2Challenge.tsx b/packages/lib/src/components/ThreeDS2/ThreeDS2Challenge.tsx index 6cef7b0093..3821a34648 100644 --- a/packages/lib/src/components/ThreeDS2/ThreeDS2Challenge.tsx +++ b/packages/lib/src/components/ThreeDS2/ThreeDS2Challenge.tsx @@ -2,11 +2,13 @@ import { h } from 'preact'; import UIElement from '../UIElement'; import PrepareChallenge from './components/Challenge'; import { ErrorCodeObject } from './components/utils'; -import { DEFAULT_CHALLENGE_WINDOW_SIZE } from './config'; +import { DEFAULT_CHALLENGE_WINDOW_SIZE, THREEDS2_CHALLENGE, THREEDS2_CHALLENGE_ERROR, THREEDS2_ERROR } from './config'; import { existy } from '../internal/SecuredFields/lib/utilities/commonUtils'; import { hasOwnProperty } from '../../utils/hasOwnProperty'; import Language from '../../language'; -import { ActionHandledReturnObject } from '../types'; +import { ActionHandledReturnObject, AnalyticsModule } from '../types'; +import { ANALYTICS_API_ERROR, ANALYTICS_ERROR_CODE_ACTION_IS_MISSING_PAYMENT_DATA, ANALYTICS_RENDERED_STR } from '../../core/Analytics/constants'; +import { SendAnalyticsObject } from '../../core/Analytics/types'; export interface ThreeDS2ChallengeProps { token?: string; @@ -21,6 +23,7 @@ export interface ThreeDS2ChallengeProps { useOriginalFlow?: boolean; i18n?: Language; onActionHandled: (rtnObj: ActionHandledReturnObject) => void; + modules?: { analytics: AnalyticsModule }; } class ThreeDS2Challenge extends UIElement { @@ -29,7 +32,13 @@ class ThreeDS2Challenge extends UIElement { public static defaultProps = { dataKey: 'threeDSResult', size: DEFAULT_CHALLENGE_WINDOW_SIZE, - type: 'ChallengeShopper' + type: THREEDS2_CHALLENGE + }; + + protected submitAnalytics = (aObj: SendAnalyticsObject) => { + if (aObj.type === ANALYTICS_RENDERED_STR) return; // suppress the rendered event (it will have the same timestamp as the "creq sent" event) + + super.submitAnalytics(aObj); }; onComplete(state) { @@ -48,10 +57,18 @@ class ThreeDS2Challenge extends UIElement { const dataTypeForError = hasOwnProperty(this.props, 'useOriginalFlow') ? 'paymentData' : 'authorisationToken'; this.props.onError({ errorCode: 'threeds2.challenge', message: `No ${dataTypeForError} received. Challenge cannot proceed` }); + + this.submitAnalytics({ + type: THREEDS2_ERROR, + code: ANALYTICS_ERROR_CODE_ACTION_IS_MISSING_PAYMENT_DATA, + errorType: ANALYTICS_API_ERROR, + message: `${THREEDS2_CHALLENGE_ERROR}: Missing 'paymentData' property from threeDS2 action` + }); + return null; } - return ; + return ; } } diff --git a/packages/lib/src/components/ThreeDS2/ThreeDS2DeviceFingerprint.test.tsx b/packages/lib/src/components/ThreeDS2/ThreeDS2DeviceFingerprint.test.tsx new file mode 100644 index 0000000000..49c7e3981b --- /dev/null +++ b/packages/lib/src/components/ThreeDS2/ThreeDS2DeviceFingerprint.test.tsx @@ -0,0 +1,54 @@ +import { ThreeDS2DeviceFingerprint } from './index'; +import Analytics from '../../core/Analytics'; +import { + ANALYTICS_API_ERROR, + ANALYTICS_ERROR_CODE_ACTION_IS_MISSING_PAYMENT_DATA, + ANALYTICS_EVENT_ERROR, + ANALYTICS_RENDERED_STR +} from '../../core/Analytics/constants'; +import { THREEDS2_ERROR, THREEDS2_FINGERPRINT_ERROR } from './config'; + +const analyticsModule = Analytics({ analytics: {}, loadingContext: '', locale: '', clientKey: '' }); + +describe('ThreeDS2DeviceFingerprint: calls that generate analytics should produce objects with the expected shapes ', () => { + let fingerprint; + beforeEach(() => { + console.log = jest.fn(() => {}); + + fingerprint = new ThreeDS2DeviceFingerprint({ + onActionHandled: () => {}, + modules: { + analytics: analyticsModule + }, + onError: () => {}, + showSpinner: null + }); + + analyticsModule.createAnalyticsEvent = jest.fn(obj => { + console.log('### analyticsPreProcessor.test:::: obj=', obj); + }); + }); + + test('A call to ThreeDS2DeviceFingerprint.submitAnalytics with an object with type "rendered" should not lead to an analytics event', () => { + fingerprint.submitAnalytics({ type: ANALYTICS_RENDERED_STR }); + + expect(analyticsModule.createAnalyticsEvent).not.toHaveBeenCalled(); + }); + + test('ThreeDS2DeviceFingerprint instantiated without paymentData should generate an analytics error', () => { + const view = fingerprint.render(); + + expect(analyticsModule.createAnalyticsEvent).toHaveBeenCalledWith({ + event: ANALYTICS_EVENT_ERROR, + data: { + component: fingerprint.constructor['type'], + type: THREEDS2_ERROR, + errorType: ANALYTICS_API_ERROR, + message: `${THREEDS2_FINGERPRINT_ERROR}: Missing 'paymentData' property from threeDS2 action`, + code: ANALYTICS_ERROR_CODE_ACTION_IS_MISSING_PAYMENT_DATA + } + }); + + expect(view).toBe(null); + }); +}); diff --git a/packages/lib/src/components/ThreeDS2/ThreeDS2DeviceFingerprint.tsx b/packages/lib/src/components/ThreeDS2/ThreeDS2DeviceFingerprint.tsx index ce814e37d7..0f4435c508 100644 --- a/packages/lib/src/components/ThreeDS2/ThreeDS2DeviceFingerprint.tsx +++ b/packages/lib/src/components/ThreeDS2/ThreeDS2DeviceFingerprint.tsx @@ -4,21 +4,25 @@ import PrepareFingerprint from './components/DeviceFingerprint'; import { ErrorCodeObject } from './components/utils'; import callSubmit3DS2Fingerprint from './callSubmit3DS2Fingerprint'; import { existy } from '../internal/SecuredFields/lib/utilities/commonUtils'; -import { ActionHandledReturnObject } from '../types'; +import { ActionHandledReturnObject, AnalyticsModule } from '../types'; +import { THREEDS2_ERROR, THREEDS2_FINGERPRINT, THREEDS2_FINGERPRINT_ERROR } from './config'; +import { ANALYTICS_API_ERROR, ANALYTICS_ERROR_CODE_ACTION_IS_MISSING_PAYMENT_DATA, ANALYTICS_RENDERED_STR } from '../../core/Analytics/constants'; +import { SendAnalyticsObject } from '../../core/Analytics/types'; export interface ThreeDS2DeviceFingerprintProps { - dataKey: string; - token: string; - notificationURL: string; - onError: (error?: string | ErrorCodeObject) => void; - paymentData: string; + dataKey?: string; + token?: string; + notificationURL?: string; + onError?: (error?: string | ErrorCodeObject) => void; + paymentData?: string; showSpinner: boolean; - type: string; + type?: string; useOriginalFlow?: boolean; loadingContext?: string; clientKey?: string; elementRef?: UIElement; onActionHandled: (rtnObj: ActionHandledReturnObject) => void; + modules?: { analytics: AnalyticsModule }; } class ThreeDS2DeviceFingerprint extends UIElement { @@ -26,11 +30,17 @@ class ThreeDS2DeviceFingerprint extends UIElement { + if (aObj.type === ANALYTICS_RENDERED_STR) return; // suppress the rendered event (it will have the same timestamp as the "threeDSMethodData sent" event) + + super.submitAnalytics(aObj); + }; + onComplete(state) { super.onComplete(state); this.unmount(); // re. fixing issue around back to back fingerprinting calls @@ -46,6 +56,15 @@ class ThreeDS2DeviceFingerprint extends UIElement; + return ( + + ); } } diff --git a/packages/lib/src/components/ThreeDS2/components/Challenge/DoChallenge3DS2.tsx b/packages/lib/src/components/ThreeDS2/components/Challenge/DoChallenge3DS2.tsx index e10eea2fb8..fb29325035 100644 --- a/packages/lib/src/components/ThreeDS2/components/Challenge/DoChallenge3DS2.tsx +++ b/packages/lib/src/components/ThreeDS2/components/Challenge/DoChallenge3DS2.tsx @@ -6,7 +6,7 @@ import ThreeDS2Form from '../Form'; import getProcessMessageHandler from '../../../../utils/get-process-message-handler'; import { encodeBase64URL } from '../utils'; import promiseTimeout from '../../../../utils/promiseTimeout'; -import { CHALLENGE_TIMEOUT, CHALLENGE_TIMEOUT_REJECT_OBJECT } from '../../config'; +import { CHALLENGE_TIMEOUT, CHALLENGE_TIMEOUT_REJECT_OBJECT, THREEDS2_NUM } from '../../config'; import { DoChallenge3DS2Props, DoChallenge3DS2State } from './types'; import { ThreeDS2FlowObject } from '../../types'; @@ -24,12 +24,16 @@ class DoChallenge3DS2 extends Component { this.setState({ status: 'iframeLoaded' }); - this.props.onActionHandled({ componentType: '3DS2Challenge', actionDescription: 'challenge-iframe-loaded' }); + // On Test - actually calls-back 3 times: once for challenge screen, once again as challenge.html reloads after the challenge is submitted, and once for redirect to threeDSNotificationURL. + // But for the purposes of calling the merchant defined onActionHandled callback - we only want to do it once + if (this.state.status === 'init') { + this.props.onActionHandled({ componentType: '3DS2Challenge', actionDescription: `${THREEDS2_NUM} challenge iframe loaded` }); + } }; private get3DS2ChallengePromise(): Promise { @@ -64,7 +68,7 @@ class DoChallenge3DS2 extends Component}