Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-14 15:26:55 +02:00
parent 28f550e633
commit 4754f0677d
28 changed files with 479 additions and 52 deletions

View file

@ -16,6 +16,9 @@ dependencies {
/** Project - API */
api(projects.features.commonFeatures.api)
api(projects.features.onramp.api)
implementation(projects.features.marketing.api)
implementation(projects.domain.marketing.models)
implementation(projects.domain.quotes)
/** Project - Core */
api(projects.core.analytics)

View file

@ -154,6 +154,7 @@ internal class AllOffersStateFactory(
category = OnrampOfferCategoryUM.Recommended,
advantages = mapOfferAdvantagesDTOtoUM(offer.advantages),
paymentMethod = quote.paymentMethod,
providerId = quote.provider.id,
providerName = quote.provider.info.name,
rate = formatCryptoAmount(quote.toAmount),
diff = formatRateDiff(offer.rateDif),
@ -185,6 +186,7 @@ internal class AllOffersStateFactory(
category = OnrampOfferCategoryUM.Recommended,
advantages = advantages,
paymentMethod = quote.paymentMethod,
providerId = quote.provider.id,
providerName = quote.provider.info.name,
rate = formatRequiredAmount(quote, currencyCode),
diff = formatRateDiff(offer.rateDif),

View file

@ -205,6 +205,7 @@ private fun AllOffersContentSheetPaymentPreview() {
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD,
),
providerId = "simplex",
providerName = "Simplex",
rate = "0,0245334 BTC",
diff = null,
@ -219,6 +220,7 @@ private fun AllOffersContentSheetPaymentPreview() {
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD,
),
providerId = "simplex",
providerName = "Simplex",
rate = "0,00145334 BTC",
diff = stringReference("0.07%"),
@ -270,6 +272,7 @@ private fun AllOffersContentSheetOffersPreview() {
"https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD,
),
providerId = "simplex",
providerName = "Simplex",
rate = "0,0245334 BTC",
diff = null,
@ -285,6 +288,7 @@ private fun AllOffersContentSheetOffersPreview() {
"https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD,
),
providerId = "simplex",
providerName = "Simplex",
rate = "0,00145334 BTC",
diff = stringReference("0.07%"),

View file

@ -273,6 +273,7 @@ private fun PaymentMethodsContentPreview() {
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD,
),
providerId = "simplex",
providerName = "Simplex",
rate = "0,0245334 BTC",
diff = null,
@ -287,6 +288,7 @@ private fun PaymentMethodsContentPreview() {
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD,
),
providerId = "simplex",
providerName = "Simplex",
rate = "0,00145334 BTC",
diff = stringReference("0.07%"),

View file

@ -10,9 +10,11 @@ import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.arkivanov.essenty.lifecycle.subscribe
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.features.marketing.api.MarketingBannerComponent
import com.tangem.features.onramp.alloffers.AllOffersComponent
import com.tangem.features.onramp.confirmresidency.ConfirmResidencyComponent
import com.tangem.features.onramp.main.entity.OnrampMainBottomSheetConfig
@ -29,10 +31,24 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor(
private val confirmResidencyComponentFactory: ConfirmResidencyComponent.Factory,
private val selectCurrencyComponentFactory: SelectCurrencyComponent.Factory,
private val allOffersComponentFactory: AllOffersComponent.Factory,
private val marketingBannerComponentFactory: MarketingBannerComponent.Factory,
) : OnrampMainComponent, AppComponentContext by appComponentContext {
private val model: OnrampMainComponentModel = getOrCreateModel(params)
private val marketingBannerComponent: MarketingBannerComponent = marketingBannerComponentFactory.create(
context = child("marketing_banner"),
params = MarketingBannerComponent.Params.Standalone(
requestFlow = model.marketingRequest,
onDeeplinkClick = model::onMarketingBannerDeeplink,
),
)
private val linkedMarketingBannerComponent: MarketingBannerComponent = marketingBannerComponentFactory.create(
context = child("marketing_banner_linked"),
params = MarketingBannerComponent.Params.Linked(requestFlow = model.linkedMarketingRequest),
)
init {
lifecycle.subscribe(onStop = model::onStop)
}
@ -49,7 +65,12 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor(
val state by model.state.collectAsState()
val bottomSheet by bottomSheetSlot.subscribeAsState()
OnrampMainScreen(modifier = modifier, state = state)
OnrampMainScreen(
modifier = modifier,
state = state,
marketingBannerComponent = marketingBannerComponent,
linkedMarketingBannerComponent = linkedMarketingBannerComponent,
)
bottomSheet.child?.instance?.BottomSheet()
}

View file

@ -24,6 +24,7 @@ internal data class OnrampOfferUM(
val category: OnrampOfferCategoryUM,
val advantages: OnrampOfferAdvantagesUM,
val paymentMethod: OnrampPaymentMethod,
val providerId: String,
val providerName: String,
val rate: String,
val diff: TextReference?,

View file

@ -44,6 +44,7 @@ internal class OnrampOffersStateFactory(
category = category,
advantages = advantages,
paymentMethod = currentQuote.paymentMethod,
providerId = currentQuote.provider.id,
providerName = currentQuote.provider.info.name,
rate = currentQuote.toAmount.value.format {
crypto(

View file

@ -3,7 +3,10 @@ package com.tangem.features.onramp.main.model
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.common.routing.deeplink.resolveMarketingDeeplink
import com.tangem.common.routing.deeplink.toContextualRoute
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
@ -11,6 +14,8 @@ import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.components.fields.InputManager
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.marketing.models.MarketingScreen
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.onramp.*
import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent
import com.tangem.domain.onramp.model.OnrampAvailability
@ -18,8 +23,11 @@ import com.tangem.domain.onramp.model.OnrampCountry
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.domain.onramp.model.OnrampQuote
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.quotes.GetCurrencyUSDQuoteUseCase
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.marketing.api.LinkedBannerRequest
import com.tangem.features.marketing.api.MarketingBannerRequest
import com.tangem.features.onramp.main.OnrampMainComponent
import com.tangem.features.onramp.main.entity.*
import com.tangem.features.onramp.main.entity.factory.OnrampAmountButtonUMStateFactory
@ -38,6 +46,7 @@ import com.tangem.utils.isNullOrZero
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.math.BigDecimal
import javax.inject.Inject
@Suppress("LongParameterList", "LargeClass")
@ -56,6 +65,7 @@ internal class OnrampMainComponentModel @Inject constructor(
private val getOnrampOffersUseCase: GetOnrampOffersUseCase,
private val isDemoCardUseCase: IsDemoCardUseCase,
private val messageSender: UiMessageSender,
private val getCurrencyUSDQuoteUseCase: GetCurrencyUSDQuoteUseCase,
paramsContainer: ParamsContainer,
getWalletsUseCase: GetWalletsUseCase,
) : Model(), OnrampIntents {
@ -85,6 +95,68 @@ internal class OnrampMainComponentModel @Inject constructor(
),
)
/**
* Expected received crypto amount, taken from the first [OnrampQuote.Data] quote's [OnrampQuote.Data.toAmount].
* Used to derive [amountUsd][MarketingBannerRequest.amountUsd] for the marketing banner request flows below,
* since the campaign min/max amount gating is expressed in USD while the user only enters a fiat amount here.
*
* Seeded with an initial `null` via [onStart] so the downstream [combine] can emit immediately on cold start
* (before any quote is available, e.g. when the user has not entered an amount yet). Without this seed the
* underlying quotes flow stays silent until a quote is stored, which would keep the banner requests from
* emitting at all.
*/
private val expectedCryptoAmount: Flow<BigDecimal?> = getOnrampQuotesUseCase.invoke()
.map { either ->
either.getOrNull()
?.filterIsInstance<OnrampQuote.Data>()
?.firstOrNull()
?.toAmount?.value
}
.distinctUntilChanged()
.onStart { emit(null) }
/**
* Request flow for the standalone marketing banner.
* [fromFiat] is the fiat currency code the user is paying in (only available once the screen is in Content state).
* [toNetwork] is the backend network id of the target crypto currency.
* [toContractAddress] is the contract address of the target token (empty string for coins).
* [amountUsd] is derived from the expected received crypto amount (see [expectedCryptoAmount]) converted to USD.
* On cold start (no quote yet, e.g. the user has not entered an amount) it is null, so the request emits
* immediately with `amountUsd = null` and the domain shows the banner ungated; once a quote arrives the request
* re-emits with the real USD amount so the min/max filter applies. It is also null if the target currency has no
* USD rate, again skipping the amount filter.
*/
val marketingRequest: Flow<MarketingBannerRequest?> = combine(state, expectedCryptoAmount) { s, crypto ->
val contentState = s as? OnrampMainComponentUM.Content ?: return@combine null
MarketingBannerRequest(
screen = MarketingScreen.Onramp(
fromFiat = contentState.amountBlockState.currencyUM.code,
toNetwork = params.cryptoCurrency.network.rawId,
toContractAddress = (params.cryptoCurrency as? CryptoCurrency.Token)?.contractAddress.orEmpty(),
),
amountUsd = computeAmountUsd(crypto),
)
}
/**
* Request flow for the LINKED_TO_PROVIDER marketing banner shown inline next to onramp provider offers.
* Carries no provider id: provider matching is done per offer at render time (each offer row asks for its
* banner via [MarketingBannerComponent.LinkedContent]), mirroring iOS.
* [amountUsd] follows the same rules as in [marketingRequest]: null on cold start (banner shown ungated) or when
* the target currency has no USD rate, and the real USD amount once a quote is available.
*/
val linkedMarketingRequest: Flow<LinkedBannerRequest?> = combine(state, expectedCryptoAmount) { s, crypto ->
val contentState = s as? OnrampMainComponentUM.Content ?: return@combine null
LinkedBannerRequest(
screen = MarketingScreen.Onramp(
fromFiat = contentState.amountBlockState.currencyUM.code,
toNetwork = params.cryptoCurrency.network.rawId,
toContractAddress = (params.cryptoCurrency as? CryptoCurrency.Token)?.contractAddress.orEmpty(),
),
amountUsd = computeAmountUsd(crypto),
)
}
private val amountStateFactory: OnrampAmountStateFactory by lazy(LazyThreadSafetyMode.NONE) {
OnrampAmountStateFactory(
currentStateProvider = Provider { state.value },
@ -125,6 +197,16 @@ internal class OnrampMainComponentModel @Inject constructor(
super.onDestroy()
}
fun onMarketingBannerDeeplink(deeplink: String): Boolean {
val route = resolveMarketingDeeplink(deeplink).toContextualRoute(
userWalletId = params.userWalletId,
currency = params.cryptoCurrency,
screenSource = AnalyticsParam.ScreensSources.Buy,
) ?: return false
router.push(route)
return true
}
override fun onAmountValueChanged(value: String) {
state.update { amountStateFactory.getOnAmountValueChange(value) }
modelScope.launch { amountInputManager.update(value) }
@ -412,6 +494,14 @@ internal class OnrampMainComponentModel @Inject constructor(
}
}
/** Converts the expected received [crypto] amount into its USD value using the target currency's USD rate. */
private suspend fun computeAmountUsd(crypto: BigDecimal?): BigDecimal? {
val amount = crypto ?: return null
val rawCurrencyId = params.cryptoCurrency.id.rawCurrencyId ?: return null
val rate = getCurrencyUSDQuoteUseCase(rawCurrencyId) ?: return null
return amount * rate
}
private fun sendOnrampQuotesErrorAnalytic(quotes: List<OnrampQuote>) {
quotes.forEach { errorState ->
when (errorState) {

View file

@ -17,11 +17,18 @@ import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.ds.message.TangemMessage
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemeRedesign
import com.tangem.core.ui.utils.WindowInsetsZero
import com.tangem.features.marketing.api.MarketingBannerComponent
import com.tangem.features.onramp.main.entity.OnrampMainComponentUM
@Composable
internal fun OnrampMainScreen(state: OnrampMainComponentUM, modifier: Modifier = Modifier) {
internal fun OnrampMainScreen(
state: OnrampMainComponentUM,
marketingBannerComponent: MarketingBannerComponent,
linkedMarketingBannerComponent: MarketingBannerComponent,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier.systemBarsPadding(),
topBar = {
@ -36,13 +43,20 @@ internal fun OnrampMainScreen(state: OnrampMainComponentUM, modifier: Modifier =
) { scaffoldPaddings ->
OnrampMainComponentContent(
state = state,
marketingBannerComponent = marketingBannerComponent,
linkedMarketingBannerComponent = linkedMarketingBannerComponent,
modifier = Modifier.padding(scaffoldPaddings),
)
}
}
@Composable
internal fun OnrampMainComponentContent(state: OnrampMainComponentUM, modifier: Modifier = Modifier) {
internal fun OnrampMainComponentContent(
state: OnrampMainComponentUM,
marketingBannerComponent: MarketingBannerComponent,
linkedMarketingBannerComponent: MarketingBannerComponent,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.fillMaxSize()
@ -56,7 +70,11 @@ internal fun OnrampMainComponentContent(state: OnrampMainComponentUM, modifier:
) {
when (state) {
is OnrampMainComponentUM.InitialLoading -> InitialLoading(state = state)
is OnrampMainComponentUM.Content -> Content(state = state)
is OnrampMainComponentUM.Content -> Content(
state = state,
marketingBannerComponent = marketingBannerComponent,
linkedMarketingBannerComponent = linkedMarketingBannerComponent,
)
}
}
@ -118,7 +136,12 @@ private fun OnrampAmountContentLoading() {
}
@Composable
private fun Content(state: OnrampMainComponentUM.Content, modifier: Modifier = Modifier) {
private fun Content(
state: OnrampMainComponentUM.Content,
marketingBannerComponent: MarketingBannerComponent,
linkedMarketingBannerComponent: MarketingBannerComponent,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
@ -133,7 +156,14 @@ private fun Content(state: OnrampMainComponentUM.Content, modifier: Modifier = M
) {
OnrampAmountContent(state = state)
OnrampOffersContent(state = state.offersBlockState)
TangemThemeRedesign {
marketingBannerComponent.Content(Modifier.fillMaxWidth())
}
OnrampOffersContent(
state = state.offersBlockState,
linkedMarketingBannerComponent = linkedMarketingBannerComponent,
)
OnrampNotifications(state = state)
}

View file

@ -29,15 +29,17 @@ import com.tangem.core.ui.components.buttons.common.TangemButtonSize
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemThemeRedesign
import com.tangem.core.ui.test.OnrampOffersBlockTestTags
import com.tangem.domain.onramp.model.OnrampPaymentMethod
import com.tangem.domain.onramp.model.PaymentMethodType
import com.tangem.features.marketing.api.MarketingBannerComponent
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.main.entity.*
import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun OnrampOffersContent(state: OnrampOffersBlockUM) {
internal fun OnrampOffersContent(state: OnrampOffersBlockUM, linkedMarketingBannerComponent: MarketingBannerComponent) {
when (state) {
is OnrampOffersBlockUM.Content -> {
Column(modifier = Modifier.fillMaxWidth()) {
@ -52,7 +54,7 @@ internal fun OnrampOffersContent(state: OnrampOffersBlockUM) {
SpacerH(8.dp)
Offer(recentOffer)
OfferWithLinkedBanner(recentOffer, linkedMarketingBannerComponent)
SpacerH(16.dp)
}
@ -74,7 +76,7 @@ internal fun OnrampOffersContent(state: OnrampOffersBlockUM) {
state.recommended.fastForEach { offer ->
key("${offer.paymentMethod.id} ${offer.providerName} ${offer.rate}") {
Offer(offer)
OfferWithLinkedBanner(offer, linkedMarketingBannerComponent)
SpacerH(8.dp)
}
}
@ -102,13 +104,34 @@ internal fun OnrampOffersContent(state: OnrampOffersBlockUM) {
}
@Composable
internal fun Offer(onrampOfferUM: OnrampOfferUM, modifier: Modifier = Modifier) {
private fun OfferWithLinkedBanner(offer: OnrampOfferUM, linkedMarketingBannerComponent: MarketingBannerComponent) {
val hasBanner = linkedMarketingBannerComponent.hasLinkedBanner(offer.providerId)
// Square the offer's bottom corners so the bottom-rounded banner glues to it as one card.
Offer(offer, roundBottom = !hasBanner)
if (hasBanner) {
TangemThemeRedesign {
linkedMarketingBannerComponent.LinkedContent(
providerId = offer.providerId,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
@Composable
internal fun Offer(onrampOfferUM: OnrampOfferUM, modifier: Modifier = Modifier, roundBottom: Boolean = true) {
// Square the bottom corners when a linked marketing banner is glued below, so they read as one card.
val shape = if (roundBottom) {
RoundedCornerShape(14.dp)
} else {
RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp)
}
Column(
modifier = modifier
.fillMaxWidth()
.background(
color = TangemTheme.colors.background.action,
shape = RoundedCornerShape(14.dp),
shape = shape,
)
.padding(12.dp),
) {
@ -386,6 +409,7 @@ private fun OnrampOffersContentPreview() {
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD,
),
providerId = "simplex",
providerName = "Simplex",
rate = "0,00045334 BTC",
diff = stringReference("27%"),
@ -401,6 +425,7 @@ private fun OnrampOffersContentPreview() {
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD,
),
providerId = "simplex",
providerName = "Simplex",
rate = "0,0245334 BTC",
diff = null,
@ -415,6 +440,7 @@ private fun OnrampOffersContentPreview() {
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD,
),
providerId = "simplex",
providerName = "Simplex",
rate = "0,00145334 BTC",
diff = stringReference("0.07%"),
@ -427,7 +453,10 @@ private fun OnrampOffersContentPreview() {
),
)
TangemThemePreview {
OnrampOffersContent(state)
OnrampOffersContent(state = state, linkedMarketingBannerComponent = object : MarketingBannerComponent {
@Composable
override fun Content(modifier: Modifier) = Unit
})
}
}
@ -436,6 +465,12 @@ private fun OnrampOffersContentPreview() {
@Composable
private fun OnrampOffersLoadingPreview() {
TangemThemePreview {
OnrampOffersContent(OnrampOffersBlockUM.Loading)
OnrampOffersContent(
state = OnrampOffersBlockUM.Loading,
linkedMarketingBannerComponent = object : MarketingBannerComponent {
@Composable
override fun Content(modifier: Modifier) = Unit
},
)
}
}