diff --git a/app/Analytics.tsx b/app/Analytics.tsx index 0f79e3cfe..c2db70247 100644 --- a/app/Analytics.tsx +++ b/app/Analytics.tsx @@ -1,7 +1,7 @@ 'use client'; import { usePathname } from 'lib/i18n/navigation'; -import { init, track } from 'lib/utils/analytics'; +import analytics from 'lib/utils/analytics'; import Script from 'next/script'; import { useEffect } from 'react'; @@ -9,12 +9,12 @@ const Analytics = () => { const path = usePathname(); useEffect(() => { - init(); + analytics.init(); }, []); useEffect(() => { if (!path) return; - track('Viewed Page', { path }); + analytics.track('Viewed Page', { path }); }, [path]); // SimpleAnalytics diff --git a/components/common/Logo.tsx b/components/common/Logo.tsx index fc309c1df..5b322684d 100644 --- a/components/common/Logo.tsx +++ b/components/common/Logo.tsx @@ -1,7 +1,7 @@ 'use client'; import Image from 'next/image'; -import { useState } from 'react'; +import { memo, useState } from 'react'; import { twMerge } from 'tailwind-merge'; import PlaceholderIcon from './PlaceholderIcon'; @@ -54,4 +54,4 @@ const Logo = ({ src, alt, size, square, border, className }: Props) => { ); }; -export default Logo; +export default memo(Logo); diff --git a/components/common/donate/DonateButton.tsx b/components/common/donate/DonateButton.tsx index 4e296cfb6..18e7735d5 100644 --- a/components/common/donate/DonateButton.tsx +++ b/components/common/donate/DonateButton.tsx @@ -16,21 +16,13 @@ const DonateButton = ({ size, style, className, type }: Props) => { const t = useTranslations(); const [open, setOpen] = useState(false); - const handleOpen = () => { - setOpen(true); - }; - - const handleClose = () => { - setOpen(false); - }; - return ( <> - - (open ? handleOpen() : handleClose())} type={type} /> + ); }; diff --git a/components/common/donate/DonateModal.tsx b/components/common/donate/DonateModal.tsx index e0bbd553f..dfe6fa7af 100644 --- a/components/common/donate/DonateModal.tsx +++ b/components/common/donate/DonateModal.tsx @@ -5,7 +5,7 @@ import Button from 'components/common/Button'; import Modal from 'components/common/Modal'; import { useDonate } from 'lib/hooks/ethereum/useDonate'; import { useTranslations } from 'next-intl'; -import { useEffect, useState } from 'react'; +import { memo, useEffect, useState } from 'react'; import { useAsyncCallback } from 'react-async-hook'; import { useChainId } from 'wagmi'; import Input from '../Input'; @@ -79,4 +79,4 @@ const DonateModal = ({ open, setOpen, type }: Props) => { ); }; -export default DonateModal; +export default memo(DonateModal); diff --git a/components/exploits/ExploitChecker.tsx b/components/exploits/ExploitChecker.tsx index 0dc3f8888..e810e8be3 100644 --- a/components/exploits/ExploitChecker.tsx +++ b/components/exploits/ExploitChecker.tsx @@ -10,7 +10,7 @@ import { } from 'lib/hooks/page-context/AddressPageContext'; import { isNullish } from 'lib/utils'; import { getAllowanceKey } from 'lib/utils/allowances'; -import { track } from 'lib/utils/analytics'; +import analytics from 'lib/utils/analytics'; import { getEventKey } from 'lib/utils/events'; import { type Exploit, getExploitStatus } from 'lib/utils/exploits'; import ExploitStatus from './ExploitStatus'; @@ -29,7 +29,7 @@ const ExploitChecker = ({ exploit }: Props) => { queryKey: ['exploit-status', exploit.slug, allowances?.map(getAllowanceKey), events?.map(getEventKey)], queryFn: () => { const status = getExploitStatus(events!, allowances!, exploit); - track('Exploit Checked', { exploit: exploit.slug, account: address, chainId: selectedChainId, status }); + analytics.track('Exploit Checked', { exploit: exploit.slug, account: address, chainId: selectedChainId, status }); return status; }, enabled: !isNullish(address) && !isNullish(events) && !isNullish(allowances) && !isNullish(selectedChainId), diff --git a/components/footer/ColorThemeSelect.tsx b/components/footer/ColorThemeSelect.tsx index 1aab202b4..c0b299859 100644 --- a/components/footer/ColorThemeSelect.tsx +++ b/components/footer/ColorThemeSelect.tsx @@ -4,7 +4,7 @@ import { ComputerDesktopIcon, MoonIcon, SunIcon } from '@heroicons/react/24/outl import Select from 'components/common/select/Select'; import { useColorTheme } from 'lib/hooks/useColorTheme'; import { useMounted } from 'lib/hooks/useMounted'; -import { track } from 'lib/utils/analytics'; +import analytics from 'lib/utils/analytics'; import { useTranslations } from 'next-intl'; const ColorThemeSelect = () => { @@ -19,7 +19,7 @@ const ColorThemeSelect = () => { ] as const; const selectTheme = (option: (typeof options)[number]) => { - track('Changed Color Theme', { theme: option.value }); + analytics.track('Changed Color Theme', { theme: option.value }); setTheme(option.value); }; diff --git a/components/footer/LanguageSelect.tsx b/components/footer/LanguageSelect.tsx index e2363ea5a..1f06f6f30 100644 --- a/components/footer/LanguageSelect.tsx +++ b/components/footer/LanguageSelect.tsx @@ -5,7 +5,7 @@ import Select from 'components/common/select/Select'; import type { Locale } from 'lib/i18n/config'; import { useCsrRouter } from 'lib/i18n/csr-navigation'; import { usePathname } from 'lib/i18n/navigation'; -import { track } from 'lib/utils/analytics'; +import analytics from 'lib/utils/analytics'; import { useLocale } from 'next-intl'; import type { FormatOptionLabelMeta } from 'react-select'; @@ -36,7 +36,7 @@ const LanguageSelect = () => { const selectLanguage = (option: Option) => { const newLocale = option.value; - track('Changed language', { from: locale, to: newLocale }); + analytics.track('Changed language', { from: locale, to: newLocale }); router.replace(path, { locale: newLocale, scroll: false, showProgress: false, retainSearchParams: ['chainId'] }); persistLocaleCookie(newLocale); }; diff --git a/components/header/SearchBar.tsx b/components/header/SearchBar.tsx index f57f89e5c..840772670 100644 --- a/components/header/SearchBar.tsx +++ b/components/header/SearchBar.tsx @@ -4,7 +4,7 @@ import AddressSearchBox from 'components/common/AddressSearchBox'; import Button from 'components/common/Button'; import { useCsrRouter } from 'lib/i18n/csr-navigation'; import { useTranslations } from 'next-intl'; -import { useRef, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { twMerge } from 'tailwind-merge'; import { useAccount } from 'wagmi'; @@ -16,21 +16,21 @@ const SearchBar = () => { const { address } = useAccount(); const timerRef = useRef(); - const onFocus = () => { + const onFocus = useCallback(() => { clearTimeout(timerRef.current); setIsFocused(true); - }; + }, []); - const onBlur = () => { + const onBlur = useCallback(() => { timerRef.current = setTimeout(() => setIsFocused(false), 200); - }; + }, []); - const onClick = () => { + const onClick = useCallback(() => { if (address) { setValue(address); router.push(`/address/${address}`, { retainSearchParams: ['chainId'] }); } - }; + }, [address, router]); return (
diff --git a/components/landing/DemoVideo.tsx b/components/landing/DemoVideo.tsx index 67607dd2c..1a73b97a8 100644 --- a/components/landing/DemoVideo.tsx +++ b/components/landing/DemoVideo.tsx @@ -6,10 +6,11 @@ const DemoVideo = () => { controls muted loop - preload="metadata" + preload="metadata" // Preload the video fully for faster LCP playsInline + poster="/assets/images/thumbnail/demo-thumbnail.webp" > - + ); }; diff --git a/components/signatures/cells/CancelMarketplaceCell.tsx b/components/signatures/cells/CancelMarketplaceCell.tsx index 2476d2ff1..cf942c35d 100644 --- a/components/signatures/cells/CancelMarketplaceCell.tsx +++ b/components/signatures/cells/CancelMarketplaceCell.tsx @@ -3,7 +3,7 @@ import { useHandleTransaction } from 'lib/hooks/ethereum/useHandleTransaction'; import { useAddressPageContext } from 'lib/hooks/page-context/AddressPageContext'; import { type Marketplace, type OnCancel, type TransactionSubmitted, TransactionType } from 'lib/interfaces'; import { waitForTransactionConfirmation } from 'lib/utils'; -import { track } from 'lib/utils/analytics'; +import analytics from 'lib/utils/analytics'; import { usePublicClient, useWalletClient } from 'wagmi'; import CancelCell from './CancelCell'; @@ -21,7 +21,7 @@ const CancelMarketplaceCell = ({ marketplace, onCancel }: Props) => { const sendCancelTransaction = async (): Promise => { const hash = await marketplace?.cancelSignatures(walletClient!); - track('Cancelled Marketplace Signatures', { + analytics.track('Cancelled Marketplace Signatures', { chainId: selectedChainId, account: address, marketplace: marketplace.name, diff --git a/components/signatures/cells/CancelPermitCell.tsx b/components/signatures/cells/CancelPermitCell.tsx index d846193d7..11ebf02f8 100644 --- a/components/signatures/cells/CancelPermitCell.tsx +++ b/components/signatures/cells/CancelPermitCell.tsx @@ -4,7 +4,7 @@ import { useHandleTransaction } from 'lib/hooks/ethereum/useHandleTransaction'; import { useAddressPageContext } from 'lib/hooks/page-context/AddressPageContext'; import { type OnCancel, type TransactionSubmitted, TransactionType } from 'lib/interfaces'; import { waitForTransactionConfirmation } from 'lib/utils'; -import { track } from 'lib/utils/analytics'; +import analytics from 'lib/utils/analytics'; import { permit } from 'lib/utils/permit'; import { type PermitTokenData, isErc721Contract } from 'lib/utils/tokens'; import { usePublicClient, useWalletClient } from 'wagmi'; @@ -25,7 +25,11 @@ const CancelPermitCell = ({ token, onCancel }: Props) => { if (isErc721Contract(token.contract)) throw new Error('Cannot cancel ERC721 tokens'); const hash = await permit(walletClient!, token.contract, DUMMY_ADDRESS, 0n); - track('Cancelled Permit Signatures', { chainId: selectedChainId, account: address, token: token.contract.address }); + analytics.track('Cancelled Permit Signatures', { + chainId: selectedChainId, + account: address, + token: token.contract.address, + }); const waitForConfirmation = async () => { // TODO: Deduplicate this with the CancelMarketplaceCell diff --git a/lib/hooks/ethereum/EthereumProvider.tsx b/lib/hooks/ethereum/EthereumProvider.tsx index ade262979..26205997b 100644 --- a/lib/hooks/ethereum/EthereumProvider.tsx +++ b/lib/hooks/ethereum/EthereumProvider.tsx @@ -4,7 +4,7 @@ import { abstractWalletConnector } from '@abstract-foundation/agw-react/connecto import { useCsrRouter } from 'lib/i18n/csr-navigation'; import { usePathname } from 'lib/i18n/navigation'; import { ORDERED_CHAINS, createViemPublicClientForChain, getViemChainConfig } from 'lib/utils/chains'; -import { type ReactNode, useEffect } from 'react'; +import { type ReactNode, memo, useEffect } from 'react'; import type { Chain } from 'viem'; import { WagmiProvider, createConfig, useAccount, useConnect } from 'wagmi'; import { coinbaseWallet, injected, safe, walletConnect } from 'wagmi/connectors'; @@ -50,7 +50,7 @@ export const EthereumProvider = ({ children }: Props) => { ); }; -const EthereumProviderChild = ({ children }: Props) => { +const EthereumProviderChild = memo(({ children }: Props) => { const { connectAsync, connectors } = useConnect(); const { connector, address } = useAccount(); const router = useCsrRouter(); @@ -105,7 +105,7 @@ const EthereumProviderChild = ({ children }: Props) => { }, [address]); return <>{children}; -}; +}); const isIframe = () => { return typeof window !== 'undefined' && window?.parent !== window; diff --git a/lib/hooks/ethereum/useAllowances.tsx b/lib/hooks/ethereum/useAllowances.tsx index a6498ce96..3559dcb53 100644 --- a/lib/hooks/ethereum/useAllowances.tsx +++ b/lib/hooks/ethereum/useAllowances.tsx @@ -9,7 +9,7 @@ import { getAllowancesFromEvents, stripAllowanceData, } from 'lib/utils/allowances'; -import { track } from 'lib/utils/analytics'; +import analytics from 'lib/utils/analytics'; import { type TimeLog, type TokenEvent, getEventKey } from 'lib/utils/events'; import { hasZeroBalance } from 'lib/utils/tokens'; import { useLayoutEffect, useState } from 'react'; @@ -30,7 +30,7 @@ export const useAllowances = (address: Address, events: TokenEvent[] | undefined queryKey: ['allowances', address, chainId, events?.map(getEventKey)], queryFn: async () => { const allowances = getAllowancesFromEvents(address, events!, publicClient, chainId); - track('Fetched Allowances', { account: address, chainId }); + analytics.track('Fetched Allowances', { account: address, chainId }); return allowances; }, // If events (transfers + approvals) don't change, derived allowances also shouldn't change, even if allowances diff --git a/lib/hooks/ethereum/useDonate.tsx b/lib/hooks/ethereum/useDonate.tsx index 3e532bf37..a1fc2cd84 100644 --- a/lib/hooks/ethereum/useDonate.tsx +++ b/lib/hooks/ethereum/useDonate.tsx @@ -4,7 +4,7 @@ import type { DonateButtonType } from 'components/common/donate/DonateModal'; import { DONATION_ADDRESS } from 'lib/constants'; import { type TransactionSubmitted, TransactionType } from 'lib/interfaces'; import { waitForTransactionConfirmation } from 'lib/utils'; -import { track } from 'lib/utils/analytics'; +import analytics from 'lib/utils/analytics'; import { type DocumentedChainId, getChainName, getChainNativeToken, getDefaultDonationAmount } from 'lib/utils/chains'; import { type SendTransactionParameters, parseEther } from 'viem'; import { usePublicClient, useWalletClient } from 'wagmi'; @@ -65,7 +65,7 @@ export const getTipSelection = (chainId: DocumentedChainId, amount: string) => { export const trackDonate = (chainId: DocumentedChainId, amount: string, type: DonateButtonType) => { if (!Number(amount)) return; - track('Donated', { + analytics.track('Donated', { chainId, chainName: getChainName(chainId), nativeToken: getChainNativeToken(chainId), diff --git a/lib/utils/allowances.ts b/lib/utils/allowances.ts index 515e203d8..3beb33db3 100644 --- a/lib/utils/allowances.ts +++ b/lib/utils/allowances.ts @@ -6,7 +6,7 @@ import { type TransactionSubmitted, TransactionType } from 'lib/interfaces'; import type { TransactionStore } from 'lib/stores/transaction-store'; import { type Address, type PublicClient, type WalletClient, type WriteContractParameters, formatUnits } from 'viem'; import { deduplicateArray, isNullish, waitForTransactionConfirmation, writeContractUnlessExcessiveGas } from '.'; -import { track } from './analytics'; +import analytics from './analytics'; import { isNetworkError, isRevertedError, isUserRejectionError, parseErrorMessage, stringifyError } from './errors'; import { type Erc20ApprovalEvent, @@ -514,7 +514,7 @@ export const prepareUpdateErc20Allowance = async ( export const trackRevokeTransaction = (allowance: TokenAllowanceData, newAmount?: string) => { if (isErc721Contract(allowance.contract)) { - track('Revoked ERC721 allowance', { + analytics.track('Revoked ERC721 allowance', { chainId: allowance.chainId, account: allowance.owner, spender: allowance.payload?.spender, @@ -525,7 +525,7 @@ export const trackRevokeTransaction = (allowance: TokenAllowanceData, newAmount? const isRevoke = !newAmount || newAmount === '0'; - track(isRevoke ? 'Revoked ERC20 allowance' : 'Updated ERC20 allowance', { + analytics.track(isRevoke ? 'Revoked ERC20 allowance' : 'Updated ERC20 allowance', { chainId: allowance.chainId, account: allowance.owner, spender: allowance.payload?.spender, diff --git a/lib/utils/analytics.ts b/lib/utils/analytics.ts index 4cc8faf2e..a820f520a 100644 --- a/lib/utils/analytics.ts +++ b/lib/utils/analytics.ts @@ -1,15 +1,26 @@ import mixpanel from 'mixpanel-browser'; -export const init = () => { - if (process.env.NEXT_PUBLIC_MIXPANEL_API_KEY) { - mixpanel.init(process.env.NEXT_PUBLIC_MIXPANEL_API_KEY, { ip: false }); - } -}; +const analytics = { + isInitialized: false, + // init only when first used + init() { + if (this.isInitialized) return; + const apiKey = process.env.NEXT_PUBLIC_MIXPANEL_API_KEY; + if (apiKey && typeof window !== 'undefined') { + mixpanel.init(apiKey, { ip: false }); + this.isInitialized = true; + } + }, -export const track = (eventName: string, eventProperties: any) => { - if (typeof window === 'undefined') return; + track(eventName: string, eventProperties?: Record) { + if (typeof window === 'undefined' || !process.env.NEXT_PUBLIC_MIXPANEL_API_KEY) return; + // lazy initialize if not already done + if (!this.isInitialized) { + this.init(); + } - if (process.env.NEXT_PUBLIC_MIXPANEL_API_KEY) { mixpanel.track(eventName, eventProperties); - } + }, }; + +export default analytics; diff --git a/lib/utils/batch-revoke.ts b/lib/utils/batch-revoke.ts index 214d1ff10..b2923f4e8 100644 --- a/lib/utils/batch-revoke.ts +++ b/lib/utils/batch-revoke.ts @@ -1,6 +1,6 @@ import { getTipSelection } from 'lib/hooks/ethereum/useDonate'; import type { TokenAllowanceData } from './allowances'; -import { track } from './analytics'; +import analytics from './analytics'; export type BatchType = 'eip5792' | 'queued'; @@ -11,7 +11,7 @@ export const trackBatchRevoke = ( tipAmount: string, batchType: BatchType, ) => { - track('Batch Revoked', { + analytics.track('Batch Revoked', { chainId, address, allowances: allowances.length, diff --git a/lib/utils/index.ts b/lib/utils/index.ts index 3a5fdbec1..c4ec984ed 100644 --- a/lib/utils/index.ts +++ b/lib/utils/index.ts @@ -17,7 +17,7 @@ import { pad, slice, } from 'viem'; -import { track } from './analytics'; +import analytics from './analytics'; import type { Log, TokenEvent } from './events'; export const assertFulfilled = (item: PromiseSettledResult): item is PromiseFulfilledResult => { @@ -126,7 +126,7 @@ export const throwIfExcessiveGas = (chainId: number, address: Address, estimated // Track excessive gas usage so we can blacklist tokens // TODO: Use a different tool than analytics for this - track('Excessive gas limit', { chainId, address, estimatedGas: estimatedGas.toString() }); + analytics.track('Excessive gas limit', { chainId, address, estimatedGas: estimatedGas.toString() }); throw new Error( 'This transaction has an excessive gas cost. It is most likely a spam token, so you do not need to revoke this approval.', diff --git a/lib/utils/risk.tsx b/lib/utils/risk.tsx index 2b5447836..34be59451 100644 --- a/lib/utils/risk.tsx +++ b/lib/utils/risk.tsx @@ -1,6 +1,6 @@ import { ExclamationCircleIcon, ExclamationTriangleIcon, InformationCircleIcon } from '@heroicons/react/24/solid'; import type { RiskFactor, RiskLevel } from 'lib/interfaces'; -import { track } from './analytics'; +import analytics from './analytics'; export const RiskFactorScore: Record = { allowlist: -100, @@ -18,7 +18,7 @@ export const RiskFactorScore: Record = { export const filterUnknownRiskFactors = (riskFactors: RiskFactor[]): RiskFactor[] => { return riskFactors.filter((riskFactor) => { if (RiskFactorScore[riskFactor.type] === undefined) { - track('Unknown Risk Factor', riskFactor); + analytics.track('Unknown Risk Factor', riskFactor); return false; } diff --git a/lib/utils/tokens.ts b/lib/utils/tokens.ts index c1c80461f..1b62f3ccb 100644 --- a/lib/utils/tokens.ts +++ b/lib/utils/tokens.ts @@ -14,7 +14,7 @@ import { toHex, } from 'viem'; import { deduplicateArray } from '.'; -import { track } from './analytics'; +import analytics from './analytics'; import { type TimeLog, type TokenEvent, TokenEventType, isApprovalTokenEvent, isTransferTokenEvent } from './events'; import { formatFixedPointBigInt } from './formatting'; import { withFallback } from './promises'; @@ -364,7 +364,7 @@ export const getPermitDomain = async (contract: Erc20TokenContract): Promise