diff --git a/.changeset/pretty-stingrays-cover.md b/.changeset/pretty-stingrays-cover.md new file mode 100644 index 0000000000..b7cca912f0 --- /dev/null +++ b/.changeset/pretty-stingrays-cover.md @@ -0,0 +1,5 @@ +--- +'@adyen/adyen-web': minor +--- + +Added support for Fastlane payment method diff --git a/packages/e2e-playwright/fixtures/URL_MAP.ts b/packages/e2e-playwright/fixtures/URL_MAP.ts index 2cc1ce06e1..c267a85ab6 100644 --- a/packages/e2e-playwright/fixtures/URL_MAP.ts +++ b/packages/e2e-playwright/fixtures/URL_MAP.ts @@ -20,6 +20,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 6f7f6c2db9..625b505ee8 100644 --- a/packages/lib/src/components/internal/Button/types.ts +++ b/packages/lib/src/components/internal/Button/types.ts @@ -1,4 +1,4 @@ -import { h } from 'preact'; +import { h, Ref } from 'preact'; export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'action' | 'link'; @@ -10,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; @@ -19,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/src/components/internal/UIElement/UIElement.tsx b/packages/lib/src/components/internal/UIElement/UIElement.tsx index 7c0e4497ac..a78916a957 100644 --- a/packages/lib/src/components/internal/UIElement/UIElement.tsx +++ b/packages/lib/src/components/internal/UIElement/UIElement.tsx @@ -461,7 +461,6 @@ export abstract class UIElement

exten /** * Get the element icon URL for the current environment */ - public get icon(): string { const type = this.props.paymentMethodType || this.type; return this.props.icon ?? this.resources.getImage()(type); diff --git a/packages/lib/src/components/internal/UIElement/types.ts b/packages/lib/src/components/internal/UIElement/types.ts index 7f9728759e..9a8322b073 100644 --- a/packages/lib/src/components/internal/UIElement/types.ts +++ b/packages/lib/src/components/internal/UIElement/types.ts @@ -25,6 +25,8 @@ type CoreCallbacks = Pick< | 'onEnterKeyPressed' >; +export type StatusFromAction = 'redirect' | 'loading' | 'custom'; + export type UIElementProps = BaseElementProps & CoreCallbacks & { environment?: string; @@ -57,7 +59,7 @@ export type UIElementProps = BaseElementProps & * Status set when creating the Component from action * @internal */ - statusType?: 'redirect' | 'loading' | 'custom'; + statusType?: StatusFromAction; type?: string; name?: string; diff --git a/packages/lib/src/components/tx-variants.ts b/packages/lib/src/components/tx-variants.ts index 549a9cb81e..4d65991736 100644 --- a/packages/lib/src/components/tx-variants.ts +++ b/packages/lib/src/components/tx-variants.ts @@ -50,6 +50,7 @@ export enum TxVariants { clicktopay = 'clicktopay', googlepay = 'googlepay', paypal = 'paypal', + fastlane = 'fastlane', paywithgoogle = 'paywithgoogle', /** Wallets */ diff --git a/packages/lib/src/components/types.ts b/packages/lib/src/components/types.ts index f32534ed20..37b814c5af 100644 --- a/packages/lib/src/components/types.ts +++ b/packages/lib/src/components/types.ts @@ -22,6 +22,7 @@ export * from './Klarna/types'; export * from './Multibanco/types'; export * from './Oxxo/types'; export * from './PayPal/types'; +export * from './PayPalFastlane/types'; export * from './Pix/types'; export * from './Redirect/types'; export * from './Sepa/types'; diff --git a/packages/lib/src/components/utilities.ts b/packages/lib/src/components/utilities.ts new file mode 100644 index 0000000000..73f2dad8ec --- /dev/null +++ b/packages/lib/src/components/utilities.ts @@ -0,0 +1,5 @@ +/** + * Exposing utilities that can be used by merchants + */ + +export { default as initializeFastlane } from './PayPalFastlane/initializeFastlane'; diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts index f934b32e11..84488f6538 100644 --- a/packages/lib/src/index.ts +++ b/packages/lib/src/index.ts @@ -2,4 +2,5 @@ import * as components from './components'; export { components }; export * from './components'; +export * from './components/utilities'; export * from './core/AdyenCheckout'; diff --git a/packages/lib/src/types.ts b/packages/lib/src/types.ts index 8b7266626e..b399fcd291 100644 --- a/packages/lib/src/types.ts +++ b/packages/lib/src/types.ts @@ -6,3 +6,4 @@ export { CustomTranslations } from './language/types'; export { default as AdyenCheckoutError } from './core/Errors/AdyenCheckoutError'; export { default as UIElement } from './components/internal/UIElement'; +export { default as FastlaneSDK } from './components/PayPalFastlane/FastlaneSDK'; diff --git a/packages/lib/src/types/custom.d.ts b/packages/lib/src/types/custom.d.ts index 0e04382135..1d3fee0d4b 100644 --- a/packages/lib/src/types/custom.d.ts +++ b/packages/lib/src/types/custom.d.ts @@ -1,12 +1,20 @@ +import { FastlaneWindowInstance, FastlaneOptions } from '../components/PayPalFastlane/types'; + declare module '*.scss' { const content: { [className: string]: string }; export default content; } -interface Window { - AdyenWeb: any; - VISA_SDK?: { - buildClientProfile?(srciDpaId?: string): any; - correlationId?: string; - }; +declare global { + interface Window { + ApplePaySession?: ApplePaySession; + paypal?: { + Fastlane?: (options?: FastlaneOptions) => Promise; + }; + AdyenWeb: any; + VISA_SDK?: { + buildClientProfile?(srciDpaId?: string): any; + correlationId?: string; + }; + } } diff --git a/packages/lib/storybook/helpers/create-advanced-checkout.ts b/packages/lib/storybook/helpers/create-advanced-checkout.ts index 6d9bac19bf..e05719b200 100644 --- a/packages/lib/storybook/helpers/create-advanced-checkout.ts +++ b/packages/lib/storybook/helpers/create-advanced-checkout.ts @@ -11,6 +11,7 @@ async function createAdvancedFlowCheckout({ countryCode, shopperLocale, amount, + allowedPaymentTypes = [], paymentMethodsOverride, ...restCheckoutProps }: AdyenCheckoutProps): Promise { @@ -28,16 +29,14 @@ async function createAdvancedFlowCheckout({ const paymentMethodsResponse = !paymentMethodsOverride ? _paymentMethodsResponse : { - storedPaymentMethods: [ - ...(_paymentMethodsResponse.storedPaymentMethods ? _paymentMethodsResponse.storedPaymentMethods : []), - ...(paymentMethodsOverride.storedPaymentMethods ? paymentMethodsOverride.storedPaymentMethods : []) - ], - paymentMethods: [ - ...(_paymentMethodsResponse.paymentMethods ? _paymentMethodsResponse.paymentMethods : []), - ...(paymentMethodsOverride.paymentMethods ? paymentMethodsOverride.paymentMethods : []) - ] + storedPaymentMethods: paymentMethodsOverride.storedPaymentMethods ? paymentMethodsOverride.storedPaymentMethods : [], + paymentMethods: paymentMethodsOverride.paymentMethods ? paymentMethodsOverride.paymentMethods : [] }; + if (allowedPaymentTypes.length > 0) { + paymentMethodsResponse.paymentMethods = paymentMethodsResponse.paymentMethods.filter(pm => allowedPaymentTypes.includes(pm.type)); + } + const checkout = await AdyenCheckout({ clientKey: process.env.CLIENT_KEY, // @ts-ignore CLIENT_ENV has valid value diff --git a/packages/lib/storybook/helpers/create-checkout.ts b/packages/lib/storybook/helpers/create-checkout.ts index 5922bfcb69..c8d5b4894a 100644 --- a/packages/lib/storybook/helpers/create-checkout.ts +++ b/packages/lib/storybook/helpers/create-checkout.ts @@ -6,15 +6,18 @@ import Core from '../../src/core'; async function createCheckout(checkoutConfig: GlobalStoryProps): Promise { const { useSessions, ...rest } = checkoutConfig; - const overidenPaymentMethodsAmount = + const overriddenPaymentMethodsAmount = (rest.paymentMethodsOverride?.paymentMethods?.length || 0) + (rest.paymentMethodsOverride?.storedPaymentMethods?.length || 0); - const hasPaymentOveride = overidenPaymentMethodsAmount > 0; + const hasPaymentOverridden = overriddenPaymentMethodsAmount > 0; - if (useSessions && !hasPaymentOveride) { - return await createSessionsCheckout(rest); - } else if (useSessions && hasPaymentOveride) { - console.warn('🟢 Checkout Storybook: paymentMethodsOverride is defined while using Sessions, forcing advance flow.'); + if (useSessions) { + if (!hasPaymentOverridden && !rest.allowedPaymentTypes) { + return await createSessionsCheckout(rest); + } else { + console.warn('🟢 Checkout Storybook: Forcing advance flow.'); + } } + return await createAdvancedFlowCheckout(rest); } 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/types.ts b/packages/lib/storybook/stories/types.ts index 91accade3e..9251fb0fc2 100644 --- a/packages/lib/storybook/stories/types.ts +++ b/packages/lib/storybook/stories/types.ts @@ -27,6 +27,7 @@ export type AdyenCheckoutProps = { shopperLocale: string; amount: number; sessionData?: PaymentMethodsResponse; + allowedPaymentTypes?: string[]; paymentMethodsOverride?: PaymentMethodsResponse; onPaymentCompleted?: (data: any, element?: UIElement) => void; }; diff --git a/packages/lib/storybook/stories/wallets/Fastlane/Fastlane.stories.tsx b/packages/lib/storybook/stories/wallets/Fastlane/Fastlane.stories.tsx new file mode 100644 index 0000000000..964ce42ece --- /dev/null +++ b/packages/lib/storybook/stories/wallets/Fastlane/Fastlane.stories.tsx @@ -0,0 +1,186 @@ +import { h } from 'preact'; +import { MetaConfiguration, StoryConfiguration } from '../../types'; +import { FastlaneInSinglePageApp } from './FastlaneInSinglePageApp'; +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/Fastlane'; +import { Checkout } from '../../Checkout'; + +type FastlaneStory = StoryConfiguration<{}>; + +const meta: MetaConfiguration = { + title: 'Wallets/Fastlane' +}; + +export const Lookup: FastlaneStory = { + render: checkoutConfig => { + const paymentMethodsOverride = { + paymentMethods: [ + { + type: 'scheme', + name: 'Cards', + brands: ['mc', 'visa'] + }, + { + configuration: { merchantId: 'QSXMR9W7GV8NY', intent: 'capture' }, + name: 'PayPal', + type: 'paypal' + }, + { + name: 'Cards', + type: 'fastlane', + brands: ['mc', 'visa'] + } + ] + }; + + return ; + } +}; + +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: [ + { + type: 'scheme', + name: 'Cards', + brands: ['mc', 'visa'] + }, + { + configuration: { merchantId: 'QSXMR9W7GV8NY', intent: 'capture' }, + name: 'PayPal', + type: 'paypal' + }, + { + name: 'Cards', + type: 'fastlane', + brands: ['mc', 'visa'] + } + ] + }; + + return ( + + {checkout => ( + + )} + + ); + } +}; + +export const MockedRecognizedFlowStandalone: FastlaneStory = { + render: checkoutConfig => { + 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/FastlaneInSinglePageApp.tsx b/packages/lib/storybook/stories/wallets/Fastlane/FastlaneInSinglePageApp.tsx new file mode 100644 index 0000000000..45471d5ab7 --- /dev/null +++ b/packages/lib/storybook/stories/wallets/Fastlane/FastlaneInSinglePageApp.tsx @@ -0,0 +1,48 @@ +import { h } from 'preact'; +import { useState } from 'preact/hooks'; +import { GlobalStoryProps } from '../../types'; + +import Dropin from '../../../../src/components/Dropin'; +import Card from '../../../../src/components/Card'; +import PayPal from '../../../../src/components/PayPal'; +import Fastlane from '../../../../src/components/PayPalFastlane'; + +import { Checkout } from '../../Checkout'; +import { ComponentContainer } from '../../ComponentContainer'; +import { GuestShopperForm } from './components/GuestShopperForm'; +import type { FastlanePaymentMethodConfiguration } from '../../../../src/components/PayPalFastlane/types'; + +interface Props { + checkoutConfig: GlobalStoryProps; +} + +export const FastlaneInSinglePageApp = ({ checkoutConfig }: Props) => { + const [componentConfig, setComponentConfig] = useState(null); + + const handleOnCheckoutStep = config => { + console.log('Component config:', config); + setComponentConfig(config); + }; + + if (!componentConfig) { + return ; + } + + return ( + + {checkout => ( + + )} + + ); +}; diff --git a/packages/lib/storybook/stories/wallets/Fastlane/components/CollectEmail.tsx b/packages/lib/storybook/stories/wallets/Fastlane/components/CollectEmail.tsx new file mode 100644 index 0000000000..14c881409b --- /dev/null +++ b/packages/lib/storybook/stories/wallets/Fastlane/components/CollectEmail.tsx @@ -0,0 +1,78 @@ +import { h } from 'preact'; +import { useEffect, useState } from 'preact/hooks'; + +import FastlaneSDK from '../../../../../src/components/PayPalFastlane/FastlaneSDK'; +import type { FastlaneAuthenticatedCustomerResult } from '../../../../../src/components/PayPalFastlane/types'; + +interface CollectEmailProps { + fastlaneSdk: FastlaneSDK; + onFastlaneLookup: (authResult: FastlaneAuthenticatedCustomerResult) => void; + onEditEmail: () => void; +} + +export const CollectEmail = ({ fastlaneSdk, onFastlaneLookup, onEditEmail }: CollectEmailProps) => { + const [email, setEmail] = useState(null); + const [viewOnly, setViewOnly] = useState(false); + + const renderWatermark = async () => { + await fastlaneSdk.mountWatermark('#watermark-container'); + }; + + const handleEmailInput = event => { + setEmail(event.currentTarget.value); + }; + + const handleEditEmail = () => { + setViewOnly(false); + onEditEmail(); + }; + + const handleButtonClick = async () => { + try { + const authResult = await fastlaneSdk.authenticate(email); + onFastlaneLookup(authResult); + setViewOnly(true); + } catch (error) { + console.log(error); + } + }; + + useEffect(() => { + void renderWatermark(); + }, []); + + return ( +

+
+

Customer

+ {viewOnly && ( + + )} +
+
+
+ +
+
+ + {!viewOnly && ( + + )} +
+ {!viewOnly && } +
+ ); +}; diff --git a/packages/lib/storybook/stories/wallets/Fastlane/components/FastlaneStory.scss b/packages/lib/storybook/stories/wallets/Fastlane/components/FastlaneStory.scss new file mode 100644 index 0000000000..7ef1997be9 --- /dev/null +++ b/packages/lib/storybook/stories/wallets/Fastlane/components/FastlaneStory.scss @@ -0,0 +1,65 @@ +.email-container { + display: flex; +} + +.email-input-wrapper { + display: flex; + flex-direction: column; +} + +#watermark-container { + align-self: end; + margin-right: 12px; +} + +.input-field { + background-color: white; + border: #aab2bc 1px solid; + border-radius: 4px; + outline: none; + padding: 10px; + position: relative; + text-overflow: ellipsis; + width: 300px; + margin-right: 12px; +} + +.shipping-section { + .input-field { + margin-bottom: 12px; + } + + .shipping-checkbox { + margin-bottom: 20px; + } +} + + +.form-container { + background-color: white; + padding: 20px; + border-radius: 8px; + border: #0d419d 1px solid; + max-width: 800px; +} + +.button { + cursor: pointer; + width: 145px; + height: 37px; + border-radius: 4px; + border: 1px solid #2d7fec; + background-color: #0551b5; + color: white; + margin-bottom: 12px; +} + +.button:hover { + background-color: #2d7fec; +} + +.section_header { + display: flex; + justify-content: space-between; + align-items: center; +} diff --git a/packages/lib/storybook/stories/wallets/Fastlane/components/GuestShopperForm.tsx b/packages/lib/storybook/stories/wallets/Fastlane/components/GuestShopperForm.tsx new file mode 100644 index 0000000000..9c220872c4 --- /dev/null +++ b/packages/lib/storybook/stories/wallets/Fastlane/components/GuestShopperForm.tsx @@ -0,0 +1,70 @@ +import { h } from 'preact'; +import { useEffect, useState } from 'preact/hooks'; + +import { CollectEmail } from './CollectEmail'; +import { Shipping } from './Shipping'; +import { ShippingWithFastlane } from './ShippingWithFastlane'; +import './FastlaneStory.scss'; + +import initializeFastlane from '../../../../../src/components/PayPalFastlane/initializeFastlane'; +import FastlaneSDK from '../../../../../src/components/PayPalFastlane/FastlaneSDK'; +import type { FastlaneAuthenticatedCustomerResult } from '../../../../../src/components/PayPalFastlane/types'; + +interface GuestShopperFormProps { + onCheckoutStep(componentConfig): void; +} + +export const GuestShopperForm = ({ onCheckoutStep }: GuestShopperFormProps) => { + const [fastlane, setFastlane] = useState(null); + const [fastlaneAuthResult, setFastlaneAuthResult] = useState(null); + + const loadFastlane = async () => { + const sdk = await initializeFastlane({ + clientKey: 'test_JC3ZFTA6WFCCRN454MVDEYOWEI5D3LT2', // Joost clientkey + environment: 'test' + }); + setFastlane(sdk); + }; + + const handleOnEditEmail = () => { + setFastlaneAuthResult(null); + }; + + const handleFastlaneLookup = data => { + setFastlaneAuthResult(data); + }; + + const handleOnCheckoutClick = (shippingAddress?: any) => { + console.log('Shipping address', shippingAddress); + const componentConfig = fastlane.getComponentConfiguration(fastlaneAuthResult); + onCheckoutStep(componentConfig); + }; + + useEffect(() => { + void loadFastlane().catch(() => { + alert('Failed to initialize: Fetch the token using Postman'); + }); + }, []); + + if (!fastlane) { + return null; + } + + return ( +
+

Merchant Checkout Page

+ +
+ + {fastlaneAuthResult?.authenticationState === 'succeeded' && ( + + )} + + {fastlaneAuthResult?.authenticationState === 'not_found' && } +
+ ); +}; diff --git a/packages/lib/storybook/stories/wallets/Fastlane/components/Shipping.tsx b/packages/lib/storybook/stories/wallets/Fastlane/components/Shipping.tsx new file mode 100644 index 0000000000..bfbf41c726 --- /dev/null +++ b/packages/lib/storybook/stories/wallets/Fastlane/components/Shipping.tsx @@ -0,0 +1,182 @@ +import { h } from 'preact'; +import { useState } from 'preact/hooks'; + +interface ShippingProps { + onCheckoutClick: (shippingAddress?: any) => void; +} + +export const Shipping = ({ onCheckoutClick }: ShippingProps) => { + const [isShippingRequired, setIsShippingRequired] = useState(true); + const [formData, setFormData] = useState({ + givenName: '', + familyName: '', + addressLine1: '', + addressLine2: '', + addressLevel1: '', + addressLevel2: '', + postalCode: '', + country: '', + telCountryCode: '', + telNational: '' + }); + + const handleShippingCheckboxClick = () => { + setIsShippingRequired(!isShippingRequired); + }; + + const handleChange = e => { + const { name, value } = e.target; + setFormData(prevState => ({ ...prevState, [name]: value })); + }; + + const fillInMockData = () => { + setFormData({ + givenName: 'John', + familyName: 'Doe', + addressLine1: 'Simon Carmilgestraat 10', + addressLine2: 'Ap 29', + addressLevel1: 'Noord Holand', + addressLevel2: 'Amsterdam', + postalCode: '1028 PX', + country: 'Netherlands', + telCountryCode: '31', + telNational: '611223399' + }); + }; + + const handleOnSubmit = e => { + e.preventDefault(); + onCheckoutClick(formData); + }; + + return ( +
+

Shipping Details

+ +
+ + +
+ + {isShippingRequired ? ( +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +
+
+ ) : ( + + )} +
+ ); +}; diff --git a/packages/lib/storybook/stories/wallets/Fastlane/components/ShippingWithFastlane.tsx b/packages/lib/storybook/stories/wallets/Fastlane/components/ShippingWithFastlane.tsx new file mode 100644 index 0000000000..59bb96e578 --- /dev/null +++ b/packages/lib/storybook/stories/wallets/Fastlane/components/ShippingWithFastlane.tsx @@ -0,0 +1,54 @@ +import { h } from 'preact'; +import { useState } from 'preact/hooks'; +import getAddressSummary from './utils/get-fastlane-address-summary'; + +import FastlaneSDK from '../../../../../src/components/PayPalFastlane/FastlaneSDK'; +import type { FastlaneShipping } from '../../../../../src/components/PayPalFastlane/types'; + +interface ShippingWithFastlaneProps { + fastlaneSdk: FastlaneSDK; + address: FastlaneShipping; + onCheckoutClick: (shippingAddress?: any) => void; +} + +export const ShippingWithFastlane = ({ fastlaneSdk, address, onCheckoutClick }: ShippingWithFastlaneProps) => { + const [addressSummary, setAddressSummary] = useState(getAddressSummary(address)); + const [shippingAddress, setShippingAddress] = useState(address); + + const handleShippingClick = async () => { + const data = await fastlaneSdk.showShippingAddressSelector(); + + if (data.selectionChanged) { + const summary = getAddressSummary(data.selectedAddress); + setAddressSummary(summary); + setShippingAddress(data.selectedAddress); + } + }; + + const handleCheckoutClick = () => { + onCheckoutClick(shippingAddress); + }; + + return ( +
+
+

Shipping Details

+ + {addressSummary && ( + + )} +
+ + {addressSummary && ( +
+
{addressSummary}
+ +
+ )} +
+ ); +}; diff --git a/packages/lib/storybook/stories/wallets/Fastlane/components/utils/get-fastlane-address-summary.ts b/packages/lib/storybook/stories/wallets/Fastlane/components/utils/get-fastlane-address-summary.ts new file mode 100644 index 0000000000..78d6390c90 --- /dev/null +++ b/packages/lib/storybook/stories/wallets/Fastlane/components/utils/get-fastlane-address-summary.ts @@ -0,0 +1,23 @@ +import { FastlaneShipping } from '../../../../../../src/components/PayPalFastlane/types'; + +/** + * Format the PayPal address to show nicely in the Storybook UI + */ +const getAddressSummary = (shipping: FastlaneShipping) => { + if (!shipping) return null; + + const { firstName, lastName, fullName } = shipping.name; + const { addressLine1, addressLine2, adminArea2, adminArea1, postalCode, countryCode } = shipping.address; + const { countryCode: telCountryCode, nationalNumber } = shipping.phoneNumber; + + const isNotEmpty = field => !!field; + const summary = [ + fullName || [firstName, lastName].filter(isNotEmpty).join(' '), + [addressLine1, addressLine2].filter(isNotEmpty).join(', '), + [adminArea2, [adminArea1, postalCode].filter(isNotEmpty).join(' '), countryCode].filter(isNotEmpty).join(', '), + [telCountryCode, nationalNumber].filter(isNotEmpty).join('') + ]; + return summary.filter(isNotEmpty).join('\n'); +}; + +export default getAddressSummary; 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." +}