From b25a696a15fc430bd19cc93c9f97ca0101d830e5 Mon Sep 17 00:00:00 2001 From: Peng Zhou Date: Thu, 4 Sep 2025 15:12:22 +1000 Subject: [PATCH 1/6] refactor(checkout): CHECKOUT-9386 Convert HostedWidgetPaymentComponent --- .../hosted-widget-integration/.eslintrc.json | 3 +- .../src/HostedWidgetPaymentComponent.tsx | 687 ++++++++---------- 2 files changed, 324 insertions(+), 366 deletions(-) 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/HostedWidgetPaymentComponent.tsx b/packages/hosted-widget-integration/src/HostedWidgetPaymentComponent.tsx index ed9301eff0..687d8f7341 100644 --- a/packages/hosted-widget-integration/src/HostedWidgetPaymentComponent.tsx +++ b/packages/hosted-widget-integration/src/HostedWidgetPaymentComponent.tsx @@ -12,7 +12,14 @@ import { } 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'; @@ -21,26 +28,15 @@ import { 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; -} - export interface PaymentContextProps { disableSubmit(method: PaymentMethod, disabled?: boolean): void; - // NOTE: This prop allows certain payment methods to override the default - // form submission behaviour. It is not recommended to use it because - // generally speaking we want to avoid method-specific snowflake behaviours. - // Nevertheless, because of some product / UX decisions made in the past - // (i.e.: Amazon), we have to have this backdoor so we can preserve these - // snowflake behaviours. In the future, if we decide to change the UX, we - // can remove this prop. setSubmit(method: PaymentMethod, fn: ((values: PaymentFormValues) => void) | null): void; setFieldValue( field: TField, @@ -103,429 +99,390 @@ 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()); - - 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); - } + const currentSelectedInstrument = getSelectedInstrument(); + + 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 = ( + addingNew: boolean, + currentSelectedInstrument: PaymentInstrument, + ): AccountInstrument | undefined => { + return !addingNew && isBankAccountInstrument(currentSelectedInstrument) + ? currentSelectedInstrument + : undefined; + }; + + const handleDeleteInstrument = (id: string): void => { + if (instruments.length === 0) { + setIsAddingNewCard(true); + setSelectedInstrumentId(undefined); + setFieldValue('instrumentId', ''); + + return; + } + + if (selectedInstrumentId === id) { + const nextId = getDefaultInstrumentId(); + + setSelectedInstrumentId(nextId); + setFieldValue('instrumentId', nextId); + } + }; - try { + const handleUseNewCard = 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); } - } + }; - 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 = (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 = (): 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; + }; + const initializeMethod = async (): Promise => { if (!isPaymentDataRequired) { - return null; - } - - const selectedInstrument = this.getSelectedInstrument(); + setSubmit(method, null); - if (isInstrumentFeatureAvailableProp && selectedInstrument) { - return storedCardValidationSchema || null; + return; } - return null; - } + if (isSignInRequired && !isSignedIn) { + setSubmit(method, signInCustomer || null); - private getSelectedInstrument(): PaymentInstrument | undefined { - const { instruments } = this.props; - const { selectedInstrumentId = this.getDefaultInstrumentId() } = this.state; + if (initializeCustomer) { + return initializeCustomer({ methodId: method.id }); + } - return find(instruments, { bigpayToken: selectedInstrumentId }); - } + return; + } - private handleDeleteInstrument: (id: string) => void = (id) => { - const { instruments, setFieldValue } = this.props; - const { selectedInstrumentId } = this.state; + setSubmit(method, null); - if (instruments.length === 0) { - this.setState({ - isAddingNewCard: true, - selectedInstrumentId: undefined, - }); + let selectedCardInstrument: CardInstrument | undefined; - setFieldValue('instrumentId', ''); - } else if (selectedInstrumentId === id) { - this.setState({ - selectedInstrumentId: this.getDefaultInstrumentId(), - }); + if (!isAddingNewCard) { + const currentSelectedInstrumentId = selectedInstrumentId || getDefaultInstrumentId(); + const maybeInstrument = + instruments.find( + (instrument) => instrument.bigpayToken === currentSelectedInstrumentId, + ) || instruments[0]; + + if (maybeInstrument && isCardInstrument(maybeInstrument)) { + selectedCardInstrument = maybeInstrument; + } + } - setFieldValue('instrumentId', this.getDefaultInstrumentId()); + if (initializePayment) { + return initializePayment( + { gatewayId: method.gateway, methodId: method.id }, + selectedCardInstrument, + ); } }; - private getSelectedBankAccountInstrument( - isAddingNewCard: boolean, - selectedInstrument: PaymentInstrument, - ): AccountInstrument | undefined { - return !isAddingNewCard && isBankAccountInstrument(selectedInstrument) - ? selectedInstrument - : undefined; - } + // 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 renderEditButtonIfAvailable() { - const { shouldShowEditButton, buttonId } = this.props; - const translatedString = ; + try { + if (isInstrumentFeatureAvailableProp) { + await loadInstruments?.(); + } + + await initializeMethod(); + } catch (error: unknown) { + if (error instanceof Error) { + onUnhandledError(error); + } + } + }; - 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} - - } -

- ); - } - } + void init(); - private renderPaymentDescriptorIfAvailable() { - const { shouldShowDescriptor, paymentDescriptor } = this.props; + return () => { + const deInit = async () => { + setValidationSchema(method, null); + setSubmit(method, null); - if (shouldShowDescriptor && paymentDescriptor) { - return
{paymentDescriptor}
; - } - } + try { + if (deinitializePayment) { + await deinitializePayment({ + gatewayId: method.gateway, + methodId: method.id, + }); + } - private async initializeMethod(): Promise { - const { - isPaymentDataRequired, - isSignedIn, - isSignInRequired, - initializeCustomer = noop, - initializePayment = noop, - instruments, - method, - setSubmit, - signInCustomer = noop, - } = this.props; + if (deinitializeCustomer) { + await deinitializeCustomer({ methodId: method.id }); + } + } catch (error: unknown) { + if (error instanceof Error) { + onUnhandledError(error); + } + } + }; - const { selectedInstrumentId = this.getDefaultInstrumentId(), isAddingNewCard } = - this.state; + void deInit(); + }; + }, []); - let selectedInstrument; + const isInitialRenderRef = useRef(true); - if (!isPaymentDataRequired) { - setSubmit(method, null); + useEffect(() => { + if (isInitialRenderRef.current) { + isInitialRenderRef.current = false; - return Promise.resolve(); + return; } - if (isSignInRequired && !isSignedIn) { - setSubmit(method, signInCustomer); + const reInit = async () => { + setValidationSchema(method, getValidationSchema()); - return initializeCustomer({ - methodId: method.id, - }); - } + try { + if (deinitializePayment) { + await deinitializePayment({ + gatewayId: method.gateway, + methodId: method.id, + }); + } + + await initializeMethod(); + } catch (error: unknown) { + if (error instanceof Error) { + onUnhandledError(error); + } + } + }; - setSubmit(method, null); + void reInit(); + }, [selectedInstrumentId, instruments, isPaymentDataRequired]); - if (!isAddingNewCard) { - selectedInstrument = - instruments.find((instrument) => instrument.bigpayToken === selectedInstrumentId) || - instruments[0]; + const PaymentDescriptor = (): ReactNode => { + if (shouldShowDescriptor && paymentDescriptor) { + return
{paymentDescriptor}
; } - return initializePayment( - { - gatewayId: method.gateway, - methodId: method.id, - }, - selectedInstrument, - ); - } + return null; + }; - private getDefaultInstrumentId(): string | undefined { - const { isAddingNewCard } = this.state; + const PaymentWidget = (): ReactElement => ( +
+ {shouldRenderCustomInstrument && renderCustomPaymentForm && renderCustomPaymentForm()} +
+ ); + + const EditButton = (): ReactNode => { + if (shouldShowEditButton) { + const translatedString = ; - if (isAddingNewCard) { - return; + return ( +

+ +

+ ); } - const { instruments } = this.props; - const defaultInstrument = - instruments.find((instrument) => instrument.defaultInstrument) || instruments[0]; + return null; + }; - return defaultInstrument && defaultInstrument.bigpayToken; + if (!shouldShow) { + return
; } - private handleUseNewCard: () => void = async () => { - const { deinitializePayment, initializePayment = noop, method } = this.props; + return ( + +
+ {shouldShowAccountInstrument && shouldShowInstrumentFieldset && ( + + )} + {!shouldShowAccountInstrument && shouldShowInstrumentFieldset && ( + + )} - this.setState({ - isAddingNewCard: true, - selectedInstrumentId: undefined, - }); + - await deinitializePayment({ - gatewayId: method.gateway, - methodId: method.id, - }); + - // 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; From 0d8311dd67880ae96f1606609a565286c46d9226 Mon Sep 17 00:00:00 2001 From: Peng Zhou Date: Thu, 4 Sep 2025 16:43:44 +1000 Subject: [PATCH 2/6] Update reInit conditions --- .../src/HostedWidgetPaymentComponent.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/hosted-widget-integration/src/HostedWidgetPaymentComponent.tsx b/packages/hosted-widget-integration/src/HostedWidgetPaymentComponent.tsx index 687d8f7341..a374cb3194 100644 --- a/packages/hosted-widget-integration/src/HostedWidgetPaymentComponent.tsx +++ b/packages/hosted-widget-integration/src/HostedWidgetPaymentComponent.tsx @@ -356,6 +356,9 @@ const HostedWidgetPaymentComponent = ({ }, []); const isInitialRenderRef = useRef(true); + const instrumentsLength = useRef(instruments.length); + const isPaymentDataRequiredRef = useRef(isPaymentDataRequired); + const selectedInstrumentIdRef = useRef(selectedInstrumentId); useEffect(() => { if (isInitialRenderRef.current) { @@ -364,9 +367,9 @@ const HostedWidgetPaymentComponent = ({ return; } - const reInit = async () => { - setValidationSchema(method, getValidationSchema()); + setValidationSchema(method, getValidationSchema()); + const reInit = async () => { try { if (deinitializePayment) { await deinitializePayment({ @@ -383,7 +386,17 @@ const HostedWidgetPaymentComponent = ({ } }; - void reInit(); + if ( + selectedInstrumentIdRef.current !== selectedInstrumentId || + (Number(instrumentsLength.current) > 0 && instruments.length === 0) || + isPaymentDataRequiredRef.current !== isPaymentDataRequired + ) { + selectedInstrumentIdRef.current = selectedInstrumentId; + instrumentsLength.current = instruments.length; + isPaymentDataRequiredRef.current = isPaymentDataRequired; + + void reInit(); + } }, [selectedInstrumentId, instruments, isPaymentDataRequired]); const PaymentDescriptor = (): ReactNode => { From f3a71e95e1c50e9a48025da641c8ef83e421ec80 Mon Sep 17 00:00:00 2001 From: Peng Zhou Date: Tue, 9 Sep 2025 15:42:54 +1000 Subject: [PATCH 3/6] Add useCallback and useMemo --- .../src/EditButton.tsx | 31 +++ .../src/HostedWidgetPaymentComponent.tsx | 205 +++++++++--------- .../src/PaymentDescriptor.tsx | 17 ++ .../src/PaymentWidget.tsx | 50 +++++ 4 files changed, 202 insertions(+), 101 deletions(-) create mode 100644 packages/hosted-widget-integration/src/EditButton.tsx create mode 100644 packages/hosted-widget-integration/src/PaymentDescriptor.tsx create mode 100644 packages/hosted-widget-integration/src/PaymentWidget.tsx 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 a374cb3194..7d6072b88e 100644 --- a/packages/hosted-widget-integration/src/HostedWidgetPaymentComponent.tsx +++ b/packages/hosted-widget-integration/src/HostedWidgetPaymentComponent.tsx @@ -10,19 +10,18 @@ import { type PaymentMethod, type PaymentRequestOptions, } from '@bigcommerce/checkout-sdk'; -import classNames from 'classnames'; import { find, noop } from 'lodash'; import React, { type ReactElement, type ReactNode, useCallback, useEffect, + useMemo, useRef, useState, } from 'react'; import { type ObjectSchema } from 'yup'; -import { preventDefault } from '@bigcommerce/checkout/dom-utils'; import { AccountInstrumentFieldset, assertIsCardInstrument, @@ -31,10 +30,13 @@ import { 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'; +import { EditButton } from './EditButton'; +import { PaymentDescriptor } from './PaymentDescriptor'; +import { PaymentWidget } from './PaymentWidget'; + export interface PaymentContextProps { disableSubmit(method: PaymentMethod, disabled?: boolean): void; setSubmit(method: PaymentMethod, fn: ((values: PaymentFormValues) => void) | null): void; @@ -175,33 +177,39 @@ const HostedWidgetPaymentComponent = ({ storedCardValidationSchema, ]); - const getSelectedBankAccountInstrument = ( - addingNew: boolean, - currentSelectedInstrument: PaymentInstrument, - ): AccountInstrument | undefined => { - return !addingNew && isBankAccountInstrument(currentSelectedInstrument) - ? currentSelectedInstrument - : undefined; - }; + const getSelectedBankAccountInstrument = useCallback( + ( + addingNew: boolean, + currentSelectedInstrument: PaymentInstrument, + ): AccountInstrument | undefined => { + return !addingNew && isBankAccountInstrument(currentSelectedInstrument) + ? currentSelectedInstrument + : undefined; + }, + [], + ); - const handleDeleteInstrument = (id: string): void => { - if (instruments.length === 0) { - setIsAddingNewCard(true); - setSelectedInstrumentId(undefined); - setFieldValue('instrumentId', ''); + const handleDeleteInstrument = useCallback( + (id: string): void => { + if (instruments.length === 0) { + setIsAddingNewCard(true); + setSelectedInstrumentId(undefined); + setFieldValue('instrumentId', ''); - return; - } + return; + } - if (selectedInstrumentId === id) { - const nextId = getDefaultInstrumentId(); + if (selectedInstrumentId === id) { + const nextId = getDefaultInstrumentId(); - setSelectedInstrumentId(nextId); - setFieldValue('instrumentId', nextId); - } - }; + setSelectedInstrumentId(nextId); + setFieldValue('instrumentId', nextId); + } + }, + [instruments, selectedInstrumentId, getDefaultInstrumentId], + ); - const handleUseNewCard = async () => { + const handleUseNewCard = useCallback(async () => { setIsAddingNewCard(true); setSelectedInstrumentId(undefined); @@ -218,14 +226,14 @@ const HostedWidgetPaymentComponent = ({ methodId: method.id, }); } - }; + }, [method, deinitializePayment, initializePayment]); - const handleSelectInstrument = (id: string) => { + const handleSelectInstrument = useCallback((id: string) => { setIsAddingNewCard(false); setSelectedInstrumentId(id); - }; + }, []); - const getValidateInstrument = (): ReactNode | undefined => { + const getValidateInstrument = useCallback((): ReactNode | undefined => { const currentSelectedId = selectedInstrumentId || getDefaultInstrumentId(); const currentSelectedInstrument = find(instruments, { bigpayToken: currentSelectedId }); @@ -247,7 +255,14 @@ const HostedWidgetPaymentComponent = ({ } return undefined; - }; + }, [ + selectedInstrumentId, + getDefaultInstrumentId, + instruments, + method, + hideVerificationFields, + validateInstrument, + ]); const initializeMethod = async (): Promise => { if (!isPaymentDataRequired) { @@ -291,23 +306,49 @@ const HostedWidgetPaymentComponent = ({ }; // 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 effectiveSelectedInstrumentId = useMemo( + () => selectedInstrumentId || getDefaultInstrumentId(), + [selectedInstrumentId, getDefaultInstrumentId], + ); + const selectedInstrument = useMemo( + () => + effectiveSelectedInstrumentId + ? instruments.find((i) => i.bigpayToken === effectiveSelectedInstrumentId) || + instruments[0] + : instruments[0], + [instruments, effectiveSelectedInstrumentId], + ); + const cardInstruments: CardInstrument[] = useMemo( + () => instruments.filter((i): i is CardInstrument => !isBankAccountInstrument(i)), + [instruments], + ); + const accountInstruments: AccountInstrument[] = useMemo( + () => instruments.filter((i): i is AccountInstrument => isBankAccountInstrument(i)), + [instruments], ); - const accountInstruments: AccountInstrument[] = instruments.filter( - (i): i is AccountInstrument => isBankAccountInstrument(i), + const shouldShowInstrumentFieldset = useMemo( + () => isInstrumentFeatureAvailableProp && instruments.length > 0, + [isInstrumentFeatureAvailableProp, instruments], + ); + const shouldShowCreditCardFieldset = useMemo( + () => !shouldShowInstrumentFieldset || isAddingNewCard, + [shouldShowInstrumentFieldset, isAddingNewCard], + ); + const isLoading = useMemo( + () => (isInitializing || isLoadingInstruments) && !hideWidget, + [isInitializing, isLoadingInstruments, hideWidget], + ); + const selectedAccountInstrument = useMemo( + () => + selectedInstrument + ? getSelectedBankAccountInstrument(isAddingNewCard, selectedInstrument) + : undefined, + [selectedInstrument, isAddingNewCard], + ); + const shouldShowAccountInstrument = useMemo( + () => instruments[0] && isBankAccountInstrument(instruments[0]), + [instruments], ); - 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 () => { @@ -399,58 +440,6 @@ const HostedWidgetPaymentComponent = ({ } }, [selectedInstrumentId, instruments, isPaymentDataRequired]); - const PaymentDescriptor = (): ReactNode => { - if (shouldShowDescriptor && paymentDescriptor) { - return
{paymentDescriptor}
; - } - - return null; - }; - - const PaymentWidget = (): ReactElement => ( -
- {shouldRenderCustomInstrument && renderCustomPaymentForm && renderCustomPaymentForm()} -
- ); - - const EditButton = (): ReactNode => { - if (shouldShowEditButton) { - const translatedString = ; - - return ( -

- -

- ); - } - - return null; - }; - if (!shouldShow) { return
; } @@ -478,9 +467,23 @@ const HostedWidgetPaymentComponent = ({ /> )} - - - + + + {isInstrumentFeatureAvailableProp && ( )} - +
); 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()} +
+); From 3d89d109720083ce2ef95dd50bfc8c9e85a66fc5 Mon Sep 17 00:00:00 2001 From: Peng Zhou Date: Tue, 9 Sep 2025 16:20:53 +1000 Subject: [PATCH 4/6] Remove useMemo --- .../src/HostedWidgetPaymentComponent.tsx | 64 +++++++------------ 1 file changed, 22 insertions(+), 42 deletions(-) diff --git a/packages/hosted-widget-integration/src/HostedWidgetPaymentComponent.tsx b/packages/hosted-widget-integration/src/HostedWidgetPaymentComponent.tsx index 7d6072b88e..cc7a38e69d 100644 --- a/packages/hosted-widget-integration/src/HostedWidgetPaymentComponent.tsx +++ b/packages/hosted-widget-integration/src/HostedWidgetPaymentComponent.tsx @@ -16,7 +16,6 @@ import React, { type ReactNode, useCallback, useEffect, - useMemo, useRef, useState, } from 'react'; @@ -39,6 +38,13 @@ import { PaymentWidget } from './PaymentWidget'; export interface PaymentContextProps { disableSubmit(method: PaymentMethod, disabled?: boolean): void; + // NOTE: This prop allows certain payment methods to override the default + // form submission behaviour. It is not recommended to use it because + // generally speaking we want to avoid method-specific snowflake behaviours. + // Nevertheless, because of some product / UX decisions made in the past + // (i.e.: Amazon), we have to have this backdoor so we can preserve these + // snowflake behaviours. In the future, if we decide to change the UX, we + // can remove this prop. setSubmit(method: PaymentMethod, fn: ((values: PaymentFormValues) => void) | null): void; setFieldValue( field: TField, @@ -306,49 +312,23 @@ const HostedWidgetPaymentComponent = ({ }; // Below values are for lower level components - const effectiveSelectedInstrumentId = useMemo( - () => selectedInstrumentId || getDefaultInstrumentId(), - [selectedInstrumentId, getDefaultInstrumentId], + 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 selectedInstrument = useMemo( - () => - effectiveSelectedInstrumentId - ? instruments.find((i) => i.bigpayToken === effectiveSelectedInstrumentId) || - instruments[0] - : instruments[0], - [instruments, effectiveSelectedInstrumentId], - ); - const cardInstruments: CardInstrument[] = useMemo( - () => instruments.filter((i): i is CardInstrument => !isBankAccountInstrument(i)), - [instruments], - ); - const accountInstruments: AccountInstrument[] = useMemo( - () => instruments.filter((i): i is AccountInstrument => isBankAccountInstrument(i)), - [instruments], - ); - const shouldShowInstrumentFieldset = useMemo( - () => isInstrumentFeatureAvailableProp && instruments.length > 0, - [isInstrumentFeatureAvailableProp, instruments], - ); - const shouldShowCreditCardFieldset = useMemo( - () => !shouldShowInstrumentFieldset || isAddingNewCard, - [shouldShowInstrumentFieldset, isAddingNewCard], - ); - const isLoading = useMemo( - () => (isInitializing || isLoadingInstruments) && !hideWidget, - [isInitializing, isLoadingInstruments, hideWidget], - ); - const selectedAccountInstrument = useMemo( - () => - selectedInstrument - ? getSelectedBankAccountInstrument(isAddingNewCard, selectedInstrument) - : undefined, - [selectedInstrument, isAddingNewCard], - ); - const shouldShowAccountInstrument = useMemo( - () => instruments[0] && isBankAccountInstrument(instruments[0]), - [instruments], + 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 () => { From 12b48f46571b33a854f59548be38daa07b0d01a7 Mon Sep 17 00:00:00 2001 From: Vitaliy Koshovyi Date: Wed, 1 Oct 2025 17:38:31 +0300 Subject: [PATCH 5/6] fix(payment): instruments ref fix --- .../src/HostedWidgetPaymentComponent.tsx | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/hosted-widget-integration/src/HostedWidgetPaymentComponent.tsx b/packages/hosted-widget-integration/src/HostedWidgetPaymentComponent.tsx index cc7a38e69d..37464b61c1 100644 --- a/packages/hosted-widget-integration/src/HostedWidgetPaymentComponent.tsx +++ b/packages/hosted-widget-integration/src/HostedWidgetPaymentComponent.tsx @@ -146,6 +146,11 @@ const HostedWidgetPaymentComponent = ({ }: HostedWidgetComponentProps & PaymentContextProps): ReactElement => { const [isAddingNewCard, setIsAddingNewCard] = useState(false); const [selectedInstrumentId, setSelectedInstrumentId] = useState(undefined); + const instrumentsRef = useRef(instruments); + + useEffect(() => { + instrumentsRef.current = instruments; + }, [instruments]); const getDefaultInstrumentId = useCallback((): string | undefined => { if (isAddingNewCard) { @@ -153,16 +158,17 @@ const HostedWidgetPaymentComponent = ({ } const defaultInstrument = - instruments.find((instrument) => instrument.defaultInstrument) || instruments[0]; + instrumentsRef.current.find((instrument) => instrument.defaultInstrument) || + instrumentsRef.current[0]; return defaultInstrument ? defaultInstrument.bigpayToken : undefined; - }, [isAddingNewCard, instruments]); + }, [isAddingNewCard]); const getSelectedInstrument = useCallback((): PaymentInstrument | undefined => { const currentSelectedId = selectedInstrumentId || getDefaultInstrumentId(); - return find(instruments, { bigpayToken: currentSelectedId }); - }, [instruments, selectedInstrumentId, getDefaultInstrumentId]); + return find(instrumentsRef.current, { bigpayToken: currentSelectedId }); + }, [selectedInstrumentId, getDefaultInstrumentId]); const getValidationSchema = useCallback((): ObjectSchema | null => { if (!isPaymentDataRequired) { @@ -271,6 +277,8 @@ const HostedWidgetPaymentComponent = ({ ]); const initializeMethod = async (): Promise => { + const currentInstruments = instrumentsRef.current; + if (!isPaymentDataRequired) { setSubmit(method, null); @@ -294,9 +302,9 @@ const HostedWidgetPaymentComponent = ({ if (!isAddingNewCard) { const currentSelectedInstrumentId = selectedInstrumentId || getDefaultInstrumentId(); const maybeInstrument = - instruments.find( + currentInstruments.find( (instrument) => instrument.bigpayToken === currentSelectedInstrumentId, - ) || instruments[0]; + ) || currentInstruments[0]; if (maybeInstrument && isCardInstrument(maybeInstrument)) { selectedCardInstrument = maybeInstrument; From f9824fb154683590c05f3d69bbe42ce43967826b Mon Sep 17 00:00:00 2001 From: Vitaliy Koshovyi Date: Wed, 1 Oct 2025 19:52:59 +0300 Subject: [PATCH 6/6] fix(payment): added additional setTimeout to fix a queue --- .../storedInstrument/InstrumentSelect/InstrumentSelect.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/instrument-utils/src/storedInstrument/InstrumentSelect/InstrumentSelect.tsx b/packages/instrument-utils/src/storedInstrument/InstrumentSelect/InstrumentSelect.tsx index c25e016b11..2a2d08a683 100644 --- a/packages/instrument-utils/src/storedInstrument/InstrumentSelect/InstrumentSelect.tsx +++ b/packages/instrument-utils/src/storedInstrument/InstrumentSelect/InstrumentSelect.tsx @@ -262,7 +262,10 @@ const InstrumentSelect: FunctionComponent = ({ useEffect(() => { if (prevSelectedInstrumentIdRef.current !== selectedInstrumentId) { - updateFieldValue(selectedInstrumentId); + // FIXME: Used setTimeout here because setFieldValue call doesnot set value if called before formik is properly mounted. + // This ensures that update Field value is called after formik has mounted. + // See GitHub issue: https://github.com/jaredpalmer/formik/issues/930 + setTimeout(() => updateFieldValue(selectedInstrumentId)); } prevSelectedInstrumentIdRef.current = selectedInstrumentId;