diff --git a/packages/e2e-playwright/fixtures/URL_MAP.ts b/packages/e2e-playwright/fixtures/URL_MAP.ts index 8fa9b23d63..ce21e34609 100644 --- a/packages/e2e-playwright/fixtures/URL_MAP.ts +++ b/packages/e2e-playwright/fixtures/URL_MAP.ts @@ -19,6 +19,7 @@ export const URL_MAP = { cardWithInstallments: '/iframe.html?args=&id=cards-card--with-installments&viewMode=story', cardWithKcp: '/iframe.html?args=&globals=&id=cards-card--with-kcp&viewMode=story', cardWithClickToPay: '/iframe.html?args=&id=cards-card--with-click-to-pay&viewMode=story', + cardWithFastlane: '/iframe.html?args=&globals=&id=cards-card--with-mocked-fastlane&viewMode=story', fullAvsWithoutPrefilledDataUrl: '/iframe.html?args=componentConfiguration.data:!undefined&globals=&id=cards-card--with-avs&viewMode=story', fullAvsWithPrefilledDataUrl: '/iframe.html?globals=&args=&id=cards-card--with-avs&viewMode=story', addressLookupUrl: '/iframe.html?id=cards-card--with-avs-address-lookup&viewMode=story', diff --git a/packages/e2e-playwright/fixtures/card.fixture.ts b/packages/e2e-playwright/fixtures/card.fixture.ts index ceba785ad5..d153dc917f 100644 --- a/packages/e2e-playwright/fixtures/card.fixture.ts +++ b/packages/e2e-playwright/fixtures/card.fixture.ts @@ -1,16 +1,18 @@ import { test as base, expect } from '@playwright/test'; +import { URL_MAP } from './URL_MAP'; import { Card } from '../models/card'; import { BCMC } from '../models/bcmc'; -import { URL_MAP } from './URL_MAP'; import { CardWithAvs } from '../models/card-avs'; import { CardWithKCP } from '../models/card-kcp'; import { CardWithSSN } from '../models/card-ssn'; +import { CardWithFastlane } from '../models/card-fastlane'; type Fixture = { card: Card; cardWithAvs: CardWithAvs; cardWithKCP: CardWithKCP; cardWithSSN: CardWithSSN; + cardWithFastlane: CardWithFastlane; bcmc: BCMC; }; @@ -19,6 +21,10 @@ const test = base.extend({ const cardPage = new Card(page); await use(cardPage); }, + cardWithFastlane: async ({ page }, use) => { + const cardPage = new CardWithFastlane(page); + await use(cardPage); + }, cardWithAvs: async ({ page }, use) => { const cardPage = new CardWithAvs(page); await use(cardPage); diff --git a/packages/e2e-playwright/models/card-fastlane.ts b/packages/e2e-playwright/models/card-fastlane.ts new file mode 100644 index 0000000000..5a8e4e909c --- /dev/null +++ b/packages/e2e-playwright/models/card-fastlane.ts @@ -0,0 +1,27 @@ +import { Page } from '@playwright/test'; +import { Card } from './card'; +import { USER_TYPE_DELAY } from '../tests/utils/constants'; + +class CardWithFastlane extends Card { + constructor(page: Page) { + super(page); + } + + get fastlaneElement() { + return this.page.getByTestId('fastlane-signup-component'); + } + + get fastlaneSignupToggle() { + return this.fastlaneElement.getByRole('switch'); + } + + get mobileNumberInput() { + return this.fastlaneElement.getByLabel('Mobile number'); + } + + async typeMobileNumber(number: string) { + return this.mobileNumberInput.pressSequentially(number, { delay: USER_TYPE_DELAY }); + } +} + +export { CardWithFastlane }; diff --git a/packages/e2e-playwright/tests/e2e/card/fastlane.signup.spec.ts b/packages/e2e-playwright/tests/e2e/card/fastlane.signup.spec.ts new file mode 100644 index 0000000000..e2cffeb4c8 --- /dev/null +++ b/packages/e2e-playwright/tests/e2e/card/fastlane.signup.spec.ts @@ -0,0 +1,260 @@ +import { test, expect } from '../../../fixtures/card.fixture'; +import { MAESTRO_CARD, PAYMENT_RESULT, REGULAR_TEST_CARD, TEST_CVC_VALUE, TEST_DATE_VALUE, VISA_CARD } from '../../utils/constants'; +import { URL_MAP } from '../../../fixtures/URL_MAP'; +import { paymentSuccessfulMock } from '../../../mocks/payments/payments.mock'; +import { getStoryUrl } from '../../utils/getStoryUrl'; + +test.describe('Card - Fastlane Sign up', () => { + test.describe('when Fastlane SDK returns "showConsent: true"', () => { + test('#1 should shown consent UI only when Mastercard or Visa number is entered', async ({ cardWithFastlane, page }) => { + await paymentSuccessfulMock(page); + const paymentsRequestPromise = page.waitForRequest(request => request.url().includes('/payments') && request.method() === 'POST'); + + await cardWithFastlane.goto(URL_MAP.cardWithFastlane); + await expect(cardWithFastlane.fastlaneElement).not.toBeVisible(); + + // Start typing VISA card + await cardWithFastlane.typeCardNumber('4111'); + await expect(cardWithFastlane.fastlaneElement).toBeVisible(); + + await cardWithFastlane.deleteCardNumber(); + await expect(cardWithFastlane.fastlaneElement).not.toBeVisible(); + + // Start typing MC card + await cardWithFastlane.typeCardNumber('5454'); + await expect(cardWithFastlane.fastlaneElement).toBeVisible(); + + await cardWithFastlane.deleteCardNumber(); + await expect(cardWithFastlane.fastlaneElement).not.toBeVisible(); + + // Enter brand not supported by fastlame (MAESTRO) + await cardWithFastlane.typeCardNumber(MAESTRO_CARD); + await cardWithFastlane.typeCvc(TEST_CVC_VALUE); + await cardWithFastlane.typeExpiryDate(TEST_DATE_VALUE); + await expect(cardWithFastlane.fastlaneElement).not.toBeVisible(); + + await cardWithFastlane.pay(); + + const request = await paymentsRequestPromise; + const paymentMethod = await request.postDataJSON().paymentMethod; + expect(paymentMethod.fastlaneData).toBeDefined(); + + expect(JSON.parse(atob(paymentMethod.fastlaneData))).toEqual({ + consentShown: true, + consentVersion: 'v1', + consentGiven: false, + fastlaneSessionId: 'ABC-123' + }); + + await expect(cardWithFastlane.paymentResult).toContainText(PAYMENT_RESULT.authorised); + }); + + test('#2 should send consentGiven:true even if the mobile number input is empty', async ({ cardWithFastlane, page }) => { + await paymentSuccessfulMock(page); + const paymentsRequestPromise = page.waitForRequest(request => request.url().includes('/payments') && request.method() === 'POST'); + + await cardWithFastlane.goto(URL_MAP.cardWithFastlane); + await cardWithFastlane.typeCardNumber(REGULAR_TEST_CARD); + await cardWithFastlane.typeCvc(TEST_CVC_VALUE); + await cardWithFastlane.typeExpiryDate(TEST_DATE_VALUE); + await expect(cardWithFastlane.fastlaneElement).toBeVisible(); + + await cardWithFastlane.pay(); + + const request = await paymentsRequestPromise; + const paymentMethod = await request.postDataJSON().paymentMethod; + expect(paymentMethod.fastlaneData).toBeDefined(); + + expect(JSON.parse(atob(paymentMethod.fastlaneData))).toEqual({ + consentShown: true, + consentGiven: true, + consentVersion: 'v1', + fastlaneSessionId: 'ABC-123' + }); + + await expect(cardWithFastlane.paymentResult).toContainText(PAYMENT_RESULT.authorised); + }); + + test('#3 should send fastlane data even if the consent UI is not displayed at all', async ({ cardWithFastlane, page }) => { + await paymentSuccessfulMock(page); + const paymentsRequestPromise = page.waitForRequest(request => request.url().includes('/payments') && request.method() === 'POST'); + + await cardWithFastlane.goto(URL_MAP.cardWithFastlane); + await cardWithFastlane.typeCardNumber(MAESTRO_CARD); + await cardWithFastlane.typeCvc(TEST_CVC_VALUE); + await cardWithFastlane.typeExpiryDate(TEST_DATE_VALUE); + await expect(cardWithFastlane.fastlaneElement).not.toBeVisible(); + + await cardWithFastlane.pay(); + + const request = await paymentsRequestPromise; + const paymentMethod = await request.postDataJSON().paymentMethod; + expect(paymentMethod.fastlaneData).toBeDefined(); + + expect(JSON.parse(atob(paymentMethod.fastlaneData))).toEqual({ + consentShown: false, + consentGiven: false, + consentVersion: 'v1', + fastlaneSessionId: 'ABC-123' + }); + + await expect(cardWithFastlane.paymentResult).toContainText(PAYMENT_RESULT.authorised); + }); + + test('#4 should sign up passing the mobile number', async ({ cardWithFastlane, page }) => { + await paymentSuccessfulMock(page); + const paymentsRequestPromise = page.waitForRequest(request => request.url().includes('/payments') && request.method() === 'POST'); + + await cardWithFastlane.goto(URL_MAP.cardWithFastlane); + await cardWithFastlane.typeCardNumber(REGULAR_TEST_CARD); + await cardWithFastlane.typeCvc(TEST_CVC_VALUE); + await cardWithFastlane.typeExpiryDate(TEST_DATE_VALUE); + + const telephoneNumber = '8001005000'; + await cardWithFastlane.typeMobileNumber(telephoneNumber); + + await cardWithFastlane.pay(); + + const request = await paymentsRequestPromise; + const paymentMethod = await request.postDataJSON().paymentMethod; + expect(paymentMethod.fastlaneData).toBeDefined(); + + expect(JSON.parse(atob(paymentMethod.fastlaneData))).toEqual({ + consentShown: true, + consentGiven: true, + consentVersion: 'v1', + fastlaneSessionId: 'ABC-123', + telephoneNumber + }); + + await expect(cardWithFastlane.paymentResult).toContainText(PAYMENT_RESULT.authorised); + }); + }); + + test.describe('when Fastlane SDK returns "showConsent: false"', () => { + test('#1 should send fastlaneData even though the sign up UI is not displayed', async ({ cardWithFastlane, page }) => { + await paymentSuccessfulMock(page); + const paymentsRequestPromise = page.waitForRequest(request => request.url().includes('/payments') && request.method() === 'POST'); + + await cardWithFastlane.goto( + getStoryUrl({ + baseUrl: URL_MAP.cardWithFastlane, + componentConfig: { + fastlaneConfiguration: { + showConsent: false, + defaultToggleState: false, + termsAndConditionsLink: 'https://adyen.com', + privacyPolicyLink: 'https://adyen.com', + termsAndConditionsVersion: 'v1', + fastlaneSessionId: 'ABC-123' + } + } + }) + ); + + await cardWithFastlane.typeCardNumber(REGULAR_TEST_CARD); + await cardWithFastlane.typeCvc(TEST_CVC_VALUE); + await cardWithFastlane.typeExpiryDate(TEST_DATE_VALUE); + + await expect(cardWithFastlane.fastlaneElement).not.toBeVisible(); + + await cardWithFastlane.pay(); + + const request = await paymentsRequestPromise; + const paymentMethod = await request.postDataJSON().paymentMethod; + expect(paymentMethod.fastlaneData).toBeDefined(); + + expect(JSON.parse(atob(paymentMethod.fastlaneData))).toEqual({ + consentShown: false, + consentGiven: false, + consentVersion: 'v1', + fastlaneSessionId: 'ABC-123' + }); + + await expect(cardWithFastlane.paymentResult).toContainText(PAYMENT_RESULT.authorised); + }); + }); + + test.describe('when Fastlane configuration is not passed to the Card component', () => { + test('#1 should not add fastlaneData to the payments request', async ({ cardWithFastlane, page }) => { + await paymentSuccessfulMock(page); + const paymentsRequestPromise = page.waitForRequest(request => request.url().includes('/payments') && request.method() === 'POST'); + + await cardWithFastlane.goto( + getStoryUrl({ + baseUrl: URL_MAP.cardWithFastlane, + componentConfig: {} + }) + ); + + await cardWithFastlane.typeCardNumber(REGULAR_TEST_CARD); + await cardWithFastlane.typeCvc(TEST_CVC_VALUE); + await cardWithFastlane.typeExpiryDate(TEST_DATE_VALUE); + + await expect(cardWithFastlane.fastlaneElement).not.toBeVisible(); + + await cardWithFastlane.pay(); + + const request = await paymentsRequestPromise; + const paymentMethod = await request.postDataJSON().paymentMethod; + expect(paymentMethod.fastlaneData).toBeUndefined(); + + await expect(cardWithFastlane.paymentResult).toContainText(PAYMENT_RESULT.authorised); + }); + + test('#2 should not show Fastlane signup interface for the supported brands', async ({ cardWithFastlane, page }) => { + await cardWithFastlane.goto( + getStoryUrl({ + baseUrl: URL_MAP.cardWithFastlane, + componentConfig: {} + }) + ); + + await cardWithFastlane.typeCardNumber(REGULAR_TEST_CARD); + await cardWithFastlane.typeCvc(TEST_CVC_VALUE); + await cardWithFastlane.typeExpiryDate(TEST_DATE_VALUE); + await expect(cardWithFastlane.fastlaneElement).not.toBeVisible(); + + await cardWithFastlane.deleteCardNumber(); + + await cardWithFastlane.typeCardNumber(VISA_CARD); + await expect(cardWithFastlane.fastlaneElement).not.toBeVisible(); + }); + }); + + test.describe('when Fastlane configuration object is not valid', () => { + test('#1 should not add fastlaneData to the payments request', async ({ cardWithFastlane, page }) => { + await paymentSuccessfulMock(page); + const paymentsRequestPromise = page.waitForRequest(request => request.url().includes('/payments') && request.method() === 'POST'); + + // Omitted 'showConsent' and 'defaultToggleState' + await cardWithFastlane.goto( + getStoryUrl({ + baseUrl: URL_MAP.cardWithFastlane, + componentConfig: { + fastlaneConfiguration: { + termsAndConditionsLink: 'https://adyen.com', + privacyPolicyLink: 'https://adyen.com', + termsAndConditionsVersion: 'v1', + fastlaneSessionId: 'ABC-123' + } + } + }) + ); + + await cardWithFastlane.typeCardNumber(REGULAR_TEST_CARD); + await cardWithFastlane.typeCvc(TEST_CVC_VALUE); + await cardWithFastlane.typeExpiryDate(TEST_DATE_VALUE); + + await expect(cardWithFastlane.fastlaneElement).not.toBeVisible(); + + await cardWithFastlane.pay(); + + const request = await paymentsRequestPromise; + const paymentMethod = await request.postDataJSON().paymentMethod; + expect(paymentMethod.fastlaneData).toBeUndefined(); + + await expect(cardWithFastlane.paymentResult).toContainText(PAYMENT_RESULT.authorised); + }); + }); +}); diff --git a/packages/lib/.size-limit.cjs b/packages/lib/.size-limit.cjs index b0ba0b52f9..c73ff3d950 100644 --- a/packages/lib/.size-limit.cjs +++ b/packages/lib/.size-limit.cjs @@ -6,7 +6,7 @@ module.exports = [ name: 'UMD', path: 'dist/umd/adyen.js', limit: '110 KB', - running: false, + running: false }, /** * 'auto' bundle with all Components included, excluding Languages @@ -14,9 +14,9 @@ module.exports = [ { name: 'Auto', path: 'auto/auto.js', - import: "{ AdyenCheckout, Dropin }", - limit: '110 KB', - running: false, + import: '{ AdyenCheckout, Dropin }', + limit: '115 KB', + running: false }, /** * ES modules (tree-shake) @@ -24,22 +24,22 @@ module.exports = [ { name: 'ESM - Core', path: 'dist/es/index.js', - import: "{ AdyenCheckout }", + import: '{ AdyenCheckout }', limit: '30 KB', - running: false, + running: false }, { name: 'ESM - Core + Card', path: 'dist/es/index.js', - import: "{ AdyenCheckout, Card }", + import: '{ AdyenCheckout, Card }', limit: '65 KB', - running: false, + running: false }, { name: 'ESM - Core + Dropin with Card', path: 'dist/es/index.js', - import: "{ AdyenCheckout, Dropin, Card }", + import: '{ AdyenCheckout, Dropin, Card }', limit: '70 KB', - running: false, - }, -] + running: false + } +]; diff --git a/packages/lib/src/components/Card/Card.tsx b/packages/lib/src/components/Card/Card.tsx index 4d1886406f..2e844b2e4e 100644 --- a/packages/lib/src/components/Card/Card.tsx +++ b/packages/lib/src/components/Card/Card.tsx @@ -154,7 +154,8 @@ export class CardElement extends UIElement { holderName: this.props.holderName ?? '' }), ...(cardBrand && { brand: cardBrand }), - ...(this.props.fundingSource && { fundingSource: this.props.fundingSource }) + ...(this.props.fundingSource && { fundingSource: this.props.fundingSource }), + ...(this.state.fastlaneData && { fastlaneData: btoa(JSON.stringify(this.state.fastlaneData)) }) }, ...(this.state.billingAddress && { billingAddress: this.state.billingAddress }), ...(this.state.socialSecurityNumber && { socialSecurityNumber: this.state.socialSecurityNumber }), diff --git a/packages/lib/src/components/Card/components/CardInput/CardInput.tsx b/packages/lib/src/components/Card/components/CardInput/CardInput.tsx index 95b1bd8b8e..2d591a47a7 100644 --- a/packages/lib/src/components/Card/components/CardInput/CardInput.tsx +++ b/packages/lib/src/components/Card/components/CardInput/CardInput.tsx @@ -28,6 +28,7 @@ import { CbObjOnBrand, CbObjOnFocus } from '../../../internal/SecuredFields/lib/ import { FieldErrorAnalyticsObject } from '../../../../core/Analytics/types'; import { PREFIX } from '../../../internal/Icon/constants'; import useSRPanelForCardInputErrors from './useSRPanelForCardInputErrors'; +import FastlaneSignup from '../Fastlane/FastlaneSignup'; const CardInput = (props: CardInputProps) => { const sfp = useRef(null); @@ -98,7 +99,6 @@ const CardInput = (props: CardInputProps) => { * if the PAN length drops below the /binLookup digit threshold. * Default value, 'card', indicates no brand detected */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars const [internallyDetectedBrand, setInternallyDetectedBrand] = useState('card'); /** @@ -480,6 +480,11 @@ const CardInput = (props: CardInputProps) => { )} /> + + {props.fastlaneConfiguration && ( + + )} + {props.showPayButton && props.payButton({ status, diff --git a/packages/lib/src/components/Card/components/CardInput/types.ts b/packages/lib/src/components/Card/components/CardInput/types.ts index a1f8ad00ce..7cca0040fe 100644 --- a/packages/lib/src/components/Card/components/CardInput/types.ts +++ b/packages/lib/src/components/Card/components/CardInput/types.ts @@ -25,6 +25,7 @@ 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 type { FastlaneSignupConfiguration } from '../../../PayPalFastlane/types'; export interface CardInputValidState { holderName?: boolean; @@ -92,6 +93,7 @@ export interface CardInputProps { enableStoreDetails?: boolean; expiryMonth?: string; expiryYear?: string; + fastlaneConfiguration?: FastlaneSignupConfiguration; forceCompat?: boolean; fundingSource?: 'debit' | 'credit'; hasCVC?: boolean; diff --git a/packages/lib/src/components/Card/components/Fastlane/FastlaneSignup.scss b/packages/lib/src/components/Card/components/Fastlane/FastlaneSignup.scss new file mode 100644 index 0000000000..add14b4fec --- /dev/null +++ b/packages/lib/src/components/Card/components/Fastlane/FastlaneSignup.scss @@ -0,0 +1,42 @@ +@use 'styles/mixins'; +@import 'styles/variable-generator'; + +.adyen-checkout-card__fastlane { + background: token(color-background-primary); + border: token(border-width-s) solid token(color-outline-primary); + border-radius: token(border-radius-m); + padding: token(spacer-060) token(spacer-070); + margin-top: token(spacer-070); + align-items: center; + + [dir='rtl'] & { + padding: token(spacer-060) token(spacer-070); + } + + &-consent-toggle { + display: flex; + gap: token(spacer-040); + + &--active { + margin-bottom: token(spacer-070); + } + } + + &-consent-text { + margin-bottom: token(spacer-070); + + @include mixins.adyen-checkout-text-caption; + } + + &-brand { + width: 168px; + height: 23px; + } +} + +.adyen-checkout__button.adyen-checkout__button--fastlane-info-modal { + width: 20px; + height: 20px; + line-height: 0; + padding: 0; +} diff --git a/packages/lib/src/components/Card/components/Fastlane/FastlaneSignup.test.tsx b/packages/lib/src/components/Card/components/Fastlane/FastlaneSignup.test.tsx new file mode 100644 index 0000000000..5d3e6218e8 --- /dev/null +++ b/packages/lib/src/components/Card/components/Fastlane/FastlaneSignup.test.tsx @@ -0,0 +1,172 @@ +import { h } from 'preact'; +import { render, screen } from '@testing-library/preact'; +import { CoreProvider } from '../../../../core/Context/CoreProvider'; +import FastlaneSignup from './FastlaneSignup'; +import type { FastlaneSignupConfiguration } from '../../../PayPalFastlane/types'; +import userEvent from '@testing-library/user-event'; + +const customRender = ui => { + return render( + + {ui} + + ); +}; + +test('should trigger onChange even if the consent UI is not allowed to be shown', () => { + const fastlaneConfiguration: FastlaneSignupConfiguration = { + showConsent: false, + defaultToggleState: false, + termsAndConditionsLink: 'https://adyen.com', + termsAndConditionsVersion: 'v1', + privacyPolicyLink: 'https://adyen.com', + fastlaneSessionId: 'xxx-bbb' + }; + + const onChangeMock = jest.fn(); + + customRender(); + + expect(onChangeMock).toHaveBeenCalledTimes(1); + expect(onChangeMock.mock.calls[0][0]).toEqual({ + fastlaneData: { + consentGiven: false, + consentShown: false, + consentVersion: 'v1', + fastlaneSessionId: 'xxx-bbb' + } + }); +}); + +test('should send "consentShown:true" flag if the shopper saw the consent UI at least once', async () => { + const user = userEvent.setup(); + + const fastlaneConfiguration: FastlaneSignupConfiguration = { + showConsent: true, + defaultToggleState: false, + termsAndConditionsLink: 'https://adyen.com', + termsAndConditionsVersion: 'v1', + privacyPolicyLink: 'https://adyen.com', + fastlaneSessionId: 'xxx-bbb' + }; + + const onChangeMock = jest.fn(); + + customRender(); + + // Show the UI + await user.click(screen.getByRole('switch')); + expect(screen.getByLabelText('Mobile number')).toBeVisible(); + + // Hide the UI + await user.click(screen.getByRole('switch')); + expect(screen.queryByText('Mobile number')).toBeNull(); + + expect(onChangeMock).lastCalledWith({ + fastlaneData: { + consentGiven: false, + consentShown: true, + consentVersion: 'v1', + fastlaneSessionId: 'xxx-bbb' + } + }); +}); + +test('should return phone number formatted (without spaces and without prefix)', async () => { + const user = userEvent.setup(); + + const fastlaneConfiguration: FastlaneSignupConfiguration = { + showConsent: true, + defaultToggleState: true, + termsAndConditionsLink: 'https://adyen.com', + termsAndConditionsVersion: 'v1', + privacyPolicyLink: 'https://adyen.com', + fastlaneSessionId: 'xxx-bbb' + }; + + const onChangeMock = jest.fn(); + + customRender(); + + const input = screen.getByLabelText('Mobile number'); + + await user.click(input); + await user.keyboard('8005550199'); + + expect(onChangeMock).lastCalledWith({ + fastlaneData: { + consentGiven: true, + consentShown: true, + consentVersion: 'v1', + fastlaneSessionId: 'xxx-bbb', + telephoneNumber: '8005550199' + } + }); +}); + +test('should display terms and privacy statement links', () => { + const fastlaneConfiguration: FastlaneSignupConfiguration = { + showConsent: true, + defaultToggleState: true, + termsAndConditionsLink: 'https://fastlane.com/terms', + termsAndConditionsVersion: 'v1', + privacyPolicyLink: 'https://fastlane.com/privacy-policy', + fastlaneSessionId: 'xxx-bbb' + }; + + const onChangeMock = jest.fn(); + + customRender(); + + 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'); +}); + +test('should open Fastlane info dialog and close it', async () => { + const user = userEvent.setup(); + + const fastlaneConfiguration: FastlaneSignupConfiguration = { + showConsent: true, + defaultToggleState: true, + termsAndConditionsLink: 'https://fastlane.com/terms', + termsAndConditionsVersion: 'v1', + privacyPolicyLink: 'https://fastlane.com/privacy-policy', + fastlaneSessionId: 'xxx-bbb' + }; + + const onChangeMock = jest.fn(); + + customRender(); + + screen.getByRole('dialog', { hidden: true }); + + const dialogButton = screen.getByRole('button', { name: /read more/i }); + await user.click(dialogButton); + screen.getByRole('dialog', { hidden: false }); + + const closeDialogButton = screen.getByRole('button', { name: /close dialog/i }); + await user.click(closeDialogButton); + screen.getByRole('dialog', { hidden: true }); +}); + +test('should not render the UI if there are missing configuration fields', () => { + // @ts-ignore Testing misconfigured component + const fastlaneConfiguration: FastlaneSignupConfiguration = { + showConsent: true, + defaultToggleState: true, + termsAndConditionsLink: 'http://invalidlink.com', + privacyPolicyLink: 'https://fastlane.com/privacy-policy' + }; + + const onChangeMock = jest.fn(); + + const consoleMock = jest.fn(); + jest.spyOn(console, 'warn').mockImplementation(consoleMock); + + const { container } = customRender(); + + expect(consoleMock).toHaveBeenCalledTimes(1); + expect(consoleMock).toHaveBeenCalledWith('Fastlane: Component configuration is not valid. Fastlane will not be displayed'); + + expect(container).toBeEmptyDOMElement(); +}); diff --git a/packages/lib/src/components/Card/components/Fastlane/FastlaneSignup.tsx b/packages/lib/src/components/Card/components/Fastlane/FastlaneSignup.tsx new file mode 100644 index 0000000000..e26300d984 --- /dev/null +++ b/packages/lib/src/components/Card/components/Fastlane/FastlaneSignup.tsx @@ -0,0 +1,116 @@ +import { Fragment, h } from 'preact'; +import { useEffect, useMemo, useState } from 'preact/hooks'; +import cx from 'classnames'; +import Toggle from '../../../internal/Toggle'; +import Img from '../../../internal/Img'; +import useImage from '../../../../core/Context/useImage'; +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 './FastlaneSignup.scss'; + +type FastlaneSignupProps = FastlaneSignupConfiguration & { + currentDetectedBrand: string; + onChange(state: any): void; +}; + +const SUPPORTED_BRANDS = ['mc', 'visa']; + +const FastlaneSignup = ({ + showConsent, + defaultToggleState, + termsAndConditionsLink, + privacyPolicyLink, + termsAndConditionsVersion, + fastlaneSessionId, + currentDetectedBrand, + onChange +}: FastlaneSignupProps) => { + const displaySignup = useMemo(() => showConsent && SUPPORTED_BRANDS.includes(currentDetectedBrand), [showConsent, currentDetectedBrand]); + const [consentShown, setConsentShown] = useState(displaySignup); + const [isChecked, setIsChecked] = useState(defaultToggleState); + const getImage = useImage(); + const [telephoneNumber, setTelephoneNumber] = useState(''); + const { i18n } = useCoreContext(); + + const isFastlaneConfigurationValid = useMemo(() => { + // TODO: Check with PayPal. If showConsent is false, do we get privacyLink, t&c link, version, etc? + return isConfigurationValid({ + showConsent, + defaultToggleState, + termsAndConditionsLink, + privacyPolicyLink, + termsAndConditionsVersion, + fastlaneSessionId + }); + }, [showConsent, defaultToggleState, termsAndConditionsLink, privacyPolicyLink, termsAndConditionsVersion, fastlaneSessionId]); + + useEffect(() => { + if (!isFastlaneConfigurationValid) { + return; + } + + onChange({ + fastlaneData: { + consentShown, + consentGiven: displaySignup ? isChecked : false, + consentVersion: termsAndConditionsVersion, + fastlaneSessionId: fastlaneSessionId, + ...(telephoneNumber && { telephoneNumber }) + } + }); + }, [ + displaySignup, + consentShown, + termsAndConditionsVersion, + isChecked, + fastlaneSessionId, + telephoneNumber, + onChange, + isFastlaneConfigurationValid + ]); + + useEffect(() => { + if (displaySignup) setConsentShown(true); + }, [displaySignup]); + + if (!displaySignup || !isFastlaneConfigurationValid) { + return null; + } + + return ( +
+
+ + +
+ + {isChecked && ( + + +
+ +
+ {i18n.get('card.fastlane.a11y.logo')} +
+ )} +
+ ); +}; + +export default FastlaneSignup; diff --git a/packages/lib/src/components/Card/components/Fastlane/InfoButton.tsx b/packages/lib/src/components/Card/components/Fastlane/InfoButton.tsx new file mode 100644 index 0000000000..3eeb26eede --- /dev/null +++ b/packages/lib/src/components/Card/components/Fastlane/InfoButton.tsx @@ -0,0 +1,40 @@ +import { Fragment, h } from 'preact'; +import { useCallback, useRef, useState } from 'preact/hooks'; +import { useCoreContext } from '../../../../core/Context/CoreProvider'; +import useImage from '../../../../core/Context/useImage'; +import { InfoModal } from './InfoModal'; +import Img from '../../../internal/Img'; +import Button from '../../../internal/Button'; + +const InfoButton = () => { + const [isInfoModalOpen, setIsInfoModalOpen] = useState(false); + const { i18n } = useCoreContext(); + const getImage = useImage(); + const buttonRef = useRef(); + + const handleOnClose = useCallback(() => { + setIsInfoModalOpen(false); + }, []); + + const handleOnIconClick = useCallback(() => { + setIsInfoModalOpen(true); + }, []); + + return ( + + diff --git a/packages/lib/src/components/internal/Button/types.ts b/packages/lib/src/components/internal/Button/types.ts index bc9c5ee82f..47dc6c5e8f 100644 --- a/packages/lib/src/components/internal/Button/types.ts +++ b/packages/lib/src/components/internal/Button/types.ts @@ -1,3 +1,5 @@ +import { h, Ref } from 'preact'; + export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'action' | 'link'; export interface ButtonProps { @@ -8,7 +10,8 @@ export interface ButtonProps { classNameModifiers?: string[]; variant?: ButtonVariant; disabled?: boolean; - label?: string; + label?: string | h.JSX.Element; + ariaLabel?: string; secondaryLabel?: string; icon?: string; inline?: boolean; @@ -17,6 +20,7 @@ export interface ButtonProps { rel?: string; onClick?: (e, callbacks) => void; onKeyDown?: (event: KeyboardEvent) => void; + buttonRef?: Ref; } export interface ButtonState { diff --git a/packages/lib/src/components/internal/DisclaimerMessage/DisclaimerMessage.tsx b/packages/lib/src/components/internal/DisclaimerMessage/DisclaimerMessage.tsx index ef03684699..5c96723d74 100644 --- a/packages/lib/src/components/internal/DisclaimerMessage/DisclaimerMessage.tsx +++ b/packages/lib/src/components/internal/DisclaimerMessage/DisclaimerMessage.tsx @@ -2,6 +2,7 @@ import { Fragment, h } from 'preact'; import { isValidHttpUrl } from '../../../utils/isValidURL'; import './DisclaimerMessage.scss'; import { interpolateElement } from '../../../language/utils'; +import Link from '../Link'; export interface DisclaimerMsgObject { message: string; @@ -43,11 +44,7 @@ export function LabelOnlyDisclaimerMessage({ message, urls }: InternalDisclaimer // for each URL in the URLs array, return a createLink function url => function createLink(translation) { - return ( - - {translation} - - ); + return {translation}; } ) )} diff --git a/packages/lib/src/components/internal/FormFields/Field/Field.scss b/packages/lib/src/components/internal/FormFields/Field/Field.scss index d129a78c4c..75fb07343c 100644 --- a/packages/lib/src/components/internal/FormFields/Field/Field.scss +++ b/packages/lib/src/components/internal/FormFields/Field/Field.scss @@ -1,3 +1,4 @@ +@use 'styles/mixins'; @import 'styles/variable-generator'; @mixin input-wrapper-inactive { @@ -10,6 +11,18 @@ margin-bottom: token(spacer-070); width: 100%; + &-static-value { + background-color: token(color-background-primary-hover); + border-radius: token(border-radius-s); + color: token(color-background-inverse-primary); + display: flex; + height: 28px; + padding: token(spacer-020) token(spacer-030); + margin-left: token(spacer-020); + + @include mixins.adyen-checkout-text-body; + } + &--no-borders { .adyen-checkout__input-wrapper { box-shadow: none; diff --git a/packages/lib/src/components/internal/FormFields/Field/Field.tsx b/packages/lib/src/components/internal/FormFields/Field/Field.tsx index 8e49383b4e..ca43e9c5ba 100644 --- a/packages/lib/src/components/internal/FormFields/Field/Field.tsx +++ b/packages/lib/src/components/internal/FormFields/Field/Field.tsx @@ -3,14 +3,14 @@ import { cloneElement, ComponentChild, Fragment, FunctionalComponent, h, toChild import Spinner from '../../Spinner'; import Icon from '../../Icon'; import { ARIA_CONTEXT_SUFFIX, ARIA_ERROR_SUFFIX } from '../../../../core/Errors/constants'; -import { useCallback, useRef, useState } from 'preact/hooks'; +import { useCallback, useMemo, useRef, useState } from 'preact/hooks'; import { getUniqueId } from '../../../../utils/idGenerator'; import { FieldProps } from './types'; import './Field.scss'; import { PREFIX } from '../../Icon/constants'; +import uuid from '../../../../utils/uuid'; const Field: FunctionalComponent = props => { - // const { children, className, @@ -36,13 +36,15 @@ const Field: FunctionalComponent = props => { useLabelElement, showErrorElement, showContextualElement, + staticValue, contextualText, // Redeclare prop names to avoid internal clashes filled: propsFilled, focused: propsFocused, i18n, contextVisibleToScreenReader, - renderAlternativeToLabel + renderAlternativeToLabel, + onInputContainerClick } = props; // Controls whether any error element has an aria-hidden="true" attr (which means it is the error for a securedField) @@ -52,6 +54,8 @@ const Field: FunctionalComponent = props => { const showContext = showContextualElement && !showError && contextualText?.length > 0; const uniqueId = useRef(getUniqueId(`adyen-checkout-${name}`)); + const staticValueId = useMemo(() => (staticValue ? `input-static-value-${uuid()}` : null), [staticValue]); + const [focused, setFocused] = useState(false); const [filled, setFilled] = useState(false); @@ -126,23 +130,34 @@ const Field: FunctionalComponent = props => { return ( + {/* The
element has a child element that allows keyboard interaction */} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
`adyen-checkout__input-wrapper--${m}`) ])} dir={dir} + onClick={onInputContainerClick} > + {staticValue && ( + + {staticValue} + + )} + {toChildArray(children).map((child: ComponentChild): ComponentChild => { - const childProps = { + const propsFromFieldComponent = { isValid, onFocusHandler, onBlurHandler, isInvalid: !!errorMessage, + 'aria-owns': staticValueId, ...(name && { uniqueId: uniqueId.current }), showErrorElement: showErrorElement }; - return cloneElement(child as VNode, childProps); + + return cloneElement(child as VNode, propsFromFieldComponent); })} {isLoading && ( diff --git a/packages/lib/src/components/internal/FormFields/Field/types.ts b/packages/lib/src/components/internal/FormFields/Field/types.ts index 7fa5504c78..82dea08016 100644 --- a/packages/lib/src/components/internal/FormFields/Field/types.ts +++ b/packages/lib/src/components/internal/FormFields/Field/types.ts @@ -26,10 +26,15 @@ export interface FieldProps { onFieldBlur?; dir?; showValidIcon?: boolean; + staticValue?: string | ComponentChildren; useLabelElement?: boolean; i18n?: Language; contextVisibleToScreenReader?: boolean; renderAlternativeToLabel?: (defaultWrapperProps, children, uniqueId) => any; + /** + * Callback that reports when there is a click on the input field parent container + */ + onInputContainerClick?(): void; } export interface FieldState { diff --git a/packages/lib/src/styles/link.scss b/packages/lib/src/components/internal/Link/Link.scss similarity index 73% rename from packages/lib/src/styles/link.scss rename to packages/lib/src/components/internal/Link/Link.scss index c7b0777cf6..0d8b8c3ad3 100644 --- a/packages/lib/src/styles/link.scss +++ b/packages/lib/src/components/internal/Link/Link.scss @@ -1,5 +1,6 @@ -@use 'mixins'; +@use 'styles/mixins'; .adyen-checkout-link { @include mixins.adyen-checkout-link; } + diff --git a/packages/lib/src/components/internal/Link/Link.tsx b/packages/lib/src/components/internal/Link/Link.tsx new file mode 100644 index 0000000000..1eccf6f3d5 --- /dev/null +++ b/packages/lib/src/components/internal/Link/Link.tsx @@ -0,0 +1,23 @@ +import { h } from 'preact'; +import type { ComponentChildren } from 'preact'; + +import './Link.scss'; + +/** + * Disclaimer: we don't follow Bento's design for Links. Checkout has its own colors + */ + +interface LinkProps { + to: string; + children?: ComponentChildren; +} + +const Link = ({ to, children }: LinkProps) => { + return ( + + {children} + + ); +}; + +export default Link; diff --git a/packages/lib/src/components/internal/Link/index.ts b/packages/lib/src/components/internal/Link/index.ts new file mode 100644 index 0000000000..241046084c --- /dev/null +++ b/packages/lib/src/components/internal/Link/index.ts @@ -0,0 +1 @@ +export { default } from './Link'; diff --git a/packages/lib/src/components/internal/UIElement/UIElement.scss b/packages/lib/src/components/internal/UIElement/UIElement.scss index ca5b904227..a5ccf7cc4e 100644 --- a/packages/lib/src/components/internal/UIElement/UIElement.scss +++ b/packages/lib/src/components/internal/UIElement/UIElement.scss @@ -1,6 +1,5 @@ /* Shared css variables and styles are imported once here at the root level for all UI components. */ @use 'styles/index'; -@import 'styles/link'; [class^='adyen-checkout'] { @include index.box-sizing-setter(true); diff --git a/packages/lib/storybook/stories/cards/Card.stories.tsx b/packages/lib/storybook/stories/cards/Card.stories.tsx index a729ec8eb6..1f31755be6 100644 --- a/packages/lib/storybook/stories/cards/Card.stories.tsx +++ b/packages/lib/storybook/stories/cards/Card.stories.tsx @@ -5,6 +5,7 @@ import { CardWith3DS2Redirect } from './cardStoryHelpers/CardWith3DS2Redirect'; import { createStoredCardComponent } from './cardStoryHelpers/createStoredCardComponent'; import { createCardComponent } from './cardStoryHelpers/createCardComponent'; import { getComponentConfigFromUrl } from '../../utils/get-configuration-from-url'; +import { h } from 'preact'; type CardStory = StoryConfiguration; @@ -143,6 +144,22 @@ export const WithKCP: CardStory = { } }; +export const WithMockedFastlane: CardStory = { + render: createCardComponent, + args: { + componentConfiguration: getComponentConfigFromUrl() ?? { + fastlaneConfiguration: { + showConsent: true, + defaultToggleState: true, + termsAndConditionsLink: 'https://adyen.com', + privacyPolicyLink: 'https://adyen.com', + termsAndConditionsVersion: 'v1', + fastlaneSessionId: 'ABC-123' + } + } + } +}; + export const WithClickToPay: CardStory = { render: createCardComponent, args: { diff --git a/packages/lib/storybook/stories/wallets/Fastlane/Fastlane.stories.tsx b/packages/lib/storybook/stories/wallets/Fastlane/Fastlane.stories.tsx index 716edb941e..964ce42ece 100644 --- a/packages/lib/storybook/stories/wallets/Fastlane/Fastlane.stories.tsx +++ b/packages/lib/storybook/stories/wallets/Fastlane/Fastlane.stories.tsx @@ -6,7 +6,7 @@ import { ComponentContainer } from '../../ComponentContainer'; import Dropin from '../../../../src/components/Dropin/Dropin'; import Card from '../../../../src/components/Card/Card'; import PayPal from '../../../../src/components/PayPal/Paypal'; -import Fastlane from '../../../../src/components/PayPalFastlane'; +import Fastlane from '../../../../src/components/PayPalFastlane/Fastlane'; import { Checkout } from '../../Checkout'; type FastlaneStory = StoryConfiguration<{}>; @@ -15,7 +15,7 @@ const meta: MetaConfiguration = { title: 'Wallets/Fastlane' }; -export const Default: FastlaneStory = { +export const Lookup: FastlaneStory = { render: checkoutConfig => { const paymentMethodsOverride = { paymentMethods: [ @@ -41,7 +41,52 @@ export const Default: FastlaneStory = { } }; -export const WithMockedRecognizedFlow: FastlaneStory = { +export const MockedUnrecognizedFlowDropin: FastlaneStory = { + render: checkoutConfig => { + const paymentMethodsOverride = { + paymentMethods: [ + { + type: 'scheme', + name: 'Cards', + brands: ['mc', 'visa'] + } + ] + }; + + return ( + + {checkout => ( + + )} + + ); + } +}; + +export const MockedRecognizedFlowDropin: FastlaneStory = { render: checkoutConfig => { const paymentMethodsOverride = { paymentMethods: [ @@ -69,6 +114,11 @@ export const WithMockedRecognizedFlow: FastlaneStory = { { + const paymentMethodsOverride = { + paymentMethods: [ + { + type: 'scheme', + name: 'Cards', + brands: ['mc', 'visa'] + }, + { + name: 'Cards', + type: 'fastlane', + brands: ['mc', 'visa'] + } + ] + }; + + return ( + + {checkout => ( + + )} + + ); + } +}; + export default meta; diff --git a/packages/lib/storybook/stories/wallets/Fastlane/components/FastlaneStory.scss b/packages/lib/storybook/stories/wallets/Fastlane/components/FastlaneStory.scss index 1f5a119d2c..7ef1997be9 100644 --- a/packages/lib/storybook/stories/wallets/Fastlane/components/FastlaneStory.scss +++ b/packages/lib/storybook/stories/wallets/Fastlane/components/FastlaneStory.scss @@ -9,6 +9,7 @@ #watermark-container { align-self: end; + margin-right: 12px; } .input-field { diff --git a/packages/server/translations/en-US.json b/packages/server/translations/en-US.json index d61909117a..5e08fea683 100644 --- a/packages/server/translations/en-US.json +++ b/packages/server/translations/en-US.json @@ -324,5 +324,18 @@ "paynow.mobileViewInstruction.step2": "Open the PayNow bank or payment app.", "paynow.mobileViewInstruction.step3": "Select the option to scan a QR code.", "paynow.mobileViewInstruction.step4": "Choose the option to upload a QR and select the screenshot.", - "paynow.mobileViewInstruction.step5": "Complete the transaction." -} \ No newline at end of file + "paynow.mobileViewInstruction.step5": "Complete the transaction.", + + "card.fastlane.a11y.logo": "Fastlane by Paypal logo", + "card.fastlane.a11y.openDialog": "Read more about Fastlane", + "card.fastlane.a11y.closeDialog": "Close dialog", + "card.fastlane.mobileInputLabel": "Mobile number", + "card.fastlane.consentToggle": "Save your info with Fastlane for faster checkouts", + "card.fastlane.consentText": "By saving your info, you agree to get codes by text to use Fastlane everywhere it's available. You also agree to the %#terms%# and %#privacy statement%#.", + "card.fastlane.modal.benefit1.header": "Autofill your checkouts", + "card.fastlane.modal.benefit1.text": "Get one-time codes to use card and addresses you’ve saved.", + "card.fastlane.modal.benefit2.header": "Protect your info", + "card.fastlane.modal.benefit2.text": "Your payment info is encrypted when it’s stored and sent to places you shop.", + "card.fastlane.modal.benefit3.header": "Use across stores", + "card.fastlane.modal.benefit3.text": "Speed through checkout everywhere Fastlane is available." +}