diff --git a/packages/hosted-widget-integration/.eslintrc.json b/packages/hosted-widget-integration/.eslintrc.json index 23eeef33ce..284c30644a 100644 --- a/packages/hosted-widget-integration/.eslintrc.json +++ b/packages/hosted-widget-integration/.eslintrc.json @@ -2,6 +2,7 @@ "extends": ["../../.eslintrc.json"], "rules": { "@typescript-eslint/no-unsafe-call": "off", - "@typescript-eslint/no-unnecessary-condition": "off" + "@typescript-eslint/no-unnecessary-condition": "off", + "react-hooks/exhaustive-deps": "off" } } diff --git a/packages/hosted-widget-integration/src/EditButton.tsx b/packages/hosted-widget-integration/src/EditButton.tsx new file mode 100644 index 0000000000..4e0bf7672c --- /dev/null +++ b/packages/hosted-widget-integration/src/EditButton.tsx @@ -0,0 +1,31 @@ +import classNames from 'classnames'; +import React, { type ReactNode } from 'react'; + +import { preventDefault } from '@bigcommerce/checkout/dom-utils'; +import { TranslatedString } from '@bigcommerce/checkout/locale'; + +interface EditButtonProps { + buttonId: string | undefined; + shouldShowEditButton: boolean | undefined; +} + +export const EditButton = ({ buttonId, shouldShowEditButton }: EditButtonProps): ReactNode => { + if (shouldShowEditButton) { + const translatedString = ; + + return ( +

+ +

+ ); + } + + return null; +}; diff --git a/packages/hosted-widget-integration/src/HostedWidgetPaymentComponent.tsx b/packages/hosted-widget-integration/src/HostedWidgetPaymentComponent.tsx index ed9301eff0..cc7a38e69d 100644 --- a/packages/hosted-widget-integration/src/HostedWidgetPaymentComponent.tsx +++ b/packages/hosted-widget-integration/src/HostedWidgetPaymentComponent.tsx @@ -10,27 +10,31 @@ import { type PaymentMethod, type PaymentRequestOptions, } from '@bigcommerce/checkout-sdk'; -import classNames from 'classnames'; import { find, noop } from 'lodash'; -import React, { Component, type ReactNode } from 'react'; +import React, { + type ReactElement, + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; import { type ObjectSchema } from 'yup'; -import { preventDefault } from '@bigcommerce/checkout/dom-utils'; import { AccountInstrumentFieldset, assertIsCardInstrument, CardInstrumentFieldset, isBankAccountInstrument, + isCardInstrument, StoreInstrumentFieldset, } from '@bigcommerce/checkout/instrument-utils'; -import { TranslatedString } from '@bigcommerce/checkout/locale'; import { type PaymentFormValues } from '@bigcommerce/checkout/payment-integration-api'; import { LoadingOverlay } from '@bigcommerce/checkout/ui'; -export interface HostedWidgetComponentState { - isAddingNewCard: boolean; - selectedInstrumentId?: string; -} +import { EditButton } from './EditButton'; +import { PaymentDescriptor } from './PaymentDescriptor'; +import { PaymentWidget } from './PaymentWidget'; export interface PaymentContextProps { disableSubmit(method: PaymentMethod, disabled?: boolean): void; @@ -103,429 +107,378 @@ export interface HostedWidgetComponentProps extends WithCheckoutHostedWidgetPaym signInCustomer?(): void; } -interface HostedWidgetPaymentMethodState { - isAddingNewCard: boolean; - selectedInstrumentId?: string; -} +const HostedWidgetPaymentComponent = ({ + instruments, + hideWidget = false, + isInitializing = false, + isAccountInstrument, + isInstrumentFeatureAvailable: isInstrumentFeatureAvailableProp, + isLoadingInstruments, + shouldHideInstrumentExpiryDate = false, + shouldShow = true, + hideVerificationFields, + method, + storedCardValidationSchema, + isPaymentDataRequired, + setValidationSchema, + loadInstruments, + onUnhandledError = noop, + deinitializeCustomer, + deinitializePayment, + setSubmit, + initializeCustomer, + initializePayment, + signInCustomer, + isSignedIn, + isSignInRequired, + isInstrumentCardNumberRequired, + validateInstrument, + containerId, + hideContentWhenSignedOut = false, + renderCustomPaymentForm, + additionalContainerClassName, + shouldRenderCustomInstrument = false, + paymentDescriptor, + shouldShowDescriptor, + shouldShowEditButton, + buttonId, + setFieldValue, +}: HostedWidgetComponentProps & PaymentContextProps): ReactElement => { + const [isAddingNewCard, setIsAddingNewCard] = useState(false); + const [selectedInstrumentId, setSelectedInstrumentId] = useState(undefined); + + const getDefaultInstrumentId = useCallback((): string | undefined => { + if (isAddingNewCard) { + return undefined; + } -class HostedWidgetPaymentComponent extends Component< - HostedWidgetComponentProps & PaymentContextProps -> { - state: HostedWidgetPaymentMethodState = { - isAddingNewCard: false, - }; + const defaultInstrument = + instruments.find((instrument) => instrument.defaultInstrument) || instruments[0]; - async componentDidMount(): Promise { - const { - isInstrumentFeatureAvailable: isInstrumentFeatureAvailableProp, - loadInstruments, - method, - onUnhandledError = noop, - setValidationSchema, - } = this.props; + return defaultInstrument ? defaultInstrument.bigpayToken : undefined; + }, [isAddingNewCard, instruments]); - setValidationSchema(method, this.getValidationSchema()); + const getSelectedInstrument = useCallback((): PaymentInstrument | undefined => { + const currentSelectedId = selectedInstrumentId || getDefaultInstrumentId(); - try { - if (isInstrumentFeatureAvailableProp) { - await loadInstruments(); - } + return find(instruments, { bigpayToken: currentSelectedId }); + }, [instruments, selectedInstrumentId, getDefaultInstrumentId]); - await this.initializeMethod(); - } catch (error) { - onUnhandledError(error); + const getValidationSchema = useCallback((): ObjectSchema | null => { + if (!isPaymentDataRequired) { + return null; } - } - async componentDidUpdate( - prevProps: Readonly< - HostedWidgetComponentProps & WithCheckoutHostedWidgetPaymentMethodProps - >, - prevState: Readonly, - ): Promise { - const { - deinitializePayment, - instruments, - method, - onUnhandledError = noop, - setValidationSchema, - isPaymentDataRequired, - } = this.props; - - const { selectedInstrumentId } = this.state; - - setValidationSchema(method, this.getValidationSchema()); + const currentSelectedInstrument = getSelectedInstrument(); - if ( - selectedInstrumentId !== prevState.selectedInstrumentId || - (prevProps.instruments.length > 0 && instruments.length === 0) || - prevProps.isPaymentDataRequired !== isPaymentDataRequired - ) { - try { - await deinitializePayment({ - gatewayId: method.gateway, - methodId: method.id, - }); - await this.initializeMethod(); - } catch (error) { - onUnhandledError(error); - } + if (isInstrumentFeatureAvailableProp && currentSelectedInstrument) { + return storedCardValidationSchema || null; } - } - async componentWillUnmount(): Promise { - const { - deinitializeCustomer = noop, - deinitializePayment, - method, - onUnhandledError = noop, - setSubmit, - setValidationSchema, - } = this.props; - - setValidationSchema(method, null); - setSubmit(method, null); + return null; + }, [ + getSelectedInstrument, + isInstrumentFeatureAvailableProp, + isPaymentDataRequired, + storedCardValidationSchema, + ]); + + const getSelectedBankAccountInstrument = useCallback( + ( + addingNew: boolean, + currentSelectedInstrument: PaymentInstrument, + ): AccountInstrument | undefined => { + return !addingNew && isBankAccountInstrument(currentSelectedInstrument) + ? currentSelectedInstrument + : undefined; + }, + [], + ); + + const handleDeleteInstrument = useCallback( + (id: string): void => { + if (instruments.length === 0) { + setIsAddingNewCard(true); + setSelectedInstrumentId(undefined); + setFieldValue('instrumentId', ''); - try { + return; + } + + if (selectedInstrumentId === id) { + const nextId = getDefaultInstrumentId(); + + setSelectedInstrumentId(nextId); + setFieldValue('instrumentId', nextId); + } + }, + [instruments, selectedInstrumentId, getDefaultInstrumentId], + ); + + const handleUseNewCard = useCallback(async () => { + setIsAddingNewCard(true); + setSelectedInstrumentId(undefined); + + if (deinitializePayment) { await deinitializePayment({ gatewayId: method.gateway, methodId: method.id, }); + } - // eslint-disable-next-line @typescript-eslint/await-thenable - await deinitializeCustomer({ + if (initializePayment) { + await initializePayment({ + gatewayId: method.gateway, methodId: method.id, }); - } catch (error) { - onUnhandledError(error); } - } + }, [method, deinitializePayment, initializePayment]); - render(): ReactNode { - const { - instruments, - hideWidget = false, - isInitializing = false, - isAccountInstrument, - isInstrumentFeatureAvailable: isInstrumentFeatureAvailableProp, - isLoadingInstruments, - shouldHideInstrumentExpiryDate = false, - shouldShow = true, - } = this.props; - - const { isAddingNewCard, selectedInstrumentId = this.getDefaultInstrumentId() } = - this.state; - - if (!shouldShow) { - return null; - } + const handleSelectInstrument = useCallback((id: string) => { + setIsAddingNewCard(false); + setSelectedInstrumentId(id); + }, []); - const selectedInstrument = - instruments.find((instrument) => instrument.bigpayToken === selectedInstrumentId) || - instruments[0]; - - const shouldShowInstrumentFieldset = - isInstrumentFeatureAvailableProp && instruments.length > 0; - const shouldShowCreditCardFieldset = !shouldShowInstrumentFieldset || isAddingNewCard; - const isLoading = (isInitializing || isLoadingInstruments) && !hideWidget; - - const selectedAccountInstrument = this.getSelectedBankAccountInstrument( - isAddingNewCard, - selectedInstrument, - ); - const shouldShowAccountInstrument = - instruments[0] && isBankAccountInstrument(instruments[0]); - - return ( - -
- {shouldShowAccountInstrument && shouldShowInstrumentFieldset && ( - - )} - - {!shouldShowAccountInstrument && shouldShowInstrumentFieldset && ( - - )} - - {this.renderPaymentDescriptorIfAvailable()} - - {this.renderContainer(shouldShowCreditCardFieldset)} - - {isInstrumentFeatureAvailableProp && ( - - )} - - {this.renderEditButtonIfAvailable()} -
-
- ); - } + const getValidateInstrument = useCallback((): ReactNode | undefined => { + const currentSelectedId = selectedInstrumentId || getDefaultInstrumentId(); + const currentSelectedInstrument = find(instruments, { bigpayToken: currentSelectedId }); + + if (currentSelectedInstrument) { + assertIsCardInstrument(currentSelectedInstrument); - getValidateInstrument(): ReactNode { - const { - hideVerificationFields, - instruments, - method, - isInstrumentCardNumberRequired: isInstrumentCardNumberRequiredProp, - validateInstrument, - } = this.props; - - const { selectedInstrumentId = this.getDefaultInstrumentId() } = this.state; - const selectedInstrument = find(instruments, { - bigpayToken: selectedInstrumentId, - }); - - if (selectedInstrument) { - assertIsCardInstrument(selectedInstrument); - - const shouldShowNumberField = isInstrumentCardNumberRequiredProp( - selectedInstrument, + const shouldShowNumberField = isInstrumentCardNumberRequired( + currentSelectedInstrument, method, ); if (hideVerificationFields) { - return; + return undefined; } if (validateInstrument) { - return validateInstrument(shouldShowNumberField, selectedInstrument); + return validateInstrument(shouldShowNumberField, currentSelectedInstrument); } } - } - - renderContainer(shouldShowCreditCardFieldset: any): ReactNode { - const { - containerId, - hideContentWhenSignedOut = false, - hideWidget, - isSignInRequired = false, - isSignedIn, - method, - additionalContainerClassName, - shouldRenderCustomInstrument = false, - renderCustomPaymentForm, - } = this.props; - - return ( -
- {shouldRenderCustomInstrument && - renderCustomPaymentForm && - renderCustomPaymentForm()} -
- ); - } - - private getValidationSchema(): ObjectSchema | null { - const { - isInstrumentFeatureAvailable: isInstrumentFeatureAvailableProp, - isPaymentDataRequired, - storedCardValidationSchema, - } = this.props; + return undefined; + }, [ + selectedInstrumentId, + getDefaultInstrumentId, + instruments, + method, + hideVerificationFields, + validateInstrument, + ]); + + const initializeMethod = async (): Promise => { if (!isPaymentDataRequired) { - return null; - } - - const selectedInstrument = this.getSelectedInstrument(); + setSubmit(method, null); - if (isInstrumentFeatureAvailableProp && selectedInstrument) { - return storedCardValidationSchema || null; + return; } - return null; - } - - private getSelectedInstrument(): PaymentInstrument | undefined { - const { instruments } = this.props; - const { selectedInstrumentId = this.getDefaultInstrumentId() } = this.state; + if (isSignInRequired && !isSignedIn) { + setSubmit(method, signInCustomer || null); - return find(instruments, { bigpayToken: selectedInstrumentId }); - } + if (initializeCustomer) { + return initializeCustomer({ methodId: method.id }); + } - private handleDeleteInstrument: (id: string) => void = (id) => { - const { instruments, setFieldValue } = this.props; - const { selectedInstrumentId } = this.state; + return; + } - if (instruments.length === 0) { - this.setState({ - isAddingNewCard: true, - selectedInstrumentId: undefined, - }); + setSubmit(method, null); - setFieldValue('instrumentId', ''); - } else if (selectedInstrumentId === id) { - this.setState({ - selectedInstrumentId: this.getDefaultInstrumentId(), - }); + let selectedCardInstrument: CardInstrument | undefined; - setFieldValue('instrumentId', this.getDefaultInstrumentId()); + if (!isAddingNewCard) { + const currentSelectedInstrumentId = selectedInstrumentId || getDefaultInstrumentId(); + const maybeInstrument = + instruments.find( + (instrument) => instrument.bigpayToken === currentSelectedInstrumentId, + ) || instruments[0]; + + if (maybeInstrument && isCardInstrument(maybeInstrument)) { + selectedCardInstrument = maybeInstrument; + } } - }; - private getSelectedBankAccountInstrument( - isAddingNewCard: boolean, - selectedInstrument: PaymentInstrument, - ): AccountInstrument | undefined { - return !isAddingNewCard && isBankAccountInstrument(selectedInstrument) - ? selectedInstrument - : undefined; - } - - private renderEditButtonIfAvailable() { - const { shouldShowEditButton, buttonId } = this.props; - const translatedString = ; - - if (shouldShowEditButton) { - return ( -

- { - // eslint-disable-next-line jsx-a11y/anchor-is-valid, jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions - - {translatedString} - - } -

+ if (initializePayment) { + return initializePayment( + { gatewayId: method.gateway, methodId: method.id }, + selectedCardInstrument, ); } - } - - private renderPaymentDescriptorIfAvailable() { - const { shouldShowDescriptor, paymentDescriptor } = this.props; + }; - if (shouldShowDescriptor && paymentDescriptor) { - return
{paymentDescriptor}
; - } - } + // Below values are for lower level components + const effectiveSelectedInstrumentId = selectedInstrumentId || getDefaultInstrumentId(); + const selectedInstrument = effectiveSelectedInstrumentId + ? instruments.find((i) => i.bigpayToken === effectiveSelectedInstrumentId) || instruments[0] + : instruments[0]; + const cardInstruments: CardInstrument[] = instruments.filter( + (i): i is CardInstrument => !isBankAccountInstrument(i), + ); + const accountInstruments: AccountInstrument[] = instruments.filter( + (i): i is AccountInstrument => isBankAccountInstrument(i), + ); + const shouldShowInstrumentFieldset = isInstrumentFeatureAvailableProp && instruments.length > 0; + const shouldShowCreditCardFieldset = !shouldShowInstrumentFieldset || isAddingNewCard; + const isLoading = (isInitializing || isLoadingInstruments) && !hideWidget; + const selectedAccountInstrument = selectedInstrument + ? getSelectedBankAccountInstrument(isAddingNewCard, selectedInstrument) + : undefined; + const shouldShowAccountInstrument = instruments[0] && isBankAccountInstrument(instruments[0]); + + useEffect(() => { + const init = async () => { + setValidationSchema(method, getValidationSchema()); - private async initializeMethod(): Promise { - const { - isPaymentDataRequired, - isSignedIn, - isSignInRequired, - initializeCustomer = noop, - initializePayment = noop, - instruments, - method, - setSubmit, - signInCustomer = noop, - } = this.props; + try { + if (isInstrumentFeatureAvailableProp) { + await loadInstruments?.(); + } + + await initializeMethod(); + } catch (error: unknown) { + if (error instanceof Error) { + onUnhandledError(error); + } + } + }; - const { selectedInstrumentId = this.getDefaultInstrumentId(), isAddingNewCard } = - this.state; + void init(); - let selectedInstrument; + return () => { + const deInit = async () => { + setValidationSchema(method, null); + setSubmit(method, null); - if (!isPaymentDataRequired) { - setSubmit(method, null); + try { + if (deinitializePayment) { + await deinitializePayment({ + gatewayId: method.gateway, + methodId: method.id, + }); + } - return Promise.resolve(); - } + if (deinitializeCustomer) { + await deinitializeCustomer({ methodId: method.id }); + } + } catch (error: unknown) { + if (error instanceof Error) { + onUnhandledError(error); + } + } + }; - if (isSignInRequired && !isSignedIn) { - setSubmit(method, signInCustomer); + void deInit(); + }; + }, []); - return initializeCustomer({ - methodId: method.id, - }); - } + const isInitialRenderRef = useRef(true); + const instrumentsLength = useRef(instruments.length); + const isPaymentDataRequiredRef = useRef(isPaymentDataRequired); + const selectedInstrumentIdRef = useRef(selectedInstrumentId); - setSubmit(method, null); + useEffect(() => { + if (isInitialRenderRef.current) { + isInitialRenderRef.current = false; - if (!isAddingNewCard) { - selectedInstrument = - instruments.find((instrument) => instrument.bigpayToken === selectedInstrumentId) || - instruments[0]; + return; } - return initializePayment( - { - gatewayId: method.gateway, - methodId: method.id, - }, - selectedInstrument, - ); - } + setValidationSchema(method, getValidationSchema()); - private getDefaultInstrumentId(): string | undefined { - const { isAddingNewCard } = this.state; + const reInit = async () => { + try { + if (deinitializePayment) { + await deinitializePayment({ + gatewayId: method.gateway, + methodId: method.id, + }); + } + + await initializeMethod(); + } catch (error: unknown) { + if (error instanceof Error) { + onUnhandledError(error); + } + } + }; - if (isAddingNewCard) { - return; - } + if ( + selectedInstrumentIdRef.current !== selectedInstrumentId || + (Number(instrumentsLength.current) > 0 && instruments.length === 0) || + isPaymentDataRequiredRef.current !== isPaymentDataRequired + ) { + selectedInstrumentIdRef.current = selectedInstrumentId; + instrumentsLength.current = instruments.length; + isPaymentDataRequiredRef.current = isPaymentDataRequired; - const { instruments } = this.props; - const defaultInstrument = - instruments.find((instrument) => instrument.defaultInstrument) || instruments[0]; + void reInit(); + } + }, [selectedInstrumentId, instruments, isPaymentDataRequired]); - return defaultInstrument && defaultInstrument.bigpayToken; + if (!shouldShow) { + return
; } - private handleUseNewCard: () => void = async () => { - const { deinitializePayment, initializePayment = noop, method } = this.props; - - this.setState({ - isAddingNewCard: true, - selectedInstrumentId: undefined, - }); - - await deinitializePayment({ - gatewayId: method.gateway, - methodId: method.id, - }); + return ( + +
+ {shouldShowAccountInstrument && shouldShowInstrumentFieldset && ( + + )} + {!shouldShowAccountInstrument && shouldShowInstrumentFieldset && ( + + )} - // eslint-disable-next-line @typescript-eslint/await-thenable - await initializePayment({ - gatewayId: method.gateway, - methodId: method.id, - }); - }; + + + + + {isInstrumentFeatureAvailableProp && ( + + )} - private handleSelectInstrument: (id: string) => void = (id) => { - this.setState({ - isAddingNewCard: false, - selectedInstrumentId: id, - }); - }; -} + +
+
+ ); +}; export default HostedWidgetPaymentComponent; diff --git a/packages/hosted-widget-integration/src/PaymentDescriptor.tsx b/packages/hosted-widget-integration/src/PaymentDescriptor.tsx new file mode 100644 index 0000000000..733d5cba76 --- /dev/null +++ b/packages/hosted-widget-integration/src/PaymentDescriptor.tsx @@ -0,0 +1,17 @@ +import React, { type ReactNode } from 'react'; + +interface PaymentDescriptorProps { + paymentDescriptor: string | undefined; + shouldShowDescriptor: boolean | undefined; +} + +export const PaymentDescriptor = ({ + shouldShowDescriptor, + paymentDescriptor, +}: PaymentDescriptorProps): ReactNode => { + if (shouldShowDescriptor && paymentDescriptor) { + return
{paymentDescriptor}
; + } + + return null; +}; diff --git a/packages/hosted-widget-integration/src/PaymentWidget.tsx b/packages/hosted-widget-integration/src/PaymentWidget.tsx new file mode 100644 index 0000000000..eb3891a8e5 --- /dev/null +++ b/packages/hosted-widget-integration/src/PaymentWidget.tsx @@ -0,0 +1,50 @@ +import { type PaymentMethod } from '@bigcommerce/checkout-sdk'; +import classNames from 'classnames'; +import React, { type ReactElement } from 'react'; + +interface PaymentWidgetProps { + additionalContainerClassName: string | undefined; + containerId: string; + hideContentWhenSignedOut: boolean; + hideWidget: boolean; + isSignInRequired: boolean | undefined; + isSignedIn: boolean; + method: PaymentMethod; + renderCustomPaymentForm: (() => React.ReactNode) | undefined; + shouldRenderCustomInstrument: boolean; + shouldShowCreditCardFieldset: boolean; +} + +export const PaymentWidget = ({ + additionalContainerClassName, + containerId, + hideContentWhenSignedOut, + hideWidget, + isSignInRequired, + isSignedIn, + method, + renderCustomPaymentForm, + shouldRenderCustomInstrument, + shouldShowCreditCardFieldset, +}: PaymentWidgetProps): ReactElement => ( +
+ {shouldRenderCustomInstrument && renderCustomPaymentForm && renderCustomPaymentForm()} +
+);