Skip to content

Commit

Permalink
feat(earn): add earn info screen (#5456)
Browse files Browse the repository at this point in the history
### Description

Adds the earn info screen in a stable coin agnostic way and passes the
tokenId to `EarnEnterAmount.tsx`.

#### Screenshots

| iOS | Android |
| ----- | ----- |
|
![](https://github.com/valora-inc/wallet/assets/26950305/82fbbaf6-10ee-4341-86a0-7f3b3046cb71)
|
![](https://github.com/valora-inc/wallet/assets/26950305/bc1fea45-2754-4106-9cda-7247b42da117)
|

### Test plan

For easy testing I hooked it up the screen in
`src/home/NotificationBell.tsx`

```TypeScript
   const onPress = () => {
     ValoraAnalytics.track(HomeEvents.notification_bell_pressed, { hasNotifications })
-    navigate(Screens.NotificationCenter)
+    navigate(Screens.EarnInfoScreen, { tokenId: networkConfig.arbUsdcTokenId })
   }

```

- [x] Tested locally on iOS
- [x] Tested locally on Android
- [x] Unit tests added

### Related issues

- Fixed ACT-1192

### Backwards compatibility

Yes

### Network scalability

If a new NetworkId and/or Network are added in the future, the changes
in this PR will:

- [x] Continue to work without code changes, OR trigger a compilation
error (guaranteeing we find it when a new network is added)
  • Loading branch information
MuckT authored May 24, 2024
1 parent 9adf90e commit 96c9e13
Show file tree
Hide file tree
Showing 14 changed files with 387 additions and 0 deletions.
1 change: 1 addition & 0 deletions branding/celo/src/brandingConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ export const INVITE_REWARDS_NFTS_LEARN_MORE =
'https://valoraapp.com/support/invite-rewards-nfts-learn-more'
export const INVITE_REWARDS_STABLETOKEN_LEARN_MORE =
'https://valoraapp.com/support/invite-rewards-stabletoken-learn-more'
export const EARN_STABLECOINS_LEARN_MORE = 'https://valoraapp.com/support/earn-on-your-stablecoins'
23 changes: 23 additions & 0 deletions locales/base/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -2377,6 +2377,29 @@
"subtitle": "Deposit today and earn returns",
"description": "See how much you can earn when supplying a lending pool"
},
"earnInfo": {
"title": "Earn on your\nstablecoins",
"details": {
"earn": {
"titleGasSubsidy": "Maximize Earnings without Gas Fees*",
"title": "Maximize Earnings",
"subtitle": "Earn on your stablecoins and get rewards when you supply a pool.",
"footnoteSubsidy": "*Gas fees are temporarily covered by Valora"
},
"manage": {
"title": "Manage Directly from Valora",
"subtitle": "We’ll take care of the setup so you can manage your position directly from Valora."
},
"access": {
"title": "Easily Access your Funds",
"subtitle": "Collect your funds effortlessly with a single tap. Enjoy the same instant access as you would directly through your favorite protocols."
}
},
"action": {
"learn": "Learn More",
"earn": "Start Earning"
}
},
"ctaV1_86": {
"title": "Maximize earnings on stablecoins",
"subtitle": "Buy {{symbol}}",
Expand Down
2 changes: 2 additions & 0 deletions src/analytics/Events.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -703,4 +703,6 @@ export enum EarnEvents {
earn_withdraw_submit_error = 'earn_withdraw_submit_error',
earn_withdraw_submit_cancel = 'earn_withdraw_submit_cancel',
earn_withdraw_add_gas_press = 'earn_withdraw_add_gas_press',
earn_info_learn_press = 'earn_info_learn_press',
earn_info_earn_press = 'earn_info_earn_press',
}
4 changes: 4 additions & 0 deletions src/analytics/Properties.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1651,6 +1651,10 @@ interface EarnEventsProperties {
}
[EarnEvents.earn_withdraw_submit_cancel]: EarnWithdrawProperties
[EarnEvents.earn_withdraw_add_gas_press]: { gasTokenId: string }
[EarnEvents.earn_info_learn_press]: undefined
[EarnEvents.earn_info_earn_press]: {
tokenId: string
}
}

export type AnalyticsPropertiesList = AppEventsProperties &
Expand Down
2 changes: 2 additions & 0 deletions src/analytics/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,8 @@ export const eventDocs: Record<AnalyticsEventType, string> = {
[EarnEvents.earn_withdraw_submit_error]: `When the withdraw and claim transactions fail`,
[EarnEvents.earn_withdraw_submit_cancel]: `When the user cancels the withdraw and claim transactions after submitting by cancelling PIN input`,
[EarnEvents.earn_withdraw_add_gas_press]: `When the user doesn't have enough for gas and clicks on the button to add gas token`,
[EarnEvents.earn_info_learn_press]: `When the user taps 'Learn More' on the earn info page`,
[EarnEvents.earn_info_earn_press]: `When the user taps 'Start Earning' on the earn info page `,

// Legacy event docs
// The below events had docs, but are no longer produced by the latest app version.
Expand Down
97 changes: 97 additions & 0 deletions src/earn/EarnInfoScreen.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { fireEvent, render } from '@testing-library/react-native'
import React from 'react'
import { Provider } from 'react-redux'
import { EarnEvents } from 'src/analytics/Events'
import ValoraAnalytics from 'src/analytics/ValoraAnalytics'
import { EARN_STABLECOINS_LEARN_MORE } from 'src/config'
import EarnInfoScreen from 'src/earn/EarnInfoScreen'
import { navigate } from 'src/navigator/NavigationService'
import { Screens } from 'src/navigator/Screens'
import { getFeatureGate } from 'src/statsig'
import { StatsigFeatureGates } from 'src/statsig/types'
import networkConfig from 'src/web3/networkConfig'
import MockedNavigator from 'test/MockedNavigator'
import { createMockStore } from 'test/utils'

jest.mock('src/statsig', () => ({
getFeatureGate: jest.fn(),
}))

const store = createMockStore({})
const tokenId = networkConfig.arbUsdcTokenId

describe('EarnInfoScreen', () => {
beforeEach(() => {
jest.clearAllMocks()
})

it('should render correctly when no gas subsidy', async () => {
const { getByText, queryByText } = render(
<Provider store={store}>
<MockedNavigator component={EarnInfoScreen} params={{ tokenId }} />
</Provider>
)

// First details item - includes subsidy code
expect(getByText('earnFlow.earnInfo.title')).toBeTruthy()
expect(getByText('earnFlow.earnInfo.details.earn.title')).toBeTruthy()
expect(queryByText('earnFlow.earnInfo.details.earn.titleGasSubsidy')).toBeFalsy()
expect(queryByText('earnFlow.earnInfo.details.earn.footnoteSubsidy')).toBeFalsy()

// Second details item
expect(getByText('earnFlow.earnInfo.details.manage.title')).toBeTruthy()
expect(getByText('earnFlow.earnInfo.details.manage.subtitle')).toBeTruthy()

// Third details item
expect(getByText('earnFlow.earnInfo.details.access.title')).toBeTruthy()
expect(getByText('earnFlow.earnInfo.details.access.subtitle')).toBeTruthy()

// Buttons
expect(getByText('earnFlow.earnInfo.action.learn')).toBeTruthy()
expect(getByText('earnFlow.earnInfo.action.earn')).toBeTruthy()
})

it('should render correctly when gas subsidy enabled', () => {
jest
.mocked(getFeatureGate)
.mockImplementation((gate) => gate === StatsigFeatureGates.SUBSIDIZE_STABLECOIN_EARN_GAS_FEES)

const { getByText, queryByText } = render(
<Provider store={store}>
<MockedNavigator component={EarnInfoScreen} params={{ tokenId }} />
</Provider>
)

expect(getByText('earnFlow.earnInfo.details.earn.titleGasSubsidy')).toBeTruthy()
expect(getByText('earnFlow.earnInfo.details.earn.footnoteSubsidy')).toBeTruthy()
expect(queryByText('earnFlow.earnInfo.details.earn.title')).toBeFalsy()
})

it('should navigate and fire analytics correctly on Learn More button press', () => {
const { getByText } = render(
<Provider store={store}>
<MockedNavigator component={EarnInfoScreen} params={{ tokenId }} />
</Provider>
)

fireEvent.press(getByText('earnFlow.earnInfo.action.learn'))
expect(navigate).toHaveBeenCalledWith(Screens.WebViewScreen, {
uri: EARN_STABLECOINS_LEARN_MORE,
})
expect(ValoraAnalytics.track).toHaveBeenCalledWith(EarnEvents.earn_info_learn_press)
})

it('should navigate and fire analytics correctly on Start Earning button press', () => {
const { getByText } = render(
<Provider store={store}>
<MockedNavigator component={EarnInfoScreen} params={{ tokenId }} />
</Provider>
)

fireEvent.press(getByText('earnFlow.earnInfo.action.earn'))
expect(navigate).toHaveBeenCalledWith(Screens.EarnEnterAmount, {
tokenId,
})
expect(ValoraAnalytics.track).toHaveBeenCalledWith(EarnEvents.earn_info_earn_press, { tokenId })
})
})
185 changes: 185 additions & 0 deletions src/earn/EarnInfoScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { useHeaderHeight } from '@react-navigation/elements'
import { NativeStackScreenProps } from '@react-navigation/native-stack'
import React, { ReactElement } from 'react'
import { useTranslation } from 'react-i18next'
import { Platform, ScrollView, StyleSheet, Text, View } from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { EarnEvents } from 'src/analytics/Events'
import ValoraAnalytics from 'src/analytics/ValoraAnalytics'
import Button, { BtnSizes, BtnTypes } from 'src/components/Button'
import { EARN_STABLECOINS_LEARN_MORE } from 'src/config'
import ArrowDown from 'src/icons/ArrowDown'
import Blob from 'src/icons/Blob'
import CircledIcon from 'src/icons/CircledIcon'
import Logo from 'src/icons/Logo'
import Palm from 'src/icons/Palm'
import UpwardGraph from 'src/icons/UpwardGraph'
import { headerWithCloseButton } from 'src/navigator/Headers'
import { navigate } from 'src/navigator/NavigationService'
import { Screens } from 'src/navigator/Screens'
import { StackParamList } from 'src/navigator/types'
import { getFeatureGate } from 'src/statsig'
import { StatsigFeatureGates } from 'src/statsig/types'
import Colors from 'src/styles/colors'
import { typeScale } from 'src/styles/fonts'
import { Spacing } from 'src/styles/styles'

const ICON_SIZE = 24
const ICON_BACKGROUND_CIRCLE_SIZE = 36

function DetailsItem({
icon,
title,
subtitle,
footnote,
}: {
icon: ReactElement
title: string
subtitle: string
footnote?: string
}) {
return (
<View style={styles.detailsItemContainer}>
<CircledIcon backgroundColor={Colors.gray2} radius={ICON_BACKGROUND_CIRCLE_SIZE}>
{icon}
</CircledIcon>
<View style={styles.flex}>
<Text style={styles.detailsItemTitle}>{title}</Text>
<Text style={styles.detailsItemSubtitle}>{subtitle}</Text>
{!!footnote && <Text style={styles.detailsItemFootnote}>{footnote}</Text>}
</View>
</View>
)
}

type Props = NativeStackScreenProps<StackParamList, Screens.EarnInfoScreen>

export default function EarnInfoScreen({ route }: Props) {
const { t } = useTranslation()
const { tokenId } = route.params
const isGasSubsidized = getFeatureGate(StatsigFeatureGates.SUBSIDIZE_STABLECOIN_EARN_GAS_FEES)

const headerHeight = useHeaderHeight()
const { bottom } = useSafeAreaInsets()
const insetsStyle = Platform.OS === 'android' && {
paddingBottom: Math.max(bottom, Spacing.Regular16),
}

return (
<SafeAreaView
style={[styles.safeAreaContainer, { paddingTop: headerHeight }]}
edges={['bottom']}
>
<ScrollView>
<Text style={styles.title}>{t('earnFlow.earnInfo.title')}</Text>
<View style={styles.detailsContainer}>
<DetailsItem
icon={<UpwardGraph size={ICON_SIZE} color={Colors.black} />}
title={
isGasSubsidized
? t('earnFlow.earnInfo.details.earn.titleGasSubsidy')
: t('earnFlow.earnInfo.details.earn.title')
}
subtitle={t('earnFlow.earnInfo.details.earn.subtitle')}
footnote={
isGasSubsidized ? t('earnFlow.earnInfo.details.earn.footnoteSubsidy') : undefined
}
/>
<DetailsItem
icon={<Logo size={ICON_SIZE} color={Colors.black} />}
title={t('earnFlow.earnInfo.details.manage.title')}
subtitle={t('earnFlow.earnInfo.details.manage.subtitle')}
/>
<DetailsItem
icon={<ArrowDown size={ICON_SIZE} color={Colors.black} />}
title={t('earnFlow.earnInfo.details.access.title')}
subtitle={t('earnFlow.earnInfo.details.access.subtitle')}
/>
</View>
</ScrollView>
<View style={[styles.buttonContainer, insetsStyle]}>
<Button
onPress={() => {
ValoraAnalytics.track(EarnEvents.earn_info_learn_press)
navigate(Screens.WebViewScreen, { uri: EARN_STABLECOINS_LEARN_MORE })
}}
text={t('earnFlow.earnInfo.action.learn')}
type={BtnTypes.SECONDARY}
size={BtnSizes.FULL}
/>
<Button
onPress={() => {
ValoraAnalytics.track(EarnEvents.earn_info_earn_press, { tokenId })
navigate(Screens.EarnEnterAmount, { tokenId })
}}
text={t('earnFlow.earnInfo.action.earn')}
type={BtnTypes.PRIMARY}
size={BtnSizes.FULL}
/>
</View>
<Blob
style={{
position: 'absolute',
left: 53,
zIndex: -1,
}}
/>
<Palm
style={{
position: 'absolute',
bottom: 0,
zIndex: -1,
}}
/>
</SafeAreaView>
)
}

EarnInfoScreen.navigationOptions = () => ({
...headerWithCloseButton,
headerTransparent: true,
headerShown: true,
headerStyle: {
backgroundColor: 'transparent',
},
})

const styles = StyleSheet.create({
safeAreaContainer: {
flex: 1,
paddingHorizontal: Spacing.Regular16,
},
flex: {
flex: 1,
},
title: {
color: Colors.black,
textAlign: 'center',
marginBottom: Spacing.Thick24,
...typeScale.titleLarge,
},
detailsContainer: {
gap: Spacing.Large32,
},
detailsItemContainer: {
flexDirection: 'row',
gap: Spacing.Regular16,
},
detailsItemTitle: {
color: Colors.black,
...typeScale.labelSemiBoldSmall,
},
detailsItemSubtitle: {
color: Colors.black,
...typeScale.bodyXSmall,
},
detailsItemFootnote: {
color: Colors.black,
marginTop: Spacing.Smallest8,
...typeScale.bodyXXSmall,
},
buttonContainer: {
gap: Spacing.Smallest8,
marginHorizontal: Spacing.Smallest8,
},
})
20 changes: 20 additions & 0 deletions src/icons/ArrowDown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import * as React from 'react'
import Svg, { Path } from 'react-native-svg'
import { Colors } from 'src/styles/colors'

interface Props {
color?: Colors
size?: number
}

const ArrowDown = ({ color = Colors.black, size = 24 }: Props) => (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Path
id="Vector"
d="M11.053 4.5L12.947 4.5L12.947 15.8636L18.1553 10.6553L19.5 12L12 19.5L4.5 12L5.8447 10.6553L11.053 15.8636L11.053 4.5Z"
fill={color}
/>
</Svg>
)

export default React.memo(ArrowDown)
21 changes: 21 additions & 0 deletions src/icons/Blob.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import React from 'react'
import { ViewStyle } from 'react-native'
import Svg, { Path } from 'react-native-svg'

interface Props {
width?: number
height?: number
color?: string
style?: ViewStyle
}

export default function Blob({ width = 210, height = 212.5, color = '#FFD62C', style }: Props) {
return (
<Svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} fill="none" style={style}>
<Path
d="M102.846 -29.5006C109.785 -29.5006 116.724 -29.8502 123.638 -29.3508C126.971 -29.1261 130.652 -28.0025 133.388 -26.13C144.53 -18.4399 155.398 -10.3254 166.292 -2.26089C171.167 1.35942 175.693 5.4291 180.593 8.99948C193.277 18.2625 201.832 30.5965 207.776 44.928C210.661 51.894 210.189 59.2594 209.393 66.4501C206.632 91.3429 200.439 115.287 187.979 137.233C179.598 151.989 167.486 163.35 153.608 172.737C149.504 175.509 144.232 177.007 139.282 178.006C129.483 180.003 119.51 182.525 109.636 182.5C96.181 182.475 82.4771 181.276 69.8676 175.709C44.6237 164.548 24.0059 147.945 10.2772 123.451C0.428372 105.924 -2.38205 87.5228 2.02009 67.8233C9.48134 34.5414 27.1396 7.90092 54.3483 -12.0482C67.6044 -21.7607 82.3776 -29.0013 99.3893 -29.7503C100.558 -29.8002 101.727 -29.9251 102.871 -30C102.871 -29.8252 102.871 -29.6504 102.871 -29.4756L102.846 -29.5006Z"
fill={color}
/>
</Svg>
)
}
Loading

0 comments on commit 96c9e13

Please sign in to comment.