Skip to content

Commit 3fb6266

Browse files
committed
feat(tracking): add tracking contact send flow lwd
1 parent d80a0e0 commit 3fb6266

32 files changed

Lines changed: 1282 additions & 115 deletions

.changeset/violet-flowers-try.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"ledger-live-desktop": minor
3+
---
4+
5+
feat(tracking): add tracking contact send flow lwd

apps/ledger-live-desktop/src/mvvm/features/Send/components/SendFlowLayout.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import { track } from "~/renderer/analytics/segment";
1616
import { getSendFlowTrackingProperties } from "../utils/tracking";
1717
import { AddNewContactHeaderProvider } from "../context/AddNewContactHeaderContext";
1818
import { RecipientContactSelectionProvider } from "../context/RecipientContactSelectionContext";
19+
import { useSendFlowTracking } from "../context/SendFlowTrackingContext";
20+
import { getSendFlowTrackingPage } from "../utils/contactTracking";
1921

2022
type SendFlowLayoutProps = Readonly<{
2123
isOpen: boolean;
@@ -25,6 +27,7 @@ type SendFlowLayoutProps = Readonly<{
2527
export function SendFlowLayout({ isOpen, onClose }: SendFlowLayoutProps) {
2628
const wizard = useFlowWizard<SendFlowStep, SendFlowBusinessContext, SendStepConfig>();
2729
const { state } = useSendFlowData();
30+
const { recipientType } = useSendFlowTracking();
2831

2932
const currentStepConfig = wizard.currentStepConfig;
3033
const StepComponent = wizard.currentStepRenderer;
@@ -38,13 +41,14 @@ export function SendFlowLayout({ isOpen, onClose }: SendFlowLayoutProps) {
3841
if (!open) {
3942
track("button_clicked", {
4043
button: "close",
41-
page: `step ${wizard.currentStep}`,
44+
page: getSendFlowTrackingPage(wizard.currentStep),
45+
recipientType,
4246
...sendFlowTrackingProperties,
4347
});
4448
onClose();
4549
}
4650
},
47-
[onClose, wizard.currentStep, sendFlowTrackingProperties],
51+
[onClose, recipientType, wizard.currentStep, sendFlowTrackingProperties],
4852
);
4953

5054
const dialogHeight = currentStepConfig?.height ?? "fixed";

apps/ledger-live-desktop/src/mvvm/features/Send/components/SendHeader.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React, { useMemo } from "react";
22
import { useTranslation } from "react-i18next";
33
import { AddressInput, DialogHeader } from "@ledgerhq/lumen-ui-react";
44
import { useFlowWizard } from "../../FlowWizard/FlowWizardContext";
5-
import { useSendFlowData, useSendFlowActions } from "../context/SendFlowContext";
5+
import { useSendFlowData } from "../context/SendFlowContext";
66
import { useSendAmountDisplayMode } from "@ledgerhq/live-common/flows/send/amount/SendAmountDisplayModeContext";
77
import {
88
SEND_FLOW_STEP,
@@ -22,7 +22,6 @@ import type { SendStepConfig } from "../types";
2222
export function SendHeader() {
2323
const wizard = useFlowWizard<SendFlowStep, SendFlowBusinessContext, SendStepConfig>();
2424
const { state, uiConfig, recipientSearch } = useSendFlowData();
25-
const { close } = useSendFlowActions();
2625
const { displayMode } = useSendAmountDisplayMode();
2726
const { t } = useTranslation();
2827
const { currentStep } = wizard;
@@ -45,8 +44,10 @@ export function SendHeader() {
4544
addressInputValue,
4645
descriptionText,
4746
handleBack,
47+
handleClose,
4848
handleRecipientInputClick,
4949
handleRecipientInputChange,
50+
handleRecipientPaste,
5051
handleQrCodeClick,
5152
handleScanPicked,
5253
isScannerOpen,
@@ -104,6 +105,7 @@ export function SendHeader() {
104105
prefix={t("newSendFlow.to")}
105106
value={addressInputValue}
106107
onChange={e => handleRecipientInputChange(e.target.value)}
108+
onPaste={handleRecipientPaste}
107109
onClear={recipientSearch.clear}
108110
onQrCodeClick={handleQrCodeClick}
109111
placeholder={recipientPlaceholder}
@@ -162,6 +164,7 @@ export function SendHeader() {
162164
onMemoValueChange,
163165
handleRecipientInputClick,
164166
handleRecipientInputChange,
167+
handleRecipientPaste,
165168
handleQrCodeClick,
166169
handleScanPicked,
167170
isScannerOpen,
@@ -175,7 +178,7 @@ export function SendHeader() {
175178
title={title}
176179
description={descriptionText || undefined}
177180
onBack={showBackButton ? handleBack : undefined}
178-
onClose={close}
181+
onClose={handleClose}
179182
/>
180183
</div>
181184
{recipientInputContent}

apps/ledger-live-desktop/src/mvvm/features/Send/components/__tests__/SendHeader.test.tsx

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,40 @@ import { useFlowWizard } from "../../../FlowWizard/FlowWizardContext";
1010
import { useSendFlowData, useSendFlowActions } from "../../context/SendFlowContext";
1111

1212
jest.mock("../../hooks/useSendHeaderModel");
13-
jest.mock("../../../FlowWizard/FlowWizardContext", () => ({ useFlowWizard: jest.fn() }));
13+
jest.mock("../../../FlowWizard/FlowWizardContext", () => ({
14+
useFlowWizard: jest.fn(),
15+
}));
1416
jest.mock("../../context/SendFlowContext", () => ({
1517
useSendFlowData: jest.fn(),
1618
useSendFlowActions: jest.fn(),
1719
}));
18-
jest.mock("../../hooks/useAvailableBalance", () => ({ useAvailableBalance: () => "" }));
20+
jest.mock("../../hooks/useAvailableBalance", () => ({
21+
useAvailableBalance: () => "",
22+
}));
1923
jest.mock("@ledgerhq/live-common/flows/send/amount/SendAmountDisplayModeContext", () => ({
20-
useSendAmountDisplayMode: () => ({ displayMode: "fiat", setDisplayMode: jest.fn() }),
24+
useSendAmountDisplayMode: () => ({
25+
displayMode: "fiat",
26+
setDisplayMode: jest.fn(),
27+
}),
2128
}));
2229
jest.mock("../AddressDisclaimer", () => ({ AddressDisclaimer: () => null }));
30+
jest.mock("../../hooks/useSendHeaderMemo", () => ({
31+
useSendHeaderMemo: () => ({
32+
currencyId: undefined,
33+
hasMemoTypeOptions: false,
34+
memo: undefined,
35+
memoTypeOptions: [],
36+
onMemoTypeChange: jest.fn(),
37+
showMemoValueInput: false,
38+
onMemoValueChange: jest.fn(),
39+
showSkipMemo: false,
40+
skipMemoState: "idle",
41+
onSkipMemoRequestConfirm: jest.fn(),
42+
onSkipMemoCancelConfirm: jest.fn(),
43+
onSkipMemoConfirm: jest.fn(),
44+
resetViewState: jest.fn(),
45+
}),
46+
}));
2347
jest.mock("@ledgerhq/lumen-ui-react", () => ({
2448
...jest.requireActual("@ledgerhq/lumen-ui-react"),
2549
DialogHeader: ({ title }: { title?: string }) => <div>{title}</div>,
@@ -31,8 +55,10 @@ const baseModel = {
3155
addressInputValue: "0x123456...12345678",
3256
descriptionText: "Base 1 · $5,969.83",
3357
handleBack: jest.fn(),
58+
handleClose: jest.fn(),
3459
handleRecipientInputClick: jest.fn(),
3560
handleRecipientInputChange: jest.fn(),
61+
handleRecipientPaste: jest.fn(),
3662
handleQrCodeClick: jest.fn(),
3763
handleScanPicked: jest.fn(),
3864
isScannerOpen: false,
@@ -114,7 +140,10 @@ describe("SendHeader", () => {
114140
currentStepConfig: { addressInput: true },
115141
navigation: { goToNextStep: jest.fn() },
116142
});
117-
mockedUseSendHeaderModel.mockReturnValue({ ...baseModel, addressInputValue: "" });
143+
mockedUseSendHeaderModel.mockReturnValue({
144+
...baseModel,
145+
addressInputValue: "",
146+
});
118147

119148
render(<SendHeader />);
120149

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import React, {
2+
createContext,
3+
type ReactNode,
4+
useCallback,
5+
useContext,
6+
useMemo,
7+
useState,
8+
} from "react";
9+
import type {
10+
RecipientInputMethod,
11+
RecipientResultType,
12+
RecipientType,
13+
} from "../utils/contactTracking";
14+
15+
type SendFlowTrackingState = Readonly<{
16+
inputMethod: RecipientInputMethod;
17+
resultType: RecipientResultType | null;
18+
recipientType: RecipientType | null;
19+
savedContactDuringFlow: boolean;
20+
}>;
21+
22+
type SendFlowTrackingContextValue = SendFlowTrackingState &
23+
Readonly<{
24+
setInputMethod: (inputMethod: RecipientInputMethod) => void;
25+
setRecipientResolution: (resultType: RecipientResultType, recipientType: RecipientType) => void;
26+
markContactSaved: () => void;
27+
}>;
28+
29+
const SendFlowTrackingContext = createContext<SendFlowTrackingContextValue | null>(null);
30+
31+
export function SendFlowTrackingProvider({ children }: Readonly<{ children: ReactNode }>) {
32+
const [state, setState] = useState<SendFlowTrackingState>({
33+
inputMethod: "manual",
34+
resultType: null,
35+
recipientType: null,
36+
savedContactDuringFlow: false,
37+
});
38+
39+
const setInputMethod = useCallback((inputMethod: RecipientInputMethod) => {
40+
setState(previous => ({ ...previous, inputMethod }));
41+
}, []);
42+
43+
const setRecipientResolution = useCallback(
44+
(resultType: RecipientResultType, recipientType: RecipientType) => {
45+
setState(previous => ({ ...previous, resultType, recipientType }));
46+
},
47+
[],
48+
);
49+
50+
const markContactSaved = useCallback(() => {
51+
setState(previous => ({ ...previous, savedContactDuringFlow: true }));
52+
}, []);
53+
54+
const value = useMemo(
55+
() => ({
56+
...state,
57+
setInputMethod,
58+
setRecipientResolution,
59+
markContactSaved,
60+
}),
61+
[markContactSaved, setInputMethod, setRecipientResolution, state],
62+
);
63+
64+
return (
65+
<SendFlowTrackingContext.Provider value={value}>{children}</SendFlowTrackingContext.Provider>
66+
);
67+
}
68+
69+
export function useSendFlowTracking(): SendFlowTrackingContextValue {
70+
const context = useContext(SendFlowTrackingContext);
71+
if (!context) {
72+
throw new Error("useSendFlowTracking must be used within a SendFlowTrackingProvider");
73+
}
74+
return context;
75+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import React, { type ReactNode } from "react";
2+
import { act, renderHook } from "tests/testSetup";
3+
import {
4+
SendFlowTrackingProvider,
5+
useSendFlowTracking,
6+
} from "../SendFlowTrackingContext";
7+
8+
function wrapper({ children }: Readonly<{ children: ReactNode }>) {
9+
return <SendFlowTrackingProvider>{children}</SendFlowTrackingProvider>;
10+
}
11+
12+
describe("SendFlowTrackingContext", () => {
13+
it("keeps recipient resolution metadata for the whole Send flow", () => {
14+
const { result } = renderHook(() => useSendFlowTracking(), { wrapper });
15+
16+
expect(result.current).toMatchObject({
17+
inputMethod: "manual",
18+
resultType: null,
19+
recipientType: null,
20+
savedContactDuringFlow: false,
21+
});
22+
23+
act(() => {
24+
result.current.setInputMethod("paste");
25+
result.current.setRecipientResolution("contact address match", "contact");
26+
result.current.markContactSaved();
27+
});
28+
29+
expect(result.current).toMatchObject({
30+
inputMethod: "paste",
31+
resultType: "contact address match",
32+
recipientType: "contact",
33+
savedContactDuringFlow: true,
34+
});
35+
});
36+
});

0 commit comments

Comments
 (0)