Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/cowswap-frontend/src/common/analytics/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { AnalyticsCategory, GtmEvent, toGtmEvent } from '@cowprotocol/analytics'
export enum CowSwapAnalyticsCategory {
// Trade Categories
TRADE = 'Trade',
AFFILIATE = 'Affiliate',
Bridge = 'Bridge',
LIST = 'Lists',
HOOKS = 'Hooks',
Expand Down
27 changes: 17 additions & 10 deletions apps/cowswap-frontend/src/legacy/components/Copy/CopyMod.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { MouseEvent } from 'react'
import { type MouseEvent, type ReactNode } from 'react'

import { useCopyClipboard } from '@cowprotocol/common-hooks'
import { UI, LinkStyledButton } from '@cowprotocol/ui'
Expand Down Expand Up @@ -50,28 +50,35 @@ interface CopyHelperProps
never
> {
toCopy: string
children?: React.ReactNode
children?: ReactNode
clickableLink?: boolean
copyIconWidth?: string
hideCopiedLabel?: boolean
onCopy?: () => void
}

// TODO: Add proper return type annotation
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export default function CopyHelper(props: CopyHelperProps) {
const { toCopy, children, clickableLink, copyIconWidth, hideCopiedLabel = false, ...rest } = props
export default function CopyHelper(props: CopyHelperProps): ReactNode {
const { toCopy, children, clickableLink, copyIconWidth, hideCopiedLabel = false, onCopy, ...rest } = props
const [isCopied, setCopied] = useCopyClipboard()

// TODO: Add proper return type annotation
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
const handleClick = (event: MouseEvent<HTMLButtonElement>): void => {
event.stopPropagation()
setCopied(toCopy)
onCopy?.()
}

return (
<>
{clickableLink && <LinkStyledButton onClick={() => setCopied(toCopy)}>{toCopy}</LinkStyledButton>}
{clickableLink && (
<LinkStyledButton
onClick={() => {
setCopied(toCopy)
onCopy?.()
}}
>
{toCopy}
</LinkStyledButton>
)}
<CopyIcon isCopied={isCopied} onClick={handleClick} copyIconWidth={copyIconWidth} {...rest}>
{isCopied ? (
<TransactionStatusText
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { useCowAnalytics, type CowAnalytics } from '@cowprotocol/analytics'

import { renderHook } from '@testing-library/react'

import { CowSwapAnalyticsCategory } from 'common/analytics/types'

import {
AffiliateCodeSource,
AffiliateEntrySource,
AffiliateModalState,
AffiliatePageState,
} from './affiliateAnalytics.types'
import {
getAffiliateCodeSourceFallback,
getAffiliateModalOpenViewKey,
getAffiliateModalViewKey,
getAffiliatePartnerPageState,
getAffiliateTraderModalState,
getAffiliateTraderPageState,
trackAffiliateEvent,
} from './affiliateAnalytics.utils'

import { useAffiliateStateViewAnalytics } from '../hooks/useAffiliateStateViewAnalytics'
import { TraderWalletStatus } from '../hooks/useAffiliateTraderWallet'

jest.mock('@cowprotocol/analytics', () => {
const actualModule = jest.requireActual('@cowprotocol/analytics')

return {
...actualModule,
__resetGtmInstance: jest.fn(),
useCowAnalytics: jest.fn(),
}
})

const useCowAnalyticsMock = useCowAnalytics as jest.MockedFunction<typeof useCowAnalytics>

describe('trackAffiliateEvent', () => {
it('sends affiliate analytics payloads without undefined fields', () => {
const sendEvent = jest.fn()
const analytics = { sendEvent } as unknown as CowAnalytics

trackAffiliateEvent({
analytics,
action: 'affiliate_trader_page_state_viewed',
chainId: 1,
walletStatus: TraderWalletStatus.LINKED,
codeSource: undefined,
})

expect(sendEvent).toHaveBeenCalledWith({
category: CowSwapAnalyticsCategory.AFFILIATE,
action: 'affiliate_trader_page_state_viewed',
chainId: 1,
walletStatus: TraderWalletStatus.LINKED,
})
})
})

describe('getAffiliatePartnerPageState', () => {
it('returns onboard when the wallet or network is not eligible for partner setup', () => {
expect(
getAffiliatePartnerPageState({
hasAccount: false,
hasExistingCode: false,
isLoading: false,
isSupportedPayoutNetwork: true,
isSupportedTradingNetwork: true,
}),
).toBe(AffiliatePageState.ONBOARD)
})

it('returns undefined while partner info is still loading', () => {
expect(
getAffiliatePartnerPageState({
hasAccount: true,
hasExistingCode: false,
isLoading: true,
isSupportedPayoutNetwork: true,
isSupportedTradingNetwork: true,
}),
).toBeUndefined()
})

it('returns the live and creation states once loading completes', () => {
expect(
getAffiliatePartnerPageState({
hasAccount: true,
hasExistingCode: true,
isLoading: false,
isSupportedPayoutNetwork: true,
isSupportedTradingNetwork: true,
}),
).toBe(AffiliatePageState.CODE_LIVE)

expect(
getAffiliatePartnerPageState({
hasAccount: true,
hasExistingCode: false,
isLoading: false,
isSupportedPayoutNetwork: true,
isSupportedTradingNetwork: true,
}),
).toBe(AffiliatePageState.CODE_CREATION)
})
})

describe('trader analytics helpers', () => {
it('derives trader page and modal states from wallet status', () => {
expect(getAffiliateTraderPageState(TraderWalletStatus.PENDING, false)).toBe(AffiliatePageState.LOADING)
expect(getAffiliateTraderPageState(TraderWalletStatus.UNSUPPORTED, false)).toBe(AffiliatePageState.UNSUPPORTED)
expect(getAffiliateTraderPageState(TraderWalletStatus.ELIGIBLE, true)).toBe(AffiliatePageState.LINKED)
expect(getAffiliateTraderModalState(TraderWalletStatus.INELIGIBLE)).toBe(AffiliateModalState.INELIGIBLE)
})

it('builds stable modal analytics keys only while the modal is open', () => {
expect(
getAffiliateModalViewKey(
true,
AffiliateModalState.CODE_LINKING,
TraderWalletStatus.ELIGIBLE,
AffiliateEntrySource.TRADER_PAGE_ONBOARD,
),
).toBe('codeLinking:eligible:traderPageOnboard')

expect(
getAffiliateModalOpenViewKey(
true,
TraderWalletStatus.LINKED,
AffiliateEntrySource.TRADER_REWARDS_ROW,
true,
true,
),
).toBe('linked:traderRewardsRow:true:true')

expect(
getAffiliateModalViewKey(false, AffiliateModalState.LINKED, TraderWalletStatus.LINKED, undefined),
).toBeUndefined()
})

it('falls back to legacy or manual code sources based on linkage state', () => {
expect(getAffiliateCodeSourceFallback(true)).toBe(AffiliateCodeSource.LEGACY_UNKNOWN)
expect(getAffiliateCodeSourceFallback(false)).toBe(AffiliateCodeSource.MANUAL_INPUT)
})
})

describe('useAffiliateStateViewAnalytics', () => {
const sendEvent = jest.fn()

beforeEach(() => {
jest.clearAllMocks()

useCowAnalyticsMock.mockReturnValue({
sendEvent,
} as unknown as ReturnType<typeof useCowAnalytics>)
})

it('re-tracks the view when payload fields change for the same view key', () => {
const { rerender } = renderHook(
({ walletStatus }) =>
useAffiliateStateViewAnalytics({
action: 'affiliate_trader_page_state_viewed',
viewKey: AffiliatePageState.LINKED,
eventParams: {
pageState: AffiliatePageState.LINKED,
walletStatus,
hasSavedCode: true,
},
}),
{
initialProps: {
walletStatus: TraderWalletStatus.ELIGIBLE,
},
},
)

expect(sendEvent).toHaveBeenCalledTimes(1)

rerender({
walletStatus: TraderWalletStatus.LINKED,
})

expect(sendEvent).toHaveBeenCalledTimes(2)
expect(sendEvent).toHaveBeenLastCalledWith({
category: CowSwapAnalyticsCategory.AFFILIATE,
action: 'affiliate_trader_page_state_viewed',
pageState: AffiliatePageState.LINKED,
walletStatus: TraderWalletStatus.LINKED,
hasSavedCode: true,
})

rerender({
walletStatus: TraderWalletStatus.LINKED,
})

expect(sendEvent).toHaveBeenCalledTimes(2)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
export enum AffiliateEntrySource {
PARTNER_PAGE_ONBOARD = 'partnerPageOnboard',
TRADER_PAGE_ONBOARD = 'traderPageOnboard',
TRADER_REWARDS_ROW = 'traderRewardsRow',
HEADER_REFER_BUTTON = 'headerReferButton',
DEEP_LINK = 'deepLink',
TRADER_PAGE_CODE_CARD = 'traderPageCodeCard',
}

export enum AffiliatePageState {
ONBOARD = 'onboard',
CODE_CREATION = 'codeCreation',
CODE_LIVE = 'codeLive',
LOADING = 'loading',
UNSUPPORTED = 'unsupported',
INELIGIBLE = 'ineligible',
ELIGIBILITY_UNKNOWN = 'eligibilityUnknown',
LINKED = 'linked',
}

export enum AffiliateModalState {
CODE_LINKING = 'codeLinking',
LINKED = 'linked',
INELIGIBLE = 'ineligible',
UNSUPPORTED = 'unsupported',
}

export enum AffiliateCodeSource {
MANUAL_INPUT = 'manualInput',
URL_REF_PARAM = 'urlRefParam',
LOCAL_ORDER_RECOVERY = 'localOrderRecovery',
ORDERBOOK_RECOVERY = 'orderbookRecovery',
LEGACY_UNKNOWN = 'legacyUnknown',
}

export enum AffiliateVerificationResult {
SUCCESS = 'success',
INVALID_FORMAT = 'invalidFormat',
INVALID_CODE = 'invalidCode',
SELF_REFERRAL = 'selfReferral',
SERVICE_UNAVAILABLE = 'serviceUnavailable',
}

export enum AffiliatePartnerCodeAvailabilityResult {
AVAILABLE = 'available',
UNAVAILABLE = 'unavailable',
NETWORK_ERROR = 'networkError',
}

export enum AffiliatePartnerCodeCreateFailureReason {
USER_REJECTED_SIGNATURE = 'userRejectedSignature',
NETWORK_ERROR = 'networkError',
CODE_UNAVAILABLE = 'codeUnavailable',
UNEXPECTED_ERROR = 'unexpectedError',
}

export type AffiliateAnalyticsAction =
| 'affiliate_partner_page_state_viewed'
| 'affiliate_partner_onboard_cta_clicked'
| 'affiliate_partner_terms_clicked'
| 'affiliate_partner_how_it_works_clicked'
| 'affiliate_partner_code_suggestion_regenerated'
| 'affiliate_partner_code_availability_resolved'
| 'affiliate_partner_code_create_started'
| 'affiliate_partner_code_create_completed'
| 'affiliate_partner_referral_code_copied'
| 'affiliate_partner_referral_link_copied'
| 'affiliate_partner_share_on_x_clicked'
| 'affiliate_partner_qr_modal_opened'
| 'affiliate_partner_qr_downloaded'
| 'affiliate_trader_page_state_viewed'
| 'affiliate_trader_onboard_cta_clicked'
| 'affiliate_trader_rewards_row_clicked'
| 'affiliate_trader_referral_url_detected'
| 'affiliate_trader_modal_opened'
| 'affiliate_trader_modal_state_viewed'
| 'affiliate_trader_code_verification_started'
| 'affiliate_trader_code_verification_completed'
| 'affiliate_trader_code_input_edited'
| 'affiliate_trader_code_removed'
| 'affiliate_trader_payout_confirmation_toggled'
| 'affiliate_trader_linked_code_recovered'
| 'affiliate_trader_modal_primary_cta_clicked'
Loading
Loading