diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml
index 7775e7abac..5809f451ba 100644
--- a/core/res/src/main/res/values/strings.xml
+++ b/core/res/src/main/res/values/strings.xml
@@ -1804,6 +1804,8 @@
MCC
A fee is charged due to the service tariffs
The transaction was partially or fully reversed by the merchant
+ We are rolling it out gradually and let you know when Tangem Pay will be available here
+ Tangem Pay is unavailable in your region
Keep using your money. You can freeze anytime.
Unfreeze your card?
Failed to unfreeze the card. Try again later.
diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt
index 6f439a26bd..0934b71734 100644
--- a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt
+++ b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt
@@ -143,6 +143,7 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor(
private fun TangemPayEntryPoint.toEligibilityType(): TangemPayEligibilityType = when (this) {
TangemPayEntryPoint.BANNER -> TangemPayEligibilityType.BANNER
TangemPayEntryPoint.DETAILS -> TangemPayEligibilityType.DETAILS
+ TangemPayEntryPoint.DEEPLINK -> TangemPayEligibilityType.DEEPLINK
}
private data class UserWalletData(
diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayEntryPoint.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayEntryPoint.kt
index 72a1de7e96..cd596dd5f5 100644
--- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayEntryPoint.kt
+++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayEntryPoint.kt
@@ -3,4 +3,5 @@ package com.tangem.domain.pay.model
enum class TangemPayEntryPoint {
BANNER,
DETAILS,
+ DEEPLINK,
}
\ No newline at end of file
diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt
index 93b9db5768..869fc11d95 100644
--- a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt
+++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt
@@ -25,6 +25,7 @@ import com.tangem.features.tangempay.model.transformers.TangemPayOnboardingButto
import com.tangem.features.tangempay.ui.TangemPayOnboardingNavigation
import com.tangem.features.tangempay.ui.TangemPayOnboardingScreenState
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
+import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -64,7 +65,7 @@ internal class TangemPayOnboardingModel @Inject constructor(
when (params) {
is TangemPayOnboardingComponent.Params.Deeplink -> {
repository.validateDeeplink(params.deeplink)
- .onRight { isValid -> if (isValid) showOnboarding() else back() }
+ .onRight { isValid -> if (isValid) checkEligibilityAndShow() else back() }
.onLeft { back() }
}
is TangemPayOnboardingComponent.Params.ContinueOnboarding -> {
@@ -93,6 +94,21 @@ internal class TangemPayOnboardingModel @Inject constructor(
}
}
+ private suspend fun checkEligibilityAndShow() {
+ runSuspendCatching {
+ eligibilityManager.getTangemPayAvailability(TangemPayEntryPoint.DEEPLINK)
+ }
+ .onSuccess { isAvailable -> if (isAvailable) showOnboarding() else showNotAvailable() }
+ .onFailure { error ->
+ TangemLogger.e(messageString = "TangemPayOnboarding: eligibility check failed", throwable = error)
+ back()
+ }
+ }
+
+ private fun showNotAvailable() {
+ uiState.update { state -> TangemPayOnboardingScreenState.NotAvailable(onBack = state.onBack) }
+ }
+
private fun checkCustomerInfo(userWalletId: UserWalletId) {
modelScope.launch {
uiState.transformerUpdate(TangemPayOnboardingButtonLoadingTransformer(isLoading = true))
diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TandemPayOnboardingScreen.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TandemPayOnboardingScreen.kt
index 346fea774a..9650260c81 100644
--- a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TandemPayOnboardingScreen.kt
+++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TandemPayOnboardingScreen.kt
@@ -2,19 +2,24 @@ package com.tangem.features.tangempay.ui
import android.content.res.Configuration
import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
@@ -23,14 +28,35 @@ import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameter
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
+import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.extensions.TextReference
+import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
-import com.tangem.core.ui.res.TangemThemePreview
+import com.tangem.core.ui.res.TangemThemePreviewRedesign
+import com.tangem.core.ui.res.generated.icons.Icons
+import com.tangem.core.ui.res.generated.icons.ic_heart_broken_32
import com.tangem.core.ui.utils.WindowInsetsZero
+// Warm top glow of the "unavailable" screen: a translucent gold (sampled from Figma) drawn over
+// bg.primary, so one value matches both light and dark themes. Fades to transparent by mid-screen.
+private const val GLOW_COLOR = 0xFFFCB92A
+private const val GLOW_ALPHA = 0.49f
+private const val GLOW_FADE_STOP = 0.5f
+
@Composable
internal fun TandemPayOnboardingScreen(state: TangemPayOnboardingScreenState, modifier: Modifier = Modifier) {
+ when (state) {
+ is TangemPayOnboardingScreenState.NotAvailable ->
+ TangemPayOnboardingUnavailable(onBack = state.onBack, modifier = modifier)
+ is TangemPayOnboardingScreenState.Loading,
+ is TangemPayOnboardingScreenState.Content,
+ -> TangemPayOnboardingScaffold(state = state, modifier = modifier)
+ }
+}
+
+@Composable
+private fun TangemPayOnboardingScaffold(state: TangemPayOnboardingScreenState, modifier: Modifier = Modifier) {
Scaffold(
modifier = modifier.systemBarsPadding(),
topBar = {
@@ -52,11 +78,81 @@ internal fun TandemPayOnboardingScreen(state: TangemPayOnboardingScreenState, mo
state = state,
modifier = contentModifier,
)
+ is TangemPayOnboardingScreenState.NotAvailable -> Unit
}
},
)
}
+@Composable
+private fun TangemPayOnboardingUnavailable(onBack: () -> Unit, modifier: Modifier = Modifier) {
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .background(TangemTheme.colors3.bg.primary),
+ ) {
+ UnavailableGlow(modifier = Modifier.matchParentSize())
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .systemBarsPadding(),
+ ) {
+ Column(
+ modifier = Modifier.padding(top = 72.dp, start = 24.dp, end = 24.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Icon(
+ modifier = Modifier.size(32.dp),
+ imageVector = Icons.ic_heart_broken_32,
+ contentDescription = null,
+ tint = TangemTheme.colors3.icon.primary,
+ )
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Text(
+ text = stringResourceSafe(R.string.tangem_pay_unavailable_region_title),
+ style = TangemTheme.typography3.heading.medium,
+ color = TangemTheme.colors3.text.primary,
+ )
+ Text(
+ text = stringResourceSafe(R.string.tangem_pay_unavailable_region_description),
+ style = TangemTheme.typography3.subheading.medium,
+ color = TangemTheme.colors3.text.secondary,
+ )
+ }
+ }
+ Spacer(modifier = Modifier.weight(1f))
+ TangemButton(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ variant = TangemButton.Variant.Primary,
+ size = TangemButton.Size.X12,
+ text = resourceReference(R.string.common_got_it),
+ onClick = onBack,
+ )
+ }
+ }
+}
+
+@Composable
+private fun UnavailableGlow(modifier: Modifier = Modifier) {
+ Box(
+ modifier = modifier.drawBehind {
+ drawRect(
+ brush = Brush.radialGradient(
+ colorStops = arrayOf(
+ 0f to Color(GLOW_COLOR).copy(alpha = GLOW_ALPHA),
+ GLOW_FADE_STOP to Color.Transparent,
+ 1f to Color.Transparent,
+ ),
+ center = Offset(x = size.width / 2f, y = 0f),
+ radius = size.height,
+ ),
+ )
+ },
+ )
+}
+
@Composable
private fun TangemPayOnboardingLoading(modifier: Modifier = Modifier) {
Box(modifier = modifier, contentAlignment = Alignment.Center) {
@@ -155,7 +251,7 @@ private fun TandemPayOnboardingScreenPreview(
@PreviewParameter(TangemPayOnboardingScreenStateProvider::class)
state: TangemPayOnboardingScreenState,
) {
- TangemThemePreview {
+ TangemThemePreviewRedesign {
TandemPayOnboardingScreen(state = state, modifier = Modifier.fillMaxSize())
}
}
@@ -174,5 +270,6 @@ private class TangemPayOnboardingScreenStateProvider :
onTermsClick = {},
buttonConfig = TangemPayOnboardingScreenState.Content.ButtonConfig(isLoading = true, onClick = {}),
),
+ TangemPayOnboardingScreenState.NotAvailable(onBack = {}),
),
)
\ No newline at end of file
diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingScreenState.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingScreenState.kt
index af92226019..f5e6506ad0 100644
--- a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingScreenState.kt
+++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingScreenState.kt
@@ -14,4 +14,9 @@ internal sealed class TangemPayOnboardingScreenState {
) : TangemPayOnboardingScreenState() {
data class ButtonConfig(val isLoading: Boolean, val onClick: () -> Unit)
}
+
+ /**
+ * Shown when the user is not eligible for Tangem Pay. [onBack] also backs the "Got it" button.
+ */
+ data class NotAvailable(override val onBack: () -> Unit) : TangemPayOnboardingScreenState()
}
\ No newline at end of file
diff --git a/features/tangempay/onboarding/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModelTest.kt b/features/tangempay/onboarding/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModelTest.kt
new file mode 100644
index 0000000000..ca82d57966
--- /dev/null
+++ b/features/tangempay/onboarding/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModelTest.kt
@@ -0,0 +1,182 @@
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package com.tangem.features.tangempay.model
+
+import arrow.core.Either
+import arrow.core.left
+import arrow.core.right
+import com.google.common.truth.Truth.assertThat
+import com.tangem.core.analytics.api.AnalyticsEventHandler
+import com.tangem.core.decompose.model.MutableParamsContainer
+import com.tangem.core.decompose.navigation.Router
+import com.tangem.core.error.UniversalError
+import com.tangem.core.navigation.url.UrlOpener
+import com.tangem.domain.pay.TangemPayEligibilityManager
+import com.tangem.domain.pay.model.TangemPayEntryPoint
+import com.tangem.domain.pay.repository.OnboardingRepository
+import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase
+import com.tangem.features.tangempay.components.TangemPayOnboardingComponent
+import com.tangem.features.tangempay.ui.TangemPayOnboardingScreenState
+import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
+import io.mockk.clearMocks
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.TestInstance
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.MethodSource
+
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+internal class TangemPayOnboardingModelTest {
+
+ private val router: Router = mockk(relaxed = true)
+ private val repository: OnboardingRepository = mockk(relaxed = true)
+ private val eligibilityManager: TangemPayEligibilityManager = mockk()
+ private val analytics: AnalyticsEventHandler = mockk(relaxed = true)
+ private val produceInitialDataUseCase: ProduceTangemPayInitialDataUseCase = mockk(relaxed = true)
+ private val urlOpener: UrlOpener = mockk(relaxed = true)
+
+ private val deeplink = "tangem://onboard-visa"
+
+ @BeforeEach
+ fun resetMocks() {
+ clearMocks(router, repository, eligibilityManager, analytics, produceInitialDataUseCase, urlOpener)
+ }
+
+ @ParameterizedTest
+ @MethodSource("provideAvailabilityCases")
+ fun `GIVEN valid deeplink WHEN model created THEN state reflects tangem pay availability`(
+ case: AvailabilityCase,
+ ) = runTest {
+ // Arrange
+ coEvery { repository.validateDeeplink(deeplink) } returns true.right()
+ coEvery { eligibilityManager.getTangemPayAvailability(TangemPayEntryPoint.DEEPLINK) } returns case.isAvailable
+
+ // Act
+ val model = createModel(TangemPayOnboardingComponent.Params.Deeplink(deeplink))
+ advanceUntilIdle()
+
+ // Assert
+ assertThat(model.uiState.value).isInstanceOf(case.expectedState)
+ coVerify(exactly = 1) { eligibilityManager.getTangemPayAvailability(TangemPayEntryPoint.DEEPLINK) }
+ model.onDestroy()
+ }
+
+ @ParameterizedTest
+ @MethodSource("provideRejectedDeeplinkCases")
+ fun `GIVEN deeplink rejected WHEN model created THEN screen closed AND availability not checked`(
+ validationResult: Either,
+ ) = runTest {
+ // Arrange
+ coEvery { repository.validateDeeplink(deeplink) } returns validationResult
+
+ // Act
+ val model = createModel(TangemPayOnboardingComponent.Params.Deeplink(deeplink))
+ advanceUntilIdle()
+
+ // Assert
+ verify(exactly = 1) { router.pop() }
+ coVerify(exactly = 0) { eligibilityManager.getTangemPayAvailability(any()) }
+ model.onDestroy()
+ }
+
+ @Test
+ fun `GIVEN availability check fails WHEN model created THEN screen closed`() = runTest {
+ // Arrange
+ coEvery { repository.validateDeeplink(deeplink) } returns true.right()
+ coEvery { eligibilityManager.getTangemPayAvailability(TangemPayEntryPoint.DEEPLINK) } throws
+ RuntimeException("network error")
+
+ // Act
+ val model = createModel(TangemPayOnboardingComponent.Params.Deeplink(deeplink))
+ advanceUntilIdle()
+
+ // Assert
+ coVerify(exactly = 1) { eligibilityManager.getTangemPayAvailability(TangemPayEntryPoint.DEEPLINK) }
+ verify(exactly = 1) { router.pop() }
+ model.onDestroy()
+ }
+
+ @Test
+ fun `GIVEN unavailable shown WHEN got it clicked THEN screen closed`() = runTest {
+ // Arrange
+ coEvery { repository.validateDeeplink(deeplink) } returns true.right()
+ coEvery { eligibilityManager.getTangemPayAvailability(TangemPayEntryPoint.DEEPLINK) } returns false
+ val model = createModel(TangemPayOnboardingComponent.Params.Deeplink(deeplink))
+ advanceUntilIdle()
+
+ // Act
+ model.uiState.value.onBack.invoke()
+
+ // Assert
+ verify(exactly = 1) { router.pop() }
+ model.onDestroy()
+ }
+
+ @Test
+ fun `GIVEN from banner entry WHEN model created THEN content shown without availability check`() = runTest {
+ // Act
+ val model = createModel(TangemPayOnboardingComponent.Params.FromBannerOnMain)
+ advanceUntilIdle()
+
+ // Assert
+ assertThat(model.uiState.value).isInstanceOf(TangemPayOnboardingScreenState.Content::class.java)
+ coVerify(exactly = 0) { eligibilityManager.getTangemPayAvailability(any()) }
+ model.onDestroy()
+ }
+
+ private fun TestScope.createModel(params: TangemPayOnboardingComponent.Params): TangemPayOnboardingModel {
+ val testDispatcher = StandardTestDispatcher(testScheduler)
+ return TangemPayOnboardingModel(
+ paramsContainer = MutableParamsContainer(params),
+ dispatchers = TestingCoroutineDispatcherProvider(
+ main = testDispatcher,
+ mainImmediate = testDispatcher,
+ io = testDispatcher,
+ default = testDispatcher,
+ single = testDispatcher,
+ ),
+ analytics = analytics,
+ router = router,
+ repository = repository,
+ produceInitialDataUseCase = produceInitialDataUseCase,
+ urlOpener = urlOpener,
+ eligibilityManager = eligibilityManager,
+ )
+ }
+
+ internal data class AvailabilityCase(
+ val isAvailable: Boolean,
+ val expectedState: Class,
+ )
+
+ private fun provideAvailabilityCases() = listOf(
+ AvailabilityCase(
+ isAvailable = true,
+ expectedState = TangemPayOnboardingScreenState.Content::class.java,
+ ),
+ AvailabilityCase(
+ isAvailable = false,
+ expectedState = TangemPayOnboardingScreenState.NotAvailable::class.java,
+ ),
+ )
+
+ private fun provideRejectedDeeplinkCases(): List> = listOf(
+ false.right(),
+ DEEPLINK_ERROR.left(),
+ )
+
+ private companion object {
+ val DEEPLINK_ERROR = object : UniversalError {
+ override val errorCode: Int = 0
+ }
+ }
+}
\ No newline at end of file