Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-14 18:26:55 +05:00
parent 85158f31dc
commit e99ffe39f0
28 changed files with 480 additions and 52 deletions

View file

@ -3,14 +3,15 @@ package com.tangem.domain.marketing.models
import java.math.BigDecimal import java.math.BigDecimal
/** /**
* USD min/max eligibility gate. Applies only to swap/onramp campaigns and only when [amountUsd] is known; * USD min/max eligibility gate (mirrors iOS `satisfiesAmount`). A campaign without min/max bounds is
* otherwise the campaign passes (non-amount screens and the "amount unknown" case are not gated). * always eligible. A bounded campaign requires a known [amountUsd] while the amount is unknown the
* campaign is NOT eligible (hidden until a quote/amount arrives), then it must fall within the bounds.
*/ */
fun MarketingCampaign.matchesUsdAmount(amountUsd: BigDecimal?): Boolean { fun MarketingCampaign.matchesUsdAmount(amountUsd: BigDecimal?): Boolean {
val isAmountScreen = type == MarketingScreenType.SWAP || type == MarketingScreenType.ONRAMP if (minAmount == null && maxAmount == null) return true
if (!isAmountScreen || amountUsd == null) return true
if (minAmount != null && amountUsd < minAmount) return false val usd = amountUsd ?: return false
if (maxAmount != null && amountUsd > maxAmount) return false if (minAmount != null && usd < minAmount) return false
if (maxAmount != null && usd > maxAmount) return false
return true return true
} }

View file

@ -21,16 +21,24 @@ internal class MarketingCampaignAmountTest {
) )
@Test @Test
fun `GIVEN non swap-onramp type WHEN matchesUsdAmount THEN always true`() { fun `GIVEN no min max bounds WHEN matchesUsdAmount THEN always true`() {
val c = campaign(MarketingScreenType.TOKEN_DETAILS, minAmount = BigDecimal(50), maxAmount = BigDecimal(300)) val c = campaign(MarketingScreenType.TOKEN_DETAILS, minAmount = null, maxAmount = null)
assertThat(c.matchesUsdAmount(BigDecimal(10))).isTrue() assertThat(c.matchesUsdAmount(BigDecimal(10))).isTrue()
assertThat(c.matchesUsdAmount(null)).isTrue() assertThat(c.matchesUsdAmount(null)).isTrue()
} }
@Test @Test
fun `GIVEN swap with null amount WHEN matchesUsdAmount THEN true`() { fun `GIVEN bounded campaign of any type WHEN amount out of range THEN false`() {
// Bounds apply regardless of screen type (iOS parity): type no longer exempts a bounded campaign.
val c = campaign(MarketingScreenType.TOKEN_DETAILS, minAmount = BigDecimal(50), maxAmount = BigDecimal(300))
assertThat(c.matchesUsdAmount(BigDecimal(10))).isFalse()
assertThat(c.matchesUsdAmount(BigDecimal(100))).isTrue()
}
@Test
fun `GIVEN bounded campaign with null amount WHEN matchesUsdAmount THEN false`() {
val c = campaign(MarketingScreenType.SWAP, minAmount = BigDecimal(50)) val c = campaign(MarketingScreenType.SWAP, minAmount = BigDecimal(50))
assertThat(c.matchesUsdAmount(null)).isTrue() assertThat(c.matchesUsdAmount(null)).isFalse()
} }
@Test @Test

View file

@ -28,7 +28,9 @@ class GetMarketingBannerUseCase(
campaigns.asSequence() campaigns.asSequence()
.filterNot { it.id in dismissed } .filterNot { it.id in dismissed }
.filter { matchesTarget(it, screen) } .filter { matchesTarget(it, screen) }
.filter { it.matchesUsdAmount(amountUsd) } // Amount gating runs reactively in the consumer (with the live amount). Skip it here when
// no amount is provided, so bounded swap/onramp campaigns aren't dropped on the pre-fetch.
.filter { amountUsd == null || it.matchesUsdAmount(amountUsd) }
.sortedBy { it.priority } .sortedBy { it.priority }
.toList() .toList()
} }

View file

@ -14,4 +14,9 @@ dependencies {
implementation(projects.domain.marketing.models) implementation(projects.domain.marketing.models)
implementation(deps.kotlin.coroutines) implementation(deps.kotlin.coroutines)
// The interface exposes a @Composable LinkedContent function, so the module needs the Compose compiler
// (enabled via the module allowlist in the configuration convention plugin) and these APIs.
api(deps.compose.runtime)
api(deps.compose.ui)
} }

View file

@ -1,11 +1,29 @@
package com.tangem.features.marketing.api package com.tangem.features.marketing.api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.decompose.ComposableContentComponent
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface MarketingBannerComponent : ComposableContentComponent { interface MarketingBannerComponent : ComposableContentComponent {
/**
* Renders the LINKED_TO_PROVIDER banner for the offer identified by [providerId] (the row this sits
* next to). Shows nothing when no linked campaign targets that provider. No-op for standalone banners.
*/
@Composable
fun LinkedContent(providerId: String, modifier: Modifier) {
// Default no-op: only the LINKED_TO_PROVIDER implementation renders a banner.
}
/**
* Whether a LINKED_TO_PROVIDER banner is available for [providerId]. The host uses this to glue the
* banner to the offer (e.g. squaring the offer's bottom corners). Always `false` for standalone banners.
*/
@Composable
fun hasLinkedBanner(providerId: String): Boolean = false
sealed interface Params { sealed interface Params {
/** /**
@ -20,8 +38,8 @@ interface MarketingBannerComponent : ComposableContentComponent {
val onDeeplinkClick: ((deeplink: String) -> Boolean)? = null, val onDeeplinkClick: ((deeplink: String) -> Boolean)? = null,
) : Params ) : Params
/** LINKED_TO_PROVIDER single banner rendered inline next to an onramp provider offer. */ /** LINKED single banner rendered inline next to a host item (currently an onramp provider offer). */
data class LinkedToProvider(val requestFlow: Flow<LinkedBannerRequest?>) : Params data class Linked(val requestFlow: Flow<LinkedBannerRequest?>) : Params
} }
interface Factory : ComponentFactory<Params, MarketingBannerComponent> interface Factory : ComponentFactory<Params, MarketingBannerComponent>

View file

@ -9,9 +9,11 @@ data class MarketingBannerRequest(
val amountUsd: BigDecimal? = null, val amountUsd: BigDecimal? = null,
) )
/** Context for a LINKED_TO_PROVIDER banner request (onramp only), matched against the shown provider. */ /**
* Context for LINKED_TO_PROVIDER banner requests (onramp only). Provider matching happens per offer at
* render time via [MarketingBannerComponent.LinkedContent], so the request carries no provider id.
*/
data class LinkedBannerRequest( data class LinkedBannerRequest(
val screen: MarketingScreen.Onramp, val screen: MarketingScreen.Onramp,
val amountUsd: BigDecimal?, val amountUsd: BigDecimal?,
val currentProviderId: String,
) )

View file

@ -8,7 +8,9 @@ import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.marketing.api.MarketingBannerComponent import com.tangem.features.marketing.api.MarketingBannerComponent
import com.tangem.features.marketing.impl.model.MarketingBannerModel import com.tangem.features.marketing.impl.model.MarketingBannerModel
import com.tangem.features.marketing.impl.ui.LinkedMarketingBanner
import com.tangem.features.marketing.impl.ui.MarketingBannerContent import com.tangem.features.marketing.impl.ui.MarketingBannerContent
import com.tangem.features.marketing.impl.ui.state.MarketingBannerListUM
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
@ -31,6 +33,26 @@ internal class DefaultMarketingBannerComponent @AssistedInject constructor(
) )
} }
@Composable
override fun LinkedContent(providerId: String, modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
val banner = (state as? MarketingBannerListUM.Content)
?.banners
?.firstOrNull { providerId in it.providerIds }
?: return
LinkedMarketingBanner(
banner = banner,
onClick = { model.onBannerClick(banner.deeplink) },
modifier = modifier,
)
}
@Composable
override fun hasLinkedBanner(providerId: String): Boolean {
val state by model.uiState.collectAsStateWithLifecycle()
return (state as? MarketingBannerListUM.Content)?.banners?.any { providerId in it.providerIds } == true
}
@AssistedFactory @AssistedFactory
interface Factory : MarketingBannerComponent.Factory { interface Factory : MarketingBannerComponent.Factory {
override fun create( override fun create(

View file

@ -68,17 +68,11 @@ internal class MarketingBannerModel @Inject constructor(
val requestFlow: Flow<MarketingRequest?> = when (val p = params) { val requestFlow: Flow<MarketingRequest?> = when (val p = params) {
is MarketingBannerComponent.Params.Standalone -> is MarketingBannerComponent.Params.Standalone ->
p.requestFlow.map { request -> p.requestFlow.map { request ->
request?.let { MarketingRequest(screen = it.screen, amountUsd = it.amountUsd, providerId = null) } request?.let { MarketingRequest(screen = it.screen, amountUsd = it.amountUsd) }
} }
is MarketingBannerComponent.Params.LinkedToProvider -> is MarketingBannerComponent.Params.Linked ->
p.requestFlow.map { request -> p.requestFlow.map { request ->
request?.let { linked -> request?.let { MarketingRequest(screen = it.screen, amountUsd = it.amountUsd) }
MarketingRequest(
screen = linked.screen,
amountUsd = linked.amountUsd,
providerId = linked.currentProviderId,
)
}
} }
} }
@ -89,19 +83,17 @@ internal class MarketingBannerModel @Inject constructor(
.mapLatest { screen -> if (screen != null) fetch(screen) else emptyList() } .mapLatest { screen -> if (screen != null) fetch(screen) else emptyList() }
val amountUsd: Flow<BigDecimal?> = requestFlow.map { it?.amountUsd }.distinctUntilChanged() val amountUsd: Flow<BigDecimal?> = requestFlow.map { it?.amountUsd }.distinctUntilChanged()
val providerId: Flow<String?> = requestFlow.map { it?.providerId }.distinctUntilChanged()
modelScope.launch { modelScope.launch {
combine( combine(
flow = campaigns, flow = campaigns,
flow2 = amountUsd, flow2 = amountUsd,
flow3 = providerId, flow3 = dismissedIds,
flow4 = dismissedIds, ) { list, usd, dismissed ->
) { list, usd, provider, dismissed ->
list.asSequence() list.asSequence()
.filterNot { it.id in dismissed } .filterNot { it.id in dismissed }
.filter { it.matchesUsdAmount(usd) } .filter { it.matchesUsdAmount(usd) }
.filter { matchesUiTypeAndProvider(it, provider) } .filter { matchesUiType(it) }
.map { it.toUM() } .map { it.toUM() }
.toList() .toList()
}.collect { banners -> }.collect { banners ->
@ -117,18 +109,16 @@ internal class MarketingBannerModel @Inject constructor(
private suspend fun fetch(screen: MarketingScreen): List<MarketingCampaign> = private suspend fun fetch(screen: MarketingScreen): List<MarketingCampaign> =
getMarketingBanner(screen, amountUsd = null).getOrElse { emptyList() } getMarketingBanner(screen, amountUsd = null).getOrElse { emptyList() }
private fun matchesUiTypeAndProvider(campaign: MarketingCampaign, providerId: String?): Boolean = when (params) { private fun matchesUiType(campaign: MarketingCampaign): Boolean = when (params) {
is MarketingBannerComponent.Params.Standalone -> is MarketingBannerComponent.Params.Standalone ->
campaign.banner.uiType == MarketingBanner.UiType.STANDALONE campaign.banner.uiType == MarketingBanner.UiType.STANDALONE
is MarketingBannerComponent.Params.LinkedToProvider -> is MarketingBannerComponent.Params.Linked ->
campaign.banner.uiType == MarketingBanner.UiType.LINKED_TO_PROVIDER && campaign.banner.uiType == MarketingBanner.UiType.LINKED_TO_PROVIDER
providerId != null && campaign.providerIds?.contains(providerId) == true
} }
private data class MarketingRequest( private data class MarketingRequest(
val screen: MarketingScreen, val screen: MarketingScreen,
val amountUsd: BigDecimal?, val amountUsd: BigDecimal?,
val providerId: String?,
) )
private fun MarketingCampaign.toUM() = MarketingBannerUM( private fun MarketingCampaign.toUM() = MarketingBannerUM(
@ -141,6 +131,7 @@ internal class MarketingBannerModel @Inject constructor(
}, },
isDismissible = banner.isDismissible, isDismissible = banner.isDismissible,
deeplink = banner.deeplink, deeplink = banner.deeplink,
providerIds = providerIds?.toSet().orEmpty(),
) )
private companion object { private companion object {

View file

@ -0,0 +1,105 @@
package com.tangem.features.marketing.impl.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.marketing.impl.ui.state.MarketingBannerUM
private val BOTTOM_CORNER_RADIUS = 20.dp
private val ICON_SIZE = 16.dp
// DS3 has no dedicated "blue 10%" background token; derive it from the accent blue to match Figma
// (rgba(0,153,255,0.1)).
private const val BACKGROUND_ALPHA = 0.1f
/**
* LINKED_TO_PROVIDER marketing banner a compact accent strip glued to the bottom of an onramp provider
* offer. Distinct from the standalone [MarketingBanner]: blue accent background, bottom-only rounded
* corners, a 16dp icon and blue title, no dismiss button.
*
* [Figma](https://www.figma.com/design/GhMZiR8xGeGSmaLinuE5qq/Onramp?node-id=401-84213&m=dev)
*/
@Composable
internal fun LinkedMarketingBanner(banner: MarketingBannerUM, onClick: () -> Unit, modifier: Modifier = Modifier) {
val hasDeeplink = !banner.deeplink.isNullOrBlank()
// Collapse the icon slot when the image fails to load, so a broken URL leaves no empty gap.
var isIconFailed by remember(banner.iconUrl) { mutableStateOf(false) }
val hasIcon = !banner.iconUrl.isNullOrBlank() && !isIconFailed
Row(
modifier = modifier
.clip(RoundedCornerShape(bottomStart = BOTTOM_CORNER_RADIUS, bottomEnd = BOTTOM_CORNER_RADIUS))
.then(if (hasDeeplink) Modifier.clickableSingle(onClick = onClick) else Modifier)
.background(TangemTheme.colors3.bg.accent.blue.copy(alpha = BACKGROUND_ALPHA))
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (hasIcon) {
SubcomposeAsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(banner.iconUrl)
.crossfade(true)
.build(),
contentDescription = null,
contentScale = ContentScale.Fit,
colorFilter = ColorFilter.tint(TangemTheme.colors3.icon.accent.blue),
onError = { isIconFailed = true },
modifier = Modifier.size(ICON_SIZE),
)
}
Text(
text = banner.text.orEmpty(),
style = TangemTheme.typography2.subheadlineMedium14,
color = TangemTheme.colors3.text.accent.blue,
)
}
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_LinkedMarketingBanner() {
TangemThemePreviewRedesign {
LinkedMarketingBanner(
banner = MarketingBannerUM(
campaignId = 1,
text = "1:1 onramp at 0 fees!",
iconUrl = null,
iconAlign = MarketingBannerUM.IconAlign.LEFT,
isDismissible = false,
deeplink = "tangem://buy",
providerIds = setOf("mercuryo"),
),
onClick = {},
modifier = Modifier.padding(16.dp),
)
}
}
// endregion

View file

@ -10,6 +10,7 @@ internal data class MarketingBannerUM(
val iconAlign: IconAlign, val iconAlign: IconAlign,
val isDismissible: Boolean, val isDismissible: Boolean,
val deeplink: String?, val deeplink: String?,
val providerIds: Set<String> = emptySet(),
) { ) {
enum class IconAlign { LEFT, RIGHT } enum class IconAlign { LEFT, RIGHT }
} }

View file

@ -177,7 +177,7 @@ internal class MarketingBannerModelTest {
} }
@Test @Test
fun `GIVEN linked campaigns WHEN provider matches THEN only matching LINKED banner shown`() = runTest { fun `GIVEN linked campaigns WHEN request emitted THEN all LINKED banners shown with providerIds`() = runTest {
// Arrange // Arrange
coEvery { getMarketingBanner(onrampScreen, null) } returns listOf( coEvery { getMarketingBanner(onrampScreen, null) } returns listOf(
campaign(1, MarketingBanner.UiType.LINKED_TO_PROVIDER, providerIds = listOf("mercuryo")), campaign(1, MarketingBanner.UiType.LINKED_TO_PROVIDER, providerIds = listOf("mercuryo")),
@ -185,16 +185,20 @@ internal class MarketingBannerModelTest {
campaign(3, MarketingBanner.UiType.STANDALONE), campaign(3, MarketingBanner.UiType.STANDALONE),
).right() ).right()
val model = createModel( val model = createModel(
MarketingBannerComponent.Params.LinkedToProvider( MarketingBannerComponent.Params.Linked(
flowOf(LinkedBannerRequest(onrampScreen, amountUsd = null, currentProviderId = "mercuryo")), flowOf(LinkedBannerRequest(onrampScreen, amountUsd = null)),
), ),
) )
// Act + Assert // Act + Assert
// Model no longer filters by provider: it emits all LINKED banners (not STANDALONE), carrying their
// providerIds; per-offer provider matching happens at render time in LinkedContent(providerId).
model.uiState.test { model.uiState.test {
advanceUntilIdle() advanceUntilIdle()
val content = expectMostRecentItem() as MarketingBannerListUM.Content val content = expectMostRecentItem() as MarketingBannerListUM.Content
assertThat(content.banners.map { it.campaignId }).containsExactly(1) assertThat(content.banners.map { it.campaignId }).containsExactly(1, 2)
assertThat(content.banners.first { it.campaignId == 1 }.providerIds).containsExactly("mercuryo")
assertThat(content.banners.first { it.campaignId == 2 }.providerIds).containsExactly("moonpay")
} }
} }

View file

@ -21,6 +21,8 @@ dependencies {
implementation(projects.features.swap.domain.api) implementation(projects.features.swap.domain.api)
implementation(projects.features.swap.domain.models) implementation(projects.features.swap.domain.models)
implementation(projects.features.feed.api) implementation(projects.features.feed.api)
implementation(projects.features.marketing.api)
/** Project - Core */ /** Project - Core */
implementation(projects.core.analytics) implementation(projects.core.analytics)
@ -57,6 +59,8 @@ dependencies {
implementation(projects.domain.appTheme.models) implementation(projects.domain.appTheme.models)
implementation(projects.data.common) implementation(projects.data.common)
implementation(projects.domain.markets) implementation(projects.domain.markets)
implementation(projects.domain.marketing.models)
implementation(projects.domain.quotes)
/** DI */ /** DI */
implementation(deps.hilt.android) implementation(deps.hilt.android)

View file

@ -154,6 +154,7 @@ internal class AllOffersStateFactory(
category = OnrampOfferCategoryUM.Recommended, category = OnrampOfferCategoryUM.Recommended,
advantages = mapOfferAdvantagesDTOtoUM(offer.advantages), advantages = mapOfferAdvantagesDTOtoUM(offer.advantages),
paymentMethod = quote.paymentMethod, paymentMethod = quote.paymentMethod,
providerId = quote.provider.id,
providerName = quote.provider.info.name, providerName = quote.provider.info.name,
rate = formatCryptoAmount(quote.toAmount), rate = formatCryptoAmount(quote.toAmount),
diff = formatRateDiff(offer.rateDif), diff = formatRateDiff(offer.rateDif),
@ -185,6 +186,7 @@ internal class AllOffersStateFactory(
category = OnrampOfferCategoryUM.Recommended, category = OnrampOfferCategoryUM.Recommended,
advantages = advantages, advantages = advantages,
paymentMethod = quote.paymentMethod, paymentMethod = quote.paymentMethod,
providerId = quote.provider.id,
providerName = quote.provider.info.name, providerName = quote.provider.info.name,
rate = formatRequiredAmount(quote, currencyCode), rate = formatRequiredAmount(quote, currencyCode),
diff = formatRateDiff(offer.rateDif), 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", imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD, type = PaymentMethodType.CARD,
), ),
providerId = "simplex",
providerName = "Simplex", providerName = "Simplex",
rate = "0,0245334 BTC", rate = "0,0245334 BTC",
diff = null, diff = null,
@ -219,6 +220,7 @@ private fun AllOffersContentSheetPaymentPreview() {
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD, type = PaymentMethodType.CARD,
), ),
providerId = "simplex",
providerName = "Simplex", providerName = "Simplex",
rate = "0,00145334 BTC", rate = "0,00145334 BTC",
diff = stringReference("0.07%"), 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", "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD, type = PaymentMethodType.CARD,
), ),
providerId = "simplex",
providerName = "Simplex", providerName = "Simplex",
rate = "0,0245334 BTC", rate = "0,0245334 BTC",
diff = null, diff = null,
@ -285,6 +288,7 @@ private fun AllOffersContentSheetOffersPreview() {
"https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD, type = PaymentMethodType.CARD,
), ),
providerId = "simplex",
providerName = "Simplex", providerName = "Simplex",
rate = "0,00145334 BTC", rate = "0,00145334 BTC",
diff = stringReference("0.07%"), 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", imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD, type = PaymentMethodType.CARD,
), ),
providerId = "simplex",
providerName = "Simplex", providerName = "Simplex",
rate = "0,0245334 BTC", rate = "0,0245334 BTC",
diff = null, diff = null,
@ -287,6 +288,7 @@ private fun PaymentMethodsContentPreview() {
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD, type = PaymentMethodType.CARD,
), ),
providerId = "simplex",
providerName = "Simplex", providerName = "Simplex",
rate = "0,00145334 BTC", rate = "0,00145334 BTC",
diff = stringReference("0.07%"), 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.decompose.router.slot.dismiss
import com.arkivanov.essenty.lifecycle.subscribe import com.arkivanov.essenty.lifecycle.subscribe
import com.tangem.core.decompose.context.AppComponentContext 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.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent 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.alloffers.AllOffersComponent
import com.tangem.features.onramp.confirmresidency.ConfirmResidencyComponent import com.tangem.features.onramp.confirmresidency.ConfirmResidencyComponent
import com.tangem.features.onramp.main.entity.OnrampMainBottomSheetConfig import com.tangem.features.onramp.main.entity.OnrampMainBottomSheetConfig
@ -29,10 +31,24 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor(
private val confirmResidencyComponentFactory: ConfirmResidencyComponent.Factory, private val confirmResidencyComponentFactory: ConfirmResidencyComponent.Factory,
private val selectCurrencyComponentFactory: SelectCurrencyComponent.Factory, private val selectCurrencyComponentFactory: SelectCurrencyComponent.Factory,
private val allOffersComponentFactory: AllOffersComponent.Factory, private val allOffersComponentFactory: AllOffersComponent.Factory,
private val marketingBannerComponentFactory: MarketingBannerComponent.Factory,
) : OnrampMainComponent, AppComponentContext by appComponentContext { ) : OnrampMainComponent, AppComponentContext by appComponentContext {
private val model: OnrampMainComponentModel = getOrCreateModel(params) 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 { init {
lifecycle.subscribe(onStop = model::onStop) lifecycle.subscribe(onStop = model::onStop)
} }
@ -49,7 +65,12 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor(
val state by model.state.collectAsState() val state by model.state.collectAsState()
val bottomSheet by bottomSheetSlot.subscribeAsState() val bottomSheet by bottomSheetSlot.subscribeAsState()
OnrampMainScreen(modifier = modifier, state = state) OnrampMainScreen(
modifier = modifier,
state = state,
marketingBannerComponent = marketingBannerComponent,
linkedMarketingBannerComponent = linkedMarketingBannerComponent,
)
bottomSheet.child?.instance?.BottomSheet() bottomSheet.child?.instance?.BottomSheet()
} }

View file

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

View file

@ -44,6 +44,7 @@ internal class OnrampOffersStateFactory(
category = category, category = category,
advantages = advantages, advantages = advantages,
paymentMethod = currentQuote.paymentMethod, paymentMethod = currentQuote.paymentMethod,
providerId = currentQuote.provider.id,
providerName = currentQuote.provider.info.name, providerName = currentQuote.provider.info.name,
rate = currentQuote.toAmount.value.format { rate = currentQuote.toAmount.value.format {
crypto( 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.SlotNavigation
import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss 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.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router 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.core.ui.components.fields.InputManager
import com.tangem.domain.demo.IsDemoCardUseCase import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.exchange.RampStateManager 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.*
import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent
import com.tangem.domain.onramp.model.OnrampAvailability 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.OnrampProviderWithQuote
import com.tangem.domain.onramp.model.OnrampQuote import com.tangem.domain.onramp.model.OnrampQuote
import com.tangem.domain.onramp.model.error.OnrampError 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.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.wallets.usecase.GetWalletsUseCase 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.OnrampMainComponent
import com.tangem.features.onramp.main.entity.* import com.tangem.features.onramp.main.entity.*
import com.tangem.features.onramp.main.entity.factory.OnrampAmountButtonUMStateFactory 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 com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.math.BigDecimal
import javax.inject.Inject import javax.inject.Inject
@Suppress("LongParameterList", "LargeClass") @Suppress("LongParameterList", "LargeClass")
@ -56,6 +65,7 @@ internal class OnrampMainComponentModel @Inject constructor(
private val getOnrampOffersUseCase: GetOnrampOffersUseCase, private val getOnrampOffersUseCase: GetOnrampOffersUseCase,
private val isDemoCardUseCase: IsDemoCardUseCase, private val isDemoCardUseCase: IsDemoCardUseCase,
private val messageSender: UiMessageSender, private val messageSender: UiMessageSender,
private val getCurrencyUSDQuoteUseCase: GetCurrencyUSDQuoteUseCase,
paramsContainer: ParamsContainer, paramsContainer: ParamsContainer,
getWalletsUseCase: GetWalletsUseCase, getWalletsUseCase: GetWalletsUseCase,
) : Model(), OnrampIntents { ) : 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) { private val amountStateFactory: OnrampAmountStateFactory by lazy(LazyThreadSafetyMode.NONE) {
OnrampAmountStateFactory( OnrampAmountStateFactory(
currentStateProvider = Provider { state.value }, currentStateProvider = Provider { state.value },
@ -125,6 +197,16 @@ internal class OnrampMainComponentModel @Inject constructor(
super.onDestroy() 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) { override fun onAmountValueChanged(value: String) {
state.update { amountStateFactory.getOnAmountValueChange(value) } state.update { amountStateFactory.getOnAmountValueChange(value) }
modelScope.launch { amountInputManager.update(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>) { private fun sendOnrampQuotesErrorAnalytic(quotes: List<OnrampQuote>) {
quotes.forEach { errorState -> quotes.forEach { errorState ->
when (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.ds.message.TangemMessage
import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemeRedesign
import com.tangem.core.ui.utils.WindowInsetsZero import com.tangem.core.ui.utils.WindowInsetsZero
import com.tangem.features.marketing.api.MarketingBannerComponent
import com.tangem.features.onramp.main.entity.OnrampMainComponentUM import com.tangem.features.onramp.main.entity.OnrampMainComponentUM
@Composable @Composable
internal fun OnrampMainScreen(state: OnrampMainComponentUM, modifier: Modifier = Modifier) { internal fun OnrampMainScreen(
state: OnrampMainComponentUM,
marketingBannerComponent: MarketingBannerComponent,
linkedMarketingBannerComponent: MarketingBannerComponent,
modifier: Modifier = Modifier,
) {
Scaffold( Scaffold(
modifier = modifier.systemBarsPadding(), modifier = modifier.systemBarsPadding(),
topBar = { topBar = {
@ -36,13 +43,20 @@ internal fun OnrampMainScreen(state: OnrampMainComponentUM, modifier: Modifier =
) { scaffoldPaddings -> ) { scaffoldPaddings ->
OnrampMainComponentContent( OnrampMainComponentContent(
state = state, state = state,
marketingBannerComponent = marketingBannerComponent,
linkedMarketingBannerComponent = linkedMarketingBannerComponent,
modifier = Modifier.padding(scaffoldPaddings), modifier = Modifier.padding(scaffoldPaddings),
) )
} }
} }
@Composable @Composable
internal fun OnrampMainComponentContent(state: OnrampMainComponentUM, modifier: Modifier = Modifier) { internal fun OnrampMainComponentContent(
state: OnrampMainComponentUM,
marketingBannerComponent: MarketingBannerComponent,
linkedMarketingBannerComponent: MarketingBannerComponent,
modifier: Modifier = Modifier,
) {
Box( Box(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
@ -56,7 +70,11 @@ internal fun OnrampMainComponentContent(state: OnrampMainComponentUM, modifier:
) { ) {
when (state) { when (state) {
is OnrampMainComponentUM.InitialLoading -> InitialLoading(state = 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 @Composable
private fun Content(state: OnrampMainComponentUM.Content, modifier: Modifier = Modifier) { private fun Content(
state: OnrampMainComponentUM.Content,
marketingBannerComponent: MarketingBannerComponent,
linkedMarketingBannerComponent: MarketingBannerComponent,
modifier: Modifier = Modifier,
) {
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
@ -133,7 +156,14 @@ private fun Content(state: OnrampMainComponentUM.Content, modifier: Modifier = M
) { ) {
OnrampAmountContent(state = state) OnrampAmountContent(state = state)
OnrampOffersContent(state = state.offersBlockState) TangemThemeRedesign {
marketingBannerComponent.Content(Modifier.fillMaxWidth())
}
OnrampOffersContent(
state = state.offersBlockState,
linkedMarketingBannerComponent = linkedMarketingBannerComponent,
)
OnrampNotifications(state = state) 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.extensions.*
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemThemeRedesign
import com.tangem.core.ui.test.OnrampOffersBlockTestTags import com.tangem.core.ui.test.OnrampOffersBlockTestTags
import com.tangem.domain.onramp.model.OnrampPaymentMethod import com.tangem.domain.onramp.model.OnrampPaymentMethod
import com.tangem.domain.onramp.model.PaymentMethodType 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.impl.R
import com.tangem.features.onramp.main.entity.* import com.tangem.features.onramp.main.entity.*
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@Composable @Composable
internal fun OnrampOffersContent(state: OnrampOffersBlockUM) { internal fun OnrampOffersContent(state: OnrampOffersBlockUM, linkedMarketingBannerComponent: MarketingBannerComponent) {
when (state) { when (state) {
is OnrampOffersBlockUM.Content -> { is OnrampOffersBlockUM.Content -> {
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
@ -52,7 +54,7 @@ internal fun OnrampOffersContent(state: OnrampOffersBlockUM) {
SpacerH(8.dp) SpacerH(8.dp)
Offer(recentOffer) OfferWithLinkedBanner(recentOffer, linkedMarketingBannerComponent)
SpacerH(16.dp) SpacerH(16.dp)
} }
@ -74,7 +76,7 @@ internal fun OnrampOffersContent(state: OnrampOffersBlockUM) {
state.recommended.fastForEach { offer -> state.recommended.fastForEach { offer ->
key("${offer.paymentMethod.id} ${offer.providerName} ${offer.rate}") { key("${offer.paymentMethod.id} ${offer.providerName} ${offer.rate}") {
Offer(offer) OfferWithLinkedBanner(offer, linkedMarketingBannerComponent)
SpacerH(8.dp) SpacerH(8.dp)
} }
} }
@ -102,13 +104,34 @@ internal fun OnrampOffersContent(state: OnrampOffersBlockUM) {
} }
@Composable @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( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.background( .background(
color = TangemTheme.colors.background.action, color = TangemTheme.colors.background.action,
shape = RoundedCornerShape(14.dp), shape = shape,
) )
.padding(12.dp), .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", imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD, type = PaymentMethodType.CARD,
), ),
providerId = "simplex",
providerName = "Simplex", providerName = "Simplex",
rate = "0,00045334 BTC", rate = "0,00045334 BTC",
diff = stringReference("27%"), 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", imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD, type = PaymentMethodType.CARD,
), ),
providerId = "simplex",
providerName = "Simplex", providerName = "Simplex",
rate = "0,0245334 BTC", rate = "0,0245334 BTC",
diff = null, diff = null,
@ -415,6 +440,7 @@ private fun OnrampOffersContentPreview() {
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD, type = PaymentMethodType.CARD,
), ),
providerId = "simplex",
providerName = "Simplex", providerName = "Simplex",
rate = "0,00145334 BTC", rate = "0,00145334 BTC",
diff = stringReference("0.07%"), diff = stringReference("0.07%"),
@ -427,7 +453,10 @@ private fun OnrampOffersContentPreview() {
), ),
) )
TangemThemePreview { TangemThemePreview {
OnrampOffersContent(state) OnrampOffersContent(state = state, linkedMarketingBannerComponent = object : MarketingBannerComponent {
@Composable
override fun Content(modifier: Modifier) = Unit
})
} }
} }
@ -436,6 +465,12 @@ private fun OnrampOffersContentPreview() {
@Composable @Composable
private fun OnrampOffersLoadingPreview() { private fun OnrampOffersLoadingPreview() {
TangemThemePreview { TangemThemePreview {
OnrampOffersContent(OnrampOffersBlockUM.Loading) OnrampOffersContent(
state = OnrampOffersBlockUM.Loading,
linkedMarketingBannerComponent = object : MarketingBannerComponent {
@Composable
override fun Content(modifier: Modifier) = Unit
},
)
} }
} }

View file

@ -50,6 +50,9 @@ dependencies {
implementation(projects.domain.feedback) implementation(projects.domain.feedback)
implementation(projects.domain.feedback.models) implementation(projects.domain.feedback.models)
implementation(projects.domain.stories) implementation(projects.domain.stories)
implementation(projects.domain.marketing.models)
implementation(projects.domain.markets.models)
implementation(projects.domain.quotes)
implementation(projects.domain.stories.models) implementation(projects.domain.stories.models)
implementation(projects.domain.txhistory) implementation(projects.domain.txhistory)
implementation(projects.domain.txhistory.models) implementation(projects.domain.txhistory.models)
@ -71,6 +74,7 @@ dependencies {
implementation(projects.features.send.api) implementation(projects.features.send.api)
implementation(projects.features.send.impl) implementation(projects.features.send.impl)
implementation(projects.features.feed.api) implementation(projects.features.feed.api)
implementation(projects.features.marketing.api)
/** AndroidX */ /** AndroidX */
implementation(deps.androidx.activity.compose) implementation(deps.androidx.activity.compose)

View file

@ -17,6 +17,7 @@ import com.arkivanov.decompose.router.stack.childStack
import com.arkivanov.decompose.router.stack.pop import com.arkivanov.decompose.router.stack.pop
import com.arkivanov.essenty.lifecycle.subscribe import com.arkivanov.essenty.lifecycle.subscribe
import com.tangem.core.decompose.context.AppComponentContext 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.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.decompose.navigation.inner.InnerRouter import com.tangem.core.decompose.navigation.inner.InnerRouter
@ -31,6 +32,7 @@ import com.tangem.feature.swap.ui.SwapScreen
import com.tangem.feature.swap.ui.SwapSuccessScreen import com.tangem.feature.swap.ui.SwapSuccessScreen
import com.tangem.features.approval.api.GiveApprovalEntryComponent import com.tangem.features.approval.api.GiveApprovalEntryComponent
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent
import com.tangem.features.marketing.api.MarketingBannerComponent
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.swap.SwapComponent import com.tangem.features.swap.SwapComponent
import com.tangem.utils.isNullOrZero import com.tangem.utils.isNullOrZero
@ -46,6 +48,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
private val swapFeeSelectorBlockComponentFactory: SwapFeeSelectorBlockComponent.Factory, private val swapFeeSelectorBlockComponentFactory: SwapFeeSelectorBlockComponent.Factory,
private val giveApprovalEntryComponentFactory: GiveApprovalEntryComponent.Factory, private val giveApprovalEntryComponentFactory: GiveApprovalEntryComponent.Factory,
private val chooseTokenComponentFactory: ChooseTokenComponent.Factory, private val chooseTokenComponentFactory: ChooseTokenComponent.Factory,
private val marketingBannerComponentFactory: MarketingBannerComponent.Factory,
) : SwapComponent, AppComponentContext by appComponentContext { ) : SwapComponent, AppComponentContext by appComponentContext {
private val stackNavigation = StackNavigation<SwapRoute>() private val stackNavigation = StackNavigation<SwapRoute>()
@ -56,6 +59,14 @@ internal class DefaultSwapComponent @AssistedInject constructor(
private val model: SwapModel = getOrCreateModel(params, router = innerRouter) private val model: SwapModel = getOrCreateModel(params, router = innerRouter)
private val marketingBannerComponent: MarketingBannerComponent = marketingBannerComponentFactory.create(
context = child("marketing_banner"),
params = MarketingBannerComponent.Params.Standalone(
requestFlow = model.marketingRequest,
onDeeplinkClick = model::onMarketingBannerDeeplink,
),
)
private val childStack = childStack( private val childStack = childStack(
key = STACK_KEY, key = STACK_KEY,
source = stackNavigation, source = stackNavigation,
@ -215,6 +226,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
SwapScreen( SwapScreen(
stateHolder = model.uiState, stateHolder = model.uiState,
feeSelectorBlockComponent = feeSelectorBlockComponent, feeSelectorBlockComponent = feeSelectorBlockComponent,
marketingBannerComponent = marketingBannerComponent,
) )
} }
} }
@ -236,6 +248,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
SwapScreen( SwapScreen(
stateHolder = model.uiState, stateHolder = model.uiState,
feeSelectorBlockComponent = feeSelectorBlockComponent, feeSelectorBlockComponent = feeSelectorBlockComponent,
marketingBannerComponent = marketingBannerComponent,
) )
} }
} }

View file

@ -16,6 +16,8 @@ import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter import com.tangem.common.routing.AppRouter
import com.tangem.common.routing.deeplink.resolveMarketingDeeplink
import com.tangem.common.routing.deeplink.toContextualRoute
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.core.analytics.api.AnalyticsErrorHandler import com.tangem.core.analytics.api.AnalyticsErrorHandler
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
@ -55,6 +57,7 @@ import com.tangem.domain.feedback.SendBackupProblemEmailUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.BlockchainErrorInfo import com.tangem.domain.feedback.models.BlockchainErrorInfo
import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.marketing.models.MarketingScreen
import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.derivationIndex import com.tangem.domain.models.account.derivationIndex
@ -65,6 +68,7 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isHotWallet import com.tangem.domain.models.wallet.isHotWallet
import com.tangem.domain.pay.WithdrawalResult import com.tangem.domain.pay.WithdrawalResult
import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase
import com.tangem.domain.quotes.GetCurrencyUSDQuoteUseCase
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
import com.tangem.domain.settings.usercountry.models.UserCountry import com.tangem.domain.settings.usercountry.models.UserCountry
import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions
@ -109,6 +113,7 @@ import com.tangem.features.approval.api.SelectApprovalTypeComponent
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
import com.tangem.features.marketing.api.MarketingBannerRequest
import com.tangem.features.send.api.entity.FeeItem import com.tangem.features.send.api.entity.FeeItem
import com.tangem.features.send.api.entity.FeeSelectorUM import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
@ -224,6 +229,30 @@ internal class SwapModel @Inject constructor(
dataStateStateFlow.value = value dataStateStateFlow.value = value
} }
/**
* Request flow for the STANDALONE marketing banner shown on the swap screen.
* Derives [MarketingScreen.Swap] from the live [dataStateStateFlow]; emits null until both the FROM and TO
* currencies are chosen. [amountUsd] is the entered FROM amount converted via the FROM token's USD rate
* ([getCurrencyUSDQuoteUseCase]); it stays null until both the amount and the USD quote are available.
*/
val marketingRequest: Flow<MarketingBannerRequest?> = dataStateStateFlow.map { data ->
val fromCurrency = data.fromSwapCurrencyStatus?.currency ?: return@map null
val toCurrency = data.toSwapCurrencyStatus?.currency ?: return@map null
val amountUsd = data.amount?.toBigDecimalOrNull()?.let { fromAmount ->
val rawCurrencyId = fromCurrency.id.rawCurrencyId ?: return@let null
getCurrencyUSDQuoteUseCase(rawCurrencyId)?.let { usdRate -> fromAmount * usdRate }
}
MarketingBannerRequest(
screen = MarketingScreen.Swap(
fromNetwork = fromCurrency.network.rawId,
fromContractAddress = (fromCurrency as? CryptoCurrency.Token)?.contractAddress.orEmpty(),
toNetwork = toCurrency.network.rawId,
toContractAddress = (toCurrency as? CryptoCurrency.Token)?.contractAddress.orEmpty(),
),
amountUsd = amountUsd,
)
}
var uiState: SwapStateHolder by mutableStateOf(stateBuilder.createInitialLoadingState()) var uiState: SwapStateHolder by mutableStateOf(stateBuilder.createInitialLoadingState())
internal set internal set
@ -353,6 +382,17 @@ internal class SwapModel @Inject constructor(
} }
} }
fun onMarketingBannerDeeplink(deeplink: String): Boolean {
val fromCurrency = dataState.fromSwapCurrencyStatus?.currency ?: return false
val route = resolveMarketingDeeplink(deeplink).toContextualRoute(
userWalletId = params.userWalletId,
currency = fromCurrency,
screenSource = ScreensSources.Swap,
) ?: return false
appRouter.push(route)
return true
}
fun onStart() { fun onStart() {
startLoadingQuotesFromLastState(true) startLoadingQuotesFromLastState(true)
} }

View file

@ -31,6 +31,7 @@ import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon
import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenu import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenu
import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemeRedesign
import com.tangem.core.ui.test.SwapTokenScreenTestTags import com.tangem.core.ui.test.SwapTokenScreenTestTags
import com.tangem.core.ui.utils.WindowInsetsZero import com.tangem.core.ui.utils.WindowInsetsZero
import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent
@ -38,9 +39,14 @@ import com.tangem.feature.swap.domain.models.domain.SwapUIMode
import com.tangem.feature.swap.models.SwapStateHolder import com.tangem.feature.swap.models.SwapStateHolder
import com.tangem.feature.swap.models.states.ChooseProviderBottomSheetConfig import com.tangem.feature.swap.models.states.ChooseProviderBottomSheetConfig
import com.tangem.feature.swap.presentation.R import com.tangem.feature.swap.presentation.R
import com.tangem.features.marketing.api.MarketingBannerComponent
@Composable @Composable
internal fun SwapScreen(stateHolder: SwapStateHolder, feeSelectorBlockComponent: SwapFeeSelectorBlockComponent?) { internal fun SwapScreen(
stateHolder: SwapStateHolder,
feeSelectorBlockComponent: SwapFeeSelectorBlockComponent?,
marketingBannerComponent: MarketingBannerComponent? = null,
) {
BackHandler(onBack = stateHolder.onBackClicked) BackHandler(onBack = stateHolder.onBackClicked)
Scaffold( Scaffold(
@ -63,6 +69,15 @@ internal fun SwapScreen(stateHolder: SwapStateHolder, feeSelectorBlockComponent:
} else { } else {
null null
}, },
marketingBanner = if (marketingBannerComponent != null) {
@Composable { modifier: Modifier ->
TangemThemeRedesign {
marketingBannerComponent.Content(modifier)
}
}
} else {
null
},
modifier = Modifier modifier = Modifier
.padding(scaffoldPaddings) .padding(scaffoldPaddings)
.testTag(SwapTokenScreenTestTags.CONTAINER), .testTag(SwapTokenScreenTestTags.CONTAINER),

View file

@ -63,6 +63,7 @@ internal fun SwapScreenContent(
state: SwapStateHolder, state: SwapStateHolder,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
feeBlock: @Composable ((Modifier) -> Unit)? = null, feeBlock: @Composable ((Modifier) -> Unit)? = null,
marketingBanner: @Composable ((Modifier) -> Unit)? = null,
) { ) {
val keyboard by keyboardAsState() val keyboard by keyboardAsState()
@ -86,6 +87,8 @@ internal fun SwapScreenContent(
) { ) {
MainInfo(state) MainInfo(state)
marketingBanner?.invoke(Modifier.fillMaxWidth())
if (state.swapUIMode == SwapUIMode.Simple) { if (state.swapUIMode == SwapUIMode.Simple) {
ProviderItemBlockSimple(state = state.providerState) ProviderItemBlockSimple(state = state.providerState)
} else { } else {

View file

@ -26,6 +26,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase
import com.tangem.domain.quotes.GetCurrencyUSDQuoteUseCase
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
import com.tangem.domain.settings.usercountry.models.UserCountry import com.tangem.domain.settings.usercountry.models.UserCountry
import com.tangem.domain.stories.ShouldShowStoriesUseCase import com.tangem.domain.stories.ShouldShowStoriesUseCase
@ -103,6 +104,7 @@ internal abstract class SwapModelTestBase {
protected val getSwapUiModeUseCase: GetSwapUiModeUseCase = mockk(relaxed = true) protected val getSwapUiModeUseCase: GetSwapUiModeUseCase = mockk(relaxed = true)
protected val setSwapUiModeUseCase: SetSwapUiModeUseCase = mockk(relaxed = true) protected val setSwapUiModeUseCase: SetSwapUiModeUseCase = mockk(relaxed = true)
protected val calculateAmountUseCase: CalculateAmountUseCase = mockk(relaxed = true) protected val calculateAmountUseCase: CalculateAmountUseCase = mockk(relaxed = true)
protected val getCurrencyUSDQuoteUseCase: GetCurrencyUSDQuoteUseCase = mockk(relaxed = true)
protected val isWalletBackupProblematicUseCase: IsWalletBackupProblematicUseCase = mockk(relaxed = true) protected val isWalletBackupProblematicUseCase: IsWalletBackupProblematicUseCase = mockk(relaxed = true)
protected val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase = mockk(relaxed = true) protected val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase = mockk(relaxed = true)
@ -175,6 +177,7 @@ internal abstract class SwapModelTestBase {
getSwapUiModeUseCase = getSwapUiModeUseCase, getSwapUiModeUseCase = getSwapUiModeUseCase,
setSwapUiModeUseCase = setSwapUiModeUseCase, setSwapUiModeUseCase = setSwapUiModeUseCase,
calculateAmountUseCase = calculateAmountUseCase, calculateAmountUseCase = calculateAmountUseCase,
getCurrencyUSDQuoteUseCase = getCurrencyUSDQuoteUseCase,
isWalletBackupProblematicUseCase = isWalletBackupProblematicUseCase, isWalletBackupProblematicUseCase = isWalletBackupProblematicUseCase,
sendBackupProblemEmailUseCase = sendBackupProblemEmailUseCase, sendBackupProblemEmailUseCase = sendBackupProblemEmailUseCase,
) )

View file

@ -31,6 +31,7 @@ internal fun BaseExtension.configureCompose(project: Project) {
contains(Regex(pattern = ":features:manage-tokens:api\$")) || // provides Composable function contains(Regex(pattern = ":features:manage-tokens:api\$")) || // provides Composable function
contains(Regex(pattern = ":features:txhistory:api\$")) || // provides Composable function contains(Regex(pattern = ":features:txhistory:api\$")) || // provides Composable function
contains(Regex(pattern = ":features:promo-banners:api\$")) || // provides Composable function contains(Regex(pattern = ":features:promo-banners:api\$")) || // provides Composable function
contains(Regex(pattern = ":features:marketing:api\$")) || // provides Composable function
contains(Regex(pattern = ":impl\$")) contains(Regex(pattern = ":impl\$"))
} }