From 5ec343f84f746088a9bcba5cebf71fafc352a705 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 24 Mar 2022 16:28:57 +0100 Subject: [PATCH] feat: improve analytics (#3415) * feat: add Google Tag Manager * fix: load GTM from cookie banner (#3417) * fix: load GTM from cookie banner * fix: GTM env init check * fix: move gtm dev env to env var * feat: create `Track` HoC (#3421) * feat: create `Track` HoC * fix: remove unnecessary `undefined` check * fix: prop names/type tweak + conditional payload * fix: short props, throw when Fragment + add tests * fix: rename file extension * fix: migrate anonymous page tracking to GTM (#3426) * fix: migrate pageview tracking to GTM * fix: cleanup event emission * fix: add chain data to tracking (#3523) * fix: add chain data to tracking + cross-env tests * fix: separate chain data from payload * fix: track `shortName` instead of `chainName` * feat: track create/load Safe (#3553) * feat: track create/load Safe steppers * fix: payload tracking in Safe create/load * fix: Track Safe creation events * fix: rename event + remove unnecessary var * fix: payload test Co-authored-by: Usame Algan * [Analytics] Track overview layout (#3606) * feat: track create/load Safe steppers * fix: payload tracking in Safe create/load * fix: Track Safe creation events * fix: rename event + remove unnecessary var * fix: payload test * wrap overview buttons with HOC * fix: Adjust structure of dataLayer object for trackEvent call * refactor: Extract track variables * chore: Revert method name refactor * refactor: Move safe overview tags to its own file * feat: Add tracking to currency change, intercom and help center * fix: Remove spreads where possible * fix: failing test Co-authored-by: iamacook Co-authored-by: Usame Algan * fix: remove GA (#3613) * fix: remove GA * fix: remove GA from test * chore: add GTM env vars to deploy script * Merge branch 'dev' into improve-analytics * fix: top-level tracking (#3659) * fix: top level tracking + unload tracking * fix: use shorthand * fix: add dependency * fix: tracking events * fix: track via listener * fix: `waitFor` event listener * fix: lint * fix: add GA `eventValue` + centralise events * fix: cleanup * fix: `currentStep` as value * fix: remove `eventValue` + less verbose events * fix: add dependency * fix: use ref in variable * fix: test * fix: check that GTM is loaded + remove comment * fix: return early * fix: track all events (#3675) * fix: add legacy events * fix: incorrect events * fix: remove unnecessary dependencies * feat: add new tracking events * fix: test * fix: memoize tracking + always init GTM * fix: spending limit tracking label * fix: rm `navigation` category + rm `pageview` vars * fix: remove memoization * fix: track wallet, (un-)pin/named Safe Apps, App click * fix: use campaign link for iPhone app * fix: only log differeng tokens after fetch * fix: tweak events + don't trigger GA on dev (#3715) * fix: events + don't trigger GA on dev env * fix: remove dev flag from events * fix: only track changing `pathname` * fix: don't cache anonymised path Co-authored-by: Usame Algan Co-authored-by: Diogo Soares <32431609+DiogoSoaress@users.noreply.github.com> Co-authored-by: katspaugh <381895+katspaugh@users.noreply.github.com> --- .github/workflows/deploy.yml | 4 +- package.json | 5 +- public/index.html | 5 +- .../AppLayout/Header/components/Layout.tsx | 10 +- .../Header/components/NetworkSelector.tsx | 4 + .../ProviderDetails/PairingDetails.tsx | 14 +- .../AppLayout/Sidebar/SafeHeader/index.tsx | 38 ++-- src/components/AppLayout/Sidebar/index.tsx | 22 ++- src/components/ConnectButton/index.tsx | 10 +- src/components/CookiesBanner/index.tsx | 6 +- src/components/ExecuteCheckbox/index.tsx | 7 +- src/components/Modal/index.tsx | 16 +- .../SafeListSidebar/AddSafeButton.tsx | 20 +- .../SafeListSidebar/SafeList/index.tsx | 18 +- src/components/SafeListSidebar/index.tsx | 8 +- src/components/Stepper/Stepper.tsx | 52 +++-- src/components/Stepper/stepperContext.tsx | 7 +- src/components/StepperForm/StepperForm.tsx | 5 +- src/components/Track/__tests__/index.test.tsx | 59 ++++++ src/components/Track/index.tsx | 48 +++++ src/logic/hooks/useGaEvents.ts | 35 ---- src/logic/wallets/onboard.ts | 4 +- src/logic/wallets/store/middleware/index.ts | 19 +- src/logic/wallets/store/reducer/index.ts | 3 +- src/routes/CreateSafePage/CreateSafePage.tsx | 20 +- .../components/SafeCreationProcess.tsx | 31 +-- .../CreateSafePage/steps/NameNewSafeStep.tsx | 12 ++ src/routes/LoadSafePage/LoadSafePage.tsx | 21 ++ .../steps/LoadSafeAddressStep.tsx | 12 ++ src/routes/index.tsx | 6 +- src/routes/opening/components/Footer.tsx | 34 ++-- .../EllipsisTransactionDetails/index.tsx | 6 + .../AddressBook/ExportEntriesModal/index.tsx | 12 +- .../AddressBook/ImportEntriesModal/index.tsx | 3 + .../safe/components/AddressBook/index.tsx | 176 +++++++++-------- .../Apps/components/AddAppForm/index.tsx | 3 + .../components/Apps/components/AppFrame.tsx | 13 +- .../Apps/components/AppsList.test.tsx | 32 +-- .../Apps/components/SearchInputCard.tsx | 9 +- .../Apps/hooks/appList/useAppList.ts | 11 +- src/routes/safe/components/Apps/utils.ts | 5 +- .../safe/components/Balances/Coins/index.tsx | 49 +++-- .../Balances/Collectibles/index.tsx | 11 +- .../SendModal/screens/ChooseTxType/index.tsx | 82 ++++---- .../screens/ReviewSendFundsTx/index.tsx | 4 + .../components/CurrencyDropdown/index.tsx | 19 +- .../components/Settings/Advanced/index.tsx | 8 +- .../components/Settings/Appearance/index.tsx | 36 ++-- .../ManageOwners/AddOwnerModal/index.tsx | 8 +- .../ManageOwners/RemoveOwnerModal/index.tsx | 8 +- .../Settings/ManageOwners/index.tsx | 54 ++--- .../components/Settings/SafeDetails/index.tsx | 10 +- .../SpendingLimit/LimitsTable/index.tsx | 10 +- .../SpendingLimit/NewLimitModal/Review.tsx | 7 + .../SpendingLimit/RemoveLimitModal.tsx | 4 + .../Settings/SpendingLimit/index.tsx | 24 ++- .../ChangeThreshold/index.tsx | 6 + .../Settings/ThresholdSettings/index.tsx | 31 ++- src/routes/safe/components/Settings/index.tsx | 12 +- .../Transactions/TxList/QueueTransactions.tsx | 17 +- .../Transactions/TxList/TxSummary.tsx | 6 +- .../TxList/hooks/useActionButtonsHandlers.ts | 11 +- .../components/Transactions/TxList/index.tsx | 9 +- .../helpers/TxEstimatedFeesDetail/index.tsx | 24 ++- .../helpers/TxParametersDetail/index.tsx | 18 +- src/routes/welcome/Welcome.tsx | 42 ++-- src/types/definitions.d.ts | 2 + src/utils/__tests__/googleTagManager.test.tsx | 181 +++++++++++++++++ src/utils/constants.ts | 6 + src/utils/events/addressBook.ts | 40 ++++ src/utils/events/assets.ts | 32 +++ src/utils/events/createLoadSafe.ts | 64 ++++++ src/utils/events/modals.ts | 55 ++++++ src/utils/events/overview.ts | 64 ++++++ src/utils/events/safeApps.ts | 28 +++ src/utils/events/settings.ts | 85 ++++++++ src/utils/events/txList.ts | 32 +++ src/utils/events/utils.ts | 10 + src/utils/events/wallet.ts | 12 ++ src/utils/googleAnalytics.ts | 184 ------------------ src/utils/googleTagManager.ts | 173 ++++++++++++++++ src/utils/intercom.ts | 5 + yarn.lock | 13 +- 83 files changed, 1655 insertions(+), 666 deletions(-) create mode 100644 src/components/Track/__tests__/index.test.tsx create mode 100644 src/components/Track/index.tsx delete mode 100644 src/logic/hooks/useGaEvents.ts create mode 100644 src/utils/__tests__/googleTagManager.test.tsx create mode 100644 src/utils/events/addressBook.ts create mode 100644 src/utils/events/assets.ts create mode 100644 src/utils/events/createLoadSafe.ts create mode 100644 src/utils/events/modals.ts create mode 100644 src/utils/events/overview.ts create mode 100644 src/utils/events/safeApps.ts create mode 100644 src/utils/events/settings.ts create mode 100644 src/utils/events/txList.ts create mode 100644 src/utils/events/utils.ts create mode 100644 src/utils/events/wallet.ts delete mode 100644 src/utils/googleAnalytics.ts create mode 100644 src/utils/googleTagManager.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8e4728d4b6..f6710f9912 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -17,7 +17,6 @@ env: REPO_NAME_ALPHANUMERIC: safereact STAGING_BUCKET_NAME: ${{ secrets.STAGING_BUCKET_NAME }} REACT_APP_SENTRY_DSN: ${{ secrets.SENTRY_DSN_MAINNET }} - REACT_APP_GOOGLE_ANALYTICS: ${{ secrets.REACT_APP_GOOGLE_ANALYTICS_ID_MAINNET }} REACT_APP_ETHERSCAN_API_KEY: ${{ secrets.REACT_APP_ETHERSCAN_API_KEY }} REACT_APP_ETHGASSTATION_API_KEY: ${{ secrets.REACT_APP_ETHGASSTATION_API_KEY }} @@ -93,6 +92,9 @@ jobs: REACT_APP_INTERCOM_ID: ${{ secrets.REACT_APP_INTERCOM_ID }} REACT_APP_BEAMER_ID: ${{ secrets.REACT_APP_BEAMER_ID }} REACT_APP_IPFS_GATEWAY: ${{ secrets.REACT_APP_IPFS_GATEWAY }} + REACT_APP_GOOGLE_TAG_MANAGER_ID: ${{ secrets.REACT_APP_GOOGLE_TAG_MANAGER_ID }} + REACT_APP_GOOGLE_TAG_MANAGER_LIVE_AUTH: ${{ secrets.REACT_APP_GOOGLE_TAG_MANAGER_LIVE_AUTH }} + REACT_APP_GOOGLE_TAG_MANAGER_DEVELOPMENT_AUTH: ${{ secrets.REACT_APP_GOOGLE_TAG_MANAGER_DEVELOPMENT_AUTH }} - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v1 diff --git a/package.json b/package.json index 15e7dac256..dece39bfc3 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "prettier": "prettier './src/**/*.{js,jsx,ts,tsx}'", "start": "rescripts start", "start:docker": "docker-compose build && docker-compose up", - "test": "REACT_APP_ENV=test rescripts test --env=jsdom", + "test": "cross-env REACT_APP_ENV=test rescripts test --env=jsdom", "test:coverage": "REACT_APP_ENV=test yarn test --coverage --watchAll=false", "test:ci": "REACT_APP_ENV=test yarn test --ci --coverage --json --watchAll=false --testLocationInResults --runInBand --outputFile=jest.results.json", "update-mocks": "./scripts/update-mocks.sh", @@ -124,7 +124,7 @@ "react-dom": "17.0.2", "react-final-form": "^6.5.3", "react-final-form-listeners": "^1.0.2", - "react-ga": "3.3.0", + "react-gtm-module": "^2.0.11", "react-intersection-observer": "^8.32.0", "react-papaparse": "^3.16.1", "react-qr-reader": "^2.2.1", @@ -160,6 +160,7 @@ "@types/node": "^16.9.1", "@types/react": "^17.0.21", "@types/react-dom": "^17.0.9", + "@types/react-gtm-module": "^2.0.1", "@types/react-redux": "^7.1.18", "@types/react-router-dom": "^5.1.9", "@types/redux-actions": "^2.6.2", diff --git a/public/index.html b/public/index.html index fd0cfd2979..14bf1dad20 100644 --- a/public/index.html +++ b/public/index.html @@ -9,7 +9,10 @@ Gnosis Safe - + diff --git a/src/components/AppLayout/Header/components/Layout.tsx b/src/components/AppLayout/Header/components/Layout.tsx index 581459779f..4de8ce1998 100644 --- a/src/components/AppLayout/Header/components/Layout.tsx +++ b/src/components/AppLayout/Header/components/Layout.tsx @@ -18,6 +18,8 @@ import WalletSwitch from 'src/components/WalletSwitch' import Divider from 'src/components/layout/Divider' import { shouldSwitchWalletChain } from 'src/logic/wallets/store/selectors' import { useSelector } from 'react-redux' +import { OVERVIEW_EVENTS } from 'src/utils/events/overview' +import Track from 'src/components/Track' const styles = () => ({ root: { @@ -95,9 +97,11 @@ const Layout = ({ classes, providerDetails, providerInfo }) => { return ( - - - + + + + + diff --git a/src/components/AppLayout/Header/components/NetworkSelector.tsx b/src/components/AppLayout/Header/components/NetworkSelector.tsx index 42c0d30629..ca3630f536 100644 --- a/src/components/AppLayout/Header/components/NetworkSelector.tsx +++ b/src/components/AppLayout/Header/components/NetworkSelector.tsx @@ -21,6 +21,8 @@ import { useSelector } from 'react-redux' import { currentChainId } from 'src/logic/config/store/selectors' import { getChainById } from 'src/config' import { ChainId } from 'src/config/chain.d' +import { trackEvent } from 'src/utils/googleTagManager' +import { OVERVIEW_EVENTS } from 'src/utils/events/overview' const styles = { root: { @@ -90,6 +92,8 @@ const NetworkSelector = ({ open, toggle, clickAway }: NetworkSelectorProps): Rea e.preventDefault() clickAway() + trackEvent({ ...OVERVIEW_EVENTS.SWITCH_NETWORK, label: chainId }) + const newRoute = getNetworkRootRoutes().find((network) => network.chainId === chainId) if (newRoute) { history.push(newRoute.route) diff --git a/src/components/AppLayout/Header/components/ProviderDetails/PairingDetails.tsx b/src/components/AppLayout/Header/components/ProviderDetails/PairingDetails.tsx index 696edfe0a4..3b3142693f 100644 --- a/src/components/AppLayout/Header/components/ProviderDetails/PairingDetails.tsx +++ b/src/components/AppLayout/Header/components/ProviderDetails/PairingDetails.tsx @@ -11,6 +11,8 @@ import Row from 'src/components/layout/Row' import usePairing from 'src/logic/wallets/pairing/hooks/usePairing' import { initPairing, isPairingModule } from 'src/logic/wallets/pairing/utils' import { useGetPairingUri } from 'src/logic/wallets/pairing/hooks/useGetPairingUri' +import { OVERVIEW_EVENTS } from 'src/utils/events/overview' +import Track from 'src/components/Track' import AppstoreButton from 'src/components/AppstoreButton' const StyledDivider = styled(Divider)` @@ -55,8 +57,12 @@ const PairingDetails = ({ classes }: { classes: Record }): React Scan this code in the{' '} - Gnosis Safe app to sign - transactions with your mobile device. + + + Gnosis Safe app + + {' '} + to sign transactions with your mobile device.
Learn more about this feature. @@ -64,7 +70,9 @@ const PairingDetails = ({ classes }: { classes: Record }): React
- + + + ) diff --git a/src/components/AppLayout/Sidebar/SafeHeader/index.tsx b/src/components/AppLayout/Sidebar/SafeHeader/index.tsx index f250e0f681..9486110410 100644 --- a/src/components/AppLayout/Sidebar/SafeHeader/index.tsx +++ b/src/components/AppLayout/Sidebar/SafeHeader/index.tsx @@ -19,6 +19,8 @@ import { ChainInfo } from '@gnosis.pm/safe-react-gateway-sdk' import PrefixedEthHashInfo from 'src/components/PrefixedEthHashInfo' import { copyShortNameSelector } from 'src/logic/appearance/selectors' import { ADDRESSED_ROUTE, extractShortChainName } from 'src/routes/routes' +import Track from 'src/components/Track' +import { OVERVIEW_EVENTS } from 'src/utils/events/overview' import Threshold from 'src/components/AppLayout/Sidebar/Threshold' export const TOGGLE_SIDEBAR_BTN_TESTID = 'TOGGLE_SIDEBAR_BTN' @@ -173,11 +175,17 @@ const SafeHeader = ({ - - - - - + + + + + + + + + + + {!granted && ( @@ -189,12 +197,20 @@ const SafeHeader = ({ )} {balance} - - - - New transaction - - + + + + + New transaction + + + ) diff --git a/src/components/AppLayout/Sidebar/index.tsx b/src/components/AppLayout/Sidebar/index.tsx index 618b47d910..220a3ee80a 100644 --- a/src/components/AppLayout/Sidebar/index.tsx +++ b/src/components/AppLayout/Sidebar/index.tsx @@ -7,6 +7,8 @@ import List, { ListItemType, StyledListItem, StyledListItemText } from 'src/comp import SafeHeader from './SafeHeader' import { IS_PRODUCTION, BEAMER_ID } from 'src/utils/constants' import { wrapInSuspense } from 'src/utils/wrapInSuspense' +import Track from 'src/components/Track' +import { OVERVIEW_EVENTS } from 'src/utils/events/overview' import ListIcon from 'src/components/List/ListIcon' import { openCookieBanner } from 'src/logic/cookies/store/actions/openCookieBanner' import { loadFromCookie } from 'src/logic/cookies/utils' @@ -125,16 +127,20 @@ const Sidebar = ({ {!isDesktop && BEAMER_ID && ( - - - What's new - + + + + What's new + + )} - - - Help Center - + + + + Help Center + + diff --git a/src/components/ConnectButton/index.tsx b/src/components/ConnectButton/index.tsx index 6d35842342..e7c9872a62 100644 --- a/src/components/ConnectButton/index.tsx +++ b/src/components/ConnectButton/index.tsx @@ -2,6 +2,8 @@ import { ReactElement } from 'react' import Button from 'src/components/layout/Button' import onboard, { checkWallet } from 'src/logic/wallets/onboard' +import { OVERVIEW_EVENTS } from 'src/utils/events/overview' +import Track from '../Track' export const onConnectButtonClick = async (): Promise => { const walletSelected = await onboard().walletSelect() @@ -13,9 +15,11 @@ export const onConnectButtonClick = async (): Promise => { } const ConnectButton = (props: { 'data-testid': string }): ReactElement => ( - + + + ) export default ConnectButton diff --git a/src/components/CookiesBanner/index.tsx b/src/components/CookiesBanner/index.tsx index 2d7fc2c069..62a4398adf 100644 --- a/src/components/CookiesBanner/index.tsx +++ b/src/components/CookiesBanner/index.tsx @@ -10,11 +10,11 @@ import { closeCookieBanner, openCookieBanner } from 'src/logic/cookies/store/act import { cookieBannerState } from 'src/logic/cookies/store/selectors' import { loadFromCookie, saveCookie } from 'src/logic/cookies/utils' import { mainFontFamily, md, primary, screenSm } from 'src/theme/variables' -import { loadGoogleAnalytics, unloadGoogleAnalytics } from 'src/utils/googleAnalytics' import { closeIntercom, isIntercomLoaded, loadIntercom } from 'src/utils/intercom' import AlertRedIcon from './assets/alert-red.svg' import IntercomIcon from './assets/intercom.png' import { useSafeAppUrl } from 'src/logic/hooks/useSafeAppUrl' +import { loadGoogleTagManager, unloadGoogleTagManager } from 'src/utils/googleTagManager' import { loadBeamer, unloadBeamer } from 'src/utils/beamer' const isDesktop = process.env.REACT_APP_BUILD_FOR_DESKTOP @@ -270,9 +270,9 @@ const CookiesBanner = isDesktop setLocalAnalytics(acceptedAnalytics) }, [setLocalNecessary, setLocalSupportAndUpdates, setLocalAnalytics, openBanner]) - // Load or unload analytics depending on user choice + // Load or unload GTM depending on user choice useEffect(() => { - localAnalytics ? loadGoogleAnalytics() : unloadGoogleAnalytics() + localAnalytics ? loadGoogleTagManager() : unloadGoogleTagManager() }, [localAnalytics]) // Toggle Intercom diff --git a/src/components/ExecuteCheckbox/index.tsx b/src/components/ExecuteCheckbox/index.tsx index 5e538cef11..3852679603 100644 --- a/src/components/ExecuteCheckbox/index.tsx +++ b/src/components/ExecuteCheckbox/index.tsx @@ -7,6 +7,8 @@ import { sm } from 'src/theme/variables' import Row from 'src/components/layout/Row' import Img from 'src/components/layout/Img' import InfoIcon from 'src/assets/icons/info.svg' +import { trackEvent } from 'src/utils/googleTagManager' +import { MODALS_EVENTS } from 'src/utils/events/modals' const StyledRow = styled(Row)` align-items: center; @@ -28,8 +30,9 @@ interface ExecuteCheckboxProps { } const ExecuteCheckbox = ({ checked, onChange }: ExecuteCheckboxProps): ReactElement => { - const handleChange = (e: React.ChangeEvent): void => { - onChange(e.target.checked) + const handleChange = (_: React.ChangeEvent, checked: boolean): void => { + trackEvent({ ...MODALS_EVENTS.EXECUTE_TX, label: checked }) + onChange(checked) } return ( diff --git a/src/components/Modal/index.tsx b/src/components/Modal/index.tsx index 48e7c819b1..99cad66785 100644 --- a/src/components/Modal/index.tsx +++ b/src/components/Modal/index.tsx @@ -3,6 +3,8 @@ import { ButtonProps as ButtonPropsMUI, Modal as ModalMUI } from '@material-ui/c import cn from 'classnames' import { ReactElement, ReactNode } from 'react' import { ModalHeader } from 'src/routes/safe/components/Balances/SendModal/screens/ModalHeader' +import { getModalEvent } from 'src/utils/events/modals' +import { trackEvent } from 'src/utils/googleTagManager' import styled from 'styled-components' type Theme = typeof theme @@ -191,6 +193,7 @@ const Buttons = ({ cancelButtonProps = {}, confirmButtonProps = {} }: ButtonsPro status: cancelStatus = ButtonStatus.READY, text: cancelText = ButtonStatus.LOADING === cancelStatus ? 'Cancelling' : 'Cancel', testId: cancelTestId = '', + onClick: cancelOnClick, ...cancelProps } = cancelButtonProps const { @@ -198,6 +201,7 @@ const Buttons = ({ cancelButtonProps = {}, confirmButtonProps = {} }: ButtonsPro status: confirmStatus = ButtonStatus.READY, text: confirmText = ButtonStatus.LOADING === confirmStatus ? 'Submitting' : 'Submit', testId: confirmTestId = '', + onClick: confirmOnClick, ...confirmProps } = confirmButtonProps @@ -207,9 +211,13 @@ const Buttons = ({ cancelButtonProps = {}, confirmButtonProps = {} }: ButtonsPro size="md" color="primary" variant="outlined" - type={cancelProps?.onClick ? 'button' : 'submit'} + type={cancelOnClick ? 'button' : 'submit'} disabled={cancelDisabled || [ButtonStatus.DISABLED, ButtonStatus.LOADING].includes(cancelStatus)} data-testid={cancelTestId} + onClick={(e) => { + trackEvent(getModalEvent(cancelText)) + cancelOnClick?.(e) + }} {...cancelProps} > {ButtonStatus.LOADING === cancelStatus ? ( @@ -223,9 +231,13 @@ const Buttons = ({ cancelButtonProps = {}, confirmButtonProps = {} }: ButtonsPro + ) + + const nextButton = ( + + ) + return ( @@ -67,20 +90,21 @@ function StepperComponent(): ReactElement { - - + {trackingCategory ? ( + <> + + {backButton} + + + {nextButton} + + + ) : ( + <> + {backButton} + {nextButton} + + )} diff --git a/src/components/Stepper/stepperContext.tsx b/src/components/Stepper/stepperContext.tsx index d2ffa9386f..d48207831a 100644 --- a/src/components/Stepper/stepperContext.tsx +++ b/src/components/Stepper/stepperContext.tsx @@ -1,7 +1,7 @@ import { useContext, ReactElement, useState, Children, createContext } from 'react' import { history } from 'src/routes/routes' -const StepperContext = createContext({}) +const StepperContext = createContext({} as StepperProviderTypes) function useStepper(): any { const context = useContext(StepperContext) @@ -22,6 +22,7 @@ type StepperProviderTypes = { stepsComponents: ReactElement[] children: ReactElement testId?: string + trackingCategory?: string } function StepperProvider({ @@ -31,6 +32,7 @@ function StepperProvider({ stepsComponents, children, testId, + trackingCategory, }: StepperProviderTypes): ReactElement { const [currentStep, setCurrentStep] = useState(0) @@ -66,7 +68,7 @@ function StepperProvider({ setCurrentStep((step) => step + 1) } - const state = { + const state: StepperProviderTypes = { currentStep, setCurrentStep, steps, @@ -84,6 +86,7 @@ function StepperProvider({ customNextButtonLabel, testId, + trackingCategory, ...store, } diff --git a/src/components/StepperForm/StepperForm.tsx b/src/components/StepperForm/StepperForm.tsx index d29bdf3974..d3716f9150 100644 --- a/src/components/StepperForm/StepperForm.tsx +++ b/src/components/StepperForm/StepperForm.tsx @@ -10,9 +10,10 @@ type StepperFormProps = { onSubmit: (values) => void initialValues?: any children: (JSX.Element | false | null)[] + trackingCategory?: string } -function StepperForm({ children, onSubmit, testId, initialValues }: StepperFormProps): ReactElement { +function StepperForm({ children, onSubmit, testId, initialValues, trackingCategory }: StepperFormProps): ReactElement { const [validate, setValidate] = useState<(values) => Validator>() const [onSubmitForm, setOnSubmitForm] = useState<(values) => void>() const steps = useMemo( @@ -52,7 +53,7 @@ function StepperForm({ children, onSubmit, testId, initialValues }: StepperFormP > {({ handleSubmit, submitting }) => (
- + {steps} diff --git a/src/components/Track/__tests__/index.test.tsx b/src/components/Track/__tests__/index.test.tsx new file mode 100644 index 0000000000..351da5db13 --- /dev/null +++ b/src/components/Track/__tests__/index.test.tsx @@ -0,0 +1,59 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' + +import * as gtm from 'src/utils/googleTagManager' +import Track from 'src/components/Track' + +describe('Track', () => { + it('renders the child', () => { + render( + + child + , + ) + + const child = screen.queryByText('child') + + expect(child).toBeInTheDocument() + }) + + it('wraps the child with a div that contains data-track attribute', () => { + render( + + child + , + ) + + const child = screen.queryByText('child')?.closest('div') + + expect(child).toHaveAttribute('data-track', 'unit-test: Render child') + }) + + it('tracks the event on click', async () => { + const trackEventSpy = jest.spyOn(gtm, 'trackEvent').mockImplementation(jest.fn()) + + render( + + child + , + ) + + const child = screen.queryByText('child')?.closest('div') + + if (!child) { + // Fail test if child doesn't exist + expect(true).toBe(false) + return + } + + fireEvent.click(child) + + await waitFor(() => { + expect(trackEventSpy).toHaveBeenCalledWith({ + event: 'customClick', + category: 'unit-test', + action: 'Render child', + label: true, + }) + }) + }) +}) diff --git a/src/components/Track/index.tsx b/src/components/Track/index.tsx new file mode 100644 index 0000000000..70e8a5d244 --- /dev/null +++ b/src/components/Track/index.tsx @@ -0,0 +1,48 @@ +import { ReactElement, Fragment, useEffect, useRef } from 'react' + +import { EventLabel, GTM_EVENT, trackEvent } from 'src/utils/googleTagManager' + +type Props = { + children: ReactElement + as?: 'span' | 'div' + category: string + action: string + label?: EventLabel +} + +const Track = ({ children, as: Wrapper = 'div', ...trackData }: Props): typeof children => { + const el = useRef(null) + + useEffect(() => { + if (!el.current) { + return + } + + const trackEl = el.current + + const handleClick = () => { + trackEvent({ + event: GTM_EVENT.CLICK, + ...trackData, + }) + } + + // We cannot use onClick as events in children do not always bubble up + trackEl.addEventListener('click', handleClick) + return () => { + trackEl.removeEventListener('click', handleClick) + } + }, [el, trackData]) + + if (children.type === Fragment) { + throw new Error('Fragments cannot be tracked.') + } + + return ( + + {children} + + ) +} + +export default Track diff --git a/src/logic/hooks/useGaEvents.ts b/src/logic/hooks/useGaEvents.ts deleted file mode 100644 index afc22d5582..0000000000 --- a/src/logic/hooks/useGaEvents.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useEffect } from 'react' -import { matchPath, useLocation } from 'react-router-dom' - -import { useAnalytics } from 'src/utils/googleAnalytics' -import { isDeeplinkedTx } from 'src/routes/safe/components/Transactions/TxList/utils' -import { extractSafeAddress, getPrefixedSafeAddressSlug, SAFE_ROUTES, TRANSACTION_ID_SLUG } from 'src/routes/routes' - -const useGaEvents = (): void => { - const { trackPage } = useAnalytics() - const location = useLocation() - const { pathname, search } = location - // Google Analytics - useEffect(() => { - let trackedPath = pathname - const address = extractSafeAddress() - - // Anonymize safe address - if (address) { - trackedPath = trackedPath.replace(getPrefixedSafeAddressSlug(), 'SAFE_ADDRESS') - } - - // Anonymize deeplinked transaction - if (isDeeplinkedTx()) { - const match = matchPath(pathname, { - path: SAFE_ROUTES.TRANSACTIONS_SINGULAR, - }) - - trackedPath = trackedPath.replace(match?.params[TRANSACTION_ID_SLUG], 'TRANSACTION_ID') - } - - trackPage(trackedPath + search) - }, [pathname, search, trackPage]) -} - -export default useGaEvents diff --git a/src/logic/wallets/onboard.ts b/src/logic/wallets/onboard.ts index 9560298d04..eb4c7b7147 100644 --- a/src/logic/wallets/onboard.ts +++ b/src/logic/wallets/onboard.ts @@ -18,6 +18,7 @@ import closeSnackbar from 'src/logic/notifications/store/actions/closeSnackbar' import { getChains } from 'src/config/cache/chains' import { shouldSwitchNetwork, switchNetwork } from 'src/logic/wallets/utils/network' import { isPairingModule } from 'src/logic/wallets/pairing/utils' +import { checksumAddress } from 'src/utils/checksumAddress' const LAST_USED_PROVIDER_KEY = 'SAFE__lastUsedProvider' @@ -65,9 +66,8 @@ const getOnboard = (chainId: ChainId): API => { store.dispatch(updateProviderWallet(wallet.name || '')) }, - // Non-checksummed address address: (address) => { - store.dispatch(updateProviderAccount(address || '')) + store.dispatch(updateProviderAccount(checksumAddress(address) || '')) if (address) { prevAddress = address diff --git a/src/logic/wallets/store/middleware/index.ts b/src/logic/wallets/store/middleware/index.ts index 5fc2cb15d4..dd51a821ea 100644 --- a/src/logic/wallets/store/middleware/index.ts +++ b/src/logic/wallets/store/middleware/index.ts @@ -4,11 +4,11 @@ import { Action } from 'redux-actions' import { store as reduxStore } from 'src/store' import { enhanceSnackbarForAction, NOTIFICATIONS } from 'src/logic/notifications' import enqueueSnackbar from 'src/logic/notifications/store/actions/enqueueSnackbar' -import { trackAnalyticsEvent, WALLET_EVENTS } from 'src/utils/googleAnalytics' import { PROVIDER_ACTIONS } from 'src/logic/wallets/store/actions' import { ProviderPayloads } from 'src/logic/wallets/store/reducer' import { providerSelector } from '../selectors' -import { currentChainId } from 'src/logic/config/store/selectors' +import { trackEvent } from 'src/utils/googleTagManager' +import { WALLET_EVENTS } from 'src/utils/events/wallet' let hasName = false let hasAccount = false @@ -39,7 +39,7 @@ const providerMiddleware = } const state = store.getState() - const { available, loaded, name, network } = providerSelector(state) + const { available, loaded, name, account } = providerSelector(state) // @TODO: `loaded` flag that is/was always set to true - should be moved to wallet connection catch // Wallet, account and network did not successfully load @@ -49,15 +49,10 @@ const providerMiddleware = return handledAction } - if (available) { - // Only track when wallet connects to same chain as chain displayed in UI - if (currentChainId(state) === network) { - const event = { - ...WALLET_EVENTS.CONNECT_WALLET, - label: name, - } - - trackAnalyticsEvent(event) + if (available && name) { + // Only track when account has been successfully saved to store + if (payload === account) { + trackEvent({ ...WALLET_EVENTS.CONNECT, label: name }) } } else { store.dispatch(enqueueSnackbar(enhanceSnackbarForAction(NOTIFICATIONS.UNLOCK_WALLET_MSG))) diff --git a/src/logic/wallets/store/reducer/index.ts b/src/logic/wallets/store/reducer/index.ts index 28c26cda4e..9e83e43192 100644 --- a/src/logic/wallets/store/reducer/index.ts +++ b/src/logic/wallets/store/reducer/index.ts @@ -2,7 +2,6 @@ import { Action, handleActions } from 'redux-actions' import { ChainId } from 'src/config/chain.d' import { PROVIDER_ACTIONS } from 'src/logic/wallets/store/actions' -import { checksumAddress } from 'src/utils/checksumAddress' export type ProvidersState = { name: string @@ -59,7 +58,7 @@ const providerReducer = handleActions( [PROVIDER_ACTIONS.ACCOUNT]: (state: ProvidersState, { payload }: Action) => providerFactory({ ...state, - account: payload ? checksumAddress(payload) : '', + account: payload, }), [PROVIDER_ACTIONS.ENS]: (state: ProvidersState, { payload }: Action) => providerFactory({ diff --git a/src/routes/CreateSafePage/CreateSafePage.tsx b/src/routes/CreateSafePage/CreateSafePage.tsx index 3414506d56..8900dbfe57 100644 --- a/src/routes/CreateSafePage/CreateSafePage.tsx +++ b/src/routes/CreateSafePage/CreateSafePage.tsx @@ -37,6 +37,8 @@ import { loadFromStorage, saveToStorage } from 'src/utils/storage' import SafeCreationProcess from './components/SafeCreationProcess' import SelectWalletAndNetworkStep, { selectWalletAndNetworkStepLabel } from './steps/SelectWalletAndNetworkStep' import { reverseENSLookup } from 'src/logic/wallets/getWeb3' +import { CREATE_SAFE_CATEGORY, CREATE_SAFE_EVENTS } from 'src/utils/events/createLoadSafe' +import { trackEvent } from 'src/utils/googleTagManager' function CreateSafePage(): ReactElement { const [safePendingToBeCreated, setSafePendingToBeCreated] = useState() @@ -69,6 +71,17 @@ function CreateSafePage(): ReactElement { const safeRandomName = useMnemonicSafeName() const showSafeCreationProcess = (newSafeFormValues: CreateSafeFormValues): void => { + // Track number of owners + trackEvent({ + ...CREATE_SAFE_EVENTS.OWNERS, + label: newSafeFormValues[FIELD_SAFE_OWNERS_LIST].length, + }) + // Track threshold + trackEvent({ + ...CREATE_SAFE_EVENTS.THRESHOLD, + label: newSafeFormValues[FIELD_NEW_SAFE_THRESHOLD], + }) + saveToStorage(SAFE_PENDING_CREATION_STORAGE_KEY, { ...newSafeFormValues }) setSafePendingToBeCreated(newSafeFormValues) } @@ -112,7 +125,12 @@ function CreateSafePage(): ReactElement { Create new Safe
- + void): Pro } console.log('Sped-up tx mined:', txReceipt) + trackEvent(CREATE_SAFE_EVENTS.CREATED_SAFE) resolve(txReceipt) }) .catch((error) => { @@ -153,6 +156,7 @@ const createNewSafe = (userAddress: string, onHash: (hash: string) => void): Pro }) .then((txReceipt) => { console.log('Original tx mined:', txReceipt) + trackEvent(CREATE_SAFE_EVENTS.CREATED_SAFE) resolve(txReceipt) }) .catch((error) => { @@ -179,7 +183,6 @@ function SafeCreationProcess(): ReactElement { const [safeCreationTxHash, setSafeCreationTxHash] = useState() const [creationTxPromise, setCreationTxPromise] = useState>() - const { trackEvent } = useAnalytics() const dispatch = useDispatch() const userAddress = useSelector(userAccountSelector) const chainId = useSelector(currentChainId) @@ -222,8 +225,6 @@ function SafeCreationProcess(): ReactElement { const safeAddressBookEntry = makeAddressBookEntry({ address: safeAddress, name: safeName, chainId }) dispatch(addressBookSafeLoad([...ownersAddressBookEntry, safeAddressBookEntry])) - trackEvent(USER_EVENTS.CREATE_SAFE) - // a default 5s wait before starting to request safe information await sleep(5000) @@ -313,16 +314,18 @@ function SafeCreationProcess(): ReactElement { } footer={ - + + + } /> diff --git a/src/routes/CreateSafePage/steps/NameNewSafeStep.tsx b/src/routes/CreateSafePage/steps/NameNewSafeStep.tsx index ef5f61f710..762b046e42 100644 --- a/src/routes/CreateSafePage/steps/NameNewSafeStep.tsx +++ b/src/routes/CreateSafePage/steps/NameNewSafeStep.tsx @@ -19,6 +19,8 @@ import { import { useStepper } from 'src/components/Stepper/stepperContext' import NetworkLabel from 'src/components/NetworkLabel/NetworkLabel' import { reverseENSLookup } from 'src/logic/wallets/getWeb3' +import { trackEvent } from 'src/utils/googleTagManager' +import { CREATE_SAFE_EVENTS } from 'src/utils/events/createLoadSafe' export const nameNewSafeStepLabel = 'Name' @@ -36,6 +38,16 @@ function NameNewSafeStep(): ReactElement { const createNewSafeForm = useForm() const formValues = createNewSafeForm.getState().values + const hasCustomSafeName = !!formValues[FIELD_CREATE_CUSTOM_SAFE_NAME] + + useEffect(() => { + // On unmount, e.g. go back/next + return () => { + if (hasCustomSafeName) { + trackEvent(CREATE_SAFE_EVENTS.NAME_SAFE) + } + } + }, [hasCustomSafeName]) useEffect(() => { const getInitialOwnerENSNames = async () => { diff --git a/src/routes/LoadSafePage/LoadSafePage.tsx b/src/routes/LoadSafePage/LoadSafePage.tsx index 40e55034cd..588895dc7f 100644 --- a/src/routes/LoadSafePage/LoadSafePage.tsx +++ b/src/routes/LoadSafePage/LoadSafePage.tsx @@ -32,6 +32,7 @@ import { FIELD_LOAD_SUGGESTED_SAFE_NAME, FIELD_SAFE_OWNER_ENS_LIST, FIELD_SAFE_OWNER_LIST, + FIELD_SAFE_THRESHOLD, LoadSafeFormValues, } from './fields/loadFields' import { extractPrefixedSafeAddress, generateSafeRoute, LOAD_SPECIFIC_SAFE_ROUTE, SAFE_ROUTES } from '../routes' @@ -39,6 +40,8 @@ import { getShortName } from 'src/config' import { currentNetworkAddressBookAsMap } from 'src/logic/addressBook/store/selectors' import { getLoadSafeName, getOwnerName } from './fields/utils' import { currentChainId } from 'src/logic/config/store/selectors' +import { LOAD_SAFE_CATEGORY, LOAD_SAFE_EVENTS } from 'src/utils/events/createLoadSafe' +import { trackEvent } from 'src/utils/googleTagManager' function Load(): ReactElement { const dispatch = useDispatch() @@ -88,6 +91,23 @@ function Load(): ReactElement { return } + // Track number of owners + trackEvent({ + ...LOAD_SAFE_EVENTS.OWNERS, + label: values[FIELD_SAFE_OWNER_LIST].length, + }) + + const threshold = values[FIELD_SAFE_THRESHOLD] + if (threshold) { + // Track threshold + trackEvent({ + ...LOAD_SAFE_EVENTS.THRESHOLD, + label: threshold, + }) + } + + trackEvent(LOAD_SAFE_EVENTS.GO_TO_SAFE) + updateAddressBook(values) const checksummedAddress = checksumAddress(address || '') @@ -123,6 +143,7 @@ function Load(): ReactElement { testId="load-safe-form" onSubmit={onSubmitLoadSafe} key={safeAddress} + trackingCategory={LOAD_SAFE_CATEGORY} > {safeAddress && shortName ? null : ( diff --git a/src/routes/LoadSafePage/steps/LoadSafeAddressStep.tsx b/src/routes/LoadSafePage/steps/LoadSafeAddressStep.tsx index 7003c32e79..ff8d716cf8 100644 --- a/src/routes/LoadSafePage/steps/LoadSafeAddressStep.tsx +++ b/src/routes/LoadSafePage/steps/LoadSafeAddressStep.tsx @@ -31,6 +31,8 @@ import NetworkLabel from 'src/components/NetworkLabel/NetworkLabel' import { getLoadSafeName } from '../fields/utils' import { currentChainId } from 'src/logic/config/store/selectors' import { reverseENSLookup } from 'src/logic/wallets/getWeb3' +import { trackEvent } from 'src/utils/googleTagManager' +import { LOAD_SAFE_EVENTS } from 'src/utils/events/createLoadSafe' export const loadSafeAddressStepLabel = 'Name and address' @@ -129,6 +131,16 @@ function LoadSafeAddressStep(): ReactElement { const formValues = loadSafeForm.getState().values as LoadSafeFormValues const safeName = getLoadSafeName(formValues, addressBook) + const hasCustomSafeName = !!formValues[FIELD_LOAD_CUSTOM_SAFE_NAME] + + useEffect(() => { + // On unmount, e.g. go back/next + return () => { + if (hasCustomSafeName) { + trackEvent(LOAD_SAFE_EVENTS.NAME_SAFE) + } + } + }, [hasCustomSafeName]) return ( diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 5082c1cbb5..d47eae9d2e 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -20,7 +20,7 @@ import { import { getShortName } from 'src/config' import { setChainId } from 'src/logic/config/utils' import { setChainIdFromUrl } from 'src/utils/history' -import useGaEvents from 'src/logic/hooks/useGaEvents' +import { usePageTracking } from 'src/utils/googleTagManager' const Welcome = React.lazy(() => import('./welcome/Welcome')) const CreateSafePage = React.lazy(() => import('./CreateSafePage/CreateSafePage')) @@ -32,8 +32,8 @@ const Routes = (): React.ReactElement => { const { pathname } = location const defaultSafe = useSelector(lastViewedSafe) - // Anonymize and track page views - useGaEvents() + // Google Tag Manager page tracking + usePageTracking() return ( diff --git a/src/routes/opening/components/Footer.tsx b/src/routes/opening/components/Footer.tsx index dfa5f2684d..7d93678e5e 100644 --- a/src/routes/opening/components/Footer.tsx +++ b/src/routes/opening/components/Footer.tsx @@ -4,6 +4,8 @@ import { Loader } from '@gnosis.pm/safe-react-components' import Button from 'src/components/layout/Button' import Hairline from 'src/components/layout/Hairline' +import Track from 'src/components/Track' +import { CREATE_SAFE_EVENTS } from 'src/utils/events/createLoadSafe' import Paragraph from 'src/components/layout/Paragraph' const ButtonWithMargin = styled(Button)` @@ -39,21 +41,23 @@ export const ContinueFooter = ({ }): ReactElement => ( - + + + ) diff --git a/src/routes/safe/components/AddressBook/EllipsisTransactionDetails/index.tsx b/src/routes/safe/components/AddressBook/EllipsisTransactionDetails/index.tsx index d6b0ea5447..3800d1ff07 100644 --- a/src/routes/safe/components/AddressBook/EllipsisTransactionDetails/index.tsx +++ b/src/routes/safe/components/AddressBook/EllipsisTransactionDetails/index.tsx @@ -13,6 +13,8 @@ import { xs } from 'src/theme/variables' import { grantedSelector } from 'src/routes/safe/container/selector' import { SAFE_ROUTES, history, extractSafeAddress, generateSafeRoute } from 'src/routes/routes' import { getShortName } from 'src/config' +import { trackEvent } from 'src/utils/googleTagManager' +import { TX_LIST_EVENTS } from 'src/utils/events/txList' const useStyles = makeStyles( createStyles({ @@ -58,6 +60,10 @@ export const EllipsisTransactionDetails = ({ const closeMenuHandler = () => setAnchorEl(null) const addOrEditEntryHandler = () => { + trackEvent({ + ...TX_LIST_EVENTS.ADDRESS_BOOK, + label: isStoredInAddressBook ? 'Edit' : 'Add', + }) history.push({ pathname: generateSafeRoute(SAFE_ROUTES.ADDRESS_BOOK, { shortName: getShortName(), diff --git a/src/routes/safe/components/AddressBook/ExportEntriesModal/index.tsx b/src/routes/safe/components/AddressBook/ExportEntriesModal/index.tsx index dde3776319..7449e093a7 100644 --- a/src/routes/safe/components/AddressBook/ExportEntriesModal/index.tsx +++ b/src/routes/safe/components/AddressBook/ExportEntriesModal/index.tsx @@ -21,6 +21,8 @@ import HelpInfo from 'src/routes/safe/components/AddressBook/HelpInfo' import SuccessSvg from './assets/success.svg' import ErrorSvg from './assets/error.svg' import LoadingSvg from './assets/wait.svg' +import { ADDRESS_BOOK_EVENTS } from 'src/utils/events/addressBook' +import Track from 'src/components/Track' type ExportEntriesModalProps = { isOpen: boolean @@ -134,10 +136,12 @@ export const ExportEntriesModal = ({ isOpen, onClose }: ExportEntriesModalProps) ) : ( - + + + )} diff --git a/src/routes/safe/components/AddressBook/ImportEntriesModal/index.tsx b/src/routes/safe/components/AddressBook/ImportEntriesModal/index.tsx index fb7da0562a..1e2cb42fa6 100644 --- a/src/routes/safe/components/AddressBook/ImportEntriesModal/index.tsx +++ b/src/routes/safe/components/AddressBook/ImportEntriesModal/index.tsx @@ -10,6 +10,8 @@ import { checksumAddress } from 'src/utils/checksumAddress' import HelpInfo from 'src/routes/safe/components/AddressBook/HelpInfo' import { validateCsvData, validateFile } from 'src/routes/safe/components/AddressBook/utils' import { ChainId } from 'src/config/chain.d' +import { trackEvent } from 'src/utils/googleTagManager' +import { ADDRESS_BOOK_EVENTS } from 'src/utils/events/addressBook' const ImportContainer = styled.div` flex-direction: column; @@ -41,6 +43,7 @@ const ImportEntriesModal = ({ importEntryModalHandler, isOpen, onClose }: Import const [entryList, setEntryList] = useState([]) const handleImportEntrySubmit = () => { + trackEvent({ ...ADDRESS_BOOK_EVENTS.IMPORT_BUTTON, label: entryList.length }) setCsvLoaded(false) importEntryModalHandler(entryList) } diff --git a/src/routes/safe/components/AddressBook/index.tsx b/src/routes/safe/components/AddressBook/index.tsx index 263df378b4..9e5594bf76 100644 --- a/src/routes/safe/components/AddressBook/index.tsx +++ b/src/routes/safe/components/AddressBook/index.tsx @@ -44,11 +44,12 @@ import SendModal from 'src/routes/safe/components/Balances/SendModal' import { safesAsList } from 'src/logic/safe/store/selectors' import { checksumAddress } from 'src/utils/checksumAddress' import { grantedSelector } from 'src/routes/safe/container/selector' -import { useAnalytics, SAFE_EVENTS } from 'src/utils/googleAnalytics' import ImportEntriesModal from './ImportEntriesModal' import { isValidAddress } from 'src/utils/isValidAddress' import { useHistory } from 'react-router' import { currentChainId } from 'src/logic/config/store/selectors' +import { ADDRESS_BOOK_EVENTS } from 'src/utils/events/addressBook' +import Track from 'src/components/Track' const StyledButton = styled(Button)` &&.MuiButton-root { @@ -91,16 +92,11 @@ const AddressBookTable = (): ReactElement => { const [deleteEntryModalOpen, setDeleteEntryModalOpen] = useState(false) const [exportEntriesModalOpen, setExportEntriesModalOpen] = useState(false) const [sendFundsModalOpen, setSendFundsModalOpen] = useState(false) - const { trackEvent } = useAnalytics() const history = useHistory() const queryParams = Object.fromEntries(new URLSearchParams(history.location.search)) const entryAddressToEditOrCreateNew = queryParams?.entryAddress - useEffect(() => { - trackEvent(SAFE_EVENTS.ADDRESS_BOOK) - }, [trackEvent]) - useEffect(() => { if (entryAddressToEditOrCreateNew) { setEditCreateEntryModalOpen(true) @@ -172,41 +168,47 @@ const AddressBookTable = (): ReactElement => { - { - setSelectedEntry(initialEntryState) - setExportEntriesModalOpen(true) - }} - color="primary" - iconType="exportImg" - iconSize="sm" - textSize="xl" - > - Export - - { - setImportEntryModalOpen(true) - }} - color="primary" - iconType="importImg" - iconSize="sm" - textSize="xl" - > - Import - - { - setSelectedEntry(initialEntryState) - setEditCreateEntryModalOpen(true) - }} - color="primary" - iconType="add" - iconSize="sm" - textSize="xl" - > - Create entry - + + { + setSelectedEntry(initialEntryState) + setExportEntriesModalOpen(true) + }} + color="primary" + iconType="exportImg" + iconSize="sm" + textSize="xl" + > + Export + + + + { + setImportEntryModalOpen(true) + }} + color="primary" + iconType="importImg" + iconSize="sm" + textSize="xl" + > + Import + + + + { + setSelectedEntry(initialEntryState) + setEditCreateEntryModalOpen(true) + }} + color="primary" + iconType="add" + iconSize="sm" + textSize="xl" + > + Create entry + + @@ -252,53 +254,59 @@ const AddressBookTable = (): ReactElement => { })} - { - setSelectedEntry({ - entry: row, - isOwnerAddress: userOwner, - }) - setEditCreateEntryModalOpen(true) - }} - > - - - { - setSelectedEntry({ entry: row }) - setDeleteEntryModalOpen(true) - }} - > - - - {granted ? ( - + { + setSelectedEntry({ + entry: row, + isOwnerAddress: userOwner, + }) + setEditCreateEntryModalOpen(true) + }} + > + + + + + { setSelectedEntry({ entry: row }) - setSendFundsModalOpen(true) + setDeleteEntryModalOpen(true) }} - size="md" - variant="contained" - data-testid={SEND_ENTRY_BUTTON} > - - - Send - - - ) : null} + + + + {granted && ( + + { + setSelectedEntry({ entry: row }) + setSendFundsModalOpen(true) + }} + size="md" + variant="contained" + data-testid={SEND_ENTRY_BUTTON} + > + + + Send + + + + )} diff --git a/src/routes/safe/components/Apps/components/AddAppForm/index.tsx b/src/routes/safe/components/Apps/components/AddAppForm/index.tsx index 14642ef860..2c42031546 100644 --- a/src/routes/safe/components/Apps/components/AddAppForm/index.tsx +++ b/src/routes/safe/components/Apps/components/AddAppForm/index.tsx @@ -12,6 +12,8 @@ import { FormButtons } from './FormButtons' import { getEmptySafeApp } from 'src/routes/safe/components/Apps/utils' import { Errors, logError } from 'src/logic/exceptions/CodedException' import { generateSafeRoute, extractPrefixedSafeAddress, SAFE_ROUTES } from 'src/routes/routes' +import { trackEvent } from 'src/utils/googleTagManager' +import { SAFE_APPS_EVENTS } from 'src/utils/events/safeApps' const FORM_ID = 'add-apps-form' @@ -100,6 +102,7 @@ const AddApp = ({ appList, closeModal, onAddApp }: AddAppProps): ReactElement => const [isLoading, setIsLoading] = useState(false) const handleSubmit = useCallback(async () => { + trackEvent(SAFE_APPS_EVENTS.ADD_CUSTOM_APP) onAddApp(appInfo) history.push({ pathname: generateSafeRoute(SAFE_ROUTES.APPS, extractPrefixedSafeAddress()), diff --git a/src/routes/safe/components/Apps/components/AppFrame.tsx b/src/routes/safe/components/Apps/components/AppFrame.tsx index ab49f166df..2d57ace6f4 100644 --- a/src/routes/safe/components/Apps/components/AppFrame.tsx +++ b/src/routes/safe/components/Apps/components/AppFrame.tsx @@ -17,12 +17,11 @@ import Web3 from 'web3' import { currentSafe } from 'src/logic/safe/store/selectors' import { getChainInfo, getSafeAppsRpcServiceUrl, getTxServiceUrl } from 'src/config' import { isSameURL } from 'src/utils/url' -import { useAnalytics, SAFE_EVENTS } from 'src/utils/googleAnalytics' import { LoadingContainer } from 'src/components/LoaderContainer/index' import { SAFE_POLLING_INTERVAL } from 'src/utils/constants' import { ConfirmTxModal } from './ConfirmTxModal' import { useIframeMessageHandler } from '../hooks/useIframeMessageHandler' -import { getAppInfoFromUrl, getEmptySafeApp, getLegacyChainName } from '../utils' +import { EMPTY_SAFE_APP, getAppInfoFromUrl, getEmptySafeApp, getLegacyChainName } from '../utils' import { SafeApp } from '../types' import { useAppCommunicator } from '../communicator' import { fetchTokenCurrenciesBalances } from 'src/logic/safe/api/fetchTokenCurrenciesBalances' @@ -35,6 +34,8 @@ import { web3HttpProviderOptions } from 'src/logic/wallets/getWeb3' import { useThirdPartyCookies } from '../hooks/useThirdPartyCookies' import { ThirdPartyCookiesWarning } from './ThirdPartyCookiesWarning' import { grantedSelector } from 'src/routes/safe/container/selector' +import { SAFE_APPS_EVENTS } from 'src/utils/events/safeApps' +import { trackEvent } from 'src/utils/googleTagManager' const AppWrapper = styled.div` display: flex; @@ -89,7 +90,6 @@ const AppFrame = ({ appUrl }: Props): ReactElement => { const { nativeCurrency, chainId, chainName, shortName } = getChainInfo() const safeName = useSelector((state) => addressBookEntryName(state, { address: safeAddress })) const granted = useSelector(grantedSelector) - const { trackEvent } = useAnalytics() const iframeRef = useRef(null) const [confirmTransactionModal, setConfirmTransactionModal] = useState(INITIAL_CONFIRM_TX_MODAL_STATE) @@ -315,12 +315,11 @@ const AppFrame = ({ appUrl }: Props): ReactElement => { loadApp() }, [appUrl]) - //track GA useEffect(() => { - if (safeApp) { - trackEvent({ ...SAFE_EVENTS.SAFE_APP, label: safeApp.name }) + if (safeApp && safeApp.name !== EMPTY_SAFE_APP) { + trackEvent({ ...SAFE_APPS_EVENTS.OPEN_APP, label: safeApp.name }) } - }, [safeApp, trackEvent]) + }, [safeApp]) return ( diff --git a/src/routes/safe/components/Apps/components/AppsList.test.tsx b/src/routes/safe/components/Apps/components/AppsList.test.tsx index a4e059256d..8b3975cc8e 100644 --- a/src/routes/safe/components/Apps/components/AppsList.test.tsx +++ b/src/routes/safe/components/Apps/components/AppsList.test.tsx @@ -5,7 +5,6 @@ import { render, screen, fireEvent, within, act, waitFor } from 'src/utils/test- import * as appUtils from 'src/routes/safe/components/Apps/utils' import { FETCH_STATUS } from 'src/utils/requests' import { loadFromStorage, saveToStorage } from 'src/utils/storage' -import * as googleAnalytics from 'src/utils/googleAnalytics' jest.mock('src/routes/routes', () => { const original = jest.requireActual('src/routes/routes') @@ -15,8 +14,6 @@ jest.mock('src/routes/routes', () => { } }) -const spyTrackEventGA = jest.fn() - beforeEach(() => { // Includes an id that doesn't exist in the remote apps to check that there's no error saveToStorage(appUtils.PINNED_SAFE_APP_IDS, ['14', '24', '228']) @@ -28,11 +25,6 @@ beforeEach(() => { }, ]) - jest.spyOn(googleAnalytics, 'useAnalytics').mockImplementation(() => ({ - trackPage: jest.fn(), - trackEvent: spyTrackEventGA, - })) - jest.spyOn(appUtils, 'getAppInfoFromUrl').mockReturnValueOnce( Promise.resolve({ id: '36', @@ -140,7 +132,7 @@ describe('Safe Apps -> AppsList -> Search', () => { it('Shows apps matching the search query', async () => { render() - const searchInput = await waitFor(() => screen.getByPlaceholderText('e.g Compound')) + const searchInput = await waitFor(() => screen.getByPlaceholderText('e.g. Compound')) fireEvent.input(searchInput, { target: { value: 'Compound' } }) @@ -151,7 +143,7 @@ describe('Safe Apps -> AppsList -> Search', () => { it('Shows app matching the name first for a query that matches in name and description of multiple apps', async () => { render() - const searchInput = await waitFor(() => screen.getByPlaceholderText('e.g Compound')) + const searchInput = await waitFor(() => screen.getByPlaceholderText('e.g. Compound')) fireEvent.input(searchInput, { target: { value: 'Tra' } }) @@ -170,7 +162,7 @@ describe('Safe Apps -> AppsList -> Search', () => { render() const query = 'not-a-real-app' - const searchInput = await waitFor(() => screen.getByPlaceholderText('e.g Compound')) + const searchInput = await waitFor(() => screen.getByPlaceholderText('e.g. Compound')) fireEvent.input(searchInput, { target: { value: query } }) @@ -185,7 +177,7 @@ describe('Safe Apps -> AppsList -> Search', () => { it('Clears the search result when you press on clear button and shows all apps again', async () => { render() - const searchInput = await waitFor(() => screen.getByPlaceholderText('e.g Compound')) + const searchInput = await waitFor(() => screen.getByPlaceholderText('e.g. Compound')) fireEvent.input(searchInput, { target: { value: 'Compound' } }) const clearButton = screen.getByLabelText('Clear the search') @@ -197,7 +189,7 @@ describe('Safe Apps -> AppsList -> Search', () => { it("Doesn't display custom/pinned apps irrelevant to the search (= hides pinned/custom sections)", async () => { render() - const searchInput = await waitFor(() => screen.getByPlaceholderText('e.g Compound')) + const searchInput = await waitFor(() => screen.getByPlaceholderText('e.g. Compound')) fireEvent.input(searchInput, { target: { value: 'Compound' } }) @@ -208,7 +200,7 @@ describe('Safe Apps -> AppsList -> Search', () => { it('Hides pinned/custom sections when you search', async () => { render() - const searchInput = await waitFor(() => screen.getByPlaceholderText('e.g Compound')) + const searchInput = await waitFor(() => screen.getByPlaceholderText('e.g. Compound')) fireEvent.input(searchInput, { target: { value: 'Compound' } }) @@ -241,8 +233,6 @@ describe('Safe Apps -> AppsList -> Pinning apps', () => { expect(within(screen.getByTestId(PINNED_APPS_LIST_TEST_ID)).queryByText('Compound')).not.toBeInTheDocument() }) - expect(spyTrackEventGA).not.toHaveBeenCalled() - const allAppsContainer = screen.getByTestId(ALL_APPS_LIST_TEST_ID) const compoundAppPinBtn = within(allAppsContainer).getByLabelText('Pin Compound') act(() => { @@ -252,11 +242,6 @@ describe('Safe Apps -> AppsList -> Pinning apps', () => { await waitFor(() => { expect(within(screen.getByTestId(PINNED_APPS_LIST_TEST_ID)).getByText('Compound')).toBeInTheDocument() expect(within(screen.getByTestId(PINNED_APPS_LIST_TEST_ID)).getByLabelText('Unpin Compound')).toBeInTheDocument() - expect(spyTrackEventGA).toHaveBeenCalledWith({ - action: 'Pin', - category: 'Safe App', - label: 'Compound', - }) }) const compoundAppUnpinBtn = within(screen.getByTestId(PINNED_APPS_LIST_TEST_ID)).getByLabelText('Unpin Compound') @@ -266,11 +251,6 @@ describe('Safe Apps -> AppsList -> Pinning apps', () => { await waitFor(() => { expect(within(screen.getByTestId(PINNED_APPS_LIST_TEST_ID)).queryByText('Compound')).not.toBeInTheDocument() - expect(spyTrackEventGA).toHaveBeenCalledWith({ - action: 'Unpin', - category: 'Safe App', - label: 'Compound', - }) }) }) diff --git a/src/routes/safe/components/Apps/components/SearchInputCard.tsx b/src/routes/safe/components/Apps/components/SearchInputCard.tsx index e6e4f53110..f0996d794c 100644 --- a/src/routes/safe/components/Apps/components/SearchInputCard.tsx +++ b/src/routes/safe/components/Apps/components/SearchInputCard.tsx @@ -5,6 +5,8 @@ import SearchIcon from '@material-ui/icons/Search' import ClearIcon from '@material-ui/icons/Clear' import styled from 'styled-components' import { Card } from '@gnosis.pm/safe-react-components' +import { trackEvent } from 'src/utils/googleTagManager' +import { SAFE_APPS_EVENTS } from 'src/utils/events/safeApps' const Container = styled(Card)` width: 100%; @@ -31,9 +33,14 @@ const SearchInputCard = ({ value, onValueChange }: Props): React.ReactElement => ) } onChange={(event: React.ChangeEvent) => onValueChange(event.target.value)} - placeholder="e.g Compound" + placeholder="e.g. Compound" value={value} style={{ width: '100%' }} + onBlur={() => { + if (value) { + trackEvent({ ...SAFE_APPS_EVENTS.SEARCH, label: value }) + } + }} /> ) diff --git a/src/routes/safe/components/Apps/hooks/appList/useAppList.ts b/src/routes/safe/components/Apps/hooks/appList/useAppList.ts index fc0ffba3aa..066ab4f36e 100644 --- a/src/routes/safe/components/Apps/hooks/appList/useAppList.ts +++ b/src/routes/safe/components/Apps/hooks/appList/useAppList.ts @@ -5,7 +5,8 @@ import { useCustomSafeApps } from './useCustomSafeApps' import { useRemoteSafeApps } from './useRemoteSafeApps' import { usePinnedSafeApps } from './usePinnedSafeApps' import { FETCH_STATUS } from 'src/utils/requests' -import { SAFE_APP_EVENTS, useAnalytics } from 'src/utils/googleAnalytics' +import { trackEvent } from 'src/utils/googleTagManager' +import { SAFE_APPS_EVENTS } from 'src/utils/events/safeApps' type UseAppListReturnType = { allApps: SafeApp[] @@ -24,8 +25,6 @@ const useAppList = (): UseAppListReturnType => { const { pinnedSafeAppIds, updatePinnedSafeApps } = usePinnedSafeApps(remoteSafeApps, remoteAppsFetchStatus) const remoteIsLoading = remoteAppsFetchStatus === FETCH_STATUS.LOADING - const { trackEvent } = useAnalytics() - const allApps = useMemo(() => { const allApps = [...remoteSafeApps, ...customSafeApps] return allApps.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())) @@ -72,16 +71,16 @@ const useAppList = (): UseAppListReturnType => { const isAppPinned = pinnedSafeAppIds.includes(appId) if (isAppPinned) { - trackEvent({ ...SAFE_APP_EVENTS.PIN, label: appName }) + trackEvent({ ...SAFE_APPS_EVENTS.UNPIN, label: appName }) newPinnedIds.splice(newPinnedIds.indexOf(appId), 1) } else { - trackEvent({ ...SAFE_APP_EVENTS.UNPIN, label: appName }) + trackEvent({ ...SAFE_APPS_EVENTS.PIN, label: appName }) newPinnedIds.push(appId) } updatePinnedSafeApps(newPinnedIds) }, - [trackEvent, updatePinnedSafeApps, pinnedSafeAppIds], + [updatePinnedSafeApps, pinnedSafeAppIds], ) return { diff --git a/src/routes/safe/components/Apps/utils.ts b/src/routes/safe/components/Apps/utils.ts index 6e66d58a90..e0618d2844 100644 --- a/src/routes/safe/components/Apps/utils.ts +++ b/src/routes/safe/components/Apps/utils.ts @@ -25,6 +25,7 @@ export interface AppManifest { export const APPS_STORAGE_KEY = 'APPS_STORAGE_KEY' export const PINNED_SAFE_APP_IDS = 'PINNED_SAFE_APP_IDS' +export const EMPTY_SAFE_APP = 'unknown' const MIN_ICON_WIDTH = 128 const MANIFEST_ERROR_MESSAGE = 'Manifest does not fulfil the required structure.' @@ -45,7 +46,7 @@ export const isAppManifestValid = (appInfo: AppManifest): boolean => // `appInfo` exists and `name` exists !!appInfo?.name && // if `name` exists is not 'unknown' - appInfo.name !== 'unknown' && + appInfo.name !== EMPTY_SAFE_APP && // `description` exists !!appInfo.description @@ -53,7 +54,7 @@ export const getEmptySafeApp = (url = ''): SafeApp => { return { id: Math.random().toString(), url, - name: 'unknown', + name: EMPTY_SAFE_APP, iconUrl: appsIconSvg, description: '', fetchStatus: FETCH_STATUS.LOADING, diff --git a/src/routes/safe/components/Balances/Coins/index.tsx b/src/routes/safe/components/Balances/Coins/index.tsx index 01bdf8ca1c..6a84eb8ea6 100644 --- a/src/routes/safe/components/Balances/Coins/index.tsx +++ b/src/routes/safe/components/Balances/Coins/index.tsx @@ -26,10 +26,12 @@ import { BalanceData, } from 'src/routes/safe/components/Balances/dataFetcher' import { extendedSafeTokensSelector, grantedSelector } from 'src/routes/safe/container/selector' -import { useAnalytics, SAFE_EVENTS } from 'src/utils/googleAnalytics' import { makeStyles } from '@material-ui/core/styles' import { styles } from './styles' import { currentCurrencySelector } from 'src/logic/currencyValues/store/selectors' +import { trackEvent } from 'src/utils/googleTagManager' +import { ASSETS_EVENTS } from 'src/utils/events/assets' +import Track from 'src/components/Track' const StyledButton = styled(Button)` &&.MuiButton-root { @@ -79,11 +81,14 @@ const Coins = (props: Props): React.ReactElement => { const selectedCurrency = useSelector(currentCurrencySelector) const safeTokens = useSelector(extendedSafeTokensSelector) const granted = useSelector(grantedSelector) - const { trackEvent } = useAnalytics() + const differingTokens = useMemo(() => safeTokens.size, [safeTokens]) useEffect(() => { - trackEvent(SAFE_EVENTS.COINS) - }, [trackEvent]) + // Safe does not have any tokens until fetching is complete + if (differingTokens > 0) { + trackEvent({ ...ASSETS_EVENTS.DIFFERING_TOKENS, label: differingTokens }) + } + }, [differingTokens]) const filteredData: List = useMemo( () => getBalanceData(safeTokens, selectedCurrency), @@ -141,25 +146,29 @@ const Coins = (props: Props): React.ReactElement => { {granted && ( - showSendFunds(row.asset.address)} - size="md" - variant="contained" - data-testid="balance-send-btn" - > - + + showSendFunds(row.asset.address)} + size="md" + variant="contained" + data-testid="balance-send-btn" + > + + + Send + + + + )} + + + - Send + Receive - )} - - - - Receive - - + diff --git a/src/routes/safe/components/Balances/Collectibles/index.tsx b/src/routes/safe/components/Balances/Collectibles/index.tsx index f62096fa1c..ace12ec099 100644 --- a/src/routes/safe/components/Balances/Collectibles/index.tsx +++ b/src/routes/safe/components/Balances/Collectibles/index.tsx @@ -1,4 +1,4 @@ -import { Fragment, useEffect, useState } from 'react' +import { Fragment, useEffect, useMemo, useState } from 'react' import Card from '@material-ui/core/Card' import { createStyles, makeStyles } from '@material-ui/core/styles' import { useSelector } from 'react-redux' @@ -14,8 +14,9 @@ import { } from 'src/logic/collectibles/store/selectors' import SendModal from 'src/routes/safe/components/Balances/SendModal' import { fontColor, lg, screenSm, screenXs } from 'src/theme/variables' -import { useAnalytics, SAFE_EVENTS } from 'src/utils/googleAnalytics' import { NFTToken } from 'src/logic/collectibles/sources/collectibles.d' +import { trackEvent } from 'src/utils/googleTagManager' +import { ASSETS_EVENTS } from 'src/utils/events/assets' const useStyles = makeStyles( createStyles({ @@ -83,7 +84,6 @@ const useStyles = makeStyles( ) const Collectibles = (): React.ReactElement => { - const { trackEvent } = useAnalytics() const classes = useStyles() const [selectedToken, setSelectedToken] = useState() const [sendNFTsModalOpen, setSendNFTsModalOpen] = useState(false) @@ -92,9 +92,10 @@ const Collectibles = (): React.ReactElement => { const nftTokens = useSelector(orderedNFTAssets) const nftAssetsFromNftTokens = useSelector(nftAssetsFromNftTokensSelector) + const nftAmount = useMemo(() => nftTokens.length, [nftTokens]) useEffect(() => { - trackEvent(SAFE_EVENTS.COLLECTIBLES) - }, [trackEvent]) + trackEvent({ ...ASSETS_EVENTS.NFT_AMOUNT, label: nftAmount }) + }, [nftAmount]) const handleItemSend = (nftToken: NFTToken) => { setSelectedToken(nftToken) diff --git a/src/routes/safe/components/Balances/SendModal/screens/ChooseTxType/index.tsx b/src/routes/safe/components/Balances/SendModal/screens/ChooseTxType/index.tsx index c9b5138656..0001e9d1a6 100644 --- a/src/routes/safe/components/Balances/SendModal/screens/ChooseTxType/index.tsx +++ b/src/routes/safe/components/Balances/SendModal/screens/ChooseTxType/index.tsx @@ -21,6 +21,8 @@ import Token from '../assets/token.svg' import { getExplorerInfo } from 'src/config' import { FEATURES } from '@gnosis.pm/safe-react-gateway-sdk' +import { MODALS_EVENTS } from 'src/utils/events/modals' +import Track from 'src/components/Track' type ActiveScreen = 'sendFunds' | 'sendCollectible' | 'contractInteraction' @@ -88,53 +90,59 @@ const ChooseTxType = ({ )} - - {erc721Enabled && ( + + + {erc721Enabled && ( + + + )} {contractInteractionEnabled && ( - + + + )} diff --git a/src/routes/safe/components/Balances/SendModal/screens/ReviewSendFundsTx/index.tsx b/src/routes/safe/components/Balances/SendModal/screens/ReviewSendFundsTx/index.tsx index 07440abd93..ba17eef71b 100644 --- a/src/routes/safe/components/Balances/SendModal/screens/ReviewSendFundsTx/index.tsx +++ b/src/routes/safe/components/Balances/SendModal/screens/ReviewSendFundsTx/index.tsx @@ -33,6 +33,8 @@ import { ModalHeader } from 'src/routes/safe/components/Balances/SendModal/scree import { isSpendingLimit } from 'src/routes/safe/components/Transactions/helpers/utils' import { TransferAmount } from 'src/routes/safe/components/Balances/SendModal/TransferAmount' import { getStepTitle } from 'src/routes/safe/components/Balances/SendModal/utils' +import { trackEvent } from 'src/utils/googleTagManager' +import { MODALS_EVENTS } from 'src/utils/events/modals' const useStyles = makeStyles(styles) @@ -99,6 +101,8 @@ const ReviewSendFundsTx = ({ onClose, onPrev, tx }: ReviewTxProps): React.ReactE const spendingLimitTokenAddress = isSendingNativeToken ? ZERO_ADDRESS : txToken.address const spendingLimit = getSpendingLimitContract() try { + trackEvent(MODALS_EVENTS.USE_SPENDING_LIMIT) + await spendingLimit.methods .executeAllowanceTransfer( safeAddress, diff --git a/src/routes/safe/components/CurrencyDropdown/index.tsx b/src/routes/safe/components/CurrencyDropdown/index.tsx index 3557bab620..545a71155b 100644 --- a/src/routes/safe/components/CurrencyDropdown/index.tsx +++ b/src/routes/safe/components/CurrencyDropdown/index.tsx @@ -21,7 +21,9 @@ import { getNativeCurrency } from 'src/config' import { sameString } from 'src/utils/strings' import { fetchSafeTokens } from 'src/logic/tokens/store/actions/fetchSafeTokens' import { currentSafe } from 'src/logic/safe/store/selectors' +import { trackEvent } from 'src/utils/googleTagManager' import { Button } from '@material-ui/core' +import { ASSETS_EVENTS } from 'src/utils/events/assets' export const CurrencyDropdown = ({ testId }: { testId: string }): React.ReactElement | null => { const dispatch = useDispatch() @@ -38,15 +40,30 @@ export const CurrencyDropdown = ({ testId }: { testId: string }): React.ReactEle ) const handleClick = (event: React.MouseEvent) => { + trackEvent({ + ...ASSETS_EVENTS.CURRENCY_MENU, + label: 'Open', + }) + setAnchorEl(event.currentTarget) import('currency-flags/dist/currency-flags.min.css' as string) } const handleClose = () => { + trackEvent({ + ...ASSETS_EVENTS.CURRENCY_MENU, + label: 'Close', + }) + setAnchorEl(null) } - const onCurrentCurrencyChangedHandler = (newCurrencySelectedName: string): void => { + const onCurrentCurrencyChangedHandler = async (newCurrencySelectedName: string): Promise => { + trackEvent({ + ...ASSETS_EVENTS.CHANGE_CURRENCY, + label: newCurrencySelectedName, + }) + handleClose() dispatch(fetchSafeTokens(address, newCurrencySelectedName)) dispatch(setSelectedCurrency({ selectedCurrency: newCurrencySelectedName })) diff --git a/src/routes/safe/components/Settings/Advanced/index.tsx b/src/routes/safe/components/Settings/Advanced/index.tsx index 86c8225d8d..bcb375b868 100644 --- a/src/routes/safe/components/Settings/Advanced/index.tsx +++ b/src/routes/safe/components/Settings/Advanced/index.tsx @@ -1,5 +1,5 @@ import { Text, theme, Title } from '@gnosis.pm/safe-react-components' -import { ReactElement, useEffect } from 'react' +import { ReactElement } from 'react' import { useSelector } from 'react-redux' import styled from 'styled-components' import semverSatisfies from 'semver/functions/satisfies' @@ -10,7 +10,6 @@ import { ModulesTable } from './ModulesTable' import Block from 'src/components/layout/Block' import { currentSafe } from 'src/logic/safe/store/selectors' -import { useAnalytics, SETTINGS_EVENTS } from 'src/utils/googleAnalytics' import { TransactionGuard } from './TransactionGuard' const InfoText = styled(Text)` @@ -41,11 +40,6 @@ const Advanced = (): ReactElement => { const moduleData = modules ? getModuleData(modules) ?? null : null const isVersionWithGuards = semverSatisfies(currentVersion, '>=1.3.0') - const { trackEvent } = useAnalytics() - - useEffect(() => { - trackEvent(SETTINGS_EVENTS.ADVANCED) - }, [trackEvent]) return ( <> diff --git a/src/routes/safe/components/Settings/Appearance/index.tsx b/src/routes/safe/components/Settings/Appearance/index.tsx index cc4eafbda6..273d527b5f 100644 --- a/src/routes/safe/components/Settings/Appearance/index.tsx +++ b/src/routes/safe/components/Settings/Appearance/index.tsx @@ -1,7 +1,7 @@ import FormGroup from '@material-ui/core/FormGroup/FormGroup' import Checkbox from '@material-ui/core/Checkbox/Checkbox' import FormControlLabel from '@material-ui/core/FormControlLabel/FormControlLabel' -import { ChangeEvent, ReactElement, useEffect } from 'react' +import { ChangeEvent, ReactElement } from 'react' import Block from 'src/components/layout/Block' import styled from 'styled-components' @@ -14,8 +14,9 @@ import { setShowShortName } from 'src/logic/appearance/actions/setShowShortName' import { setCopyShortName } from 'src/logic/appearance/actions/setCopyShortName' import { extractSafeAddress } from 'src/routes/routes' import PrefixedEthHashInfo from 'src/components/PrefixedEthHashInfo' -import { useAnalytics, SETTINGS_EVENTS } from 'src/utils/googleAnalytics' import useDarkMode from 'src/logic/hooks/useDarkMode' +import { trackEvent } from 'src/utils/googleTagManager' +import { SETTINGS_EVENTS } from 'src/utils/events/settings' // Other settings sections use MUI createStyles .container // will adjust that during dark mode implementation @@ -34,21 +35,32 @@ const Appearance = (): ReactElement => { const safeAddress = extractSafeAddress() const [darkMode, setDarkMode] = useDarkMode() - const { trackEvent } = useAnalytics() - - useEffect(() => { - trackEvent(SETTINGS_EVENTS.APPEARANCE) - }, [trackEvent]) - const handleShowChange = (_: ChangeEvent, checked: boolean) => { dispatch(setShowShortName({ showShortName: checked })) - const label = `${SETTINGS_EVENTS.APPEARANCE.label} (${checked ? 'Enable' : 'Disable'} EIP-3770 prefixes)` - trackEvent({ ...SETTINGS_EVENTS.APPEARANCE, label }) + trackEvent({ + ...SETTINGS_EVENTS.APPEARANCE.PREPEND_PREFIXES, + label: checked, + }) } - const handleCopyChange = (_: ChangeEvent, checked: boolean) => + const handleCopyChange = (_: ChangeEvent, checked: boolean) => { dispatch(setCopyShortName({ copyShortName: checked })) + trackEvent({ + ...SETTINGS_EVENTS.APPEARANCE.COPY_PREFIXES, + label: checked, + }) + } + + const handleInvertChange = (_: ChangeEvent, checked: boolean) => { + setDarkMode(!darkMode) + + trackEvent({ + ...SETTINGS_EVENTS.APPEARANCE.INVERT_COLORS, + label: checked, + }) + } + return ( <> @@ -71,7 +83,7 @@ const Appearance = (): ReactElement => { Theme (experimental) setDarkMode(!darkMode)} name="showShortName" />} + control={} label="Inverted colors" /> diff --git a/src/routes/safe/components/Settings/ManageOwners/AddOwnerModal/index.tsx b/src/routes/safe/components/Settings/ManageOwners/AddOwnerModal/index.tsx index 28b5138887..c542493ef3 100644 --- a/src/routes/safe/components/Settings/ManageOwners/AddOwnerModal/index.tsx +++ b/src/routes/safe/components/Settings/ManageOwners/AddOwnerModal/index.tsx @@ -17,8 +17,11 @@ import { ReviewAddOwner } from './screens/Review' import { ThresholdForm } from './screens/ThresholdForm' import { getSafeSDK } from 'src/logic/wallets/getWeb3' import { Errors, logError } from 'src/logic/exceptions/CodedException' -import { currentSafeCurrentVersion } from 'src/logic/safe/store/selectors' +import { currentSafe, currentSafeCurrentVersion } from 'src/logic/safe/store/selectors' import { currentChainId } from 'src/logic/config/store/selectors' +import { trackEvent } from 'src/utils/googleTagManager' +import { SETTINGS_EVENTS } from 'src/utils/events/settings' +import { store } from 'src/store' export type OwnerValues = { ownerAddress: string @@ -55,6 +58,9 @@ export const sendAddOwner = async ( delayExecution, }), ) + + trackEvent({ ...SETTINGS_EVENTS.THRESHOLD.THRESHOLD, label: values.threshold }) + trackEvent({ ...SETTINGS_EVENTS.THRESHOLD.OWNERS, label: currentSafe(store.getState()).owners.length }) } type Props = { diff --git a/src/routes/safe/components/Settings/ManageOwners/RemoveOwnerModal/index.tsx b/src/routes/safe/components/Settings/ManageOwners/RemoveOwnerModal/index.tsx index d844a1d90d..48893b4509 100644 --- a/src/routes/safe/components/Settings/ManageOwners/RemoveOwnerModal/index.tsx +++ b/src/routes/safe/components/Settings/ManageOwners/RemoveOwnerModal/index.tsx @@ -15,7 +15,10 @@ import { TxParameters } from 'src/routes/safe/container/hooks/useTransactionPara import { extractSafeAddress } from 'src/routes/routes' import { getSafeSDK } from 'src/logic/wallets/getWeb3' import { Errors, logError } from 'src/logic/exceptions/CodedException' -import { currentSafeCurrentVersion } from 'src/logic/safe/store/selectors' +import { currentSafe, currentSafeCurrentVersion } from 'src/logic/safe/store/selectors' +import { trackEvent } from 'src/utils/googleTagManager' +import { SETTINGS_EVENTS } from 'src/utils/events/settings' +import { store } from 'src/store' type OwnerValues = OwnerData & { threshold: string @@ -51,6 +54,9 @@ export const sendRemoveOwner = async ( delayExecution, }), ) + + trackEvent({ ...SETTINGS_EVENTS.THRESHOLD.THRESHOLD, label: values.threshold }) + trackEvent({ ...SETTINGS_EVENTS.THRESHOLD.OWNERS, label: currentSafe(store.getState()).owners.length }) } type RemoveOwnerProps = { diff --git a/src/routes/safe/components/Settings/ManageOwners/index.tsx b/src/routes/safe/components/Settings/ManageOwners/index.tsx index 90ca03d1ba..419092a6a0 100644 --- a/src/routes/safe/components/Settings/ManageOwners/index.tsx +++ b/src/routes/safe/components/Settings/ManageOwners/index.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, ReactElement } from 'react' +import { useState, ReactElement } from 'react' import { Icon } from '@gnosis.pm/safe-react-components' import TableCell from '@material-ui/core/TableCell' import TableContainer from '@material-ui/core/TableContainer' @@ -24,8 +24,9 @@ import Heading from 'src/components/layout/Heading' import Paragraph from 'src/components/layout/Paragraph/index' import Row from 'src/components/layout/Row' import PrefixedEthHashInfo from 'src/components/PrefixedEthHashInfo' -import { useAnalytics, SETTINGS_EVENTS } from 'src/utils/googleAnalytics' import { AddressBookState } from 'src/logic/addressBook/model/addressBook' +import Track from 'src/components/Track' +import { SETTINGS_EVENTS } from 'src/utils/events/settings' export const RENAME_OWNER_BTN_TEST_ID = 'rename-owner-btn' export const REMOVE_OWNER_BTN_TEST_ID = 'remove-owner-btn' @@ -39,7 +40,6 @@ type Props = { } const ManageOwners = ({ granted, owners }: Props): ReactElement => { - const { trackEvent } = useAnalytics() const classes = useStyles() const [selectedOwner, setSelectedOwner] = useState() @@ -68,10 +68,6 @@ const ManageOwners = ({ granted, owners }: Props): ReactElement => { setSelectedOwner(undefined) } - useEffect(() => { - trackEvent(SETTINGS_EVENTS.OWNERS) - }, [trackEvent]) - const columns = generateColumns() const autoColumns = columns.filter((c) => !c.custom) const ownerData = getOwnerData(owners) @@ -122,18 +118,24 @@ const ManageOwners = ({ granted, owners }: Props): ReactElement => { ))} - - - + + + + + {granted && ( <> - - - - {ownerData.length > 1 && ( - - + + + + + {ownerData.length > 1 && ( + + + + + )} )} @@ -150,15 +152,17 @@ const ManageOwners = ({ granted, owners }: Props): ReactElement => { - + + + diff --git a/src/routes/safe/components/Settings/SafeDetails/index.tsx b/src/routes/safe/components/Settings/SafeDetails/index.tsx index c467d7e39d..ef9866ed05 100644 --- a/src/routes/safe/components/Settings/SafeDetails/index.tsx +++ b/src/routes/safe/components/Settings/SafeDetails/index.tsx @@ -32,11 +32,12 @@ import { latestMasterContractVersion as latestMasterContractVersionSelector, safesWithNamesAsMap, } from 'src/logic/safe/store/selectors' -import { useAnalytics, SETTINGS_EVENTS } from 'src/utils/googleAnalytics' import { fetchMasterCopies, MasterCopy, MasterCopyDeployer } from 'src/logic/contracts/api/masterCopies' import { getMasterCopyAddressFromProxyAddress } from 'src/logic/contracts/safeContracts' import ChainIndicator from 'src/components/ChainIndicator' import { currentChainId } from 'src/logic/config/store/selectors' +import { trackEvent } from 'src/utils/googleTagManager' +import { SETTINGS_EVENTS } from 'src/utils/events/settings' export const SAFE_NAME_INPUT_TEST_ID = 'safe-name-input' export const SAFE_NAME_SUBMIT_BTN_TEST_ID = 'change-safe-name-btn' @@ -71,7 +72,6 @@ const SafeDetails = (): ReactElement => { const safeName = safeNamesMap[safeAddress]?.name const dispatch = useDispatch() - const { trackEvent } = useAnalytics() const [isModalOpen, setModalOpen] = useState(false) const [safeInfo, setSafeInfo] = useState() @@ -81,6 +81,8 @@ const SafeDetails = (): ReactElement => { } const handleSubmit = (values) => { + trackEvent(SETTINGS_EVENTS.DETAILS.SAFE_NAME) + dispatch( addressBookAddOrUpdate( makeAddressBookEntry({ address: safeAddress, name: values.safeName, chainId: curChainId }), @@ -115,10 +117,6 @@ const SafeDetails = (): ReactElement => { : '' } - useEffect(() => { - trackEvent(SETTINGS_EVENTS.DETAILS) - }, [trackEvent]) - useEffect(() => { const getMasterCopyInfo = async () => { const masterCopies = await fetchMasterCopies() diff --git a/src/routes/safe/components/Settings/SpendingLimit/LimitsTable/index.tsx b/src/routes/safe/components/Settings/SpendingLimit/LimitsTable/index.tsx index 1bed74b2ed..54a650ce16 100644 --- a/src/routes/safe/components/Settings/SpendingLimit/LimitsTable/index.tsx +++ b/src/routes/safe/components/Settings/SpendingLimit/LimitsTable/index.tsx @@ -8,10 +8,12 @@ import ButtonHelper from 'src/components/ButtonHelper' import Row from 'src/components/layout/Row' import { TableCell, TableRow } from 'src/components/layout/Table' import Table from 'src/components/Table' +import Track from 'src/components/Track' import { AddressInfo } from 'src/routes/safe/components/Settings/SpendingLimit/InfoDisplay' import { RemoveLimitModal } from 'src/routes/safe/components/Settings/SpendingLimit/RemoveLimitModal' import { useStyles } from 'src/routes/safe/components/Settings/SpendingLimit/style' import { grantedSelector } from 'src/routes/safe/container/selector' +import { SETTINGS_EVENTS } from 'src/utils/events/settings' import { generateColumns, @@ -73,9 +75,11 @@ export const LimitsTable = ({ data }: SpendingLimitTableProps): ReactElement => {granted && ( - setSelectedRow(row)} data-testid="remove-limit-btn"> - - + + setSelectedRow(row)} data-testid="remove-limit-btn"> + + + )} diff --git a/src/routes/safe/components/Settings/SpendingLimit/NewLimitModal/Review.tsx b/src/routes/safe/components/Settings/SpendingLimit/NewLimitModal/Review.tsx index 375de88537..f081de4d19 100644 --- a/src/routes/safe/components/Settings/SpendingLimit/NewLimitModal/Review.tsx +++ b/src/routes/safe/components/Settings/SpendingLimit/NewLimitModal/Review.tsx @@ -34,6 +34,8 @@ import { TxModalWrapper } from 'src/routes/safe/components/Transactions/helpers/ import { ActionCallback, CREATE } from 'src/routes/safe/components/Settings/SpendingLimit/NewLimitModal' import { TransferAmount } from 'src/routes/safe/components/Balances/SendModal/TransferAmount' import { getStepTitle } from 'src/routes/safe/components/Balances/SendModal/utils' +import { SETTINGS_EVENTS } from 'src/utils/events/settings' +import { trackEvent } from 'src/utils/googleTagManager' const useExistentSpendingLimit = ({ spendingLimits, @@ -223,6 +225,11 @@ export const ReviewSpendingLimits = ({ onBack, onClose, txToken, values }: Revie ) dispatch(createTransaction({ ...spendingLimitTxData, delayExecution })) + + trackEvent({ + ...SETTINGS_EVENTS.SPENDING_LIMIT.RESET_PERIOD, + label: values.withResetTime ? `${values.resetTime} minutes` : 'never', + }) } } diff --git a/src/routes/safe/components/Settings/SpendingLimit/RemoveLimitModal.tsx b/src/routes/safe/components/Settings/SpendingLimit/RemoveLimitModal.tsx index a5e46a1725..aa7d944916 100644 --- a/src/routes/safe/components/Settings/SpendingLimit/RemoveLimitModal.tsx +++ b/src/routes/safe/components/Settings/SpendingLimit/RemoveLimitModal.tsx @@ -18,6 +18,8 @@ import { SpendingLimitTable } from './LimitsTable/dataFetcher' import { extractSafeAddress } from 'src/routes/routes' import { TxModalWrapper } from 'src/routes/safe/components/Transactions/helpers/TxModalWrapper' import { TransferAmount } from 'src/routes/safe/components/Balances/SendModal/TransferAmount' +import { trackEvent } from 'src/utils/googleTagManager' +import { SETTINGS_EVENTS } from 'src/utils/events/settings' interface RemoveSpendingLimitModalProps { onClose: () => void @@ -55,6 +57,8 @@ export const RemoveLimitModal = ({ onClose, spendingLimit, open }: RemoveSpendin delayExecution, }), ) + + trackEvent(SETTINGS_EVENTS.SPENDING_LIMIT.LIMIT_REMOVED) } catch (e) { console.error( `failed to remove spending limit ${spendingLimit.beneficiary} -> ${spendingLimit.spent.tokenAddress}`, diff --git a/src/routes/safe/components/Settings/SpendingLimit/index.tsx b/src/routes/safe/components/Settings/SpendingLimit/index.tsx index ea115976e2..d38af7ed4a 100644 --- a/src/routes/safe/components/Settings/SpendingLimit/index.tsx +++ b/src/routes/safe/components/Settings/SpendingLimit/index.tsx @@ -14,6 +14,8 @@ import { getSpendingLimitData } from './LimitsTable/dataFetcher' import { NewLimitModal } from './NewLimitModal' import { NewLimitSteps } from './NewLimitSteps' import { useStyles } from './style' +import { SETTINGS_EVENTS } from 'src/utils/events/settings' +import Track from 'src/components/Track' const InfoText = styled(Text)` margin-top: 16px; @@ -50,16 +52,18 @@ const SpendingLimit = (): ReactElement => { <> - + + + {showNewSpendingLimitModal && } diff --git a/src/routes/safe/components/Settings/ThresholdSettings/ChangeThreshold/index.tsx b/src/routes/safe/components/Settings/ThresholdSettings/ChangeThreshold/index.tsx index 05b34d130b..73aa1d129c 100644 --- a/src/routes/safe/components/Settings/ThresholdSettings/ChangeThreshold/index.tsx +++ b/src/routes/safe/components/Settings/ThresholdSettings/ChangeThreshold/index.tsx @@ -20,6 +20,8 @@ import { TxModalWrapper } from 'src/routes/safe/components/Transactions/helpers/ import { TxParameters } from 'src/routes/safe/container/hooks/useTransactionParameters' import { useStyles } from './style' +import { trackEvent } from 'src/utils/googleTagManager' +import { SETTINGS_EVENTS } from 'src/utils/events/settings' const THRESHOLD_FIELD_NAME = 'threshold' @@ -79,6 +81,10 @@ export const ChangeThresholdModal = ({ delayExecution, }), ) + + trackEvent({ ...SETTINGS_EVENTS.THRESHOLD.OWNERS, label: ownersCount }) + trackEvent({ ...SETTINGS_EVENTS.THRESHOLD.THRESHOLD, label: editedThreshold }) + onClose() } diff --git a/src/routes/safe/components/Settings/ThresholdSettings/index.tsx b/src/routes/safe/components/Settings/ThresholdSettings/index.tsx index 0089fc461c..9663bb7d26 100644 --- a/src/routes/safe/components/Settings/ThresholdSettings/index.tsx +++ b/src/routes/safe/components/Settings/ThresholdSettings/index.tsx @@ -1,5 +1,5 @@ import { makeStyles } from '@material-ui/core/styles' -import { useState, useEffect } from 'react' +import { useState } from 'react' import { useSelector } from 'react-redux' import Modal from 'src/components/Modal' @@ -11,10 +11,11 @@ import Paragraph from 'src/components/layout/Paragraph' import Row from 'src/components/layout/Row' import { grantedSelector } from 'src/routes/safe/container/selector' import { currentSafe } from 'src/logic/safe/store/selectors' -import { useAnalytics, SETTINGS_EVENTS } from 'src/utils/googleAnalytics' import { ChangeThresholdModal } from './ChangeThreshold' import { styles } from './style' +import { SETTINGS_EVENTS } from 'src/utils/events/settings' +import Track from 'src/components/Track' const useStyles = makeStyles(styles) @@ -28,12 +29,6 @@ const ThresholdSettings = (): React.ReactElement => { setModalOpen((prevOpen) => !prevOpen) } - const { trackEvent } = useAnalytics() - - useEffect(() => { - trackEvent(SETTINGS_EVENTS.OWNERS) - }, [trackEvent]) - return ( <> @@ -44,15 +39,17 @@ const ThresholdSettings = (): React.ReactElement => { {owners && owners.length > 1 && granted && ( - + + + )} diff --git a/src/routes/safe/components/Settings/index.tsx b/src/routes/safe/components/Settings/index.tsx index bef60cb3b5..6c1bc8fdbf 100644 --- a/src/routes/safe/components/Settings/index.tsx +++ b/src/routes/safe/components/Settings/index.tsx @@ -14,6 +14,8 @@ import { currentSafeWithNames } from 'src/logic/safe/store/selectors' import { grantedSelector } from 'src/routes/safe/container/selector' import { generatePrefixedAddressRoutes, SAFE_ROUTES, SAFE_SUBSECTION_ROUTE } from 'src/routes/routes' import { getShortName } from 'src/config' +import { SETTINGS_EVENTS } from 'src/utils/events/settings' +import Track from 'src/components/Track' const Advanced = lazy(() => import('./Advanced')) const SpendingLimitSettings = lazy(() => import('./SpendingLimit')) @@ -94,10 +96,12 @@ const Settings = (): React.ReactElement => { {!loadedViaUrl ? ( - - Remove Safe - - + + + Remove Safe + + + ) : ( diff --git a/src/routes/safe/components/Transactions/TxList/QueueTransactions.tsx b/src/routes/safe/components/Transactions/TxList/QueueTransactions.tsx index 943eea5051..901904aad3 100644 --- a/src/routes/safe/components/Transactions/TxList/QueueTransactions.tsx +++ b/src/routes/safe/components/Transactions/TxList/QueueTransactions.tsx @@ -1,5 +1,5 @@ import { Loader, Title } from '@gnosis.pm/safe-react-components' -import { ReactElement } from 'react' +import { ReactElement, useEffect, useMemo } from 'react' import Img from 'src/components/layout/Img' import NoTransactionsImage from './assets/no-transactions.svg' @@ -8,10 +8,25 @@ import { QueueTxList } from './QueueTxList' import { Centered, NoTransactions } from './styled' import { TxsInfiniteScroll } from './TxsInfiniteScroll' import { TxLocationContext } from './TxLocationProvider' +import { trackEvent } from 'src/utils/googleTagManager' +import { TX_LIST_EVENTS } from 'src/utils/events/txList' export const QueueTransactions = (): ReactElement => { const { count, isLoading, hasMore, next, transactions } = usePagedQueuedTransactions() + const queuedTxCount = useMemo( + () => (transactions ? transactions.next.count + transactions.queue.count : 0), + [transactions], + ) + useEffect(() => { + if (queuedTxCount > 0) { + trackEvent({ + ...TX_LIST_EVENTS.QUEUED_TXS, + label: queuedTxCount, + }) + } + }, [queuedTxCount]) + if (count === 0 && isLoading) { return ( diff --git a/src/routes/safe/components/Transactions/TxList/TxSummary.tsx b/src/routes/safe/components/Transactions/TxList/TxSummary.tsx index bad3e61469..fea3de0751 100644 --- a/src/routes/safe/components/Transactions/TxList/TxSummary.tsx +++ b/src/routes/safe/components/Transactions/TxList/TxSummary.tsx @@ -15,6 +15,8 @@ import TxInfoMultiSend from './TxInfoMultiSend' import DelegateCallWarning from './DelegateCallWarning' import { TxDataRow } from 'src/routes/safe/components/Transactions/TxList/TxDataRow' import { sm } from 'src/theme/variables' +import Track from 'src/components/Track' +import { TX_LIST_EVENTS } from 'src/utils/events/txList' const StyledButtonLink = styled(ButtonLink)` margin-top: ${sm}; @@ -60,7 +62,9 @@ export const TxSummary = ({ txDetails }: Props): ReactElement => { <> {isMultiSigExecutionDetails(txDetails.detailedExecutionInfo) && (
- + + +
)} {txData?.operation === Operation.DELEGATE && ( diff --git a/src/routes/safe/components/Transactions/TxList/hooks/useActionButtonsHandlers.ts b/src/routes/safe/components/Transactions/TxList/hooks/useActionButtonsHandlers.ts index 8ac022c350..fa66da3e1e 100644 --- a/src/routes/safe/components/Transactions/TxList/hooks/useActionButtonsHandlers.ts +++ b/src/routes/safe/components/Transactions/TxList/hooks/useActionButtonsHandlers.ts @@ -16,6 +16,8 @@ import { TxLocationContext } from 'src/routes/safe/components/Transactions/TxLis import enqueueSnackbar from 'src/logic/notifications/store/actions/enqueueSnackbar' import { NOTIFICATIONS } from 'src/logic/notifications' import useTxStatus from 'src/logic/hooks/useTxStatus' +import { trackEvent } from 'src/utils/googleTagManager' +import { TX_LIST_EVENTS } from 'src/utils/events/txList' type ActionButtonsHandlers = { canCancel: boolean @@ -50,8 +52,12 @@ export const useActionButtonsHandlers = (transaction: Transaction): ActionButton return } } + const actionSelected = canExecute || canConfirmThenExecute ? 'execute' : 'confirm' + + trackEvent(TX_LIST_EVENTS[actionSelected.toUpperCase()]) + actionContext.current.selectAction({ - actionSelected: canExecute || canConfirmThenExecute ? 'execute' : 'confirm', + actionSelected, transactionId: transaction.id, }) }, @@ -61,6 +67,9 @@ export const useActionButtonsHandlers = (transaction: Transaction): ActionButton const handleCancelButtonClick = useCallback( (event: ReactMouseEvent) => { event.stopPropagation() + + trackEvent(TX_LIST_EVENTS.REJECT) + actionContext.current.selectAction({ actionSelected: 'cancel', transactionId: transaction.id, diff --git a/src/routes/safe/components/Transactions/TxList/index.tsx b/src/routes/safe/components/Transactions/TxList/index.tsx index 1b7eaa52f9..1ed04c27f8 100644 --- a/src/routes/safe/components/Transactions/TxList/index.tsx +++ b/src/routes/safe/components/Transactions/TxList/index.tsx @@ -1,11 +1,10 @@ import { Menu, Breadcrumb, BreadcrumbElement, Tab } from '@gnosis.pm/safe-react-components' import { Item } from '@gnosis.pm/safe-react-components/dist/navigation/Tab' -import { ReactElement, useEffect } from 'react' +import { ReactElement } from 'react' import { Redirect, Route, Switch, useHistory, useRouteMatch } from 'react-router-dom' import Col from 'src/components/layout/Col' import { extractPrefixedSafeAddress, generateSafeRoute, SAFE_ROUTES } from 'src/routes/routes' -import { SAFE_EVENTS, useAnalytics } from 'src/utils/googleAnalytics' import { HistoryTransactions } from './HistoryTransactions' import { QueueTransactions } from './QueueTransactions' import { ContentWrapper, Wrapper } from './styled' @@ -21,12 +20,6 @@ const GatewayTransactions = (): ReactElement => { const history = useHistory() const { path } = useRouteMatch() - const { trackEvent } = useAnalytics() - - useEffect(() => { - trackEvent(SAFE_EVENTS.TRANSACTIONS) - }, [trackEvent]) - const onTabChange = (path: string) => history.replace(generateSafeRoute(path, extractPrefixedSafeAddress())) return ( diff --git a/src/routes/safe/components/Transactions/helpers/TxEstimatedFeesDetail/index.tsx b/src/routes/safe/components/Transactions/helpers/TxEstimatedFeesDetail/index.tsx index 1208199f6e..209c3677fc 100644 --- a/src/routes/safe/components/Transactions/helpers/TxEstimatedFeesDetail/index.tsx +++ b/src/routes/safe/components/Transactions/helpers/TxEstimatedFeesDetail/index.tsx @@ -8,6 +8,8 @@ import { TxParameters } from 'src/routes/safe/container/hooks/useTransactionPara import { ParametersStatus, areEthereumParamsVisible } from '../utils' import Bold from 'src/components/layout/Bold' import { isMaxFeeParam } from 'src/logic/safe/transactions/gas' +import Track from 'src/components/Track' +import { MODALS_EVENTS } from 'src/utils/events/modals' const TxParameterWrapper = styled.div` display: flex; @@ -70,12 +72,14 @@ export const TxEstimatedFeesDetail = ({ return ( - - Estimated fee price - - {gasCost} - - + + + Estimated fee price + + {gasCost} + + + {areEthereumParamsVisible(parametersStatus || defaultParameterStatus) && ( @@ -103,9 +107,11 @@ export const TxEstimatedFeesDetail = ({ )} )} - - Edit - + + + Edit + + diff --git a/src/routes/safe/components/Transactions/helpers/TxParametersDetail/index.tsx b/src/routes/safe/components/Transactions/helpers/TxParametersDetail/index.tsx index be814b4350..a3ef106000 100644 --- a/src/routes/safe/components/Transactions/helpers/TxParametersDetail/index.tsx +++ b/src/routes/safe/components/Transactions/helpers/TxParametersDetail/index.tsx @@ -25,6 +25,8 @@ import { getExplorerInfo } from 'src/config' import PrefixedEthHashInfo from 'src/components/PrefixedEthHashInfo' import { getByteLength } from 'src/utils/getByteLength' import { md } from 'src/theme/variables' +import Track from 'src/components/Track' +import { MODALS_EVENTS } from 'src/utils/events/modals' const TxParameterWrapper = styled.div` display: flex; @@ -146,9 +148,11 @@ export const TxParametersDetail = ({ return ( - - Advanced parameters - + + + Advanced parameters + + @@ -162,9 +166,11 @@ export const TxParametersDetail = ({ /> {showSafeTxGas && } - - Edit - + + + Edit + + {storedTx?.length > 0 && } diff --git a/src/routes/welcome/Welcome.tsx b/src/routes/welcome/Welcome.tsx index 82e0977dc4..c05e967813 100644 --- a/src/routes/welcome/Welcome.tsx +++ b/src/routes/welcome/Welcome.tsx @@ -7,6 +7,8 @@ import Page from 'src/components/layout/Page' import Block from 'src/components/layout/Block' import Link from 'src/components/layout/Link' import { LOAD_SAFE_ROUTE, OPEN_SAFE_ROUTE } from 'src/routes/routes' +import Track from 'src/components/Track' +import { CREATE_SAFE_EVENTS, LOAD_SAFE_EVENTS } from 'src/utils/events/createLoadSafe' function Welcome(): ReactElement { return ( @@ -29,11 +31,13 @@ function Welcome(): ReactElement { Create a new Safe that is controlled by one or multiple owners. You will be required to pay a network fee for creating your new Safe. - + + + @@ -47,19 +51,21 @@ function Welcome(): ReactElement { Safe address. - + + + diff --git a/src/types/definitions.d.ts b/src/types/definitions.d.ts index c48d7a7e40..1b11710b5f 100644 --- a/src/types/definitions.d.ts +++ b/src/types/definitions.d.ts @@ -1,5 +1,6 @@ import 'styled-components' import { theme } from '@gnosis.pm/safe-react-components' +import { DataLayerArgs } from 'react-gtm-module' import { BeamerConfig, BeamerMethods } from './Beamer.d' type Theme = typeof theme @@ -14,6 +15,7 @@ declare global { } beamer_config?: BeamerConfig Beamer?: BeamerMethods + dataLayer?: DataLayerArgs['dataLayer'] } } declare module '@openzeppelin/contracts/build/contracts/ERC721' diff --git a/src/utils/__tests__/googleTagManager.test.tsx b/src/utils/__tests__/googleTagManager.test.tsx new file mode 100644 index 0000000000..922617ee6b --- /dev/null +++ b/src/utils/__tests__/googleTagManager.test.tsx @@ -0,0 +1,181 @@ +import * as TagManager from 'react-gtm-module' +import { matchPath } from 'react-router-dom' +import { renderHook } from '@testing-library/react-hooks' + +import { history } from 'src/routes/routes' +import { getAnonymizedLocation, usePageTracking, GTM_EVENT } from 'src/utils/googleTagManager' +import { waitFor } from '@testing-library/react' + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + matchPath: jest.fn(), +})) + +describe('googleTagManager', () => { + beforeEach(() => { + jest.resetModules() + jest.resetAllMocks() + }) + describe('getAnonymizedLocation', () => { + it('anonymizes routes with Safe addresses', () => { + ;(matchPath as jest.Mock).mockImplementation(() => ({ + isExact: false, + path: '', + url: '', + params: { + prefixedSafeAddress: '0x0000000000000000000000000000000000000000', + }, + })) + + const anonymizedLocation = getAnonymizedLocation({ + pathname: '/rin/0x0000000000000000000000000000000000000000', + search: '?test=true', + hash: '#hash', + state: null, + }) + + expect(anonymizedLocation).toBe('/rin/SAFE_ADDRESS?test=true#hash') + }) + it('anonymizes transaction ids', () => { + ;(matchPath as jest.Mock).mockImplementation(() => ({ + isExact: false, + path: '', + url: '', + params: { + prefixedSafeAddress: '0x0000000000000000000000000000000000000000', + safeTxHash: '0x73e9512853f394f4c3485752a56806f61a5a0a98d8c13877ee3e7ae5d2769d2b', + }, + })) + + const anonymizedLocation = getAnonymizedLocation({ + pathname: + '/rin/0x0000000000000000000000000000000000000000/transactions/0x73e9512853f394f4c3485752a56806f61a5a0a98d8c13877ee3e7ae5d2769d2b', + search: '?test=true', + hash: '#hash', + state: null, + }) + + expect(anonymizedLocation).toBe('/rin/SAFE_ADDRESS/transactions/TRANSACTION_ID?test=true#hash') + }) + it("doesn't anonymize other links", () => { + ;(matchPath as jest.Mock).mockImplementation(() => ({ + isExact: false, + path: '', + url: '', + params: {}, + })) + + const anonymizedLocation = getAnonymizedLocation({ + pathname: '/other/test/link', + search: '?test=true', + hash: '#hash', + state: null, + }) + + expect(anonymizedLocation).toBe('/other/test/link?test=true#hash') + }) + }) + describe('loadGoogleTagManager', () => { + it('prevents init without a gtm id/auth', () => { + jest.doMock('src/utils/constants.ts', () => ({ + GOOGLE_TAG_MANAGER_ID: '', + GOOGLE_TAG_MANAGER_DEVELOPMENT_AUTH: '', + })) + + const mockInitialize = jest.fn() + jest.doMock('react-gtm-module', () => ({ + initialize: mockInitialize, + })) + + // doMock doesn't hoist + const { loadGoogleTagManager } = require('src/utils/googleTagManager') + loadGoogleTagManager() + + expect(mockInitialize).not.toHaveBeenCalled() + }) + it('inits gtm with a pageview event', () => { + jest.doMock('src/utils/constants.ts', () => ({ + GOOGLE_TAG_MANAGER_ID: 'id123', + GOOGLE_TAG_MANAGER_DEVELOPMENT_AUTH: 'auth123', + })) + + jest.doMock('src/config', () => ({ + _getChainId: jest.fn(() => '4'), + })) + + const mockInitialize = jest.fn() + jest.doMock('react-gtm-module', () => ({ + initialize: mockInitialize, + })) + + // doMock doesn't hoist + const { loadGoogleTagManager } = require('src/utils/googleTagManager') + loadGoogleTagManager() + + expect(mockInitialize).toHaveBeenCalledWith({ + gtmId: 'id123', + auth: 'auth123', + preview: 'env-3', + dataLayer: { + event: 'pageview', + chainId: '4', + page: '/', + }, + }) + }) + }) + describe('usePageTracking', () => { + it('dispatches a pageview event on page change', () => { + const dataLayerSpy = jest.spyOn(TagManager.default, 'dataLayer').mockImplementation(jest.fn()) + + const { waitFor } = renderHook(() => usePageTracking()) + + waitFor(() => { + expect(dataLayerSpy).toHaveBeenCalledWith({ + dataLayer: { + event: 'pageview', + chainId: '4', + page: '/', + }, + }) + }) + + history.push('/test1') + history.push('/test2') + history.push('/test3') + + waitFor(() => { + expect(dataLayerSpy).toHaveBeenCalledTimes(4) + }) + }) + }) + describe('trackEvent', () => { + it('tracks a correctly formed event from the arguments', async () => { + const mockDataLayer = jest.fn() + jest.doMock('react-gtm-module', () => ({ + dataLayer: mockDataLayer, + })) + + // doMock doesn't hoist + const { trackEvent } = require('src/utils/googleTagManager') + trackEvent({ + event: 'testEvent' as GTM_EVENT, + category: 'unit-test', + action: 'Track event', + label: 1, + }) + + await waitFor(() => { + expect(mockDataLayer).toHaveBeenCalledWith({ + dataLayer: { + event: 'testEvent', + chainId: '4', + eventCategory: 'unit-test', + eventAction: 'Track event', + eventLabel: 1, + }, + }) + }) + }) + }) +}) diff --git a/src/utils/constants.ts b/src/utils/constants.ts index d0a47cdb39..7fad527ff4 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -25,6 +25,12 @@ export const IPFS_GATEWAY = process.env.REACT_APP_IPFS_GATEWAY export const SPENDING_LIMIT_MODULE_ADDRESS = process.env.REACT_APP_SPENDING_LIMIT_MODULE_ADDRESS || '0xCFbFaC74C26F8647cBDb8c5caf80BB5b32E43134' +// Google Tag Manager +export const GOOGLE_TAG_MANAGER_ID = process.env.REACT_APP_GOOGLE_TAG_MANAGER_ID || '' +export const GOOGLE_TAG_MANAGER_AUTH_LIVE = process.env.REACT_APP_GOOGLE_TAG_MANAGER_LIVE_AUTH || '' +export const GOOGLE_TAG_MANAGER_AUTH_LATEST = process.env.REACT_APP_GOOGLE_TAG_MANAGER_LATEST_AUTH || '' +export const GOOGLE_TAG_MANAGER_DEVELOPMENT_AUTH = process.env.REACT_APP_GOOGLE_TAG_MANAGER_DEVELOPMENT_AUTH || '' + // localStorage-related constants export const LS_NAMESPACE = 'SAFE' export const LS_SEPARATOR = '__' diff --git a/src/utils/events/addressBook.ts b/src/utils/events/addressBook.ts new file mode 100644 index 0000000000..7a9f40f10f --- /dev/null +++ b/src/utils/events/addressBook.ts @@ -0,0 +1,40 @@ +import { GTM_EVENT } from 'src/utils/googleTagManager' +import { addEventCategory } from 'src/utils/events/utils' + +const ADDRESS_BOOK = { + EXPORT: { + event: GTM_EVENT.CLICK, + action: 'Export', + }, + DOWNLOAD_BUTTON: { + event: GTM_EVENT.CLICK, + action: 'Download address book', + }, + IMPORT: { + event: GTM_EVENT.CLICK, + action: 'Import', + }, + IMPORT_BUTTON: { + event: GTM_EVENT.CLICK, + action: 'Import address book', + }, + CREATE_ENTRY: { + event: GTM_EVENT.CLICK, + action: 'Create entry', + }, + EDIT_ENTRY: { + event: GTM_EVENT.CLICK, + action: 'Edit entry', + }, + DELETE_ENTRY: { + event: GTM_EVENT.CLICK, + action: 'Delete entry', + }, + SEND: { + event: GTM_EVENT.CLICK, + action: 'Send to contact', + }, +} + +const ADDRESS_BOOK_CATEGORY = 'address-book' +export const ADDRESS_BOOK_EVENTS = addEventCategory(ADDRESS_BOOK, ADDRESS_BOOK_CATEGORY) diff --git a/src/utils/events/assets.ts b/src/utils/events/assets.ts new file mode 100644 index 0000000000..2d9f555304 --- /dev/null +++ b/src/utils/events/assets.ts @@ -0,0 +1,32 @@ +import { GTM_EVENT } from 'src/utils/googleTagManager' +import { addEventCategory } from 'src/utils/events/utils' + +const ASSETS = { + CURRENCY_MENU: { + event: GTM_EVENT.CLICK, + action: 'Currency menu', + }, + CHANGE_CURRENCY: { + event: GTM_EVENT.META, + action: 'Change currency', + }, + DIFFERING_TOKENS: { + event: GTM_EVENT.META, + action: 'Tokens', + }, + NFT_AMOUNT: { + event: GTM_EVENT.META, + action: 'NFTs', + }, + SEND: { + event: GTM_EVENT.CLICK, + action: 'Send', + }, + RECEIVE: { + event: GTM_EVENT.CLICK, + action: 'Receive', + }, +} + +export const ASSETS_CATEGORY = 'assets' +export const ASSETS_EVENTS = addEventCategory(ASSETS, ASSETS_CATEGORY) diff --git a/src/utils/events/createLoadSafe.ts b/src/utils/events/createLoadSafe.ts new file mode 100644 index 0000000000..914287481b --- /dev/null +++ b/src/utils/events/createLoadSafe.ts @@ -0,0 +1,64 @@ +import { GTM_EVENT } from 'src/utils/googleTagManager' +import { addEventCategory } from 'src/utils/events/utils' + +// Back/forward is automatically tracked in create/load stepper + +const CREATE_SAFE = { + CREATE_BUTTON: { + event: GTM_EVENT.CLICK, + action: 'Open stepper', + }, + NAME_SAFE: { + event: GTM_EVENT.META, + action: 'Name Safe', + }, + OWNERS: { + event: GTM_EVENT.META, + action: 'Owners', + }, + THRESHOLD: { + event: GTM_EVENT.META, + action: 'Threshold', + }, + CREATED_SAFE: { + event: GTM_EVENT.META, + action: 'Created Safe', + }, + GET_STARTED: { + event: GTM_EVENT.CLICK, + action: 'Load Safe', + }, + GO_TO_SAFE: { + event: GTM_EVENT.CLICK, + action: 'Open Safe', + }, +} + +export const CREATE_SAFE_CATEGORY = 'create-safe' +export const CREATE_SAFE_EVENTS = addEventCategory(CREATE_SAFE, CREATE_SAFE_CATEGORY) + +const LOAD_SAFE = { + LOAD_BUTTON: { + event: GTM_EVENT.CLICK, + action: 'Open stepper', + }, + NAME_SAFE: { + event: GTM_EVENT.META, + action: 'Name Safe', + }, + OWNERS: { + event: GTM_EVENT.META, + action: 'Owners', + }, + THRESHOLD: { + event: GTM_EVENT.META, + action: 'Threshold', + }, + GO_TO_SAFE: { + event: GTM_EVENT.CLICK, + action: 'Open Safe', + }, +} + +export const LOAD_SAFE_CATEGORY = 'load-safe' +export const LOAD_SAFE_EVENTS = addEventCategory(LOAD_SAFE, LOAD_SAFE_CATEGORY) diff --git a/src/utils/events/modals.ts b/src/utils/events/modals.ts new file mode 100644 index 0000000000..adb889698f --- /dev/null +++ b/src/utils/events/modals.ts @@ -0,0 +1,55 @@ +import { GTM_EVENT } from 'src/utils/googleTagManager' +import { addEventCategory, TrackEvent } from 'src/utils/events/utils' + +const MODALS = { + SEND_FUNDS: { + event: GTM_EVENT.CLICK, + action: 'Send funds', + }, + SEND_COLLECTIBLE: { + event: GTM_EVENT.CLICK, + action: 'Send collectible', + }, + CONTRACT_INTERACTION: { + event: GTM_EVENT.CLICK, + action: 'Contract interaction', + }, + ADVANCED_PARAMS: { + event: GTM_EVENT.CLICK, + action: 'Advanced params', + }, + EDIT_ADVANCED_PARAMS: { + event: GTM_EVENT.CLICK, + action: 'Edit advanced params', + }, + ESTIMATION: { + event: GTM_EVENT.CLICK, + action: 'Estimation', + }, + EDIT_ESTIMATION: { + event: GTM_EVENT.CLICK, + action: 'Edit estimation', + }, + EXECUTE_TX: { + event: GTM_EVENT.CLICK, + action: 'Execute transaction', + }, + USE_SPENDING_LIMIT: { + event: GTM_EVENT.META, + action: 'Use spending limit', + }, +} + +const MODALS_CATEGORY = 'modals' + +// Modal.Footer.Buttons buttons automatically generate events from button strings +// which can be used as reference for 'finalising' a modal form, e.g. +// we do not need to track: add owner => owner added, just add owner + +export const getModalEvent = (action: string, event = GTM_EVENT.CLICK): TrackEvent => ({ + event, + category: MODALS_CATEGORY, + action, +}) + +export const MODALS_EVENTS = addEventCategory(MODALS, MODALS_CATEGORY) diff --git a/src/utils/events/overview.ts b/src/utils/events/overview.ts new file mode 100644 index 0000000000..833079bb55 --- /dev/null +++ b/src/utils/events/overview.ts @@ -0,0 +1,64 @@ +import { GTM_EVENT } from 'src/utils/googleTagManager' +import { addEventCategory } from 'src/utils/events/utils' + +const OVERVIEW = { + HOME: { + event: GTM_EVENT.CLICK, + action: 'Go to Welcome page', + }, + IPHONE_APP_BUTTON: { + event: GTM_EVENT.CLICK, + action: 'Download App', + }, + OPEN_ONBOARD: { + event: GTM_EVENT.CLICK, + action: 'Open wallet modal', + }, + SWITCH_NETWORK: { + event: GTM_EVENT.CLICK, + action: 'Switch network', + }, + SHOW_QR: { + event: GTM_EVENT.CLICK, + action: 'Show Safe QR code', + }, + COPY_ADDRESS: { + event: GTM_EVENT.CLICK, + action: 'Copy Safe address', + }, + OPEN_EXPLORER: { + event: GTM_EVENT.CLICK, + action: 'Open Safe on block explorer', + }, + ADD_SAFE: { + event: GTM_EVENT.CLICK, + action: 'Add Safe', + }, + SIDEBAR: { + event: GTM_EVENT.CLICK, + action: 'Sidebar', + }, + ADDED_SAFES_ON_NETWORK: { + event: GTM_EVENT.META, + action: 'Added Safes on', // Safe name is appended in trackEvent + }, + WHATS_NEW: { + event: GTM_EVENT.CLICK, + action: "Open What's New", + }, + HELP_CENTER: { + event: GTM_EVENT.CLICK, + action: 'Open Help Center', + }, + OPEN_INTERCOM: { + event: GTM_EVENT.CLICK, + action: 'Open Intercom', + }, + NEW_TRANSACTION: { + event: GTM_EVENT.CLICK, + action: 'New transaction', + }, +} + +export const OVERVIEW_CATEGORY = 'overview' +export const OVERVIEW_EVENTS = addEventCategory(OVERVIEW, OVERVIEW_CATEGORY) diff --git a/src/utils/events/safeApps.ts b/src/utils/events/safeApps.ts new file mode 100644 index 0000000000..726e7fde77 --- /dev/null +++ b/src/utils/events/safeApps.ts @@ -0,0 +1,28 @@ +import { GTM_EVENT } from 'src/utils/googleTagManager' +import { addEventCategory } from 'src/utils/events/utils' + +const SAFE_APPS = { + OPEN_APP: { + event: GTM_EVENT.CLICK, + action: 'Open Safe App', + }, + PIN: { + event: GTM_EVENT.CLICK, + action: 'Pin Safe App', + }, + UNPIN: { + event: GTM_EVENT.CLICK, + action: 'Unpin Safe App', + }, + SEARCH: { + event: GTM_EVENT.META, + action: 'Search for Safe App', + }, + ADD_CUSTOM_APP: { + event: GTM_EVENT.META, + action: 'Add custom Safe App', + }, +} + +const SAFE_APPS_CATEGORY = 'safe-apps' +export const SAFE_APPS_EVENTS = addEventCategory(SAFE_APPS, SAFE_APPS_CATEGORY) diff --git a/src/utils/events/settings.ts b/src/utils/events/settings.ts new file mode 100644 index 0000000000..c4d8a159bf --- /dev/null +++ b/src/utils/events/settings.ts @@ -0,0 +1,85 @@ +import { GTM_EVENT } from 'src/utils/googleTagManager' +import { addEventCategory } from 'src/utils/events/utils' + +const SETTINGS = { + DETAILS: { + SAFE_NAME: { + event: GTM_EVENT.CLICK, + action: 'Name Safe', + }, + }, + APPEARANCE: { + PREPEND_PREFIXES: { + event: GTM_EVENT.CLICK, + action: 'Prepend EIP-3770 prefixes', + }, + COPY_PREFIXES: { + event: GTM_EVENT.CLICK, + action: 'Copy EIP-3770 prefixes', + }, + INVERT_COLORS: { + event: GTM_EVENT.CLICK, + action: 'Invert colors', + }, + }, + OWNERS: { + REMOVE_SAFE: { + event: GTM_EVENT.CLICK, + action: 'Remove Safe', + }, + ADD_OWNER: { + event: GTM_EVENT.CLICK, + action: 'Add owner', + }, + EDIT_OWNER: { + event: GTM_EVENT.CLICK, + action: 'Edit owner', + }, + REPLACE_OWNER: { + event: GTM_EVENT.CLICK, + action: 'Replace owner', + }, + REMOVE_OWNER: { + event: GTM_EVENT.CLICK, + action: 'Remove owner', + }, + }, + THRESHOLD: { + CHANGE: { + event: GTM_EVENT.CLICK, + action: 'Change threshold', + }, + OWNERS: { + event: GTM_EVENT.META, + action: 'Owners', + }, + THRESHOLD: { + event: GTM_EVENT.META, + action: 'Threshold', + }, + }, + SPENDING_LIMIT: { + NEW_LIMIT: { + event: GTM_EVENT.CLICK, + action: 'New spending limit', + }, + RESET_PERIOD: { + event: GTM_EVENT.META, + action: 'Spending limit reset period', + }, + REMOVE_LIMIT: { + event: GTM_EVENT.CLICK, + action: 'Remove spending limit', + }, + LIMIT_REMOVED: { + event: GTM_EVENT.CLICK, + action: 'Spending limit removed', + }, + }, +} + +const SETTINGS_CATEGORY = 'settings' +export const SETTINGS_EVENTS: Record> = Object.entries(SETTINGS).reduce( + (settings, [key, value]) => ({ ...settings, [key]: addEventCategory(value, SETTINGS_CATEGORY) }), + {}, +) diff --git a/src/utils/events/txList.ts b/src/utils/events/txList.ts new file mode 100644 index 0000000000..ace7f1df85 --- /dev/null +++ b/src/utils/events/txList.ts @@ -0,0 +1,32 @@ +import { GTM_EVENT } from 'src/utils/googleTagManager' +import { addEventCategory } from 'src/utils/events/utils' + +const TX_LIST = { + QUEUED_TXS: { + event: GTM_EVENT.META, + action: 'Queued transactions', + }, + ADDRESS_BOOK: { + event: GTM_EVENT.CLICK, + action: 'Update address book', + }, + COPY_DEEPLINK: { + event: GTM_EVENT.CLICK, + action: 'Copy deeplink', + }, + CONFIRM: { + event: GTM_EVENT.CLICK, + action: 'Confirm transaction', + }, + EXECUTE: { + event: GTM_EVENT.CLICK, + action: 'Execute transaction', + }, + REJECT: { + event: GTM_EVENT.CLICK, + action: 'Reject transaction', + }, +} + +const TX_LIST_CATEGORY = 'tx-list' +export const TX_LIST_EVENTS = addEventCategory(TX_LIST, TX_LIST_CATEGORY) diff --git a/src/utils/events/utils.ts b/src/utils/events/utils.ts new file mode 100644 index 0000000000..0787cc37d5 --- /dev/null +++ b/src/utils/events/utils.ts @@ -0,0 +1,10 @@ +import { trackEvent } from 'src/utils/googleTagManager' + +export type TrackEvent = Parameters[0] + +export const addEventCategory = ( + events: Record>, + category: string, +): Record => { + return Object.entries(events).reduce((events, [key, value]) => ({ ...events, [key]: { ...value, category } }), {}) +} diff --git a/src/utils/events/wallet.ts b/src/utils/events/wallet.ts new file mode 100644 index 0000000000..6353cfcd4a --- /dev/null +++ b/src/utils/events/wallet.ts @@ -0,0 +1,12 @@ +import { GTM_EVENT } from 'src/utils/googleTagManager' +import { addEventCategory } from 'src/utils/events/utils' + +const WALLET = { + CONNECT: { + event: GTM_EVENT.META, + action: 'Connect wallet', + }, +} + +const WALLET_CATEGORY = 'wallet' +export const WALLET_EVENTS = addEventCategory(WALLET, WALLET_CATEGORY) diff --git a/src/utils/googleAnalytics.ts b/src/utils/googleAnalytics.ts deleted file mode 100644 index 76a91c490f..0000000000 --- a/src/utils/googleAnalytics.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { useCallback, useEffect, useState } from 'react' -import ReactGA, { EventArgs } from 'react-ga' -import { useSelector } from 'react-redux' -import { getChainInfo, _getChainId } from 'src/config' - -import { currentChainId } from 'src/logic/config/store/selectors' -import { COOKIES_KEY, BannerCookiesType } from 'src/logic/cookies/model/cookie' -import { Cookie, loadFromCookie, removeCookies } from 'src/logic/cookies/utils' -import { GOOGLE_ANALYTICS_ID, IS_PRODUCTION } from './constants' -import { capitalize } from './css' - -const USER_EVENT = 'User' -export const USER_EVENTS: Record = { - CREATE_SAFE: { - category: USER_EVENT, - action: 'Created a Safe', - }, -} - -const WALLET_EVENT = 'Wallets' -export const WALLET_EVENTS: Record = { - CONNECT_WALLET: { category: WALLET_EVENT, action: 'Connect a Wallet' }, -} - -const SAFE_EVENT = 'Safe Navigation' -export const SAFE_EVENTS: Record = { - SIDEBAR: { - category: SAFE_EVENT, - action: 'Safe List Sidebar', - }, - ADDRESS_BOOK: { - category: SAFE_EVENT, - action: 'Address Book', - }, - SAFE_APP: { - category: SAFE_EVENT, - action: 'Apps', - }, - COINS: { - category: SAFE_EVENT, - action: 'Coins', - }, - COLLECTIBLES: { - category: SAFE_EVENT, - action: 'Collectibles', - }, - SETTINGS: { - category: SAFE_EVENT, - action: 'Settings', - }, - TRANSACTIONS: { - category: SAFE_EVENT, - action: 'Transactions', - }, -} - -const SAFE_APP_EVENT = 'Safe App' -export const SAFE_APP_EVENTS: Record = { - PIN: { - category: SAFE_APP_EVENT, - action: 'Unpin', - }, - UNPIN: { - category: SAFE_APP_EVENT, - action: 'Pin', - }, -} - -export const SETTINGS_EVENTS: Record = { - ADVANCED: { ...SAFE_EVENTS.SETTINGS, label: 'Advanced' }, - APPEARANCE: { ...SAFE_EVENTS.SETTINGS, label: 'Appearance' }, - DETAILS: { ...SAFE_EVENTS.SETTINGS, label: 'Details' }, - OWNERS: { ...SAFE_EVENTS.SETTINGS, label: 'Owners' }, -} - -const GA_COOKIE_LIST: Cookie[] = [ - { name: '_ga', path: '/' }, - { name: '_gat', path: '/' }, - { name: '_gid', path: '/' }, -] - -const shouldUseGoogleAnalytics = IS_PRODUCTION - -export const trackAnalyticsEvent = (event: Parameters[0]): void => { - const { chainName } = getChainInfo() - - // action, category, label, etc. => eventAction, eventCategory, eventLabel, etc. - const fieldsObject: Parameters[1] = Object.entries(event).reduce( - (acc, [key, value]) => ({ ...acc, [`event${capitalize(key)}`]: value }), - { hitType: 'event', chainName }, - ) - - return shouldUseGoogleAnalytics - ? ReactGA.ga('send', fieldsObject) - : console.info('[GA] - Event:', { ...event, chainName }) -} -const trackAnalyticsPage: typeof ReactGA.pageview = (...args) => { - return shouldUseGoogleAnalytics ? ReactGA.pageview(...args) : console.info('[GA] - Page:', ...args) -} - -let analyticsLoaded = false -export const loadGoogleAnalytics = (): void => { - if (analyticsLoaded) { - return - } - - console.info( - shouldUseGoogleAnalytics - ? 'Loading Google Analytics...' - : 'Google Analytics will not load in the development environment, but instead log.', - ) - - const customDimensions: ReactGA.FieldsObject = { - anonymizeIp: true, - appName: `Gnosis Safe Web`, - appVersion: process.env.REACT_APP_APP_VERSION, - dimension1: _getChainId(), - } - - const gaTrackingId = GOOGLE_ANALYTICS_ID - - if (shouldUseGoogleAnalytics) { - if (!gaTrackingId) { - console.error('In order to use Google Analytics you need to add a tracking ID.') - } else { - ReactGA.initialize(gaTrackingId) - ReactGA.set(customDimensions) - } - } else { - console.info('[GA] - Custom dimensions:', customDimensions) - } - - analyticsLoaded = true -} - -export const unloadGoogleAnalytics = (): void => { - if (analyticsLoaded) { - removeCookies(GA_COOKIE_LIST) - } -} - -type UseAnalyticsResponse = { - trackPage: (path: string) => void - trackEvent: (event: EventArgs) => void -} - -export const useAnalytics = (): UseAnalyticsResponse => { - const [analyticsAllowed, setAnalyticsAllowed] = useState(false) - const chainId = useSelector(currentChainId) - - useEffect(() => { - if (analyticsAllowed && analyticsLoaded) { - ReactGA.set({ dimension1: chainId }) - } - }, [chainId, analyticsAllowed]) - - useEffect(() => { - const cookiesState = loadFromCookie(COOKIES_KEY) - if (cookiesState) { - const { acceptedAnalytics } = cookiesState - setAnalyticsAllowed(acceptedAnalytics) - } - }, []) - - const trackPage = useCallback( - (page) => { - if (analyticsAllowed && analyticsLoaded) { - trackAnalyticsPage(page) - } - }, - [analyticsAllowed], - ) - - const trackEvent = useCallback( - (event: EventArgs) => { - if (analyticsAllowed && analyticsLoaded) { - trackAnalyticsEvent(event) - } - }, - [analyticsAllowed], - ) - - return { trackPage, trackEvent } -} diff --git a/src/utils/googleTagManager.ts b/src/utils/googleTagManager.ts new file mode 100644 index 0000000000..154f7633b5 --- /dev/null +++ b/src/utils/googleTagManager.ts @@ -0,0 +1,173 @@ +import { useEffect } from 'react' +import TagManager, { TagManagerArgs } from 'react-gtm-module' +import { matchPath } from 'react-router-dom' +import { Location } from 'history' +import { useSelector } from 'react-redux' + +import { ADDRESSED_ROUTE, history, SAFE_ADDRESS_SLUG, SAFE_ROUTES, TRANSACTION_ID_SLUG } from 'src/routes/routes' +import { + GOOGLE_TAG_MANAGER_ID, + GOOGLE_TAG_MANAGER_AUTH_LIVE, + GOOGLE_TAG_MANAGER_AUTH_LATEST, + IS_PRODUCTION, + GOOGLE_TAG_MANAGER_DEVELOPMENT_AUTH, +} from 'src/utils/constants' +import { _getChainId } from 'src/config' +import { currentChainId } from 'src/logic/config/store/selectors' +import { Cookie, removeCookies } from 'src/logic/cookies/utils' + +export const getAnonymizedLocation = ({ pathname, search, hash }: Location = history.location): string => { + const ANON_SAFE_ADDRESS = 'SAFE_ADDRESS' + const ANON_TX_ID = 'TRANSACTION_ID' + + let anonPathname = pathname + + // Anonymize safe address + const safeAddressMatch = matchPath(pathname, { path: ADDRESSED_ROUTE }) + if (safeAddressMatch) { + anonPathname = anonPathname.replace(safeAddressMatch.params[SAFE_ADDRESS_SLUG], ANON_SAFE_ADDRESS) + } + + // Anonymise transaction id + const txIdMatch = matchPath(pathname, { path: SAFE_ROUTES.TRANSACTIONS_SINGULAR }) + if (txIdMatch) { + anonPathname = anonPathname.replace(txIdMatch.params[TRANSACTION_ID_SLUG], ANON_TX_ID) + } + + return anonPathname + search + hash +} + +type GTMEnvironment = 'LIVE' | 'LATEST' | 'DEVELOPMENT' +type GTMEnvironmentArgs = Required> + +const GTM_ENV_AUTH: Record = { + LIVE: { + auth: GOOGLE_TAG_MANAGER_AUTH_LIVE, + preview: 'env-1', + }, + LATEST: { + auth: GOOGLE_TAG_MANAGER_AUTH_LATEST, + preview: 'env-2', + }, + DEVELOPMENT: { + auth: GOOGLE_TAG_MANAGER_DEVELOPMENT_AUTH, + preview: 'env-3', + }, +} + +export enum GTM_EVENT { + PAGEVIEW = 'pageview', + CLICK = 'customClick', + META = 'metadata', +} + +let currentPathname = history.location.pathname +export const loadGoogleTagManager = (): void => { + const GTM_ENVIRONMENT = IS_PRODUCTION ? GTM_ENV_AUTH.LIVE : GTM_ENV_AUTH.DEVELOPMENT + + if (!GOOGLE_TAG_MANAGER_ID || !GTM_ENVIRONMENT.auth) { + console.warn('[GTM] - Unable to initialize Google Tag Manager. `id` or `gtm_auth` missing.') + return + } + + // Cache name to prevent tracking of same page + currentPathname = history.location.pathname + + const page = getAnonymizedLocation() + + TagManager.initialize({ + gtmId: GOOGLE_TAG_MANAGER_ID, + ...GTM_ENVIRONMENT, + dataLayer: { + // Must emit (custom) event in order to trigger page tracking + event: GTM_EVENT.PAGEVIEW, + chainId: _getChainId(), + page, + }, + }) +} + +export const unloadGoogleTagManager = (): void => { + if (!window.dataLayer) { + return + } + + const GOOGLE_ANALYTICS_COOKIE_LIST: Cookie[] = [ + { name: '_ga', path: '/' }, + { name: '_gat', path: '/' }, + { name: '_gid', path: '/' }, + ] + + removeCookies(GOOGLE_ANALYTICS_COOKIE_LIST) +} + +export const usePageTracking = (): void => { + const chainId = useSelector(currentChainId) + + useEffect(() => { + const unsubscribe = history.listen((location) => { + if (location.pathname === currentPathname) { + return + } + + currentPathname = location.pathname + + TagManager.dataLayer({ + dataLayer: { + // Must emit (custom) event in order to trigger page tracking + event: GTM_EVENT.PAGEVIEW, + chainId, + page: getAnonymizedLocation(location), + // Clear dataLayer + eventCategory: undefined, + eventAction: undefined, + eventLabel: undefined, + }, + }) + }) + + return () => { + unsubscribe() + } + }, [chainId]) +} + +export type EventLabel = string | number | boolean | null +const tryParse = (value?: EventLabel): EventLabel | undefined => { + if (typeof value !== 'string') { + return value + } + try { + return JSON.parse(value) + } catch { + return value + } +} + +export const trackEvent = ({ + event, + category, + action, + label, +}: { + event: GTM_EVENT + category: string + action: string + label?: EventLabel +}): void => { + const dataLayer = { + event, + chainId: _getChainId(), + eventCategory: category, + eventAction: action, + eventLabel: tryParse(label), + } + + if (!IS_PRODUCTION) { + console.info('[GTM]', dataLayer) + } + + TagManager.dataLayer({ + dataLayer, + }) +} diff --git a/src/utils/intercom.ts b/src/utils/intercom.ts index 4d1bf25424..81892b31ca 100644 --- a/src/utils/intercom.ts +++ b/src/utils/intercom.ts @@ -3,6 +3,8 @@ import { CookieAttributes } from 'js-cookie' import { COOKIES_KEY_INTERCOM, IntercomCookieType } from 'src/logic/cookies/model/cookie' import { loadFromCookie, saveCookie } from 'src/logic/cookies/utils' import { INTERCOM_ID } from 'src/utils/constants' +import { trackEvent } from 'src/utils/googleTagManager' +import { OVERVIEW_EVENTS } from 'src/utils/events/overview' let intercomLoaded = false @@ -46,6 +48,9 @@ export const loadIntercom = (): void => { user_id: intercomUserId, }) intercomLoaded = true + ;(window as any).Intercom('onShow', () => { + trackEvent(OVERVIEW_EVENTS.OPEN_INTERCOM) + }) } } diff --git a/yarn.lock b/yarn.lock index 02bd8a4136..fa36a18719 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3173,6 +3173,11 @@ dependencies: "@types/react" "*" +"@types/react-gtm-module@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@types/react-gtm-module/-/react-gtm-module-2.0.1.tgz#b2c6cd14ec251d6ae7fa576edf1d43825908a378" + integrity sha512-T/DN9gAbCYk5wJ1nxf4pSwmXz4d1iVjM++OoG+mwMfz9STMAotGjSb65gJHOS5bPvl6vLSsJnuC+y/43OQrltg== + "@types/react-modal@^3.12.0": version "3.13.1" resolved "https://registry.yarnpkg.com/@types/react-modal/-/react-modal-3.13.1.tgz#5b9845c205fccc85d9a77966b6e16dc70a60825a" @@ -14386,10 +14391,10 @@ react-final-form@^6.5.3: dependencies: "@babel/runtime" "^7.15.4" -react-ga@3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/react-ga/-/react-ga-3.3.0.tgz#c91f407198adcb3b49e2bc5c12b3fe460039b3ca" - integrity sha512-o8RScHj6Lb8cwy3GMrVH6NJvL+y0zpJvKtc0+wmH7Bt23rszJmnqEQxRbyrqUzk9DTJIHoP42bfO5rswC9SWBQ== +react-gtm-module@^2.0.11: + version "2.0.11" + resolved "https://registry.yarnpkg.com/react-gtm-module/-/react-gtm-module-2.0.11.tgz#14484dac8257acd93614e347c32da9c5ac524206" + integrity sha512-8gyj4TTxeP7eEyc2QKawEuQoAZdjKvMY4pgWfycGmqGByhs17fR+zEBs0JUDq4US/l+vbTl+6zvUIx27iDo/Vw== react-intersection-observer@^8.32.0: version "8.32.1"