-
Notifications
You must be signed in to change notification settings - Fork 479
[LWM] feat(lazy-onboarding): add portfolio device banner #20290
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
sarneijim
merged 9 commits into
develop
from
feat/engagement-LIVE-34516-lazy-onboarding-banner
Aug 7, 2026
Merged
Changes from 8 commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
68120f0
feat(feature-flags): add lazy onboarding banner shop link
sarneijim aa51181
feat(lazy-onboarding-banner): add shared banner flow
sarneijim 9708010
feat(mobile): implement lazy onboarding portfolio banner
sarneijim 2458373
refactor(lazy-onboarding-banner): colocate native view
sarneijim bc89746
refactor(lazy-onboarding-banner): replace png asset with webp
sarneijim 1d11367
chore(lockfile): fix pnpm-lock.yaml after rebase with develop
sarneijim 2c6f544
test(mobile): fix lazy onboarding banner priority test
sarneijim 845efd8
fix(lazy-onboarding-banner): harden shop link handling
sarneijim 05c8258
Merge remote-tracking branch 'origin/develop' into feat/engagement-LI…
sarneijim File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| "@features/flow-lazy-onboarding-banner": patch | ||
| "@shared/feature-flags": minor | ||
| "live-mobile": minor | ||
| --- | ||
|
|
||
| Add the shared lazy onboarding banner flow, its Mobile portfolio view and configurable Shop link feature flag. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
134 changes: 134 additions & 0 deletions
134
.../features/LazyOnboardingBanner/__integrations__/LazyOnboardingBanner.integration.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| import React from "react"; | ||
| import { Linking } from "react-native"; | ||
| import { resetLazyOnboardingBannerSession } from "@features/flow-lazy-onboarding-banner/testing"; | ||
| import { DeviceModelId } from "@ledgerhq/devices"; | ||
| import { render, screen, withFlagOverrides } from "@tests/test-renderer"; | ||
| import type { State } from "~/reducers/types"; | ||
| import { LazyOnboardingBanner } from "../components/LazyOnboardingBanner"; | ||
|
|
||
| type RenderOptions = Readonly<{ | ||
| enabled?: boolean; | ||
| mode?: "shop_direct" | "feature_intro"; | ||
| stateTransform?: (state: State) => State; | ||
| }>; | ||
|
|
||
| const SHOP_LINK = "https://shop.ledger.com/?product=flex"; | ||
|
|
||
| function withEligibleLazyOnboardingState(state: State): State { | ||
| return { | ||
| ...state, | ||
| settings: { | ||
| ...state.settings, | ||
| hasCompletedOnboarding: true, | ||
| readOnlyModeEnabled: true, | ||
| onboardingHasDevice: false, | ||
| isReborn: true, | ||
| seenDevices: [], | ||
| lastConnectedDevice: null, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| function renderBanner({ | ||
| enabled = true, | ||
| mode = "shop_direct", | ||
| stateTransform, | ||
| }: RenderOptions = {}) { | ||
| return render(<LazyOnboardingBanner />, { | ||
| overrideInitialState: withFlagOverrides( | ||
| { | ||
| lazyOnboardingBanner: { | ||
| enabled, | ||
| params: { mode, link: SHOP_LINK }, | ||
| }, | ||
| }, | ||
| state => | ||
| stateTransform?.(withEligibleLazyOnboardingState(state)) ?? | ||
| withEligibleLazyOnboardingState(state), | ||
| ), | ||
| }); | ||
| } | ||
|
|
||
| describe("LazyOnboardingBanner", () => { | ||
| beforeEach(() => { | ||
| jest.clearAllMocks(); | ||
| resetLazyOnboardingBannerSession(); | ||
| }); | ||
|
|
||
| it.each(["shop_direct", "feature_intro"] as const)( | ||
| "should open the shop with attribution in %s mode", | ||
| async mode => { | ||
| const { user } = renderBanner({ mode }); | ||
|
|
||
| expect(screen.getByText("Discover Ledger devices")).toBeVisible(); | ||
| expect(screen.getByText("Explore our latest products and accessories")).toBeVisible(); | ||
|
|
||
| await user.press(screen.getByTestId("lazy-onboarding-banner")); | ||
|
|
||
| expect(Linking.openURL).toHaveBeenCalledTimes(1); | ||
| const openedUrl = new URL(jest.mocked(Linking.openURL).mock.calls[0][0]); | ||
| expect(openedUrl.searchParams.get("product")).toBe("flex"); | ||
| expect(openedUrl.searchParams.get("utm_source")).toBe("ledger_wallet_mobile"); | ||
| expect(openedUrl.searchParams.get("utm_medium")).toBe("ledger_live"); | ||
| expect(openedUrl.searchParams.get("utm_campaign")).toBe("upsell_large_screen"); | ||
| expect(openedUrl.searchParams.get("utm_content")).toBe("lazy_onboarding_banner"); | ||
| }, | ||
| ); | ||
|
|
||
| it("should remain hidden for the rest of the session after it is closed", async () => { | ||
| const firstRender = renderBanner(); | ||
|
|
||
| await firstRender.user.press(screen.getByTestId("media-banner-close-button")); | ||
| expect(screen.queryByText("Discover Ledger devices")).toBeNull(); | ||
|
|
||
| firstRender.unmount(); | ||
| renderBanner(); | ||
|
|
||
| expect(screen.queryByText("Discover Ledger devices")).toBeNull(); | ||
| expect(Linking.openURL).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("should not render when the feature is disabled", () => { | ||
| renderBanner({ enabled: false }); | ||
|
|
||
| expect(screen.queryByText("Discover Ledger devices")).toBeNull(); | ||
| }); | ||
|
|
||
| it("should stop rendering after a device has been connected", () => { | ||
| renderBanner({ | ||
| stateTransform: state => ({ | ||
| ...state, | ||
| settings: { | ||
| ...state.settings, | ||
| lastConnectedDevice: { | ||
| deviceId: "device-id", | ||
| deviceName: "Ledger Flex", | ||
| modelId: DeviceModelId.europa, | ||
| wired: false, | ||
| }, | ||
| }, | ||
| }), | ||
| }); | ||
|
|
||
| expect(screen.queryByText("Discover Ledger devices")).toBeNull(); | ||
| }); | ||
|
|
||
| it("should stay hidden when a previously connected model is no longer recognized", () => { | ||
| renderBanner({ | ||
| stateTransform: state => ({ | ||
| ...state, | ||
| settings: { | ||
| ...state.settings, | ||
| lastConnectedDevice: { | ||
| deviceId: "device-id", | ||
| deviceName: "Legacy Ledger", | ||
| modelId: "legacy-model" as DeviceModelId, | ||
| wired: false, | ||
| }, | ||
| }, | ||
| }), | ||
| }); | ||
|
|
||
| expect(screen.queryByText("Discover Ledger devices")).toBeNull(); | ||
| }); | ||
| }); | ||
9 changes: 9 additions & 0 deletions
9
...e-mobile/src/mvvm/features/LazyOnboardingBanner/components/LazyOnboardingBanner/index.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| import React from "react"; | ||
| import { LazyOnboardingBannerView } from "@features/flow-lazy-onboarding-banner"; | ||
| import { useLazyOnboardingBannerViewModel } from "./useLazyOnboardingBannerViewModel"; | ||
|
|
||
| export function LazyOnboardingBanner() { | ||
| return <LazyOnboardingBannerView {...useLazyOnboardingBannerViewModel()} />; | ||
| } | ||
|
|
||
| export { useLazyOnboardingBannerViewModel } from "./useLazyOnboardingBannerViewModel"; |
26 changes: 26 additions & 0 deletions
26
.../LazyOnboardingBanner/components/LazyOnboardingBanner/useLazyOnboardingBannerViewModel.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| import { useCallback } from "react"; | ||
| import { Linking } from "react-native"; | ||
| import { | ||
| buildLazyOnboardingBannerLink, | ||
| type LazyOnboardingBannerViewProps, | ||
| } from "@features/flow-lazy-onboarding-banner"; | ||
| import { useTranslation } from "~/context/Locale"; | ||
| import { useLazyOnboardingBannerState } from "../../hooks/useLazyOnboardingBannerState"; | ||
|
|
||
| export function useLazyOnboardingBannerViewModel(): LazyOnboardingBannerViewProps { | ||
| const { t } = useTranslation(); | ||
| const { isShown, link, dismiss } = useLazyOnboardingBannerState(); | ||
| const shopLink = buildLazyOnboardingBannerLink(link, "mobile"); | ||
|
|
||
| const onPress = useCallback(() => { | ||
| void Linking.openURL(shopLink); | ||
| }, [shopLink]); | ||
|
|
||
| return { | ||
| isShown, | ||
| title: t("lazyOnboardingBanner.title"), | ||
| description: t("lazyOnboardingBanner.description"), | ||
| onPress, | ||
| onClose: dismiss, | ||
| }; | ||
| } |
48 changes: 48 additions & 0 deletions
48
...-live-mobile/src/mvvm/features/LazyOnboardingBanner/hooks/useLazyOnboardingBannerState.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import { | ||
| getLazyOnboardingBannerDecision, | ||
| useLazyOnboardingBannerSession, | ||
| } from "@features/flow-lazy-onboarding-banner"; | ||
| import { useFeature } from "@features/platform-feature-flags"; | ||
| import { useSelector } from "~/context/hooks"; | ||
| import { | ||
| hasCompletedOnboardingSelector, | ||
| isRebornSelector, | ||
| onboardingHasDeviceSelector, | ||
| readOnlyModeEnabledSelector, | ||
| seenDevicesSelector, | ||
| } from "~/reducers/settings"; | ||
|
|
||
| export type LazyOnboardingBannerState = Readonly<{ | ||
| isShown: boolean; | ||
| link: string; | ||
| dismiss: () => void; | ||
| }>; | ||
|
|
||
| export function useLazyOnboardingBannerState(): LazyOnboardingBannerState { | ||
| const feature = useFeature("lazyOnboardingBanner"); | ||
| const hasCompletedOnboarding = useSelector(hasCompletedOnboardingSelector); | ||
| const isReadOnlyModeEnabled = useSelector(readOnlyModeEnabledSelector); | ||
| const onboardingHasDevice = useSelector(onboardingHasDeviceSelector); | ||
| const isReborn = useSelector(isRebornSelector); | ||
| const seenDevices = useSelector(seenDevicesSelector); | ||
| const hasLastConnectedDevice = useSelector(state => state.settings.lastConnectedDevice !== null); | ||
| const { isDismissed, dismiss } = useLazyOnboardingBannerSession(); | ||
|
|
||
| const decision = getLazyOnboardingBannerDecision( | ||
| { | ||
| hasCompletedOnboarding, | ||
| isReadOnlyModeEnabled, | ||
| onboardingHasDevice, | ||
| isReborn, | ||
| hasEverConnectedDevice: seenDevices.length > 0 || hasLastConnectedDevice, | ||
| isDismissed, | ||
| }, | ||
| { isFeatureEnabled: feature?.enabled === true }, | ||
| ); | ||
|
|
||
| return { | ||
| isShown: decision.shouldShow, | ||
| link: typeof feature?.params?.link === "string" ? feature.params.link : "", | ||
| dismiss, | ||
| }; | ||
|
sarneijim marked this conversation as resolved.
|
||
| } | ||
4 changes: 4 additions & 0 deletions
4
apps/ledger-live-mobile/src/mvvm/features/LazyOnboardingBanner/index.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| export { | ||
| LazyOnboardingBanner, | ||
| useLazyOnboardingBannerViewModel, | ||
| } from "./components/LazyOnboardingBanner"; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.