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

Click to Pay - 3rd party cookies #2409

Merged
merged 16 commits into from
Dec 4, 2023
Merged
Show file tree
Hide file tree
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
5 changes: 5 additions & 0 deletions .changeset/lemon-emus-wait.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@adyen/adyen-web': minor
---

Click to Pay - Enabling MC/Visa to drop cookies if the shopper gives consent
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { loginValidationRules } from './validate';
import useCoreContext from '../../../../../core/Context/useCoreContext';
import useForm from '../../../../../utils/useForm';
import Field from '../../../FormFields/Field';
import InputText from '../../../FormFields/InputText';
import InputEmail from '../../../FormFields/InputEmail';

type OnChangeProps = { data: CtPLoginInputDataState; valid; errors; isValid: boolean };

Expand Down Expand Up @@ -73,7 +73,7 @@ const CtPLoginInput = (props: CtPLoginInputProps): h.JSX.Element => {
errorMessage={isLoginInputDirty ? props.errorMessage || !!errors.shopperLogin : null}
classNameModifiers={['shopperLogin']}
>
<InputText
<InputEmail
name={'shopperLogin'}
autocorrect={'off'}
spellcheck={false}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { h } from 'preact';
import { render, screen } from '@testing-library/preact';
import CoreProvider from '../../../../../core/Context/CoreProvider';
import ClickToPayProvider from '../../context/ClickToPayProvider';
import { IClickToPayService } from '../../services/types';
import { mock } from 'jest-mock-extended';
import { Resources } from '../../../../../core/Context/Resources';
import Language from '../../../../../language';
import userEvent from '@testing-library/user-event';
import CtPOneTimePassword from './CtPOneTimePassword';

const customRender = (ui, { clickToPayService = mock<IClickToPayService>(), configuration = {} } = {}) => {
return render(
<CoreProvider i18n={new Language()} loadingContext="test" resources={new Resources()}>
<ClickToPayProvider
clickToPayService={clickToPayService}
isStandaloneComponent={true}
amount={{ value: 5000, currency: 'USD' }}
onSetStatus={jest.fn()}
configuration={configuration}
onError={jest.fn()}
onSubmit={jest.fn()}
setClickToPayRef={jest.fn()}
>
{ui}
</ClickToPayProvider>
</CoreProvider>
);
};

describe('Click to Pay - CtPOneTimePassword', () => {
test('should set to store the cookie if shopper ticks the checkbox', async () => {
const user = userEvent.setup();
const ctpServiceMock = mock<IClickToPayService>();
ctpServiceMock.schemes = ['visa', 'mc'];
// const onResendCodeMock = jest.fn();

customRender(<CtPOneTimePassword />, { clickToPayService: ctpServiceMock });

// Default false
const checkbox = (await screen.findByLabelText('Skip verification next time')) as HTMLInputElement;
expect(checkbox.checked).toBe(false);

// Checked
await user.click(checkbox);
expect(checkbox.checked).toBe(true);
expect(ctpServiceMock.updateStoreCookiesConsent).toHaveBeenCalledWith(true);
expect(ctpServiceMock.updateStoreCookiesConsent).toHaveBeenCalledTimes(1);

// Unchecked
await user.click(checkbox);
expect(checkbox.checked).toBe(false);
expect(ctpServiceMock.updateStoreCookiesConsent).toHaveBeenCalledWith(false);
expect(ctpServiceMock.updateStoreCookiesConsent).toHaveBeenCalledTimes(2);
});

test('should pass OTP to ClickToPay service', async () => {
const user = userEvent.setup({ delay: 100 });
const ctpServiceMock = mock<IClickToPayService>();
ctpServiceMock.schemes = ['visa', 'mc'];

customRender(<CtPOneTimePassword />, { clickToPayService: ctpServiceMock });

await screen.findByLabelText('One time code', { exact: false });

await user.keyboard('654321');
await user.keyboard('[Enter]');

expect(ctpServiceMock.finishIdentityValidation).toHaveBeenCalledWith('654321');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { CtPInfo } from '../CtPInfo';
import CtPSection from '../CtPSection';
import useCoreContext from '../../../../../core/Context/useCoreContext';
import './CtPOneTimePassword.scss';
import CtPSaveCookiesCheckbox from './CtPSaveCookiesCheckbox';

type CtPOneTimePasswordProps = {
onDisplayCardComponent?(): void;
Expand Down Expand Up @@ -80,6 +81,9 @@ const CtPOneTimePassword = ({ onDisplayCardComponent }: CtPOneTimePasswordProps)
onResendCode={onResendCode}
isValidatingOtp={isValidatingOtp}
/>

<CtPSaveCookiesCheckbox />

<Button
disabled={isAccountLocked}
label={i18n.get('continue')}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,5 @@
}

.adyen-checkout-ctp__section > .adyen-checkout__field.adyen-checkout__field--otp {
margin-bottom: 20px;
margin-bottom: 12px;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
@import "../../../../../../style/index";

.adyen-checkout-ctp__otp-checkbox-container {
padding: 12px;
background-color: $color-gray-lighter;
border-radius: $border-radius-medium;
margin-bottom: 16px;
}

.adyen-checkout-ctp__otp-checkbox-container--checked {
background-color: #F2F8FF;
ribeiroguilherme marked this conversation as resolved.
Show resolved Hide resolved
}

.adyen-checkout-ctp__otp-checkbox-container label {
cursor: pointer;
}

.adyen-checkout-ctp__section .adyen-checkout__field.adyen-checkout-ctp__otp-checkbox-container {
margin-bottom: 4px;
}

.adyen-checkout-ctp__otp-checkbox-info {
color: $color-new-gray-darker;
font-size: 13px;
line-height: 19px;
font-weight: 400;
margin: 4px 0 0;
}

.adyen-checkout-ctp__otp-readmore-button {
all: unset;
text-transform: lowercase;
cursor: pointer;
color: $color-blue;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { h, Fragment } from 'preact';
import classnames from 'classnames';
import Field from '../../../../FormFields/Field';
import Checkbox from '../../../../FormFields/Checkbox';
import useCoreContext from '../../../../../../core/Context/useCoreContext';
import { useState, useCallback } from 'preact/hooks';
import useClickToPayContext from '../../../context/useClickToPayContext';
import isScreenSmall from '../../../../../../utils/isScreenSmall';
import './CtPSaveCookiesCheckbox.scss';

function CtPSaveCookiesCheckbox() {
const { i18n } = useCoreContext();
const { updateStoreCookiesConsent, isStoringCookies } = useClickToPayContext();
const [checked, setIsChecked] = useState(isStoringCookies);
const [isTextTruncated, setIsTextTruncated] = useState<boolean>(isScreenSmall());

const handleOnChange = useCallback(() => {
const newChecked = !checked;
setIsChecked(newChecked);
updateStoreCookiesConsent(newChecked);
}, [updateStoreCookiesConsent, setIsChecked, checked]);

return (
<div
className={classnames('adyen-checkout-ctp__otp-checkbox-container', {
'adyen-checkout-ctp__otp-checkbox-container--checked': checked
})}
>
<Field name={'clickToPayCookiesCheckbox'} addContextualElement={false} useLabelElement={false} i18n={i18n}>
<Checkbox
name={'clickToPayCookiesCheckbox'}
onInput={handleOnChange}
label={i18n.get('ctp.otp.saveCookiesCheckbox.label')}
checked={checked}
aria-describedby={'adyen-ctp-cookies-info'}
/>
</Field>

<p className="adyen-checkout-ctp__otp-checkbox-info">
{isTextTruncated ? (
<Fragment>
<span id="adyen-ctp-cookies-info">{i18n.get('ctp.otp.saveCookiesCheckbox.shorterInfo')} </span>
<button className="adyen-checkout-ctp__otp-readmore-button" onClick={() => setIsTextTruncated(false)}>
{i18n.get('readMore')}..
</button>
</Fragment>
) : (
<span id="adyen-ctp-cookies-info">{i18n.get('ctp.otp.saveCookiesCheckbox.information')}</span>
)}
</p>
</div>
);
}

export default CtPSaveCookiesCheckbox;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './CtPSaveCookiesCheckbox';
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
.adyen-checkout-ctp__section {
position: relative;
background-color: white;
box-shadow: 0 8px 24px rgb(0 0 0 / 15%);
box-shadow: 0 6px 12px rgb(0 17 44 / 8%), 0 2px 4px rgb(0 17 44 / 4%);
border-radius: 12px;
padding: 16px;

&-brand {
display: flex;
align-items: center;
height: 18px;
margin-bottom: 14px;
margin-bottom: 16px;
}

&--standalone {
Expand All @@ -31,8 +31,8 @@
font-size: 17px;
font-weight: 600;
line-height: 22px;
margin: 0 0 4px;
padding: 0;
margin: 0;
width: auto;

@media screen and (max-width: 400px) {
Expand All @@ -49,7 +49,7 @@
font-size: 13px;
font-weight: 400;
line-height: 19px;
color: $color-gray-darker;
color: $color-new-gray-darker;
margin: 0 0 16px;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ export interface IClickToPayContext
extends Pick<IClickToPayService, 'checkout' | 'startIdentityValidation' | 'finishIdentityValidation' | 'verifyIfShopperIsEnrolled'> {
isStandaloneComponent: boolean;
isCtpPrimaryPaymentMethod: boolean;
isStoringCookies: boolean;
setIsCtpPrimaryPaymentMethod(isPrimary: boolean): void;
logoutShopper(): Promise<void>;
updateStoreCookiesConsent(shouldStore: boolean): void;
ctpState: CtpState;
cards: ShopperCard[];
schemes: string[];
Expand All @@ -37,8 +39,10 @@ const ClickToPayContext = createContext<IClickToPayContext>({
configuration: null,
isStandaloneComponent: null,
isCtpPrimaryPaymentMethod: null,
isStoringCookies: false,
setIsCtpPrimaryPaymentMethod: null,
logoutShopper: null,
updateStoreCookiesConsent: null,
ctpState: null,
cards: [],
schemes: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,13 @@ const ClickToPayProvider = ({
await ctpService?.logout();
}, [ctpService]);

const updateStoreCookiesConsent = useCallback(
(shouldStore: boolean) => {
ctpService.updateStoreCookiesConsent(shouldStore);
},
[ctpService]
);

return (
<ClickToPayContext.Provider
value={{
Expand All @@ -99,6 +106,7 @@ const ClickToPayProvider = ({
onSetStatus,
amount,
configuration,
isStoringCookies: ctpService?.storeCookies,
isStandaloneComponent,
isCtpPrimaryPaymentMethod,
setIsCtpPrimaryPaymentMethod,
Expand All @@ -112,6 +120,7 @@ const ClickToPayProvider = ({
logoutShopper,
startIdentityValidation,
finishIdentityValidation,
updateStoreCookiesConsent,
onReady
}}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,94 @@ import { ISrcSdkLoader } from './sdks/SrcSdkLoader';
import VisaSdk from './sdks/VisaSdk';
import MastercardSdk from './sdks/MastercardSdk';
import { IdentityLookupParams, SchemesConfiguration } from './types';
import { SrciIdentityLookupResponse, SrcProfile } from './sdks/types';
import { SrciCheckoutResponse, SrciIdentityLookupResponse, SrcProfile } from './sdks/types';
import SrciError from './sdks/SrciError';
import ShopperCard from '../models/ShopperCard';

test('should be able to tweak the configuration to store the cookie', () => {
const visa = mock<VisaSdk>();
const schemesConfig = mock<SchemesConfiguration>();
const sdkLoader = mock<ISrcSdkLoader>();
sdkLoader.load.mockResolvedValue([visa]);

const service = new ClickToPayService(schemesConfig, sdkLoader, 'test');
expect(service.storeCookies).toBe(false);

service.updateStoreCookiesConsent(true);
expect(service.storeCookies).toBe(true);

service.updateStoreCookiesConsent(false);
expect(service.storeCookies).toBe(false);
});

test('should pass the complianceSettings if the cookie is set to be stored', async () => {
const checkoutResponseMock = mock<SrciCheckoutResponse>();
checkoutResponseMock.dcfActionCode = 'COMPLETE';

const profileFromVisaSrcSystem: SrcProfile = {
srcCorrelationId: '123456',
profiles: [
{
maskedCards: [
{
srcDigitalCardId: 'xxxx',
panLastFour: '8902',
dateOfCardLastUsed: '2019-09-28T08:10:02.312Z',
paymentCardDescriptor: 'visa',
panExpirationMonth: '12',
panExpirationYear: '2020',
digitalCardData: {
descriptorName: 'Visa',
artUri: 'https://image.com/visa'
},
tokenId: '9w8e8e'
}
]
}
]
};

const visa = mock<VisaSdk>();
// @ts-ignore Mocking readonly property
visa.schemeName = 'visa';
visa.checkout.mockResolvedValue(checkoutResponseMock);
visa.init.mockResolvedValue();
visa.isRecognized.mockResolvedValue({ recognized: true, idTokens: ['id-token'] });
visa.getSrcProfile.mockResolvedValue(profileFromVisaSrcSystem);

const sdkLoader = mock<ISrcSdkLoader>();
const schemesConfig = mock<SchemesConfiguration>();

const shopperCard = mock<ShopperCard>();
shopperCard.srcDigitalCardId = 'xxxx';
shopperCard.srcCorrelationId = 'zzzz';
shopperCard.scheme = 'visa';
Object.defineProperty(shopperCard, 'isDcfPopupEmbedded', {
get: jest.fn(() => false)
});

sdkLoader.load.mockResolvedValue([visa]);

const service = new ClickToPayService(schemesConfig, sdkLoader, 'test');
service.updateStoreCookiesConsent(true);

await service.initialize();
await service.checkout(shopperCard);

expect(visa.checkout).toHaveBeenCalledTimes(1);
expect(visa.checkout).toHaveBeenCalledWith({
complianceSettings: {
complianceResources: [
{
complianceType: 'REMEMBER_ME',
uri: ''
}
]
},
srcCorrelationId: 'zzzz',
srcDigitalCardId: 'xxxx'
});
});

test('should pass the correct configuration to the respective scheme SDKs', async () => {
const visa = mock<VisaSdk>();
Expand Down
Loading
Loading