Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/lazy-banner-modes.md
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.
4 changes: 4 additions & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ libs/ledgerjs/packages/types-live/src/postOnboarding.ts @ledgerhq/engagement
features/flow/large-screen-upsell/ @ledgerhq/engagement
domain/entity/analytics-consent/ @ledgerhq/engagement
features/flow/analytics-consent/ @ledgerhq/engagement
features/flow/lazy-onboarding-banner/ @ledgerhq/engagement

### ... market/altcoins sentiment => @ledgerhq/wallet-xp ... ###
domain/entity/market-sentiment/ @ledgerhq/wallet-xp
Expand Down Expand Up @@ -402,6 +403,8 @@ apps/ledger-live-desktop/src/renderer/hooks/useDeeplinking/handlers/recover.hand

# Engagement team

shared/feature-flags/src/lazyOnboardingBanner.ts @ledgerhq/engagement

## Desktop
apps/ledger-live-desktop/src/renderer/analytics/ @ledgerhq/engagement
apps/ledger-live-desktop/src/renderer/components/SyncOnboarding/ @ledgerhq/engagement
Expand Down Expand Up @@ -491,6 +494,7 @@ apps/ledger-live-mobile/src/mvvm/features/Q2WalletV4Tour/
apps/ledger-live-mobile/src/mvvm/features/BackupHub/ @ledgerhq/engagement
apps/ledger-live-mobile/src/mvvm/features/LNUpsell/ @ledgerhq/engagement
apps/ledger-live-mobile/src/mvvm/features/LargeScreenUpsell/ @ledgerhq/engagement
apps/ledger-live-mobile/src/mvvm/features/LazyOnboardingBanner/ @ledgerhq/engagement
apps/ledger-live-mobile/src/mvvm/features/Reborn/ @ledgerhq/engagement
apps/ledger-live-mobile/src/mvvm/features/GenericAwarenessModal/ @ledgerhq/engagement
apps/ledger-live-mobile/src/mvvm/components/FeatureIntroLayout/ @ledgerhq/engagement
Expand Down
1 change: 1 addition & 0 deletions apps/ledger-live-mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@
"@features/flow-pay-card-auth": "workspace:^",
"@features/flow-contacts": "workspace:^",
"@features/flow-fear-and-greed": "workspace:^",
"@features/flow-lazy-onboarding-banner": "workspace:^",
"@features/platform-wallet-sync": "workspace:^",
"@features/platform-currencies": "workspace:^",
"@features/platform-env": "workspace:*",
Expand Down
4 changes: 4 additions & 0 deletions apps/ledger-live-mobile/src/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -9522,6 +9522,10 @@
"cta": "Learn more"
}
},
"lazyOnboardingBanner": {
"title": "Discover Ledger devices",
"description": "Explore our latest products and accessories"
},
"marketBanner": {
"title": "Market",
"viewAll": "View all",
Expand Down
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();
});
Comment thread
sarneijim marked this conversation as resolved.

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();
});
});
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";
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,
};
}
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,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export {
LazyOnboardingBanner,
useLazyOnboardingBannerViewModel,
} from "./components/LazyOnboardingBanner";
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import React from "react";
import { renderWithReactQuery, screen } from "@tests/test-renderer";
import { resetLazyOnboardingBannerSession } from "@features/flow-lazy-onboarding-banner/testing";
import { renderWithReactQuery, screen, withFlagOverrides } from "@tests/test-renderer";
import { ReadOnlyPortfolioTest } from "./shared";
import { withConsentDrawerState } from "LLM/features/AnalyticsConsentDrawer/__tests__/helpers";

describe("ReadOnly Portfolio Screen", () => {
beforeEach(() => {
resetLazyOnboardingBannerSession();
});

it("should render ReadOnly Portfolio", async () => {
renderWithReactQuery(<ReadOnlyPortfolioTest />);

Expand Down Expand Up @@ -35,4 +40,31 @@ describe("ReadOnly Portfolio Screen", () => {
await screen.findByTestId("PortfolioReadOnlyItems");
expect(await screen.findByText("Continue improving Ledger?")).toBeVisible();
});

it("should show the lazy onboarding banner to an eligible no-device user", async () => {
renderWithReactQuery(<ReadOnlyPortfolioTest />, {
overrideInitialState: withFlagOverrides(
{
lazyOnboardingBanner: {
enabled: true,
params: { mode: "shop_direct", link: "https://shop.ledger.com/" },
},
},
state => ({
...state,
settings: {
...state.settings,
hasCompletedOnboarding: true,
readOnlyModeEnabled: true,
onboardingHasDevice: false,
isReborn: true,
seenDevices: [],
lastConnectedDevice: null,
},
}),
),
});

expect(await screen.findByText("Discover Ledger devices")).toBeVisible();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,17 @@ const MockOnboardingWidget = jest.mocked(OnboardingWidget);
const MockLNUpsellBanner = jest.mocked(LNUpsellBanner);
const MockContentCardsLocation = jest.mocked(ContentCardsLocation);

const hiddenLazyOnboardingBanner = {
isShown: false,
title: "Discover Ledger devices",
description: "Explore our latest products and accessories",
onPress: jest.fn(),
onClose: jest.fn(),
};

const baseViewModel = {
shouldShowOnboardingWidget: false,
lazyOnboardingBanner: hiddenLazyOnboardingBanner,
contentCardsPaddingTop: undefined,
hasAssets: false,
hasTopWalletDisplayableCards: false,
Expand Down Expand Up @@ -80,6 +89,22 @@ describe("PortfolioBannersSection", () => {
);
});

it("renders the lazy onboarding banner with priority over other banners", () => {
mockUseViewModel.mockReturnValue({
...baseViewModel,
lazyOnboardingBanner: { ...hiddenLazyOnboardingBanner, isShown: true },
shouldShowOnboardingWidget: true,
shouldDisplayRecover: true,
});

render(<PortfolioBannersSection isFirst={false} isLNUpsellBannerShown />);

expect(screen.getByText("Discover Ledger devices")).toBeVisible();
expect(screen.queryByTestId("mock-ln-banner")).toBeNull();
expect(screen.queryByTestId("mock-recover-banner")).toBeNull();
expect(screen.queryByTestId("mock-onboarding-widget")).toBeNull();
});

describe("when user has assets and onboarding is hidden", () => {
beforeEach(() => {
mockUseViewModel.mockReturnValue({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
NativeSyntheticEvent,
} from "react-native";
import { Box, PageIndicator } from "@ledgerhq/lumen-ui-rnative";
import { LazyOnboardingBannerView } from "@features/flow-lazy-onboarding-banner";
import { LNUpsellBanner } from "LLM/features/LNUpsell/components/LNUpsellBanner";
import ContentCardsLocation from "~/dynamicContent/ContentCardsLocation";
import { ContentCardLocation } from "~/dynamicContent/types";
Expand Down Expand Up @@ -109,6 +110,7 @@ export const PortfolioBannersSection = ({
showAssets,
}: PortfolioBannersSectionProps) => {
const {
lazyOnboardingBanner,
shouldShowOnboardingWidget: showOnboarding,
shouldDisplayRecover: showRecover,
contentCardsPaddingTop,
Expand All @@ -125,6 +127,16 @@ export const PortfolioBannersSection = ({
<LNUpsellBanner location="wallet" />
) : undefined;

if (lazyOnboardingBanner.isShown) {
return (
<BannersSectionShell isFirst={isFirst}>
<PaddedBanner>
<LazyOnboardingBannerView {...lazyOnboardingBanner} />
</PaddedBanner>
</BannersSectionShell>
);
}

if (isLNUpsellBannerShown && !canCoexistWithBraze) {
return (
<BannersSectionShell isFirst={isFirst}>
Expand Down
Loading
Loading