Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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 @@ -170,6 +170,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 @@ -396,6 +397,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 @@ -485,6 +488,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 @@ -111,6 +111,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-currencies": "workspace:^",
"@features/platform-env": "workspace:*",
"@features/platform-feature-flags": "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 @@ -9520,6 +9520,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,
};
Comment thread
sarneijim marked this conversation as resolved.
}
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