diff --git a/app/feature/feature-cross-sell-sheet/src/main/kotlin/com/hedvig/android/feature/cross/sell/sheet/CrossSellSheetViewModel.kt b/app/feature/feature-cross-sell-sheet/src/main/kotlin/com/hedvig/android/feature/cross/sell/sheet/CrossSellSheetViewModel.kt index 02d1384d24..9fa2af83c0 100644 --- a/app/feature/feature-cross-sell-sheet/src/main/kotlin/com/hedvig/android/feature/cross/sell/sheet/CrossSellSheetViewModel.kt +++ b/app/feature/feature-cross-sell-sheet/src/main/kotlin/com/hedvig/android/feature/cross/sell/sheet/CrossSellSheetViewModel.kt @@ -22,9 +22,13 @@ import com.hedvig.android.core.common.di.HedvigViewModel import com.hedvig.android.core.demomode.DemoManager import com.hedvig.android.core.demomode.DemoSwitcher import com.hedvig.android.crosssells.BundleProgress +import com.hedvig.android.crosssells.CrossSellFlowSource +import com.hedvig.android.crosssells.CrossSellImpressionTracker import com.hedvig.android.crosssells.CrossSellSheetData +import com.hedvig.android.crosssells.CrossSellUserFlow import com.hedvig.android.crosssells.RecommendedAddon import com.hedvig.android.crosssells.RecommendedCrossSell +import com.hedvig.android.crosssells.crossSellSheetShown import com.hedvig.android.data.contract.CrossSell import com.hedvig.android.data.contract.ImageAsset import com.hedvig.android.data.cross.sell.after.flow.CrossSellAfterFlowRepository @@ -55,9 +59,10 @@ import octopus.type.UserFlow internal class CrossSellSheetViewModel( getCrossSellSheetDataUseCase: GetCrossSellSheetDataUseCase, crossSellAfterFlowRepository: CrossSellAfterFlowRepository, + crossSellImpressionTracker: CrossSellImpressionTracker, ) : MoleculeViewModel( CrossSellSheetState.Loading, - CrossSellSheetPresenter(getCrossSellSheetDataUseCase, crossSellAfterFlowRepository), + CrossSellSheetPresenter(getCrossSellSheetDataUseCase, crossSellAfterFlowRepository, crossSellImpressionTracker), ) internal sealed interface CrossSellSheetEvent { @@ -77,6 +82,7 @@ internal sealed interface CrossSellSheetState { internal class CrossSellSheetPresenter( private val getCrossSellSheetDataUseCase: GetCrossSellSheetDataUseCase, private val crossSellAfterFlowRepository: CrossSellAfterFlowRepository, + private val crossSellImpressionTracker: CrossSellImpressionTracker, ) : MoleculePresenter { @Composable override fun MoleculePresenterScope.present( @@ -87,7 +93,15 @@ internal class CrossSellSheetPresenter( CollectEvents { event -> when (event) { is CrossSellSheetEvent.CrossSellSheetShown -> { - crossSellAfterFlowRepository.showedCrossSellSheet((state as? CrossSellSheetState.Content)?.infoType) + val content = state as? CrossSellSheetState.Content + crossSellAfterFlowRepository.showedCrossSellSheet(content?.infoType) + if (content != null) { + crossSellImpressionTracker.crossSellSheetShown( + data = content.crossSellSheetData, + userFlow = CrossSellUserFlow.SmartXSell, + flowSource = content.infoType.toFlowSource(), + ) + } } } } @@ -121,6 +135,16 @@ internal class CrossSellSheetPresenter( } } +internal fun CrossSellInfoType.toFlowSource(): CrossSellFlowSource { + return when (this) { + CrossSellInfoType.Addon -> CrossSellFlowSource.Addon + is CrossSellInfoType.ChangeTier -> CrossSellFlowSource.ChangeTier + is CrossSellInfoType.ClosedClaim -> CrossSellFlowSource.ClosedClaim + CrossSellInfoType.EditCoInsured -> CrossSellFlowSource.EditCoInsured + is CrossSellInfoType.MovingFlow -> CrossSellFlowSource.Moving + } +} + internal fun CrossSellInfoType.toCrossSellSource(): CrossSellInput { val smartCrossSellInput: (FlowSource) -> CrossSellInput = { flowSource -> CrossSellInput( diff --git a/app/feature/feature-cross-sell-sheet/src/test/kotlin/com/hedvig/android/feature/cross/sell/sheet/CrossSellSheetPresenterTest.kt b/app/feature/feature-cross-sell-sheet/src/test/kotlin/com/hedvig/android/feature/cross/sell/sheet/CrossSellSheetPresenterTest.kt index 8a89d9f9e5..72c5c38dbe 100644 --- a/app/feature/feature-cross-sell-sheet/src/test/kotlin/com/hedvig/android/feature/cross/sell/sheet/CrossSellSheetPresenterTest.kt +++ b/app/feature/feature-cross-sell-sheet/src/test/kotlin/com/hedvig/android/feature/cross/sell/sheet/CrossSellSheetPresenterTest.kt @@ -5,8 +5,14 @@ import arrow.core.right import assertk.assertThat import assertk.assertions.isEqualTo import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.crosssells.CrossSellFlowSource +import com.hedvig.android.crosssells.CrossSellImpressionTracker import com.hedvig.android.crosssells.CrossSellSheetData +import com.hedvig.android.crosssells.CrossSellType +import com.hedvig.android.crosssells.CrossSellUserFlow import com.hedvig.android.crosssells.RecommendedAddon +import com.hedvig.android.data.contract.CrossSell +import com.hedvig.android.data.contract.ImageAsset import com.hedvig.android.data.cross.sell.after.flow.CrossSellAfterFlowRepository import com.hedvig.android.data.cross.sell.after.flow.CrossSellInfoType import com.hedvig.android.molecule.test.test @@ -36,6 +42,7 @@ internal class CrossSellSheetPresenterTest { val presenter = CrossSellSheetPresenter( FakeGetCrossSellSheetDataUseCase(emptyData), FakeCrossSellAfterFlowRepository(CrossSellInfoType.EditCoInsured), + RecordingCrossSellImpressionTracker(), ) presenter.test(CrossSellSheetState.Loading) { @@ -50,6 +57,7 @@ internal class CrossSellSheetPresenterTest { val presenter = CrossSellSheetPresenter( FakeGetCrossSellSheetDataUseCase(addonData), FakeCrossSellAfterFlowRepository(CrossSellInfoType.EditCoInsured), + RecordingCrossSellImpressionTracker(), ) presenter.test(CrossSellSheetState.Loading) { @@ -59,6 +67,72 @@ internal class CrossSellSheetPresenterTest { ) } } + + @Test + fun `showing the sheet tracks an impression per shown offer`() = runTest { + val newPromise = CrossSell( + id = "cs1", + title = "Car", + subtitle = "Car insurance", + storeUrl = "url", + pillowImage = ImageAsset(id = "img", src = "src", description = "alt"), + ) + val data = CrossSellSheetData( + recommendedCrossSell = null, + otherCrossSells = listOf(newPromise), + recommendedAddon = recommendedAddon, + ) + val tracker = RecordingCrossSellImpressionTracker() + val presenter = CrossSellSheetPresenter( + FakeGetCrossSellSheetDataUseCase(data), + FakeCrossSellAfterFlowRepository(CrossSellInfoType.EditCoInsured), + tracker, + ) + + presenter.test(CrossSellSheetState.Loading) { + assertThat(awaitItem()).isEqualTo(CrossSellSheetState.Loading) + assertThat(awaitItem()).isEqualTo(CrossSellSheetState.Content(data, CrossSellInfoType.EditCoInsured)) + sendEvent(CrossSellSheetEvent.CrossSellSheetShown) + assertThat(awaitItem()).isEqualTo(CrossSellSheetState.DontShow) + } + + assertThat(tracker.shown).isEqualTo( + listOf( + RecordingCrossSellImpressionTracker.Impression( + CrossSellUserFlow.SmartXSell, + CrossSellType.Addon, + "addonId", + CrossSellFlowSource.EditCoInsured, + ), + RecordingCrossSellImpressionTracker.Impression( + CrossSellUserFlow.SmartXSell, + CrossSellType.NewPromise, + "cs1", + CrossSellFlowSource.EditCoInsured, + ), + ), + ) + } +} + +private class RecordingCrossSellImpressionTracker : CrossSellImpressionTracker { + data class Impression( + val userFlow: CrossSellUserFlow, + val crossSellType: CrossSellType, + val offerId: String, + val flowSource: CrossSellFlowSource?, + ) + + val shown = mutableListOf() + + override fun crossSellShown( + userFlow: CrossSellUserFlow, + crossSellType: CrossSellType, + offerId: String, + flowSource: CrossSellFlowSource?, + ) { + shown += Impression(userFlow, crossSellType, offerId, flowSource) + } } private class FakeGetCrossSellSheetDataUseCase( diff --git a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeDestination.kt b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeDestination.kt index 1b17bbbf20..2c399ccc7d 100644 --- a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeDestination.kt +++ b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeDestination.kt @@ -63,6 +63,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.layout.onVisibilityChanged import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag @@ -88,8 +89,11 @@ import com.google.accompanist.permissions.isGranted import com.hedvig.android.compose.ui.plus import com.hedvig.android.compose.ui.preview.BooleanCollectionPreviewParameterProvider import com.hedvig.android.crosssells.BundleProgress +import com.hedvig.android.crosssells.CROSS_SELL_IMPRESSION_MIN_DURATION_MS +import com.hedvig.android.crosssells.CROSS_SELL_IMPRESSION_MIN_FRACTION_VISIBLE import com.hedvig.android.crosssells.CrossSellBottomSheet import com.hedvig.android.crosssells.CrossSellSheetData +import com.hedvig.android.crosssells.CrossSellType import com.hedvig.android.crosssells.CrossSellsSection import com.hedvig.android.crosssells.PillowRow import com.hedvig.android.crosssells.RecommendedCrossSell @@ -245,6 +249,15 @@ internal fun HomeDestination( deleteDraftClaim = { draftId -> viewModel.emit(HomeEvent.DeleteDraftClaim(draftId)) }, navigateToFirstVet = navigateToFirstVet, markCrossSellsNotificationAsSeen = { viewModel.emit(HomeEvent.MarkCardCrossSellsAsSeen) }, + onCrossSellsShown = { viewModel.emit(HomeEvent.CrossSellsShown(it)) }, + onHomeCrossSellImpression = { crossSell -> + viewModel.emit(HomeEvent.HomeCrossSellImpression(crossSell.id, CrossSellType.NewPromise)) + }, + onHomeAddonImpression = { bannerInfo -> + // Addon banners carry no per-offer id, so the flow type (lowercased to match enum-value casing) + // identifies the addon offer. + viewModel.emit(HomeEvent.HomeCrossSellImpression(bannerInfo.flowType.name.lowercase(), CrossSellType.Addon)) + }, navigateToContactInfo = navigateToContactInfo, navigateToChipIdScreen = navigateToChipId, setEpochDayWhenLastToolTipShown = { epochDay -> @@ -279,6 +292,9 @@ private fun HomeScreen( navigateToContactInfo: () -> Unit, navigateToChipIdScreen: () -> Unit, markCrossSellsNotificationAsSeen: () -> Unit, + onCrossSellsShown: (CrossSellSheetData) -> Unit, + onHomeCrossSellImpression: (CrossSell) -> Unit = {}, + onHomeAddonImpression: (AddonBannerInfo) -> Unit = {}, setEpochDayWhenLastToolTipShown: (Long) -> Unit, imageLoader: ImageLoader, navigateToTravelCertificate: () -> Unit, @@ -296,6 +312,7 @@ private fun HomeScreen( CrossSellBottomSheet( state = crossSellBottomSheetState, markCrossSellsNotificationAsSeen = markCrossSellsNotificationAsSeen, + onCrossSellsShown = onCrossSellsShown, onCrossSellClick = openCrossSellUrl, imageLoader = imageLoader, ) @@ -415,6 +432,8 @@ private fun HomeScreen( imageLoader = imageLoader, navigateToTravelCertificate = navigateToTravelCertificate, navigateToAddonPurchaseFlow = navigateToAddonPurchaseFlow, + onHomeCrossSellImpression = onHomeCrossSellImpression, + onHomeAddonImpression = onHomeAddonImpression, ) } } @@ -575,6 +594,8 @@ private fun HomeScreenSuccess( imageLoader: ImageLoader, navigateToTravelCertificate: () -> Unit, navigateToAddonPurchaseFlow: (List) -> Unit, + onHomeCrossSellImpression: (CrossSell) -> Unit = {}, + onHomeAddonImpression: (AddonBannerInfo) -> Unit = {}, modifier: Modifier = Modifier, ) { val consumedWindowInsets = remember { MutableWindowInsets() } @@ -874,6 +895,7 @@ private fun HomeScreenSuccess( OffersSection( recommendedCrossSell = recommended, onCrossSellClick = openCrossSellUrl, + onImpression = { onHomeCrossSellImpression(recommended.crossSell) }, imageLoader = imageLoader, horizontalInsets = horizontalInsets, ) @@ -882,12 +904,14 @@ private fun HomeScreenSuccess( HomeSection.DiscoverInsurances -> DiscoverInsurancesSection( crossSells = uiState.crossSellsPartition.discoverCrossSells, onCrossSellClick = openCrossSellUrl, + onCrossSellImpression = onHomeCrossSellImpression, imageLoader = imageLoader, ) HomeSection.Addons -> AddonsSection( addonBannerInfos = uiState.addonBannerInfos, navigateToAddonPurchaseFlow = navigateToAddonPurchaseFlow, + onAddonImpression = onHomeAddonImpression, horizontalInsets = horizontalInsets, imageLoader = imageLoader, ) @@ -1090,12 +1114,19 @@ private fun OffersSection( onCrossSellClick: (String) -> Unit, imageLoader: ImageLoader, horizontalInsets: PaddingValues, + onImpression: () -> Unit = {}, ) { val crossSell = recommendedCrossSell.crossSell Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier .fillMaxWidth() + .onVisibilityChanged( + minDurationMs = CROSS_SELL_IMPRESSION_MIN_DURATION_MS, + minFractionVisible = CROSS_SELL_IMPRESSION_MIN_FRACTION_VISIBLE, + ) { visible -> + if (visible) onImpression() + } .padding(horizontal = 16.dp) .padding(horizontalInsets), ) { @@ -1283,6 +1314,7 @@ private fun AddonsSection( navigateToAddonPurchaseFlow: (List) -> Unit, horizontalInsets: PaddingValues, imageLoader: ImageLoader, + onAddonImpression: (AddonBannerInfo) -> Unit = {}, ) { Column( verticalArrangement = Arrangement.spacedBy(16.dp), @@ -1305,7 +1337,14 @@ private fun AddonsSection( buttonText = stringResource(Res.string.ADDON_FLOW_LEARN_MORE_BUTTON), onButtonClick = { navigateToAddonPurchaseFlow(addon.eligibleInsurancesIds) }, imageLoader = imageLoader, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .onVisibilityChanged( + minDurationMs = CROSS_SELL_IMPRESSION_MIN_DURATION_MS, + minFractionVisible = CROSS_SELL_IMPRESSION_MIN_FRACTION_VISIBLE, + ) { visible -> + if (visible) onAddonImpression(addon) + }, buttonSize = ButtonSize.Small, buttonShape = HedvigTheme.shapes.cornerFull, ) @@ -1318,6 +1357,7 @@ private fun DiscoverInsurancesSection( crossSells: List, onCrossSellClick: (String) -> Unit, imageLoader: ImageLoader, + onCrossSellImpression: ((CrossSell) -> Unit)? = null, ) { CrossSellsSection( title = stringResource(Res.string.CROSS_SELL_SUBTITLE), @@ -1328,6 +1368,7 @@ private fun DiscoverInsurancesSection( imageLoader = imageLoader, buttonSize = ButtonSize.Small, buttonShape = HedvigTheme.shapes.cornerFull, + onCrossSellImpression = onCrossSellImpression, ) } @@ -1371,6 +1412,7 @@ private fun WelcomeMessage(firstName: String, modifier: Modifier = Modifier) { private fun CrossSellBottomSheet( state: HedvigBottomSheetState, markCrossSellsNotificationAsSeen: () -> Unit, + onCrossSellsShown: (CrossSellSheetData) -> Unit, onCrossSellClick: (String) -> Unit, imageLoader: ImageLoader, ) { @@ -1378,6 +1420,7 @@ private fun CrossSellBottomSheet( snapshotFlow { state.isVisible }.distinctUntilChanged().collect { isVisible -> if (isVisible) { markCrossSellsNotificationAsSeen() + state.data?.let(onCrossSellsShown) } } } @@ -1506,6 +1549,7 @@ private fun PreviewHomeScreen( deleteDraftClaim = {}, navigateToFirstVet = {}, markCrossSellsNotificationAsSeen = {}, + onCrossSellsShown = {}, navigateToContactInfo = {}, navigateToChipIdScreen = {}, setEpochDayWhenLastToolTipShown = {}, @@ -1542,6 +1586,7 @@ private fun PreviewHomeScreenWithError() { deleteDraftClaim = {}, navigateToFirstVet = {}, markCrossSellsNotificationAsSeen = {}, + onCrossSellsShown = {}, navigateToContactInfo = {}, navigateToChipIdScreen = {}, setEpochDayWhenLastToolTipShown = {}, @@ -1600,6 +1645,7 @@ private fun PreviewHomeScreenAllHomeTextTypes( deleteDraftClaim = {}, navigateToFirstVet = {}, markCrossSellsNotificationAsSeen = {}, + onCrossSellsShown = {}, navigateToContactInfo = {}, navigateToChipIdScreen = {}, setEpochDayWhenLastToolTipShown = {}, diff --git a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomePresenter.kt b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomePresenter.kt index d716be1460..477816b1ba 100644 --- a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomePresenter.kt +++ b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomePresenter.kt @@ -12,8 +12,12 @@ import androidx.compose.runtime.snapshots.Snapshot import arrow.core.Either import com.hedvig.android.apollo.ApolloOperationError import com.hedvig.android.core.common.ApplicationScope +import com.hedvig.android.crosssells.CrossSellImpressionTracker import com.hedvig.android.crosssells.CrossSellSheetData +import com.hedvig.android.crosssells.CrossSellType +import com.hedvig.android.crosssells.CrossSellUserFlow import com.hedvig.android.crosssells.RecommendedCrossSell +import com.hedvig.android.crosssells.crossSellSheetShown import com.hedvig.android.data.addons.data.AddonBannerInfo import com.hedvig.android.data.claimintent.DeleteClaimIntentDraftUseCase import com.hedvig.android.data.contract.CrossSell @@ -42,6 +46,7 @@ internal class HomePresenter( private val applicationScope: ApplicationScope, private val isProduction: Boolean, private val deleteClaimIntentDraftUseCase: DeleteClaimIntentDraftUseCase, + private val crossSellImpressionTracker: CrossSellImpressionTracker, ) : MoleculePresenter { @Composable override fun MoleculePresenterScope.present(lastState: HomeUiState): HomeUiState { @@ -50,6 +55,7 @@ internal class HomePresenter( var successData: SuccessData? by remember { mutableStateOf(SuccessData.fromLastState(lastState)) } var loadIteration by remember { mutableIntStateOf(0) } var crossSellToolTipShownEpochDay by remember { mutableStateOf(null) } + val trackedHomeImpressions = remember { mutableSetOf() } val alreadySeenImportantMessages: List by seenImportantMessagesStorage.seenMessages.collectAsState() @@ -69,6 +75,22 @@ internal class HomePresenter( } } + is HomeEvent.CrossSellsShown -> { + crossSellImpressionTracker.crossSellSheetShown(homeEvent.crossSells, CrossSellUserFlow.InsuranceCard) + } + + is HomeEvent.HomeCrossSellImpression -> { + val impressionKey = "${homeEvent.crossSellType.analyticsValue}:${homeEvent.offerId}" + if (trackedHomeImpressions.add(impressionKey)) { + crossSellImpressionTracker.crossSellShown( + userFlow = CrossSellUserFlow.HomeScreen, + crossSellType = homeEvent.crossSellType, + offerId = homeEvent.offerId, + flowSource = null, + ) + } + } + is HomeEvent.CrossSellToolTipShown -> { crossSellToolTipShownEpochDay = homeEvent.epochDay } @@ -169,6 +191,10 @@ internal sealed interface HomeEvent { data object MarkCardCrossSellsAsSeen : HomeEvent + data class CrossSellsShown(val crossSells: CrossSellSheetData) : HomeEvent + + data class HomeCrossSellImpression(val offerId: String, val crossSellType: CrossSellType) : HomeEvent + data class CrossSellToolTipShown(val epochDay: Long) : HomeEvent data class DeleteDraftClaim(val draftId: String) : HomeEvent diff --git a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeViewModel.kt b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeViewModel.kt index 92584d1d60..55e1d24b49 100644 --- a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeViewModel.kt +++ b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeViewModel.kt @@ -4,6 +4,7 @@ import com.hedvig.android.core.buildconstants.HedvigBuildConstants import com.hedvig.android.core.common.ApplicationScope import com.hedvig.android.core.common.di.ActivityRetainedScope import com.hedvig.android.core.common.di.HedvigViewModel +import com.hedvig.android.crosssells.CrossSellImpressionTracker import com.hedvig.android.data.claimintent.DeleteClaimIntentDraftUseCase import com.hedvig.android.feature.home.home.data.GetHomeDataUseCase import com.hedvig.android.feature.home.home.data.SeenImportantMessagesStorage @@ -20,6 +21,7 @@ internal class HomeViewModel( applicationScope: ApplicationScope, hedvigBuildConstants: HedvigBuildConstants, deleteClaimIntentDraftUseCase: DeleteClaimIntentDraftUseCase, + crossSellImpressionTracker: CrossSellImpressionTracker, ) : MoleculeViewModel( HomeUiState.Loading, HomePresenter( @@ -29,5 +31,6 @@ internal class HomeViewModel( applicationScope, hedvigBuildConstants.isProduction, deleteClaimIntentDraftUseCase, + crossSellImpressionTracker, ), ) diff --git a/app/feature/feature-home/src/test/kotlin/com/hedvig/android/feature/home/home/ui/HomePresenterTest.kt b/app/feature/feature-home/src/test/kotlin/com/hedvig/android/feature/home/home/ui/HomePresenterTest.kt index 4c12dc5366..d43d6a795e 100644 --- a/app/feature/feature-home/src/test/kotlin/com/hedvig/android/feature/home/home/ui/HomePresenterTest.kt +++ b/app/feature/feature-home/src/test/kotlin/com/hedvig/android/feature/home/home/ui/HomePresenterTest.kt @@ -16,6 +16,7 @@ import com.google.testing.junit.testparameterinjector.TestParameterInjector import com.hedvig.android.apollo.ApolloOperationError import com.hedvig.android.core.common.ApplicationScope import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.crosssells.CrossSellImpressionTracker import com.hedvig.android.crosssells.CrossSellSheetData import com.hedvig.android.crosssells.RecommendedAddon import com.hedvig.android.crosssells.RecommendedCrossSell @@ -83,6 +84,7 @@ internal class HomePresenterTest { ApplicationScope(backgroundScope), false, TestDeleteClaimIntentDraftUseCase(), + noopCrossSellImpressionTracker, ) homePresenter.test(HomeUiState.Loading) { @@ -111,6 +113,7 @@ internal class HomePresenterTest { ApplicationScope(backgroundScope), false, TestDeleteClaimIntentDraftUseCase(), + noopCrossSellImpressionTracker, ) homePresenter.test(HomeUiState.Loading) { @@ -137,6 +140,7 @@ internal class HomePresenterTest { ApplicationScope(backgroundScope), false, TestDeleteClaimIntentDraftUseCase(), + noopCrossSellImpressionTracker, ) homePresenter.test(HomeUiState.Loading) { @@ -214,6 +218,7 @@ internal class HomePresenterTest { ApplicationScope(backgroundScope), false, TestDeleteClaimIntentDraftUseCase(), + noopCrossSellImpressionTracker, ) val addonOnlyCrossSells = CrossSellSheetData(null, listOf(), testAddon) @@ -257,6 +262,7 @@ internal class HomePresenterTest { ApplicationScope(backgroundScope), false, TestDeleteClaimIntentDraftUseCase(), + noopCrossSellImpressionTracker, ) homePresenter.test(HomeUiState.Loading) { @@ -311,6 +317,7 @@ internal class HomePresenterTest { ApplicationScope(backgroundScope), false, TestDeleteClaimIntentDraftUseCase(), + noopCrossSellImpressionTracker, ) homePresenter.test(HomeUiState.Loading) { @@ -336,6 +343,7 @@ internal class HomePresenterTest { ApplicationScope(backgroundScope), false, TestDeleteClaimIntentDraftUseCase(), + noopCrossSellImpressionTracker, ) homePresenter.test(HomeUiState.Loading) { @@ -375,6 +383,7 @@ internal class HomePresenterTest { ApplicationScope(backgroundScope), false, TestDeleteClaimIntentDraftUseCase(), + noopCrossSellImpressionTracker, ) homePresenter.test(HomeUiState.Loading) { @@ -425,6 +434,7 @@ internal class HomePresenterTest { ApplicationScope(backgroundScope), false, TestDeleteClaimIntentDraftUseCase(), + noopCrossSellImpressionTracker, ) val firstVet = FirstVetSection( buttonTitle = "ButtonTitle", @@ -482,6 +492,7 @@ internal class HomePresenterTest { ApplicationScope(backgroundScope), false, TestDeleteClaimIntentDraftUseCase(), + noopCrossSellImpressionTracker, ) val crossSell = CrossSell( id = "id", @@ -546,6 +557,7 @@ internal class HomePresenterTest { ApplicationScope(backgroundScope), false, TestDeleteClaimIntentDraftUseCase(), + noopCrossSellImpressionTracker, ) homePresenter.test(HomeUiState.Loading) { assertThat(awaitItem()).isEqualTo(HomeUiState.Loading) @@ -595,6 +607,7 @@ internal class HomePresenterTest { ApplicationScope(backgroundScope), false, TestDeleteClaimIntentDraftUseCase(), + noopCrossSellImpressionTracker, ) homePresenter.test(HomeUiState.Loading) { assertThat(awaitItem()).isEqualTo(HomeUiState.Loading) @@ -644,6 +657,7 @@ internal class HomePresenterTest { ApplicationScope(backgroundScope), false, TestDeleteClaimIntentDraftUseCase(), + noopCrossSellImpressionTracker, ) val otherCrossSell = CrossSell( id = "other", @@ -683,6 +697,7 @@ internal class HomePresenterTest { ApplicationScope(backgroundScope), false, deleteClaimIntentDraftUseCase, + noopCrossSellImpressionTracker, ) homePresenter.test(HomeUiState.Loading) { assertThat(awaitItem()).isEqualTo(HomeUiState.Loading) @@ -711,6 +726,7 @@ internal class HomePresenterTest { ApplicationScope(backgroundScope), false, TestDeleteClaimIntentDraftUseCase(), + noopCrossSellImpressionTracker, ) homePresenter.test(HomeUiState.Loading) { assertThat(awaitItem()).isEqualTo(HomeUiState.Loading) @@ -738,6 +754,7 @@ internal class HomePresenterTest { ApplicationScope(backgroundScope), false, deleteClaimIntentDraftUseCase, + noopCrossSellImpressionTracker, ) homePresenter.test(HomeUiState.Loading) { assertThat(awaitItem()).isEqualTo(HomeUiState.Loading) @@ -792,6 +809,8 @@ private class FakeCrossSellHomeNotificationService : CrossSellHomeNotificationSe } } +private val noopCrossSellImpressionTracker = CrossSellImpressionTracker { _, _, _, _ -> } + private class TestDeleteClaimIntentDraftUseCase : DeleteClaimIntentDraftUseCase { val deletedIdsTurbine = Turbine() var result: Either = Unit.right() diff --git a/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/insurance/InsuranceDestination.kt b/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/insurance/InsuranceDestination.kt index c23518b8ae..e64ec19fb8 100644 --- a/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/insurance/InsuranceDestination.kt +++ b/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/insurance/InsuranceDestination.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.layout.onVisibilityChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.heading @@ -53,7 +54,10 @@ import com.hedvig.android.compose.ui.preview.BooleanCollectionPreviewParameterPr import com.hedvig.android.compose.ui.preview.PreviewContentWithProvidedParametersAnimatedOnClick import com.hedvig.android.core.uidata.UiCurrencyCode import com.hedvig.android.core.uidata.UiMoney +import com.hedvig.android.crosssells.CROSS_SELL_IMPRESSION_MIN_DURATION_MS +import com.hedvig.android.crosssells.CROSS_SELL_IMPRESSION_MIN_FRACTION_VISIBLE import com.hedvig.android.crosssells.CrossSellItemPlaceholder +import com.hedvig.android.crosssells.CrossSellType import com.hedvig.android.crosssells.CrossSellsSection import com.hedvig.android.data.addons.data.AddonBannerInfo import com.hedvig.android.data.addons.data.FlowType @@ -129,6 +133,14 @@ internal fun InsuranceDestination( onNavigateToMovingFlow = onNavigateToMovingFlow, imageLoader = imageLoader, onNavigateToAddonPurchaseFlow = onNavigateToAddonPurchaseFlow, + onCrossSellImpression = { crossSell -> + viewModel.emit(InsuranceScreenEvent.CrossSellShown(crossSell.id, CrossSellType.NewPromise)) + }, + onAddonImpression = { bannerInfo -> + // Addon banners carry no per-offer id, so the flow type (lowercased to match enum-value casing) + // identifies the addon offer. + viewModel.emit(InsuranceScreenEvent.CrossSellShown(bannerInfo.flowType.name.lowercase(), CrossSellType.Addon)) + }, ) } @@ -142,6 +154,8 @@ private fun InsuranceScreen( onNavigateToMovingFlow: () -> Unit, imageLoader: ImageLoader, onNavigateToAddonPurchaseFlow: (List) -> Unit, + onCrossSellImpression: (CrossSell) -> Unit = {}, + onAddonImpression: (AddonBannerInfo) -> Unit = {}, ) { val isRetrying = uiState.isRetrying val systemBarInsetTopDp = with(LocalDensity.current) { @@ -188,6 +202,8 @@ private fun InsuranceScreen( modifier = Modifier.fillMaxSize(), pullRefreshState = pullRefreshState, onNavigateToAddonPurchaseFlow = onNavigateToAddonPurchaseFlow, + onCrossSellImpression = onCrossSellImpression, + onAddonImpression = onAddonImpression, ) } } @@ -212,6 +228,8 @@ private fun InsuranceScreenContent( navigateToCancelledInsurances: () -> Unit, onNavigateToMovingFlow: () -> Unit, onNavigateToAddonPurchaseFlow: (List) -> Unit, + onCrossSellImpression: (CrossSell) -> Unit = {}, + onAddonImpression: (AddonBannerInfo) -> Unit = {}, modifier: Modifier = Modifier, ) { Column( @@ -265,6 +283,7 @@ private fun InsuranceScreenContent( onSheetDismissed = {}, imageLoader = imageLoader, hasCrossSellDiscounts = uiState.hasCrossSellDiscounts, + onCrossSellImpression = onCrossSellImpression, ) } if (uiState.addonBannerInfoList.isNotEmpty()) { @@ -287,6 +306,12 @@ private fun InsuranceScreenContent( }, modifier = Modifier .fillMaxWidth() + .onVisibilityChanged( + minDurationMs = CROSS_SELL_IMPRESSION_MIN_DURATION_MS, + minFractionVisible = CROSS_SELL_IMPRESSION_MIN_FRACTION_VISIBLE, + ) { visible -> + if (visible) onAddonImpression(bannerInfo) + } .padding(horizontal = 16.dp), ) if (index != uiState.addonBannerInfoList.lastIndex) { diff --git a/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/insurance/presentation/InsurancePresenter.kt b/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/insurance/presentation/InsurancePresenter.kt index 121664970f..8195585ab1 100644 --- a/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/insurance/presentation/InsurancePresenter.kt +++ b/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/insurance/presentation/InsurancePresenter.kt @@ -11,6 +11,9 @@ import androidx.compose.runtime.snapshots.Snapshot import arrow.core.Either import arrow.core.raise.either import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.crosssells.CrossSellImpressionTracker +import com.hedvig.android.crosssells.CrossSellType +import com.hedvig.android.crosssells.CrossSellUserFlow import com.hedvig.android.data.addons.data.AddonBannerInfo import com.hedvig.android.data.addons.data.AddonBannerSource import com.hedvig.android.data.addons.data.GetAddonBannerInfoUseCase @@ -30,6 +33,8 @@ import kotlinx.coroutines.flow.flow internal sealed interface InsuranceScreenEvent { data object RetryLoading : InsuranceScreenEvent + + data class CrossSellShown(val offerId: String, val crossSellType: CrossSellType) : InsuranceScreenEvent } internal data class InsuranceUiState( @@ -63,6 +68,7 @@ internal class InsurancePresenter( private val getInsuranceContractsUseCase: GetInsuranceContractsUseCase, private val getCrossSellsUseCase: GetCrossSellsUseCase, private val getAddonBannerInfoUseCase: GetAddonBannerInfoUseCase, + private val crossSellImpressionTracker: CrossSellImpressionTracker, ) : MoleculePresenter { @Composable override fun MoleculePresenterScope.present(lastState: InsuranceUiState): InsuranceUiState { @@ -75,10 +81,25 @@ internal class InsurancePresenter( var isRetrying by remember { mutableStateOf(false) } var didFailToLoad by remember { mutableStateOf(false) } var loadIteration by remember { mutableIntStateOf(0) } + val trackedImpressions = remember { mutableSetOf() } CollectEvents { event -> when (event) { - InsuranceScreenEvent.RetryLoading -> loadIteration++ + InsuranceScreenEvent.RetryLoading -> { + loadIteration++ + } + + is InsuranceScreenEvent.CrossSellShown -> { + val impressionKey = "${event.crossSellType.analyticsValue}:${event.offerId}" + if (trackedImpressions.add(impressionKey)) { + crossSellImpressionTracker.crossSellShown( + userFlow = CrossSellUserFlow.InsuranceScreen, + crossSellType = event.crossSellType, + offerId = event.offerId, + flowSource = null, + ) + } + } } } diff --git a/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/insurance/presentation/InsuranceViewModel.kt b/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/insurance/presentation/InsuranceViewModel.kt index 095b1f8aec..35c4dc21c0 100644 --- a/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/insurance/presentation/InsuranceViewModel.kt +++ b/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/insurance/presentation/InsuranceViewModel.kt @@ -2,6 +2,7 @@ package com.hedvig.android.feature.insurances.insurance.presentation import com.hedvig.android.core.common.di.ActivityRetainedScope import com.hedvig.android.core.common.di.HedvigViewModel +import com.hedvig.android.crosssells.CrossSellImpressionTracker import com.hedvig.android.data.addons.data.GetAddonBannerInfoUseCase import com.hedvig.android.feature.insurances.data.GetCrossSellsUseCase import com.hedvig.android.feature.insurances.data.GetInsuranceContractsUseCase @@ -14,11 +15,13 @@ internal class InsuranceViewModel( getInsuranceContractsUseCase: GetInsuranceContractsUseCase, getCrossSellsUseCase: GetCrossSellsUseCase, getAddonBannerInfoUseCase: GetAddonBannerInfoUseCase, + crossSellImpressionTracker: CrossSellImpressionTracker, ) : MoleculeViewModel( initialState = InsuranceUiState.initialState, presenter = InsurancePresenter( getInsuranceContractsUseCase = getInsuranceContractsUseCase, getCrossSellsUseCase = getCrossSellsUseCase, getAddonBannerInfoUseCase = getAddonBannerInfoUseCase, + crossSellImpressionTracker = crossSellImpressionTracker, ), ) diff --git a/app/feature/feature-insurances/src/test/kotlin/com/hedvig/android/feature/insurances/insurance/presentation/InsurancePresenterTest.kt b/app/feature/feature-insurances/src/test/kotlin/com/hedvig/android/feature/insurances/insurance/presentation/InsurancePresenterTest.kt index 1ab23b44eb..a41a53ed75 100644 --- a/app/feature/feature-insurances/src/test/kotlin/com/hedvig/android/feature/insurances/insurance/presentation/InsurancePresenterTest.kt +++ b/app/feature/feature-insurances/src/test/kotlin/com/hedvig/android/feature/insurances/insurance/presentation/InsurancePresenterTest.kt @@ -18,6 +18,7 @@ import com.google.testing.junit.testparameterinjector.TestParameterInjector import com.hedvig.android.core.common.ErrorMessage import com.hedvig.android.core.uidata.UiCurrencyCode import com.hedvig.android.core.uidata.UiMoney +import com.hedvig.android.crosssells.CrossSellImpressionTracker import com.hedvig.android.data.addons.data.AddonBannerInfo import com.hedvig.android.data.addons.data.AddonBannerSource import com.hedvig.android.data.addons.data.FlowType @@ -46,6 +47,8 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +private val noopCrossSellImpressionTracker = CrossSellImpressionTracker { _, _, _, _ -> } + @RunWith(TestParameterInjector::class) internal class InsurancePresenterTest { @get:Rule @@ -273,6 +276,7 @@ internal class InsurancePresenterTest { getInsuranceContractsUseCase, getCrossSellsUseCase, getTravelAddonBannerInfoUseCase, + noopCrossSellImpressionTracker, ) presenter.test(InsuranceUiState.initialState) { awaitItem().also { uiState -> @@ -307,6 +311,7 @@ internal class InsurancePresenterTest { getInsuranceContractsUseCase, getCrossSellsUseCase, getTravelAddonBannerInfoUseCase, + noopCrossSellImpressionTracker, ) presenter.test(InsuranceUiState.initialState) { skipItems(1) @@ -332,6 +337,7 @@ internal class InsurancePresenterTest { getInsuranceContractsUseCase, getCrossSellsUseCase, getTravelAddonBannerInfoUseCase, + noopCrossSellImpressionTracker, ) presenter.test(InsuranceUiState.initialState) { skipItems(1) @@ -357,6 +363,7 @@ internal class InsurancePresenterTest { getInsuranceContractsUseCase, getCrossSellsUseCase, getTravelAddonBannerInfoUseCase, + noopCrossSellImpressionTracker, ) presenter.test(InsuranceUiState.initialState) { skipItems(1) @@ -401,6 +408,7 @@ internal class InsurancePresenterTest { getInsuranceContractsUseCase, getCrossSellsUseCase, getTravelAddonBannerInfoUseCase, + noopCrossSellImpressionTracker, ) val allContracts = validContracts + terminatedContracts presenter.test(InsuranceUiState.initialState) { @@ -433,6 +441,7 @@ internal class InsurancePresenterTest { getInsuranceContractsUseCase, getCrossSellsUseCase, getTravelAddonBannerInfoUseCase, + noopCrossSellImpressionTracker, ) val contracts = validContracts.map { it.copy(supportsAddressChange = supportsAddressChange) } presenter.test(InsuranceUiState.initialState) { @@ -454,6 +463,7 @@ internal class InsurancePresenterTest { getInsuranceContractsUseCase, getCrossSellsUseCase, getTravelAddonBannerInfoUseCase, + noopCrossSellImpressionTracker, ) presenter.test(InsuranceUiState.initialState) { skipItems(1) @@ -473,6 +483,7 @@ internal class InsurancePresenterTest { getInsuranceContractsUseCase, getCrossSellsUseCase, getTravelAddonBannerInfoUseCase, + noopCrossSellImpressionTracker, ) presenter.test(InsuranceUiState.initialState) { skipItems(1) @@ -492,6 +503,7 @@ internal class InsurancePresenterTest { getInsuranceContractsUseCase, getCrossSellsUseCase, getTravelAddonBannerInfoUseCase, + noopCrossSellImpressionTracker, ) val initialState = InsuranceUiState( contracts = listOf(), diff --git a/app/ui/cross-sells/build.gradle.kts b/app/ui/cross-sells/build.gradle.kts index 05d6c95e2c..373c30888b 100644 --- a/app/ui/cross-sells/build.gradle.kts +++ b/app/ui/cross-sells/build.gradle.kts @@ -16,6 +16,7 @@ dependencies { implementation(libs.jetbrains.compose.ui) implementation(libs.jetbrains.lifecycle.runtime.compose) implementation(projects.composeUi) + implementation(projects.coreCommonPublic) implementation(projects.coreResources) implementation(projects.coreUiData) implementation(projects.dataAddons) diff --git a/app/ui/cross-sells/src/main/kotlin/com/hedvig/android/crosssells/CrossSellImpressionTracker.kt b/app/ui/cross-sells/src/main/kotlin/com/hedvig/android/crosssells/CrossSellImpressionTracker.kt new file mode 100644 index 0000000000..d81222c05c --- /dev/null +++ b/app/ui/cross-sells/src/main/kotlin/com/hedvig/android/crosssells/CrossSellImpressionTracker.kt @@ -0,0 +1,100 @@ +package com.hedvig.android.crosssells + +import com.hedvig.android.core.common.di.AppScope +import com.hedvig.android.core.tracking.EventTrackingClient +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject + +/** + * Fires the `cross_sell_shown` analytics event, once for each cross-sell offer surfaced to a member. + * + * Call [crossSellShown] per individual offer that becomes visible, so a sheet or list showing several offers + * produces several events. [CrossSellUserFlow] records which surface the offer appeared on; consumers segment on it + * because the surfaces fire at very different rates (the insurances tab far more often than the after-flow sheet). + */ +fun interface CrossSellImpressionTracker { + fun crossSellShown( + userFlow: CrossSellUserFlow, + crossSellType: CrossSellType, + offerId: String, + flowSource: CrossSellFlowSource?, + ) +} + +/** + * Fires one impression per offer shown in a [CrossSellSheetData], mirroring the sheet's render priority: an addon + * recommendation takes the primary slot over a cross-sell recommendation, and every [CrossSellSheetData.otherCrossSells] + * entry is a new-insurance offer. Used by the after-flow and home sheets, which render the same data. + */ +fun CrossSellImpressionTracker.crossSellSheetShown( + data: CrossSellSheetData, + userFlow: CrossSellUserFlow, + flowSource: CrossSellFlowSource? = null, +) { + when { + data.recommendedAddon != null -> { + crossSellShown(userFlow, CrossSellType.Addon, data.recommendedAddon.id, flowSource) + } + + data.recommendedCrossSell != null -> { + crossSellShown(userFlow, CrossSellType.NewPromise, data.recommendedCrossSell.crossSell.id, flowSource) + } + } + data.otherCrossSells.forEach { crossSell -> + crossSellShown(userFlow, CrossSellType.NewPromise, crossSell.id, flowSource) + } +} + +/** The surface a cross-sell offer was shown on. */ +enum class CrossSellUserFlow(val analyticsValue: String) { + SmartXSell("smart_x_sell"), + InsuranceCard("insurance_card"), + InsuranceScreen("insurance_screen"), + HomeScreen("home_screen"), +} + +/** Whether the offer is an add-on to an existing insurance or a new insurance. */ +enum class CrossSellType(val analyticsValue: String) { + Addon("addon"), + NewPromise("new_promise"), +} + +/** The self-service flow a member just completed when a [CrossSellUserFlow.SmartXSell] offer is shown. */ +enum class CrossSellFlowSource(val analyticsValue: String) { + ChangeTier("change_tier"), + ClosedClaim("closed_claim"), + Addon("addon"), + Moving("moving"), + EditCoInsured("edit_co_insured"), +} + +@ContributesBinding(AppScope::class) +@Inject +internal class CrossSellImpressionTrackerImpl( + private val eventTrackingClient: EventTrackingClient, +) : CrossSellImpressionTracker { + override fun crossSellShown( + userFlow: CrossSellUserFlow, + crossSellType: CrossSellType, + offerId: String, + flowSource: CrossSellFlowSource?, + ) { + eventTrackingClient.trackEvent( + name = CROSS_SELL_SHOWN_EVENT, + parameters = buildMap { + put("user_flow", userFlow.analyticsValue) + put("offer_type", crossSellType.analyticsValue) + put("offer_id", offerId) + if (flowSource != null) { + put("flow_source", flowSource.analyticsValue) + } + }, + ) + } +} + +private const val CROSS_SELL_SHOWN_EVENT = "cross_sell_shown" + +/** An offer counts as shown once at least half of it has been on screen for this long. */ +const val CROSS_SELL_IMPRESSION_MIN_DURATION_MS = 500L +const val CROSS_SELL_IMPRESSION_MIN_FRACTION_VISIBLE = 0.5f diff --git a/app/ui/cross-sells/src/main/kotlin/com/hedvig/android/crosssells/CrossSells.kt b/app/ui/cross-sells/src/main/kotlin/com/hedvig/android/crosssells/CrossSells.kt index f6546b48f3..07b10a47a9 100644 --- a/app/ui/cross-sells/src/main/kotlin/com/hedvig/android/crosssells/CrossSells.kt +++ b/app/ui/cross-sells/src/main/kotlin/com/hedvig/android/crosssells/CrossSells.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Shape import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onVisibilityChanged import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription @@ -612,35 +613,47 @@ fun CrossSellsSection( title: String? = null, buttonSize: ButtonDefaults.ButtonSize = ButtonDefaults.ButtonSize.Medium, buttonShape: Shape? = null, + onCrossSellImpression: ((CrossSell) -> Unit)? = null, ) { Column(modifier) { if (withSubHeader) { CrossSellsSubHeaderWithDivider(title) } for ((index, crossSell) in crossSells.withIndex()) { - if (hasCrossSellDiscounts) { - CrossSellItemWithDiscounts( - crossSellTitle = crossSell.title, - crossSellSubtitle = crossSell.subtitle, - storeUrl = crossSell.storeUrl, - onCrossSellClick = onCrossSellClick, - isLoading = false, - imageLoader = imageLoader, - crossSellImageAsset = crossSell.pillowImage, - onSheetDismissed = onSheetDismissed, - buttonText = crossSell.buttonText, - buttonSize = buttonSize, - buttonShape = buttonShape, - ) - } else { - CrossSellItem( - crossSell, - onCrossSellClick, - onSheetDismissed = onSheetDismissed, - imageLoader = imageLoader, - buttonSize = buttonSize, - buttonShape = buttonShape, - ) + Box( + Modifier + .fillMaxWidth() + .onVisibilityChanged( + minDurationMs = CROSS_SELL_IMPRESSION_MIN_DURATION_MS, + minFractionVisible = CROSS_SELL_IMPRESSION_MIN_FRACTION_VISIBLE, + ) { visible -> + if (visible) onCrossSellImpression?.invoke(crossSell) + }, + ) { + if (hasCrossSellDiscounts) { + CrossSellItemWithDiscounts( + crossSellTitle = crossSell.title, + crossSellSubtitle = crossSell.subtitle, + storeUrl = crossSell.storeUrl, + onCrossSellClick = onCrossSellClick, + isLoading = false, + imageLoader = imageLoader, + crossSellImageAsset = crossSell.pillowImage, + onSheetDismissed = onSheetDismissed, + buttonText = crossSell.buttonText, + buttonSize = buttonSize, + buttonShape = buttonShape, + ) + } else { + CrossSellItem( + crossSell, + onCrossSellClick, + onSheetDismissed = onSheetDismissed, + imageLoader = imageLoader, + buttonSize = buttonSize, + buttonShape = buttonShape, + ) + } } if (index != crossSells.lastIndex) { Spacer(Modifier.height(16.dp))