diff --git a/.husky/pre-commit b/.husky/pre-commit index 70c3cdf60..b3df95fa7 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,5 @@ -yarn test -yarn lint -yarn typecheck -swiftformat --lint . \ No newline at end of file +yarn test > /dev/null 2>&1 || yarn test +yarn lint > /dev/null 2>&1 || yarn lint +yarn typecheck > /dev/null 2>&1 || yarn typecheck +swiftformat --lint ios > /dev/null 2>&1 || swiftformat --lint ios +ktlint "!**node_modules**" > /dev/null 2>&1 || ktlint "!**node_modules**" \ No newline at end of file diff --git a/Architecture.md b/Architecture.md new file mode 100644 index 000000000..a8b13dffe --- /dev/null +++ b/Architecture.md @@ -0,0 +1,384 @@ +# Adyen React Native SDK Architecture + +## Overview + +The Adyen React Native SDK is a bridge between React Native applications and native Adyen payment SDKs for iOS and Android. It follows a layered architecture with clear separation between the JavaScript/TypeScript layer and native platform implementations. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ React Native Application │ +├─────────────────────────────────────────────────────────────────┤ +│ src/ (TypeScript) │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │ +│ │ components/ │ │ hooks/ │ │ modules/ │ │ +│ │ │ │ │ │ (Wrapper Classes) │ │ +│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │ +├─────────────────────────────────────────────────────────────────┤ +│ React Native Bridge │ +│ (NativeEventEmitter / NativeModules) │ +├───────────────────────────────┬─────────────────────────────────┤ +│ ios/ (Swift) │ android/ (Kotlin) │ +│ ┌───────────--------──────┐ │ ┌─────────────────────────┐ │ +│ │ Components/ │ │ │ component/ │ │ +│ │ (Modules) │ │ │ (Modules) │ │ +│ └────--------─────────────┘ │ └─────────────────────────┘ │ +├────--------───────────────────┼─────────────────────────────────┤ +│ Adyen iOS SDK │ Adyen Android SDK │ +└───────────────────────────────┴─────────────────────────────────┘ +``` + +--- + +## Layer Details + +### 1. TypeScript Layer (`src/`) + +#### 1.1 Components (`src/components/`) + +| Component | Description | +| --------------- | -------------------------------------------------------------------------------------------- | +| `AdyenCheckout` | Main provider component that manages payment flow, session handling, and event subscriptions | + +**AdyenCheckout Responsibilities:** + +- Creates and manages session context via `SessionHelper` +- Subscribes to native events using `NativeEventEmitter` +- Provides `AdyenCheckoutContext` for child components +- Routes events to appropriate callbacks (`onSubmit`, `onError`, `onComplete`, `onAdditionalDetails`) + +#### 1.2 Modules (`src/modules/`) + +Modules are TypeScript wrappers around native modules that provide type-safe interfaces. + +``` +src/modules/ +├── base/ # Base wrapper classes +│ ├── EventListenerWrapper # Abstract base for event-capable modules +│ ├── ModuleWrapper # Base for non-embedded modules (open/hide) +│ ├── ActionHandlingComponentWrapper # Adds action handling capability +│ └── getWrapper # Factory for getting appropriate wrapper +├── dropin/ # Drop-in component wrapper +│ └── DropInWrapper # Full-featured drop-in implementation +├── session/ # Session management +│ └── SessionWrapper # Handles session creation and events +├── instant/ # Instant payment methods +├── googlepay/ # Google Pay wrapper +├── applepay/ # Apple Pay wrapper +├── message/ # MessageBus for embedded components +└── cse/ # Client-side encryption +``` + +**Wrapper Class Hierarchy:** + +``` +EventListenerWrapper + │ + ├── ModuleWrapper + │ │ + │ └── ActionHandlingComponentWrapper + │ │ + │ └── DropInWrapper + │ └── InstantWrapper + │ └── GooglePayWrapper + │ └── ApplePayWrapper + │ + └── MessageBusWrapper (for embedded components) +``` + +#### 1.3 Core (`src/core/`) + +| File | Purpose | +| ----------------- | -------------------------------------- | +| `constants.ts` | Event names, error codes, result codes | +| `types.ts` | TypeScript type definitions | +| `configurations/` | Configuration interfaces | +| `utils.ts` | Validation utilities | + +--- + +### 2. iOS Layer (`ios/`) + +#### 2.1 Components (`ios/Components/`) + +``` +ios/Components/ +├── Base/ +│ ├── BaseModule.swift # RCTEventEmitter base with presentation logic +│ ├── BaseModuleSender.swift # Adds delegate implementations for sending events +│ └── NativeModuleError.swift # Error type definitions +├── DropInModule.swift # Drop-in component implementation +├── SessionHelperModule.swift # Session management (AdyenSessionDelegate) +├── InstantModule.swift # Instant payment methods +├── MessageBusModule.swift # Event bus for embedded components +├── ApplePay/ # Apple Pay implementation +└── GooglePayModuleMock.swift # Mock for unsupported Google Pay +``` + +**Module Hierarchy:** + +``` +RCTEventEmitter (React Native) + │ + └── BaseModule + │ + ├── BaseModuleSender + │ │ + │ └── DropInModule + │ └── InstantModule + │ └── ApplePayModule + │ + └── SessionHelperModule + └── MessageBusModule +``` + +**Key Static Properties in BaseModule:** + +- `session: AdyenSession?` - Shared session instance +- `activeModule: BaseModule?` - Currently presenting module (for dismiss delegation) +- `currentPresenter: UIViewController?` - View controller for presentation + +#### 2.2 Configuration (`ios/Configuration/`) + +| File | Purpose | +| ------------------------------- | ------------------------------------------ | +| `Parameters.swift` | Event enum definitions, configuration keys | +| `RootConfigurationParser.swift` | Parses configuration from JS | +| `*ConfigurationParser.swift` | Component-specific parsers | + +--- + +### 3. Android Layer (`android/`) + +#### 3.1 Components (`android/.../component/`) + +``` +component/ +├── base/ +│ ├── BaseModule.kt # ReactContextBaseJavaModule base +│ ├── BaseViewModel.kt # ViewModel for component state +│ ├── AdvancedComponentViewModel.kt # Advanced flow ViewModel +│ ├── SessionsComponentViewModel.kt # Sessions flow ViewModel +│ ├── BaseComponentFragment.kt # Fragment base for UI components +│ └── ModuleException.kt # Error definitions +├── dropin/ +│ ├── DropInModule.kt # Drop-in implementation +│ ├── AdvancedCheckoutService.kt # Service for advanced flow +│ └── SessionCheckoutService.kt # Service for session flow +├── SessionHelperModule.kt # Session creation +├── MessageBusModule.kt # Event bus for embedded components +├── googlepay/ # Google Pay implementation +├── instant/ # Instant payment methods +└── applepay/ # Mock for Apple Pay +``` + +#### 3.2 Messaging (`android/.../util/messaging/`) + +``` +messaging/ +├── MessageBus.kt # Central event dispatcher +├── ComponentEventListener.kt # Interface for component events +├── CardComponentEventListener.kt # Card-specific events +└── DropInStoredPaymentEventListener.kt # Stored payment events +``` + +**MessageBus** is the central hub that: + +- Implements multiple listener interfaces +- Converts native events to React Native events +- Handles both session and advanced flow events + +--- + +## Event System + +### Event Flow + +``` +Native Component → Native Module → React Native Bridge → TypeScript → Application Callback +``` + +### Event Types + +Events are defined consistently across all layers: + +| TypeScript (`Event` enum) | iOS (`Events` enum) | Android (`MessageBus`) | Description | +| ------------------------------ | ------------------------------- | ----------------------------------- | ---------------------------------- | +| `onSubmit` | `didSubmit` | `DID_SUBMIT` | Payment data ready for submission | +| `onAdditionalDetails` | `didProvide` | `DID_PROVIDE` | Additional action data (3DS, etc.) | +| `onComplete` | `didComplete` | `DID_COMPLETE` | Payment flow completed (advanced) | +| `onSessionComplete` | `didCompleteSession` | `DID_COMPLETE_SESSION` | Session payment completed | +| `onError` | `didFail` | `DID_FAILED` | Error occurred (advanced) | +| `onSessionError` | `didFailSession` | `DID_FAILED_SESSION` | Session error occurred | +| `onDisableStoredPaymentMethod` | `didDisableStoredPaymentMethod` | `DID_DISABLE_STORED_PAYMENT_METHOD` | Remove stored payment | +| `onAddressUpdate` | `didUpdateAddress` | `DID_UPDATE_ADDRESS` | Address lookup query | +| `onAddressConfirm` | `didConfirmAddress` | `DID_CONFIRM_ADDRESS` | Address selected | +| `onCheckBalance` | `didCheckBalance` | `DID_CHECK_BALANCE` | Balance check request | +| `onRequestOrder` | `didRequestOrder` | `DID_REQUEST_ORDER` | Order creation request | +| `onCancelOrder` | `didCancelOrder` | `DID_CANCEL_ORDER` | Order cancellation | +| `onBinLookup` | `didBinLookup` | `DID_BIN_LOOKUP` | Card BIN lookup result | +| `onBinValue` | `didChangeBinValue` | `DID_CHANGE_BIN_VALUE` | Card BIN value changed | + +### Session vs Advanced Flow Events + +The SDK differentiates between session-based and advanced payment flows: + +**Session Flow:** + +- Uses `SessionHelper` for session creation +- Events: `onSessionComplete`, `onSessionError` +- Native delegates: `AdyenSessionDelegate` (iOS), `SessionComponentCallback` (Android) + +**Advanced Flow:** + +- Uses direct component modules +- Events: `onComplete`, `onError`, `onSubmit`, `onAdditionalDetails` +- Requires merchant backend integration + +--- + +## Integration Flows + +### 1. Session Flow + +``` +┌─────────────┐ ┌──────────────┐ ┌─────────────────┐ ┌──────────────┐ +│ Application │────▶│ AdyenCheckout│────▶│ SessionHelper │────▶│ Native Module│ +│ │ │ │ │ (SessionWrapper)│ │ │ +└─────────────┘ └──────────────┘ └─────────────────┘ └──────────────┘ + │ │ │ + │ createSession() │ │ + │─────────────────────▶│ │ + │ │ createSession() │ + │ │─────────────────────▶│ + │ │ │ + │ │◀── SessionContext ───│ + │◀─── paymentMethods ──│ │ + │ │ │ + │ start('dropin') │ │ + │─────────────────────────────────────────────▶ + │ │ │ + │◀─── onSessionComplete/onSessionError ───────│ +``` + +### 2. Advanced Flow + +``` +┌─────────────┐ ┌──────────────┐ ┌─────────────────┐ ┌──────────────┐ +│ Application │────▶│ AdyenCheckout│────▶│ ComponentWrapper│────▶│ Native Module│ +│ │ │ │ │ (DropIn, etc.) │ │ │ +└─────────────┘ └──────────────┘ └─────────────────┘ └──────────────┘ + │ │ │ + │ start('dropin') │ │ + │─────────────────────▶│ │ + │ │ open() │ + │ │─────────────────────▶│ + │ │ │ + │◀───────── onSubmit ─────────────────────────│ + │ │ │ + │ (merchant backend) │ │ + │─────────────────────▶│ │ + │ │ handle(action) │ + │ │─────────────────────▶│ + │ │ │ + │◀── onAdditionalDetails ─────────────────────│ + │ │ │ + │◀── onComplete/onError ──────────────────────│ +``` + +--- + +## Module Responsibilities + +### iOS BaseModule + +| Responsibility | Implementation | +| ---------------- | ------------------------------------------------------------------------- | +| Event emission | `sendEvent(event:body:)`, `sendEvent(error:)`, `sendSessionEvent(error:)` | +| UI Presentation | `present(_:)` via `PresentationDelegate` | +| Dismiss handling | `dismiss(_:)` with `activeModule` delegation | +| Cleanup | `cleanUp()` clears session, presenter, component references | +| Action handling | `actionHandler: AdyenActionComponent` | + +### Android MessageBus + +| Responsibility | Implementation | +| ------------------- | ------------------------------------------------------------ | +| Event dispatch | `sendEvent()`, `sendErrorEvent()`, `sendSessionErrorEvent()` | +| Component callbacks | Implements `ComponentEventListener`, `AddressLookupCallback` | +| State serialization | Uses Adyen SDK serializers + `ReactNativeJson` | +| Session completion | `onFinished()` → `DID_COMPLETE_SESSION` | + +--- + +## Dependencies + +### External Dependencies + +| Platform | Dependency | Purpose | +| ---------- | ---------------------------------------------------- | -------------------- | +| iOS | Adyen SDK (`Adyen`, `Adyen3DS2`) | Payment components | +| iOS | React (`RCTEventEmitter`) | Native bridge | +| Android | Adyen SDK (`com.adyen.checkout.*`) | Payment components | +| Android | React Native (`ReactContextBaseJavaModule`) | Native bridge | +| TypeScript | React Native (`NativeEventEmitter`, `NativeModules`) | Bridge communication | + +### Internal Dependencies + +``` +TypeScript: + AdyenCheckout + └── SessionHelper (SessionWrapper) + └── MessageBus (MessageBusWrapper) + └── getWrapper() → DropInWrapper, InstantWrapper, etc. + +iOS: + DropInModule + └── BaseModuleSender + └── BaseModule + └── static session, activeModule, currentPresenter + SessionHelperModule + └── BaseModule + +Android: + DropInModule + └── BaseModule + └── MessageBus (injected) + SessionHelperModule + └── BaseModule + *ViewModel + └── MessageBus (via AdyenPaymentPackage.messageBus) +``` + +--- + +## Key Design Patterns + +1. **Wrapper Pattern**: TypeScript wrappers abstract native module interfaces +2. **Observer Pattern**: Event-based communication via NativeEventEmitter +3. **Delegation**: iOS uses delegate pattern for session/component callbacks +4. **Singleton**: Static session and activeModule references for cross-module state +5. **Factory**: `getWrapper()` creates appropriate wrapper based on payment type +6. **Context Provider**: React Context for sharing checkout state with children + +--- + +## File Structure Summary + +``` +adyen-react-native/ +├── src/ # TypeScript source +│ ├── components/ # React components +│ ├── core/ # Types, constants, utils +│ ├── hooks/ # React hooks +│ └── modules/ # Native module wrappers +├── ios/ # iOS native code +│ ├── Components/ # Native modules +│ ├── Configuration/ # Config parsers +│ ├── Helpers/ # Utilities +│ └── Model/ # Data models +└── android/src/main/java/.../ # Android native code + ├── component/ # Native modules + ├── configuration/ # Config factories + ├── react/ # React integration + └── util/ # Utilities & MessageBus +``` diff --git a/android/src/main/java/com/adyenreactnativesdk/AdyenCheckout.kt b/android/src/main/java/com/adyenreactnativesdk/AdyenCheckout.kt index 2bb9e0a31..55750656d 100644 --- a/android/src/main/java/com/adyenreactnativesdk/AdyenCheckout.kt +++ b/android/src/main/java/com/adyenreactnativesdk/AdyenCheckout.kt @@ -15,11 +15,10 @@ import com.adyen.checkout.components.core.internal.ActivityResultHandlingCompone import com.adyen.checkout.components.core.internal.Component import com.adyen.checkout.dropin.DropIn import com.adyen.checkout.dropin.DropInCallback -import com.adyen.checkout.dropin.DropInResult import com.adyen.checkout.dropin.SessionDropInCallback -import com.adyen.checkout.dropin.SessionDropInResult import com.adyen.checkout.dropin.internal.ui.model.DropInResultContractParams import com.adyen.checkout.dropin.internal.ui.model.SessionDropInResultContractParams +import com.adyenreactnativesdk.component.dropin.DropInCallbackListener import com.adyenreactnativesdk.component.dropin.ReactDropInCallback import com.adyenreactnativesdk.component.googlepay.GooglePayModule import java.lang.ref.WeakReference @@ -117,32 +116,3 @@ object AdyenCheckout { private const val TAG = "AdyenCheckout" } - -private class DropInCallbackListener : - DropInCallback, - SessionDropInCallback { - var callback: WeakReference = - WeakReference(null) - - override fun onDropInResult(dropInResult: DropInResult?) { - callback.get()?.let { - when (dropInResult) { - is DropInResult.CancelledByUser -> it.onCancel() - is DropInResult.Error -> it.onError(dropInResult.reason) - is DropInResult.Finished -> it.onCompleted(dropInResult.result) - null -> return - } - } - } - - override fun onDropInResult(sessionDropInResult: SessionDropInResult?) { - callback.get()?.let { - when (sessionDropInResult) { - is SessionDropInResult.CancelledByUser -> it.onCancel() - is SessionDropInResult.Error -> it.onError(sessionDropInResult.reason) - is SessionDropInResult.Finished -> it.onFinished(sessionDropInResult.result) - null -> return - } - } - } -} diff --git a/android/src/main/java/com/adyenreactnativesdk/AdyenPaymentPackage.kt b/android/src/main/java/com/adyenreactnativesdk/AdyenPaymentPackage.kt index 18998e27c..c9a66800d 100644 --- a/android/src/main/java/com/adyenreactnativesdk/AdyenPaymentPackage.kt +++ b/android/src/main/java/com/adyenreactnativesdk/AdyenPaymentPackage.kt @@ -7,30 +7,49 @@ package com.adyenreactnativesdk import android.annotation.SuppressLint +import com.adyen.checkout.components.core.AddressData import com.adyen.checkout.components.core.internal.analytics.AnalyticsPlatform import com.adyen.checkout.components.core.internal.analytics.AnalyticsPlatformParams +import com.adyenreactnativesdk.component.MessageBusModule import com.adyenreactnativesdk.component.SessionHelperModule import com.adyenreactnativesdk.component.applepay.ApplePayModuleMock import com.adyenreactnativesdk.component.dropin.DropInModule import com.adyenreactnativesdk.component.googlepay.GooglePayModule import com.adyenreactnativesdk.component.instant.InstantModule +import com.adyenreactnativesdk.component.model.AddressDataAdapter import com.adyenreactnativesdk.cse.ActionModule import com.adyenreactnativesdk.cse.AdyenCSEModule +import com.adyenreactnativesdk.react.CardViewManager import com.adyenreactnativesdk.react.PlatformPayViewManager +import com.adyenreactnativesdk.util.messaging.MessageBus import com.facebook.react.ReactPackage import com.facebook.react.bridge.NativeModule import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager +import com.google.gson.GsonBuilder class AdyenPaymentPackage : ReactPackage { - override fun createViewManagers(reactContext: ReactApplicationContext) = listOf(PlatformPayViewManager()) + override fun createViewManagers(reactContext: ReactApplicationContext): List> { + val messageBus = getOrCreateMessageBus(reactContext) + val cardView = CardViewManager(messageBus) + MessageBusModule.consumers[CardViewManager.NAME] = cardView + + return listOf( + PlatformPayViewManager(), + cardView, + ) + } override fun createNativeModules(reactContext: ReactApplicationContext): List { configureAnalytics() + val messageBus = getOrCreateMessageBus(reactContext) + return listOf( - DropInModule(reactContext), - InstantModule(reactContext), - GooglePayModule(reactContext), - ApplePayModuleMock(reactContext), + DropInModule(reactContext, messageBus, gson), + InstantModule(reactContext, messageBus), + GooglePayModule(reactContext, messageBus), + ApplePayModuleMock(reactContext, messageBus), + MessageBusModule(reactContext, messageBus), AdyenCSEModule(reactContext), SessionHelperModule(reactContext), ActionModule(reactContext), @@ -43,4 +62,25 @@ class AdyenPaymentPackage : ReactPackage { val version = BuildConfig.CHECKOUT_VERSION AnalyticsPlatformParams.overrideForCrossPlatform(AnalyticsPlatform.REACT_NATIVE, version) } + + companion object { + public val messageBus: MessageBus + get() { + return _messageBus ?: throw Exception("AdyenCheckout MessageBus is not initialized") + } + + @Volatile + private var _messageBus: MessageBus? = null + private val lock = Any() + + private fun getOrCreateMessageBus(context: ReactApplicationContext): MessageBus = + _messageBus ?: synchronized(lock) { + _messageBus ?: MessageBus(context, gson).also { _messageBus = it } + } + + private val gson = + GsonBuilder() + .registerTypeAdapter(AddressData::class.java, AddressDataAdapter()) + .create() + } } diff --git a/android/src/main/java/com/adyenreactnativesdk/component/CheckoutProxy.kt b/android/src/main/java/com/adyenreactnativesdk/component/CheckoutProxy.kt deleted file mode 100644 index ef3286a6b..000000000 --- a/android/src/main/java/com/adyenreactnativesdk/component/CheckoutProxy.kt +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright (c) 2023 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.adyen.checkout.card.BinLookupData -import com.adyen.checkout.components.core.AddressLookupCallback -import com.adyen.checkout.components.core.StoredPaymentMethod -import com.adyen.checkout.core.exception.CheckoutException -import com.adyen.checkout.dropin.BaseDropInServiceContract -import com.adyen.checkout.dropin.DropInServiceContract -import com.adyen.checkout.sessions.core.SessionPaymentResult -import com.facebook.react.bridge.ReadableMap -import org.json.JSONObject -import java.lang.ref.WeakReference - -class CheckoutProxy private constructor() { - private var _componentListener = WeakReference(null) - - var sessionService: BaseDropInServiceContract? = null - - var advancedService: BaseDropInServiceContract? = null - - var componentListener: ComponentEventListener? - get() = _componentListener.get() - set(value) { - _componentListener = WeakReference(value) - } - - /** Base events coming from Components */ - interface ComponentEventListener : DropInServiceContract { - fun onException(exception: CheckoutException) - - fun onFinished(result: SessionPaymentResult) - - fun onRemove(storedPaymentMethod: StoredPaymentMethod) - } - - /** Events coming from Card Component */ - interface CardComponentEventListener : ComponentEventListener { - fun onBinValue(binValue: String) - - fun onBinLookup(data: List) - } - - companion object { - var shared = CheckoutProxy() - } -} diff --git a/android/src/main/java/com/adyenreactnativesdk/component/MessageBusModule.kt b/android/src/main/java/com/adyenreactnativesdk/component/MessageBusModule.kt new file mode 100644 index 000000000..df576fc8d --- /dev/null +++ b/android/src/main/java/com/adyenreactnativesdk/component/MessageBusModule.kt @@ -0,0 +1,60 @@ +package com.adyenreactnativesdk.component + +import com.adyen.checkout.components.core.action.Action +import com.adyenreactnativesdk.component.base.BaseModule +import com.adyenreactnativesdk.component.base.ModuleException +import com.adyenreactnativesdk.react.ComponentContract +import com.adyenreactnativesdk.util.ReactNativeJson +import com.adyenreactnativesdk.util.messaging.MessageBus +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.ReadableMap +import org.json.JSONException + +class MessageBusModule( + val context: ReactApplicationContext?, + val messageBus: MessageBus, +) : BaseModule(context) { + override fun getName(): String = COMPONENT_NAME + + @ReactMethod + fun addListener(eventName: String?) { + } + + @ReactMethod + fun removeListeners(count: Int?) { + } + + @ReactMethod + fun hide( + success: Boolean, + message: ReadableMap?, + ) { + cleanup() + } + + @ReactMethod + fun handle(actionMap: ReadableMap?) { + val name = + currentComponent ?: return messageBus.sendErrorEvent(ModuleException.NoPaymentRegistered()) + + val component = + consumers[name] + ?: return messageBus.sendErrorEvent(ModuleException.NoConsumer(name)) + + try { + val jsonObject = ReactNativeJson.convertMapToJson(actionMap) + val action = Action.Companion.SERIALIZER.deserialize(jsonObject) + component.onAction(action) + } catch (e: JSONException) { + messageBus.sendErrorEvent(ModuleException.InvalidAction(e)) + } + } + + companion object { + private const val TAG = "MessageBusModule" + private const val COMPONENT_NAME = "AdyenMessageBus" + var consumers: MutableMap = mutableMapOf() + var currentComponent: String? = null + } +} diff --git a/android/src/main/java/com/adyenreactnativesdk/component/SessionHelperModule.kt b/android/src/main/java/com/adyenreactnativesdk/component/SessionHelperModule.kt index 6dbd4778c..3bfad087c 100644 --- a/android/src/main/java/com/adyenreactnativesdk/component/SessionHelperModule.kt +++ b/android/src/main/java/com/adyenreactnativesdk/component/SessionHelperModule.kt @@ -1,8 +1,15 @@ package com.adyenreactnativesdk.component import androidx.lifecycle.lifecycleScope -import com.adyen.checkout.sessions.core.SessionPaymentResult +import com.adyen.checkout.components.core.CheckoutConfiguration +import com.adyen.checkout.sessions.core.CheckoutSessionProvider +import com.adyen.checkout.sessions.core.CheckoutSessionResult +import com.adyen.checkout.sessions.core.SessionModel +import com.adyen.checkout.sessions.core.SessionSetupResponse import com.adyenreactnativesdk.component.base.BaseModule +import com.adyenreactnativesdk.component.base.ModuleException +import com.adyenreactnativesdk.configuration.CheckoutConfigurationFactory +import com.adyenreactnativesdk.util.ReactNativeJson import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactMethod @@ -14,18 +21,13 @@ class SessionHelperModule( context: ReactApplicationContext?, ) : BaseModule(context) { @ReactMethod - fun addListener(eventName: String?) { // No JS events expected + fun addListener(eventName: String?) { + // Required for NativeEventEmitter } @ReactMethod - fun removeListeners(count: Int?) { // No JS events expected - } - - @ReactMethod - fun open( - paymentMethodsData: ReadableMap?, - configuration: ReadableMap, - ) { // No UI + fun removeListeners(count: Int?) { + // Required for NativeEventEmitter } @ReactMethod @@ -33,12 +35,11 @@ class SessionHelperModule( success: Boolean, message: ReadableMap?, ) { // No UI + cleanup() } override fun getName(): String = COMPONENT_NAME - override fun onFinished(result: SessionPaymentResult): Unit = throw NotImplementedError("This Module have no events") - @ReactMethod fun createSession( sessionModelJSON: ReadableMap, @@ -46,10 +47,48 @@ class SessionHelperModule( promise: Promise, ) { appCompatActivity.lifecycleScope.launch(Dispatchers.IO) { - super.createSessionAsync(sessionModelJSON, configurationJSON, promise) + createSessionAsync(sessionModelJSON, configurationJSON, promise) } } + suspend fun createSessionAsync( + sessionModelJSON: ReadableMap, + configurationJSON: ReadableMap, + promise: Promise, + ) { + val sessionModel: SessionModel + val configuration: CheckoutConfiguration + try { + sessionModel = parseSessionModel(sessionModelJSON) + configuration = CheckoutConfigurationFactory.get(configurationJSON) + } catch (e: java.lang.Exception) { + promise.reject(ModuleException.SessionError(e)) + return + } + + val session = + when (val result = CheckoutSessionProvider.createSession(sessionModel, configuration)) { + is CheckoutSessionResult.Success -> { + result.checkoutSession + } + + is CheckoutSessionResult.Error -> { + promise.reject(ModuleException.SessionError(result.exception)) + return + } + } + + val json = SessionSetupResponse.SERIALIZER.serialize(session.sessionSetupResponse) + val map = ReactNativeJson.convertJsonToMap(json) + setSession(session) + promise.resolve(map) + } + + private fun parseSessionModel(json: ReadableMap): SessionModel { + val sessionModelJSON = ReactNativeJson.convertMapToJson(json) + return SessionModel.SERIALIZER.deserialize(sessionModelJSON) + } + companion object { private const val COMPONENT_NAME = "SessionHelper" } diff --git a/android/src/main/java/com/adyenreactnativesdk/component/applepay/ApplePayModuleMock.kt b/android/src/main/java/com/adyenreactnativesdk/component/applepay/ApplePayModuleMock.kt index 5f2179e7c..1fb149879 100644 --- a/android/src/main/java/com/adyenreactnativesdk/component/applepay/ApplePayModuleMock.kt +++ b/android/src/main/java/com/adyenreactnativesdk/component/applepay/ApplePayModuleMock.kt @@ -8,6 +8,7 @@ package com.adyenreactnativesdk.component.applepay import com.adyenreactnativesdk.component.base.BaseModule import com.adyenreactnativesdk.component.base.ModuleException +import com.adyenreactnativesdk.util.messaging.MessageBus import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactMethod @@ -15,6 +16,7 @@ import com.facebook.react.bridge.ReadableMap class ApplePayModuleMock( context: ReactApplicationContext?, + val messageBus: MessageBus, ) : BaseModule(context) { override fun getName(): String = COMPONENT_NAME @@ -27,7 +29,7 @@ class ApplePayModuleMock( paymentMethodsData: ReadableMap, configuration: ReadableMap, ) { - sendErrorEvent(ModuleException.NotSupported()) + messageBus.sendErrorEvent(ModuleException.NotSupported()) } @ReactMethod diff --git a/android/src/main/java/com/adyenreactnativesdk/component/base/AdvancedComponentViewModel.kt b/android/src/main/java/com/adyenreactnativesdk/component/base/AdvancedComponentViewModel.kt index 1927bda27..95f2e2f1d 100644 --- a/android/src/main/java/com/adyenreactnativesdk/component/base/AdvancedComponentViewModel.kt +++ b/android/src/main/java/com/adyenreactnativesdk/component/base/AdvancedComponentViewModel.kt @@ -1,13 +1,12 @@ package com.adyenreactnativesdk.component.base -import android.util.Log import androidx.lifecycle.viewModelScope import com.adyen.checkout.components.core.ActionComponentData import com.adyen.checkout.components.core.ComponentCallback import com.adyen.checkout.components.core.PaymentComponentState import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.sessions.core.CheckoutSession -import com.adyenreactnativesdk.component.CheckoutProxy +import com.adyenreactnativesdk.AdyenPaymentPackage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -26,24 +25,10 @@ class AdvancedComponentViewModel, TComponentDa } override fun onAdditionalDetails(actionComponentData: ActionComponentData) { - CheckoutProxy.shared.componentListener?.onAdditionalDetails(actionComponentData) ?: { - Log.e( - TAG, - COMPONENT_LISTENER_IS_NULL, - ) - } + AdyenPaymentPackage.messageBus.onAdditionalDetails(actionComponentData) } override fun onSubmit(state: TState) { - CheckoutProxy.shared.componentListener?.onSubmit(state) ?: { - Log.e( - TAG, - COMPONENT_LISTENER_IS_NULL, - ) - } - } - - companion object { - private const val TAG = "AdvancedViewModel" + AdyenPaymentPackage.messageBus.onSubmit(state, null) } } diff --git a/android/src/main/java/com/adyenreactnativesdk/component/base/BaseModule.kt b/android/src/main/java/com/adyenreactnativesdk/component/base/BaseModule.kt index 142170b7c..d1e4506cf 100644 --- a/android/src/main/java/com/adyenreactnativesdk/component/base/BaseModule.kt +++ b/android/src/main/java/com/adyenreactnativesdk/component/base/BaseModule.kt @@ -7,89 +7,21 @@ package com.adyenreactnativesdk.component.base import androidx.appcompat.app.AppCompatActivity -import com.adyen.checkout.adyen3ds2.Cancelled3DS2Exception -import com.adyen.checkout.adyen3ds2.adyen3DS2 -import com.adyen.checkout.bcmc.bcmc -import com.adyen.checkout.card.card -import com.adyen.checkout.components.core.ActionComponentData -import com.adyen.checkout.components.core.CheckoutConfiguration -import com.adyen.checkout.components.core.Order -import com.adyen.checkout.components.core.OrderResponse -import com.adyen.checkout.components.core.PaymentComponentData -import com.adyen.checkout.components.core.PaymentComponentState import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.PaymentMethodsApiResponse -import com.adyen.checkout.components.core.StoredPaymentMethod -import com.adyen.checkout.core.exception.CancellationException -import com.adyen.checkout.core.exception.CheckoutException -import com.adyen.checkout.dropin.dropIn -import com.adyen.checkout.giftcard.giftCard -import com.adyen.checkout.googlepay.GooglePayComponentState -import com.adyen.checkout.googlepay.googlePay import com.adyen.checkout.sessions.core.CheckoutSession -import com.adyen.checkout.sessions.core.CheckoutSessionProvider -import com.adyen.checkout.sessions.core.CheckoutSessionResult -import com.adyen.checkout.sessions.core.SessionModel -import com.adyen.checkout.sessions.core.SessionPaymentResult -import com.adyen.checkout.sessions.core.SessionSetupResponse import com.adyenreactnativesdk.AdyenCheckout -import com.adyenreactnativesdk.component.CheckoutProxy -import com.adyenreactnativesdk.component.model.SubmitMap -import com.adyenreactnativesdk.configuration.AnalyticsParser -import com.adyenreactnativesdk.configuration.CardConfigurationParser -import com.adyenreactnativesdk.configuration.DropInConfigurationParser -import com.adyenreactnativesdk.configuration.GooglePayConfigurationParser -import com.adyenreactnativesdk.configuration.PartialPaymentParser -import com.adyenreactnativesdk.configuration.RootConfigurationParser -import com.adyenreactnativesdk.configuration.ThreeDSConfigurationParser -import com.adyenreactnativesdk.util.AdyenConstants -import com.adyenreactnativesdk.util.ReactNativeError import com.adyenreactnativesdk.util.ReactNativeJson -import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReadableMap -import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter import org.json.JSONException -import org.json.JSONObject abstract class BaseModule( context: ReactApplicationContext?, -) : ReactContextBaseJavaModule(context), - CheckoutProxy.ComponentEventListener { - internal fun sendEvent( - eventName: String, - jsonObject: JSONObject, - ) { - try { - reactApplicationContext - .getJSModule(RCTDeviceEventEmitter::class.java) - .emit(eventName, ReactNativeJson.convertJsonToMap(jsonObject)) - } catch (e: JSONException) { - sendErrorEvent(e) - } - } - +) : ReactContextBaseJavaModule(context) { internal var integration = if (session == null) "advanced" else "session" - protected fun sendErrorEvent(error: Exception) { - reactApplicationContext - .getJSModule(RCTDeviceEventEmitter::class.java) - .emit(DID_FAILED, ReactNativeError.mapError(error)) - } - - private fun sendFinishEvent(result: SessionPaymentResult) { - val jsonObject = - JSONObject().apply { - put(RESULT_CODE_KEY, result.resultCode) - put(ORDER_KEY, result.order?.let { OrderResponse.SERIALIZER.serialize(it) }) - put(SESSION_RESULT_KEY, result.sessionResult) - put(SESSION_DATA_KEY, result.sessionData) - put(SESSION_ID_KEY, result.sessionId) - } - sendEvent(DID_COMPLETE, jsonObject) - } - protected fun getPaymentMethodsApiResponse(paymentMethods: ReadableMap?): PaymentMethodsApiResponse = try { val jsonObject = ReactNativeJson.convertMapToJson(paymentMethods) @@ -110,185 +42,19 @@ abstract class BaseModule( ?: throw Exception("Not an AppCompact Activity") } - open suspend fun createSessionAsync( - sessionModelJSON: ReadableMap, - configurationJSON: ReadableMap, - promise: Promise, - ) { - val sessionModel: SessionModel - val configuration: CheckoutConfiguration - try { - sessionModel = parseSessionModel(sessionModelJSON) - configuration = getCheckoutConfiguration(configurationJSON) - } catch (e: java.lang.Exception) { - promise.reject(ModuleException.SessionError(e)) - return - } - - session = - when (val result = CheckoutSessionProvider.createSession(sessionModel, configuration)) { - is CheckoutSessionResult.Success -> result.checkoutSession - is CheckoutSessionResult.Error -> { - promise.reject(ModuleException.SessionError(null)) - return - } - } - - session?.sessionSetupResponse?.let { - val json = SessionSetupResponse.SERIALIZER.serialize(it) - val map = ReactNativeJson.convertJsonToMap(json) - promise.resolve(map) - } - } - - private fun parseSessionModel(json: ReadableMap): SessionModel { - val sessionModelJSON = ReactNativeJson.convertMapToJson(json) - return SessionModel.SERIALIZER.deserialize(sessionModelJSON) - } - - open fun getRedirectUrl(): String? = null - - override fun onSubmit(state: PaymentComponentState<*>) { - val extra = - if (state is GooglePayComponentState) { - state.paymentData?.let { - JSONObject(it.toJson()) - } - } else { - null - } - val jsonObject = PaymentComponentData.SERIALIZER.serialize(state.data) - getRedirectUrl()?.let { - jsonObject.put(AdyenConstants.PARAMETER_RETURN_URL, it) - } - - val submitMap = SubmitMap(jsonObject, extra) - sendEvent(DID_SUBMIT, submitMap.toJSONObject()) - } - - override fun onException(exception: CheckoutException) { - if (exception is CancellationException || - exception is Cancelled3DS2Exception || - exception.message == "Payment canceled." - ) { - sendErrorEvent(ModuleException.Canceled()) - } else { - sendErrorEvent(exception) - } - } - - override fun onFinished(result: SessionPaymentResult) { - val updatedResult = - if (result.resultCode == VOUCHER_RESULT_CODE) { - result.copy(resultCode = RESULT_CODE_PRESENTED) - } else { - result - } - sendFinishEvent(updatedResult) - } - - override fun onAdditionalDetails(actionComponentData: ActionComponentData) { - val jsonObject = ActionComponentData.SERIALIZER.serialize(actionComponentData) - sendEvent(DID_PROVIDE, jsonObject) - } - - override fun onRemove(storedPaymentMethod: StoredPaymentMethod): Unit = - throw NotImplementedError("An operation only available for DropIn.") - - override fun onBalanceCheck(paymentComponentState: PaymentComponentState<*>) { - val jsonObject = PaymentComponentData.SERIALIZER.serialize(paymentComponentState.data) - sendEvent(DID_CHECK_BALANCE, jsonObject) - } - - override fun onOrderRequest() { - sendEvent(DID_REQUEST_ORDER, JSONObject()) - } - - override fun onOrderCancel( - order: Order, - shouldUpdatePaymentMethods: Boolean, - ) { - val jsonObject = - JSONObject().apply { - this.put(ORDER_KEY, Order.SERIALIZER.serialize(order)) - this.put(SHOULD_UPDATE_PAYMENT_METHODS_KEY, shouldUpdatePaymentMethods) - } - sendEvent(DID_CANCEL_ORDER, jsonObject) - } - - protected fun getCheckoutConfiguration(json: ReadableMap): CheckoutConfiguration { - val rootParser = RootConfigurationParser(json) - val countryCode = rootParser.countryCode - val analyticsConfiguration = AnalyticsParser(json).analytics - - val clientKey = rootParser.clientKey ?: throw ModuleException.NoClientKey() - return CheckoutConfiguration( - environment = rootParser.environment, - clientKey = clientKey, - shopperLocale = rootParser.locale, - amount = rootParser.amount, - analyticsConfiguration = analyticsConfiguration, - ) { - googlePay { - setCountryCode(countryCode) - val googlePayParser = GooglePayConfigurationParser(json) - googlePayParser.applyConfiguration(this) - } - val cardParser = CardConfigurationParser(json, countryCode) - card { - cardParser.applyConfiguration(this) - } - bcmc { - cardParser.applyConfiguration(this) - } - dropIn { - val parser = DropInConfigurationParser(json) - parser.applyConfiguration(this) - } - adyen3DS2 { - val parser = ThreeDSConfigurationParser(json) - parser.applyConfiguration(this) - } - giftCard { - val parser = PartialPaymentParser(json) - setPinRequired(parser.pinRequired) - } - } + protected fun setSession(session: CheckoutSession) { + BaseModule.session = session } protected fun cleanup() { session = null AdyenCheckout.removeComponent() AdyenCheckout.removeDropInListener() - CheckoutProxy.shared.componentListener = null } companion object { - const val DID_COMPLETE = "didCompleteCallback" - const val DID_PROVIDE = "didProvideCallback" - const val DID_FAILED = "didFailCallback" - const val DID_SUBMIT = "didSubmitCallback" - const val DID_UPDATE_ADDRESS = "didUpdateAddressCallback" - const val DID_CONFIRM_ADDRESS = "didConfirmAddressCallback" - const val DID_DISABLE_STORED_PAYMENT_METHOD = "didDisableStoredPaymentMethodCallback" - const val DID_CHECK_BALANCE = "didCheckBalanceCallback" - const val DID_REQUEST_ORDER = "didRequestOrderCallback" - const val DID_CANCEL_ORDER = "didCancelOrderCallback" - const val DID_BIN_LOOKUP = "didBinLookupCallback" - const val DID_CHANGE_BIN_VALUE = "didChangeBinValueCallback" - - const val RESULT_CODE_PRESENTED = "PresentToShopper" - - private const val VOUCHER_RESULT_CODE = "finish_with_action" - private const val RESULT_CODE_KEY = "resultCode" - private const val ORDER_KEY = "order" - private const val SESSION_RESULT_KEY = "sessionResult" - private const val SESSION_DATA_KEY = "sessionData" - private const val SESSION_ID_KEY = "sessionId" - private const val SHOULD_UPDATE_PAYMENT_METHODS_KEY = "shouldUpdatePaymentMethods" - @JvmStatic - protected var session: CheckoutSession? = null + var session: CheckoutSession? = null private set } } diff --git a/android/src/main/java/com/adyenreactnativesdk/component/base/BaseViewModel.kt b/android/src/main/java/com/adyenreactnativesdk/component/base/BaseViewModel.kt index ba45cf926..bc9a6efff 100644 --- a/android/src/main/java/com/adyenreactnativesdk/component/base/BaseViewModel.kt +++ b/android/src/main/java/com/adyenreactnativesdk/component/base/BaseViewModel.kt @@ -6,7 +6,6 @@ package com.adyenreactnativesdk.component.base -import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.adyen.checkout.components.core.ComponentError @@ -14,7 +13,7 @@ import com.adyen.checkout.components.core.PaymentComponentState import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.action.Action import com.adyen.checkout.sessions.core.CheckoutSession -import com.adyenreactnativesdk.component.CheckoutProxy +import com.adyenreactnativesdk.AdyenPaymentPackage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -58,23 +57,11 @@ abstract class BaseViewModel, TComponentData : } } - fun onError(componentError: ComponentError) { - CheckoutProxy.shared.componentListener?.let { it.onException(componentError.exception) } - ?: { - Log.e( - TAG, - COMPONENT_LISTENER_IS_NULL, - ) - } + open fun onError(componentError: ComponentError) { + AdyenPaymentPackage.messageBus.onException(componentError.exception) } protected suspend fun emitData(componentData: ComponentData) { _componentDataFlow.emit(componentData as TComponentData) } - - companion object { - const val COMPONENT_LISTENER_IS_NULL = - "CheckoutProxy.shared.componentListener is null" - private const val TAG = "ComponentViewModel" - } } diff --git a/android/src/main/java/com/adyenreactnativesdk/component/base/ModuleException.kt b/android/src/main/java/com/adyenreactnativesdk/component/base/ModuleException.kt index 5f4cf9c84..b04687c33 100644 --- a/android/src/main/java/com/adyenreactnativesdk/component/base/ModuleException.kt +++ b/android/src/main/java/com/adyenreactnativesdk/component/base/ModuleException.kt @@ -106,4 +106,17 @@ sealed class ModuleException( code = "noActivity", message = "ViewModel callback is inconsistent", ) + + class NoConsumer( + val name: String, + ) : ModuleException( + code = "noConsumer", + message = "This View is not registered in MessageBus for $name", + ) + + class NoPaymentRegistered : + ModuleException( + code = "noPaymentRegistered", + message = "No `onSubmit` was detected to process this action.", + ) } diff --git a/android/src/main/java/com/adyenreactnativesdk/component/base/SessionsComponentViewModel.kt b/android/src/main/java/com/adyenreactnativesdk/component/base/SessionsComponentViewModel.kt index 9858b6d36..71cb3b606 100644 --- a/android/src/main/java/com/adyenreactnativesdk/component/base/SessionsComponentViewModel.kt +++ b/android/src/main/java/com/adyenreactnativesdk/component/base/SessionsComponentViewModel.kt @@ -2,12 +2,13 @@ package com.adyenreactnativesdk.component.base import android.util.Log import androidx.lifecycle.viewModelScope +import com.adyen.checkout.components.core.ComponentError import com.adyen.checkout.components.core.PaymentComponentState import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.sessions.core.CheckoutSession import com.adyen.checkout.sessions.core.SessionComponentCallback import com.adyen.checkout.sessions.core.SessionPaymentResult -import com.adyenreactnativesdk.component.CheckoutProxy +import com.adyenreactnativesdk.AdyenPaymentPackage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -26,12 +27,10 @@ class SessionsComponentViewModel, TComponentDa } override fun onFinished(result: SessionPaymentResult) { - CheckoutProxy.shared.componentListener?.let { it.onFinished(result) } ?: { - Log.e(TAG, COMPONENT_LISTENER_IS_NULL) - } + AdyenPaymentPackage.messageBus.onFinished(result) } - companion object { - private const val TAG = "SessionsViewModel" + override fun onError(componentError: ComponentError) { + AdyenPaymentPackage.messageBus.onSessionException(componentError.exception) } } diff --git a/android/src/main/java/com/adyenreactnativesdk/component/dropin/AdvancedCheckoutService.kt b/android/src/main/java/com/adyenreactnativesdk/component/dropin/AdvancedCheckoutService.kt index 7c44b1cc6..ab98d9329 100644 --- a/android/src/main/java/com/adyenreactnativesdk/component/dropin/AdvancedCheckoutService.kt +++ b/android/src/main/java/com/adyenreactnativesdk/component/dropin/AdvancedCheckoutService.kt @@ -6,83 +6,64 @@ package com.adyenreactnativesdk.component.dropin -import android.util.Log import com.adyen.checkout.card.BinLookupData import com.adyen.checkout.components.core.ActionComponentData -import com.adyen.checkout.components.core.AddressLookupCallback import com.adyen.checkout.components.core.LookupAddress import com.adyen.checkout.components.core.Order import com.adyen.checkout.components.core.PaymentComponentState import com.adyen.checkout.components.core.StoredPaymentMethod import com.adyen.checkout.dropin.DropInService -import com.adyenreactnativesdk.component.CheckoutProxy -import com.adyenreactnativesdk.component.CheckoutProxy.CardComponentEventListener +import com.adyen.checkout.redirect.RedirectComponent +import com.adyenreactnativesdk.AdyenPaymentPackage open class AdvancedCheckoutService : DropInService() { override fun onCreate() { super.onCreate() - CheckoutProxy.shared.advancedService = this + DropInModule.advancedService = this } override fun onSubmit(state: PaymentComponentState<*>) { - val listener = CheckoutProxy.shared.componentListener - listener?.onSubmit(state) - ?: Log.e( - TAG, - "Invalid state: DropInServiceListener is missing", - ) + val returnUrl = RedirectComponent.getReturnUrl(applicationContext) + AdyenPaymentPackage.messageBus.onSubmit(state, returnUrl) } override fun onAdditionalDetails(actionComponentData: ActionComponentData) { - val listener = CheckoutProxy.shared.componentListener - listener?.onAdditionalDetails(actionComponentData) - ?: Log.e( - TAG, - "Invalid state: DropInServiceListener is missing", - ) + AdyenPaymentPackage.messageBus.onAdditionalDetails(actionComponentData) } override fun onAddressLookupQueryChanged(query: String) { - val listener = CheckoutProxy.shared.componentListener as? AddressLookupCallback - listener?.onQueryChanged(query) + AdyenPaymentPackage.messageBus.onQueryChanged(query) } - override fun onAddressLookupCompletion(lookupAddress: LookupAddress): Boolean { - val listener = CheckoutProxy.shared.componentListener as? AddressLookupCallback - return listener?.onLookupCompletion(lookupAddress) ?: false - } + override fun onAddressLookupCompletion(lookupAddress: LookupAddress): Boolean = + AdyenPaymentPackage.messageBus.onLookupCompletion(lookupAddress) override fun onBalanceCheck(paymentComponentState: PaymentComponentState<*>) { - val listener = CheckoutProxy.shared.componentListener - listener?.onBalanceCheck(paymentComponentState) + AdyenPaymentPackage.messageBus.onBalanceCheck(paymentComponentState) } override fun onOrderRequest() { - val listener = CheckoutProxy.shared.componentListener - listener?.onOrderRequest() + AdyenPaymentPackage.messageBus.onOrderRequest() } override fun onOrderCancel( order: Order, shouldUpdatePaymentMethods: Boolean, ) { - val listener = CheckoutProxy.shared.componentListener - listener?.onOrderCancel(order, shouldUpdatePaymentMethods) + AdyenPaymentPackage.messageBus.onOrderCancel(order, shouldUpdatePaymentMethods) } override fun onBinLookup(data: List) { - val listener = CheckoutProxy.shared.componentListener as? CardComponentEventListener - listener?.onBinLookup(data) + AdyenPaymentPackage.messageBus.onBinLookup(data) } override fun onBinValue(binValue: String) { - val listener = CheckoutProxy.shared.componentListener as? CardComponentEventListener - listener?.onBinValue(binValue) + AdyenPaymentPackage.messageBus.onBinValue(binValue) } override fun onRemoveStoredPaymentMethod(storedPaymentMethod: StoredPaymentMethod) { - val listener = CheckoutProxy.shared.componentListener as? CardComponentEventListener - listener?.onRemove(storedPaymentMethod) + DropInModule.storedPaymentMethodID = storedPaymentMethod.id + AdyenPaymentPackage.messageBus.onRemove(storedPaymentMethod) } companion object { diff --git a/android/src/main/java/com/adyenreactnativesdk/component/dropin/DropInCallbackListener.kt b/android/src/main/java/com/adyenreactnativesdk/component/dropin/DropInCallbackListener.kt new file mode 100644 index 000000000..62ceeabf7 --- /dev/null +++ b/android/src/main/java/com/adyenreactnativesdk/component/dropin/DropInCallbackListener.kt @@ -0,0 +1,36 @@ +package com.adyenreactnativesdk.component.dropin + +import com.adyen.checkout.dropin.DropInCallback +import com.adyen.checkout.dropin.DropInResult +import com.adyen.checkout.dropin.SessionDropInCallback +import com.adyen.checkout.dropin.SessionDropInResult +import java.lang.ref.WeakReference + +class DropInCallbackListener : + DropInCallback, + SessionDropInCallback { + var callback: WeakReference = + WeakReference(null) + + override fun onDropInResult(dropInResult: DropInResult?) { + callback.get()?.let { + when (dropInResult) { + is DropInResult.CancelledByUser -> it.onCancel() + is DropInResult.Error -> it.onError(dropInResult.reason) + is DropInResult.Finished -> it.onCompleted(dropInResult.result) + null -> return + } + } + } + + override fun onDropInResult(sessionDropInResult: SessionDropInResult?) { + callback.get()?.let { + when (sessionDropInResult) { + is SessionDropInResult.CancelledByUser -> it.onCancel() + is SessionDropInResult.Error -> it.onError(sessionDropInResult.reason) + is SessionDropInResult.Finished -> it.onFinished(sessionDropInResult.result) + null -> return + } + } + } +} diff --git a/android/src/main/java/com/adyenreactnativesdk/component/dropin/DropInModule.kt b/android/src/main/java/com/adyenreactnativesdk/component/dropin/DropInModule.kt index 5ad528f85..02ad4a615 100644 --- a/android/src/main/java/com/adyenreactnativesdk/component/dropin/DropInModule.kt +++ b/android/src/main/java/com/adyenreactnativesdk/component/dropin/DropInModule.kt @@ -7,15 +7,11 @@ package com.adyenreactnativesdk.component.dropin import android.util.Log -import com.adyen.checkout.card.BinLookupData -import com.adyen.checkout.components.core.AddressData -import com.adyen.checkout.components.core.AddressLookupCallback import com.adyen.checkout.components.core.BalanceResult import com.adyen.checkout.components.core.CheckoutConfiguration import com.adyen.checkout.components.core.LookupAddress import com.adyen.checkout.components.core.OrderResponse import com.adyen.checkout.components.core.PaymentMethodsApiResponse -import com.adyen.checkout.components.core.StoredPaymentMethod import com.adyen.checkout.components.core.action.Action import com.adyen.checkout.dropin.AddressLookupDropInServiceResult import com.adyen.checkout.dropin.BalanceDropInServiceResult @@ -28,13 +24,13 @@ import com.adyen.checkout.dropin.RecurringDropInServiceResult import com.adyen.checkout.redirect.RedirectComponent import com.adyen.checkout.sessions.core.SessionPaymentResult import com.adyenreactnativesdk.AdyenCheckout -import com.adyenreactnativesdk.component.CheckoutProxy import com.adyenreactnativesdk.component.base.BaseModule import com.adyenreactnativesdk.component.base.ModuleException -import com.adyenreactnativesdk.component.model.AddressDataAdapter -import com.adyenreactnativesdk.component.model.BinLookupDataDTO +import com.adyenreactnativesdk.configuration.CheckoutConfigurationFactory import com.adyenreactnativesdk.util.AdyenConstants import com.adyenreactnativesdk.util.ReactNativeJson +import com.adyenreactnativesdk.util.messaging.MessageBus +import com.adyenreactnativesdk.util.messaging.MessageBus.Companion.RESULT_CODE_PRESENTED import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext @@ -43,23 +39,18 @@ import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap import com.facebook.react.jstasks.HeadlessJsTaskConfig import com.facebook.react.jstasks.HeadlessJsTaskContext -import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter -import com.google.gson.GsonBuilder -import org.json.JSONArray -import org.json.JSONObject +import com.google.gson.Gson class DropInModule( - context: ReactApplicationContext?, + context: ReactApplicationContext, + val messageBus: MessageBus, + val gson: Gson, ) : BaseModule(context), - ReactDropInCallback, - AddressLookupCallback, - CheckoutProxy.CardComponentEventListener { + ReactDropInCallback { private var taskId: Int? = null - private fun getService(): BaseDropInServiceContract? = - if (session != null) CheckoutProxy.shared.sessionService else CheckoutProxy.shared.advancedService - - private var storedPaymentMethodID: String? = null + private val service: BaseDropInServiceContract? + get() = if (session != null) sessionService else advancedService @ReactMethod fun addListener(eventName: String?) { // No JS events expected @@ -71,7 +62,7 @@ class DropInModule( @ReactMethod fun getReturnURL(promise: Promise) { - promise.resolve(getRedirectUrl()) + promise.resolve(RedirectComponent.getReturnUrl(reactApplicationContext)) } override fun getName(): String = COMPONENT_NAME @@ -85,12 +76,11 @@ class DropInModule( val paymentMethodsResponse: PaymentMethodsApiResponse try { paymentMethodsResponse = getPaymentMethodsApiResponse(paymentMethodsData) - checkoutConfiguration = getCheckoutConfiguration(configuration) + checkoutConfiguration = CheckoutConfigurationFactory.get(configuration) } catch (e: java.lang.Exception) { - return sendErrorEvent(e) + return messageBus.sendErrorEvent(e) } - CheckoutProxy.shared.componentListener = this AdyenCheckout.addDropInListener(this) val session = session if (session != null) { @@ -120,17 +110,12 @@ class DropInModule( @ReactMethod fun handle(actionMap: ReadableMap?) { - val listener = getService() - if (listener == null) { - sendErrorEvent(ModuleException.NoModuleListener(integration)) - return - } try { val jsonObject = ReactNativeJson.convertMapToJson(actionMap) val action = Action.SERIALIZER.deserialize(jsonObject) - listener.sendResult(DropInServiceResult.Action(action)) + service?.sendResult(DropInServiceResult.Action(action)) } catch (e: Exception) { - sendErrorEvent(ModuleException.InvalidAction(e)) + messageBus.sendErrorEvent(ModuleException.InvalidAction(e)) } } @@ -148,27 +133,17 @@ class DropInModule( } @ReactMethod - fun update(results: ReadableArray?) { - if (results == null) return - val listener = getService() - if (listener == null) { - sendErrorEvent(ModuleException.NoModuleListener(integration)) - return - } - - try { - val jsonString = ReactNativeJson.convertArrayToJson(results).toString() - val addresses = gson.fromJson(jsonString, Array::class.java) - val result = AddressLookupDropInServiceResult.LookupResult(addresses.toList()) - listener.sendAddressLookupResult(result) - } catch (error: Throwable) { - Log.w(TAG, error) - val result = - AddressLookupDropInServiceResult.LookupResult( - arrayListOf(), - ) - listener.sendAddressLookupResult(result) - } + fun update(array: ReadableArray) { + val result = + try { + val jsonString = ReactNativeJson.convertArrayToJson(array).toString() + val addresses = gson.fromJson(jsonString, Array::class.java) + AddressLookupDropInServiceResult.LookupResult(addresses.toList()) + } catch (error: Throwable) { + Log.w(TAG, error) + AddressLookupDropInServiceResult.LookupResult(arrayListOf()) + } + service?.sendAddressLookupResult(result) } @ReactMethod @@ -176,57 +151,52 @@ class DropInModule( success: Boolean, address: ReadableMap?, ) { - val listener = getService() - if (listener == null) { - sendErrorEvent(ModuleException.NoModuleListener(integration)) - return - } - - if (success) { - try { - val jsonString = ReactNativeJson.convertMapToJson(address).toString() - val lookupAddress = gson.fromJson(jsonString, LookupAddress::class.java) - listener.sendAddressLookupResult( - AddressLookupDropInServiceResult.LookupComplete( - lookupAddress, - ), - ) - } catch (error: Throwable) { - listener.sendAddressLookupResult( + val result = + if (success) { + try { + val jsonString = ReactNativeJson.convertMapToJson(address).toString() + val lookupAddress = gson.fromJson(jsonString, LookupAddress::class.java) + AddressLookupDropInServiceResult.LookupComplete(lookupAddress) + } catch (error: Throwable) { AddressLookupDropInServiceResult.Error( ErrorDialog( message = error.localizedMessage, ), null, false, - ), - ) - } - } else { - val error = address?.getString("message")?.let { ErrorDialog(message = it) } - listener.sendAddressLookupResult( + ) + } + } else { + val error = address?.getString("message")?.let { ErrorDialog(message = it) } AddressLookupDropInServiceResult.Error( error, null, false, - ), - ) - } + ) + } + service?.sendAddressLookupResult(result) } @ReactMethod fun removeStored(success: Boolean) { - val successfulResult = - if (success) { - storedPaymentMethodID?.let { - RecurringDropInServiceResult.PaymentMethodRemoved(it) + val id = storedPaymentMethodID + if (id == null) { + Log.w(TAG, "No stored payment method was marked for removal") + return + } + + val result = + when { + success -> { + RecurringDropInServiceResult.PaymentMethodRemoved(id) } - } else { - null - } - val result = successfulResult ?: RecurringDropInServiceResult.Error(null, null, false) - CheckoutProxy.shared.advancedService?.sendRecurringResult(result) + else -> { + RecurringDropInServiceResult.Error(null, null, false) + } + } + advancedService?.sendRecurringResult(result) + storedPaymentMethodID = null } @ReactMethod @@ -235,19 +205,16 @@ class DropInModule( balance: ReadableMap?, error: ReadableMap?, ) { - val listener = getService() - if (listener == null) { - sendErrorEvent(ModuleException.NoModuleListener(integration)) - return - } - if (success) { - val jsonObject = ReactNativeJson.convertMapToJson(balance) - val balanceResult = BalanceResult.SERIALIZER.deserialize(jsonObject) - listener.sendBalanceResult(BalanceDropInServiceResult.Balance(balanceResult)) - } else { - val message = error?.getString(AdyenConstants.PARAMETER_MESSAGE) - listener.sendBalanceResult(BalanceDropInServiceResult.Error(null, message, true)) - } + val result = + if (success) { + val jsonObject = ReactNativeJson.convertMapToJson(balance) + val balanceResult = BalanceResult.SERIALIZER.deserialize(jsonObject) + BalanceDropInServiceResult.Balance(balanceResult) + } else { + val message = error?.getString(AdyenConstants.PARAMETER_MESSAGE) + BalanceDropInServiceResult.Error(null, message, true) + } + service?.sendBalanceResult(result) } @ReactMethod @@ -256,76 +223,77 @@ class DropInModule( order: ReadableMap?, error: ReadableMap?, ) { - val listener = getService() - if (listener == null) { - sendErrorEvent(ModuleException.NoModuleListener(integration)) - return - } - if (success) { - val jsonObject = ReactNativeJson.convertMapToJson(order) - val orderResponse = OrderResponse.SERIALIZER.deserialize(jsonObject) - listener.sendOrderResult(OrderDropInServiceResult.OrderCreated(orderResponse)) - } else { - val message = error?.getString(AdyenConstants.PARAMETER_MESSAGE) - listener.sendOrderResult(OrderDropInServiceResult.Error(null, message, true)) - } + val result = + if (success) { + val jsonObject = ReactNativeJson.convertMapToJson(order) + val orderResponse = OrderResponse.SERIALIZER.deserialize(jsonObject) + OrderDropInServiceResult.OrderCreated(orderResponse) + } else { + val message = error?.getString(AdyenConstants.PARAMETER_MESSAGE) + OrderDropInServiceResult.Error(null, message, true) + } + service?.sendOrderResult(result) } @ReactMethod fun providePaymentMethods( paymentMethods: ReadableMap, - order: ReadableMap?, + map: ReadableMap?, ) { - val listener = getService() - if (listener == null) { - sendErrorEvent(ModuleException.NoModuleListener(integration)) - return - } val pmJsonObject = ReactNativeJson.convertMapToJson(paymentMethods) val paymentMethods = PaymentMethodsApiResponse.SERIALIZER.deserialize(pmJsonObject) val order = - order?.let { + map?.let { val jsonObject = ReactNativeJson.convertMapToJson(it) - return@let OrderResponse.SERIALIZER.deserialize(jsonObject) + OrderResponse.SERIALIZER.deserialize(jsonObject) } - listener.sendResult(DropInServiceResult.Update(paymentMethods, order)) + service?.sendResult(DropInServiceResult.Update(paymentMethods, order)) } - override fun getRedirectUrl(): String? = RedirectComponent.getReturnUrl(reactApplicationContext) - override fun onCancel() { - sendErrorEvent(ModuleException.Canceled()) + if (session != null) { + messageBus.sendSessionErrorEvent(ModuleException.Canceled()) + } else { + messageBus.sendErrorEvent(ModuleException.Canceled()) + } } override fun onError(reason: String?) { - if (reason == THREEDS_CANCELED_MESSAGE) { // for canceled 3DS - sendErrorEvent(ModuleException.Canceled()) + val error = + if (reason == THREEDS_CANCELED_MESSAGE) { // for canceled 3DS + ModuleException.Canceled() + } else { + ModuleException.Unknown(reason) + } + if (session != null) { + messageBus.sendSessionErrorEvent(error) } else { - sendErrorEvent(ModuleException.Unknown(reason)) + messageBus.sendErrorEvent(error) } } override fun onCompleted(result: String) { - val jsonObject = JSONObject("{\"resultCode\": ${RESULT_CODE_PRESENTED}}") - sendEvent(DID_COMPLETE, jsonObject) + val result = SessionPaymentResult(null, null, null, RESULT_CODE_PRESENTED, null) + messageBus.onFinished(result) + } + + override fun onFinished(result: SessionPaymentResult) { + messageBus.onFinished(result) } private fun proxyHideDropInCommand( success: Boolean, message: ReadableMap?, ) { - val listener = getService() - if (listener == null) { - sendErrorEvent(ModuleException.NoModuleListener(integration)) - return - } val messageString = message?.getString(AdyenConstants.PARAMETER_MESSAGE) - if (success && messageString != null) { - listener.sendResult(DropInServiceResult.Finished(messageString)) - } else { - listener.sendResult(DropInServiceResult.Error(null, messageString, true)) - } + val result = + if (success && messageString != null) { + DropInServiceResult.Finished(messageString) + } else { + DropInServiceResult.Error(null, messageString, true) + } + service?.sendResult(result) } private fun startBackgroundService() { @@ -350,60 +318,13 @@ class DropInModule( private const val COMPONENT_NAME = "AdyenDropIn" private const val THREEDS_CANCELED_MESSAGE = "Challenge canceled." private const val TASK_NAME = "ADYEN_DROPIN_TASK" - - private val gson = - GsonBuilder() - .registerTypeAdapter(AddressData::class.java, AddressDataAdapter()) - .create() - } - - override fun onQueryChanged(query: String) { - reactApplicationContext - .getJSModule(RCTDeviceEventEmitter::class.java) - .emit(DID_UPDATE_ADDRESS, query) - } - - override fun onLookupCompletion(lookupAddress: LookupAddress): Boolean { - val jsonString = gson.toJson(lookupAddress) - val jsonObject = JSONObject(jsonString) - reactApplicationContext - .getJSModule(RCTDeviceEventEmitter::class.java) - .emit(DID_CONFIRM_ADDRESS, ReactNativeJson.convertJsonToMap(jsonObject)) - return true - } - - override fun onBinValue(binValue: String) { - reactApplicationContext - .getJSModule(RCTDeviceEventEmitter::class.java) - .emit(DID_CHANGE_BIN_VALUE, binValue) - } - - override fun onBinLookup(data: List) { - when { - data.isEmpty() -> { - return - } - else -> { - val brandOnlyMap = data.map { BinLookupDataDTO(it.brand) } - val jsonString = gson.toJson(brandOnlyMap) - val jsonObject = JSONArray(jsonString) - reactApplicationContext - .getJSModule(RCTDeviceEventEmitter::class.java) - .emit(DID_BIN_LOOKUP, ReactNativeJson.convertJsonToArray(jsonObject)) - } - } - } - - override fun onRemove(storedPaymentMethod: StoredPaymentMethod) { - storedPaymentMethodID = storedPaymentMethod.id - val jsonObject = StoredPaymentMethod.SERIALIZER.serialize(storedPaymentMethod) - reactApplicationContext - .getJSModule(RCTDeviceEventEmitter::class.java) - .emit(DID_DISABLE_STORED_PAYMENT_METHOD, ReactNativeJson.convertJsonToMap(jsonObject)) + var sessionService: BaseDropInServiceContract? = null + var advancedService: BaseDropInServiceContract? = null + var storedPaymentMethodID: String? = null } } -internal interface ReactDropInCallback { +interface ReactDropInCallback { fun onCancel() fun onError(reason: String?) diff --git a/android/src/main/java/com/adyenreactnativesdk/component/dropin/SessionCheckoutService.kt b/android/src/main/java/com/adyenreactnativesdk/component/dropin/SessionCheckoutService.kt index 3d8039960..cc5a17303 100644 --- a/android/src/main/java/com/adyenreactnativesdk/component/dropin/SessionCheckoutService.kt +++ b/android/src/main/java/com/adyenreactnativesdk/component/dropin/SessionCheckoutService.kt @@ -1,36 +1,28 @@ package com.adyenreactnativesdk.component.dropin import com.adyen.checkout.card.BinLookupData -import com.adyen.checkout.components.core.AddressLookupCallback import com.adyen.checkout.components.core.LookupAddress import com.adyen.checkout.dropin.SessionDropInService -import com.adyenreactnativesdk.AdyenCheckout -import com.adyenreactnativesdk.component.CheckoutProxy -import com.adyenreactnativesdk.component.CheckoutProxy.CardComponentEventListener +import com.adyenreactnativesdk.AdyenPaymentPackage class SessionCheckoutService : SessionDropInService() { override fun onCreate() { super.onCreate() - CheckoutProxy.shared.sessionService = this + DropInModule.sessionService = this } override fun onAddressLookupQueryChanged(query: String) { - val listener = CheckoutProxy.shared.componentListener as? AddressLookupCallback - listener?.onQueryChanged(query) + AdyenPaymentPackage.messageBus.onQueryChanged(query) } - override fun onAddressLookupCompletion(lookupAddress: LookupAddress): Boolean { - val listener = CheckoutProxy.shared.componentListener as? AddressLookupCallback - return listener?.onLookupCompletion(lookupAddress) ?: false - } + override fun onAddressLookupCompletion(lookupAddress: LookupAddress): Boolean = + AdyenPaymentPackage.messageBus.onLookupCompletion(lookupAddress) override fun onBinLookup(data: List) { - val listener = CheckoutProxy.shared.componentListener as? CardComponentEventListener - listener?.onBinLookup(data) + AdyenPaymentPackage.messageBus.onBinLookup(data) } override fun onBinValue(binValue: String) { - val listener = CheckoutProxy.shared.componentListener as? CardComponentEventListener - listener?.onBinValue(binValue) + AdyenPaymentPackage.messageBus.onBinValue(binValue) } } diff --git a/android/src/main/java/com/adyenreactnativesdk/component/googlepay/GooglePayModule.kt b/android/src/main/java/com/adyenreactnativesdk/component/googlepay/GooglePayModule.kt index 5e49aed29..bf0ab53cc 100644 --- a/android/src/main/java/com/adyenreactnativesdk/component/googlepay/GooglePayModule.kt +++ b/android/src/main/java/com/adyenreactnativesdk/component/googlepay/GooglePayModule.kt @@ -6,18 +6,18 @@ package com.adyenreactnativesdk.component.googlepay -import android.app.Application import com.adyen.checkout.components.core.CheckoutConfiguration import com.adyen.checkout.components.core.ComponentAvailableCallback import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.PaymentMethodsApiResponse import com.adyen.checkout.components.core.action.Action import com.adyen.checkout.googlepay.GooglePayComponent -import com.adyenreactnativesdk.component.CheckoutProxy import com.adyenreactnativesdk.component.base.BaseModule import com.adyenreactnativesdk.component.base.KnownException import com.adyenreactnativesdk.component.base.ModuleException +import com.adyenreactnativesdk.configuration.CheckoutConfigurationFactory import com.adyenreactnativesdk.util.ReactNativeJson +import com.adyenreactnativesdk.util.messaging.MessageBus import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactMethod @@ -26,8 +26,8 @@ import org.json.JSONException class GooglePayModule( context: ReactApplicationContext?, -) : BaseModule(context), - CheckoutProxy.ComponentEventListener { + val messageBus: MessageBus, +) : BaseModule(context) { override fun getName(): String = COMPONENT_NAME @ReactMethod @@ -47,19 +47,18 @@ class GooglePayModule( val paymentMethodsResponse: PaymentMethodsApiResponse try { paymentMethodsResponse = getPaymentMethodsApiResponse(paymentMethodsData) - checkoutConfiguration = getCheckoutConfiguration(configuration) + checkoutConfiguration = CheckoutConfigurationFactory.get(configuration) } catch (e: java.lang.Exception) { - return sendErrorEvent(e) + return messageBus.sendErrorEvent(e) } val googlePayPaymentMethod = getPaymentMethod(paymentMethodsResponse, PAYMENT_METHOD_KEYS) if (googlePayPaymentMethod == null) { - sendErrorEvent(ModuleException.NoPaymentMethods(PAYMENT_METHOD_KEYS)) + messageBus.sendErrorEvent(ModuleException.NoPaymentMethods(PAYMENT_METHOD_KEYS)) return } val payPaymentMethod: PaymentMethod = googlePayPaymentMethod - CheckoutProxy.shared.componentListener = this GooglePayComponent.run { PROVIDER.isAvailable( appCompatActivity.application, @@ -71,7 +70,7 @@ class GooglePayModule( paymentMethod: PaymentMethod, ) { if (!isAvailable) { - sendErrorEvent(GooglePayException.NotSupported()) + messageBus.sendErrorEvent(GooglePayException.NotSupported()) return } GooglePayFragment.show( @@ -93,7 +92,7 @@ class GooglePayModule( val action = Action.SERIALIZER.deserialize(jsonObject) GooglePayFragment.handle(appCompatActivity.supportFragmentManager, action) } catch (e: JSONException) { - sendErrorEvent(ModuleException.InvalidAction(e)) + messageBus.sendErrorEvent(ModuleException.InvalidAction(e)) } } @@ -117,7 +116,7 @@ class GooglePayModule( try { val jsonObject = ReactNativeJson.convertMapToJson(paymentMethods) paymentMethod = PaymentMethod.SERIALIZER.deserialize(jsonObject) - checkoutConfiguration = getCheckoutConfiguration(configuration) + checkoutConfiguration = CheckoutConfigurationFactory.get(configuration) } catch (e: java.lang.Exception) { return promise.reject(e) } diff --git a/android/src/main/java/com/adyenreactnativesdk/component/instant/InstantModule.kt b/android/src/main/java/com/adyenreactnativesdk/component/instant/InstantModule.kt index e3b1628f4..3a56d5e78 100644 --- a/android/src/main/java/com/adyenreactnativesdk/component/instant/InstantModule.kt +++ b/android/src/main/java/com/adyenreactnativesdk/component/instant/InstantModule.kt @@ -10,10 +10,11 @@ import com.adyen.checkout.components.core.CheckoutConfiguration import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.PaymentMethodTypes import com.adyen.checkout.components.core.action.Action -import com.adyenreactnativesdk.component.CheckoutProxy import com.adyenreactnativesdk.component.base.BaseModule import com.adyenreactnativesdk.component.base.ModuleException +import com.adyenreactnativesdk.configuration.CheckoutConfigurationFactory import com.adyenreactnativesdk.util.ReactNativeJson +import com.adyenreactnativesdk.util.messaging.MessageBus import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactMethod import com.facebook.react.bridge.ReadableMap @@ -21,8 +22,8 @@ import org.json.JSONException class InstantModule( context: ReactApplicationContext?, -) : BaseModule(context), - CheckoutProxy.ComponentEventListener { + val messageBus: MessageBus, +) : BaseModule(context) { override fun getName(): String = COMPONENT_NAME @ReactMethod @@ -41,15 +42,14 @@ class InstantModule( val checkoutConfiguration: CheckoutConfiguration val paymentMethod: PaymentMethod try { - checkoutConfiguration = getCheckoutConfiguration(configuration) + checkoutConfiguration = CheckoutConfigurationFactory.get(configuration) paymentMethod = getPaymentMethodsApiResponse(paymentMethodsData).paymentMethods?.firstOrNull() ?: throw ModuleException.InvalidPaymentMethods(null) } catch (e: Exception) { - return sendErrorEvent(e) + return messageBus.sendErrorEvent(e) } - CheckoutProxy.shared.componentListener = this fragment = when (paymentMethod.type) { PaymentMethodTypes.IDEAL -> IdealFragment @@ -71,7 +71,7 @@ class InstantModule( val action = Action.SERIALIZER.deserialize(jsonObject) fragment?.handle(appCompatActivity.supportFragmentManager, action) } catch (e: JSONException) { - sendErrorEvent(ModuleException.InvalidAction(e)) + messageBus.sendErrorEvent(ModuleException.InvalidAction(e)) } } diff --git a/android/src/main/java/com/adyenreactnativesdk/component/model/OrderUpdateDTO.kt b/android/src/main/java/com/adyenreactnativesdk/component/model/OrderUpdateDTO.kt new file mode 100644 index 000000000..197ef44c4 --- /dev/null +++ b/android/src/main/java/com/adyenreactnativesdk/component/model/OrderUpdateDTO.kt @@ -0,0 +1,10 @@ +package com.adyenreactnativesdk.component.model + +import com.adyen.checkout.components.core.Order +import org.json.JSONObject + +fun Order.toJSONObject(shouldUpdatePaymentMethods: Boolean): JSONObject = + JSONObject().apply { + put("order", Order.SERIALIZER.serialize(this@toJSONObject)) + put("shouldUpdatePaymentMethods", shouldUpdatePaymentMethods) + } diff --git a/android/src/main/java/com/adyenreactnativesdk/component/model/SessionPaymentResultDTO.kt b/android/src/main/java/com/adyenreactnativesdk/component/model/SessionPaymentResultDTO.kt new file mode 100644 index 000000000..3c1bda2b3 --- /dev/null +++ b/android/src/main/java/com/adyenreactnativesdk/component/model/SessionPaymentResultDTO.kt @@ -0,0 +1,14 @@ +package com.adyenreactnativesdk.component.model + +import com.adyen.checkout.components.core.OrderResponse +import com.adyen.checkout.sessions.core.SessionPaymentResult +import org.json.JSONObject + +fun SessionPaymentResult.toJSONObject(): JSONObject = + JSONObject().apply { + put("resultCode", resultCode) + put("order", order?.let { OrderResponse.Companion.SERIALIZER.serialize(it) }) + put("sessionResult", sessionResult) + put("sessionData", sessionData) + put("sessionId", sessionId) + } diff --git a/android/src/main/java/com/adyenreactnativesdk/configuration/CardConfigurationParser.kt b/android/src/main/java/com/adyenreactnativesdk/configuration/CardConfigurationParser.kt index 8c6b173fe..d42bf6824 100644 --- a/android/src/main/java/com/adyenreactnativesdk/configuration/CardConfigurationParser.kt +++ b/android/src/main/java/com/adyenreactnativesdk/configuration/CardConfigurationParser.kt @@ -127,7 +127,9 @@ class CardConfigurationParser( } } - else -> null + else -> { + null + } } } @@ -159,7 +161,9 @@ class CardConfigurationParser( } } - else -> null + else -> { + null + } } } diff --git a/android/src/main/java/com/adyenreactnativesdk/configuration/CheckoutConfigurationFactory.kt b/android/src/main/java/com/adyenreactnativesdk/configuration/CheckoutConfigurationFactory.kt new file mode 100644 index 000000000..8de145f85 --- /dev/null +++ b/android/src/main/java/com/adyenreactnativesdk/configuration/CheckoutConfigurationFactory.kt @@ -0,0 +1,53 @@ +package com.adyenreactnativesdk.configuration + +import com.adyen.checkout.adyen3ds2.adyen3DS2 +import com.adyen.checkout.bcmc.bcmc +import com.adyen.checkout.card.card +import com.adyen.checkout.components.core.CheckoutConfiguration +import com.adyen.checkout.dropin.dropIn +import com.adyen.checkout.giftcard.giftCard +import com.adyen.checkout.googlepay.googlePay +import com.adyenreactnativesdk.component.base.ModuleException +import com.facebook.react.bridge.ReadableMap + +object CheckoutConfigurationFactory { + fun get(json: ReadableMap): CheckoutConfiguration { + val rootParser = RootConfigurationParser(json) + val countryCode = rootParser.countryCode + val analyticsConfiguration = AnalyticsParser(json).analytics + + val clientKey = rootParser.clientKey ?: throw ModuleException.NoClientKey() + return CheckoutConfiguration( + environment = rootParser.environment, + clientKey = clientKey, + shopperLocale = rootParser.locale, + amount = rootParser.amount, + analyticsConfiguration = analyticsConfiguration, + ) { + googlePay { + setCountryCode(countryCode) + val googlePayParser = GooglePayConfigurationParser(json) + googlePayParser.applyConfiguration(this) + } + val cardParser = CardConfigurationParser(json, countryCode) + card { + cardParser.applyConfiguration(this) + } + bcmc { + cardParser.applyConfiguration(this) + } + dropIn { + val parser = DropInConfigurationParser(json) + parser.applyConfiguration(this) + } + adyen3DS2 { + val parser = ThreeDSConfigurationParser(json) + parser.applyConfiguration(this) + } + giftCard { + val parser = PartialPaymentParser(json) + setPinRequired(parser.pinRequired) + } + } + } +} diff --git a/android/src/main/java/com/adyenreactnativesdk/configuration/RootConfigurationParser.kt b/android/src/main/java/com/adyenreactnativesdk/configuration/RootConfigurationParser.kt index 58adbca48..71a592b20 100644 --- a/android/src/main/java/com/adyenreactnativesdk/configuration/RootConfigurationParser.kt +++ b/android/src/main/java/com/adyenreactnativesdk/configuration/RootConfigurationParser.kt @@ -33,7 +33,7 @@ class RootConfigurationParser( try { ReactNativeJson.convertMapToJson(map) } catch (e: Throwable) { - Log.w(TAG, "Amount" + map.toString() + " not valid", e) + Log.w(TAG, "Amount $map not valid", e) return null } return Amount.SERIALIZER.deserialize(jsonObject) diff --git a/android/src/main/java/com/adyenreactnativesdk/cse/ActionModule.kt b/android/src/main/java/com/adyenreactnativesdk/cse/ActionModule.kt index 430d86a6c..b1089f32b 100644 --- a/android/src/main/java/com/adyenreactnativesdk/cse/ActionModule.kt +++ b/android/src/main/java/com/adyenreactnativesdk/cse/ActionModule.kt @@ -8,10 +8,11 @@ import com.adyen.checkout.components.core.ComponentError import com.adyen.checkout.components.core.action.Action import com.adyen.checkout.core.exception.CancellationException import com.adyen.threeds2.ThreeDS2Service -import com.adyenreactnativesdk.component.CheckoutProxy import com.adyenreactnativesdk.component.base.BaseModule import com.adyenreactnativesdk.component.base.ModuleException +import com.adyenreactnativesdk.configuration.CheckoutConfigurationFactory import com.adyenreactnativesdk.util.ReactNativeJson +import com.adyenreactnativesdk.util.messaging.MessageBus import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactMethod @@ -20,13 +21,12 @@ import com.facebook.react.bridge.ReadableMap class ActionModule( context: ReactApplicationContext?, ) : BaseModule(context), - CheckoutProxy.ComponentEventListener, ActionComponentCallback { private var promise: Promise? = null override fun getName(): String = COMPONENT_NAME - override fun getConstants(): MutableMap = hashMapOf(THREEDS_VERSION_NAME to THREEDS_VERSION) + override fun getConstants(): MutableMap = hashMapOf(THREEDS_VERSION_NAME to threeDS2SdkVersion) @ReactMethod fun addListener(eventName: String?) { // No JS events expected @@ -48,7 +48,7 @@ class ActionModule( try { val jsonObject = ReactNativeJson.convertMapToJson(actionMap) action = Action.SERIALIZER.deserialize(jsonObject) - checkoutConfiguration = getCheckoutConfiguration(configuration) + checkoutConfiguration = CheckoutConfigurationFactory.get(configuration) } catch (e: ModuleException) { promise.reject(e.code, e.message, e) return @@ -74,7 +74,7 @@ class ActionModule( companion object { private const val COMPONENT_NAME = "AdyenAction" private const val TAG = "ActionModule" - private var THREEDS_VERSION = ThreeDS2Service.INSTANCE.sdkVersion + private var threeDS2SdkVersion = ThreeDS2Service.INSTANCE.sdkVersion private const val THREEDS_VERSION_NAME = "threeDS2SdkVersion" private const val COMPONENT_ERROR = "actionError" private const val PARSING_ERROR = "parsingError" diff --git a/android/src/main/java/com/adyenreactnativesdk/react/CardViewManager.kt b/android/src/main/java/com/adyenreactnativesdk/react/CardViewManager.kt new file mode 100644 index 000000000..55d0f69c4 --- /dev/null +++ b/android/src/main/java/com/adyenreactnativesdk/react/CardViewManager.kt @@ -0,0 +1,210 @@ +package com.adyenreactnativesdk.react + +import android.util.Size +import androidx.fragment.app.FragmentActivity +import com.adyen.checkout.components.core.CheckoutConfiguration +import com.adyen.checkout.components.core.action.Action +import com.adyenreactnativesdk.AdyenCheckout +import com.adyenreactnativesdk.configuration.CheckoutConfigurationFactory +import com.adyenreactnativesdk.react.base.DynamicComponentView +import com.adyenreactnativesdk.react.base.LayoutListener +import com.adyenreactnativesdk.react.card.CardComponentManager +import com.adyenreactnativesdk.util.ReactNativeJson +import com.adyenreactnativesdk.util.ifNotNull +import com.adyenreactnativesdk.util.messaging.MessageBus +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReactContext +import com.facebook.react.bridge.WritableMap +import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.uimanager.ReactStylesDiffMap +import com.facebook.react.uimanager.SimpleViewManager +import com.facebook.react.uimanager.StateWrapper +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.ViewManagerDelegate +import com.facebook.react.uimanager.annotations.ReactProp +import com.facebook.react.uimanager.events.Event +import com.facebook.react.viewmanagers.CardViewManagerDelegate +import com.facebook.react.viewmanagers.CardViewManagerInterface +import org.json.JSONObject + +@ReactModule(name = CardViewManager.NAME) +class CardViewManager( + val messageBus: MessageBus, +) : SimpleViewManager(), + CardViewManagerInterface, + LayoutListener, + ComponentContract { + private val delegate: ViewManagerDelegate = CardViewManagerDelegate(this) + private var dynamicComponentView: DynamicComponentView? = null + private var cardComponentManager: CardComponentManager? = null + private var configuration: CheckoutConfiguration? = null + private var paymentMethod: JSONObject? = null + private var fragmentActivity: FragmentActivity? = null + private var stateWrapper: StateWrapper? = null + + override fun getDelegate(): ViewManagerDelegate = delegate + + override fun getName(): String = NAME + + public override fun createViewInstance(context: ThemedReactContext): DynamicComponentView { + fragmentActivity = context.currentActivity as? FragmentActivity + cardComponentManager = CardComponentManager(context, messageBus) + if (dynamicComponentView != null) { + dynamicComponentView?.onDispose() + } + + val view = DynamicComponentView(context) + view.layoutListener = this + + dynamicComponentView = view + return view + } + + override fun onDropViewInstance(view: DynamicComponentView) { + super.onDropViewInstance(view) + // Ensure proper cleanup when view is dropped + view.onDispose() + if (view == dynamicComponentView) { + dynamicComponentView = null + cardComponentManager = null + fragmentActivity = null + } + } + + override fun updateState( + view: DynamicComponentView, + props: ReactStylesDiffMap?, + stateWrapper: StateWrapper?, + ): Any? { + this.stateWrapper = stateWrapper + return super.updateState(view, props, stateWrapper) + } + + override fun onAfterUpdateTransaction(view: DynamicComponentView) { + super.onAfterUpdateTransaction(view) + + if (dynamicComponentView?.hasComponent == true) { + return + } + + ifNotNull( + paymentMethod, + configuration, + fragmentActivity, + ) { paymentMethodJson, configuration, fragmentActivity -> + // Check if FragmentActivity is still valid and not destroyed + if (!fragmentActivity.isDestroyed && !fragmentActivity.isFinishing) { + cardComponentManager?.init(configuration, paymentMethodJson) + cardComponentManager?.component?.let { cardComponent -> + dynamicComponentView?.addComponent(cardComponent, fragmentActivity) + } + } + } + } + + override fun setPaymentMethod( + view: DynamicComponentView?, + value: String?, + ) { + value?.let { + paymentMethod = JSONObject(it) + } + } + + override fun setConfiguration( + view: DynamicComponentView?, + value: String?, + ) { + value?.let { + val json = JSONObject(it) + val map = ReactNativeJson.convertJsonToMap(json) + configuration = CheckoutConfigurationFactory.get(map) + } + } + + @ReactProp(name = "showButton", defaultBoolean = false) + override fun setShowButton( + view: DynamicComponentView?, + value: Boolean, + ) { + // TODO: add removable button + } + + companion object { + const val NAME = "CardView" + } + + private fun emitOnPressEvent( + context: ReactContext, + viewId: Int, + ) { + val surfaceId = UIManagerHelper.getSurfaceId(context) + val eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(context, viewId) + val event = OnPressEvent(surfaceId, viewId) + eventDispatcher?.dispatchEvent(event) + } + + private fun emitResizableCustomViewEvent( + context: ReactContext, + viewId: Int, + width: Int, + height: Int, + ) { + val surfaceId = UIManagerHelper.getSurfaceId(context) + val eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(context, viewId) + val event = ResizableCustomViewEvent(surfaceId, viewId, width, height) + eventDispatcher?.dispatchEvent(event) + } + + override fun getExportedCustomDirectEventTypeConstants(): Map = + mapOf( + OnPressEvent.EVENT_NAME to mapOf("registrationName" to "onButtonPress"), + ResizableCustomViewEvent.EVENT_NAME to mapOf("registrationName" to "onResizableCustomView"), + ) + + override fun onLayoutSizeUpdate(size: Size) { + dynamicComponentView?.let { view -> + val context = view.context as? ReactContext + context?.let { + emitResizableCustomViewEvent(it, view.id, size.width, size.height) + } + } + } + + override fun onAction(action: Action) { + ifNotNull( + fragmentActivity, + cardComponentManager?.component, + ) { activity, component -> + // Check if FragmentActivity is still valid before handling action + if (!activity.isDestroyed && !activity.isFinishing) { + AdyenCheckout.setComponent(component) + component.handleAction(action, activity) + } + } + } +} + +class ResizableCustomViewEvent( + surfaceId: Int, + viewId: Int, + private val width: Int, + private val height: Int, +) : Event(surfaceId, viewId) { + override fun getEventName() = EVENT_NAME + + override fun getEventData(): WritableMap = + Arguments.createMap().apply { + putInt("width", width) + putInt("height", height) + } + + companion object { + const val EVENT_NAME: String = "onLayoutChange" + } +} + +interface ComponentContract { + fun onAction(action: Action) +} diff --git a/android/src/main/java/com/adyenreactnativesdk/react/PlatformPayViewManager.kt b/android/src/main/java/com/adyenreactnativesdk/react/PlatformPayViewManager.kt index 524fe8acc..cfd3b0612 100644 --- a/android/src/main/java/com/adyenreactnativesdk/react/PlatformPayViewManager.kt +++ b/android/src/main/java/com/adyenreactnativesdk/react/PlatformPayViewManager.kt @@ -1,5 +1,8 @@ package com.adyenreactnativesdk.react +import com.adyenreactnativesdk.react.platformpay.ButtonTheme +import com.adyenreactnativesdk.react.platformpay.ButtonType +import com.adyenreactnativesdk.react.platformpay.PlatformPayView import com.facebook.react.bridge.ReactContext import com.facebook.react.module.annotations.ReactModule import com.facebook.react.uimanager.PixelUtil diff --git a/android/src/main/java/com/adyenreactnativesdk/react/base/ComponentAdvancedCallback.kt b/android/src/main/java/com/adyenreactnativesdk/react/base/ComponentAdvancedCallback.kt new file mode 100644 index 000000000..adc0da441 --- /dev/null +++ b/android/src/main/java/com/adyenreactnativesdk/react/base/ComponentAdvancedCallback.kt @@ -0,0 +1,27 @@ +package com.adyenreactnativesdk.react.base + +import com.adyen.checkout.components.core.ActionComponentData +import com.adyen.checkout.components.core.ComponentCallback +import com.adyen.checkout.components.core.ComponentError +import com.adyen.checkout.components.core.PaymentComponentState +import com.adyenreactnativesdk.component.MessageBusModule +import com.adyenreactnativesdk.util.messaging.MessageBus + +class ComponentAdvancedCallback>( + private val messageBus: MessageBus, + private val componentId: String, +) : ComponentCallback { + override fun onSubmit(state: T) { + MessageBusModule.currentComponent = componentId + messageBus.onSubmit(state, null) + } + + override fun onAdditionalDetails(actionComponentData: ActionComponentData) { + messageBus.onAdditionalDetails(actionComponentData) + } + + override fun onError(componentError: ComponentError) { + MessageBusModule.currentComponent = null + messageBus.onException(componentError.exception) + } +} diff --git a/android/src/main/java/com/adyenreactnativesdk/react/base/ComponentSessionCallback.kt b/android/src/main/java/com/adyenreactnativesdk/react/base/ComponentSessionCallback.kt new file mode 100644 index 000000000..0a7493ead --- /dev/null +++ b/android/src/main/java/com/adyenreactnativesdk/react/base/ComponentSessionCallback.kt @@ -0,0 +1,27 @@ +package com.adyenreactnativesdk.react.base + +import com.adyen.checkout.components.core.ComponentError +import com.adyen.checkout.components.core.PaymentComponentState +import com.adyen.checkout.components.core.action.Action +import com.adyen.checkout.sessions.core.SessionComponentCallback +import com.adyen.checkout.sessions.core.SessionPaymentResult +import com.adyenreactnativesdk.util.messaging.MessageBus +import com.facebook.react.uimanager.ThemedReactContext + +class ComponentSessionCallback>( + private val messageBus: MessageBus, + private val onActionCallback: (Action) -> Unit, + private val componentId: String, +) : SessionComponentCallback { + override fun onAction(action: Action) { + onActionCallback(action) + } + + override fun onFinished(result: SessionPaymentResult) { + messageBus.onFinished(result) + } + + override fun onError(componentError: ComponentError) { + messageBus.onSessionException(componentError.exception) + } +} diff --git a/android/src/main/java/com/adyenreactnativesdk/react/base/DynamicComponentView.kt b/android/src/main/java/com/adyenreactnativesdk/react/base/DynamicComponentView.kt new file mode 100644 index 000000000..4c2608fc1 --- /dev/null +++ b/android/src/main/java/com/adyenreactnativesdk/react/base/DynamicComponentView.kt @@ -0,0 +1,177 @@ +package com.adyenreactnativesdk.react.base + +import android.content.Context +import android.util.AttributeSet +import android.util.Size +import android.view.ViewGroup +import android.view.ViewTreeObserver +import android.widget.FrameLayout +import androidx.activity.ComponentActivity +import androidx.core.view.children +import androidx.core.view.postDelayed +import com.adyen.checkout.card.CardComponent +import com.adyen.checkout.components.core.internal.Component +import com.adyen.checkout.ui.core.AdyenComponentView +import com.adyen.checkout.ui.core.internal.ui.ViewableComponent +import com.google.android.material.button.MaterialButton +import com.google.android.material.textfield.TextInputLayout + +class DynamicComponentView + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, + ) : FrameLayout(context) { + private val screenDensity = resources.displayMetrics.density + private var activity: ComponentActivity? = null + private var ignoreLayoutChanges = false + private var interactionBlocked = false + var layoutListener: LayoutListener? = null + var hasComponent = false + + // Usage of complete component height also when having error hints + override fun onMeasure( + widthMeasureSpec: Int, + heightMeasureSpec: Int, + ) { + val heightSize = MeasureSpec.getSize(heightMeasureSpec) + super.onMeasure(widthMeasureSpec, heightSize) + } + + override fun onLayout( + changed: Boolean, + l: Int, + t: Int, + r: Int, + b: Int, + ) { + super.onLayout(changed, l, t, r, b) + + if (changed && !ignoreLayoutChanges) { + resizeViewport(calculateViewportHeight(), calculateViewportWidth()) + } + } + + fun addComponent( + component: T, + activity: ComponentActivity, + ) where T : Component, T : ViewableComponent { + val adyenComponentView = + AdyenComponentView(context).apply { + onComponentViewGlobalLayout(this, component) + attach(component, activity) + } + + hasComponent = true + addView(adyenComponentView) + } + + fun onDispose() { + // Clean up any child views to prevent Fragment lifecycle issues + removeAllViews() + activity = null + ignoreLayoutChanges = false + interactionBlocked = false + hasComponent = false + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + // Ensure cleanup when view is detached to prevent Fragment lifecycle issues + onDispose() + } + + private fun onComponentViewGlobalLayout( + adyenComponentView: AdyenComponentView, + component: T, + ) where T : Component, T : ViewableComponent { + adyenComponentView.getViewTreeObserver()?.addOnGlobalLayoutListener( + object : ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + if (component is CardComponent) { + overrideSubmit(component) + } + + adyenComponentView.getViewTreeObserver()?.removeOnGlobalLayoutListener(this) + } + }, + ) + } + + private fun overrideSubmit(component: CardComponent) { + val payButton = findViewById(com.adyen.checkout.ui.core.R.id.payButton) + if (android.os.Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.O) { + disableRippleAnimationOnPayButton() + disableRippleAnimationOnStorePaymentMethodSwitch() + } + + payButton?.setOnClickListener { + isHintAnimationEnabledOnTextInputFields(this, false) + ignoreLayoutChanges = true + if (!interactionBlocked) { + interactionBlocked = true + component.submit() + } + resetInteractionBlocked() + postDelayed(100) { + resizeViewport(calculateViewportHeight(), calculateViewportWidth()) + } + postDelayed(500) { + ignoreLayoutChanges = false + isHintAnimationEnabledOnTextInputFields(this, true) + } + } + } + + // This is necessary because the RippleAnimation leads to an crash on older Android devices: https://github.com/Adyen/adyen-flutter/issues/335 + private fun disableRippleAnimationOnPayButton() { + // TODO: check if relevant + } + + // This is necessary because the RippleAnimation leads to an crash on older Android devices: https://github.com/Adyen/adyen-flutter/issues/335 + private fun disableRippleAnimationOnStorePaymentMethodSwitch() { + // TODO: check if relevant + } + + private fun calculateViewportHeight(): Int { + val componentViewHeightScreenDensity = measuredHeight / screenDensity + return componentViewHeightScreenDensity.toInt() + } + + private fun calculateViewportWidth(): Int { + val componentViewHeightScreenDensity = measuredWidth / screenDensity + return componentViewHeightScreenDensity.toInt() + } + + private fun resizeViewport( + viewportHeight: Int, + viewportWidth: Int, + ) { + layoutListener?.onLayoutSizeUpdate(Size(viewportWidth, viewportHeight)) + } + + private fun isHintAnimationEnabledOnTextInputFields( + viewGroup: ViewGroup, + enabled: Boolean, + ) { + viewGroup.children.forEach { child -> + when (child) { + is TextInputLayout -> child.isHintAnimationEnabled = enabled + !is ViewGroup -> Unit + else -> isHintAnimationEnabledOnTextInputFields(child, enabled) + } + } + } + + // TODO - We can use cardComponent.setInteractionBlocked() when the fix for releasing the blocked interaction is available in then native SDK + private fun resetInteractionBlocked() { + postDelayed(1000) { + interactionBlocked = false + } + } + } + +interface LayoutListener { + fun onLayoutSizeUpdate(size: Size) +} diff --git a/android/src/main/java/com/adyenreactnativesdk/react/card/CardComponentManager.kt b/android/src/main/java/com/adyenreactnativesdk/react/card/CardComponentManager.kt new file mode 100644 index 000000000..ccad8f5e8 --- /dev/null +++ b/android/src/main/java/com/adyenreactnativesdk/react/card/CardComponentManager.kt @@ -0,0 +1,117 @@ +package com.adyenreactnativesdk.react.card + +import androidx.fragment.app.FragmentActivity +import com.adyen.checkout.card.CardComponent +import com.adyen.checkout.components.core.CheckoutConfiguration +import com.adyen.checkout.components.core.PaymentMethod +import com.adyen.checkout.components.core.StoredPaymentMethod +import com.adyen.checkout.components.core.action.Action +import com.adyen.checkout.sessions.core.CheckoutSession +import com.adyenreactnativesdk.component.base.BaseModule +import com.adyenreactnativesdk.react.CardViewManager.Companion.NAME +import com.adyenreactnativesdk.react.base.ComponentAdvancedCallback +import com.adyenreactnativesdk.react.base.ComponentSessionCallback +import com.adyenreactnativesdk.util.messaging.MessageBus +import com.facebook.react.uimanager.ThemedReactContext +import org.json.JSONObject +import java.util.UUID + +class CardComponentManager( + val context: ThemedReactContext, + val messageBus: MessageBus, +) { + val activity = context.currentActivity as FragmentActivity + + var component: CardComponent? = null + + fun init( + configuration: CheckoutConfiguration, + paymentMethodJson: JSONObject, + ) { + val session = BaseModule.session + component = + if (session != null) { + createSessionCardComponent( + activity, + session, + configuration, + paymentMethodJson, + ) + } else { + createAdvancedCardComponent(configuration, paymentMethodJson) + } + } + + private fun createAdvancedCardComponent( + configuration: CheckoutConfiguration, + paymentMethodJson: JSONObject, + ): CardComponent { + // TODO: make work with stored cards + val isStoredPaymentMethod = false + when (isStoredPaymentMethod) { + true -> { + val storedPaymentMethod = StoredPaymentMethod.SERIALIZER.deserialize(paymentMethodJson) + return CardComponent.PROVIDER.get( + activity = activity, + storedPaymentMethod = storedPaymentMethod, + checkoutConfiguration = configuration, + callback = ComponentAdvancedCallback(messageBus, NAME), + key = UUID.randomUUID().toString(), + ) + } + + false -> { + val paymentMethod = PaymentMethod.SERIALIZER.deserialize(paymentMethodJson) + return CardComponent.PROVIDER.get( + activity = activity, + paymentMethod = paymentMethod, + checkoutConfiguration = configuration, + callback = ComponentAdvancedCallback(messageBus, NAME), + key = UUID.randomUUID().toString(), + ) + } + } + } + + private fun createSessionCardComponent( + activity: FragmentActivity, + session: CheckoutSession, + configuration: CheckoutConfiguration, + paymentMethodJson: JSONObject, + ): CardComponent { + // TODO: make work with stored cards + val isStoredPaymentMethod = false + when (isStoredPaymentMethod) { + true -> { + val storedPaymentMethod = StoredPaymentMethod.SERIALIZER.deserialize(paymentMethodJson) + return CardComponent.PROVIDER.get( + activity = activity, + checkoutSession = session, + storedPaymentMethod = storedPaymentMethod, + checkoutConfiguration = configuration, + componentCallback = ComponentSessionCallback(messageBus, ::actionHandle, NAME), + key = UUID.randomUUID().toString(), + ) + } + + false -> { + val paymentMethod = PaymentMethod.SERIALIZER.deserialize(paymentMethodJson) + return CardComponent.PROVIDER.get( + activity = activity, + checkoutSession = session, + paymentMethod = paymentMethod, + checkoutConfiguration = configuration, + componentCallback = ComponentSessionCallback(messageBus, ::actionHandle, NAME), + key = UUID.randomUUID().toString(), + ) + } + } + } + + private fun actionHandle(action: Action) { + // Check if FragmentActivity is still valid before handling action + if (!activity.isDestroyed && !activity.isFinishing) { + component?.handleAction(action, activity) + } + } +} diff --git a/android/src/main/java/com/adyenreactnativesdk/react/PlatformPayView.kt b/android/src/main/java/com/adyenreactnativesdk/react/platformpay/PlatformPayView.kt similarity index 83% rename from android/src/main/java/com/adyenreactnativesdk/react/PlatformPayView.kt rename to android/src/main/java/com/adyenreactnativesdk/react/platformpay/PlatformPayView.kt index 5d00bce44..8ff7b281b 100644 --- a/android/src/main/java/com/adyenreactnativesdk/react/PlatformPayView.kt +++ b/android/src/main/java/com/adyenreactnativesdk/react/platformpay/PlatformPayView.kt @@ -1,6 +1,7 @@ -package com.adyenreactnativesdk.react +package com.adyenreactnativesdk.react.platformpay import android.annotation.SuppressLint +import android.view.ViewTreeObserver import android.widget.FrameLayout import com.facebook.react.uimanager.ThemedReactContext import com.google.android.gms.wallet.button.ButtonConstants @@ -46,12 +47,14 @@ class PlatformPayView( var type: ButtonType = ButtonType.Buy var radius: Int = 50 - private var googlePayButton: PayButton = PayButton(context) + private var googlePayButton: PayButton? = null + private val onGlobalLayoutListener = ViewTreeObserver.OnGlobalLayoutListener { requestLayout() } fun showButton() { - removeView(googlePayButton) + googlePayButton?.let { removeView(it) } scheduleUpdate() addView(googlePayButton) + viewTreeObserver.addOnGlobalLayoutListener(onGlobalLayoutListener) } private fun scheduleUpdate() { @@ -83,6 +86,11 @@ class PlatformPayView( } } + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + viewTreeObserver.removeOnGlobalLayoutListener(onGlobalLayoutListener) + } + companion object { private val allowedPaymentMethods: String = """ diff --git a/android/src/main/java/com/adyenreactnativesdk/util/Constants.kt b/android/src/main/java/com/adyenreactnativesdk/util/AdyenConstants.kt similarity index 100% rename from android/src/main/java/com/adyenreactnativesdk/util/Constants.kt rename to android/src/main/java/com/adyenreactnativesdk/util/AdyenConstants.kt diff --git a/android/src/main/java/com/adyenreactnativesdk/util/IfNotNull.kt b/android/src/main/java/com/adyenreactnativesdk/util/IfNotNull.kt new file mode 100644 index 000000000..c8e79033f --- /dev/null +++ b/android/src/main/java/com/adyenreactnativesdk/util/IfNotNull.kt @@ -0,0 +1,22 @@ +package com.adyenreactnativesdk.util + +inline fun ifNotNull( + a: A?, + b: B?, + code: (A, B) -> R, +) { + if (a != null && b != null) { + code(a, b) + } +} + +inline fun ifNotNull( + a: A?, + b: B?, + c: C?, + code: (A, B, C) -> R, +) { + if (a != null && b != null && c != null) { + code(a, b, c) + } +} diff --git a/android/src/main/java/com/adyenreactnativesdk/util/ReactNativeJson.kt b/android/src/main/java/com/adyenreactnativesdk/util/ReactNativeJson.kt index b823e72ca..97f992410 100644 --- a/android/src/main/java/com/adyenreactnativesdk/util/ReactNativeJson.kt +++ b/android/src/main/java/com/adyenreactnativesdk/util/ReactNativeJson.kt @@ -66,18 +66,34 @@ object ReactNativeJson { while (iterator.hasNextKey()) { val key = iterator.nextKey() when (readableMap.getType(key)) { - ReadableType.Null -> obj.put(key, JSONObject.NULL) - ReadableType.Boolean -> obj.put(key, readableMap.getBoolean(key)) - ReadableType.Number -> obj.put(key, readableMap.getDouble(key)) - ReadableType.String -> obj.put(key, readableMap.getString(key)) - ReadableType.Map -> obj.put(key, convertMapToJson(readableMap.getMap(key))) - ReadableType.Array -> + ReadableType.Null -> { + obj.put(key, JSONObject.NULL) + } + + ReadableType.Boolean -> { + obj.put(key, readableMap.getBoolean(key)) + } + + ReadableType.Number -> { + obj.put(key, readableMap.getDouble(key)) + } + + ReadableType.String -> { + obj.put(key, readableMap.getString(key)) + } + + ReadableType.Map -> { + obj.put(key, convertMapToJson(readableMap.getMap(key))) + } + + ReadableType.Array -> { obj.put( key, convertArrayToJson( readableMap.getArray(key), ), ) + } } } return obj @@ -89,11 +105,26 @@ object ReactNativeJson { for (i in 0 until readableArray!!.size()) { when (readableArray.getType(i)) { ReadableType.Null -> {} - ReadableType.Boolean -> array.put(readableArray.getBoolean(i)) - ReadableType.Number -> array.put(readableArray.getDouble(i)) - ReadableType.String -> array.put(readableArray.getString(i)) - ReadableType.Map -> array.put(convertMapToJson(readableArray.getMap(i))) - ReadableType.Array -> array.put(convertArrayToJson(readableArray.getArray(i))) + + ReadableType.Boolean -> { + array.put(readableArray.getBoolean(i)) + } + + ReadableType.Number -> { + array.put(readableArray.getDouble(i)) + } + + ReadableType.String -> { + array.put(readableArray.getString(i)) + } + + ReadableType.Map -> { + array.put(convertMapToJson(readableArray.getMap(i))) + } + + ReadableType.Array -> { + array.put(convertArrayToJson(readableArray.getArray(i))) + } } } return array diff --git a/android/src/main/java/com/adyenreactnativesdk/util/messaging/CardComponentEventListener.kt b/android/src/main/java/com/adyenreactnativesdk/util/messaging/CardComponentEventListener.kt new file mode 100644 index 000000000..3c85e1902 --- /dev/null +++ b/android/src/main/java/com/adyenreactnativesdk/util/messaging/CardComponentEventListener.kt @@ -0,0 +1,10 @@ +package com.adyenreactnativesdk.util.messaging + +import com.adyen.checkout.card.BinLookupData + +/** Events coming from Card Component */ +interface CardComponentEventListener { + fun onBinValue(binValue: String) + + fun onBinLookup(data: List) +} diff --git a/android/src/main/java/com/adyenreactnativesdk/util/messaging/ComponentEventListener.kt b/android/src/main/java/com/adyenreactnativesdk/util/messaging/ComponentEventListener.kt new file mode 100644 index 000000000..8b0734fb9 --- /dev/null +++ b/android/src/main/java/com/adyenreactnativesdk/util/messaging/ComponentEventListener.kt @@ -0,0 +1,19 @@ +package com.adyenreactnativesdk.util.messaging + +import com.adyen.checkout.components.core.ActionComponentData +import com.adyen.checkout.components.core.PaymentComponentState +import com.adyen.checkout.core.exception.CheckoutException +import com.adyen.checkout.sessions.core.SessionPaymentResult + +interface ComponentEventListener { + fun onSubmit( + state: PaymentComponentState<*>, + returnUrl: String?, + ) + + fun onAdditionalDetails(actionComponentData: ActionComponentData) + + fun onException(exception: CheckoutException) + + fun onFinished(result: SessionPaymentResult) +} diff --git a/android/src/main/java/com/adyenreactnativesdk/util/messaging/DropInStoredPaymentEventListener.kt b/android/src/main/java/com/adyenreactnativesdk/util/messaging/DropInStoredPaymentEventListener.kt new file mode 100644 index 000000000..14491e121 --- /dev/null +++ b/android/src/main/java/com/adyenreactnativesdk/util/messaging/DropInStoredPaymentEventListener.kt @@ -0,0 +1,18 @@ +package com.adyenreactnativesdk.util.messaging + +import com.adyen.checkout.components.core.Order +import com.adyen.checkout.components.core.PaymentComponentState +import com.adyen.checkout.components.core.StoredPaymentMethod + +interface DropInStoredPaymentEventListener { + fun onRemove(storedPaymentMethod: StoredPaymentMethod) + + fun onBalanceCheck(paymentComponentState: PaymentComponentState<*>) + + fun onOrderRequest() + + fun onOrderCancel( + order: Order, + shouldUpdatePaymentMethods: Boolean, + ) +} diff --git a/android/src/main/java/com/adyenreactnativesdk/util/messaging/MessageBus.kt b/android/src/main/java/com/adyenreactnativesdk/util/messaging/MessageBus.kt new file mode 100644 index 000000000..9e0044144 --- /dev/null +++ b/android/src/main/java/com/adyenreactnativesdk/util/messaging/MessageBus.kt @@ -0,0 +1,222 @@ +package com.adyenreactnativesdk.util.messaging + +import com.adyen.checkout.adyen3ds2.Cancelled3DS2Exception +import com.adyen.checkout.card.BinLookupData +import com.adyen.checkout.components.core.ActionComponentData +import com.adyen.checkout.components.core.AddressLookupCallback +import com.adyen.checkout.components.core.LookupAddress +import com.adyen.checkout.components.core.Order +import com.adyen.checkout.components.core.PaymentComponentData +import com.adyen.checkout.components.core.PaymentComponentState +import com.adyen.checkout.components.core.StoredPaymentMethod +import com.adyen.checkout.core.exception.CancellationException +import com.adyen.checkout.core.exception.CheckoutException +import com.adyen.checkout.googlepay.GooglePayComponentState +import com.adyen.checkout.sessions.core.SessionPaymentResult +import com.adyenreactnativesdk.component.base.ModuleException +import com.adyenreactnativesdk.component.model.BinLookupDataDTO +import com.adyenreactnativesdk.component.model.SubmitMap +import com.adyenreactnativesdk.component.model.toJSONObject +import com.adyenreactnativesdk.util.AdyenConstants +import com.adyenreactnativesdk.util.ReactNativeError +import com.adyenreactnativesdk.util.ReactNativeJson +import com.facebook.react.bridge.ReactContext +import com.facebook.react.modules.core.DeviceEventManagerModule +import com.google.gson.Gson +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject + +class MessageBus( + private val context: ReactContext, + private val gson: Gson, +) : ComponentEventListener, + DropInStoredPaymentEventListener, + CardComponentEventListener, + AddressLookupCallback { + fun sendErrorEvent(error: Exception) { + context + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit(DID_FAILED, ReactNativeError.mapError(error)) + } + + fun sendSessionErrorEvent(error: Exception) { + context + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit(DID_FAILED_SESSION, ReactNativeError.mapError(error)) + } + + override fun onSubmit( + state: PaymentComponentState<*>, + returnUrl: String?, + ) { + val extra = + if (state is GooglePayComponentState) { + state.paymentData?.let { + JSONObject(it.toJson()) + } + } else { + null + } + val jsonObject = PaymentComponentData.Companion.SERIALIZER.serialize(state.data) + returnUrl?.let { + jsonObject.put(AdyenConstants.PARAMETER_RETURN_URL, it) + } + + val submitMap = SubmitMap(jsonObject, extra) + sendEvent(DID_SUBMIT, submitMap.toJSONObject()) + } + + override fun onOrderCancel( + order: Order, + shouldUpdatePaymentMethods: Boolean, + ) { + sendEvent(DID_CANCEL_ORDER, order.toJSONObject(shouldUpdatePaymentMethods)) + } + + override fun onException(exception: CheckoutException) { + if (exception is CancellationException || + exception is Cancelled3DS2Exception || + exception.message == "Payment canceled." + ) { + sendErrorEvent(ModuleException.Canceled()) + } else { + sendErrorEvent(exception) + } + } + + fun onSessionException(exception: CheckoutException) { + if (exception is CancellationException || + exception is Cancelled3DS2Exception || + exception.message == "Payment canceled." + ) { + sendSessionErrorEvent(ModuleException.Canceled()) + } else { + sendSessionErrorEvent(exception) + } + } + + override fun onFinished(result: SessionPaymentResult) { + val updatedResult = + when (result.resultCode) { + VOUCHER_RESULT_CODE -> result.copy(resultCode = RESULT_CODE_PRESENTED) + else -> result + } + sendFinishEvent(updatedResult) + } + + private fun sendFinishEvent(result: SessionPaymentResult) { + sendEvent(DID_COMPLETE_SESSION, result.toJSONObject()) + } + + override fun onAdditionalDetails(actionComponentData: ActionComponentData) { + val jsonObject = ActionComponentData.Companion.SERIALIZER.serialize(actionComponentData) + sendEvent(DID_PROVIDE, jsonObject) + } + + override fun onBalanceCheck(paymentComponentState: PaymentComponentState<*>) { + val jsonObject = PaymentComponentData.Companion.SERIALIZER.serialize(paymentComponentState.data) + sendEvent(DID_CHECK_BALANCE, jsonObject) + } + + override fun onOrderRequest() { + sendEvent(DID_REQUEST_ORDER, JSONObject()) + } + + override fun onRemove(storedPaymentMethod: StoredPaymentMethod) { + val jsonObject = StoredPaymentMethod.Companion.SERIALIZER.serialize(storedPaymentMethod) + sendEvent(DID_DISABLE_STORED_PAYMENT_METHOD, jsonObject) + } + + override fun onQueryChanged(query: String) { + sendEvent(DID_UPDATE_ADDRESS, query) + } + + override fun onLookupCompletion(lookupAddress: LookupAddress): Boolean { + val jsonString = gson.toJson(lookupAddress) + val jsonObject = JSONObject(jsonString) + sendEvent(DID_CONFIRM_ADDRESS, jsonObject) + return true + } + + override fun onBinValue(binValue: String) { + sendEvent(DID_CHANGE_BIN_VALUE, binValue) + } + + override fun onBinLookup(data: List) { + when { + data.isEmpty() -> { + return + } + + else -> { + val brandOnlyMap = data.map { BinLookupDataDTO(it.brand) } + val jsonString = gson.toJson(brandOnlyMap) + val jsonObject = JSONArray(jsonString) + sendEvent(DID_BIN_LOOKUP, jsonObject) + } + } + } + + private fun send( + eventName: String, + payload: Any?, + ) { + try { + context + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit(eventName, payload) + } catch (e: JSONException) { + sendErrorEvent(e) + } + } + + private fun sendEvent( + eventName: String, + jsonObject: JSONObject, + ) { + try { + send(eventName, ReactNativeJson.convertJsonToMap(jsonObject)) + } catch (e: JSONException) { + sendErrorEvent(e) + } + } + + private fun sendEvent( + eventName: String, + jsonObject: JSONArray, + ) { + try { + send(eventName, ReactNativeJson.convertJsonToArray(jsonObject)) + } catch (e: JSONException) { + sendErrorEvent(e) + } + } + + private fun sendEvent( + eventName: String, + string: String, + ) { + send(eventName, string) + } + + companion object { + private const val VOUCHER_RESULT_CODE = "finish_with_action" + + const val DID_COMPLETE = "didCompleteCallback" + const val DID_COMPLETE_SESSION = "didCompleteSessionCallback" + const val DID_PROVIDE = "didProvideCallback" + const val DID_FAILED = "didFailCallback" + const val DID_FAILED_SESSION = "didFailSessionCallback" + const val DID_SUBMIT = "didSubmitCallback" + const val DID_UPDATE_ADDRESS = "didUpdateAddressCallback" + const val DID_CONFIRM_ADDRESS = "didConfirmAddressCallback" + const val DID_DISABLE_STORED_PAYMENT_METHOD = "didDisableStoredPaymentMethodCallback" + const val DID_CHECK_BALANCE = "didCheckBalanceCallback" + const val DID_REQUEST_ORDER = "didRequestOrderCallback" + const val DID_CANCEL_ORDER = "didCancelOrderCallback" + const val DID_BIN_LOOKUP = "didBinLookupCallback" + const val DID_CHANGE_BIN_VALUE = "didChangeBinValueCallback" + const val RESULT_CODE_PRESENTED = "PresentToShopper" + } +} diff --git a/example/ios/ComponentWrapperView.swift b/example/ios/ComponentWrapperView.swift new file mode 100644 index 000000000..0c112e76e --- /dev/null +++ b/example/ios/ComponentWrapperView.swift @@ -0,0 +1,24 @@ +import Adyen +import UIKit + +class ComponentWrapperView: UIStackView { + var resizeViewportCallback: () -> Void = {} + + init() { + super.init(frame: .zero) + axis = .vertical + } + + @available(*, unavailable) + required init(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var safeAreaInsets: UIEdgeInsets { .zero } + + override func layoutSubviews() { + super.layoutSubviews() + + resizeViewportCallback() + } +} \ No newline at end of file diff --git a/example/src/App.tsx b/example/src/App.tsx index 04a749e23..929460e81 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -6,56 +6,53 @@ * @flow strict-local */ -import { - NavigationContainer, - DarkTheme, - DefaultTheme, -} from '@react-navigation/native'; - import { Alert, SafeAreaView, useColorScheme } from 'react-native'; import { DEFAULT_CONFIGURATION } from './Configuration'; -import * as Screens from './components'; -import { Stack } from './State/RootStackParamList'; import AppContextProvider from './hooks/useAppContext'; import Styles from './components/common/Styles'; +import { + DefaultTheme, + DarkTheme, + NavigationContainer, +} from '@react-navigation/native'; +import { + rootNavigationRef, + RootStackNavigator, +} from './router/RootStackNavigator'; +import { useMemo } from 'react'; + const App = () => { - const isDarkMode = useColorScheme() === 'dark'; + const isDark = useColorScheme(); + + const theme = useMemo(() => { + return isDark === 'dark' ? DarkTheme : DefaultTheme; + }, [isDark]); + + const MyTheme = useMemo(() => { + return { + ...theme, + colors: { + ...theme.colors, + }, + }; + }, [theme]); return ( - { - Alert.alert('App error', error.message || 'Error'); - }} - > - - - - - ({ title: 'Sessions Checkout' })} - /> - ({ title: 'Advanced Checkout' })} - /> - ({ title: 'Partial Payment' })} - /> - - - - - - - + + { + Alert.alert('App error', error.message || 'Error'); + }} + navigationRef={rootNavigationRef} + > + + + + + ); }; diff --git a/example/src/State/RootStackParamList.ts b/example/src/State/RootStackParamList.ts deleted file mode 100644 index 5f9553d81..000000000 --- a/example/src/State/RootStackParamList.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { ResultCode } from '@adyen/react-native'; -import { - createNativeStackNavigator, - type NativeStackNavigationProp, -} from '@react-navigation/native-stack'; - -export type RootStackParamList = { - Home: undefined; - SessionsCheckout: undefined; - AdvancedCheckout: undefined; - Settings: undefined; - CustomCard: undefined; - Result: { resultCode: ResultCode }; - PartialPaymentCheckout: undefined; -}; - -export const Stack = createNativeStackNavigator(); - -export type PageProps = { - navigation: NativeStackNavigationProp; -}; diff --git a/example/src/components/CardForm.tsx b/example/src/components/CardForm.tsx new file mode 100644 index 000000000..099128609 --- /dev/null +++ b/example/src/components/CardForm.tsx @@ -0,0 +1,10 @@ +import { ScrollView } from 'react-native'; +import { CardView } from '@adyen/react-native'; + +export default () => { + return ( + + + + ); +}; diff --git a/example/src/components/Checkout/AdvancedCheckout.tsx b/example/src/components/Checkout/AdvancedCheckout.tsx index 7671b9c31..6037ba30e 100644 --- a/example/src/components/Checkout/AdvancedCheckout.tsx +++ b/example/src/components/Checkout/AdvancedCheckout.tsx @@ -9,19 +9,18 @@ import type { AdyenError, AdyenComponent, } from '@adyen/react-native'; -import PaymentMethods from './components/PaymentMethodsView'; import Styles from '../common/Styles'; import TopView from './components/TopView'; import ApiClient from '../../api/APIClient'; import { useAppContext } from '../../hooks/useAppContext'; -import { checkoutConfiguration } from '../../State/checkoutConfiguration'; -import type { PageProps } from '../../State/RootStackParamList'; -import { processResult } from './utils/processResult'; +import { checkoutConfiguration } from '../utilities/checkoutConfiguration'; import { processAdyenError } from './utils/processAdyenError'; import { processError } from './utils/processError'; +import { PaymentResponse } from '../../api/types'; +import { CheckoutNavigator } from '../../router/CheckoutNavigator'; -const AdvancedCheckout = ({ navigation }: PageProps) => { - const { configuration } = useAppContext(); +const AdvancedCheckout = () => { + const { configuration, processResult } = useAppContext(); const [loading, setLoading] = useState(true); const [initError, setError] = useState(undefined); const [paymentMethods, setPaymentMethods] = useState< @@ -58,35 +57,41 @@ const AdvancedCheckout = ({ navigation }: PageProps) => { if (result.action) { nativeComponent.handle(result.action); } else { - processResult(result, nativeComponent, navigation); + processResult(result, nativeComponent); } } catch (error) { processError(error, nativeComponent); } }, - [configuration, navigation] + [configuration, processResult] ); const didProvide = useCallback( async (data: PaymentDetailsData, nativeComponent: AdyenActionComponent) => { try { const result = await ApiClient.paymentDetails(data); - processResult(result, nativeComponent, navigation); + processResult(result, nativeComponent); } catch (error) { processError(error, nativeComponent); } }, - [navigation] + [processResult] ); const didFail = useCallback( async (error: AdyenError, nativeComponent: AdyenComponent) => { - console.log(`didFailed: ${error.message}`); processAdyenError(error, nativeComponent); }, [] ); + const didComplete = useCallback( + async (result: PaymentResponse, nativeComponent: AdyenComponent) => { + processResult(result, nativeComponent); + }, + [processResult] + ); + if (loading) { return ( @@ -104,20 +109,21 @@ const AdvancedCheckout = ({ navigation }: PageProps) => { } return ( - + { - // `onComplete` is only called for Voucher payment methods - processResult(result, component, navigation); - }} + onComplete={didComplete} onError={didFail} > - + ); diff --git a/example/src/components/Checkout/PartialPaymentCheckout.tsx b/example/src/components/Checkout/PartialPaymentCheckout.tsx index 0ac94b4f9..eb5d25992 100644 --- a/example/src/components/Checkout/PartialPaymentCheckout.tsx +++ b/example/src/components/Checkout/PartialPaymentCheckout.tsx @@ -13,19 +13,18 @@ import type { Balance, PartialPaymentComponent, } from '@adyen/react-native'; -import PaymentMethods from './components/PaymentMethodsView'; +import { CheckoutNavigator } from '../../router/CheckoutNavigator'; import Styles from '../common/Styles'; import TopView from './components/TopView'; import ApiClient from '../../api/APIClient'; import { useAppContext } from '../../hooks/useAppContext'; -import { checkoutConfiguration } from '../../State/checkoutConfiguration'; -import type { PageProps } from '../../State/RootStackParamList'; +import { checkoutConfiguration } from '../utilities/checkoutConfiguration'; import { processAdyenError } from './utils/processAdyenError'; import { processError } from './utils/processError'; import { processPartialPaymentResult } from './utils/processPartialPaymentResult'; -const PartialPaymentCheckout = ({ navigation }: PageProps) => { - const { configuration } = useAppContext(); +const PartialPaymentCheckout = () => { + const { configuration, processResult } = useAppContext(); const [loading, setLoading] = useState(true); const [initError, setError] = useState(undefined); const [paymentMethods, setPaymentMethods] = useState< @@ -59,34 +58,38 @@ const PartialPaymentCheckout = ({ navigation }: PageProps) => { configuration, data.returnUrl ); - processPartialPaymentResult( + const outcome = await processPartialPaymentResult( result, nativeComponent as DropInModule, - navigation, configuration ); + if (outcome) { + processResult(outcome, nativeComponent); + } } catch (error) { processError(error, nativeComponent); } }, - [configuration, navigation] + [configuration, processResult] ); const didProvide = useCallback( async (data: PaymentDetailsData, nativeComponent: AdyenActionComponent) => { try { const result = await ApiClient.paymentDetails(data); - processPartialPaymentResult( + const outcome = await processPartialPaymentResult( result, nativeComponent as DropInModule, - navigation, configuration ); + if (outcome) { + processResult(outcome, nativeComponent); + } } catch (error) { processError(error, nativeComponent); } }, - [configuration, navigation] + [configuration, processResult] ); const didFail = useCallback( @@ -171,7 +174,7 @@ const PartialPaymentCheckout = ({ navigation }: PageProps) => { } return ( - + { onAdditionalDetails={didProvide} onError={didFail} > - + ); diff --git a/example/src/components/Checkout/SessionsComponentsCheckout.tsx b/example/src/components/Checkout/SessionsComponentsCheckout.tsx new file mode 100644 index 000000000..d2d8bbed8 --- /dev/null +++ b/example/src/components/Checkout/SessionsComponentsCheckout.tsx @@ -0,0 +1,100 @@ +import { useEffect, useCallback, useState } from 'react'; +import { Text, ActivityIndicator, View } from 'react-native'; +import { AdyenCheckout } from '@adyen/react-native'; +import type { + AdyenError, + AdyenComponent, + SessionsResult, + SessionConfiguration, +} from '@adyen/react-native'; +import { CheckoutNavigator } from '../../router/CheckoutNavigator'; +import Styles from '../common/Styles'; +import TopView from './components/TopView'; +import ApiClient from '../../api/APIClient'; +import { useAppContext } from '../../hooks/useAppContext'; +import { checkoutConfiguration } from '../utilities/checkoutConfiguration'; +import { processAdyenError } from './utils/processAdyenError'; +import { ENVIRONMENT } from '../../Configuration'; + +const SessionsComponentsCheckout = () => { + const { configuration, processResult, navigateToRoot } = useAppContext(); + const [loading, setLoading] = useState(true); + const [initError, setError] = useState(undefined); + const [session, setSession] = useState( + undefined + ); + + useEffect(() => { + const refreshSession = async () => { + try { + const returnUrl = ENVIRONMENT.returnUrl; + console.log('Session returnUrl', returnUrl); + const newSession = await ApiClient.requestSession( + configuration, + returnUrl + ); + setSession(newSession); + } catch (e) { + setError(String(e)); + } finally { + setLoading(false); + } + }; + refreshSession(); + }, [configuration, setSession, setLoading, setError]); + + const didFail = useCallback( + async (error: AdyenError, nativeComponent: AdyenComponent) => { + processAdyenError(error, nativeComponent); + navigateToRoot(); + }, + [navigateToRoot] + ); + + const didComplete = useCallback( + async (result: SessionsResult, nativeComponent: AdyenComponent) => { + if (result.resultCode === 'PresentToShopper') { + processResult(result, nativeComponent); + return; + } + const status = await ApiClient.requestSessionResult( + result.sessionId, + result.sessionResult + ); + processResult(status, nativeComponent); + }, + [processResult] + ); + + if (loading) { + return ( + + + + ); + } + + if (initError) { + return ( + + {initError} + + ); + } + + return ( + + + + + + + ); +}; + +export default SessionsComponentsCheckout; diff --git a/example/src/components/Checkout/SessionsCheckout.tsx b/example/src/components/Checkout/SessionsDropInCheckout.tsx similarity index 75% rename from example/src/components/Checkout/SessionsCheckout.tsx rename to example/src/components/Checkout/SessionsDropInCheckout.tsx index 733e36b2f..4464ee73f 100644 --- a/example/src/components/Checkout/SessionsCheckout.tsx +++ b/example/src/components/Checkout/SessionsDropInCheckout.tsx @@ -7,19 +7,17 @@ import type { SessionsResult, SessionConfiguration, } from '@adyen/react-native'; -import PaymentMethods from './components/PaymentMethodsView'; +import { CheckoutNavigator } from '../../router/CheckoutNavigator'; import Styles from '../common/Styles'; import TopView from './components/TopView'; import ApiClient from '../../api/APIClient'; import { useAppContext } from '../../hooks/useAppContext'; -import { checkoutConfiguration } from '../../State/checkoutConfiguration'; -import type { PageProps } from '../../State/RootStackParamList'; +import { checkoutConfiguration } from '../utilities/checkoutConfiguration'; import { processAdyenError } from './utils/processAdyenError'; import { ENVIRONMENT } from '../../Configuration'; -import { processResult } from './utils/processResult'; -const SessionsCheckout = ({ navigation }: PageProps) => { - const { configuration } = useAppContext(); +const SessionsDropInCheckout = () => { + const { configuration, processResult, navigateToRoot } = useAppContext(); const [loading, setLoading] = useState(true); const [initError, setError] = useState(undefined); const [session, setSession] = useState( @@ -28,11 +26,12 @@ const SessionsCheckout = ({ navigation }: PageProps) => { useEffect(() => { const refreshSession = async () => { - const returnUrl = Platform.select({ - android: await AdyenDropIn.getReturnURL(), - default: ENVIRONMENT.returnUrl, - }); try { + const returnUrl = Platform.select({ + android: await AdyenDropIn.getReturnURL(), + default: ENVIRONMENT.returnUrl, + }); + console.log('Session returnUrl', returnUrl); const newSession = await ApiClient.requestSession( configuration, returnUrl @@ -50,23 +49,24 @@ const SessionsCheckout = ({ navigation }: PageProps) => { const didFail = useCallback( async (error: AdyenError, nativeComponent: AdyenComponent) => { processAdyenError(error, nativeComponent); + navigateToRoot(); }, - [] + [navigateToRoot] ); const didComplete = useCallback( async (result: SessionsResult, nativeComponent: AdyenComponent) => { if (result.resultCode === 'PresentToShopper') { - processResult(result, nativeComponent, navigation); + processResult(result, nativeComponent); return; } const status = await ApiClient.requestSessionResult( result.sessionId, result.sessionResult ); - processResult(status, nativeComponent, navigation); + processResult(status, nativeComponent); }, - [navigation] + [processResult] ); if (loading) { @@ -86,7 +86,7 @@ const SessionsCheckout = ({ navigation }: PageProps) => { } return ( - + { onComplete={didComplete} onError={didFail} > - + ); }; -export default SessionsCheckout; +export default SessionsDropInCheckout; diff --git a/example/src/components/Checkout/StoredCardsCheckout.tsx b/example/src/components/Checkout/StoredCardsCheckout.tsx new file mode 100644 index 000000000..26d04cb1d --- /dev/null +++ b/example/src/components/Checkout/StoredCardsCheckout.tsx @@ -0,0 +1,90 @@ +import { useEffect, useCallback, useState } from 'react'; +import { Text, ActivityIndicator, View, ScrollView } from 'react-native'; +import type { PaymentMethodsResponse } from '@adyen/react-native'; +import { AdyenAction } from '@adyen/react-native'; +import Styles from '../common/Styles'; +import TopView from './components/TopView'; +import StoredPaymentMethodsList from './components/StoredPaymentMethodsList'; +import ApiClient from '../../api/APIClient'; +import { useAppContext } from '../../hooks/useAppContext'; +import { processError } from './utils/processError'; +import { payByID } from './utils/payByID'; +import type { StoredCardPaymentMethod } from '../../api/types'; + +const StoredCardsCheckout = () => { + const { configuration, processResult } = useAppContext(); + const [loading, setLoading] = useState(true); + const [initError, setError] = useState(undefined); + const [paymentMethods, setPaymentMethods] = useState< + PaymentMethodsResponse | undefined + >(undefined); + + useEffect(() => { + const refreshPaymentMethods = async () => { + try { + const paymentMethodsResponse = + await ApiClient.paymentMethods(configuration); + setPaymentMethods(paymentMethodsResponse); + } catch (e) { + setError(String(e)); + } finally { + setLoading(false); + } + }; + refreshPaymentMethods(); + }, [configuration, setPaymentMethods, setLoading]); + + const makePayment = useCallback( + async (storedCard: StoredCardPaymentMethod) => { + try { + const cvv = '737'; + const result = await payByID(storedCard.id, cvv, configuration); + processResult(result, AdyenAction); + } catch (e) { + processError(e, AdyenAction); + } + }, + [configuration, processResult] + ); + + if (loading) { + return ( + + + + ); + } + + if (initError) { + return ( + + {initError} + + ); + } + + if (!paymentMethods || paymentMethods.storedPaymentMethods?.length === 0) { + return ( + + + No stored payment methods available. Please add a card first. + + + ); + } + + return ( + + + + + + + + ); +}; + +export default StoredCardsCheckout; diff --git a/example/src/components/Checkout/components/PaymentMethodsList.tsx b/example/src/components/Checkout/components/PaymentMethodsList.tsx index 520f26233..0e3dc314a 100644 --- a/example/src/components/Checkout/components/PaymentMethodsList.tsx +++ b/example/src/components/Checkout/components/PaymentMethodsList.tsx @@ -17,7 +17,9 @@ const PaymentMethodsList = ({ paymentMethods }: PaymentMethodsListProps) => { return ( - Components + + Components(obsolete) + {paymentMethods.map((paymentMethod) => { return ( ; -const PaymentMethods = ({ - showComponents, - navigation, -}: PaymentMethodsProps) => { - const { configuration } = useAppContext(); +const PaymentMethods = (prop: PaymentMethodsProps) => { const { isReady, paymentMethods } = useAdyenCheckout(); - const makePayment = useCallback( - async (storedCard: StoredCardPaymentMethod) => { - await handleStoredPayment(storedCard, configuration, navigation); - }, - [configuration, navigation] - ); + const showDropIn = prop.route.params?.showDropIn ?? false; + const showEmbeddedComponents = + prop.route.params?.showEmbeddedComponents ?? false; + const showDropinBasedComponents = + prop.route.params?.showDropBasedComponents ?? false; if (!isReady) { return ; } + if (!paymentMethods) { + return No payment methods available; + } + return ( - + {showDropIn && } - - - {showComponents && ( + {showEmbeddedComponents && ( <> - + - + prop.navigation.navigate('CardForm')} + /> )} + {showDropinBasedComponents && ( + + )} + ); diff --git a/example/src/components/Checkout/components/PlatformPayButton.tsx b/example/src/components/Checkout/components/PlatformPayButton.tsx index 9dd0711cc..b8529af04 100644 --- a/example/src/components/Checkout/components/PlatformPayButton.tsx +++ b/example/src/components/Checkout/components/PlatformPayButton.tsx @@ -42,7 +42,6 @@ const PlatformPayButton = () => { type="PLAIN" style={Styles.btnClickContain} onPress={() => { - console.log('Paying with apple'); start('applepay'); }} /> @@ -53,7 +52,6 @@ const PlatformPayButton = () => { type="PAY" style={Styles.btnClickContain} onPress={() => { - console.log('Paying with google'); start('googlepay'); }} /> diff --git a/example/src/components/Checkout/index.ts b/example/src/components/Checkout/index.ts index a5191c543..0b5d5438c 100644 --- a/example/src/components/Checkout/index.ts +++ b/example/src/components/Checkout/index.ts @@ -1,3 +1,2 @@ -export { default as SessionsCheckout } from './SessionsCheckout'; export { default as AdvancedCheckout } from './AdvancedCheckout'; export { default as PartialPaymentCheckout } from './PartialPaymentCheckout'; diff --git a/example/src/components/Checkout/utils/handleStoredPayment.tsx b/example/src/components/Checkout/utils/handleStoredPayment.tsx deleted file mode 100644 index 010c36057..000000000 --- a/example/src/components/Checkout/utils/handleStoredPayment.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { ResultCode, AdyenAction } from '@adyen/react-native'; -import type { - StoredCardPaymentMethod, - PaymentConfiguration, - PaymentResponse, -} from '../../../api/types'; -import { payByID } from './payByID'; -import { processResult } from './processResult'; -import type { PageProps } from '../../../State/RootStackParamList'; -import { processError } from './processError'; - -export async function handleStoredPayment( - paymentMethod: StoredCardPaymentMethod, - configuration: PaymentConfiguration, - navigation?: PageProps['navigation'] -) { - let result: PaymentResponse; - try { - let cvv = '737'; /** Collect CVV from shopper if nececery */ - result = await payByID(paymentMethod.id, cvv, configuration); - processResult(result, AdyenAction, navigation); - } catch (e) { - result = { - resultCode: ResultCode.error, - }; - processError(e, AdyenAction); - } -} diff --git a/example/src/components/Checkout/utils/payByID.ts b/example/src/components/Checkout/utils/payByID.ts index b304cd8e3..22e7d308b 100644 --- a/example/src/components/Checkout/utils/payByID.ts +++ b/example/src/components/Checkout/utils/payByID.ts @@ -1,18 +1,19 @@ import { AdyenCSE, AdyenAction, + type PaymentResponse, type PaymentMethodData, } from '@adyen/react-native'; import type { PaymentConfiguration } from '../../../api/types'; import { ENVIRONMENT } from '../../../Configuration'; import ApiClient from '../../../api/APIClient'; -import { checkoutConfiguration } from '../../../State/checkoutConfiguration'; +import { checkoutConfiguration } from '../../utilities/checkoutConfiguration'; export async function payByID( id: string, cvv: string, configuration: PaymentConfiguration -) { +): Promise { const encryptedCard = await AdyenCSE.encryptCard( { cvv }, ENVIRONMENT.publicKey diff --git a/example/src/components/Checkout/utils/processPartialPaymentResult.ts b/example/src/components/Checkout/utils/processPartialPaymentResult.ts index 8166e41e0..39debf472 100644 --- a/example/src/components/Checkout/utils/processPartialPaymentResult.ts +++ b/example/src/components/Checkout/utils/processPartialPaymentResult.ts @@ -1,9 +1,7 @@ import { ResultCode } from '@adyen/react-native'; -import { isSuccess } from '../../utilities/isSuccess'; import type { PaymentResponse } from '../../../api/types'; import type { DropInModule, Order } from '@adyen/react-native'; import ApiClient from '../../../api/APIClient'; -import type { PageProps } from '../../../State/RootStackParamList'; import type { PaymentConfiguration } from '../../../api/types'; function isRefusedInPartialPaymentFlow(response: PaymentResponse) { @@ -23,10 +21,8 @@ function isNonFullyPaidOrder(order: Order) { export async function processPartialPaymentResult( result: PaymentResponse, dropInComponent: DropInModule, - navigation: PageProps['navigation'], configuration: PaymentConfiguration -) { - let success = isSuccess(result.resultCode); +): Promise { var outcome: ResultCode = result.resultCode; const action = result.action; const order = result?.order; @@ -34,7 +30,6 @@ export async function processPartialPaymentResult( dropInComponent.handle(action); return; } else if (isRefusedInPartialPaymentFlow(result)) { - success = false; outcome = ResultCode.refused; } else if (order && isNonFullyPaidOrder(order)) { try { @@ -45,12 +40,9 @@ export async function processPartialPaymentResult( dropInComponent.providePaymentMethods(paymentMethods, order); return; } catch (error) { - success = false; outcome = ResultCode.error; console.error(error); } } - dropInComponent.hide(success); - navigation.popToTop(); - navigation.push('Result', { resultCode: outcome }); + return { resultCode: outcome }; } diff --git a/example/src/components/Checkout/utils/processResult.ts b/example/src/components/Checkout/utils/processResult.ts deleted file mode 100644 index c2b161b2a..000000000 --- a/example/src/components/Checkout/utils/processResult.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { AdyenComponent } from '@adyen/react-native'; -import { isSuccess } from '../../utilities/isSuccess'; -import type { PageProps } from '../../../State/RootStackParamList'; -import type { PaymentResponse } from '../../../api/types'; - -export function processResult( - result: PaymentResponse, - nativeComponent: AdyenComponent, - navigation?: PageProps['navigation'] -) { - const success = isSuccess(result.resultCode); - nativeComponent.hide(success); - navigation?.popToTop(); - navigation?.push('Result', { resultCode: result.resultCode }); -} diff --git a/example/src/components/CustomCard/CseView.tsx b/example/src/components/CustomCard/CseView.tsx index 879927db7..9771b3b4f 100644 --- a/example/src/components/CustomCard/CseView.tsx +++ b/example/src/components/CustomCard/CseView.tsx @@ -2,18 +2,16 @@ import { useCallback, useMemo, useState } from 'react'; import { Button, View, Alert, ScrollView } from 'react-native'; import { AdyenAction } from '@adyen/react-native'; import Styles from '../common/Styles'; -import { isSuccess } from '../utilities/isSuccess'; import { payWithCard } from './utils/payWithCard'; import { useAppContext } from '../../hooks/useAppContext'; -import type { PageProps } from '../../State/RootStackParamList'; import type { PaymentResponse } from '../../api/types'; import CardNumberInput from './components/CardNumberInput'; import ExpiryDateInput from './components/ExpiryDateInput'; import SecureCodeInput from './components/SecureCodeInput'; import { formatMinorUnits } from '../utilities/formatMinorUnits'; -const CseView = ({ navigation }: PageProps) => { - const { configuration } = useAppContext(); +const CseView = () => { + const { configuration, processResult } = useAppContext(); const [number, setNumber] = useState(''); const [expiryDate, setExpiryDate] = useState(''); const [cvv, setCvv] = useState(''); @@ -34,14 +32,12 @@ const CseView = ({ navigation }: PageProps) => { let result: PaymentResponse; try { result = await payWithCard(unencryptedCard, configuration); + processResult(result, AdyenAction); } catch (e) { Alert.alert('Error', String(e)); return; } - AdyenAction.hide(isSuccess(result.resultCode)); - navigation.popToTop(); - navigation.push('Result', { resultCode: result.resultCode }); - }, [configuration, navigation, unencryptedCard]); + }, [configuration, unencryptedCard, processResult]); const amountLabel = useMemo(() => { return formatMinorUnits( diff --git a/example/src/components/CustomCard/utils/payWithCard.ts b/example/src/components/CustomCard/utils/payWithCard.ts index 8fd367e98..085f38137 100644 --- a/example/src/components/CustomCard/utils/payWithCard.ts +++ b/example/src/components/CustomCard/utils/payWithCard.ts @@ -6,7 +6,7 @@ import { } from '@adyen/react-native'; import { ENVIRONMENT } from '../../../Configuration'; import ApiClient from '../../../api/APIClient'; -import { checkoutConfiguration } from '../../../State/checkoutConfiguration'; +import { checkoutConfiguration } from '../../utilities/checkoutConfiguration'; import type { PaymentConfiguration } from '../../../api/types'; export async function payWithCard( diff --git a/example/src/components/Home/HomeView.tsx b/example/src/components/Home/HomeView.tsx new file mode 100644 index 000000000..6cc711d27 --- /dev/null +++ b/example/src/components/Home/HomeView.tsx @@ -0,0 +1,71 @@ +import { useCallback, useState } from 'react'; +import { View } from 'react-native'; +import { HomeStackParamList } from '../../router/HomeStackNavigator'; +import type { NativeStackScreenProps } from '@react-navigation/native-stack'; +import Styles from '../common/Styles'; +import TabItem from './TabItem'; +import MenuButton from './MenuButton'; + +type HomeScreenProps = NativeStackScreenProps; + +type TabName = 'Sessions' | 'Advanced' | 'API-Only'; + +type PageType = { + title: string; + route: keyof HomeStackParamList; +}; + +const TABS: TabName[] = ['Sessions', 'Advanced', 'API-Only']; + +const TAB_CONTENT: Record = { + 'Sessions': [ + { title: 'Sessions DropIn', route: 'SessionsDropInCheckout' }, + { title: 'Sessions Components', route: 'SessionsComponentsCheckout' }, + ], + 'Advanced': [ + { title: 'Advanced Checkout', route: 'AdvancedCheckout' }, + { title: 'Partial Payment', route: 'PartialPaymentCheckout' }, + ], + 'API-Only': [ + { title: 'Custom Card (CSE)', route: 'CustomCard' }, + { title: 'Stored Cards', route: 'StoredCards' }, + ], +}; + +const Home = ({ navigation }: HomeScreenProps) => { + const [activeTab, setActiveTab] = useState('Sessions'); + + const navigationHandler = useCallback( + (screenName: keyof HomeStackParamList) => { + navigation.navigate(screenName as any); + }, + [navigation] + ); + + return ( + + + {TABS.map((tab) => ( + setActiveTab(tab)} + /> + ))} + + + + {TAB_CONTENT[activeTab].map(({ title, route }) => ( + navigationHandler(route)} + /> + ))} + + + ); +}; + +export default Home; diff --git a/example/src/components/Home/MenuButton.tsx b/example/src/components/Home/MenuButton.tsx new file mode 100644 index 000000000..8e55aba06 --- /dev/null +++ b/example/src/components/Home/MenuButton.tsx @@ -0,0 +1,15 @@ +import { Text, TouchableOpacity } from 'react-native'; +import Styles from '../common/Styles'; + +type MenuButtonProps = { + title: string; + onPress: () => void; +}; + +const MenuButton = ({ title, onPress }: MenuButtonProps) => ( + + {title} + +); + +export default MenuButton; diff --git a/example/src/components/Home/TabItem.tsx b/example/src/components/Home/TabItem.tsx new file mode 100644 index 000000000..86a849c78 --- /dev/null +++ b/example/src/components/Home/TabItem.tsx @@ -0,0 +1,21 @@ +import { Text, TouchableOpacity } from 'react-native'; +import Styles from '../common/Styles'; + +type TabItemProps = { + label: string; + isActive: boolean; + onPress: () => void; +}; + +const TabItem = ({ label, isActive, onPress }: TabItemProps) => ( + + + {label} + + +); + +export default TabItem; diff --git a/example/src/components/HomeView.tsx b/example/src/components/HomeView.tsx deleted file mode 100644 index e75c68449..000000000 --- a/example/src/components/HomeView.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { useCallback, useEffect } from 'react'; -import { View, Button } from 'react-native'; -import type { PageProps } from '../State/RootStackParamList'; -import Styles from './common/Styles'; - -function createOptions({ navigation }: PageProps) { - return { - headerRight: () => ( -