Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 111 additions & 29 deletions packages/adyen-integration/src/adyenv3/AdyenV3PaymentMethod.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,18 @@ import {
type AdyenValidationState,
type CardInstrument,
type PaymentInitializeOptions,
type PaymentMethod,
} from '@bigcommerce/checkout-sdk';
import { createAdyenV3PaymentStrategy } from '@bigcommerce/checkout-sdk/integrations/adyen';
import React, { type FunctionComponent, useCallback, useRef, useState } from 'react';
import React, { type FunctionComponent, useCallback, useEffect, useRef, useState } from 'react';

import { type HostedWidgetComponentProps } from '@bigcommerce/checkout/hosted-widget-integration';
import {
type PaymentMethodProps,
type PaymentMethodResolveId,
toResolvableComponent,
} from '@bigcommerce/checkout/payment-integration-api';
import { FormContext, LoadingOverlay } from '@bigcommerce/checkout/ui';
import { FormContext, LoadingOverlay, Modal } from '@bigcommerce/checkout/ui';

import AdyenV3CardValidation from './AdyenV3CardValidation';
import AdyenV3Form from './AdyenV3Form';
Expand Down Expand Up @@ -48,17 +49,41 @@ const AdyenV3PaymentMethod: FunctionComponent<PaymentMethodProps> = ({
shouldShowModal: true,
});

const groupedMethods = (method.initializationData as { groupedMethods?: PaymentMethod[] } | null)?.groupedMethods;
const isGrouped = Boolean(groupedMethods?.length);
const [selectedVariantMethod, setSelectedVariantMethod] = useState<PaymentMethod>(method);

const [shouldRenderAdditionalActionContentModal, setShouldRenderAdditionalActionContentModal] =
useState<boolean>(false);
const [isAdditionalActionContentModalVisible, setIsAdditionalActionContentModalVisible] =
useState<boolean>(false);
const [cardValidationState, setCardValidationState] = useState<AdyenValidationState>();
const containerId = `adyen-${method.id}-component-field`;
const additionalActionContainerId = `adyen-${method.id}-additional-action-component-field`;
const cardVerificationContainerId = `adyen-${method.id}-tsv-component-field`;
const component = method.id;
const containerId = `adyen-${selectedVariantMethod.id}-component-field`;
const additionalActionContainerId = `adyen-${selectedVariantMethod.id}-additional-action-component-field`;
const cardVerificationContainerId = `adyen-${selectedVariantMethod.id}-tsv-component-field`;
const component = selectedVariantMethod.id;
const shouldHideInstrumentExpiryDate = component === AdyenV3PaymentMethodType.bcmc;

const handleVariantChange = useCallback((variantId: string) => {
const variant = groupedMethods?.find((m) => m.id === variantId);

if (variant && variant.id !== selectedVariantMethod.id) {
setSelectedVariantMethod(variant);

paymentForm.setFieldValue('methodIdOverride', variant.id);
}
}, [groupedMethods, paymentForm, selectedVariantMethod.id]);

useEffect(() => {
if (!isGrouped) {
return;
}

return () => {
paymentForm.setFieldValue('methodIdOverride', undefined);
};
}, []);

const onBeforeLoad = useCallback((shopperInteraction: boolean) => {
ref.current.shouldShowModal = shopperInteraction;

Expand Down Expand Up @@ -103,6 +128,7 @@ const AdyenV3PaymentMethod: FunctionComponent<PaymentMethodProps> = ({

return checkoutService.initializePayment({
...options,
methodId: component,
integrations: [createAdyenV3PaymentStrategy],
adyenv3: {
cardVerificationContainerId:
Expand Down Expand Up @@ -138,6 +164,34 @@ const AdyenV3PaymentMethod: FunctionComponent<PaymentMethodProps> = ({
],
);

useEffect(() => {
if (!isGrouped) {
return;
}

paymentForm.setValidationSchema(method, null);
paymentForm.setSubmit(method, null);

void initializeAdyenPayment(
{ methodId: component, gatewayId: method.gateway },
undefined as unknown as CardInstrument,
).catch((error: unknown) => {
if (onUnhandledError && error instanceof Error) {
onUnhandledError(error);
}
});

return () => {
paymentForm.setValidationSchema(method, null);
paymentForm.setSubmit(method, null);

void checkoutService.deinitializePayment({
gatewayId: method.gateway,
methodId: component,
});
};
}, [initializeAdyenPayment]);

const validateInstrument = (
shouldShowNumberField: boolean,
selectedInstrument: CardInstrument,
Expand Down Expand Up @@ -179,29 +233,57 @@ const AdyenV3PaymentMethod: FunctionComponent<PaymentMethodProps> = ({

return (
<FormContext.Provider value={formContextProps}>
<LoadingOverlay hideContentWhenLoading isLoading={isLoading}>
<AdyenV3Form
{...rest}
additionalActionContainerId={additionalActionContainerId}
cancelAdditionalActionModalFlow={cancelAdditionalActionModalFlow}
checkoutService={checkoutService}
checkoutState={checkoutState}
containerId={containerId}
hideContentWhenSignedOut
initializePayment={initializeAdyenPayment}
isAccountInstrument={isAccountInstrument()}
isModalVisible={isAdditionalActionContentModalVisible}
language={language}
method={method}
onUnhandledError={onUnhandledError}
paymentForm={paymentForm}
shouldHideInstrumentExpiryDate={shouldHideInstrumentExpiryDate}
shouldRenderAdditionalActionContentModal={
shouldRenderAdditionalActionContentModal
}
validateInstrument={validateInstrument}
/>
</LoadingOverlay>
{isGrouped ? (
<>
<select
onChange={(e) => handleVariantChange(e.target.value)}
value={selectedVariantMethod.id}
>
{groupedMethods!.map((m) => (
<option key={m.id} value={m.id}>
{m.config.displayName}
</option>
))}
</select>
<div id={containerId} />
<Modal
additionalBodyClassName="modal-body--center"
closeButtonLabel={language.translate('common.close_action')}
isOpen={shouldRenderAdditionalActionContentModal}
onRequestClose={cancelAdditionalActionModalFlow}
shouldShowCloseButton={true}
>
<div id={additionalActionContainerId} style={{ width: '100%' }} />
</Modal>
{!shouldRenderAdditionalActionContentModal && (
<div id={additionalActionContainerId} />
)}
</>
) : (
<LoadingOverlay hideContentWhenLoading isLoading={isLoading}>
<AdyenV3Form
{...rest}
additionalActionContainerId={additionalActionContainerId}
cancelAdditionalActionModalFlow={cancelAdditionalActionModalFlow}
checkoutService={checkoutService}
checkoutState={checkoutState}
containerId={containerId}
hideContentWhenSignedOut
initializePayment={initializeAdyenPayment}
isAccountInstrument={isAccountInstrument()}
isModalVisible={isAdditionalActionContentModalVisible}
language={language}
method={method}
onUnhandledError={onUnhandledError}
paymentForm={paymentForm}
shouldHideInstrumentExpiryDate={shouldHideInstrumentExpiryDate}
shouldRenderAdditionalActionContentModal={
shouldRenderAdditionalActionContentModal
}
validateInstrument={validateInstrument}
/>
</LoadingOverlay>
)}
</FormContext.Provider>
);
};
Expand Down
44 changes: 43 additions & 1 deletion packages/core/src/app/payment/Payment.tsx

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m concerned that introducing isHidden concept in the payment method list may not scale well, since some methods need to remain visible during initialization.

Instead of keeping hidden payment methods and teaching PaymentMethodList to skip them, can we consider filtering non-representative methods out of filteredMethods entirely, and let the Oney component handle the selection and showing a represented method?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, I have updated PR.

Original file line number Diff line number Diff line change
Expand Up @@ -64,19 +64,54 @@ interface PaymentMethodSelectionParams {
checkout: Checkout;
methods: PaymentMethod[];
consignments?: Consignment[];
checkoutSettings?: CheckoutSettings;
getPaymentMethod: (methodId: string, gatewayId?: string) => PaymentMethod | undefined;
paymentProviderCustomer?: PaymentProviderCustomer;
}

const groupMethodsByPrefix = (methods: PaymentMethod[], prefix: string): PaymentMethod[] => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall, the pr looks good to me.

Can we break this function into 3 functions:

  • One function to select & sort the group
  • Another to build the representative
  • A third to splice it back into the list

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bc-peng Thanks for the suggestion! Since this is just a POC, we can definitely incorporate these changes during the actual development phase.

const group = methods.filter((m) => m.id.startsWith(prefix));

if (group.length <= 1) {
return methods;
}

const sorted = [...group].sort((a, b) => {
const toNum = (id: string) => parseInt(id.slice(prefix.length), 10) || 0;

return toNum(a.id) - toNum(b.id);
});

const [first] = sorted;
const representative: PaymentMethod = {
...first,
config: {
...first.config,
displayName: first.config.displayName?.replace(/^\d+x\s+/i, '') ?? first.config.displayName,
},
initializationData: {
...(first.initializationData as Record<string, unknown>),
groupedMethods: sorted,
},
};

return methods.flatMap((m) => {
if (!m.id.startsWith(prefix)) return [m];
if (m.id === first.id) return [representative];

return [];
});
};

const getDefaultPaymentMethod = ({
checkout,
checkoutSettings,
consignments,
getPaymentMethod,
methods,
paymentProviderCustomer,
}: PaymentMethodSelectionParams): { filteredMethods: PaymentMethod[]; defaultMethod?: PaymentMethod } => {
let filteredMethods = methods;

// TODO: In accordance with the checkout team, this functionality is temporary and will be implemented in the backend instead.
if (paymentProviderCustomer?.stripeLinkAuthenticationState) {
const stripeUpePaymentMethod = filteredMethods.filter(
Expand All @@ -94,6 +129,12 @@ const getDefaultPaymentMethod = ({
return method.id !== PaymentMethodId.BraintreeLocalPaymentMethod;
});

if (isExperimentEnabled(checkoutSettings, 'PAYMENTS-XXXX.oney_facilypay_grouping', false)) {
const GROUPED_METHOD_ID_PREFIXES = ['facilypay_'];

filteredMethods = GROUPED_METHOD_ID_PREFIXES.reduce(groupMethodsByPrefix, filteredMethods);
}

if (consignments && consignments.length > 1) {
const multiShippingIncompatibleMethodIds: string[] = [
PaymentMethodId.AmazonPay,
Expand Down Expand Up @@ -550,6 +591,7 @@ const Payment= (props: PaymentProps & WithCheckoutPaymentProps & WithLanguagePro
const defaultMethod = checkout
? getDefaultPaymentMethod({
checkout,
checkoutSettings: updatedState.data.getConfig()?.checkoutSettings,
consignments: updatedState.data.getConsignments(),
getPaymentMethod: updatedState.data.getPaymentMethod,
methods,
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/app/payment/mapToOrderRequestBody.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ export default function mapToOrderRequestBody(
return {};
}

const { paymentProviderRadio, ...rest } = values;
const { methodId, gatewayId } = parseUniquePaymentMethodId(paymentProviderRadio);
const { paymentProviderRadio, methodIdOverride, ...rest } = values;
const { methodId: baseMethodId, gatewayId } = parseUniquePaymentMethodId(paymentProviderRadio);
const methodId = methodIdOverride || baseMethodId;
const payload: OrderRequestBody = {
payment: { gatewayId, methodId },
};
Expand Down