Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 }
Expand All @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Comment thread
PrinceD96 marked this conversation as resolved.

fun handleAction(action: Action) {
component?.let {
AdyenCheckout.setComponent(it)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<CardComponent>()
private val sut =
CardComponentManager(activity, mock<MessageBus>()).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()
}
}
7 changes: 4 additions & 3 deletions docs/Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -140,6 +140,7 @@ EventListenerWrapper<EmbeddedNativeModule>
└──► 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)

Expand Down Expand Up @@ -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) │
│ └── <NativeCardView paymentMethod={...} configuration={...} /> │
│ │
│ useSubscriptionManager │
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -698,4 +700,3 @@ override fun getConstants(): MutableMap<String, Any> =
| ---------------- | ---------------- | ------------------------------------ |
| `onLayoutChange` | `CardView` | Embedded view size changed (w × h) |
| `onButtonPress` | `PlatformPayView`| Pay button tapped |

27 changes: 27 additions & 0 deletions docs/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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<CardViewHandle>(null);

return (
<>
<CardView ref={cardViewRef} />
<Button
title="Pay"
onPress={() => cardViewRef.current?.submit()}
/>
</>
);
}
```

Calling `submit()` validates the card form and, when valid, invokes the existing `onSubmit` callback with the payment data and component proxy. Call `component.hide(false)` after a recoverable submission failure to restore card interaction and allow another attempt.

### 3D Secure 2

| Parameter | Description | Required |
Expand Down
8 changes: 7 additions & 1 deletion etc/api/adyen-react-native.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,12 +360,18 @@ export interface CardsConfiguration {
onUpdateAddress?(prompt: string, lookup: AddressLookup): void;
showInstallmentAmount?: boolean;
showStorePaymentField?: boolean;
showSubmitButton?: boolean;
socialSecurity?: FieldVisibility;
supported?: string[];
}

// @public
export const CardView: React_2.FC<CardViewProps>;
export const CardView: React_2.ForwardRefExoticComponent<CardViewProps & React_2.RefAttributes<CardViewHandle>>;

// @public
export interface CardViewHandle {
submit(): void;
}

// @public (undocumented)
export interface CardViewProps {
Expand Down
24 changes: 23 additions & 1 deletion example/ios/AdyenExampleTests/CardConfigurationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
//

@_spi(AdyenInternal) import Adyen
import adyen_react_native
@testable import adyen_react_native
import PassKit
import XCTest

Expand Down Expand Up @@ -42,6 +42,28 @@ final class CardConfigurationTests: XCTestCase {
XCTAssertNotNil(sut.configuration)
}

func test_configuration_showsSubmitButtonByDefault() {
// GIVEN
let configDict: NSDictionary = ["card": [:]]

// WHEN
let sut = CardConfigurationParser(configuration: configDict, delegate: mockAddressLookupProvider)

// THEN
XCTAssertTrue(sut.showsSubmitButton)
}

func test_configuration_hidesSubmitButton_whenConfigured() {
// GIVEN
let configDict: NSDictionary = ["card": ["showSubmitButton": false]]

// WHEN
let sut = CardConfigurationParser(configuration: configDict, delegate: mockAddressLookupProvider)

// THEN
XCTAssertFalse(sut.showsSubmitButton)
}

func test_configuration_setsShowStorePaymentField_whenProvided() {
// GIVEN
let configDict: NSDictionary = ["card": ["showStorePaymentField": false]]
Expand Down
Loading
Loading