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 }, + }, );