From 4754f0677d30f958506e0d6bfc142b018f7a312a Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 14 Jul 2026 15:26:55 +0200 Subject: [PATCH] Updated on 2026-08-14 --- .../models/MarketingCampaignAmount.kt | 13 ++- .../models/MarketingCampaignAmountTest.kt | 16 ++- .../marketing/GetMarketingBannerUseCase.kt | 4 +- features/marketing/api/build.gradle.kts | 5 + .../marketing/api/MarketingBannerComponent.kt | 22 +++- .../marketing/api/MarketingBannerRequest.kt | 6 +- .../impl/DefaultMarketingBannerComponent.kt | 22 ++++ .../impl/model/MarketingBannerModel.kt | 29 ++--- .../impl/ui/LinkedMarketingBanner.kt | 105 ++++++++++++++++++ .../impl/ui/state/MarketingBannerUM.kt | 1 + .../impl/model/MarketingBannerModelTest.kt | 12 +- features/onramp/impl/build.gradle.kts | 3 + .../alloffers/entity/AllOffersStateFactory.kt | 2 + .../alloffers/ui/AllOffersContentSheet.kt | 4 + .../alloffers/ui/PaymentMethodsContent.kt | 2 + .../onramp/main/DefaultOnrampMainComponent.kt | 23 +++- .../onramp/main/entity/OnrampOfferBlockUM.kt | 1 + .../factory/OnrampOffersStateFactory.kt | 1 + .../main/model/OnrampMainComponentModel.kt | 90 +++++++++++++++ .../main/ui/OnrampMainComponentContent.kt | 40 ++++++- .../onramp/main/ui/OnrampOffersContent.kt | 49 ++++++-- features/swap/impl/build.gradle.kts | 3 + .../feature/swap/DefaultSwapComponent.kt | 13 +++ .../tangem/feature/swap/model/SwapModel.kt | 41 +++++++ .../com/tangem/feature/swap/ui/SwapScreen.kt | 17 ++- .../feature/swap/ui/SwapScreenContent.kt | 3 + .../feature/swap/model/SwapModelTestBase.kt | 3 + .../extension/BaseExtensionConfigurations.kt | 1 + 28 files changed, 479 insertions(+), 52 deletions(-) create mode 100644 features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/LinkedMarketingBanner.kt diff --git a/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmount.kt b/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmount.kt index 5fffde6c7d..5edd396f2e 100644 --- a/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmount.kt +++ b/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmount.kt @@ -3,14 +3,15 @@ package com.tangem.domain.marketing.models import java.math.BigDecimal /** - * USD min/max eligibility gate. Applies only to swap/onramp campaigns and only when [amountUsd] is known; - * otherwise the campaign passes (non-amount screens and the "amount unknown" case are not gated). + * USD min/max eligibility gate (mirrors iOS `satisfiesAmount`). A campaign without min/max bounds is + * 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 { - val isAmountScreen = type == MarketingScreenType.SWAP || type == MarketingScreenType.ONRAMP - if (!isAmountScreen || amountUsd == null) return true + if (minAmount == null && maxAmount == null) return true - if (minAmount != null && amountUsd < minAmount) return false - if (maxAmount != null && amountUsd > maxAmount) return false + val usd = amountUsd ?: return false + if (minAmount != null && usd < minAmount) return false + if (maxAmount != null && usd > maxAmount) return false return true } \ No newline at end of file diff --git a/domain/marketing/models/src/test/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmountTest.kt b/domain/marketing/models/src/test/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmountTest.kt index 2e8fa7ebef..2ab57b78a4 100644 --- a/domain/marketing/models/src/test/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmountTest.kt +++ b/domain/marketing/models/src/test/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmountTest.kt @@ -21,16 +21,24 @@ internal class MarketingCampaignAmountTest { ) @Test - fun `GIVEN non swap-onramp type WHEN matchesUsdAmount THEN always true`() { - val c = campaign(MarketingScreenType.TOKEN_DETAILS, minAmount = BigDecimal(50), maxAmount = BigDecimal(300)) + fun `GIVEN no min max bounds WHEN matchesUsdAmount THEN always true`() { + val c = campaign(MarketingScreenType.TOKEN_DETAILS, minAmount = null, maxAmount = null) assertThat(c.matchesUsdAmount(BigDecimal(10))).isTrue() assertThat(c.matchesUsdAmount(null)).isTrue() } @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)) - assertThat(c.matchesUsdAmount(null)).isTrue() + assertThat(c.matchesUsdAmount(null)).isFalse() } @Test diff --git a/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/GetMarketingBannerUseCase.kt b/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/GetMarketingBannerUseCase.kt index 387553c39a..87d54579f0 100644 --- a/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/GetMarketingBannerUseCase.kt +++ b/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/GetMarketingBannerUseCase.kt @@ -28,7 +28,9 @@ class GetMarketingBannerUseCase( campaigns.asSequence() .filterNot { it.id in dismissed } .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 } .toList() } diff --git a/features/marketing/api/build.gradle.kts b/features/marketing/api/build.gradle.kts index 3cfd5dcf11..b0d679e453 100644 --- a/features/marketing/api/build.gradle.kts +++ b/features/marketing/api/build.gradle.kts @@ -14,4 +14,9 @@ dependencies { implementation(projects.domain.marketing.models) 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) } \ No newline at end of file diff --git a/features/marketing/api/src/main/kotlin/com/tangem/features/marketing/api/MarketingBannerComponent.kt b/features/marketing/api/src/main/kotlin/com/tangem/features/marketing/api/MarketingBannerComponent.kt index 9cd5052a66..19dd18fc97 100644 --- a/features/marketing/api/src/main/kotlin/com/tangem/features/marketing/api/MarketingBannerComponent.kt +++ b/features/marketing/api/src/main/kotlin/com/tangem/features/marketing/api/MarketingBannerComponent.kt @@ -1,11 +1,29 @@ 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.ui.decompose.ComposableContentComponent import kotlinx.coroutines.flow.Flow 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 { /** @@ -20,8 +38,8 @@ interface MarketingBannerComponent : ComposableContentComponent { val onDeeplinkClick: ((deeplink: String) -> Boolean)? = null, ) : Params - /** LINKED_TO_PROVIDER single banner rendered inline next to an onramp provider offer. */ - data class LinkedToProvider(val requestFlow: Flow) : Params + /** LINKED single banner rendered inline next to a host item (currently an onramp provider offer). */ + data class Linked(val requestFlow: Flow) : Params } interface Factory : ComponentFactory diff --git a/features/marketing/api/src/main/kotlin/com/tangem/features/marketing/api/MarketingBannerRequest.kt b/features/marketing/api/src/main/kotlin/com/tangem/features/marketing/api/MarketingBannerRequest.kt index 3c64911cd0..0c517aa39d 100644 --- a/features/marketing/api/src/main/kotlin/com/tangem/features/marketing/api/MarketingBannerRequest.kt +++ b/features/marketing/api/src/main/kotlin/com/tangem/features/marketing/api/MarketingBannerRequest.kt @@ -9,9 +9,11 @@ data class MarketingBannerRequest( 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( val screen: MarketingScreen.Onramp, val amountUsd: BigDecimal?, - val currentProviderId: String, ) \ No newline at end of file diff --git a/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/DefaultMarketingBannerComponent.kt b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/DefaultMarketingBannerComponent.kt index 3de80d23c6..89acb2c793 100644 --- a/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/DefaultMarketingBannerComponent.kt +++ b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/DefaultMarketingBannerComponent.kt @@ -8,7 +8,9 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.features.marketing.api.MarketingBannerComponent 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.state.MarketingBannerListUM import dagger.assisted.Assisted import dagger.assisted.AssistedFactory 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 interface Factory : MarketingBannerComponent.Factory { override fun create( diff --git a/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/model/MarketingBannerModel.kt b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/model/MarketingBannerModel.kt index 6844151314..6c03a6d43a 100644 --- a/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/model/MarketingBannerModel.kt +++ b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/model/MarketingBannerModel.kt @@ -68,17 +68,11 @@ internal class MarketingBannerModel @Inject constructor( val requestFlow: Flow = when (val p = params) { is MarketingBannerComponent.Params.Standalone -> 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 -> - request?.let { linked -> - MarketingRequest( - screen = linked.screen, - amountUsd = linked.amountUsd, - providerId = linked.currentProviderId, - ) - } + request?.let { MarketingRequest(screen = it.screen, amountUsd = it.amountUsd) } } } @@ -89,19 +83,17 @@ internal class MarketingBannerModel @Inject constructor( .mapLatest { screen -> if (screen != null) fetch(screen) else emptyList() } val amountUsd: Flow = requestFlow.map { it?.amountUsd }.distinctUntilChanged() - val providerId: Flow = requestFlow.map { it?.providerId }.distinctUntilChanged() modelScope.launch { combine( flow = campaigns, flow2 = amountUsd, - flow3 = providerId, - flow4 = dismissedIds, - ) { list, usd, provider, dismissed -> + flow3 = dismissedIds, + ) { list, usd, dismissed -> list.asSequence() .filterNot { it.id in dismissed } .filter { it.matchesUsdAmount(usd) } - .filter { matchesUiTypeAndProvider(it, provider) } + .filter { matchesUiType(it) } .map { it.toUM() } .toList() }.collect { banners -> @@ -117,18 +109,16 @@ internal class MarketingBannerModel @Inject constructor( private suspend fun fetch(screen: MarketingScreen): List = 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 -> campaign.banner.uiType == MarketingBanner.UiType.STANDALONE - is MarketingBannerComponent.Params.LinkedToProvider -> - campaign.banner.uiType == MarketingBanner.UiType.LINKED_TO_PROVIDER && - providerId != null && campaign.providerIds?.contains(providerId) == true + is MarketingBannerComponent.Params.Linked -> + campaign.banner.uiType == MarketingBanner.UiType.LINKED_TO_PROVIDER } private data class MarketingRequest( val screen: MarketingScreen, val amountUsd: BigDecimal?, - val providerId: String?, ) private fun MarketingCampaign.toUM() = MarketingBannerUM( @@ -141,6 +131,7 @@ internal class MarketingBannerModel @Inject constructor( }, isDismissible = banner.isDismissible, deeplink = banner.deeplink, + providerIds = providerIds?.toSet().orEmpty(), ) private companion object { diff --git a/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/LinkedMarketingBanner.kt b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/LinkedMarketingBanner.kt new file mode 100644 index 0000000000..1be64bf57b --- /dev/null +++ b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/LinkedMarketingBanner.kt @@ -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 \ No newline at end of file diff --git a/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/state/MarketingBannerUM.kt b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/state/MarketingBannerUM.kt index c4bb2acc88..200871fc3e 100644 --- a/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/state/MarketingBannerUM.kt +++ b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/state/MarketingBannerUM.kt @@ -10,6 +10,7 @@ internal data class MarketingBannerUM( val iconAlign: IconAlign, val isDismissible: Boolean, val deeplink: String?, + val providerIds: Set = emptySet(), ) { enum class IconAlign { LEFT, RIGHT } } \ No newline at end of file diff --git a/features/marketing/impl/src/test/kotlin/com/tangem/features/marketing/impl/model/MarketingBannerModelTest.kt b/features/marketing/impl/src/test/kotlin/com/tangem/features/marketing/impl/model/MarketingBannerModelTest.kt index 38145f1145..6e1643f546 100644 --- a/features/marketing/impl/src/test/kotlin/com/tangem/features/marketing/impl/model/MarketingBannerModelTest.kt +++ b/features/marketing/impl/src/test/kotlin/com/tangem/features/marketing/impl/model/MarketingBannerModelTest.kt @@ -177,7 +177,7 @@ internal class MarketingBannerModelTest { } @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 coEvery { getMarketingBanner(onrampScreen, null) } returns listOf( campaign(1, MarketingBanner.UiType.LINKED_TO_PROVIDER, providerIds = listOf("mercuryo")), @@ -185,16 +185,20 @@ internal class MarketingBannerModelTest { campaign(3, MarketingBanner.UiType.STANDALONE), ).right() val model = createModel( - MarketingBannerComponent.Params.LinkedToProvider( - flowOf(LinkedBannerRequest(onrampScreen, amountUsd = null, currentProviderId = "mercuryo")), + MarketingBannerComponent.Params.Linked( + flowOf(LinkedBannerRequest(onrampScreen, amountUsd = null)), ), ) // 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 { advanceUntilIdle() 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") } } diff --git a/features/onramp/impl/build.gradle.kts b/features/onramp/impl/build.gradle.kts index 60ccf215e6..c6e3fc95fb 100644 --- a/features/onramp/impl/build.gradle.kts +++ b/features/onramp/impl/build.gradle.kts @@ -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) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateFactory.kt index ee7af4a393..cb9f3f7a6a 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateFactory.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateFactory.kt @@ -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), diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/AllOffersContentSheet.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/AllOffersContentSheet.kt index beaf222881..8f4e481d37 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/AllOffersContentSheet.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/AllOffersContentSheet.kt @@ -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%"), diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/PaymentMethodsContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/PaymentMethodsContent.kt index 5e40a22e41..a608fbe170 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/PaymentMethodsContent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/PaymentMethodsContent.kt @@ -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%"), diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/DefaultOnrampMainComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/DefaultOnrampMainComponent.kt index cb55143094..268e40bb54 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/DefaultOnrampMainComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/DefaultOnrampMainComponent.kt @@ -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() } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/OnrampOfferBlockUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/OnrampOfferBlockUM.kt index 978aed9131..52e49d6e5f 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/OnrampOfferBlockUM.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/OnrampOfferBlockUM.kt @@ -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?, diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampOffersStateFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampOffersStateFactory.kt index 889f62feca..6e9e7ec2e0 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampOffersStateFactory.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampOffersStateFactory.kt @@ -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( diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt index ba9858ba48..5fa513496c 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt @@ -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 = getOnrampQuotesUseCase.invoke() + .map { either -> + either.getOrNull() + ?.filterIsInstance() + ?.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 = 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 = 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) { quotes.forEach { errorState -> when (errorState) { diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampMainComponentContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampMainComponentContent.kt index 2c61db8413..16ebf9180a 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampMainComponentContent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampMainComponentContent.kt @@ -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) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampOffersContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampOffersContent.kt index 4c161835e9..a8ac8de316 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampOffersContent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampOffersContent.kt @@ -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 + }, + ) } } \ No newline at end of file diff --git a/features/swap/impl/build.gradle.kts b/features/swap/impl/build.gradle.kts index a40a8c20cc..ea768ab1ba 100644 --- a/features/swap/impl/build.gradle.kts +++ b/features/swap/impl/build.gradle.kts @@ -17,6 +17,7 @@ dependencies { api(projects.features.send.api) api(projects.features.swap.api) api(projects.features.swap.domain) + implementation(projects.features.marketing.api) implementation(projects.features.send.impl) /** Core modules */ @@ -59,7 +60,9 @@ dependencies { implementation(projects.domain.balanceHiding.models) implementation(projects.domain.core) implementation(projects.domain.feedback.models) + implementation(projects.domain.marketing.models) implementation(projects.domain.markets.models) + implementation(projects.domain.quotes) implementation(projects.domain.stories.models) implementation(projects.domain.txhistory.models) implementation(projects.domain.visa.models) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt index 8ae771fb83..3cd443d894 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt @@ -17,6 +17,7 @@ import com.arkivanov.decompose.router.stack.childStack import com.arkivanov.decompose.router.stack.pop 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.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.features.approval.api.GiveApprovalEntryComponent 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.swap.SwapComponent import com.tangem.utils.isNullOrZero @@ -46,6 +48,7 @@ internal class DefaultSwapComponent @AssistedInject constructor( private val swapFeeSelectorBlockComponentFactory: SwapFeeSelectorBlockComponent.Factory, private val giveApprovalEntryComponentFactory: GiveApprovalEntryComponent.Factory, private val chooseTokenComponentFactory: ChooseTokenComponent.Factory, + private val marketingBannerComponentFactory: MarketingBannerComponent.Factory, ) : SwapComponent, AppComponentContext by appComponentContext { private val stackNavigation = StackNavigation() @@ -56,6 +59,14 @@ internal class DefaultSwapComponent @AssistedInject constructor( 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( key = STACK_KEY, source = stackNavigation, @@ -215,6 +226,7 @@ internal class DefaultSwapComponent @AssistedInject constructor( SwapScreen( stateHolder = model.uiState, feeSelectorBlockComponent = feeSelectorBlockComponent, + marketingBannerComponent = marketingBannerComponent, ) } } @@ -236,6 +248,7 @@ internal class DefaultSwapComponent @AssistedInject constructor( SwapScreen( stateHolder = model.uiState, feeSelectorBlockComponent = feeSelectorBlockComponent, + marketingBannerComponent = marketingBannerComponent, ) } } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index b50b960811..c0c79e3427 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -16,6 +16,8 @@ import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.common.routing.AppRoute 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.core.analytics.api.AnalyticsErrorHandler 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.models.BlockchainErrorInfo 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.AccountStatus 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.pay.WithdrawalResult import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase +import com.tangem.domain.quotes.GetCurrencyUSDQuoteUseCase import com.tangem.domain.quotes.IsHighNetworkFeeUseCase import com.tangem.domain.settings.usercountry.GetUserCountryUseCase import com.tangem.domain.settings.usercountry.models.UserCountry @@ -110,6 +114,7 @@ import com.tangem.features.approval.api.SelectApprovalTypeComponent import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult +import com.tangem.features.marketing.api.MarketingBannerRequest import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger @@ -175,6 +180,7 @@ internal class SwapModel @Inject constructor( private val setSwapUiModeUseCase: SetSwapUiModeUseCase, private val calculateAmountUseCase: CalculateAmountUseCase, private val isHighNetworkFeeUseCase: IsHighNetworkFeeUseCase, + private val getCurrencyUSDQuoteUseCase: GetCurrencyUSDQuoteUseCase, ) : Model() { private val params = paramsContainer.require() @@ -225,6 +231,30 @@ internal class SwapModel @Inject constructor( 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 = 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()) internal set @@ -354,6 +384,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() { startLoadingQuotesFromLastState(true) } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt index 866456ee72..8b03ae47f9 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt @@ -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.extensions.stringResourceSafe 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.utils.WindowInsetsZero 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.states.ChooseProviderBottomSheetConfig import com.tangem.feature.swap.presentation.R +import com.tangem.features.marketing.api.MarketingBannerComponent @Composable -internal fun SwapScreen(stateHolder: SwapStateHolder, feeSelectorBlockComponent: SwapFeeSelectorBlockComponent?) { +internal fun SwapScreen( + stateHolder: SwapStateHolder, + feeSelectorBlockComponent: SwapFeeSelectorBlockComponent?, + marketingBannerComponent: MarketingBannerComponent? = null, +) { BackHandler(onBack = stateHolder.onBackClicked) Scaffold( @@ -63,6 +69,15 @@ internal fun SwapScreen(stateHolder: SwapStateHolder, feeSelectorBlockComponent: } else { null }, + marketingBanner = if (marketingBannerComponent != null) { + @Composable { modifier: Modifier -> + TangemThemeRedesign { + marketingBannerComponent.Content(modifier) + } + } + } else { + null + }, modifier = Modifier .padding(scaffoldPaddings) .testTag(SwapTokenScreenTestTags.CONTAINER), diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt index cddf6734fd..9456f52bd7 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt @@ -63,6 +63,7 @@ internal fun SwapScreenContent( state: SwapStateHolder, modifier: Modifier = Modifier, feeBlock: @Composable ((Modifier) -> Unit)? = null, + marketingBanner: @Composable ((Modifier) -> Unit)? = null, ) { val keyboard by keyboardAsState() @@ -86,6 +87,8 @@ internal fun SwapScreenContent( ) { MainInfo(state) + marketingBanner?.invoke(Modifier.fillMaxWidth()) + if (state.swapUIMode == SwapUIMode.Simple) { ProviderItemBlockSimple(state = state.providerState) } else { diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapModelTestBase.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapModelTestBase.kt index 23510180fa..0e2f75bbb4 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapModelTestBase.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapModelTestBase.kt @@ -26,6 +26,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase +import com.tangem.domain.quotes.GetCurrencyUSDQuoteUseCase import com.tangem.domain.quotes.IsHighNetworkFeeUseCase import com.tangem.domain.settings.usercountry.GetUserCountryUseCase import com.tangem.domain.settings.usercountry.models.UserCountry @@ -105,6 +106,7 @@ internal abstract class SwapModelTestBase { protected val setSwapUiModeUseCase: SetSwapUiModeUseCase = mockk(relaxed = true) protected val calculateAmountUseCase: CalculateAmountUseCase = mockk(relaxed = true) protected val isHighNetworkFeeUseCase: IsHighNetworkFeeUseCase = mockk(relaxed = true) + protected val getCurrencyUSDQuoteUseCase: GetCurrencyUSDQuoteUseCase = mockk(relaxed = true) protected val isWalletBackupProblematicUseCase: IsWalletBackupProblematicUseCase = mockk(relaxed = true) protected val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase = mockk(relaxed = true) @@ -178,6 +180,7 @@ internal abstract class SwapModelTestBase { setSwapUiModeUseCase = setSwapUiModeUseCase, calculateAmountUseCase = calculateAmountUseCase, isHighNetworkFeeUseCase = isHighNetworkFeeUseCase, + getCurrencyUSDQuoteUseCase = getCurrencyUSDQuoteUseCase, isWalletBackupProblematicUseCase = isWalletBackupProblematicUseCase, sendBackupProblemEmailUseCase = sendBackupProblemEmailUseCase, ) diff --git a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt index 44920f24a3..66b1af8edc 100644 --- a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt +++ b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt @@ -31,6 +31,7 @@ internal fun BaseExtension.configureCompose(project: Project) { contains(Regex(pattern = ":features:manage-tokens: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:marketing:api\$")) || // provides Composable function contains(Regex(pattern = ":impl\$")) }