From 07016ab93fc486f25cb7e6a2800839d1131b4e38 Mon Sep 17 00:00:00 2001 From: Olivier Freyssinet Date: Tue, 29 Oct 2024 12:04:59 +0100 Subject: [PATCH] feat(llm/post onboarding): auto redirection to Recover upsell & post onboarding --- .changeset/green-rice-cheer.md | 5 +- .../src/actions/settings.ts | 6 + apps/ledger-live-mobile/src/actions/types.ts | 3 + .../useAutoRedirectToPostOnboarding/index.ts | 39 ++++ .../useOpenPostOnboardingCallback.ts | 20 ++ .../useOpenProtectUpsellCallback.ts | 73 ++++++ .../useShouldRedirect.test.ts | 215 ++++++++++++++++++ .../useShouldRedirect.ts | 40 ++++ .../src/reducers/settings.ts | 10 + apps/ledger-live-mobile/src/reducers/types.ts | 1 + .../screens/MyLedgerChooseDevice/index.tsx | 10 + .../src/screens/Onboarding/steps/pairNew.tsx | 18 +- .../src/screens/Portfolio/index.tsx | 61 +---- .../PostOnboarding/PostOnboardingHub.tsx | 13 +- .../Debug/Configuration/RecoverUpsellRow.tsx | 34 +++ .../Configuration/ResetOnboardingStateRow.tsx | 10 +- .../Settings/Debug/Configuration/index.tsx | 2 + .../SyncOnboarding/CompletionScreen.tsx | 54 +++-- .../SyncOnboardingCompanion.tsx | 2 +- .../src/hooks/recoverFeatureFlag.ts | 15 ++ 20 files changed, 523 insertions(+), 108 deletions(-) create mode 100644 apps/ledger-live-mobile/src/hooks/useAutoRedirectToPostOnboarding/index.ts create mode 100644 apps/ledger-live-mobile/src/hooks/useAutoRedirectToPostOnboarding/useOpenPostOnboardingCallback.ts create mode 100644 apps/ledger-live-mobile/src/hooks/useAutoRedirectToPostOnboarding/useOpenProtectUpsellCallback.ts create mode 100644 apps/ledger-live-mobile/src/hooks/useAutoRedirectToPostOnboarding/useShouldRedirect.test.ts create mode 100644 apps/ledger-live-mobile/src/hooks/useAutoRedirectToPostOnboarding/useShouldRedirect.ts create mode 100644 apps/ledger-live-mobile/src/screens/Settings/Debug/Configuration/RecoverUpsellRow.tsx diff --git a/.changeset/green-rice-cheer.md b/.changeset/green-rice-cheer.md index cf14d04f993f..028c42b9fd23 100644 --- a/.changeset/green-rice-cheer.md +++ b/.changeset/green-rice-cheer.md @@ -2,4 +2,7 @@ "live-mobile": patch --- -Hide Backup section during onboarding on Flex and Stax +Stax/Flex onboarding: + - Hide "Backup with Recover" section + - Auto redirect to Recover upsell between the onboarding and the post onboarding + diff --git a/apps/ledger-live-mobile/src/actions/settings.ts b/apps/ledger-live-mobile/src/actions/settings.ts index 3e3065bc28ed..2e01412b6e31 100755 --- a/apps/ledger-live-mobile/src/actions/settings.ts +++ b/apps/ledger-live-mobile/src/actions/settings.ts @@ -70,6 +70,7 @@ import { SettingsAddStarredMarketcoinsPayload, SettingsRemoveStarredMarketcoinsPayload, SettingsSetFromLedgerSyncOnboardingPayload, + SettingsSetHasBeenRedirectedToPostOnboardingPayload, } from "./types"; import { ImageType } from "~/components/CustomImage/types"; @@ -264,6 +265,11 @@ export const setHasBeenUpsoldProtect = createAction( + SettingsActionTypes.SET_HAS_BEEN_REDIRECTED_TO_POST_ONBOARDING, + ); + export const setGeneralTermsVersionAccepted = createAction( SettingsActionTypes.SET_GENERAL_TERMS_VERSION_ACCEPTED, ); diff --git a/apps/ledger-live-mobile/src/actions/types.ts b/apps/ledger-live-mobile/src/actions/types.ts index a4e20fdd3dd9..7493911df020 100644 --- a/apps/ledger-live-mobile/src/actions/types.ts +++ b/apps/ledger-live-mobile/src/actions/types.ts @@ -273,6 +273,7 @@ export enum SettingsActionTypes { SET_FEATURE_FLAGS_BANNER_VISIBLE = "SET_FEATURE_FLAGS_BANNER_VISIBLE", SET_DEBUG_APP_LEVEL_DRAWER_OPENED = "SET_DEBUG_APP_LEVEL_DRAWER_OPENED", SET_HAS_BEEN_UPSOLD_PROTECT = "SET_HAS_BEEN_UPSOLD_PROTECT", + SET_HAS_BEEN_REDIRECTED_TO_POST_ONBOARDING = "SET_HAS_BEEN_REDIRECTED_TO_POST_ONBOARDING", SET_GENERAL_TERMS_VERSION_ACCEPTED = "SET_GENERAL_TERMS_VERSION_ACCEPTED", SET_ONBOARDING_TYPE = "SET_ONBOARDING_TYPE", SET_CLOSED_NETWORK_BANNER = "SET_CLOSED_NETWORK_BANNER", @@ -379,6 +380,8 @@ export type SettingsSetDebugAppLevelDrawerOpenedPayload = SettingsState["debugAppLevelDrawerOpened"]; export type SettingsSetHasBeenUpsoldProtectPayload = SettingsState["hasBeenUpsoldProtect"]; +export type SettingsSetHasBeenRedirectedToPostOnboardingPayload = + SettingsState["hasBeenRedirectedToPostOnboarding"]; export type SettingsCompleteOnboardingPayload = void | SettingsState["hasCompletedOnboarding"]; export type SettingsSetGeneralTermsVersionAccepted = SettingsState["generalTermsVersionAccepted"]; diff --git a/apps/ledger-live-mobile/src/hooks/useAutoRedirectToPostOnboarding/index.ts b/apps/ledger-live-mobile/src/hooks/useAutoRedirectToPostOnboarding/index.ts new file mode 100644 index 000000000000..7800bc38e010 --- /dev/null +++ b/apps/ledger-live-mobile/src/hooks/useAutoRedirectToPostOnboarding/index.ts @@ -0,0 +1,39 @@ +import { useEffect } from "react"; +import { useSelector } from "react-redux"; +import { lastConnectedDeviceSelector } from "~/reducers/settings"; +import { useOpenPostOnboardingCallback } from "./useOpenPostOnboardingCallback"; +import { useShouldRedirect } from "./useShouldRedirect"; +import { useOpenProtectUpsellCallback } from "./useOpenProtectUpsellCallback"; +import { useIsFocused } from "@react-navigation/core"; + +/** + * Redirects the user to the post onboarding or the protect (Ledger Recover) upsell if needed + * */ +export function useAutoRedirectToPostOnboarding() { + const focused = useIsFocused(); + const lastConnectedDevice = useSelector(lastConnectedDeviceSelector); + + const { shouldRedirectToProtectUpsell, shouldRedirectToPostOnboarding } = useShouldRedirect(); + + const openProtectUpsell = useOpenProtectUpsellCallback(); + const openPostOnboarding = useOpenPostOnboardingCallback(); + + const isFocused = useIsFocused(); + + useEffect(() => { + if (!isFocused) return; + if (shouldRedirectToProtectUpsell) { + openProtectUpsell(); + } else if (shouldRedirectToPostOnboarding && lastConnectedDevice) { + openPostOnboarding(lastConnectedDevice.modelId); + } + }, [ + lastConnectedDevice, + openPostOnboarding, + openProtectUpsell, + shouldRedirectToPostOnboarding, + shouldRedirectToProtectUpsell, + focused, + isFocused, + ]); +} diff --git a/apps/ledger-live-mobile/src/hooks/useAutoRedirectToPostOnboarding/useOpenPostOnboardingCallback.ts b/apps/ledger-live-mobile/src/hooks/useAutoRedirectToPostOnboarding/useOpenPostOnboardingCallback.ts new file mode 100644 index 000000000000..38bcb51ebc04 --- /dev/null +++ b/apps/ledger-live-mobile/src/hooks/useAutoRedirectToPostOnboarding/useOpenPostOnboardingCallback.ts @@ -0,0 +1,20 @@ +import { useStartPostOnboardingCallback } from "@ledgerhq/live-common/postOnboarding/hooks/useStartPostOnboardingCallback"; +import { DeviceModelId } from "@ledgerhq/types-devices"; +import { useCallback } from "react"; + +/** + * Returns a callback to open the post onboarding screen + * */ +export function useOpenPostOnboardingCallback() { + const startPostOnboarding = useStartPostOnboardingCallback(); + return useCallback( + (deviceModelId: DeviceModelId) => { + startPostOnboarding({ + deviceModelId: deviceModelId, + resetNavigationStack: false, + fallbackIfNoAction: () => {}, + }); + }, + [startPostOnboarding], + ); +} diff --git a/apps/ledger-live-mobile/src/hooks/useAutoRedirectToPostOnboarding/useOpenProtectUpsellCallback.ts b/apps/ledger-live-mobile/src/hooks/useAutoRedirectToPostOnboarding/useOpenProtectUpsellCallback.ts new file mode 100644 index 000000000000..5e40f0c89f2f --- /dev/null +++ b/apps/ledger-live-mobile/src/hooks/useAutoRedirectToPostOnboarding/useOpenProtectUpsellCallback.ts @@ -0,0 +1,73 @@ +import { useFeature } from "@ledgerhq/live-common/featureFlags/index"; +import { + Source, + useAlreadyOnboardedURI, + useHomeURI, + usePostOnboardingURI, + useTouchScreenOnboardingUpsellURI, +} from "@ledgerhq/live-common/hooks/recoverFeatureFlag"; +import { DeviceModelId } from "@ledgerhq/types-devices"; +import { useIsFocused } from "@react-navigation/core"; +import { useCallback, useEffect, useState } from "react"; +import { Linking } from "react-native"; +import { useDispatch, useSelector } from "react-redux"; +import { setHasBeenUpsoldProtect } from "~/actions/settings"; +import { internetReachable } from "~/logic/internetReachable"; +import { lastConnectedDeviceSelector, onboardingTypeSelector } from "~/reducers/settings"; +import { OnboardingType } from "~/reducers/types"; + +/** + * Returns a callback to open the Protect (Ledger Recover) upsell + * */ +export function useOpenProtectUpsellCallback() { + const lastConnectedDevice = useSelector(lastConnectedDeviceSelector); + const onboardingType = useSelector(onboardingTypeSelector); + const protectFeature = useFeature("protectServicesMobile"); + const recoverAlreadyOnboardedURI = useAlreadyOnboardedURI(protectFeature); + const recoverPostOnboardingURI = usePostOnboardingURI(protectFeature); + const touchScreenURI = useTouchScreenOnboardingUpsellURI( + protectFeature, + Source.LLM_ONBOARDING_24, + ); + const recoverHomeURI = useHomeURI(protectFeature); + const dispatch = useDispatch(); + const [redirectionStarted, setRedirectionStarted] = useState(false); + const isFocused = useIsFocused(); + + useEffect(() => { + if (redirectionStarted && !isFocused) { + dispatch(setHasBeenUpsoldProtect(true)); + } + }, [redirectionStarted, isFocused, dispatch]); + + return useCallback(async () => { + const internetConnected = await internetReachable(); + if (internetConnected && protectFeature?.enabled) { + if ( + lastConnectedDevice && + touchScreenURI && + [DeviceModelId.stax, DeviceModelId.europa].includes(lastConnectedDevice.modelId) + ) { + Linking.openURL(touchScreenURI); + setRedirectionStarted(true); + } else if (recoverPostOnboardingURI && onboardingType === OnboardingType.restore) { + Linking.openURL(recoverPostOnboardingURI); + setRedirectionStarted(true); + } else if (recoverHomeURI && onboardingType === OnboardingType.setupNew) { + Linking.openURL(recoverHomeURI); + setRedirectionStarted(true); + } else if (recoverAlreadyOnboardedURI) { + Linking.openURL(recoverAlreadyOnboardedURI); + setRedirectionStarted(true); + } + } + }, [ + lastConnectedDevice, + onboardingType, + protectFeature?.enabled, + recoverAlreadyOnboardedURI, + recoverHomeURI, + recoverPostOnboardingURI, + touchScreenURI, + ]); +} diff --git a/apps/ledger-live-mobile/src/hooks/useAutoRedirectToPostOnboarding/useShouldRedirect.test.ts b/apps/ledger-live-mobile/src/hooks/useAutoRedirectToPostOnboarding/useShouldRedirect.test.ts new file mode 100644 index 000000000000..c81e2a0cf7d0 --- /dev/null +++ b/apps/ledger-live-mobile/src/hooks/useAutoRedirectToPostOnboarding/useShouldRedirect.test.ts @@ -0,0 +1,215 @@ +import { Device } from "@ledgerhq/live-common/hw/actions/types"; +import { useShouldRedirect } from "./useShouldRedirect"; +import { DeviceModelId } from "@ledgerhq/types-devices"; + +jest.mock("react-redux", () => ({ + useSelector: (fn: () => void) => fn(), +})); + +jest.mock("@ledgerhq/live-common/featureFlags/index", () => ({ + useFeature: jest.fn(), +})); + +jest.mock("~/reducers/settings", () => ({ + hasBeenUpsoldProtectSelector: jest.fn(), + hasBeenRedirectedToPostOnboardingSelector: jest.fn(), + lastConnectedDeviceSelector: jest.fn(), +})); + +const { useFeature } = jest.requireMock("@ledgerhq/live-common/featureFlags/index"); +const { + hasBeenUpsoldProtectSelector, + hasBeenRedirectedToPostOnboardingSelector, + lastConnectedDeviceSelector, +} = jest.requireMock("~/reducers/settings"); + +function mockUseFeature(value: { enabled: boolean }) { + useFeature.mockReturnValue(value); +} +function mockHasBeenUpsoldProtect(value: boolean) { + hasBeenUpsoldProtectSelector.mockReturnValue(value); +} + +function mockHasRedirectedToPostOnboarding(value: boolean) { + hasBeenRedirectedToPostOnboardingSelector.mockReturnValue(value); +} + +function mockLastConnectedDevice(value: Device) { + lastConnectedDeviceSelector.mockReturnValue(value); +} + +type Scenario = { + device: { modelId: DeviceModelId }; + featureFlagEnabled: boolean; + expected: { shouldRedirectToProtectUpsell: boolean; shouldRedirectToPostOnboarding: boolean }; +}; + +function testScenarios(scenarios: Scenario[]) { + scenarios.forEach(scenario => { + it(`should return ${JSON.stringify(scenario.expected)} for ${JSON.stringify(scenario.device)} and feature flag enabled: ${scenario.featureFlagEnabled}`, () => { + mockLastConnectedDevice(scenario.device as Device); + mockUseFeature({ enabled: scenario.featureFlagEnabled }); + + const result = useShouldRedirect(); + + expect( + [result.shouldRedirectToPostOnboarding, result.shouldRedirectToProtectUpsell].filter( + Boolean, + ).length, + ).toBeLessThanOrEqual(1); + + expect(result).toEqual(scenario.expected); + }); + }); +} + +describe("useShouldRedirect", () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("user HAS NOT BEEN UPSOLD protect & HAS NOT BEEN REDIRECTED to post onboarding", () => { + beforeEach(() => { + mockHasBeenUpsoldProtect(false); + mockHasRedirectedToPostOnboarding(false); + }); + + testScenarios([ + { + device: { modelId: DeviceModelId.nanoSP }, + featureFlagEnabled: false, + expected: { shouldRedirectToProtectUpsell: false, shouldRedirectToPostOnboarding: true }, + }, + { + device: { modelId: DeviceModelId.nanoSP }, + featureFlagEnabled: true, + expected: { shouldRedirectToProtectUpsell: false, shouldRedirectToPostOnboarding: true }, + }, + { + device: { modelId: DeviceModelId.nanoX }, + featureFlagEnabled: false, + expected: { shouldRedirectToProtectUpsell: true, shouldRedirectToPostOnboarding: false }, + }, + { + device: { modelId: DeviceModelId.stax }, + featureFlagEnabled: false, + expected: { shouldRedirectToProtectUpsell: false, shouldRedirectToPostOnboarding: true }, + }, + { + device: { modelId: DeviceModelId.stax }, + featureFlagEnabled: true, + expected: { shouldRedirectToProtectUpsell: true, shouldRedirectToPostOnboarding: false }, + }, + { + device: { modelId: DeviceModelId.europa }, + featureFlagEnabled: false, + expected: { shouldRedirectToProtectUpsell: false, shouldRedirectToPostOnboarding: true }, + }, + { + device: { modelId: DeviceModelId.europa }, + featureFlagEnabled: true, + expected: { shouldRedirectToProtectUpsell: true, shouldRedirectToPostOnboarding: false }, + }, + ]); + }); + + describe("user HAS BEEN UPSOLD protect & HAS NOT BEEN REDIRECTED to post onboarding", () => { + beforeEach(() => { + mockHasBeenUpsoldProtect(true); + mockHasRedirectedToPostOnboarding(false); + }); + + [ + DeviceModelId.nanoS, + DeviceModelId.nanoSP, + DeviceModelId.nanoX, + DeviceModelId.stax, + DeviceModelId.europa, + ].forEach(modelId => { + [true, false].forEach(featureFlagEnabled => + testScenarios([ + { + device: { modelId }, + featureFlagEnabled, + expected: { + shouldRedirectToProtectUpsell: false, + shouldRedirectToPostOnboarding: true, + }, + }, + ]), + ); + }); + }); + + describe("user HAS BEEN UPSOLD PROTECT & HAS BEEN REDIRECTED to post onboarding", () => { + beforeEach(() => { + mockHasBeenUpsoldProtect(true); + mockHasRedirectedToPostOnboarding(true); + }); + [ + DeviceModelId.nanoS, + DeviceModelId.nanoSP, + DeviceModelId.nanoX, + DeviceModelId.stax, + DeviceModelId.europa, + ].forEach(modelId => { + [true, false].forEach(featureFlagEnabled => + testScenarios([ + { + device: { modelId }, + featureFlagEnabled, + expected: { + shouldRedirectToProtectUpsell: false, + shouldRedirectToPostOnboarding: false, + }, + }, + ]), + ); + }); + }); + + describe("user HAS NOT BEEN UPSOLD protect & HAS BEEN REDIRECTED to post onboarding", () => { + beforeEach(() => { + mockHasBeenUpsoldProtect(false); + mockHasRedirectedToPostOnboarding(true); + }); + + testScenarios([ + { + device: { modelId: DeviceModelId.nanoSP }, + featureFlagEnabled: false, + expected: { shouldRedirectToProtectUpsell: false, shouldRedirectToPostOnboarding: false }, + }, + { + device: { modelId: DeviceModelId.nanoSP }, + featureFlagEnabled: true, + expected: { shouldRedirectToProtectUpsell: false, shouldRedirectToPostOnboarding: false }, + }, + { + device: { modelId: DeviceModelId.nanoX }, + featureFlagEnabled: false, + expected: { shouldRedirectToProtectUpsell: true, shouldRedirectToPostOnboarding: false }, + }, + { + device: { modelId: DeviceModelId.stax }, + featureFlagEnabled: false, + expected: { shouldRedirectToProtectUpsell: false, shouldRedirectToPostOnboarding: false }, + }, + { + device: { modelId: DeviceModelId.stax }, + featureFlagEnabled: true, + expected: { shouldRedirectToProtectUpsell: true, shouldRedirectToPostOnboarding: false }, + }, + { + device: { modelId: DeviceModelId.europa }, + featureFlagEnabled: false, + expected: { shouldRedirectToProtectUpsell: false, shouldRedirectToPostOnboarding: false }, + }, + { + device: { modelId: DeviceModelId.europa }, + featureFlagEnabled: true, + expected: { shouldRedirectToProtectUpsell: true, shouldRedirectToPostOnboarding: false }, + }, + ]); + }); +}); diff --git a/apps/ledger-live-mobile/src/hooks/useAutoRedirectToPostOnboarding/useShouldRedirect.ts b/apps/ledger-live-mobile/src/hooks/useAutoRedirectToPostOnboarding/useShouldRedirect.ts new file mode 100644 index 000000000000..9132cff6323b --- /dev/null +++ b/apps/ledger-live-mobile/src/hooks/useAutoRedirectToPostOnboarding/useShouldRedirect.ts @@ -0,0 +1,40 @@ +import { useFeature } from "@ledgerhq/live-common/featureFlags/index"; +import { DeviceModelId } from "@ledgerhq/types-devices"; +import { useSelector } from "react-redux"; +import { + hasBeenRedirectedToPostOnboardingSelector, + hasBeenUpsoldProtectSelector, + lastConnectedDeviceSelector, +} from "~/reducers/settings"; + +/** + * Returns whether the user should be redirected to the Protect upsell or the post onboarding + * */ +export function useShouldRedirect(): { + shouldRedirectToProtectUpsell: boolean; + shouldRedirectToPostOnboarding: boolean; +} { + const hasBeenUpsoldProtect = useSelector(hasBeenUpsoldProtectSelector); + const hasRedirectedToPostOnboarding = useSelector(hasBeenRedirectedToPostOnboardingSelector); + const recoverUpsellRedirection = useFeature("recoverUpsellRedirection"); + const lastConnectedDevice = useSelector(lastConnectedDeviceSelector); + const eligibleDevicesForUpsell = recoverUpsellRedirection?.enabled + ? [DeviceModelId.stax, DeviceModelId.europa] + : [DeviceModelId.nanoX]; + + const eligibleForUpsell = lastConnectedDevice?.modelId + ? eligibleDevicesForUpsell.includes(lastConnectedDevice.modelId) + : false; + + const shouldRedirectToProtectUpsell = !hasBeenUpsoldProtect && eligibleForUpsell; + + const shouldRedirectToPostOnboarding = + Boolean(lastConnectedDevice) && + !hasRedirectedToPostOnboarding && + (eligibleForUpsell ? hasBeenUpsoldProtect : true); + + return { + shouldRedirectToProtectUpsell, + shouldRedirectToPostOnboarding, + }; +} diff --git a/apps/ledger-live-mobile/src/reducers/settings.ts b/apps/ledger-live-mobile/src/reducers/settings.ts index b48a119e8a55..cb7f88187b23 100644 --- a/apps/ledger-live-mobile/src/reducers/settings.ts +++ b/apps/ledger-live-mobile/src/reducers/settings.ts @@ -80,6 +80,7 @@ import type { SettingsAddStarredMarketcoinsPayload, SettingsRemoveStarredMarketcoinsPayload, SettingsSetFromLedgerSyncOnboardingPayload, + SettingsSetHasBeenRedirectedToPostOnboardingPayload, } from "../actions/types"; import { SettingsActionTypes, @@ -165,6 +166,7 @@ export const INITIAL_STATE: SettingsState = { debugAppLevelDrawerOpened: false, dateFormat: "default", hasBeenUpsoldProtect: false, + hasBeenRedirectedToPostOnboarding: false, onboardingType: null, depositFlow: { hasClosedNetworkBanner: false, @@ -601,6 +603,12 @@ const handlers: ReducerMap = { ...state, hasBeenUpsoldProtect: (action as Action).payload, }), + [SettingsActionTypes.SET_HAS_BEEN_REDIRECTED_TO_POST_ONBOARDING]: (state, action) => ({ + ...state, + hasBeenRedirectedToPostOnboarding: ( + action as Action + ).payload, + }), [SettingsActionTypes.SET_GENERAL_TERMS_VERSION_ACCEPTED]: (state, action) => ({ ...state, generalTermsVersionAccepted: (action as Action).payload, @@ -874,6 +882,8 @@ export const featureFlagsBannerVisibleSelector = (state: State) => export const debugAppLevelDrawerOpenedSelector = (state: State) => state.settings.debugAppLevelDrawerOpened; export const hasBeenUpsoldProtectSelector = (state: State) => state.settings.hasBeenUpsoldProtect; +export const hasBeenRedirectedToPostOnboardingSelector = (state: State) => + state.settings.hasBeenRedirectedToPostOnboarding; export const generalTermsVersionAcceptedSelector = (state: State) => state.settings.generalTermsVersionAccepted; export const userNpsSelector = (state: State) => state.settings.userNps; diff --git a/apps/ledger-live-mobile/src/reducers/types.ts b/apps/ledger-live-mobile/src/reducers/types.ts index 7dc9c9aae716..24f3584fb53b 100644 --- a/apps/ledger-live-mobile/src/reducers/types.ts +++ b/apps/ledger-live-mobile/src/reducers/types.ts @@ -255,6 +255,7 @@ export type SettingsState = { debugAppLevelDrawerOpened: boolean; dateFormat: string; hasBeenUpsoldProtect: boolean; + hasBeenRedirectedToPostOnboarding: boolean; generalTermsVersionAccepted?: string; depositFlow: { hasClosedNetworkBanner: boolean; diff --git a/apps/ledger-live-mobile/src/screens/MyLedgerChooseDevice/index.tsx b/apps/ledger-live-mobile/src/screens/MyLedgerChooseDevice/index.tsx index bdb76854353b..f43ab081bdda 100644 --- a/apps/ledger-live-mobile/src/screens/MyLedgerChooseDevice/index.tsx +++ b/apps/ledger-live-mobile/src/screens/MyLedgerChooseDevice/index.tsx @@ -22,6 +22,7 @@ import { MyLedgerNavigatorStackParamList } from "~/components/RootNavigator/type import { useManagerDeviceAction } from "~/hooks/deviceActions"; import ContentCardsLocation from "~/dynamicContent/ContentCardsLocation"; import { ContentCardLocation } from "~/dynamicContent/types"; +import { useAutoRedirectToPostOnboarding } from "~/hooks/useAutoRedirectToPostOnboarding"; type NavigationProps = BaseComposite< StackNavigatorProps @@ -46,6 +47,15 @@ const ChooseDevice: React.FC = ({ isFocused }) => { const navigation = useNavigation(); const { params } = useRoute(); + /** + * FIXME: + * This is here because for now the Recover upsell redirect to this screen (My Ledger) + * via a deeplink, and after the Recover upsell, we are supposed to automatically redirect + * to the post-onboarding hub. + * When the Recover webpage is fixed to only redirect to the Portfolio screen, this can be removed. + */ + useAutoRedirectToPostOnboarding(); + const onSelectDevice = (device?: Device) => { if (device) track("ManagerDeviceEntered", { diff --git a/apps/ledger-live-mobile/src/screens/Onboarding/steps/pairNew.tsx b/apps/ledger-live-mobile/src/screens/Onboarding/steps/pairNew.tsx index 9655664fc439..c93d20011526 100644 --- a/apps/ledger-live-mobile/src/screens/Onboarding/steps/pairNew.tsx +++ b/apps/ledger-live-mobile/src/screens/Onboarding/steps/pairNew.tsx @@ -1,14 +1,12 @@ import React, { useCallback, useMemo, memo } from "react"; import { useNavigation, useRoute } from "@react-navigation/native"; import { useDispatch } from "react-redux"; -import { DeviceModelId } from "@ledgerhq/devices"; -import { useStartPostOnboardingCallback } from "@ledgerhq/live-common/postOnboarding/hooks/index"; import { NavigatorName, ScreenName } from "~/const"; import BaseStepperView, { PairNew, ConnectNano } from "./setupDevice/scenes"; import { TrackScreen } from "~/analytics"; import SeedWarning from "../shared/SeedWarning"; import Illustration from "~/images/illustration/Illustration"; -import { completeOnboarding } from "~/actions/settings"; +import { completeOnboarding, setHasBeenRedirectedToPostOnboarding } from "~/actions/settings"; import { useNavigationInterceptor } from "../onboardingContext"; import useNotifications from "~/logic/notifications"; import { @@ -84,8 +82,6 @@ export default memo(function () { [isProtectFlow], ); - const startPostOnboarding = useStartPostOnboardingCallback(); - const onFinish = useCallback(() => { if (next && deviceModelId) { // only used for protect for now @@ -105,21 +101,17 @@ export default memo(function () { parentNav.popToTop(); } - startPostOnboarding({ - deviceModelId: deviceModelId as DeviceModelId, - resetNavigationStack: true, - fallbackIfNoAction: () => - navigation.replace(NavigatorName.Base, { - screen: NavigatorName.Main, - }), + navigation.replace(NavigatorName.Base, { + screen: NavigatorName.Main, }); + dispatch(setHasBeenRedirectedToPostOnboarding(false)); + triggerJustFinishedOnboardingNewDevicePushNotificationModal(); }, [ dispatch, resetCurrentStep, navigation, - startPostOnboarding, deviceModelId, triggerJustFinishedOnboardingNewDevicePushNotificationModal, next, diff --git a/apps/ledger-live-mobile/src/screens/Portfolio/index.tsx b/apps/ledger-live-mobile/src/screens/Portfolio/index.tsx index 38d72b130a02..ffdacc790d7d 100644 --- a/apps/ledger-live-mobile/src/screens/Portfolio/index.tsx +++ b/apps/ledger-live-mobile/src/screens/Portfolio/index.tsx @@ -1,28 +1,14 @@ -import React, { useCallback, useMemo, useState, useEffect } from "react"; -import { useSelector, useDispatch } from "react-redux"; -import { ListRenderItemInfo, Linking, Platform } from "react-native"; +import React, { useCallback, useMemo, useState } from "react"; +import { useSelector } from "react-redux"; +import { ListRenderItemInfo, Platform } from "react-native"; import { useTranslation } from "react-i18next"; import { useFocusEffect } from "@react-navigation/native"; import { Box, Flex } from "@ledgerhq/native-ui"; import { useTheme } from "styled-components/native"; import useEnv from "@ledgerhq/live-common/hooks/useEnv"; import { ReactNavigationPerformanceView } from "@shopify/react-native-performance-navigation"; -import { useFeature } from "@ledgerhq/live-common/featureFlags/index"; import WalletTabSafeAreaView from "~/components/WalletTab/WalletTabSafeAreaView"; -import { - useAlreadyOnboardedURI, - usePostOnboardingURI, - useHomeURI, -} from "@ledgerhq/live-common/hooks/recoverFeatureFlag"; import { useRefreshAccountsOrdering } from "~/actions/general"; -import { - // TODO: discreetMode is never used 😱 is it safe to remove - // discreetModeSelector, - hasBeenUpsoldProtectSelector, - lastConnectedDeviceSelector, - onboardingTypeSelector, -} from "~/reducers/settings"; -import { setHasBeenUpsoldProtect } from "~/actions/settings"; import Carousel from "~/components/Carousel"; import { ScreenName } from "~/const"; import FirmwareUpdateBanner from "LLM/features/FirmwareUpdate/components/UpdateBanner"; @@ -47,13 +33,12 @@ import { hasTokenAccountsNotBlackListedWithPositiveBalanceSelector, } from "~/reducers/accounts"; import PortfolioAssets from "./PortfolioAssets"; -import { internetReachable } from "~/logic/internetReachable"; import { UpdateStep } from "../FirmwareUpdate"; -import { OnboardingType } from "~/reducers/types"; import ContentCardsLocation from "~/dynamicContent/ContentCardsLocation"; import { ContentCardLocation } from "~/dynamicContent/types"; import usePortfolioAnalyticsOptInPrompt from "~/hooks/analyticsOptInPrompt/usePorfolioAnalyticsOptInPrompt"; import AddAccountDrawer from "LLM/features/Accounts/screens/AddAccount"; +import { useAutoRedirectToPostOnboarding } from "~/hooks/useAutoRedirectToPostOnboarding"; export { default as PortfolioTabIcon } from "./TabIcon"; @@ -68,19 +53,9 @@ const RefreshableCollapsibleHeaderFlatList = globalSyncRefreshControl(Collapsibl function PortfolioScreen({ navigation }: NavigationProps) { const hideEmptyTokenAccount = useEnv("HIDE_EMPTY_TOKEN_ACCOUNTS"); const { t } = useTranslation(); - // TODO: discreetMode is never used 😱 is it safe to remove - // const discreetMode = useSelector(discreetModeSelector); - const hasBeenUpsoldProtect = useSelector(hasBeenUpsoldProtectSelector); - const lastConnectedDevice = useSelector(lastConnectedDeviceSelector); const [isAddModalOpened, setAddModalOpened] = useState(false); const { colors } = useTheme(); const { isAWalletCardDisplayed } = useDynamicContent(); - const onboardingType = useSelector(onboardingTypeSelector); - const protectFeature = useFeature("protectServicesMobile"); - const recoverAlreadyOnboardedURI = useAlreadyOnboardedURI(protectFeature); - const recoverPostOnboardingURI = usePostOnboardingURI(protectFeature); - const recoverHomeURI = useHomeURI(protectFeature); - const dispatch = useDispatch(); const onBackFromUpdate = useCallback( (_updateState: UpdateStep) => { @@ -89,33 +64,7 @@ function PortfolioScreen({ navigation }: NavigationProps) { [navigation], ); - useEffect(() => { - const openProtectUpsell = async () => { - const internetConnected = await internetReachable(); - if (internetConnected && protectFeature?.enabled) { - if (recoverPostOnboardingURI && onboardingType === OnboardingType.restore) { - Linking.openURL(recoverPostOnboardingURI); - } else if (recoverHomeURI && onboardingType === OnboardingType.setupNew) { - Linking.openURL(recoverHomeURI); - } else if (recoverAlreadyOnboardedURI) { - Linking.openURL(recoverAlreadyOnboardedURI); - } - } - }; - if (!hasBeenUpsoldProtect && lastConnectedDevice?.modelId === "nanoX") { - openProtectUpsell(); - dispatch(setHasBeenUpsoldProtect(true)); - } - }, [ - onboardingType, - hasBeenUpsoldProtect, - lastConnectedDevice, - recoverPostOnboardingURI, - recoverAlreadyOnboardedURI, - recoverHomeURI, - dispatch, - protectFeature?.enabled, - ]); + useAutoRedirectToPostOnboarding(); usePortfolioAnalyticsOptInPrompt(); diff --git a/apps/ledger-live-mobile/src/screens/PostOnboarding/PostOnboardingHub.tsx b/apps/ledger-live-mobile/src/screens/PostOnboarding/PostOnboardingHub.tsx index 557a6a607b03..a8353d65306f 100644 --- a/apps/ledger-live-mobile/src/screens/PostOnboarding/PostOnboardingHub.tsx +++ b/apps/ledger-live-mobile/src/screens/PostOnboarding/PostOnboardingHub.tsx @@ -14,25 +14,22 @@ import { TrackScreen } from "~/analytics"; import Link from "~/components/wrappedUi/Link"; import { useCompletePostOnboarding } from "~/logic/postOnboarding/useCompletePostOnboarding"; import { ScrollContainer } from "@ledgerhq/native-ui"; +import { setHasBeenRedirectedToPostOnboarding } from "~/actions/settings"; const PostOnboardingHub = () => { const dispatch = useDispatch(); const { t } = useTranslation(); const { actionsState, deviceModelId } = usePostOnboardingHubState(); const closePostOnboarding = useCompletePostOnboarding(); - const clearLastActionCompleted = useCallback(() => { - dispatch(clearPostOnboardingLastActionCompleted()); - }, [dispatch]); - - useEffect( + useEffect(() => { /** * The last action context (specific title & popup) should only be visible * the 1st time the hub is navigated to after that action was completed. * So here we clear the last action completed. * */ - () => clearLastActionCompleted, - [clearLastActionCompleted], - ); + dispatch(clearPostOnboardingLastActionCompleted()); + dispatch(setHasBeenRedirectedToPostOnboarding(true)); + }, [dispatch]); const navigateToMainScreen = useCallback(() => { closePostOnboarding(); diff --git a/apps/ledger-live-mobile/src/screens/Settings/Debug/Configuration/RecoverUpsellRow.tsx b/apps/ledger-live-mobile/src/screens/Settings/Debug/Configuration/RecoverUpsellRow.tsx new file mode 100644 index 000000000000..a85163724364 --- /dev/null +++ b/apps/ledger-live-mobile/src/screens/Settings/Debug/Configuration/RecoverUpsellRow.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import SettingsRow from "~/components/SettingsRow"; +import Switch from "~/components/Switch"; +import { useFeature, useFeatureFlags } from "@ledgerhq/live-common/featureFlags/index"; + +export function RecoverUpsellRow() { + const { overrideFeature, resetFeature } = useFeatureFlags(); + + const protectFeature = useFeature("protectServicesMobile"); + + if (protectFeature === null || protectFeature === undefined) return null; + + const currentTarget = protectFeature?.params?.protectId; + + const onChange = (enabled: boolean) => { + if (enabled) { + overrideFeature("protectServicesMobile", { + ...protectFeature, + params: { ...protectFeature?.params, protectId: "protect-prod" }, + }); + } else { + resetFeature("protectServicesMobile"); + } + }; + + return ( + + + + ); +} diff --git a/apps/ledger-live-mobile/src/screens/Settings/Debug/Configuration/ResetOnboardingStateRow.tsx b/apps/ledger-live-mobile/src/screens/Settings/Debug/Configuration/ResetOnboardingStateRow.tsx index a364d984d4ff..ecc1cf381937 100644 --- a/apps/ledger-live-mobile/src/screens/Settings/Debug/Configuration/ResetOnboardingStateRow.tsx +++ b/apps/ledger-live-mobile/src/screens/Settings/Debug/Configuration/ResetOnboardingStateRow.tsx @@ -1,7 +1,13 @@ import React, { useContext } from "react"; import { useDispatch, useSelector } from "react-redux"; import SettingsRow from "~/components/SettingsRow"; -import { completeOnboarding, setHasOrderedNano, setReadOnlyMode } from "~/actions/settings"; +import { + completeOnboarding, + setHasBeenRedirectedToPostOnboarding, + setHasBeenUpsoldProtect, + setHasOrderedNano, + setReadOnlyMode, +} from "~/actions/settings"; import { RebootContext } from "~/context/Reboot"; import { knownDevicesSelector } from "~/reducers/ble"; import { removeKnownDevices } from "~/actions/ble"; @@ -22,6 +28,8 @@ export default function ResetOnboardingStateRow() { dispatch(setHasOrderedNano(false)); dispatch(completeOnboarding(false)); dispatch(removeKnownDevices(knownDevices.map(d => d.id))); + dispatch(setHasBeenUpsoldProtect(false)); + dispatch(setHasBeenRedirectedToPostOnboarding(false)); unacceptGeneralTerms(); requestAnimationFrame(() => { reboot(); diff --git a/apps/ledger-live-mobile/src/screens/Settings/Debug/Configuration/index.tsx b/apps/ledger-live-mobile/src/screens/Settings/Debug/Configuration/index.tsx index 369b7308e0c0..19334be5c9ec 100644 --- a/apps/ledger-live-mobile/src/screens/Settings/Debug/Configuration/index.tsx +++ b/apps/ledger-live-mobile/src/screens/Settings/Debug/Configuration/index.tsx @@ -17,6 +17,7 @@ import ResetOnboardingStateRow from "./ResetOnboardingStateRow"; import NftMetadataServiceRow from "./NftMetadataServiceRow"; import HasStaxEuropaRows from "./HasStaxEuropaRows"; import SkipOnboardingRow from "./SkipOnboardingRow"; +import { RecoverUpsellRow } from "./RecoverUpsellRow"; export default function Configuration() { const navigation = useNavigation>(); @@ -40,6 +41,7 @@ export default function Configuration() { + diff --git a/apps/ledger-live-mobile/src/screens/SyncOnboarding/CompletionScreen.tsx b/apps/ledger-live-mobile/src/screens/SyncOnboarding/CompletionScreen.tsx index cd8a3b7fd76f..efbbebfc2420 100644 --- a/apps/ledger-live-mobile/src/screens/SyncOnboarding/CompletionScreen.tsx +++ b/apps/ledger-live-mobile/src/screens/SyncOnboarding/CompletionScreen.tsx @@ -1,8 +1,7 @@ -import React, { useCallback } from "react"; +import React, { useCallback, useEffect } from "react"; import { Flex } from "@ledgerhq/native-ui"; import { StackScreenProps } from "@react-navigation/stack"; import { TouchableWithoutFeedback } from "react-native-gesture-handler"; -import { useStartPostOnboardingCallback } from "@ledgerhq/live-common/postOnboarding/hooks/useStartPostOnboardingCallback"; import { NavigatorName, ScreenName } from "~/const"; import { SyncOnboardingStackParamList } from "~/components/RootNavigator/types/SyncOnboardingNavigator"; @@ -10,6 +9,8 @@ import { BaseComposite, RootNavigation } from "~/components/RootNavigator/types/ import { DeviceModelId } from "@ledgerhq/devices"; import EuropaCompletionView from "./EuropaCompletionView"; import StaxCompletionView from "./StaxCompletionView"; +import { useDispatch } from "react-redux"; +import { setHasBeenRedirectedToPostOnboarding } from "~/actions/settings"; type Props = BaseComposite< StackScreenProps @@ -17,40 +18,37 @@ type Props = BaseComposite< const CompletionScreen = ({ navigation, route }: Props) => { const { device } = route.params; - const startPostOnboarding = useStartPostOnboardingCallback(); + const dispatch = useDispatch(); - const redirectToPostOnboarding = useCallback(() => { - startPostOnboarding({ - deviceModelId: device.modelId, - resetNavigationStack: true, - fallbackIfNoAction: () => - // Resets the navigation stack to avoid allowing to go back to the onboarding welcome screen - // FIXME: bindings to react-navigation seem to have issues with composites - (navigation as unknown as RootNavigation).reset({ - index: 0, - routes: [ - { - name: NavigatorName.Base, - state: { - routes: [ - { - name: NavigatorName.Main, - }, - ], + useEffect(() => { + dispatch(setHasBeenRedirectedToPostOnboarding(false)); + }, [dispatch]); + + const redirectToMainScreen = useCallback(() => { + (navigation as unknown as RootNavigation).reset({ + index: 0, + routes: [ + { + name: NavigatorName.Base, + state: { + routes: [ + { + name: NavigatorName.Main, }, - }, - ], - }), + ], + }, + }, + ], }); - }, [device.modelId, navigation, startPostOnboarding]); + }, [navigation]); return ( - + {device.modelId === DeviceModelId.europa ? ( - + ) : ( - + )} diff --git a/apps/ledger-live-mobile/src/screens/SyncOnboarding/SyncOnboardingCompanion.tsx b/apps/ledger-live-mobile/src/screens/SyncOnboarding/SyncOnboardingCompanion.tsx index 79458d1e7f0c..fa164a3bc8ea 100644 --- a/apps/ledger-live-mobile/src/screens/SyncOnboarding/SyncOnboardingCompanion.tsx +++ b/apps/ledger-live-mobile/src/screens/SyncOnboarding/SyncOnboardingCompanion.tsx @@ -18,7 +18,7 @@ import { useTranslation } from "react-i18next"; import { getDeviceModel } from "@ledgerhq/devices"; import { Device } from "@ledgerhq/live-common/hw/actions/types"; import { useDispatch } from "react-redux"; -import useFeature from "@ledgerhq/live-common/featureFlags/useFeature"; +import { useFeature } from "@ledgerhq/live-common/featureFlags/index"; import { SeedPhraseType, StorylyInstanceID } from "@ledgerhq/types-live"; import { DeviceModelId } from "@ledgerhq/types-devices"; diff --git a/libs/ledger-live-common/src/hooks/recoverFeatureFlag.ts b/libs/ledger-live-common/src/hooks/recoverFeatureFlag.ts index 0ae1ac130ad4..a5c0b09a4a21 100644 --- a/libs/ledger-live-common/src/hooks/recoverFeatureFlag.ts +++ b/libs/ledger-live-common/src/hooks/recoverFeatureFlag.ts @@ -172,6 +172,8 @@ export function useCustomURI( if (source && deeplinkCampaign) { uri.searchParams.append("ajs_recover_source", source); uri.searchParams.append("ajs_recover_campaign", deeplinkCampaign); + uri.searchParams.append("ajs_prop_source", source); + uri.searchParams.append("ajs_prop_campaign", deeplinkCampaign); } return uri; @@ -190,3 +192,16 @@ export function useCustomPath( return usePath(servicesConfig, uri); } + +export enum Source { + LLM_ONBOARDING_24 = "llm-onboarding-24", + LLD_ONBOARDING_24 = "lld-onboarding-24", +} + +export function useTouchScreenOnboardingUpsellURI( + servicesConfig: Feature_ProtectServicesMobile | null, + source: Source, +): string | undefined { + const campaign = "touchscreen-onboarding"; + return useCustomURI(servicesConfig, "upsell", source, campaign); +}