Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fastlane analytics #3175

Open
wants to merge 3 commits into
base: fastlane
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/lib/src/components/Card/Card.tsx
Original file line number Diff line number Diff line change
@@ -352,6 +352,7 @@ export class CardElement extends UIElement<CardConfiguration> {
setComponentRef={this.setComponentRef}
{...this.props}
{...this.state}
onSubmitAnalytics={this.submitAnalytics}
onChange={this.setState}
onSubmit={this.submit}
handleKeyPress={this.handleKeyPress}
Original file line number Diff line number Diff line change
@@ -27,7 +27,8 @@ const cardInputRequiredProps = {
loadingContext: 'test',
resources: global.resources,
brandsIcons: [],
showPayButton: false
showPayButton: false,
onSubmitAnalytics: jest.fn()
};

const getWrapper = ui => {
Original file line number Diff line number Diff line change
@@ -482,7 +482,12 @@ const CardInput = (props: CardInputProps) => {
/>

{props.fastlaneConfiguration && (
<FastlaneSignup {...props.fastlaneConfiguration} currentDetectedBrand={internallyDetectedBrand} onChange={props.onChange} />
<FastlaneSignup
{...props.fastlaneConfiguration}
currentDetectedBrand={internallyDetectedBrand}
onChange={props.onChange}
onSubmitAnalytics={props.onSubmitAnalytics}
/>
)}

{props.showPayButton &&
Original file line number Diff line number Diff line change
@@ -24,7 +24,7 @@ import { OnAddressLookupType, OnAddressSelectedType } from '../../../internal/Ad
import { ComponentMethodsRef } from '../../../internal/UIElement/types';
import { AddressData, PaymentAmount } from '../../../../types/global-types';
import { AnalyticsModule } from '../../../../types/global-types';
import { FieldErrorAnalyticsObject } from '../../../../core/Analytics/types';
import { FieldErrorAnalyticsObject, SendAnalyticsObject } from '../../../../core/Analytics/types';
import type { FastlaneSignupConfiguration } from '../../../PayPalFastlane/types';

export interface CardInputValidState {
@@ -127,6 +127,7 @@ export interface CardInputProps {
onFieldValid?: (o: CardFieldValidData) => {};
onFocus?: (e) => {};
onLoad?: (o: CardLoadData) => {};
onSubmitAnalytics(event: SendAnalyticsObject): void;
handleKeyPress?: (obj: KeyboardEvent) => void;
onAddressLookup?: OnAddressLookupType;
onAddressSelected?: OnAddressSelectedType;
Original file line number Diff line number Diff line change
@@ -25,7 +25,7 @@ test('should trigger onChange event if the consent UI is not allowed to be shown

const onChangeMock = jest.fn();

customRender(<FastlaneSignup {...fastlaneConfiguration} onChange={onChangeMock} currentDetectedBrand="card" />);
customRender(<FastlaneSignup {...fastlaneConfiguration} onChange={onChangeMock} currentDetectedBrand="card" onSubmitAnalytics={jest.fn()} />);

expect(onChangeMock).toHaveBeenCalledTimes(1);
expect(onChangeMock.mock.calls[0][0]).toEqual({
@@ -51,7 +51,7 @@ test('should send "consentShown:true" flag if the shopper saw the consent UI at

const onChangeMock = jest.fn();

customRender(<FastlaneSignup {...fastlaneConfiguration} onChange={onChangeMock} currentDetectedBrand="mc" />);
customRender(<FastlaneSignup {...fastlaneConfiguration} onChange={onChangeMock} currentDetectedBrand="mc" onSubmitAnalytics={jest.fn()} />);

// Show the UI
await user.click(screen.getByRole('switch'));
@@ -85,7 +85,7 @@ test('should return phone number formatted (without spaces and without prefix)',

const onChangeMock = jest.fn();

customRender(<FastlaneSignup {...fastlaneConfiguration} onChange={onChangeMock} currentDetectedBrand="visa" />);
customRender(<FastlaneSignup {...fastlaneConfiguration} onChange={onChangeMock} currentDetectedBrand="visa" onSubmitAnalytics={jest.fn()} />);

const input = screen.getByLabelText('Mobile number');

@@ -115,7 +115,7 @@ test('should display terms and privacy statement links', () => {

const onChangeMock = jest.fn();

customRender(<FastlaneSignup {...fastlaneConfiguration} onChange={onChangeMock} currentDetectedBrand="visa" />);
customRender(<FastlaneSignup {...fastlaneConfiguration} onChange={onChangeMock} currentDetectedBrand="visa" onSubmitAnalytics={jest.fn()} />);

expect(screen.getByRole('link', { name: 'terms' })).toHaveAttribute('href', 'https://fastlane.com/terms');
expect(screen.getByRole('link', { name: 'privacy statement' })).toHaveAttribute('href', 'https://fastlane.com/privacy-policy');
@@ -135,7 +135,7 @@ test('should open Fastlane info dialog and close it', async () => {

const onChangeMock = jest.fn();

customRender(<FastlaneSignup {...fastlaneConfiguration} onChange={onChangeMock} currentDetectedBrand="visa" />);
customRender(<FastlaneSignup {...fastlaneConfiguration} onChange={onChangeMock} currentDetectedBrand="visa" onSubmitAnalytics={jest.fn()} />);

screen.getByRole('dialog', { hidden: true });

@@ -162,7 +162,9 @@ test('should not render the UI if there are missing configuration fields', () =>
const consoleMock = jest.fn();
jest.spyOn(console, 'warn').mockImplementation(consoleMock);

const { container } = customRender(<FastlaneSignup {...fastlaneConfiguration} onChange={onChangeMock} currentDetectedBrand="visa" />);
const { container } = customRender(
<FastlaneSignup {...fastlaneConfiguration} onChange={onChangeMock} currentDetectedBrand="visa" onSubmitAnalytics={jest.fn()} />
);

expect(consoleMock).toHaveBeenCalledTimes(1);
expect(consoleMock).toHaveBeenCalledWith('Fastlane: Component configuration is not valid. Fastlane will not be displayed');
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Fragment, h } from 'preact';
import { useEffect, useMemo, useState } from 'preact/hooks';
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks';
import cx from 'classnames';
import Toggle from '../../../internal/Toggle';
import Img from '../../../internal/Img';
@@ -8,15 +8,18 @@ import USOnlyPhoneInput from './USOnlyPhoneInput';
import { InfoButton } from './InfoButton';
import { useCoreContext } from '../../../../core/Context/CoreProvider';
import { LabelOnlyDisclaimerMessage } from '../../../internal/DisclaimerMessage/DisclaimerMessage';
import type { FastlaneSignupConfiguration } from '../../../PayPalFastlane/types';
import { isConfigurationValid } from './utils/validate-configuration';
import mobileNumberFormatter from './utils/mobile-number-formatter';
import { ANALYTICS_EVENT, InfoEventTypes } from '../../../../core/Analytics/constants';
import type { FastlaneSignupConfiguration } from '../../../PayPalFastlane/types';
import type { SendAnalyticsObject } from '../../../../core/Analytics/types';

import './FastlaneSignup.scss';
import mobileNumberFormatter from './utils/mobile-number-formatter';

type FastlaneSignupProps = FastlaneSignupConfiguration & {
currentDetectedBrand: string;
onChange(state: any): void;
onSubmitAnalytics(event: SendAnalyticsObject): void;
};

const SUPPORTED_BRANDS = ['mc', 'visa'];
@@ -30,15 +33,15 @@ const FastlaneSignup = ({
fastlaneSessionId,
currentDetectedBrand,
telephoneNumber: telephoneNumberFromProps,
onChange
onChange,
onSubmitAnalytics
}: FastlaneSignupProps) => {
const displaySignup = useMemo(() => showConsent && SUPPORTED_BRANDS.includes(currentDetectedBrand), [showConsent, currentDetectedBrand]);
const [consentShown, setConsentShown] = useState<boolean>(displaySignup);
const shouldDisplaySignup = useMemo(() => showConsent && SUPPORTED_BRANDS.includes(currentDetectedBrand), [showConsent, currentDetectedBrand]);
const [hasConsentFormBeenShown, setHasConsentFormBeenShown] = useState<boolean>(shouldDisplaySignup);
const [isChecked, setIsChecked] = useState<boolean>(defaultToggleState);
const getImage = useImage();
const [telephoneNumber, setTelephoneNumber] = useState<string>('');
const { i18n } = useCoreContext();

const isFastlaneConfigurationValid = useMemo(() => {
return isConfigurationValid({
showConsent,
@@ -50,6 +53,20 @@ const FastlaneSignup = ({
});
}, [showConsent, defaultToggleState, termsAndConditionsLink, privacyPolicyLink, termsAndConditionsVersion, fastlaneSessionId]);

const handleToggleChange = useCallback(() => {
const newValue = !isChecked;
setIsChecked(newValue);

onSubmitAnalytics({
type: ANALYTICS_EVENT.info,
infoType: InfoEventTypes.clicked,
target: 'fastlane_signup_consent_toggle',
configData: {
isToggleOn: newValue
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there any backend changes needed?
Have you tested that we don't get network errors sending in new properties like "isToggleOn", "isFastlaneSignupRendered"?

}
});
}, [isChecked, onSubmitAnalytics]);

/**
* If the configuration is valid, the Component propagates fastlaneData to the Card component state
*
@@ -63,16 +80,16 @@ const FastlaneSignup = ({

onChange({
fastlaneData: {
consentShown,
consentShown: hasConsentFormBeenShown,
fastlaneSessionId: fastlaneSessionId,
consentGiven: displaySignup ? isChecked : false,
consentGiven: shouldDisplaySignup ? isChecked : false,
...(termsAndConditionsVersion && { consentVersion: termsAndConditionsVersion }),
...(telephoneNumber && { telephoneNumber })
}
});
}, [
displaySignup,
consentShown,
shouldDisplaySignup,
hasConsentFormBeenShown,
termsAndConditionsVersion,
isChecked,
fastlaneSessionId,
@@ -82,13 +99,30 @@ const FastlaneSignup = ({
]);

/**
* If the sign-up has been displayed at least once, we set consentShown: true
* If the sign-up has been displayed at least once, we set hasConsentFormBeenShown: true
*/
useEffect(() => {
if (displaySignup) setConsentShown(true);
}, [displaySignup]);
if (shouldDisplaySignup) {
setHasConsentFormBeenShown(true);
}
}, [shouldDisplaySignup]);

useEffect(() => {
if (!isFastlaneConfigurationValid) {
return;
}

onSubmitAnalytics({
type: ANALYTICS_EVENT.info,
infoType: InfoEventTypes.rendered,
target: '',
configData: {
isFastlaneSignupRendered: shouldDisplaySignup
}
});
}, [shouldDisplaySignup, isFastlaneConfigurationValid, onSubmitAnalytics]);

if (!displaySignup || !isFastlaneConfigurationValid) {
if (!shouldDisplaySignup || !isFastlaneConfigurationValid) {
return null;
}

@@ -101,7 +135,7 @@ const FastlaneSignup = ({
>
<Toggle
checked={isChecked}
onChange={setIsChecked}
onChange={handleToggleChange}
ariaLabel={i18n.get('card.fastlane.consentToggle')}
label={
<Fragment>
Original file line number Diff line number Diff line change
@@ -20,6 +20,10 @@ const VALID_KEYS: ConfigurationKey[] = [
* @param config
*/
const isConfigurationValid = (config: FastlaneSignupConfiguration): boolean => {
if (!config) {
return false;
}

Object.keys(config).forEach(
(key: keyof FastlaneSignupConfiguration) =>
!VALID_KEYS.includes(key) && console.warn(`Fastlane: '${key}' is not valid Fastlane config property`)
Original file line number Diff line number Diff line change
@@ -5,7 +5,7 @@ import getOrderStatus from '../../../core/Services/order-status';
import './DropinComponent.scss';
import { sanitizeOrder } from '../../internal/UIElement/utils';
import { PaymentAmount } from '../../../types/global-types';
import { ANALYTICS_RENDERED_STR } from '../../../core/Analytics/constants';
import { ANALYTICS_EVENT, ANALYTICS_RENDERED_STR, InfoEventTypes } from '../../../core/Analytics/constants';
import AdyenCheckoutError from '../../../core/Errors/AdyenCheckoutError';
import Button from '../../internal/Button';
import type { DropinComponentProps, DropinComponentState, DropinStatus, DropinStatusProps, onOrderCancelData } from '../types';
@@ -126,6 +126,12 @@ export class DropinComponent extends Component<DropinComponentProps, DropinCompo
this.setState({
showDefaultPaymentMethodList: true
});

this.props.modules?.analytics.sendAnalytics('dropin', {
type: ANALYTICS_EVENT.info,
infoType: InfoEventTypes.clicked,
target: 'otherpaymentmethod_button'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to my comment above - can the backend handle this target value, "otherpaymentmethod_button" without errors?

});
};

closeActivePaymentMethod() {
9 changes: 9 additions & 0 deletions packages/lib/src/core/Analytics/analyticsPreProcessor.ts
Original file line number Diff line number Diff line change
@@ -155,6 +155,15 @@ export const analyticsPreProcessor = (analyticsModule: AnalyticsModule) => {
break;
}

case ANALYTICS_EVENT.info: {
const { infoType, configData } = analyticsObj;
analyticsModule.createAnalyticsEvent({
event: ANALYTICS_EVENT.info,
data: { component, type: infoType, configData, target }
});
break;
}

default: {
analyticsModule.createAnalyticsEvent(analyticsObj as CreateAnalyticsEventObject);
}
8 changes: 8 additions & 0 deletions packages/lib/src/core/Analytics/constants.ts
Original file line number Diff line number Diff line change
@@ -34,6 +34,14 @@ export const ANALYTICS_ERROR_CODE = {
redirect: '600'
};

/**
* Info Events
*/
export enum InfoEventTypes {
clicked = 'clicked',
rendered = 'rendered'
}

export const ANALYTICS_ACTION_STR = 'action';
export const ANALYTICS_SUBMIT_STR = 'submit';
export const ANALYTICS_SELECTED_STR = 'selected';
6 changes: 6 additions & 0 deletions packages/lib/src/core/Analytics/types.ts
Original file line number Diff line number Diff line change
@@ -69,6 +69,7 @@ export interface AnalyticsObject {
component: string;
id: string;
code?: string;
infoType?: string;
errorType?: string;
message?: string;
type?: string;
@@ -171,4 +172,9 @@ export type CardConfigData = {
hasOnFocus: boolean;
hasOnLoad: boolean;
hasOnEnterKeyPressed: boolean;
/**
* Fastlane
*/
hasFastlaneConfigured?: boolean;
isFastlaneConsentDefaultOn?: boolean;
};
15 changes: 13 additions & 2 deletions packages/lib/src/core/Analytics/utils.ts
Original file line number Diff line number Diff line change
@@ -8,6 +8,7 @@ import { CardConfiguration } from '../../components/Card/types';
import CardInputDefaultProps from '../../components/Card/components/CardInput/defaultProps';
import { DEFAULT_CARD_GROUP_TYPES } from '../../components/internal/SecuredFields/lib/constants';
import { notFalsy } from '../../utils/commonUtils';
import { isConfigurationValid as isFastlaneComponentConfigValid } from '../../components/Card/components/Fastlane/utils/validate-configuration';

const MAX_LENGTH = 128;
export const getUTCTimestamp = () => Date.now();
@@ -98,6 +99,7 @@ export const getCardConfigData = (cardProps: CardConfiguration): CardConfigData
doBinLookup,
enableStoreDetails,
exposeExpiryDate,
fastlaneConfiguration,
forceCompat,
hasHolderName,
hideCVC,
@@ -133,14 +135,15 @@ export const getCardConfigData = (cardProps: CardConfiguration): CardConfigData

const riskEnabled = cardProps.modules?.risk?.enabled;

const isFastlaneConfigValid = isFastlaneComponentConfigValid(fastlaneConfiguration);

const billingAddressModeValue = cardProps.onAddressLookup ? 'lookup' : billingAddressMode;

let showKCPType: 'none' | 'auto' | 'atStart' = 'none';
if (configuration?.koreanAuthenticationRequired === true) {
showKCPType = countryCode?.toLowerCase() === 'kr' ? 'atStart' : 'auto';
}

// @ts-ignore commenting out props until endpoint is ready
const configData: CardConfigData = {
autoFocus,
...(billingAddressAllowedCountries?.length > 0 && {
@@ -192,7 +195,15 @@ export const getCardConfigData = (cardProps: CardConfiguration): CardConfigData
hasOnLoad: onLoad !== CardInputDefaultProps.onLoad,
// Card level props
hasOnBinLookup: !!onBinLookup,
hasOnEnterKeyPressed: !!onEnterKeyPressed
hasOnEnterKeyPressed: !!onEnterKeyPressed,
/**
* Fastlane
*/
// CHECK: Do we always append the prop even if it is not used? e.g. merchants not using fastlane
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what is the best here

Copy link
Contributor

@sponglord sponglord Mar 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For other, universal, props we want to know what config the merchant has ended up with, be it something they've set deliberately or a default that we've set for them.
But for something opt-in like Fastlane, we should only send it if it's relevant to their setup, imo

...(isFastlaneConfigValid && {
hasFastlaneConfigured: true,
isFastlaneConsentDefaultOn: fastlaneConfiguration.defaultToggleState
})
};

return configData;
9 changes: 8 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
@@ -6893,7 +6893,7 @@ is-negative-zero@^2.0.3:
resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.3.tgz#ced903a027aca6381b777a5743069d7376a49747"
integrity sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==

is-node-process@^1.2.0:
is-node-process@^1.0.1, is-node-process@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/is-node-process/-/is-node-process-1.2.0.tgz#ea02a1b90ddb3934a19aea414e88edef7e11d134"
integrity sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==
@@ -8216,6 +8216,13 @@ ms@2.1.3, ms@^2.1.1, ms@^2.1.3:
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==

msw-storybook-addon@^2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/msw-storybook-addon/-/msw-storybook-addon-2.0.4.tgz#ff1f583c95aef5f8c2014299f235e13cdd34dc1b"
integrity sha512-rstO8+r01sRMg6PPP7OxM8LG5/6r4+wmp2uapHeHvm9TQQRHvpPXOU/Y9/Somysz8Oi4Ea1aummXH3JlnP2LIA==
dependencies:
is-node-process "^1.0.1"

msw@^2.6.4:
version "2.7.0"
resolved "https://registry.yarnpkg.com/msw/-/msw-2.7.0.tgz#d13ff87f7e018fc4c359800ff72ba5017033fb56"
Loading
Oops, something went wrong.