diff --git a/.changeset/calm-clouds-debug.md b/.changeset/calm-clouds-debug.md new file mode 100644 index 000000000000..e3354278e84d --- /dev/null +++ b/.changeset/calm-clouds-debug.md @@ -0,0 +1,5 @@ +--- +"ledger-live-desktop": patch +--- + +Upgrade analytics consent QA debug screen to scenario parity diff --git a/apps/ledger-live-desktop/src/mvvm/features/AnalyticsConsentDialog/__tests__/useAnalyticsConsentDialogViewModel.test.ts b/apps/ledger-live-desktop/src/mvvm/features/AnalyticsConsentDialog/__tests__/useAnalyticsConsentDialogViewModel.test.ts index a5273f27e5a1..876641a5c30a 100644 --- a/apps/ledger-live-desktop/src/mvvm/features/AnalyticsConsentDialog/__tests__/useAnalyticsConsentDialogViewModel.test.ts +++ b/apps/ledger-live-desktop/src/mvvm/features/AnalyticsConsentDialog/__tests__/useAnalyticsConsentDialogViewModel.test.ts @@ -3,29 +3,16 @@ import { act, renderHook } from "tests/testSetup"; import { INITIAL_STATE } from "~/renderer/reducers/settings"; import { track } from "~/renderer/analytics/segment"; import { - ANALYTICS_CONSENT_FLOW, ANALYTICS_CONSENT_DIALOG_PAGE, useAnalyticsConsentDialogViewModel, } from "../hooks/useAnalyticsConsentDialogViewModel"; -const mockUseMatch = jest.fn(); - const FIXED_NOW = new Date("2024-06-15T12:00:00.000Z"); -jest.mock("react-router", () => ({ - ...jest.requireActual("react-router"), - useMatch: (args: unknown) => mockUseMatch(args), -})); - jest.mock("~/renderer/hooks/useLocalizedUrls", () => ({ useLocalizedUrl: (url: string) => url, })); -const dialogClosedPayload = { - page: ANALYTICS_CONSENT_DIALOG_PAGE, - flow: ANALYTICS_CONSENT_FLOW, -}; - type ViewModel = ReturnType; type SettingsState = typeof INITIAL_STATE; type SettingsOverrides = Partial & { @@ -124,20 +111,12 @@ describe("useAnalyticsConsentDialogViewModel", () => { jest.useFakeTimers({ doNotFake: ["queueMicrotask"] }); jest.setSystemTime(FIXED_NOW); jest.clearAllMocks(); - mockUseMatch.mockReturnValue({}); }); afterEach(() => { jest.useRealTimers(); }); - it("keeps phase closed when portfolio route is not focused", () => { - mockUseMatch.mockReturnValue(null); - const { result } = renderReconfirmViewModel(); - - expectClosed(result.current); - }); - it("opens consentReconfirm when renewal is needed, policy is current, and share analytics is on", async () => { const { result } = renderReconfirmViewModel(); @@ -298,19 +277,4 @@ describe("useAnalyticsConsentDialogViewModel", () => { true, ); }); - - it("tracks drawer_closed when leaving portfolio while modal is open", async () => { - const { result, rerender } = renderReconfirmViewModel(); - - await flushEffects(); - expectConsentReconfirm(result.current); - - act(() => { - mockUseMatch.mockReturnValue(null); - rerender(undefined); - }); - - expect(jest.mocked(track)).toHaveBeenCalledWith("drawer_closed", dialogClosedPayload); - expectClosed(result.current); - }); }); diff --git a/apps/ledger-live-desktop/src/mvvm/features/AnalyticsConsentDialog/hooks/useAnalyticsConsentDialogViewModel.ts b/apps/ledger-live-desktop/src/mvvm/features/AnalyticsConsentDialog/hooks/useAnalyticsConsentDialogViewModel.ts index 13fa3a951fef..be7593d65b0c 100644 --- a/apps/ledger-live-desktop/src/mvvm/features/AnalyticsConsentDialog/hooks/useAnalyticsConsentDialogViewModel.ts +++ b/apps/ledger-live-desktop/src/mvvm/features/AnalyticsConsentDialog/hooks/useAnalyticsConsentDialogViewModel.ts @@ -1,7 +1,6 @@ import { useEffect, useState } from "react"; import logger from "~/renderer/logger"; import { useTranslation } from "react-i18next"; -import { useMatch } from "react-router"; import { useDispatch, useSelector } from "LLD/hooks/redux"; import { analyticsConsentInfoSelector, @@ -37,8 +36,6 @@ export function useAnalyticsConsentDialogViewModel() { const { t } = useTranslation(); const dispatch = useDispatch(); const privacyPolicyUrl = useLocalizedUrl(urls.privacyPolicy); - const portfolioRouteMatch = useMatch({ path: "/", end: true }); - const isPortfolioRouteFocused = Boolean(portfolioRouteMatch); const hasCompletedOnboarding = useSelector(hasCompletedOnboardingSelector); const consentInfo = useSelector(analyticsConsentInfoSelector); @@ -83,7 +80,7 @@ export function useAnalyticsConsentDialogViewModel() { }; useEffect(() => { - if (!isPortfolioRouteFocused || !shouldOffer) { + if (!shouldOffer) { setPhase(current => { if (current !== "closed") { track("drawer_closed", dialogClosedPayload); @@ -96,7 +93,7 @@ export function useAnalyticsConsentDialogViewModel() { if (current === "preferences") return current; return resolveAnalyticsConsentPhase(current, decision, shareAnalytics); }); - }, [isPortfolioRouteFocused, shouldOffer, decision, shareAnalytics]); + }, [shouldOffer, decision, shareAnalytics]); const persistConsentCompletion = async () => { dispatch( diff --git a/apps/ledger-live-desktop/src/renderer/actions/settings.ts b/apps/ledger-live-desktop/src/renderer/actions/settings.ts index 81710e9554e8..e6774ddb2c75 100644 --- a/apps/ledger-live-desktop/src/renderer/actions/settings.ts +++ b/apps/ledger-live-desktop/src/renderer/actions/settings.ts @@ -72,35 +72,9 @@ export const setSharePersonalizedRecommendations = (sharePersonalizedRecommandat sharePersonalizedRecommandations, }); -export const setAnalyticsConsentInfo = (info: AnalyticsConsentInfo) => +export const setAnalyticsConsentInfo = (info: Partial) => saveAnalyticsConsentInfo(info); -/** - * @deprecated QA / developer tools only. Do not use in production flows. - * Merges a partial patch into `analyticsConsentInfo` (e.g. force stale privacy version or clear consent date). - */ -export const DANGEROUSLY_setAnalyticsConsentInfoForQa = (patch: Partial) => - saveAnalyticsConsentInfo(patch); - -/** - * @deprecated QA / developer tools only. Do not use in production flows. - * Clears consent metadata and turns off analytics sharing flags to simulate a pre-consent state. - */ -export const DANGEROUSLY_resetAnalyticsOptInStateForQa = () => (dispatch: AppDispatch) => { - dispatch( - saveSettings({ - shareAnalytics: false, - sharePersonalizedRecommandations: false, - }), - ); - dispatch( - saveAnalyticsConsentInfo({ - consentDate: null, - privacyPolicyVersion: null, - }), - ); -}; - /** * @deprecated QA / developer tools only. Do not use in production flows. * Replaces `devicesModelList` so debug screens can simulate audience gates (e.g. pretend Nano seen). diff --git a/apps/ledger-live-desktop/src/renderer/screens/settings/sections/Developer/AnalyticsConsentOptInDevTool/AnalyticsConsentOptInDevScreen.tsx b/apps/ledger-live-desktop/src/renderer/screens/settings/sections/Developer/AnalyticsConsentOptInDevTool/AnalyticsConsentOptInDevScreen.tsx index 82e1bcfd96f0..eb68d5f59889 100644 --- a/apps/ledger-live-desktop/src/renderer/screens/settings/sections/Developer/AnalyticsConsentOptInDevTool/AnalyticsConsentOptInDevScreen.tsx +++ b/apps/ledger-live-desktop/src/renderer/screens/settings/sections/Developer/AnalyticsConsentOptInDevTool/AnalyticsConsentOptInDevScreen.tsx @@ -1,201 +1,238 @@ import React from "react"; -import { Button } from "@ledgerhq/lumen-ui-react"; -import { ArrowLeft } from "@ledgerhq/lumen-ui-react/symbols"; -import { useNavigate } from "react-router"; -import { useDispatch, useSelector } from "LLD/hooks/redux"; -import { useFeature } from "@features/platform-feature-flags"; import { - getAnalyticsConsentDecision, - resolveAnalyticsConsentPhase, - resolveAnalyticsOptInParams, -} from "@features/flow-analytics-consent"; + Button, + Divider, + SegmentedControl, + SegmentedControlButton, + Switch, + Tag, +} from "@ledgerhq/lumen-ui-react"; +import { ArrowLeft } from "@ledgerhq/lumen-ui-react/symbols"; +import { AnalyticsConsentDialog } from "LLD/features/AnalyticsConsentDialog"; import Box from "~/renderer/components/Box"; import { - analyticsConsentInfoSelector, - hasCompletedOnboardingSelector, - shareAnalyticsSelector, -} from "~/renderer/reducers/settings"; -import { - DANGEROUSLY_resetAnalyticsOptInStateForQa, - DANGEROUSLY_setAnalyticsConsentInfoForQa, -} from "~/renderer/actions/settings"; + scenarioConfirmMessage, + useAnalyticsConsentOptInQaViewModel, + type AnalyticsConsentOptInQaViewModel, + type InspectorField, + type ToggleField, +} from "./useAnalyticsConsentOptInQaViewModel"; -const COPY = { - back: "Back", - title: "Analytics opt-in consent — QA", - readSection: "Current settings (read-only)", - currentPrivacyVersion: (version: string) => `App current privacy policy version: ${version}`, - storedPrivacyVersion: (version: string) => `Stored privacy policy version: ${version}`, - consentDate: (value: string) => `Consent date (raw): ${value}`, - decision: (value: string) => `Shared decision: ${value}`, - outdatePrivacyVersion: "Outdate version (major - 1)", - clearConsentDate: "Clear consent date (force renewal)", - resetForConsentFresh: - "Reset for consent fresh (clear consent data, turn off analytics & personalization)", - modalSection: "Expected modal on portfolio (home)", - modalGoToHome: "Open the portfolio home page to see the modal when it should appear.", - modal: { - noModalUserOk: - "No modal. Privacy policy version and consent date are fine: nothing to renew or re-acknowledge.", - noModalFeatureOff: "No modal. The analytics opt-in feature flag is off.", - noModalOnboarding: "No modal. Onboarding is not complete yet.", - fresh: - "First-time consent modal (consent fresh). Full analytics reconsent is required and Share analytics is off.", - reconfirm: - "Reconfirm consent modal. Full analytics reconsent is required and Share analytics is on.", - privacy: - "Privacy update modal. Minor policyVersion bump with otherwise valid analytics consent.", - }, -} as const; - -const formatConsentDate = (value: string | null) => { - if (value === null) return "null"; - if (value === "") return '""'; - return value; +const TONE_TEXT: Record = { + error: "text-error", + warning: "text-warning", + success: "text-success", + gray: "text-muted", }; -export function AnalyticsConsentOptInDevScreen() { - const navigate = useNavigate(); - const dispatch = useDispatch(); - const consentInfo = useSelector(analyticsConsentInfoSelector); - const hasCompletedOnboarding = useSelector(hasCompletedOnboardingSelector); - const shareAnalytics = useSelector(shareAnalyticsSelector); - const analyticsOptInFeature = useFeature("analyticsOptIn"); - const { currentPolicyVersion } = resolveAnalyticsOptInParams(analyticsOptInFeature); - const decision = getAnalyticsConsentDecision(consentInfo, { currentPolicyVersion }); - - const shouldOfferModal = - Boolean(analyticsOptInFeature?.enabled) && hasCompletedOnboarding && decision.kind !== "none"; - - const modalPhaseIfOffered = resolveAnalyticsConsentPhase("closed", decision, shareAnalytics); - - const modalPreview = (() => { - if (!hasCompletedOnboarding) { - return { body: COPY.modal.noModalOnboarding, textClass: "text-muted" as const }; - } - if (!analyticsOptInFeature?.enabled) { - return { body: COPY.modal.noModalFeatureOff, textClass: "text-muted" as const }; - } - if (!shouldOfferModal) { - return { body: COPY.modal.noModalUserOk, textClass: "text-success" as const }; - } - if (modalPhaseIfOffered === "consentReconfirm") { - return { body: COPY.modal.reconfirm, textClass: "text-interactive" as const }; - } - if (modalPhaseIfOffered === "consentFresh") { - return { body: COPY.modal.fresh, textClass: "text-interactive" as const }; - } - if (modalPhaseIfOffered === "privacy") { - return { body: COPY.modal.privacy, textClass: "text-interactive" as const }; - } - return { body: COPY.modal.noModalUserOk, textClass: "text-success" as const }; - })(); +function confirmAction(message: string): boolean { + return window.confirm(message); +} - const onBack = () => { - navigate("/settings/developer"); - }; +function InspectorRow({ field }: Readonly<{ field: InspectorField }>) { + return ( +
+
+ {field.label} + +
+ {field.value} + {field.raw ? {field.raw} : null} +
+ ); +} - const onPrivacyOutdated = () => { - const major = Math.max(1, (currentPolicyVersion?.major ?? 1) - 1); - dispatch( - DANGEROUSLY_setAnalyticsConsentInfoForQa({ - privacyPolicyVersion: `${major}.0`, - }), - ); - }; +function ToggleRow({ field }: Readonly<{ field: ToggleField }>) { + return ( +
+
+ {field.label} + +
+
+ +
+ {field.note ? {field.note} : null} +
+ ); +} - const onClearConsentDate = () => { - dispatch( - DANGEROUSLY_setAnalyticsConsentInfoForQa({ - consentDate: null, - privacyPolicyVersion: consentInfo.privacyPolicyVersion, - }), - ); - }; +function GroupLabel({ + first, + meta, +}: Readonly<{ + first?: boolean; + meta: AnalyticsConsentOptInQaViewModel["scenariosByGroup"][number]["meta"]; +}>) { + return ( +
+ + {meta.hint} +
+ ); +} - const onResetForConsentFresh = () => { - dispatch(DANGEROUSLY_resetAnalyticsOptInStateForQa()); - }; +export function AnalyticsConsentOptInDevScreen() { + const vm = useAnalyticsConsentOptInQaViewModel(); return ( + {vm.isPreviewMounted ? : null}
-
- {COPY.title} + Analytics consent QA
- -
-

{COPY.readSection}

-
-

- {COPY.currentPrivacyVersion(currentPolicyVersion?.normalized ?? "null")} -

-

- {COPY.decision(`${decision.kind} (${decision.reason})`)} -

-
- - {COPY.storedPrivacyVersion( - consentInfo.privacyPolicyVersion === null - ? "null" - : String(consentInfo.privacyPolicyVersion), - )} - +
+ + +
+
+
+ - {COPY.clearConsentDate} - + Scenarios + Inspect +
+
-
-
-

{COPY.modalSection}

-

- {modalPreview.body} -

-

{COPY.modalGoToHome}

-
- -
- + {vm.tab === "scenarios" ? ( +
+ {vm.scenariosByGroup.map(({ expected, meta, scenarios }, index) => ( +
+ +
+ {scenarios.map(scenario => ( + + ))} +
+
+ ))} +
+ ) : ( +
+
+

Stored on this device

+
+ {vm.storedFields.map(field => ( + + ))} +
+
+
+

From remote config

+
+ + +
+
+
+

User preferences

+
+ {vm.toggleFields.map(field => ( + + ))} +
+
+
+ )}
- +
); } diff --git a/apps/ledger-live-desktop/src/renderer/screens/settings/sections/Developer/AnalyticsConsentOptInDevTool/__tests__/useAnalyticsConsentOptInQaViewModel.test.ts b/apps/ledger-live-desktop/src/renderer/screens/settings/sections/Developer/AnalyticsConsentOptInDevTool/__tests__/useAnalyticsConsentOptInQaViewModel.test.ts new file mode 100644 index 000000000000..f81f2caf0a6f --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/screens/settings/sections/Developer/AnalyticsConsentOptInDevTool/__tests__/useAnalyticsConsentOptInQaViewModel.test.ts @@ -0,0 +1,223 @@ +import { FEATURE_FLAGS_DEFAULTS, FEATURE_FLAGS_INITIAL_STATE } from "@shared/feature-flags"; +import { act, renderHook } from "tests/testSetup"; +import { INITIAL_STATE } from "~/renderer/reducers/settings"; +import { + formatBareValue, + scenarioConfirmMessage, + useAnalyticsConsentOptInQaViewModel, +} from "../useAnalyticsConsentOptInQaViewModel"; + +const analyticsOptInOverrides = { + ...FEATURE_FLAGS_INITIAL_STATE.overrides, + analyticsOptIn: { + ...FEATURE_FLAGS_DEFAULTS.analyticsOptIn, + ...(FEATURE_FLAGS_INITIAL_STATE.overrides.analyticsOptIn ?? {}), + enabled: true, + params: { + ...FEATURE_FLAGS_DEFAULTS.analyticsOptIn.params, + policyVersion: "1.0", + }, + }, +}; + +const featureFlagsWithAnalyticsOptIn = { + ...FEATURE_FLAGS_INITIAL_STATE, + overrides: analyticsOptInOverrides, + resolved: { ...FEATURE_FLAGS_DEFAULTS, ...analyticsOptInOverrides }, +}; + +const defaultSettings = { + ...INITIAL_STATE, + hasCompletedOnboarding: true, + shareAnalytics: true, + sharePersonalizedRecommandations: true, + hasSeenAnalyticsOptInPrompt: true, + analyticsConsentInfo: { + consentDate: "2024-05-01T12:00:00.000Z", + privacyPolicyVersion: "1.0", + }, +}; + +const renderViewModel = ( + settings: Partial = {}, + featureFlags = featureFlagsWithAnalyticsOptIn, +) => + renderHook(() => useAnalyticsConsentOptInQaViewModel(), { + initialState: { + featureFlags, + settings: { + ...defaultSettings, + ...settings, + analyticsConsentInfo: { + ...defaultSettings.analyticsConsentInfo, + ...(settings.analyticsConsentInfo ?? {}), + }, + }, + }, + }); + +describe("useAnalyticsConsentOptInQaViewModel", () => { + it("reports Quiet when consent matches remote policy", () => { + const { result } = renderViewModel(); + + expect(result.current.headline).toBe("No drawer"); + expect(result.current.isEligibleForDrawer).toBe(false); + expect(result.current.reasonLabel).toBe("Consent matches policy"); + expect(result.current.storedFields[0]?.status).toEqual({ label: "Valid", tone: "success" }); + }); + + it("reports Full reconsent on a major policy bump scenario", () => { + const { result, store } = renderViewModel(); + const major = result.current.scenariosByGroup + .flatMap(group => group.scenarios) + .find(scenario => scenario.id === "major-bump"); + + expect(major).toBeDefined(); + + act(() => { + result.current.applyScenario(major!); + }); + + const state = store.getState(); + expect(state.featureFlags.overrides.analyticsOptIn?.params?.policyVersion).toBe("2.0"); + expect(state.settings.analyticsConsentInfo.privacyPolicyVersion).toBe("1.0"); + expect(result.current.headline).toBe("Full reconsent"); + expect(result.current.isEligibleForDrawer).toBe(true); + expect(result.current.trackingPausedUntilAnswered).toBe(true); + expect(result.current.storedFields[0]?.status).toEqual({ + label: "Valid · outdated", + tone: "warning", + }); + }); + + it("reports Privacy only on a minor policy bump scenario", () => { + const { result } = renderViewModel(); + const minor = result.current.scenariosByGroup + .flatMap(group => group.scenarios) + .find(scenario => scenario.id === "minor-bump"); + + act(() => { + result.current.applyScenario(minor!); + }); + + expect(result.current.headline).toBe("Privacy only"); + expect(result.current.isEligibleForDrawer).toBe(true); + expect(result.current.trackingPausedUntilAnswered).toBe(false); + }); + + it("keeps stored policy version on the no-consent scenario", () => { + const { result, store } = renderViewModel({ + analyticsConsentInfo: { + consentDate: "2024-05-01T12:00:00.000Z", + privacyPolicyVersion: "1.0", + }, + }); + const firstTime = result.current.scenariosByGroup + .flatMap(group => group.scenarios) + .find(scenario => scenario.id === "first-time"); + + act(() => { + result.current.applyScenario(firstTime!); + }); + + expect(store.getState().settings.analyticsConsentInfo).toEqual({ + consentDate: null, + privacyPolicyVersion: "1.0", + }); + expect(result.current.headline).toBe("Full reconsent"); + }); + + it("resets consent, preferences, and the feature flag override", () => { + const { result, store } = renderViewModel(); + const major = result.current.scenariosByGroup + .flatMap(group => group.scenarios) + .find(scenario => scenario.id === "major-bump"); + + act(() => { + result.current.applyScenario(major!); + }); + act(() => { + result.current.onResetAll(); + }); + + const state = store.getState(); + expect(state.featureFlags.overrides.analyticsOptIn).toBeUndefined(); + expect(state.settings.analyticsConsentInfo).toEqual({ + consentDate: null, + privacyPolicyVersion: null, + }); + expect(state.settings.shareAnalytics).toBe(false); + expect(state.settings.sharePersonalizedRecommandations).toBe(false); + expect(state.settings.hasSeenAnalyticsOptInPrompt).toBe(false); + expect(result.current.isAlreadyReset).toBe(true); + }); + + it("blocks preview when the feature flag is off", () => { + const { result } = renderViewModel( + {}, + { + ...featureFlagsWithAnalyticsOptIn, + overrides: { + ...analyticsOptInOverrides, + analyticsOptIn: { + ...analyticsOptInOverrides.analyticsOptIn, + enabled: false, + }, + }, + resolved: { + ...FEATURE_FLAGS_DEFAULTS, + ...analyticsOptInOverrides, + analyticsOptIn: { + ...analyticsOptInOverrides.analyticsOptIn, + enabled: false, + }, + }, + }, + ); + + expect(result.current.headline).toBe("Blocked — no drawer"); + expect(result.current.isEligibleForDrawer).toBe(false); + expect(result.current.reasonLabel).toBe("Feature flag disabled"); + }); + + it("mounts preview in place without navigating away", () => { + const { result } = renderViewModel(); + const major = result.current.scenariosByGroup + .flatMap(group => group.scenarios) + .find(scenario => scenario.id === "major-bump"); + + act(() => { + result.current.applyScenario(major!); + }); + act(() => { + result.current.onPreviewDialog(); + }); + + expect(result.current.isPreviewMounted).toBe(true); + expect(result.current.previewKey).toBe(1); + }); +}); + +describe("scenarioConfirmMessage / formatBareValue", () => { + it("includes the expected verdict title", () => { + const message = scenarioConfirmMessage({ + id: "up-to-date", + name: "Up to date", + expected: "Quiet", + summary: "Policy matches consent → no drawer", + remotePolicy: "matchBaseline", + storedPolicy: "matchBaseline", + consentDate: { kind: "daysAgo", days: 28 }, + analyticsEnabled: true, + hasSeenPrompt: true, + }); + + expect(message).toContain("No drawer"); + expect(message).toContain("Policy matches consent → no drawer"); + }); + + it("stringifies inspector raw values", () => { + expect(formatBareValue(null)).toBe("null"); + expect(formatBareValue("1.0")).toBe('"1.0"'); + }); +}); diff --git a/apps/ledger-live-desktop/src/renderer/screens/settings/sections/Developer/AnalyticsConsentOptInDevTool/index.tsx b/apps/ledger-live-desktop/src/renderer/screens/settings/sections/Developer/AnalyticsConsentOptInDevTool/index.tsx index 6718d19c3d6a..d9c83cacb326 100644 --- a/apps/ledger-live-desktop/src/renderer/screens/settings/sections/Developer/AnalyticsConsentOptInDevTool/index.tsx +++ b/apps/ledger-live-desktop/src/renderer/screens/settings/sections/Developer/AnalyticsConsentOptInDevTool/index.tsx @@ -1,24 +1,16 @@ -import React, { useCallback } from "react"; +import React from "react"; +import { Link } from "react-router"; import { Button } from "@ledgerhq/lumen-ui-react"; -import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router"; import { SettingsSectionRow } from "~/renderer/screens/settings/SettingsSection"; export default function AnalyticsConsentOptInDevTool() { - const { t } = useTranslation(); - const navigate = useNavigate(); - - const onOpen = useCallback(() => { - navigate("/settings/developer/analytics-consent-opt-in-qa"); - }, [navigate]); - return ( - ); diff --git a/apps/ledger-live-desktop/src/renderer/screens/settings/sections/Developer/AnalyticsConsentOptInDevTool/useAnalyticsConsentOptInQaViewModel.ts b/apps/ledger-live-desktop/src/renderer/screens/settings/sections/Developer/AnalyticsConsentOptInDevTool/useAnalyticsConsentOptInQaViewModel.ts new file mode 100644 index 000000000000..2a86a7ab358b --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/screens/settings/sections/Developer/AnalyticsConsentOptInDevTool/useAnalyticsConsentOptInQaViewModel.ts @@ -0,0 +1,376 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router"; +import { useFeature } from "@features/platform-feature-flags"; +import { featureFlagsOverridesSelector, setOverride } from "@shared/feature-flags"; +import { + parseConsentDate, + parseStoredPolicyVersion, + type AnalyticsConsentInfo, + type PolicyVersion, +} from "@domain/entity-analytics-consent"; +import { + getConsentDateState, + resolveAnalyticsConsentPhase, + useAnalyticsConsentDecision, + type ConsentDateState, +} from "@features/flow-analytics-consent"; +import { + mapDecisionToQaExpectation, + QA_SCENARIOS, + REASON_LABEL, + resolveBaselinePolicyVersion, + resolveScenarioConsentDate, + resolveScenarioVersions, + resolveStoredPolicyInspectorStatus, + SCENARIO_GROUPS, + SYNTHETIC_BASELINE, + VERDICT_META, + type QaScenario, +} from "@features/flow-analytics-consent/debug"; +import { useDispatch, useSelector } from "LLD/hooks/redux"; +import { + setAnalyticsConsentInfo, + setHasSeenAnalyticsOptInPrompt, + setShareAnalytics, + setSharePersonalizedRecommendations, +} from "~/renderer/actions/settings"; +import { + analyticsConsentInfoSelector, + hasCompletedOnboardingSelector, + hasSeenAnalyticsOptInPromptSelector, + shareAnalyticsSelector, + sharePersonalizedRecommendationsSelector, + trackingEnabledSelector, +} from "~/renderer/reducers/settings"; + +const FLAG_KEY = "analyticsOptIn"; + +export type TabId = "scenarios" | "inspect"; + +export type FieldTone = "success" | "error" | "warning" | "gray"; + +export type InspectorField = { + label: string; + value: string; + raw?: string; + status: { label: string; tone: FieldTone }; +}; + +export type ToggleField = { + label: string; + value: boolean; + note?: string; + onChange: (value: boolean) => void; +}; + +type BlockedReason = keyof typeof BLOCKED_META; + +const BLOCKED_META = { + "analyticsOptIn flag is off": { + hint: "Feature flag off. Turn on Feature flag enabled in Inspect.", + previewHint: "Enable feature flag in Inspect first.", + }, + "onboarding incomplete": { + hint: "Onboarding incomplete. Finish onboarding first.", + previewHint: "Finish onboarding first.", + }, +} as const; + +export function scenarioConfirmMessage(scenario: QaScenario): string { + return `Applies this preset.\n\n${scenario.summary}\n\nExpected: ${VERDICT_META[scenario.expected].title}.`; +} + +export function formatConsentDate(iso: string | null): string | null { + const date = parseConsentDate(iso); + if (date === null) return null; + return date.toLocaleString(undefined, { + month: "long", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +export function formatBareValue(value: unknown): string { + if (value === null || value === undefined) return "null"; + return JSON.stringify(value); +} + +function resolveBlockedReason( + featureEnabled: boolean | undefined, + hasCompletedOnboarding: boolean, +): BlockedReason | null { + if (!featureEnabled) return "analyticsOptIn flag is off"; + if (!hasCompletedOnboarding) return "onboarding incomplete"; + return null; +} + +function buildInspectorFields( + consentInfo: AnalyticsConsentInfo, + storedVersion: PolicyVersion | null, + formattedConsentDate: string | null, + consentDateState: ConsentDateState, + currentPolicyVersion: PolicyVersion | null, + rawPolicyVersion: unknown, +): Readonly<{ storedFields: InspectorField[]; policyVersionField: InspectorField }> { + let storedPolicyValue: string; + if (storedVersion) { + storedPolicyValue = `Version ${storedVersion.major}.${storedVersion.minor}`; + } else if (consentInfo.privacyPolicyVersion === null) { + storedPolicyValue = "Missing"; + } else { + storedPolicyValue = "Invalid"; + } + + let consentDateValue: string; + if (formattedConsentDate) { + consentDateValue = formattedConsentDate; + } else if (consentDateState === "missing") { + consentDateValue = "Missing"; + } else { + consentDateValue = "Invalid"; + } + + let consentDateStatus: InspectorField["status"]; + if (consentDateState === "valid") { + consentDateStatus = { label: "Valid", tone: "success" }; + } else if (consentDateState === "missing") { + consentDateStatus = { label: "Missing", tone: "error" }; + } else { + consentDateStatus = { label: "Invalid", tone: "error" }; + } + + return { + storedFields: [ + { + label: "Saved policy version", + value: storedPolicyValue, + raw: `privacyPolicyVersion: ${formatBareValue(consentInfo.privacyPolicyVersion)}`, + status: resolveStoredPolicyInspectorStatus( + consentInfo.privacyPolicyVersion, + storedVersion, + currentPolicyVersion, + ), + }, + { + label: "Consent date", + value: consentDateValue, + raw: consentInfo.consentDate + ? `consentDate: ${formatBareValue(consentInfo.consentDate)}` + : "consentDate: null", + status: consentDateStatus, + }, + ], + policyVersionField: { + label: "Remote policy version", + value: currentPolicyVersion + ? `Version ${currentPolicyVersion.major}.${currentPolicyVersion.minor}` + : "Invalid — checks off", + raw: `policyVersion: ${formatBareValue(rawPolicyVersion)}`, + status: currentPolicyVersion + ? { label: "Valid", tone: "success" } + : { label: "Invalid", tone: "error" }, + }, + }; +} + +export function useAnalyticsConsentOptInQaViewModel() { + const navigate = useNavigate(); + const dispatch = useDispatch(); + const feature = useFeature(FLAG_KEY); + const rawPolicyVersion = feature?.params?.policyVersion; + + const consentInfo = useSelector(analyticsConsentInfoSelector); + const shareAnalytics = useSelector(shareAnalyticsSelector); + const personalizedEnabled = useSelector(sharePersonalizedRecommendationsSelector); + const hasCompletedOnboarding = useSelector(hasCompletedOnboardingSelector); + const hasSeenPrompt = useSelector(hasSeenAnalyticsOptInPromptSelector); + const trackingEnabled = useSelector(trackingEnabledSelector); + const overrides = useSelector(featureFlagsOverridesSelector); + const hasAnalyticsOptInOverride = overrides[FLAG_KEY] !== undefined; + + const { decision, currentPolicyVersion } = useAnalyticsConsentDecision(consentInfo); + const storedVersion = parseStoredPolicyVersion(consentInfo.privacyPolicyVersion); + const consentDateState = getConsentDateState(consentInfo.consentDate); + + const [tab, setTab] = useState("scenarios"); + const [isPreviewMounted, setPreviewMounted] = useState(false); + const [previewKey, setPreviewKey] = useState(0); + // Remote policyVersion without a local override — refreshed on mount and after Reset all. + const [baselinePolicyVersion, setBaselinePolicyVersion] = + useState(SYNTHETIC_BASELINE); + + useEffect(() => { + if (!hasAnalyticsOptInOverride) { + setBaselinePolicyVersion(resolveBaselinePolicyVersion(feature?.params?.policyVersion)); + } + }, [hasAnalyticsOptInOverride, feature?.params?.policyVersion]); + + const blockedReason = resolveBlockedReason(feature?.enabled, hasCompletedOnboarding); + const phase = resolveAnalyticsConsentPhase("closed", decision, shareAnalytics); + const isEligibleForDrawer = blockedReason === null && phase !== "closed"; + const verdict = mapDecisionToQaExpectation(decision, shareAnalytics, blockedReason); + const verdictMeta = VERDICT_META[verdict]; + const verdictReason = blockedReason ?? decision.reason; + const blocked = blockedReason !== null ? BLOCKED_META[blockedReason] : null; + const headline = blocked ? "Blocked — no drawer" : verdictMeta.title; + const headlineHint = blocked ? blocked.hint : verdictMeta.hint; + const previewHint = blocked ? blocked.previewHint : verdictMeta.previewHint; + + const isAlreadyReset = + !hasAnalyticsOptInOverride && + consentInfo.consentDate === null && + consentInfo.privacyPolicyVersion === null && + !shareAnalytics && + !personalizedEnabled && + !hasSeenPrompt; + + const clearPreview = () => { + setPreviewMounted(false); + }; + + const overrideConfigVersion = (policyVersion: number | string | undefined) => { + clearPreview(); + dispatch( + setOverride({ + key: FLAG_KEY, + value: + policyVersion === undefined + ? undefined + : { + ...feature, + enabled: feature?.enabled ?? true, + params: { ...feature?.params, policyVersion }, + }, + }), + ); + }; + + const overrideFlagEnabled = (enabled: boolean) => { + clearPreview(); + dispatch( + setOverride({ + key: FLAG_KEY, + value: { + ...feature, + enabled, + params: feature?.params ?? {}, + }, + }), + ); + }; + + const applyScenario = (scenario: QaScenario) => { + const { policyVersion, storedVersion: nextStoredVersion } = resolveScenarioVersions( + scenario, + baselinePolicyVersion, + ); + overrideConfigVersion(policyVersion); + dispatch( + setAnalyticsConsentInfo({ + consentDate: resolveScenarioConsentDate(scenario.consentDate, new Date()), + // `undefined` from resolveScenarioVersions means leave the saved version as-is. + privacyPolicyVersion: + nextStoredVersion === undefined ? consentInfo.privacyPolicyVersion : nextStoredVersion, + }), + ); + dispatch(setShareAnalytics(scenario.analyticsEnabled)); + dispatch(setSharePersonalizedRecommendations(scenario.analyticsEnabled)); + dispatch(setHasSeenAnalyticsOptInPrompt(scenario.hasSeenPrompt)); + clearPreview(); + }; + + const onResetAll = () => { + overrideConfigVersion(undefined); + dispatch(setAnalyticsConsentInfo({ consentDate: null, privacyPolicyVersion: null })); + dispatch(setShareAnalytics(false)); + dispatch(setSharePersonalizedRecommendations(false)); + dispatch(setHasSeenAnalyticsOptInPrompt(false)); + clearPreview(); + }; + + const onPreviewDialog = () => { + setPreviewKey(key => key + 1); + setPreviewMounted(true); + }; + + const onBack = () => { + navigate("/settings/developer"); + }; + + const { storedFields, policyVersionField } = buildInspectorFields( + consentInfo, + storedVersion, + formatConsentDate(consentInfo.consentDate), + consentDateState, + currentPolicyVersion, + rawPolicyVersion, + ); + + const toggleFields: ToggleField[] = [ + { + label: "Analytics enabled", + value: shareAnalytics, + onChange: value => { + clearPreview(); + dispatch(setShareAnalytics(value)); + }, + }, + { + label: "Personalized recommendations", + value: personalizedEnabled, + onChange: value => { + clearPreview(); + dispatch(setSharePersonalizedRecommendations(value)); + }, + }, + { + label: "Has seen consent prompt", + value: hasSeenPrompt, + note: hasSeenPrompt ? undefined : "Never answered consent prompt", + onChange: value => { + clearPreview(); + dispatch(setHasSeenAnalyticsOptInPrompt(value)); + }, + }, + ]; + + const scenariosByGroup = SCENARIO_GROUPS.map(expected => ({ + expected, + meta: VERDICT_META[expected], + scenarios: QA_SCENARIOS.filter(scenario => scenario.expected === expected), + })); + + return { + tab, + setTab, + headline, + headlineHint, + headlineTone: (blocked ? "warning" : verdictMeta.tone) as FieldTone, + reasonLabel: REASON_LABEL[verdictReason] ?? verdictReason, + trackingEnabled, + trackingPausedUntilAnswered: !trackingEnabled && verdict === "Re-ask", + isEligibleForDrawer, + isPreviewMounted, + previewKey, + previewHint, + isAlreadyReset, + policyVersionField, + storedFields, + toggleFields, + scenariosByGroup, + overrideFlagEnabled, + featureEnabled: Boolean(feature?.enabled), + applyScenario, + onResetAll, + onPreviewDialog, + onBack, + }; +} + +export type AnalyticsConsentOptInQaViewModel = ReturnType< + typeof useAnalyticsConsentOptInQaViewModel +>; + +export type { QaExpectation, QaScenario } from "@features/flow-analytics-consent/debug"; diff --git a/apps/ledger-live-desktop/static/i18n/en/app.json b/apps/ledger-live-desktop/static/i18n/en/app.json index 2a412a85991a..9dda9684142b 100644 --- a/apps/ledger-live-desktop/static/i18n/en/app.json +++ b/apps/ledger-live-desktop/static/i18n/en/app.json @@ -5036,10 +5036,6 @@ "title": "Theme Toggle", "desc": "Display a floating overlay to quickly switch themes." }, - "analyticsConsentOptInQa": { - "rowTitle": "Analytics opt-in consent (QA)", - "rowDesc": "Inspect stored privacy consent version and consent date; simulate outdated states for the portfolio analytics consent modal." - }, "devTools": { "rowTitle": "DevTools", "rowDesc": "Open the DevTools shell (feature flags and dev tools panel)."