diff --git a/packages/lib/src/components/PayPal/types.ts b/packages/lib/src/components/PayPal/types.ts index 10aa8c7b97..75bef42420 100644 --- a/packages/lib/src/components/PayPal/types.ts +++ b/packages/lib/src/components/PayPal/types.ts @@ -2,12 +2,6 @@ import { AddressData } from '../../types/global-types'; import { UIElementProps } from '../internal/UIElement/types'; import PaypalElement from './Paypal'; -declare global { - interface Window { - paypal: object; - } -} - export interface PayPalConfiguration extends UIElementProps { /** * Configuration returned by the backend diff --git a/packages/lib/src/components/PayPalFastlane/FastlaneSDK.test.ts b/packages/lib/src/components/PayPalFastlane/FastlaneSDK.test.ts new file mode 100644 index 0000000000..d7824b383a --- /dev/null +++ b/packages/lib/src/components/PayPalFastlane/FastlaneSDK.test.ts @@ -0,0 +1,229 @@ +import { mockDeep, mock, mockReset } from 'jest-mock-extended'; +import initializeFastlane from './initializeFastlane'; +import { httpPost } from '../../core/Services/http'; +import Script from '../../utils/Script'; +import type { Fastlane, FastlaneProfile, FastlaneShipping } from './types'; + +const fastlaneMock = mockDeep(); +const fastlaneConstructorMock = jest.fn().mockResolvedValue(fastlaneMock); + +const mockScriptLoaded = jest.fn().mockImplementation(() => { + window.paypal = {}; + window.paypal.Fastlane = fastlaneConstructorMock; + return Promise.resolve(); +}); + +jest.mock('../../core/Services/http'); +jest.mock('../../utils/Script', () => { + return jest.fn().mockImplementation(() => { + return { load: mockScriptLoaded }; + }); +}); + +const httpPostMock = (httpPost as jest.Mock).mockResolvedValue({ + id: 'RANDOM-ID', + clientId: 'CLIENT-ID', + merchantId: 'XXXYYYZZZ', + value: 'TOKEN-VALUE', + expiresAt: '2024-11-01T13:34:01.804+00:00' +}); + +describe('FastlaneSDK', () => { + beforeEach(() => { + mockReset(fastlaneMock); + }); + + test('should initialize the Fastlane SDK', async () => { + await initializeFastlane({ + clientKey: 'test_xxx', + environment: 'test' + }); + + expect(fastlaneConstructorMock).toHaveBeenCalledTimes(1); + expect(fastlaneConstructorMock).toHaveBeenCalledWith({}); + expect(fastlaneMock.setLocale).toHaveBeenCalledWith('en_us'); + expect(httpPostMock).toHaveBeenCalledWith({ + loadingContext: 'https://checkoutshopper-test.adyen.com/checkoutshopper/', + path: 'utility/v1/payPalFastlane/tokens?clientKey=test_xxx', + errorLevel: 'fatal' + }); + expect(Script).toHaveBeenCalledWith( + 'https://www.paypal.com/sdk/js?client-id=CLIENT-ID&components=buttons,fastlane', + 'body', + {}, + { sdkClientToken: 'TOKEN-VALUE' } + ); + }); + + test('should return not_found if email is not recognized', async () => { + fastlaneMock.identity.lookupCustomerByEmail.mockResolvedValue({ + customerContextId: null + }); + + const fastlane = await initializeFastlane({ + clientKey: 'test_xxx', + environment: 'test' + }); + + const authResult = await fastlane.authenticate('test@adyen.com'); + + expect(authResult.authenticationState).toBe('not_found'); + expect(authResult.profileData).toBeUndefined(); + }); + + test('should authenticate the user with email', async () => { + const customerContextId = 'customer-context-id'; + const mockedFastlaneProfile = mock(); + + fastlaneMock.identity.lookupCustomerByEmail.mockResolvedValue({ + customerContextId + }); + + fastlaneMock.identity.triggerAuthenticationFlow.mockResolvedValue({ + authenticationState: 'succeeded', + profileData: mockedFastlaneProfile + }); + + const fastlane = await initializeFastlane({ + clientKey: 'test_xxx', + environment: 'test' + }); + + const authResult = await fastlane.authenticate('test@adyen.com'); + + expect(fastlaneMock.identity.lookupCustomerByEmail).toHaveBeenCalledWith('test@adyen.com'); + expect(fastlaneMock.identity.triggerAuthenticationFlow).toHaveBeenCalledWith(customerContextId); + expect(authResult.authenticationState).toBe('succeeded'); + expect(authResult.profileData).toBeDefined(); + }); + + test('should call Fastlane shipping address selector method', async () => { + const customerContextId = 'customer-context-id'; + const mockedFastlaneProfile = mock(); + const mockedFastlaneShipping = mock(); + + const fastlane = await initializeFastlane({ + clientKey: 'test_xxx', + environment: 'test' + }); + + fastlaneMock.profile.showShippingAddressSelector.mockResolvedValue({ + selectionChanged: false, + selectedAddress: mockedFastlaneShipping + }); + + fastlaneMock.identity.lookupCustomerByEmail.mockResolvedValue({ + customerContextId + }); + + fastlaneMock.identity.triggerAuthenticationFlow.mockResolvedValue({ + authenticationState: 'succeeded', + profileData: mockedFastlaneProfile + }); + + await fastlane.authenticate('test@adyen.com'); + const addressSelectorResult = await fastlane.showShippingAddressSelector(); + + expect(fastlaneMock.profile.showShippingAddressSelector).toHaveBeenCalledTimes(1); + expect(addressSelectorResult.selectionChanged).toBeFalsy(); + }); + + test('should mount Fastlane watermark', async () => { + const componentMock = { + render: jest.fn() + }; + fastlaneMock.FastlaneWatermarkComponent.mockResolvedValue(componentMock); + + const fastlane = await initializeFastlane({ + clientKey: 'test_xxx', + environment: 'test' + }); + + await fastlane.mountWatermark('.my-div'); + + expect(fastlaneMock.FastlaneWatermarkComponent).toHaveBeenCalledTimes(1); + expect(componentMock.render).toHaveBeenCalledTimes(1); + expect(componentMock.render).toHaveBeenCalledWith('.my-div'); + }); + + test('should return fastlane component configuration if shopper has profile', async () => { + const customerContextId = 'customer-context-id'; + fastlaneMock.identity.lookupCustomerByEmail.mockResolvedValue({ + customerContextId + }); + fastlaneMock.identity.triggerAuthenticationFlow.mockResolvedValue({ + authenticationState: 'succeeded', + profileData: mock({ + card: { + id: 'xxxx', + paymentSource: { + card: { + brand: 'visa', + lastDigits: '1111' + } + } + } + }) + }); + + const fastlane = await initializeFastlane({ + clientKey: 'test_xxx', + environment: 'test' + }); + const authResult = await fastlane.authenticate('test@adyen.com'); + const config = fastlane.getComponentConfiguration(authResult); + + expect(config).toStrictEqual({ + paymentType: 'fastlane', + configuration: { + brand: 'visa', + customerId: 'customer-context-id', + email: 'test@adyen.com', + lastFour: '1111', + sessionId: 'xxxx-yyyy', + tokenId: 'xxxx' + } + }); + }); + + test('should return card component configuration if shopper does not have profile', async () => { + const customerContextId = 'customer-context-id'; + fastlaneMock.identity.lookupCustomerByEmail.mockResolvedValue({ + customerContextId + }); + fastlaneMock.identity.triggerAuthenticationFlow.mockResolvedValue({ + authenticationState: 'not_found', + profileData: undefined + }); + + const fastlane = await initializeFastlane({ + clientKey: 'test_xxx', + environment: 'test' + }); + const authResult = await fastlane.authenticate('test@adyen.com'); + const config = fastlane.getComponentConfiguration(authResult); + + expect(config).toStrictEqual({ + paymentType: 'card', + configuration: { + fastlaneConfiguration: { + defaultToggleState: true, + privacyPolicyLink: 'https://...', + showConsent: true, + termsAndConditionsLink: 'https://...', + termsAndConditionsVersion: 'v1' + } + } + }); + }); + + test('should thrown an error if there is no auth result to create the component configuration', async () => { + const fastlane = await initializeFastlane({ + clientKey: 'test_xxx', + environment: 'test' + }); + + // @ts-ignore It is expected to omit the parameter here + expect(() => fastlane.getComponentConfiguration()).toThrowError(); + }); +}); diff --git a/packages/lib/src/components/PayPalFastlane/FastlaneSDK.ts b/packages/lib/src/components/PayPalFastlane/FastlaneSDK.ts new file mode 100644 index 0000000000..464d6abfad --- /dev/null +++ b/packages/lib/src/components/PayPalFastlane/FastlaneSDK.ts @@ -0,0 +1,135 @@ +import { resolveEnvironments } from '../../core/Environment'; +import requestFastlaneToken from './services/request-fastlane-token'; +import { convertAdyenLocaleToFastlaneLocale } from './utils/convert-locale'; +import Script from '../../utils/Script'; +import AdyenCheckoutError from '../../core/Errors/AdyenCheckoutError'; +import { + ComponentConfiguration, + Fastlane, + FastlaneAuthenticatedCustomerResult, + FastlaneShippingAddressSelectorResult, + FastlaneSDKConfiguration +} from './types'; +import type { FastlaneTokenData } from './services/request-fastlane-token'; + +class FastlaneSDK { + private readonly clientKey: string; + private readonly checkoutShopperURL: string; + private readonly locale: string; + + private fastlaneSdk: Fastlane; + private authenticatedShopper: { email: string; customerId: string }; + + constructor(configuration: FastlaneSDKConfiguration) { + if (!configuration.environment) throw new AdyenCheckoutError('IMPLEMENTATION_ERROR', "FastlaneSDK: 'environment' property is required"); + if (!configuration.clientKey) throw new AdyenCheckoutError('IMPLEMENTATION_ERROR', "FastlaneSDK: 'clientKey' property is required"); + + const { apiUrl } = resolveEnvironments(configuration.environment); + + this.checkoutShopperURL = apiUrl; + this.clientKey = configuration.clientKey; + this.locale = convertAdyenLocaleToFastlaneLocale(configuration.locale || 'en-US'); + } + + public async initialize(): Promise { + const tokenData = await this.requestClientToken(); + await this.fetchSdk(tokenData.value, tokenData.clientId); + await this.initializeFastlane(); + return this; + } + + public async authenticate(email: string): Promise { + if (!this.fastlaneSdk) { + throw new AdyenCheckoutError('IMPLEMENTATION_ERROR', 'FastlaneSDK is not initialized'); + } + + const { customerContextId } = await this.fastlaneSdk.identity.lookupCustomerByEmail(email); + + if (customerContextId) { + this.authenticatedShopper = { email, customerId: customerContextId }; + return this.fastlaneSdk.identity.triggerAuthenticationFlow(customerContextId); + } else { + return { + authenticationState: 'not_found', + profileData: undefined + }; + } + } + + /** + * TODO: Waiting for PayPal to provide the specific methods to fetch sessionId and Consent UI details + */ + public getComponentConfiguration(authResult: FastlaneAuthenticatedCustomerResult): ComponentConfiguration { + if (!authResult) { + throw new AdyenCheckoutError( + 'IMPLEMENTATION_ERROR', + 'FastlaneSDK: you must pass the authentication result to get the component configuration' + ); + } + + if (authResult.authenticationState === 'succeeded') { + return { + paymentType: 'fastlane', + configuration: { + sessionId: 'xxxx-yyyy', + customerId: this.authenticatedShopper.customerId, + email: this.authenticatedShopper.email, + tokenId: authResult.profileData.card.id, + lastFour: authResult.profileData.card.paymentSource.card.lastDigits, + brand: authResult.profileData.card.paymentSource.card.brand + } + }; + } else { + return { + paymentType: 'card', + configuration: { + fastlaneConfiguration: { + showConsent: true, + defaultToggleState: true, + termsAndConditionsLink: 'https://...', + privacyPolicyLink: 'https://...', + termsAndConditionsVersion: 'v1' + } + } + }; + } + } + + public showShippingAddressSelector(): Promise { + if (!this.fastlaneSdk) { + throw new AdyenCheckoutError('IMPLEMENTATION_ERROR', 'FastlaneSDK is not initialized'); + } + return this.fastlaneSdk.profile.showShippingAddressSelector(); + } + + public async mountWatermark(container: HTMLElement | string, options = { includeAdditionalInfo: false }): Promise { + if (!this.fastlaneSdk) { + throw new AdyenCheckoutError('IMPLEMENTATION_ERROR', 'FastlaneSDK is not initialized'); + } + + const component = await this.fastlaneSdk.FastlaneWatermarkComponent(options); + component.render(container); + } + + private requestClientToken(): Promise { + return requestFastlaneToken(this.checkoutShopperURL, this.clientKey); + } + + private async fetchSdk(clientToken: string, clientId: string): Promise { + const url = `https://www.paypal.com/sdk/js?client-id=${clientId}&components=buttons,fastlane`; + const script = new Script(url, 'body', {}, { sdkClientToken: clientToken }); + + try { + await script.load(); + } catch (error) { + console.error(error); + } + } + + private async initializeFastlane(): Promise { + this.fastlaneSdk = await window.paypal.Fastlane({}); + this.fastlaneSdk.setLocale(this.locale); + } +} + +export default FastlaneSDK; diff --git a/packages/lib/src/components/PayPalFastlane/initializeFastlane.ts b/packages/lib/src/components/PayPalFastlane/initializeFastlane.ts new file mode 100644 index 0000000000..d70aaad4f4 --- /dev/null +++ b/packages/lib/src/components/PayPalFastlane/initializeFastlane.ts @@ -0,0 +1,9 @@ +import FastlaneSDK from './FastlaneSDK'; +import type { FastlaneSDKConfiguration } from './types'; + +async function initializeFastlane(configuration: FastlaneSDKConfiguration): Promise { + const fastlane = new FastlaneSDK(configuration); + return await fastlane.initialize(); +} + +export default initializeFastlane; diff --git a/packages/lib/src/components/PayPalFastlane/services/request-fastlane-token.ts b/packages/lib/src/components/PayPalFastlane/services/request-fastlane-token.ts new file mode 100644 index 0000000000..dce2802b61 --- /dev/null +++ b/packages/lib/src/components/PayPalFastlane/services/request-fastlane-token.ts @@ -0,0 +1,27 @@ +import { httpPost } from '../../../core/Services/http'; + +export interface FastlaneTokenData { + id: string; + clientId: string; + value: string; + expiresAt: string; + merchantId: string; +} + +function requestFastlaneToken(url: string, clientKey: string): Promise { + const path = `utility/v1/payPalFastlane/tokens?clientKey=${clientKey}`; + return httpPost({ loadingContext: url, path, errorLevel: 'fatal' }); + + /** + * TODO: Endpoint is not ready. The only way to test right now is mocking the response here + */ + // return Promise.resolve({ + // id: '2747bd08-783a-45c6-902b-3efbda5497b7', + // clientId: 'AXy9hIzWB6h_LjZUHjHmsbsiicSIbL4GKOrcgomEedVjduUinIU4C2llxkW5p0OG0zTNgviYFceaXEnj', + // merchantId: 'C3UCKQHMW4948', + // value: 'xxxeyJraWQiOiJkMTA2ZTUwNjkzOWYxMWVlYjlkMTAyNDJhYzEyMDAwMiIsInR5cCI6IkpXVCIsImFsZyI6IkVTMjU2In0.eyJpc3MiOiJodHRwczovL2FwaS5zYW5kYm94LnBheXBhbC5jb20iLCJhdWQiOlsiaHR0cHM6Ly9hcGkuYnJhaW50cmVlZ2F0ZXdheS5jb20iLCJjaGVja291dC1wbGF5Z3JvdW5kLm5ldGxpZnkuYXBwIl0sInN1YiI6Ik02VE5BRVNaNUZHTk4iLCJhY3IiOlsiY2xpZW50Il0sInNjb3BlIjpbIkJyYWludHJlZTpWYXVsdCJdLCJvcHRpb25zIjp7fSwiYXoiOiJjY2cxOC5zbGMiLCJleHRlcm5hbF9pZCI6WyJQYXlQYWw6QzNVQ0tRSE1XNDk0OCIsIkJyYWludHJlZTozZGI4aG5rdHJ0bXpzMmd0Il0sImV4cCI6MTczMjc5NjA4NywiaWF0IjoxNzMyNzk1MTg3LCJqdGkiOiJVMkFBTEV0ZjNVbHZiQWFBT0Y5cEtla29YTEREY1NqTmtnS3kzYjJ5YnNuUWlnV01VTEtpaXFYYXBUeTdqTy1EWllhTnU1ZEZBVy05bVRybU5Cay1nTi1qS0VVdnVJSDYtR3h5dV9wMUVXZlRydjdrVmJ4V2NnM2psUDI5aGhtQSJ9.57b4zg2TeN44I5aehBkfXdUf5kqWp8sjo58LKHOFNsuBlcmuWAmqQnprNgys3aL0Y8FqBVqhp69yF_v8_Qiy2w', + // expiresAt: '2024-11-01T13:34:01.804+00:00' + // }); +} + +export default requestFastlaneToken; diff --git a/packages/lib/src/components/PayPalFastlane/types.ts b/packages/lib/src/components/PayPalFastlane/types.ts new file mode 100644 index 0000000000..a6bbe7234d --- /dev/null +++ b/packages/lib/src/components/PayPalFastlane/types.ts @@ -0,0 +1,123 @@ +import type { CoreConfiguration } from '../../core/types'; + +/** + * PayPal Fastlane Reference: + * https://developer.paypal.com/docs/checkout/fastlane/reference/#link-customizeyourintegration + */ + +/** + * Fastlane object available in the window + */ +export interface Fastlane { + identity: { + lookupCustomerByEmail: (email: string) => Promise<{ customerContextId: string }>; + triggerAuthenticationFlow: (customerContextId: string, options?: AuthenticationFlowOptions) => Promise; + }; + profile: { + showShippingAddressSelector: () => Promise; + }; + setLocale: (locale: string) => void; + FastlaneWatermarkComponent: (options: { includeAdditionalInfo: boolean }) => Promise<{ + render: (container) => null; + }>; +} + +// TODO: TBD if this is needed +export interface FastlaneOptions {} + +// TODO: TBD if this is needed +interface AuthenticationFlowOptions {} + +interface CardPaymentSource { + brand: string; + expiry: string; + lastDigits: string; + name: string; + billingAddress: FastlaneAddress; +} + +/** + * External types + */ +export interface FastlaneShippingAddressSelectorResult { + selectionChanged: boolean; + selectedAddress: FastlaneShipping; +} +export interface FastlaneAuthenticatedCustomerResult { + authenticationState: 'succeeded' | 'failed' | 'canceled' | 'not_found'; + profileData: FastlaneProfile; +} + +export interface FastlaneAddress { + addressLine1: string; + addressLine2: string; + adminArea1: string; + adminArea2: string; + postalCode: string; + countryCode: string; + phone: { + nationalNumber: string; + countryCode: string; + }; +} + +export interface FastlaneShipping { + name: { + firstName: string; + lastName: string; + fullName: string; + }; + address: FastlaneAddress; + phoneNumber: { + nationalNumber: string; + countryCode: string; + }; +} + +export interface FastlaneProfile { + name: { + firstName: string; + lastName: string; + fullName: string; + }; + shippingAddress: FastlaneShipping; + card: { + id: string; + paymentSource: { + card: CardPaymentSource; + }; + }; +} + +type FastlaneComponentConfiguration = { + paymentType: 'fastlane'; + configuration: { + sessionId: string; + customerId: string; + email: string; + tokenId: string; + lastFour: string; + brand: string; + }; +}; + +type CardComponentConfiguration = { + paymentType: 'card'; + configuration: { + fastlaneConfiguration: { + showConsent: boolean; + defaultToggleState: boolean; + termsAndConditionsLink: string; + privacyPolicyLink: string; + termsAndConditionsVersion: string; + }; + }; +}; + +export type ComponentConfiguration = FastlaneComponentConfiguration | CardComponentConfiguration; + +export interface FastlaneSDKConfiguration { + clientKey: string; + environment: CoreConfiguration['environment']; + locale?: 'en-US' | 'es-US' | 'fr-RS' | 'zh-US'; +} diff --git a/packages/lib/src/components/PayPalFastlane/utils/convert-locale.ts b/packages/lib/src/components/PayPalFastlane/utils/convert-locale.ts new file mode 100644 index 0000000000..ada3b678e7 --- /dev/null +++ b/packages/lib/src/components/PayPalFastlane/utils/convert-locale.ts @@ -0,0 +1,3 @@ +export function convertAdyenLocaleToFastlaneLocale(locale: string) { + return locale.replace('-', '_').toLowerCase(); +} diff --git a/packages/lib/src/components/index.ts b/packages/lib/src/components/index.ts index d05155adff..f9254c90c9 100644 --- a/packages/lib/src/components/index.ts +++ b/packages/lib/src/components/index.ts @@ -90,6 +90,10 @@ export { default as ANCV } from './ANCV'; export { default as Giftcard } from './Giftcard'; export { default as MealVoucherFR } from './MealVoucherFR'; +/** Utilities */ +export { default as initializeFastlane } from './PayPalFastlane/initializeFastlane'; +export { default as FastlaneSDK } from './PayPalFastlane/FastlaneSDK'; + /** Internal */ export { default as Address } from './Address'; export { default as BankTransfer } from './BankTransfer'; diff --git a/packages/lib/src/types/custom.d.ts b/packages/lib/src/types/custom.d.ts index 0e04382135..433bc511a5 100644 --- a/packages/lib/src/types/custom.d.ts +++ b/packages/lib/src/types/custom.d.ts @@ -1,12 +1,20 @@ +import { Fastlane, 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 c3cef699be..14df554433 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,12 @@ 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 : [] }; + 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/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..71a6462762 --- /dev/null +++ b/packages/lib/storybook/stories/wallets/Fastlane/Fastlane.stories.tsx @@ -0,0 +1,17 @@ +import { MetaConfiguration, StoryConfiguration } from '../../types'; +import { FastlaneInSinglePageApp } from './FastlaneInSinglePageApp'; + +type FastlaneStory = StoryConfiguration<{}>; + +const meta: MetaConfiguration = { + title: 'Wallets/Fastlane' +}; + +export const Default: FastlaneStory = { + render: checkoutConfig => { + const allowedPaymentTypes = ['scheme', 'paypal']; + return ; + } +}; + +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..a68101f4fc --- /dev/null +++ b/packages/lib/storybook/stories/wallets/Fastlane/FastlaneInSinglePageApp.tsx @@ -0,0 +1,36 @@ +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 { Checkout } from '../../Checkout'; +import { ComponentContainer } from '../../ComponentContainer'; +import { GuestShopperForm } from './components/GuestShopperForm'; + +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..510f4019e6 --- /dev/null +++ b/packages/lib/storybook/stories/wallets/Fastlane/components/CollectEmail.tsx @@ -0,0 +1,79 @@ +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); + console.log('triggerAuthenticationFlow result:', authResult); + 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..1f5a119d2c --- /dev/null +++ b/packages/lib/storybook/stories/wallets/Fastlane/components/FastlaneStory.scss @@ -0,0 +1,64 @@ +.email-container { + display: flex; +} + +.email-input-wrapper { + display: flex; + flex-direction: column; +} + +#watermark-container { + align-self: end; +} + +.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..720e3b20fd --- /dev/null +++ b/packages/lib/storybook/stories/wallets/Fastlane/components/GuestShopperForm.tsx @@ -0,0 +1,71 @@ +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(error => { + console.log(error); + }); + }, []); + + 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;