diff --git a/.changeset/lazy-banner-modes.md b/.changeset/lazy-banner-modes.md
new file mode 100644
index 000000000000..69a1a4a03c7e
--- /dev/null
+++ b/.changeset/lazy-banner-modes.md
@@ -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.
diff --git a/CODEOWNERS b/CODEOWNERS
index be2910a95056..c1bfd913d786 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -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
@@ -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
@@ -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
diff --git a/apps/ledger-live-mobile/package.json b/apps/ledger-live-mobile/package.json
index 8ddaca43bace..78728ac30133 100644
--- a/apps/ledger-live-mobile/package.json
+++ b/apps/ledger-live-mobile/package.json
@@ -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:*",
diff --git a/apps/ledger-live-mobile/src/locales/en/common.json b/apps/ledger-live-mobile/src/locales/en/common.json
index 3130296daa60..64cc7dc4a7f7 100644
--- a/apps/ledger-live-mobile/src/locales/en/common.json
+++ b/apps/ledger-live-mobile/src/locales/en/common.json
@@ -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",
diff --git a/apps/ledger-live-mobile/src/mvvm/features/LazyOnboardingBanner/__integrations__/LazyOnboardingBanner.integration.test.tsx b/apps/ledger-live-mobile/src/mvvm/features/LazyOnboardingBanner/__integrations__/LazyOnboardingBanner.integration.test.tsx
new file mode 100644
index 000000000000..8c3007bb4422
--- /dev/null
+++ b/apps/ledger-live-mobile/src/mvvm/features/LazyOnboardingBanner/__integrations__/LazyOnboardingBanner.integration.test.tsx
@@ -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(, {
+ 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();
+ });
+});
diff --git a/apps/ledger-live-mobile/src/mvvm/features/LazyOnboardingBanner/components/LazyOnboardingBanner/index.tsx b/apps/ledger-live-mobile/src/mvvm/features/LazyOnboardingBanner/components/LazyOnboardingBanner/index.tsx
new file mode 100644
index 000000000000..f115deae1593
--- /dev/null
+++ b/apps/ledger-live-mobile/src/mvvm/features/LazyOnboardingBanner/components/LazyOnboardingBanner/index.tsx
@@ -0,0 +1,9 @@
+import React from "react";
+import { LazyOnboardingBannerView } from "@features/flow-lazy-onboarding-banner";
+import { useLazyOnboardingBannerViewModel } from "./useLazyOnboardingBannerViewModel";
+
+export function LazyOnboardingBanner() {
+ return ;
+}
+
+export { useLazyOnboardingBannerViewModel } from "./useLazyOnboardingBannerViewModel";
diff --git a/apps/ledger-live-mobile/src/mvvm/features/LazyOnboardingBanner/components/LazyOnboardingBanner/useLazyOnboardingBannerViewModel.ts b/apps/ledger-live-mobile/src/mvvm/features/LazyOnboardingBanner/components/LazyOnboardingBanner/useLazyOnboardingBannerViewModel.ts
new file mode 100644
index 000000000000..b0b32d08f28f
--- /dev/null
+++ b/apps/ledger-live-mobile/src/mvvm/features/LazyOnboardingBanner/components/LazyOnboardingBanner/useLazyOnboardingBannerViewModel.ts
@@ -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,
+ };
+}
diff --git a/apps/ledger-live-mobile/src/mvvm/features/LazyOnboardingBanner/hooks/useLazyOnboardingBannerState.ts b/apps/ledger-live-mobile/src/mvvm/features/LazyOnboardingBanner/hooks/useLazyOnboardingBannerState.ts
new file mode 100644
index 000000000000..f2d8654acf65
--- /dev/null
+++ b/apps/ledger-live-mobile/src/mvvm/features/LazyOnboardingBanner/hooks/useLazyOnboardingBannerState.ts
@@ -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,
+ };
+}
diff --git a/apps/ledger-live-mobile/src/mvvm/features/LazyOnboardingBanner/index.ts b/apps/ledger-live-mobile/src/mvvm/features/LazyOnboardingBanner/index.ts
new file mode 100644
index 000000000000..b67ab36b1279
--- /dev/null
+++ b/apps/ledger-live-mobile/src/mvvm/features/LazyOnboardingBanner/index.ts
@@ -0,0 +1,4 @@
+export {
+ LazyOnboardingBanner,
+ useLazyOnboardingBannerViewModel,
+} from "./components/LazyOnboardingBanner";
diff --git a/apps/ledger-live-mobile/src/mvvm/features/Portfolio/__integrations__/readOnlyPortfolio.integration.test.tsx b/apps/ledger-live-mobile/src/mvvm/features/Portfolio/__integrations__/readOnlyPortfolio.integration.test.tsx
index 42054510a924..a77897c09326 100644
--- a/apps/ledger-live-mobile/src/mvvm/features/Portfolio/__integrations__/readOnlyPortfolio.integration.test.tsx
+++ b/apps/ledger-live-mobile/src/mvvm/features/Portfolio/__integrations__/readOnlyPortfolio.integration.test.tsx
@@ -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();
@@ -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(, {
+ 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();
+ });
});
diff --git a/apps/ledger-live-mobile/src/mvvm/features/Portfolio/components/PortfolioBannersSection/__tests__/PortfolioBannersSection.test.tsx b/apps/ledger-live-mobile/src/mvvm/features/Portfolio/components/PortfolioBannersSection/__tests__/PortfolioBannersSection.test.tsx
index 815c19a58879..4d7b6dec88e6 100644
--- a/apps/ledger-live-mobile/src/mvvm/features/Portfolio/components/PortfolioBannersSection/__tests__/PortfolioBannersSection.test.tsx
+++ b/apps/ledger-live-mobile/src/mvvm/features/Portfolio/components/PortfolioBannersSection/__tests__/PortfolioBannersSection.test.tsx
@@ -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,
@@ -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();
+
+ 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({
diff --git a/apps/ledger-live-mobile/src/mvvm/features/Portfolio/components/PortfolioBannersSection/index.tsx b/apps/ledger-live-mobile/src/mvvm/features/Portfolio/components/PortfolioBannersSection/index.tsx
index db0f133140ab..afc17dc95948 100644
--- a/apps/ledger-live-mobile/src/mvvm/features/Portfolio/components/PortfolioBannersSection/index.tsx
+++ b/apps/ledger-live-mobile/src/mvvm/features/Portfolio/components/PortfolioBannersSection/index.tsx
@@ -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";
@@ -109,6 +110,7 @@ export const PortfolioBannersSection = ({
showAssets,
}: PortfolioBannersSectionProps) => {
const {
+ lazyOnboardingBanner,
shouldShowOnboardingWidget: showOnboarding,
shouldDisplayRecover: showRecover,
contentCardsPaddingTop,
@@ -125,6 +127,16 @@ export const PortfolioBannersSection = ({
) : undefined;
+ if (lazyOnboardingBanner.isShown) {
+ return (
+
+
+
+
+
+ );
+ }
+
if (isLNUpsellBannerShown && !canCoexistWithBraze) {
return (
diff --git a/apps/ledger-live-mobile/src/mvvm/features/Portfolio/components/PortfolioBannersSection/usePortfolioBannersSectionViewModel.ts b/apps/ledger-live-mobile/src/mvvm/features/Portfolio/components/PortfolioBannersSection/usePortfolioBannersSectionViewModel.ts
index f064e9bb2e79..486ca67b47ca 100644
--- a/apps/ledger-live-mobile/src/mvvm/features/Portfolio/components/PortfolioBannersSection/usePortfolioBannersSectionViewModel.ts
+++ b/apps/ledger-live-mobile/src/mvvm/features/Portfolio/components/PortfolioBannersSection/usePortfolioBannersSectionViewModel.ts
@@ -1,5 +1,7 @@
import { useCallback, useState } from "react";
import { NativeSyntheticEvent, NativeScrollEvent } from "react-native";
+import type { LazyOnboardingBannerViewProps } from "@features/flow-lazy-onboarding-banner";
+import { useLazyOnboardingBannerViewModel } from "LLM/features/LazyOnboardingBanner";
import { useTopWalletHasDisplayableContentCards } from "~/dynamicContent/useTopWalletHasDisplayableContentCards";
import { useOnboardingWidgetVisibility } from "../../hooks/useOnboardingWidgetVisibility";
import useShouldDisplayRecoverBanner from "../RecoverBanner/useShouldDisplayRecoverBanner";
@@ -14,6 +16,7 @@ interface PortfolioBannersSectionViewModelResult {
readonly hasAssets: boolean;
readonly hasTopWalletDisplayableCards: boolean;
readonly shouldShowOnboardingWidget: boolean;
+ readonly lazyOnboardingBanner: LazyOnboardingBannerViewProps;
readonly shouldDisplayRecover: boolean;
/** LN upsell exclusive over Recover/onboarding; coexist only with Braze TopWallet cards. */
readonly canCoexistWithBraze: boolean;
@@ -27,6 +30,7 @@ export const usePortfolioBannersSectionViewModel = ({
}: PortfolioBannersSectionViewModelParams): PortfolioBannersSectionViewModelResult => {
const hasTopWalletDisplayableCards = useTopWalletHasDisplayableContentCards();
const shouldShowOnboardingWidget = useOnboardingWidgetVisibility();
+ const lazyOnboardingBanner = useLazyOnboardingBannerViewModel();
const shouldDisplayRecover = useShouldDisplayRecoverBanner();
const [carouselIndex, setCarouselIndex] = useState(0);
@@ -55,6 +59,7 @@ export const usePortfolioBannersSectionViewModel = ({
return {
shouldShowOnboardingWidget,
+ lazyOnboardingBanner,
contentCardsPaddingTop,
hasAssets,
hasTopWalletDisplayableCards,
diff --git a/features/flow/lazy-onboarding-banner/CHANGELOG.md b/features/flow/lazy-onboarding-banner/CHANGELOG.md
new file mode 100644
index 000000000000..870d4ffc63a4
--- /dev/null
+++ b/features/flow/lazy-onboarding-banner/CHANGELOG.md
@@ -0,0 +1 @@
+# @features/flow-lazy-onboarding-banner
diff --git a/features/flow/lazy-onboarding-banner/README.md b/features/flow/lazy-onboarding-banner/README.md
new file mode 100644
index 000000000000..ed914e095510
--- /dev/null
+++ b/features/flow/lazy-onboarding-banner/README.md
@@ -0,0 +1,28 @@
+# @features/flow-lazy-onboarding-banner
+
+> [!CAUTION]
+> **Status: UNSTABLE** — Initial shared banner flow; platform views may change.
+
+Cross-platform business logic for the lazy onboarding device banner.
+
+## Exports
+
+- `getLazyOnboardingBannerDecision` evaluates normalized user and feature state.
+- `buildLazyOnboardingBannerLink` produces a safe Shop URL with campaign attribution.
+- `useLazyOnboardingBannerSession` keeps dismissal in memory until the application restarts.
+- The React Native entrypoint exports `LazyOnboardingBannerView` and owns its native asset.
+
+Tests can reset that singleton through the explicit
+`@features/flow-lazy-onboarding-banner/testing` subpath.
+
+Mobile and Desktop own their Redux adapters, translations, URL opening, analytics and screen
+composition. Platform-specific presentation stays in the flow through platform entrypoints; the
+Mobile implementation is exposed by the `.native` entrypoint.
+
+## Validation
+
+```sh
+pnpm test
+pnpm typecheck
+pnpm unimported
+```
diff --git a/features/flow/lazy-onboarding-banner/jest.config.js b/features/flow/lazy-onboarding-banner/jest.config.js
new file mode 100644
index 000000000000..7022b41d9cce
--- /dev/null
+++ b/features/flow/lazy-onboarding-banner/jest.config.js
@@ -0,0 +1,21 @@
+module.exports = {
+ testEnvironment: "node",
+ roots: ["/src"],
+ testMatch: ["**/*.test.ts"],
+ transform: {
+ "^.+\\.(t|j)sx?$": [
+ "@swc/jest",
+ {
+ jsc: {
+ target: "esnext",
+ parser: { syntax: "typescript" },
+ },
+ },
+ ],
+ },
+ coverageReporters: ["json", ["lcov", { file: "lcov.info", projectRoot: "../../../" }], "text"],
+ reporters: [
+ "default",
+ ["jest-sonar", { outputName: "sonar-executionTests-report.xml", reportedFilePath: "absolute" }],
+ ],
+};
diff --git a/features/flow/lazy-onboarding-banner/package.json b/features/flow/lazy-onboarding-banner/package.json
new file mode 100644
index 000000000000..d24abed75844
--- /dev/null
+++ b/features/flow/lazy-onboarding-banner/package.json
@@ -0,0 +1,65 @@
+{
+ "name": "@features/flow-lazy-onboarding-banner",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Cross-platform lazy onboarding banner decision, CTA and session state",
+ "main": "src/index.ts",
+ "react-native": "src/index.native.ts",
+ "types": "src/index.ts",
+ "exports": {
+ ".": {
+ "react-native": "./src/index.native.ts",
+ "default": "./src/index.ts"
+ },
+ "./testing": "./src/testing.ts",
+ "./package.json": "./package.json"
+ },
+ "sideEffects": false,
+ "scripts": {
+ "format": "oxfmt src",
+ "format:check": "oxfmt src --check",
+ "lint": "oxlint src",
+ "lint:ci": "oxlint src --quiet",
+ "typecheck": "tsc --noEmit",
+ "test": "jest",
+ "test:watch": "jest --watch",
+ "coverage": "jest --coverage",
+ "unimported": "pnpm knip --directory ../../.. -W features/flow/lazy-onboarding-banner"
+ },
+ "peerDependencies": {
+ "@ledgerhq/lumen-design-core": "catalog:",
+ "@ledgerhq/lumen-ui-rnative": "catalog:",
+ "react": ">=19.0.0",
+ "react-native": ">=0.70.0",
+ "react-native-svg": ">=15.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@ledgerhq/lumen-design-core": {
+ "optional": true
+ },
+ "@ledgerhq/lumen-ui-rnative": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ },
+ "react-native-svg": {
+ "optional": true
+ }
+ },
+ "devDependencies": {
+ "@ledgerhq/lumen-design-core": "catalog:",
+ "@ledgerhq/lumen-ui-rnative": "catalog:",
+ "@swc/core": "catalog:",
+ "@swc/jest": "catalog:",
+ "@types/jest": "catalog:",
+ "@types/react": "catalog:",
+ "jest": "catalog:",
+ "oxfmt": "catalog:",
+ "oxlint": "catalog:",
+ "react": "catalog:",
+ "react-native": "catalog:",
+ "react-native-svg": "catalog:",
+ "typescript": "catalog:"
+ }
+}
diff --git a/features/flow/lazy-onboarding-banner/project.json b/features/flow/lazy-onboarding-banner/project.json
new file mode 100644
index 000000000000..81aa6c4336f3
--- /dev/null
+++ b/features/flow/lazy-onboarding-banner/project.json
@@ -0,0 +1,8 @@
+{
+ "name": "@features/flow-lazy-onboarding-banner",
+ "targets": {
+ "build": {
+ "executor": "nx:noop"
+ }
+ }
+}
diff --git a/features/flow/lazy-onboarding-banner/src/assets/lazy-onboarding-banner.webp b/features/flow/lazy-onboarding-banner/src/assets/lazy-onboarding-banner.webp
new file mode 100644
index 000000000000..613de221cf33
Binary files /dev/null and b/features/flow/lazy-onboarding-banner/src/assets/lazy-onboarding-banner.webp differ
diff --git a/features/flow/lazy-onboarding-banner/src/components/LazyOnboardingBanner/LazyOnboardingBanner.native.tsx b/features/flow/lazy-onboarding-banner/src/components/LazyOnboardingBanner/LazyOnboardingBanner.native.tsx
new file mode 100644
index 000000000000..c6e3e07b5aa6
--- /dev/null
+++ b/features/flow/lazy-onboarding-banner/src/components/LazyOnboardingBanner/LazyOnboardingBanner.native.tsx
@@ -0,0 +1,29 @@
+import React from "react";
+import { Image } from "react-native";
+import { MediaBanner, MediaBannerDescription, MediaBannerTitle } from "@ledgerhq/lumen-ui-rnative";
+import bannerImageSource from "../../assets/lazy-onboarding-banner.webp";
+import type { LazyOnboardingBannerViewProps } from "./types";
+
+const bannerImageUrl = Image.resolveAssetSource(bannerImageSource).uri;
+
+export function LazyOnboardingBannerView({
+ isShown,
+ title,
+ description,
+ onPress,
+ onClose,
+}: LazyOnboardingBannerViewProps) {
+ if (!isShown) return null;
+
+ return (
+
+ {title}
+ {description}
+
+ );
+}
diff --git a/features/flow/lazy-onboarding-banner/src/components/LazyOnboardingBanner/index.native.ts b/features/flow/lazy-onboarding-banner/src/components/LazyOnboardingBanner/index.native.ts
new file mode 100644
index 000000000000..5a5ed9255040
--- /dev/null
+++ b/features/flow/lazy-onboarding-banner/src/components/LazyOnboardingBanner/index.native.ts
@@ -0,0 +1,2 @@
+export { LazyOnboardingBannerView } from "./LazyOnboardingBanner.native";
+export type { LazyOnboardingBannerViewProps } from "./types";
diff --git a/features/flow/lazy-onboarding-banner/src/components/LazyOnboardingBanner/types.ts b/features/flow/lazy-onboarding-banner/src/components/LazyOnboardingBanner/types.ts
new file mode 100644
index 000000000000..c14ae36f6166
--- /dev/null
+++ b/features/flow/lazy-onboarding-banner/src/components/LazyOnboardingBanner/types.ts
@@ -0,0 +1,7 @@
+export type LazyOnboardingBannerViewProps = Readonly<{
+ isShown: boolean;
+ title: string;
+ description: string;
+ onPress: () => void;
+ onClose: () => void;
+}>;
diff --git a/features/flow/lazy-onboarding-banner/src/decision/getLazyOnboardingBannerDecision.test.ts b/features/flow/lazy-onboarding-banner/src/decision/getLazyOnboardingBannerDecision.test.ts
new file mode 100644
index 000000000000..4aee92ebc97a
--- /dev/null
+++ b/features/flow/lazy-onboarding-banner/src/decision/getLazyOnboardingBannerDecision.test.ts
@@ -0,0 +1,35 @@
+import { getLazyOnboardingBannerDecision } from "./getLazyOnboardingBannerDecision";
+
+const eligibleUserState = {
+ hasCompletedOnboarding: true,
+ isReadOnlyModeEnabled: true,
+ onboardingHasDevice: false,
+ isReborn: true,
+ hasEverConnectedDevice: false,
+ isDismissed: false,
+} as const;
+
+describe("getLazyOnboardingBannerDecision", () => {
+ it("should show the banner to an eligible lazy onboarding user", () => {
+ expect(getLazyOnboardingBannerDecision(eligibleUserState, { isFeatureEnabled: true })).toEqual({
+ shouldShow: true,
+ });
+ });
+
+ it.each([
+ ["feature_disabled", eligibleUserState, false],
+ ["onboarding_incomplete", { ...eligibleUserState, hasCompletedOnboarding: false }, true],
+ ["read_only_disabled", { ...eligibleUserState, isReadOnlyModeEnabled: false }, true],
+ ["onboarding_device", { ...eligibleUserState, onboardingHasDevice: true }, true],
+ ["onboarding_device_unknown", { ...eligibleUserState, onboardingHasDevice: null }, true],
+ ["not_reborn", { ...eligibleUserState, isReborn: false }, true],
+ ["reborn_unknown", { ...eligibleUserState, isReborn: null }, true],
+ ["device_connected", { ...eligibleUserState, hasEverConnectedDevice: true }, true],
+ ["dismissed", { ...eligibleUserState, isDismissed: true }, true],
+ ] as const)("should return %s when ineligible", (reason, userState, isFeatureEnabled) => {
+ expect(getLazyOnboardingBannerDecision(userState, { isFeatureEnabled })).toEqual({
+ shouldShow: false,
+ reason,
+ });
+ });
+});
diff --git a/features/flow/lazy-onboarding-banner/src/decision/getLazyOnboardingBannerDecision.ts b/features/flow/lazy-onboarding-banner/src/decision/getLazyOnboardingBannerDecision.ts
new file mode 100644
index 000000000000..42f5dfa451b0
--- /dev/null
+++ b/features/flow/lazy-onboarding-banner/src/decision/getLazyOnboardingBannerDecision.ts
@@ -0,0 +1,25 @@
+import type {
+ LazyOnboardingBannerContext,
+ LazyOnboardingBannerDecision,
+ LazyOnboardingBannerUserState,
+} from "../types";
+
+export function getLazyOnboardingBannerDecision(
+ userState: LazyOnboardingBannerUserState,
+ context: LazyOnboardingBannerContext,
+): LazyOnboardingBannerDecision {
+ if (!context.isFeatureEnabled) return { shouldShow: false, reason: "feature_disabled" };
+ if (!userState.hasCompletedOnboarding)
+ return { shouldShow: false, reason: "onboarding_incomplete" };
+ if (!userState.isReadOnlyModeEnabled) return { shouldShow: false, reason: "read_only_disabled" };
+ if (userState.onboardingHasDevice === true)
+ return { shouldShow: false, reason: "onboarding_device" };
+ if (userState.onboardingHasDevice === null)
+ return { shouldShow: false, reason: "onboarding_device_unknown" };
+ if (userState.isReborn === false) return { shouldShow: false, reason: "not_reborn" };
+ if (userState.isReborn === null) return { shouldShow: false, reason: "reborn_unknown" };
+ if (userState.hasEverConnectedDevice) return { shouldShow: false, reason: "device_connected" };
+ if (userState.isDismissed) return { shouldShow: false, reason: "dismissed" };
+
+ return { shouldShow: true };
+}
diff --git a/features/flow/lazy-onboarding-banner/src/index.native.ts b/features/flow/lazy-onboarding-banner/src/index.native.ts
new file mode 100644
index 000000000000..0d1bd7c65d3d
--- /dev/null
+++ b/features/flow/lazy-onboarding-banner/src/index.native.ts
@@ -0,0 +1,16 @@
+export { getLazyOnboardingBannerDecision } from "./decision/getLazyOnboardingBannerDecision";
+export {
+ type LazyOnboardingBannerSession,
+ useLazyOnboardingBannerSession,
+} from "./react/useLazyOnboardingBannerSession";
+export type {
+ LazyOnboardingBannerContext,
+ LazyOnboardingBannerDecision,
+ LazyOnboardingBannerHiddenReason,
+ LazyOnboardingBannerUserState,
+} from "./types";
+export { buildLazyOnboardingBannerLink } from "./utils/buildLazyOnboardingBannerLink";
+export {
+ LazyOnboardingBannerView,
+ type LazyOnboardingBannerViewProps,
+} from "./components/LazyOnboardingBanner/index.native";
diff --git a/features/flow/lazy-onboarding-banner/src/index.ts b/features/flow/lazy-onboarding-banner/src/index.ts
new file mode 100644
index 000000000000..8245eee67c37
--- /dev/null
+++ b/features/flow/lazy-onboarding-banner/src/index.ts
@@ -0,0 +1,12 @@
+export { getLazyOnboardingBannerDecision } from "./decision/getLazyOnboardingBannerDecision";
+export {
+ type LazyOnboardingBannerSession,
+ useLazyOnboardingBannerSession,
+} from "./react/useLazyOnboardingBannerSession";
+export type {
+ LazyOnboardingBannerContext,
+ LazyOnboardingBannerDecision,
+ LazyOnboardingBannerHiddenReason,
+ LazyOnboardingBannerUserState,
+} from "./types";
+export { buildLazyOnboardingBannerLink } from "./utils/buildLazyOnboardingBannerLink";
diff --git a/features/flow/lazy-onboarding-banner/src/react/useLazyOnboardingBannerSession.ts b/features/flow/lazy-onboarding-banner/src/react/useLazyOnboardingBannerSession.ts
new file mode 100644
index 000000000000..07b90cded8ca
--- /dev/null
+++ b/features/flow/lazy-onboarding-banner/src/react/useLazyOnboardingBannerSession.ts
@@ -0,0 +1,21 @@
+import { useSyncExternalStore } from "react";
+import {
+ dismissLazyOnboardingBannerForSession,
+ getLazyOnboardingBannerDismissed,
+ subscribeToLazyOnboardingBannerSession,
+} from "../state/lazyOnboardingBannerSession";
+
+export type LazyOnboardingBannerSession = Readonly<{
+ isDismissed: boolean;
+ dismiss: () => void;
+}>;
+
+export function useLazyOnboardingBannerSession(): LazyOnboardingBannerSession {
+ const isDismissed = useSyncExternalStore(
+ subscribeToLazyOnboardingBannerSession,
+ getLazyOnboardingBannerDismissed,
+ getLazyOnboardingBannerDismissed,
+ );
+
+ return { isDismissed, dismiss: dismissLazyOnboardingBannerForSession };
+}
diff --git a/features/flow/lazy-onboarding-banner/src/state/lazyOnboardingBannerSession.test.ts b/features/flow/lazy-onboarding-banner/src/state/lazyOnboardingBannerSession.test.ts
new file mode 100644
index 000000000000..2ebf63d48698
--- /dev/null
+++ b/features/flow/lazy-onboarding-banner/src/state/lazyOnboardingBannerSession.test.ts
@@ -0,0 +1,33 @@
+import {
+ dismissLazyOnboardingBannerForSession,
+ getLazyOnboardingBannerDismissed,
+ resetLazyOnboardingBannerSession,
+ subscribeToLazyOnboardingBannerSession,
+} from "./lazyOnboardingBannerSession";
+
+describe("lazyOnboardingBannerSession", () => {
+ beforeEach(() => {
+ resetLazyOnboardingBannerSession();
+ });
+
+ it("should remain dismissed until the session is reset", () => {
+ dismissLazyOnboardingBannerForSession();
+ expect(getLazyOnboardingBannerDismissed()).toBe(true);
+
+ resetLazyOnboardingBannerSession();
+ expect(getLazyOnboardingBannerDismissed()).toBe(false);
+ });
+
+ it("should notify subscribers only when the value changes", () => {
+ const listener = jest.fn();
+ const unsubscribe = subscribeToLazyOnboardingBannerSession(listener);
+
+ dismissLazyOnboardingBannerForSession();
+ dismissLazyOnboardingBannerForSession();
+ resetLazyOnboardingBannerSession();
+ resetLazyOnboardingBannerSession();
+
+ expect(listener).toHaveBeenCalledTimes(2);
+ unsubscribe();
+ });
+});
diff --git a/features/flow/lazy-onboarding-banner/src/state/lazyOnboardingBannerSession.ts b/features/flow/lazy-onboarding-banner/src/state/lazyOnboardingBannerSession.ts
new file mode 100644
index 000000000000..77e046b2165c
--- /dev/null
+++ b/features/flow/lazy-onboarding-banner/src/state/lazyOnboardingBannerSession.ts
@@ -0,0 +1,28 @@
+type Listener = () => void;
+
+let isDismissed = false;
+const listeners = new Set();
+
+function setDismissed(nextValue: boolean) {
+ if (isDismissed === nextValue) return;
+
+ isDismissed = nextValue;
+ for (const listener of listeners) listener();
+}
+
+export function getLazyOnboardingBannerDismissed(): boolean {
+ return isDismissed;
+}
+
+export function subscribeToLazyOnboardingBannerSession(listener: Listener): () => void {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
+
+export function dismissLazyOnboardingBannerForSession(): void {
+ setDismissed(true);
+}
+
+export function resetLazyOnboardingBannerSession(): void {
+ setDismissed(false);
+}
diff --git a/features/flow/lazy-onboarding-banner/src/testing.ts b/features/flow/lazy-onboarding-banner/src/testing.ts
new file mode 100644
index 000000000000..cd7fbf6eab9d
--- /dev/null
+++ b/features/flow/lazy-onboarding-banner/src/testing.ts
@@ -0,0 +1 @@
+export { resetLazyOnboardingBannerSession } from "./state/lazyOnboardingBannerSession";
diff --git a/features/flow/lazy-onboarding-banner/src/types.ts b/features/flow/lazy-onboarding-banner/src/types.ts
new file mode 100644
index 000000000000..8c6e29e25b9f
--- /dev/null
+++ b/features/flow/lazy-onboarding-banner/src/types.ts
@@ -0,0 +1,27 @@
+export type LazyOnboardingBannerUserState = Readonly<{
+ hasCompletedOnboarding: boolean;
+ isReadOnlyModeEnabled: boolean;
+ onboardingHasDevice: boolean | null;
+ isReborn: boolean | null;
+ hasEverConnectedDevice: boolean;
+ isDismissed: boolean;
+}>;
+
+export type LazyOnboardingBannerContext = Readonly<{
+ isFeatureEnabled: boolean;
+}>;
+
+export type LazyOnboardingBannerHiddenReason =
+ | "feature_disabled"
+ | "onboarding_incomplete"
+ | "read_only_disabled"
+ | "onboarding_device"
+ | "onboarding_device_unknown"
+ | "not_reborn"
+ | "reborn_unknown"
+ | "device_connected"
+ | "dismissed";
+
+export type LazyOnboardingBannerDecision =
+ | Readonly<{ shouldShow: true }>
+ | Readonly<{ shouldShow: false; reason: LazyOnboardingBannerHiddenReason }>;
diff --git a/features/flow/lazy-onboarding-banner/src/utils/buildLazyOnboardingBannerLink.test.ts b/features/flow/lazy-onboarding-banner/src/utils/buildLazyOnboardingBannerLink.test.ts
new file mode 100644
index 000000000000..f6e6b4346aff
--- /dev/null
+++ b/features/flow/lazy-onboarding-banner/src/utils/buildLazyOnboardingBannerLink.test.ts
@@ -0,0 +1,53 @@
+import { buildLazyOnboardingBannerLink } from "./buildLazyOnboardingBannerLink";
+
+const DEFAULT_LINK = "https://shop.ledger.com/";
+
+describe("buildLazyOnboardingBannerLink", () => {
+ it("should append the mobile lazy onboarding attribution", () => {
+ const url = new URL(buildLazyOnboardingBannerLink("https://shop.ledger.com/", "mobile"));
+
+ expect(url.searchParams.get("utm_source")).toBe("ledger_wallet_mobile");
+ expect(url.searchParams.get("utm_medium")).toBe("ledger_live");
+ expect(url.searchParams.get("utm_campaign")).toBe("upsell_large_screen");
+ expect(url.searchParams.get("utm_content")).toBe("lazy_onboarding_banner");
+ });
+
+ it("should use the desktop source for Ledger Wallet Desktop", () => {
+ const url = new URL(buildLazyOnboardingBannerLink("https://shop.ledger.com/", "desktop"));
+
+ expect(url.searchParams.get("utm_source")).toBe("ledger_wallet_desktop");
+ expect(url.searchParams.get("utm_medium")).toBe("ledger_live");
+ });
+
+ it("should preserve unrelated parameters and replace existing attribution", () => {
+ const url = new URL(
+ buildLazyOnboardingBannerLink(
+ "https://ledger.com/shop?product=flex&utm_source=old&utm_medium=old",
+ "mobile",
+ ),
+ );
+
+ expect(url.origin + url.pathname).toBe("https://ledger.com/shop");
+ expect(url.searchParams.get("product")).toBe("flex");
+ expect(url.searchParams.get("utm_source")).toBe("ledger_wallet_mobile");
+ expect(url.searchParams.get("utm_medium")).toBe("ledger_live");
+ });
+
+ it.each(["", " "])('should fall back to the default Shop URL for "%s"', link => {
+ const url = new URL(buildLazyOnboardingBannerLink(link, "mobile"));
+
+ expect(url.origin + url.pathname).toBe(DEFAULT_LINK);
+ expect(url.protocol).toBe("https:");
+ expect(url.searchParams.get("utm_content")).toBe("lazy_onboarding_banner");
+ });
+
+ it.each(["not a url", "javascript:alert(1)", "http://shop.ledger.com/"])(
+ "should fall back to the default Shop URL for unsafe link %p",
+ link => {
+ const url = new URL(buildLazyOnboardingBannerLink(link, "mobile"));
+
+ expect(url.origin + url.pathname).toBe(DEFAULT_LINK);
+ expect(url.protocol).toBe("https:");
+ },
+ );
+});
diff --git a/features/flow/lazy-onboarding-banner/src/utils/buildLazyOnboardingBannerLink.ts b/features/flow/lazy-onboarding-banner/src/utils/buildLazyOnboardingBannerLink.ts
new file mode 100644
index 000000000000..8094d4ab0e54
--- /dev/null
+++ b/features/flow/lazy-onboarding-banner/src/utils/buildLazyOnboardingBannerLink.ts
@@ -0,0 +1,41 @@
+const ATTRIBUTION = {
+ utm_medium: "ledger_live",
+ utm_campaign: "upsell_large_screen",
+ utm_content: "lazy_onboarding_banner",
+} as const;
+
+const UTM_SOURCE_BY_PLATFORM = {
+ mobile: "ledger_wallet_mobile",
+ desktop: "ledger_wallet_desktop",
+} as const;
+
+const DEFAULT_LINK = "https://shop.ledger.com/";
+
+function resolveSafeShopUrl(link: string): URL {
+ const candidate = typeof link === "string" ? link.trim() : "";
+
+ if (candidate) {
+ try {
+ const url = new URL(candidate);
+ if (url.protocol === "https:") return url;
+ } catch {
+ // Fall through to the default Shop URL.
+ }
+ }
+
+ return new URL(DEFAULT_LINK);
+}
+
+export function buildLazyOnboardingBannerLink(
+ link: string,
+ platform: "mobile" | "desktop",
+): string {
+ const url = resolveSafeShopUrl(link);
+
+ url.searchParams.set("utm_source", UTM_SOURCE_BY_PLATFORM[platform]);
+ for (const [key, value] of Object.entries(ATTRIBUTION)) {
+ url.searchParams.set(key, value);
+ }
+
+ return url.toString();
+}
diff --git a/features/flow/lazy-onboarding-banner/src/webp.d.ts b/features/flow/lazy-onboarding-banner/src/webp.d.ts
new file mode 100644
index 000000000000..db81143e4a75
--- /dev/null
+++ b/features/flow/lazy-onboarding-banner/src/webp.d.ts
@@ -0,0 +1,4 @@
+declare module "*.webp" {
+ const src: number;
+ export default src;
+}
diff --git a/features/flow/lazy-onboarding-banner/tsconfig.json b/features/flow/lazy-onboarding-banner/tsconfig.json
new file mode 100644
index 000000000000..a089b2b3be70
--- /dev/null
+++ b/features/flow/lazy-onboarding-banner/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "jsx": "react-jsx",
+ "lib": ["ES2022", "DOM"],
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "noEmit": true,
+ "types": ["jest"]
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "lib"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 014d519eb21b..8d22e8c66371 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1672,6 +1672,9 @@ importers:
'@features/flow-fear-and-greed':
specifier: workspace:^
version: link:../../features/flow/fear-and-greed
+ '@features/flow-lazy-onboarding-banner':
+ specifier: workspace:^
+ version: link:../../features/flow/lazy-onboarding-banner
'@features/flow-pay-card-auth':
specifier: workspace:^
version: link:../../features/flow/pay-card-auth
@@ -5166,6 +5169,48 @@ importers:
specifier: 'catalog:'
version: '@typescript/typescript6@6.0.2'
+ features/flow/lazy-onboarding-banner:
+ devDependencies:
+ '@ledgerhq/lumen-design-core':
+ specifier: 'catalog:'
+ version: 0.1.23
+ '@ledgerhq/lumen-ui-rnative':
+ specifier: 'catalog:'
+ version: 0.1.52(@ledgerhq/lumen-design-core@0.1.23)(@types/react@19.0.14)(react-native-svg@15.15.1(react-native@0.81.6(patch_hash=fbb22f6ab81c7336a446f504c4aef2f23fc558838854399c868c5590e29ac9f1)(@react-native-community/cli@15.1.0(@typescript/typescript6@6.0.2))(@types/react@19.0.14)(react@19.1.4))(react@19.1.4))(react-native@0.81.6(patch_hash=fbb22f6ab81c7336a446f504c4aef2f23fc558838854399c868c5590e29ac9f1)(@react-native-community/cli@15.1.0(@typescript/typescript6@6.0.2))(@types/react@19.0.14)(react@19.1.4))(react@19.1.4)
+ '@swc/core':
+ specifier: 'catalog:'
+ version: 1.15.11
+ '@swc/jest':
+ specifier: 'catalog:'
+ version: 0.2.39(@swc/core@1.15.11)
+ '@types/jest':
+ specifier: 'catalog:'
+ version: 30.0.0
+ '@types/react':
+ specifier: 'catalog:'
+ version: 19.0.14
+ jest:
+ specifier: 'catalog:'
+ version: 30.2.0
+ oxfmt:
+ specifier: 'catalog:'
+ version: 0.36.0
+ oxlint:
+ specifier: 'catalog:'
+ version: 1.51.0
+ react:
+ specifier: 19.1.4
+ version: 19.1.4
+ react-native:
+ specifier: 'catalog:'
+ version: 0.81.6(patch_hash=fbb22f6ab81c7336a446f504c4aef2f23fc558838854399c868c5590e29ac9f1)(@react-native-community/cli@15.1.0(@typescript/typescript6@6.0.2))(@types/react@19.0.14)(react@19.1.4)
+ react-native-svg:
+ specifier: 'catalog:'
+ version: 15.15.1(react-native@0.81.6(patch_hash=fbb22f6ab81c7336a446f504c4aef2f23fc558838854399c868c5590e29ac9f1)(@react-native-community/cli@15.1.0(@typescript/typescript6@6.0.2))(@types/react@19.0.14)(react@19.1.4))(react@19.1.4)
+ typescript:
+ specifier: 'catalog:'
+ version: '@typescript/typescript6@6.0.2'
+
features/flow/market-banner:
devDependencies:
'@gorhom/bottom-sheet':
@@ -67011,7 +67056,7 @@ snapshots:
'@babel/parser': 7.29.2
'@babel/types': 7.29.0
flow-enums-runtime: 0.0.6
- metro: 0.83.3
+ metro: 0.83.3(metro-transform-worker@0.83.3)
metro-babel-transformer: 0.83.3
metro-cache: 0.83.3
metro-cache-key: 0.83.3
@@ -67138,6 +67183,54 @@ snapshots:
- supports-color
- utf-8-validate
+ metro@0.83.3(metro-transform-worker@0.83.3):
+ dependencies:
+ '@babel/code-frame': 7.29.0
+ '@babel/core': 7.28.5
+ '@babel/generator': 7.28.5
+ '@babel/parser': 7.29.2
+ '@babel/template': 7.28.6
+ '@babel/traverse': 7.29.0
+ '@babel/types': 7.29.0
+ accepts: 1.3.8
+ chalk: 4.1.2
+ ci-info: 2.0.0
+ connect: 3.7.0
+ debug: 4.4.3
+ error-stack-parser: 2.1.4
+ flow-enums-runtime: 0.0.6
+ graceful-fs: 4.2.11
+ hermes-parser: 0.32.0
+ image-size: 1.1.1
+ invariant: 2.2.4
+ jest-worker: 29.7.0(metro@0.83.3)
+ jsc-safe-url: 0.2.4
+ lodash.throttle: 4.1.1
+ metro-babel-transformer: 0.83.3
+ metro-cache: 0.83.3
+ metro-cache-key: 0.83.3
+ metro-config: 0.83.3(metro-transform-worker@0.83.3)
+ metro-core: 0.83.3
+ metro-file-map: 0.83.3(metro@0.83.3)
+ metro-resolver: 0.83.3
+ metro-runtime: 0.83.3
+ metro-source-map: 0.83.3
+ metro-symbolicate: 0.83.3
+ metro-transform-plugins: 0.83.3
+ metro-transform-worker: 0.83.3
+ mime-types: 2.1.35
+ nullthrows: 1.1.1
+ serialize-error: 2.1.0
+ source-map: 0.5.7
+ throat: 5.0.0
+ ws: 7.5.10
+ yargs: 17.7.2
+ transitivePeerDependencies:
+ - bufferutil
+ - metro-transform-worker
+ - supports-color
+ - utf-8-validate
+
metro@0.83.7:
dependencies:
'@babel/code-frame': 7.29.0
diff --git a/shared/feature-flags/src/flags/team-engagement/lazyOnboardingBanner.test.ts b/shared/feature-flags/src/flags/team-engagement/lazyOnboardingBanner.test.ts
index 038c3a195c4b..1c5e46211bcc 100644
--- a/shared/feature-flags/src/flags/team-engagement/lazyOnboardingBanner.test.ts
+++ b/shared/feature-flags/src/flags/team-engagement/lazyOnboardingBanner.test.ts
@@ -1,17 +1,30 @@
import { lazyOnboardingBanner } from "./lazyOnboardingBanner";
+const DEFAULT_LINK = "https://shop.ledger.com/";
+
describe("lazyOnboardingBanner", () => {
it("should default to the disabled shop direct mode", () => {
expect(lazyOnboardingBanner.parse(undefined)).toEqual({
enabled: false,
- params: { mode: "shop_direct" },
+ params: { mode: "shop_direct", link: DEFAULT_LINK },
});
});
it.each(["shop_direct", "feature_intro"] as const)("should accept the %s mode", mode => {
expect(lazyOnboardingBanner.parse({ enabled: true, params: { mode } })).toEqual({
enabled: true,
- params: { mode },
+ params: { mode, link: DEFAULT_LINK },
+ });
+ });
+
+ it("should accept a custom HTTPS shop link on another domain", () => {
+ const link = "https://ledger.com/pages/hardware-wallets-comparison";
+
+ expect(
+ lazyOnboardingBanner.parse({ enabled: true, params: { mode: "shop_direct", link } }),
+ ).toEqual({
+ enabled: true,
+ params: { mode: "shop_direct", link },
});
});
@@ -23,4 +36,13 @@ describe("lazyOnboardingBanner", () => {
}),
).toThrow();
});
+
+ it.each(["", " ", "not a url", "http://shop.ledger.com/", "javascript:alert(1)"])(
+ "should reject the invalid HTTPS link %p",
+ link => {
+ expect(() =>
+ lazyOnboardingBanner.parse({ enabled: true, params: { mode: "shop_direct", link } }),
+ ).toThrow();
+ },
+ );
});
diff --git a/shared/feature-flags/src/flags/team-engagement/lazyOnboardingBanner.ts b/shared/feature-flags/src/flags/team-engagement/lazyOnboardingBanner.ts
index b7aeaeb740d1..8095ae2bdfc7 100644
--- a/shared/feature-flags/src/flags/team-engagement/lazyOnboardingBanner.ts
+++ b/shared/feature-flags/src/flags/team-engagement/lazyOnboardingBanner.ts
@@ -1,7 +1,21 @@
import { z } from "zod";
import { flagWith } from "../../define";
+const DEFAULT_LINK = "https://shop.ledger.com/";
+
+const httpsUrl = z
+ .string()
+ .trim()
+ .url()
+ .refine(link => link.startsWith("https://"), "Expected an HTTPS URL");
+
export const lazyOnboardingBanner = flagWith(
- { mode: z.enum(["shop_direct", "feature_intro"]) },
- { enabled: false, params: { mode: "shop_direct" } },
+ {
+ mode: z.enum(["shop_direct", "feature_intro"]),
+ link: httpsUrl.default(DEFAULT_LINK),
+ },
+ {
+ enabled: false,
+ params: { mode: "shop_direct", link: DEFAULT_LINK },
+ },
);