diff --git a/android/src/main/java/com/adyenreactnativesdk/component/EmbeddedComponentBusModule.kt b/android/src/main/java/com/adyenreactnativesdk/component/EmbeddedComponentBusModule.kt index 6c9407918..85885a719 100644 --- a/android/src/main/java/com/adyenreactnativesdk/component/EmbeddedComponentBusModule.kt +++ b/android/src/main/java/com/adyenreactnativesdk/component/EmbeddedComponentBusModule.kt @@ -47,6 +47,15 @@ class EmbeddedComponentBusModule( } } + @ReactMethod + fun submit(viewId: String) { + val consumer = + getConsumer(viewId) + ?: return sendError(ModuleException.NoConsumer(viewId)) + + consumer.onSubmit() + } + @ReactMethod fun handle( viewId: String, @@ -107,7 +116,11 @@ class EmbeddedComponentBusModule( success: Boolean, message: ReadableMap?, ) { - Companion.unregister(viewId) + if (success) { + Companion.unregister(viewId) + } else { + getConsumer(viewId)?.onStopLoading() + } if (subscribedViews.isEmpty()) { cleanup() } diff --git a/android/src/main/java/com/adyenreactnativesdk/configuration/CardConfigurationParser.kt b/android/src/main/java/com/adyenreactnativesdk/configuration/CardConfigurationParser.kt index c29d2d2fb..4ee7f8683 100644 --- a/android/src/main/java/com/adyenreactnativesdk/configuration/CardConfigurationParser.kt +++ b/android/src/main/java/com/adyenreactnativesdk/configuration/CardConfigurationParser.kt @@ -25,6 +25,7 @@ class CardConfigurationParser( companion object { const val TAG = "CardConfigurationParser" const val ROOT_KEY = "card" + const val SHOW_SUBMIT_BUTTON_KEY = "showSubmitButton" const val SHOW_STORE_PAYMENT_FIELD_KEY = "showStorePaymentField" const val HOLDER_NAME_REQUIRED_KEY = "holderNameRequired" const val HIDE_CVC_STORED_CARD_KEY = "hideCvcStoredCard" @@ -50,6 +51,7 @@ class CardConfigurationParser( fun applyConfiguration(builder: CardConfiguration.Builder) { supportedCardTypes?.let { builder.supportedCardBrands = it } + showSubmitButton?.let { builder.isSubmitButtonVisible = it } showStorePaymentField?.let { builder.isStorePaymentFieldVisible = it } hideCvcStoredCard?.let { builder.isHideCvcStoredCard = it } hideCvc?.let { builder.isHideCvc = it } @@ -65,6 +67,14 @@ class CardConfigurationParser( holderNameRequired?.let { builder.isHolderNameRequired = it } } + private val showSubmitButton: Boolean? + get() = + if (config.hasKey(SHOW_SUBMIT_BUTTON_KEY)) { + config.getBoolean(SHOW_SUBMIT_BUTTON_KEY) + } else { + null + } + private val showStorePaymentField: Boolean? get() = if (config.hasKey(SHOW_STORE_PAYMENT_FIELD_KEY)) { diff --git a/android/src/main/java/com/adyenreactnativesdk/react/ComponentContract.kt b/android/src/main/java/com/adyenreactnativesdk/react/ComponentContract.kt index 02301d34f..75d936291 100644 --- a/android/src/main/java/com/adyenreactnativesdk/react/ComponentContract.kt +++ b/android/src/main/java/com/adyenreactnativesdk/react/ComponentContract.kt @@ -5,6 +5,10 @@ import com.adyen.checkout.components.core.LookupAddress import com.adyen.checkout.components.core.action.Action interface ComponentContract { + fun onSubmit() + + fun onStopLoading() + fun onAction(action: Action) fun onAddressLookupResult(result: AddressLookupResult) diff --git a/android/src/main/java/com/adyenreactnativesdk/react/card/CardComponentManager.kt b/android/src/main/java/com/adyenreactnativesdk/react/card/CardComponentManager.kt index 80c6bc8bb..4a51aad98 100644 --- a/android/src/main/java/com/adyenreactnativesdk/react/card/CardComponentManager.kt +++ b/android/src/main/java/com/adyenreactnativesdk/react/card/CardComponentManager.kt @@ -86,6 +86,18 @@ class CardComponentManager( component?.setAddressLookupResult(addressLookupResult) } + fun submit() { + activity.runOnUiThread { + component?.submit() ?: Log.e("CardComponentManager", "Can not submit, Component is null") + } + } + + fun stopLoading() { + activity.runOnUiThread { + component?.setInteractionBlocked(false) ?: Log.e("CardComponentManager", "Can not stop loading, Component is null") + } + } + fun handleAction(action: Action) { component?.let { AdyenCheckout.setComponent(it) diff --git a/android/src/main/java/com/adyenreactnativesdk/react/card/CardViewState.kt b/android/src/main/java/com/adyenreactnativesdk/react/card/CardViewState.kt index 972d9ba8b..f19016d2c 100644 --- a/android/src/main/java/com/adyenreactnativesdk/react/card/CardViewState.kt +++ b/android/src/main/java/com/adyenreactnativesdk/react/card/CardViewState.kt @@ -57,6 +57,14 @@ class CardViewState( eventDispatcher?.dispatchEvent(event) } + override fun onSubmit() { + componentManager?.submit() + } + + override fun onStopLoading() { + componentManager?.stopLoading() + } + override fun onAction(action: Action) { componentManager?.handleAction(action) } diff --git a/android/src/test/java/com/adyenreactnativesdk/component/EmbeddedComponentBusModuleTest.kt b/android/src/test/java/com/adyenreactnativesdk/component/EmbeddedComponentBusModuleTest.kt new file mode 100644 index 000000000..c18b6035b --- /dev/null +++ b/android/src/test/java/com/adyenreactnativesdk/component/EmbeddedComponentBusModuleTest.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + */ + +package com.adyenreactnativesdk.component + +import com.adyenreactnativesdk.react.ComponentContract +import com.adyenreactnativesdk.util.messaging.MessageBus +import org.junit.After +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.times +import org.mockito.Mockito.verify + +class EmbeddedComponentBusModuleTest { + private val messageBus = mock(MessageBus::class.java) + private val consumer = mock(ComponentContract::class.java) + private val sut = EmbeddedComponentBusModule(context = null, messageBus) + + @After + fun tearDown() { + EmbeddedComponentBusModule.clearConsumers() + } + + @Test + fun submitRoutesToRegisteredConsumer() { + EmbeddedComponentBusModule.register(VIEW_ID, consumer) + + sut.submit(VIEW_ID) + + verify(consumer, times(1)).onSubmit() + } + + @Test + fun hideFailureStopsLoadingWithoutUnregisteringConsumer() { + EmbeddedComponentBusModule.register(VIEW_ID, consumer) + + sut.hide(VIEW_ID, success = false, message = null) + + verify(consumer, times(1)).onStopLoading() + sut.submit(VIEW_ID) + verify(consumer, times(1)).onSubmit() + } + + companion object { + private const val VIEW_ID = "card-view" + } +} diff --git a/android/src/test/java/com/adyenreactnativesdk/configuration/CardConfigurationParserTest.kt b/android/src/test/java/com/adyenreactnativesdk/configuration/CardConfigurationParserTest.kt index 46b65cff1..4b522f64b 100644 --- a/android/src/test/java/com/adyenreactnativesdk/configuration/CardConfigurationParserTest.kt +++ b/android/src/test/java/com/adyenreactnativesdk/configuration/CardConfigurationParserTest.kt @@ -36,6 +36,7 @@ class CardConfigurationParserTest { sut.applyConfiguration(mockBuilder) // THEN + verify(mockBuilder, times(0)).setSubmitButtonVisible(any()) verify(mockBuilder, times(0)).setShowStorePaymentField(any()) verify(mockBuilder, times(0)).setHolderNameRequired(any()) verify(mockBuilder, times(0)).setHideCvc(any()) @@ -111,6 +112,7 @@ class CardConfigurationParserTest { fun testApplyConfiguration() { // GIVEN val config = WritableMapMock() + config.putBoolean(CardConfigurationParser.SHOW_SUBMIT_BUTTON_KEY, false) config.putBoolean(CardConfigurationParser.SHOW_STORE_PAYMENT_FIELD_KEY, false) config.putBoolean(CardConfigurationParser.HOLDER_NAME_REQUIRED_KEY, true) config.putBoolean(CardConfigurationParser.HIDE_CVC_KEY, true) @@ -135,6 +137,7 @@ class CardConfigurationParserTest { val mockBuilder = mock(CardConfiguration.Builder::class.java) sut.applyConfiguration(mockBuilder) + verify(mockBuilder, times(1)).isSubmitButtonVisible = false verify(mockBuilder, times(1)).isStorePaymentFieldVisible = false verify(mockBuilder, times(1)).isHolderNameRequired = true verify(mockBuilder, times(1)).isHideCvc = true diff --git a/android/src/test/java/com/adyenreactnativesdk/react/card/CardComponentManagerTest.kt b/android/src/test/java/com/adyenreactnativesdk/react/card/CardComponentManagerTest.kt new file mode 100644 index 000000000..e5278b77f --- /dev/null +++ b/android/src/test/java/com/adyenreactnativesdk/react/card/CardComponentManagerTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + */ + +package com.adyenreactnativesdk.react.card + +import android.os.Looper +import androidx.fragment.app.FragmentActivity +import com.adyen.checkout.card.CardComponent +import com.adyenreactnativesdk.util.messaging.MessageBus +import org.junit.Assert.assertSame +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf + +@RunWith(RobolectricTestRunner::class) +class CardComponentManagerTest { + private val activity = Robolectric.buildActivity(FragmentActivity::class.java).setup().get() + private val component = mock() + private val sut = + CardComponentManager(activity, mock()).apply { + component = this@CardComponentManagerTest.component + } + + @Test + fun `submit invokes the card component on the main thread`() { + var invocationThread: Thread? = null + doAnswer { + invocationThread = Thread.currentThread() + }.whenever(component).submit() + + runFromBackgroundThread(sut::submit) + + assertSame(Looper.getMainLooper().thread, invocationThread) + } + + @Test + fun `stop loading invokes the card component on the main thread`() { + var invocationThread: Thread? = null + doAnswer { + invocationThread = Thread.currentThread() + }.whenever(component).setInteractionBlocked(false) + + runFromBackgroundThread(sut::stopLoading) + + assertSame(Looper.getMainLooper().thread, invocationThread) + } + + private fun runFromBackgroundThread(action: () -> Unit) { + Thread(action).apply { + start() + join() + } + shadowOf(Looper.getMainLooper()).idle() + } +} diff --git a/docs/Architecture.md b/docs/Architecture.md index 13be8479a..7e8317877 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -76,7 +76,7 @@ src/ │ └── DropInWrapper.ts ├── embedded/ # Embedded component bus (for CardView and future embedded views) │ ├── EmbeddedComponentBus.ts # Singleton bus instance - │ ├── EmbeddedComponentBusWrapper.ts # Wrapper with subscribe/unsubscribe/handle/hide/update/confirm + │ ├── EmbeddedComponentBusWrapper.ts # Wrapper with subscribe/unsubscribe/submit/handle/hide/update/confirm │ └── EmbeddedComponentProxy.ts # Per-component proxy that binds a key to bus calls ├── googlepay/ # Google Pay module │ ├── AdyenGooglePay.ts @@ -140,6 +140,7 @@ EventListenerWrapper │ └──► EmbeddedComponentBusWrapper # Bus for all embedded views - subscribe(key), unsubscribe(key) + - submit(key) - handle(key, action), hide(key, success) - update(key, results), confirm(key, success, body) @@ -359,6 +360,7 @@ Embedded views are rendered inline within the React tree using Fabric codegen. U │ CardView.tsx │ │ ├── ref → findNodeHandle() → reactTag │ │ ├── subscribe(reactTag) → EmbeddedComponentBus │ +│ ├── ref.submit() → EmbeddedComponentBus.submit(reactTag) │ │ └── │ │ │ │ useSubscriptionManager │ @@ -420,7 +422,7 @@ CardViewState # Per-view state holder - cardComponentManager: CardComponentManager - renderComponentIfNeeded(view) — creates component, registers with bus - dispose(view) — unregisters, clears state - - onAction/onAddressLookup* — delegates to cardComponentManager + - onSubmit/onStopLoading/onAction/onAddressLookup* — delegates to cardComponentManager CardComponentManager # Creates and manages CardComponent - createComponent(config, paymentMethod) → CardComponent @@ -698,4 +700,3 @@ override fun getConstants(): MutableMap = | ---------------- | ---------------- | ------------------------------------ | | `onLayoutChange` | `CardView` | Embedded view size changed (w × h) | | `onButtonPress` | `PlatformPayView`| Pay button tapped | - diff --git a/docs/Configuration.md b/docs/Configuration.md index bade8e356..bc97db3ab 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -45,6 +45,7 @@ | `hideCvcStoredCard` | Indicates whether to show the security code field on a stored card payment. Defaults to **false**. | No | | `holderNameRequired` | Indicates if the field for entering the holder name should be displayed in the form. Defaults to **false**. | No | | `kcpVisibility` | Indicates whether to show the security fields for South Korea-issued cards. Options: **"show"** or **"hide"**. Defaults to **"hide"**. | No | +| `showSubmitButton` | Indicates whether the native Card Component submit button is displayed. Set to **false** when submitting through an external button. Defaults to **true**. | No | | `showStorePaymentField` | Indicates if the field for storing the card payment method should be displayed in the form. Defaults to **true**. | No | | `socialSecurity` | Indicates the visibility mode for the social security number field (CPF/CNPJ) for Brazilian cards. Options: "show" or **"hide"**. Defaults to **"hide"**. | No | | `supported` | The list of allowed card types. By default, a list of `brands` from the payment method is used. Fallbacks to a list of all known cards. | No | @@ -55,6 +56,32 @@ | `installmentOptions` | Configuration for installment options. Each key is a card brand (e.g., `"visa"`, `"mc"`, `"amex"`) or `"card"` for defaults that apply to all brands. Each entry has `values` (array of allowed installment counts) and optional `plans` (`"regular"` and/or `"revolving"`). | No | | `showInstallmentAmount` | Indicates whether to show the installment amount in the payment form. Defaults to **false**. | No | +#### External CardView submission + +Set `card.showSubmitButton` to `false` and use a `CardViewHandle` ref when your checkout owns the primary submit button: + +```tsx +import { useRef } from 'react'; +import { Button } from 'react-native'; +import { CardView, type CardViewHandle } from '@adyen/react-native'; + +function CheckoutCard() { + const cardViewRef = useRef(null); + + return ( + <> + +