diff --git a/packages/common/src/models/Analytics.ts b/packages/common/src/models/Analytics.ts index 8fbb1323fa5..674a91b8238 100644 --- a/packages/common/src/models/Analytics.ts +++ b/packages/common/src/models/Analytics.ts @@ -12,6 +12,7 @@ import { import { Nullable } from '~/utils/typeUtils' import { Chain } from './Chain' +import { LaunchCoinResponse, LaunchpadFormValues } from './Launchpad' import { PlaylistLibraryKind } from './PlaylistLibrary' import { PurchaseMethod } from './PurchaseContent' import { AccessConditions, TrackAccessType } from './Track' @@ -589,7 +590,37 @@ export enum Name { // Android App Lifecycle ANDROID_APP_RESTART_HEARTBEAT = 'Android App: Restart Due to Heartbeat', ANDROID_APP_RESTART_STALE = 'Android App: Restart Due to Stale Time', - ANDROID_APP_RESTART_FORCE_QUIT = 'Android App: Restart Due to Force Quit' + ANDROID_APP_RESTART_FORCE_QUIT = 'Android App: Restart Due to Force Quit', + + // Artist Coin Launchpad + LAUNCHPAD_SPLASH_GET_STARTED = 'Launchpad: Get Started Clicked', + LAUNCHPAD_SPLASH_LEARN_MORE_CLICKED = 'Launchpad: Learn More Clicked', + LAUNCHPAD_WALLET_CONNECT_SUCCESS = 'Launchpad: Wallet Connect Success', + LAUNCHPAD_WALLET_CONNECT_ERROR = 'Launchpad: Wallet Connect Error', + LAUNCHPAD_WALLET_INSUFFICIENT_BALANCE = 'Launchpad: Wallet Insufficient Balance', + LAUNCHPAD_SETUP_CONTINUE = 'Launchpad: Setup Continue', + LAUNCHPAD_FORM_BACK = 'Launchpad: Back To Previous Step', + LAUNCHPAD_FORM_INPUT_CHANGE = 'Launchpad: Form Input Change', + LAUNCHPAD_REVIEW_CONTINUE = 'Launchpad: Review Continue', + LAUNCHPAD_COIN_CREATION_STARTED = 'Launchpad: Coin Creation Started', + LAUNCHPAD_COIN_CREATION_SUCCESS = 'Launchpad: Coin Creation Success', + LAUNCHPAD_COIN_CREATION_FAILURE = 'Launchpad: Coin Creation Failure', + LAUNCHPAD_FIRST_BUY_STARTED = 'Launchpad: First Buy Started', + LAUNCHPAD_FIRST_BUY_SUCCESS = 'Launchpad: First Buy Success', + LAUNCHPAD_FIRST_BUY_FAILURE = 'Launchpad: First Buy Failure', + LAUNCHPAD_FIRST_BUY_RETRY = 'Launchpad: First Buy Retry', + LAUNCHPAD_FIRST_BUY_MAX_BUTTON = 'Launchpad: First Buy Max Button Clicked', + LAUNCHPAD_FIRST_BUY_QUOTE_RECEIVED = 'Launchpad: First Buy Quote Received', + LAUNCHPAD_BUY_MODAL_OPEN = 'Launchpad: Buy Audio Modal Open', + LAUNCHPAD_BUY_MODAL_CLOSE = 'Launchpad: Buy Audio Modal Close', + LAUNCHPAD_BUY_MODAL_SUBMIT = 'Launchpad: Buy Audio Modal Submit', + LAUNCHPAD_BUY_MODAL_SUCCESS = 'Launchpad: Buy Audio Modal Success', + LAUNCHPAD_BUY_MODAL_FAILURE = 'Launchpad: Buy Audio Modal Failure', + LAUNCHPAD_BUY_MODAL_CHANGE_CURRENCY = 'Launchpad: Buy Audio Modal Change Currency', + LAUNCHPAD_BUY_MODAL_FORM_CHANGE = 'Launchpad: Buy Audio Modal Form Change', + LAUNCHPAD_BUY_MODAL_MAX_BUTTON = 'Launchpad: Buy Audio Modal Max Button Clicked', + LAUNCHPAD_BUY_MODAL_CONTINUE = 'Launchpad: Buy Audio Modal Continue Clicked', + LAUNCHPAD_BUY_MODAL_BACK = 'Launchpad: Buy Audio Modal Back Clicked' } type PageView = { @@ -2851,6 +2882,163 @@ export type AndroidAppRestartForceQuit = { eventName: Name.ANDROID_APP_RESTART_FORCE_QUIT } +// Artist Coin Launchpad +export type LaunchpadSplashGetStarted = { + eventName: Name.LAUNCHPAD_SPLASH_GET_STARTED +} + +export type LaunchpadSplashLearnMoreClicked = { + eventName: Name.LAUNCHPAD_SPLASH_LEARN_MORE_CLICKED +} + +export type LaunchpadFormBack = { + eventName: Name.LAUNCHPAD_FORM_BACK +} + +export type LaunchpadFormInputChange = { + eventName: Name.LAUNCHPAD_FORM_INPUT_CHANGE + input: string + newValue: string +} + +export type LaunchpadWalletConnectSuccess = { + eventName: Name.LAUNCHPAD_WALLET_CONNECT_SUCCESS + walletAddress: string + walletSolBalance: number +} + +export type LaunchpadWalletConnectError = { + eventName: Name.LAUNCHPAD_WALLET_CONNECT_ERROR + error: string +} + +export type LaunchpadWalletInsufficientBalance = { + eventName: Name.LAUNCHPAD_WALLET_INSUFFICIENT_BALANCE + walletAddress: string + walletSolBalance: number +} + +export type LaunchpadSetupContinue = { + eventName: Name.LAUNCHPAD_SETUP_CONTINUE +} & Partial + +export type LaunchpadReviewContinue = { + eventName: Name.LAUNCHPAD_REVIEW_CONTINUE +} & Partial + +export type LaunchpadCoinCreationStarted = { + eventName: Name.LAUNCHPAD_COIN_CREATION_STARTED + coinName: string + coinSymbol: string + walletAddress: string + initialBuyAmount?: string +} + +export type LaunchpadCoinCreationSuccess = { + eventName: Name.LAUNCHPAD_COIN_CREATION_SUCCESS + launchCoinResponse: LaunchCoinResponse +} + +export type LaunchpadCoinCreationFailure = { + eventName: Name.LAUNCHPAD_COIN_CREATION_FAILURE + errorState: + | 'poolCreateFailed' + | 'sdkCoinFailed' + | 'firstBuyFailed' + | 'unknownError' + launchCoinResponse: LaunchCoinResponse +} + +export type LaunchpadFirstBuyStarted = { + eventName: Name.LAUNCHPAD_FIRST_BUY_STARTED + coinSymbol: string + mintAddress: string + payAmount: string + receiveAmount: string +} + +export type LaunchpadFirstBuySuccess = { + eventName: Name.LAUNCHPAD_FIRST_BUY_SUCCESS + coinSymbol: string + mintAddress: string + payAmount: string + receiveAmount: string +} + +export type LaunchpadFirstBuyFailure = { + eventName: Name.LAUNCHPAD_FIRST_BUY_FAILURE + coinSymbol: string + mintAddress: string + payAmount: string + error: string +} + +export type LaunchpadFirstBuyRetry = { + eventName: Name.LAUNCHPAD_FIRST_BUY_RETRY + launchCoinResponse: LaunchCoinResponse +} + +export type LaunchpadFirstBuyMaxButton = { + eventName: Name.LAUNCHPAD_FIRST_BUY_MAX_BUTTON + maxValue?: string +} & Partial + +export type LaunchpadFirstBuyQuoteReceived = { + eventName: Name.LAUNCHPAD_FIRST_BUY_QUOTE_RECEIVED + payAmount: string + receiveAmount: string + usdcValue: string +} + +export type LaunchpadBuyModalOpen = { + eventName: Name.LAUNCHPAD_BUY_MODAL_OPEN +} + +export type LaunchpadBuyModalClose = { + eventName: Name.LAUNCHPAD_BUY_MODAL_CLOSE +} + +export type LaunchpadBuyModalContinue = { + eventName: Name.LAUNCHPAD_BUY_MODAL_CONTINUE +} + +export type LaunchpadBuyModalBack = { + eventName: Name.LAUNCHPAD_BUY_MODAL_BACK +} + +export type LaunchpadBuyModalSubmit = { + eventName: Name.LAUNCHPAD_BUY_MODAL_SUBMIT + inputAmount: string + outputAmount: string + inputTokenSymbol: string + outputTokenSymbol: string + walletAddress: string +} + +export type LaunchpadBuyModalSuccess = { + eventName: Name.LAUNCHPAD_BUY_MODAL_SUCCESS +} + +export type LaunchpadBuyModalFailure = { + eventName: Name.LAUNCHPAD_BUY_MODAL_FAILURE + error: any +} + +export type LaunchpadBuyModalChangeCurrency = { + eventName: Name.LAUNCHPAD_BUY_MODAL_CHANGE_CURRENCY + newCurrencySymbol: string +} + +export type LaunchpadBuyModalFormChange = { + eventName: Name.LAUNCHPAD_BUY_MODAL_FORM_CHANGE + inputChanged: string + newValue: string +} + +export type LaunchpadBuyModalMaxButton = { + eventName: Name.LAUNCHPAD_BUY_MODAL_MAX_BUTTON +} + export type BaseAnalyticsEvent = { type: typeof ANALYTICS_TRACK_EVENT } export type AllTrackingEvents = @@ -3234,3 +3422,31 @@ export type AllTrackingEvents = | AndroidAppRestartHeartbeat | AndroidAppRestartStale | AndroidAppRestartForceQuit + | LaunchpadSplashGetStarted + | LaunchpadSplashLearnMoreClicked + | LaunchpadWalletConnectSuccess + | LaunchpadWalletInsufficientBalance + | LaunchpadSetupContinue + | LaunchpadReviewContinue + | LaunchpadCoinCreationStarted + | LaunchpadCoinCreationSuccess + | LaunchpadCoinCreationFailure + | LaunchpadFirstBuyStarted + | LaunchpadFirstBuySuccess + | LaunchpadFirstBuyFailure + | LaunchpadFirstBuyRetry + | LaunchpadFormInputChange + | LaunchpadFormBack + | LaunchpadWalletConnectError + | LaunchpadFirstBuyMaxButton + | LaunchpadFirstBuyQuoteReceived + | LaunchpadBuyModalOpen + | LaunchpadBuyModalClose + | LaunchpadBuyModalSubmit + | LaunchpadBuyModalSuccess + | LaunchpadBuyModalFailure + | LaunchpadBuyModalChangeCurrency + | LaunchpadBuyModalFormChange + | LaunchpadBuyModalMaxButton + | LaunchpadBuyModalContinue + | LaunchpadBuyModalBack diff --git a/packages/common/src/models/Launchpad.ts b/packages/common/src/models/Launchpad.ts new file mode 100644 index 00000000000..b4415cb078c --- /dev/null +++ b/packages/common/src/models/Launchpad.ts @@ -0,0 +1,40 @@ +/** + * Errors here are complicated & a sensitive area for users, so we want to log lots of info + */ +export type LaunchCoinErrorMetadata = { + userId: number + lastStep: string + relayResponseReceived: boolean + poolCreateConfirmed: boolean + sdkCoinAdded: boolean + firstBuyConfirmed: boolean + requestedFirstBuy: boolean + createPoolTx: string + firstBuyTx: string | undefined + initialBuyAmountAudio: string | undefined + coinMetadata: { + mint: string + imageUri: string + name: string + symbol: string + description: string + walletAddress: string + } +} + +export type LaunchCoinResponse = { + isError: boolean + errorMetadata: LaunchCoinErrorMetadata + newMint: string + logoUri: string +} + +export type LaunchpadFormValues = { + coinName: string + coinSymbol: string + coinImage: File | null + payAmount: string + receiveAmount: string + usdcValue: string + wantsToBuy: 'yes' | 'no' +} diff --git a/packages/common/src/models/index.ts b/packages/common/src/models/index.ts index cee900b102d..62f56e4c4fc 100644 --- a/packages/common/src/models/index.ts +++ b/packages/common/src/models/index.ts @@ -40,3 +40,4 @@ export * from './Search' export * from './Comment' export * from './AccessType' export * from './Event' +export * from './Launchpad' diff --git a/packages/web/src/hooks/useLaunchCoin.ts b/packages/web/src/hooks/useLaunchCoin.ts index 1e9890da34b..28d6a2c8ab7 100644 --- a/packages/web/src/hooks/useLaunchCoin.ts +++ b/packages/web/src/hooks/useLaunchCoin.ts @@ -3,7 +3,11 @@ import { getUserCreatedCoinsQueryKey, useQueryContext } from '@audius/common/api' -import { Feature } from '@audius/common/models' +import { + Feature, + LaunchCoinErrorMetadata, + LaunchCoinResponse +} from '@audius/common/models' import { Id } from '@audius/sdk' import type { Provider as SolanaProvider } from '@reown/appkit-adapter-solana/react' import { PublicKey, VersionedTransaction } from '@solana/web3.js' @@ -24,37 +28,6 @@ export type LaunchCoinParams = { image: Blob } -/** - * Errors here are complicated & a sensitive area for users, so we want to log lots of info - */ -export type LaunchCoinErrorMetadata = { - userId: number - lastStep: string - relayResponseReceived: boolean - poolCreateConfirmed: boolean - sdkCoinAdded: boolean - firstBuyConfirmed: boolean - requestedFirstBuy: boolean - createPoolTx: string - firstBuyTx: string | undefined - initialBuyAmountAudio: string | undefined - coinMetadata: { - mint: string - imageUri: string - name: string - symbol: string - description: string - walletAddress: string - } -} - -export type LaunchCoinResponse = { - isError: boolean - errorMetadata: LaunchCoinErrorMetadata - newMint: string - logoUri: string -} - export const LAUNCHPAD_COIN_DECIMALS = 9 // All our launched coins will have 9 decimals /** diff --git a/packages/web/src/pages/artist-coins-launchpad-page/LaunchpadPage.tsx b/packages/web/src/pages/artist-coins-launchpad-page/LaunchpadPage.tsx index 4d8397692c5..3b7037a121d 100644 --- a/packages/web/src/pages/artist-coins-launchpad-page/LaunchpadPage.tsx +++ b/packages/web/src/pages/artist-coins-launchpad-page/LaunchpadPage.tsx @@ -11,6 +11,7 @@ import { } from '@audius/common/api' import { launchpadMessages } from '@audius/common/messages' import { Feature } from '@audius/common/models' +import type { LaunchpadFormValues } from '@audius/common/models' import { TOKEN_LISTING_MAP, useCoinSuccessModal } from '@audius/common/store' import { shortenSPLAddress, route } from '@audius/common/utils' import { FixedDecimal, wAUDIO } from '@audius/fixed-decimal' @@ -38,10 +39,9 @@ import { InsufficientBalanceModal, LaunchpadSubmitModal } from './components/LaunchpadModals' -import type { SetupFormValues } from './components/types' import { LAUNCHPAD_COIN_DESCRIPTION, MIN_SOL_BALANCE, Phase } from './constants' import { BuyCoinPage, ReviewPage, SetupPage, SplashPage } from './pages' -import { getLatestConnectedWallet } from './utils' +import { getLatestConnectedWallet, useLaunchpadAnalytics } from './utils' import { useLaunchpadFormSchema } from './validation' const messages = { @@ -74,6 +74,17 @@ const LaunchpadPageContent = ({ () => getLatestConnectedWallet(connectedWallets), [connectedWallets] ) + const { + trackSplashGetStarted, + trackSetupContinue, + trackFormBack, + trackReviewContinue, + trackWalletConnectSuccess, + trackWalletConnectError, + trackWalletInsufficientBalance + } = useLaunchpadAnalytics({ + externalWalletAddress: connectedWallet?.address + }) const [isInsufficientBalanceModalOpen, setIsInsufficientBalanceModalOpen] = useState(false) @@ -105,7 +116,10 @@ const LaunchpadPageContent = ({ }) const walletBalanceLamports = balanceData.balanceLamports - return walletBalanceLamports >= MIN_SOL_BALANCE + return { + isValid: walletBalanceLamports >= MIN_SOL_BALANCE, + walletBalanceLamports + } }, [queryClient, queryContext] ) @@ -129,9 +143,13 @@ const LaunchpadPageContent = ({ async (wallets: ConnectedWallet[]) => { const newWallet = wallets[0] - const isValidWalletBalance = await getIsValidWalletBalance( - newWallet.address - ) + const { isValid: isValidWalletBalance, walletBalanceLamports } = + await getIsValidWalletBalance(newWallet.address) + if (isValidWalletBalance) { + trackWalletConnectSuccess(newWallet.address, walletBalanceLamports) + } else { + trackWalletInsufficientBalance(newWallet.address, walletBalanceLamports) + } try { if (isValidWalletBalance) { handleWalletAddSuccess(newWallet) @@ -145,32 +163,46 @@ const LaunchpadPageContent = ({ [ getIsValidWalletBalance, handleWalletAddSuccess, - setIsInsufficientBalanceModalOpen + setIsInsufficientBalanceModalOpen, + trackWalletConnectSuccess, + trackWalletInsufficientBalance ] ) + // NOTE: an error here can also mean that a wallet has already been added recently const handleWalletConnectError = useCallback( async (error: unknown) => { // If wallet is already linked, continue with the flow if (error instanceof AlreadyAssociatedError) { const lastConnectedWallet = getLatestConnectedWallet(connectedWallets) if (lastConnectedWallet) { - const isValidWalletBalance = await getIsValidWalletBalance( - lastConnectedWallet?.address - ) + const { isValid: isValidWalletBalance, walletBalanceLamports } = + await getIsValidWalletBalance(lastConnectedWallet?.address) if (isValidWalletBalance) { + trackWalletConnectSuccess( + lastConnectedWallet.address, + walletBalanceLamports + ) handleWalletAddSuccess(lastConnectedWallet) } else { + trackWalletInsufficientBalance( + lastConnectedWallet.address, + walletBalanceLamports + ) setIsInsufficientBalanceModalOpen(true) } } + } else { + trackWalletConnectError(error) } }, [ connectedWallets, getIsValidWalletBalance, handleWalletAddSuccess, - setIsInsufficientBalanceModalOpen + trackWalletConnectError, + trackWalletInsufficientBalance, + trackWalletConnectSuccess ] ) @@ -183,30 +215,36 @@ const LaunchpadPageContent = ({ const handleSplashContinue = useCallback(async () => { // Switch to Solana network to prioritize SOL wallets await appkitModal.switchNetwork(solana) + trackSplashGetStarted() openAppKitModal('solana') - }, [openAppKitModal]) + }, [openAppKitModal, trackSplashGetStarted]) const handleSetupContinue = useCallback(() => { setPhase(Phase.REVIEW) - }, []) + trackSetupContinue() + }, [trackSetupContinue]) const handleSetupBack = useCallback(async () => { resetForm() await validateForm() setPhase(Phase.SPLASH) - }, [resetForm, validateForm]) + trackFormBack() + }, [resetForm, validateForm, trackFormBack]) const handleReviewContinue = useCallback(() => { setPhase(Phase.BUY_COIN) - }, []) + trackReviewContinue() + }, [trackReviewContinue]) const handleReviewBack = useCallback(() => { setPhase(Phase.SETUP) - }, []) + trackFormBack() + }, [trackFormBack]) const handleBuyCoinBack = useCallback(() => { setPhase(Phase.REVIEW) - }, []) + trackFormBack() + }, [trackFormBack]) const renderCurrentPage = () => { switch (phase) { @@ -282,11 +320,24 @@ export const LaunchpadPage = () => { const { data: user } = useCurrentAccountUser() const { data: connectedWallets } = useConnectedWallets() const { validationSchema } = useLaunchpadFormSchema() - const [formValues, setFormValues] = useState(null) + const [formValues, setFormValues] = useState(null) const { onOpen: openCoinSuccessModal } = useCoinSuccessModal() const navigate = useNavigate() + const connectedWallet = useMemo( + () => getLatestConnectedWallet(connectedWallets), + [connectedWallets] + ) + const { + trackCoinCreationStarted, + trackCoinCreationFailure, + trackCoinCreationSuccess, + trackFirstBuyRetry + } = useLaunchpadAnalytics({ + externalWalletAddress: connectedWallet?.address + }) + // Launch coin mutation hook - this handles pool creation, sdk coin creation, and first buy transaction const { mutate: launchCoin, @@ -302,6 +353,10 @@ export const LaunchpadPage = () => { const isLaunchCoinError = launchCoinResponse?.isError const isPoolCreateError = isLaunchCoinError && !errorMetadata?.poolCreateConfirmed + const isSdkCreateError = + isLaunchCoinError && + errorMetadata?.poolCreateConfirmed && + !errorMetadata?.sdkCoinAdded const isFirstBuyError = isLaunchCoinError && errorMetadata?.poolCreateConfirmed && @@ -327,6 +382,27 @@ export const LaunchpadPage = () => { const isError = uncaughtLaunchCoinError || isLaunchCoinError || isSwapRetryError + useEffect(() => { + if (isLaunchCoinError) { + const errorState = isPoolCreateError + ? 'poolCreateFailed' + : isFirstBuyError + ? 'firstBuyFailed' + : isSdkCreateError + ? 'sdkCoinFailed' + : 'unknownError' + trackCoinCreationFailure(launchCoinResponse, errorState) + } + }, [ + isLaunchCoinError, + launchCoinResponse, + formValues, + trackCoinCreationFailure, + isPoolCreateError, + isFirstBuyError, + isSdkCreateError + ]) + // If an error occurs after the pool is created, we close the modal to let the user resubmit via the swap retry flow useEffect(() => { if (isPoolCreateError) { @@ -337,6 +413,7 @@ export const LaunchpadPage = () => { // Handle successful coin creation useEffect(() => { if (isSuccess && launchCoinResponse && formValues) { + trackCoinCreationSuccess(launchCoinResponse, formValues) // Show toast notification toast( @@ -366,6 +443,7 @@ export const LaunchpadPage = () => { openCoinSuccessModal, navigate, formValues, + trackCoinCreationSuccess, isError, isSuccess, toast @@ -396,7 +474,7 @@ export const LaunchpadPage = () => { }, [isSwapRetryError, toast]) const handleSubmit = useCallback( - (formValues: SetupFormValues) => { + (formValues: LaunchpadFormValues) => { // Store form values for success modal setFormValues(formValues) @@ -436,6 +514,7 @@ export const LaunchpadPage = () => { const mintAddress = launchCoinResponse.newMint || errorMetadata?.coinMetadata?.mint if (formValues.payAmount && mintAddress) { + trackFirstBuyRetry(launchCoinResponse) // Retry the first buy transaction with a new swap TX swapTokens({ inputToken: TOKEN_LISTING_MAP.AUDIO, @@ -463,6 +542,7 @@ export const LaunchpadPage = () => { }) } } else { + trackCoinCreationStarted(connectedWallet.address, formValues) launchCoin({ userId: user.user_id, name: formValues.coinName, @@ -481,10 +561,12 @@ export const LaunchpadPage = () => { connectedWallets, user, isFirstBuyError, - launchCoinResponse?.newMint, + toast, + launchCoinResponse, errorMetadata, + trackFirstBuyRetry, swapTokens, - toast, + trackCoinCreationStarted, launchCoin ] ) @@ -495,7 +577,7 @@ export const LaunchpadPage = () => { } return ( - + initialValues={{ coinName: '', coinSymbol: '', diff --git a/packages/web/src/pages/artist-coins-launchpad-page/components/CoinFormFields.tsx b/packages/web/src/pages/artist-coins-launchpad-page/components/CoinFormFields.tsx index 4c3978522be..b1207e9f94c 100644 --- a/packages/web/src/pages/artist-coins-launchpad-page/components/CoinFormFields.tsx +++ b/packages/web/src/pages/artist-coins-launchpad-page/components/CoinFormFields.tsx @@ -1,7 +1,8 @@ +import type { LaunchpadFormValues } from '@audius/common/models' import { Flex, TextInput } from '@audius/harmony' import { useFormikContext } from 'formik' -import type { SetupFormValues } from './types' +import { useLaunchpadAnalytics } from '../utils' const messages = { coinName: 'Coin Name', @@ -10,7 +11,16 @@ const messages = { export const CoinFormFields = () => { const { values, errors, touched, handleChange, handleBlur } = - useFormikContext() + useFormikContext() + + const { trackFormInputChange } = useLaunchpadAnalytics() + + const handleBlurWithAnalytics = + (name: keyof LaunchpadFormValues) => + (event: React.FocusEvent) => { + handleBlur(event) + trackFormInputChange(name, event.target.value) + } return ( @@ -20,7 +30,7 @@ export const CoinFormFields = () => { name='coinName' value={values.coinName} onChange={handleChange} - onBlur={handleBlur} + onBlur={handleBlurWithAnalytics('coinName')} error={!!(touched.coinName && errors.coinName)} helperText={touched.coinName ? errors.coinName : undefined} maxLength={30} @@ -32,7 +42,7 @@ export const CoinFormFields = () => { name='coinSymbol' value={values.coinSymbol} onChange={handleChange} - onBlur={handleBlur} + onBlur={handleBlurWithAnalytics('coinSymbol')} error={!!(touched.coinSymbol && errors.coinSymbol)} helperText={touched.coinSymbol ? errors.coinSymbol : undefined} startAdornmentText='$' diff --git a/packages/web/src/pages/artist-coins-launchpad-page/components/LaunchpadBuyModal.tsx b/packages/web/src/pages/artist-coins-launchpad-page/components/LaunchpadBuyModal.tsx index 56cc366a6a1..0a5e2f6a053 100644 --- a/packages/web/src/pages/artist-coins-launchpad-page/components/LaunchpadBuyModal.tsx +++ b/packages/web/src/pages/artist-coins-launchpad-page/components/LaunchpadBuyModal.tsx @@ -2,6 +2,7 @@ import { useContext, useEffect, useMemo, useState } from 'react' import { buySellMessages } from '@audius/common/messages' import { useConnectedWallets } from '@audius/common/src/api/tan-query/wallets/useConnectedWallets' +import { Name } from '@audius/common/src/models/Analytics' import { TOKEN_LISTING_MAP } from '@audius/common/src/store/ui/buy-audio/constants' import { TokenInfo } from '@audius/common/src/store/ui/buy-sell/types' import { useTokenSwapForm } from '@audius/common/src/store/ui/buy-sell/useTokenSwapForm' @@ -33,6 +34,7 @@ import { TokenDropdown } from 'components/buy-sell-modal/components/TokenDropdow import { ToastContext } from 'components/toast/ToastContext' import { Tooltip } from 'components/tooltip' import { useExternalWalletSwap } from 'hooks/useExternalWalletSwap' +import { make, track } from 'services/analytics' import zIndex from 'utils/zIndex' import { getLatestConnectedWallet } from '../utils' @@ -77,7 +79,7 @@ const FormInputStep = ({ onContinue, availableBalance, isBalanceLoading, - handleMaxClick, + handleMaxClick: onMaxClick, onInputTokenChange, onInputAmountChange, onOutputAmountChange @@ -95,8 +97,40 @@ const FormInputStep = ({ const handleInputTokenChange = (token: TokenInfo) => { onInputTokenChange(token) + track( + make({ + eventName: Name.LAUNCHPAD_BUY_MODAL_CHANGE_CURRENCY, + newCurrencySymbol: token.symbol + }) + ) setFieldValue('selectedInputToken', token) } + + const handleInputAmountBlur = (event: React.FocusEvent) => { + track( + make({ + eventName: Name.LAUNCHPAD_BUY_MODAL_FORM_CHANGE, + inputChanged: 'inputAmount', + newValue: event.target.value + }) + ) + } + const handleOutputAmountBlur = ( + event: React.FocusEvent + ) => { + track( + make({ + eventName: Name.LAUNCHPAD_BUY_MODAL_FORM_CHANGE, + inputChanged: 'outputAmount', + newValue: event.target.value + }) + ) + } + const handleMaxClick = () => { + track(make({ eventName: Name.LAUNCHPAD_BUY_MODAL_MAX_BUTTON })) + onMaxClick() + } + return ( <> @@ -163,6 +197,7 @@ const FormInputStep = ({ tokenLabel={values.selectedInputToken.symbol} value={values.inputAmount} onChange={onInputAmountChange} + onBlur={handleInputAmountBlur} error={!!errors.inputAmount} helperText={errors.inputAmount} /> @@ -192,6 +227,7 @@ const FormInputStep = ({ endIcon={} value={values.outputAmount} onChange={onOutputAmountChange} + onBlur={handleOutputAmountBlur} /> {/* Button */} @@ -341,12 +377,16 @@ export const LaunchpadBuyModal = ({ useEffect(() => { if (swapSuccess) { + track(make({ eventName: Name.LAUNCHPAD_BUY_MODAL_SUCCESS })) setCurrentStep(BuyModalStep.Success) } if (swapPending) { setCurrentStep(BuyModalStep.Loading) } if (swapError || swapData?.isError) { + track( + make({ eventName: Name.LAUNCHPAD_BUY_MODAL_FAILURE, error: swapError }) + ) console.error(swapError) const toastMessage = swapData?.progress?.userCancelled ? buySellMessages.transactionCancelled @@ -383,9 +423,20 @@ export const LaunchpadBuyModal = ({ ) const handleContinue = () => { + track(make({ eventName: Name.LAUNCHPAD_BUY_MODAL_CONTINUE })) if (currentStep === BuyModalStep.Form) { setCurrentStep(BuyModalStep.Confirmation) - } else if (currentStep === 'confirmation') { + } else if (currentStep === BuyModalStep.Confirmation) { + track( + make({ + eventName: Name.LAUNCHPAD_BUY_MODAL_SUBMIT, + inputAmount: buyModalForm.values.inputAmount, + outputAmount: buyModalForm.values.outputAmount, + inputTokenSymbol: selectedInputToken.symbol, + outputTokenSymbol: OUTPUT_TOKEN.symbol, + walletAddress: externalWalletAddress! + }) + ) swapTokens({ inputAmountUi: Number(buyModalForm.values.inputAmount), inputToken: selectedInputToken, @@ -397,6 +448,7 @@ export const LaunchpadBuyModal = ({ } const handleBack = () => { + track(make({ eventName: Name.LAUNCHPAD_BUY_MODAL_BACK })) if (currentStep === BuyModalStep.Confirmation) { setCurrentStep(BuyModalStep.Form) } diff --git a/packages/web/src/pages/artist-coins-launchpad-page/components/LaunchpadModals.tsx b/packages/web/src/pages/artist-coins-launchpad-page/components/LaunchpadModals.tsx index 8305095e494..cb560d2ae99 100644 --- a/packages/web/src/pages/artist-coins-launchpad-page/components/LaunchpadModals.tsx +++ b/packages/web/src/pages/artist-coins-launchpad-page/components/LaunchpadModals.tsx @@ -1,5 +1,9 @@ import { useEffect, useState } from 'react' +import { + LaunchpadFormValues, + LaunchCoinErrorMetadata +} from '@audius/common/models' import { useSendTokensModal } from '@audius/common/store' import { wAUDIO } from '@audius/fixed-decimal' import { @@ -17,11 +21,8 @@ import { import { useFormikContext } from 'formik' import { AddressTile } from 'components/address-tile' -import { LaunchCoinErrorMetadata } from 'hooks/useLaunchCoin' import { env } from 'services/env' -import { SetupFormValues } from './types' - const messages = { awaitingConfirmation: 'Awaiting Confirmation', launchingCoinDescription: (numTxs: number) => @@ -183,7 +184,7 @@ export const LaunchpadSubmitModal = ({ mintAddress: string | undefined errorMetadata?: LaunchCoinErrorMetadata }) => { - const { values } = useFormikContext() + const { values } = useFormikContext() const { payAmount } = values const payAmountNumber = Number(wAUDIO(payAmount).value) diff --git a/packages/web/src/pages/artist-coins-launchpad-page/components/WalletSetupCard.tsx b/packages/web/src/pages/artist-coins-launchpad-page/components/WalletSetupCard.tsx index bac09c581a4..e66e4089086 100644 --- a/packages/web/src/pages/artist-coins-launchpad-page/components/WalletSetupCard.tsx +++ b/packages/web/src/pages/artist-coins-launchpad-page/components/WalletSetupCard.tsx @@ -1,5 +1,7 @@ import { Flex, IconCheck, Paper, Text, TextLink } from '@audius/harmony' +import { useLaunchpadAnalytics } from '../utils' + const messages = { title: 'How to Get Ready', subtitle: 'Go through this checklist to prepare for launch.', @@ -22,6 +24,7 @@ const messages = { } export const WalletSetupCard = () => { + const { trackSplashLearnMoreClicked } = useLaunchpadAnalytics() return ( @@ -55,7 +58,11 @@ export const WalletSetupCard = () => { {messages.newtoWallets} - + {messages.learnMore} diff --git a/packages/web/src/pages/artist-coins-launchpad-page/components/types.ts b/packages/web/src/pages/artist-coins-launchpad-page/components/types.ts index ed3a89fef7a..fdd14bb01c7 100644 --- a/packages/web/src/pages/artist-coins-launchpad-page/components/types.ts +++ b/packages/web/src/pages/artist-coins-launchpad-page/components/types.ts @@ -1,13 +1,3 @@ -export type SetupFormValues = { - coinName: string - coinSymbol: string - coinImage: File | null - payAmount: string - receiveAmount: string - usdcValue: string - wantsToBuy: 'yes' | 'no' -} - export type PhasePageProps = { onContinue?: () => void onBack?: () => void diff --git a/packages/web/src/pages/artist-coins-launchpad-page/pages/BuyCoinPage.tsx b/packages/web/src/pages/artist-coins-launchpad-page/pages/BuyCoinPage.tsx index 19546106672..3648260abdd 100644 --- a/packages/web/src/pages/artist-coins-launchpad-page/pages/BuyCoinPage.tsx +++ b/packages/web/src/pages/artist-coins-launchpad-page/pages/BuyCoinPage.tsx @@ -6,7 +6,7 @@ import { useWalletAudioBalance } from '@audius/common/api' import { useDebouncedCallback } from '@audius/common/hooks' -import { Chain } from '@audius/common/models' +import { Chain, LaunchpadFormValues } from '@audius/common/models' import { AUDIO } from '@audius/fixed-decimal' import { Artwork, @@ -32,9 +32,9 @@ import { useLaunchpadConfig } from 'hooks/useLaunchpadConfig' import { ArtistCoinsSubmitRow } from '../components/ArtistCoinsSubmitRow' import { LaunchpadBuyModal } from '../components/LaunchpadBuyModal' -import type { PhasePageProps, SetupFormValues } from '../components/types' +import type { PhasePageProps } from '../components/types' import { AMOUNT_OF_STEPS } from '../constants' -import { getLatestConnectedWallet } from '../utils' +import { getLatestConnectedWallet, useLaunchpadAnalytics } from '../utils' import { FIELDS } from '../validation' const messages = { @@ -82,7 +82,7 @@ export const BuyCoinPage = ({ }) => { // Use Formik context to manage form state, including payAmount and receiveAmount const { values, setFieldValue, errors, touched, validateForm } = - useFormikContext() + useFormikContext() const { data: launchpadConfig } = useLaunchpadConfig() const { maxTokenOutputAmount, maxAudioInputAmount } = launchpadConfig ?? { maxTokenOutputAmount: Infinity, @@ -95,7 +95,25 @@ export const BuyCoinPage = ({ () => getLatestConnectedWallet(connectedWallets), [connectedWallets] ) + + const { + trackBuyModalOpen, + trackBuyModalClose, + trackFirstBuyQuoteReceived, + trackFormInputChange, + trackFirstBuyMaxButton + } = useLaunchpadAnalytics({ + externalWalletAddress: connectedWallet?.address + }) const [isBuyModalOpen, setIsBuyModalOpen] = useState(false) + const handleBuyModalOpen = () => { + trackBuyModalOpen() + setIsBuyModalOpen(true) + } + const handleBuyModalClose = () => { + trackBuyModalClose() + setIsBuyModalOpen(false) + } const { data: audioBalance } = useWalletAudioBalance({ address: connectedWallet?.address ?? '', chain: connectedWallet?.chain ?? Chain.Sol @@ -139,15 +157,27 @@ export const BuyCoinPage = ({ FIELDS.usdcValue, firstBuyQuoteData.usdcAmountUiString ?? '0.00' ) + if (isReceiveAmountChanging) { setFieldValue( FIELDS.receiveAmount, firstBuyQuoteData.tokenAmountUiString ) + trackFirstBuyQuoteReceived({ + payAmount: firstBuyQuoteData.audioAmountUiString, + receiveAmount: firstBuyQuoteData.tokenAmountUiString, + usdcValue: firstBuyQuoteData.usdcAmountUiString ?? '0.00' + }) + validateForm() } if (isPayAmountChanging) { setFieldValue(FIELDS.payAmount, firstBuyQuoteData.audioAmountUiString) + trackFirstBuyQuoteReceived({ + payAmount: firstBuyQuoteData.audioAmountUiString, + receiveAmount: firstBuyQuoteData.tokenAmountUiString, + usdcValue: firstBuyQuoteData.usdcAmountUiString ?? '0.00' + }) validateForm() } } @@ -157,7 +187,9 @@ export const BuyCoinPage = ({ isReceiveAmountChanging, isPayAmountChanging, prevFirstBuyQuoteData, - validateForm + validateForm, + trackFormInputChange, + trackFirstBuyQuoteReceived ]) const handleBack = () => { @@ -173,6 +205,7 @@ export const BuyCoinPage = ({ ) => { const newValue = event.target.value setFieldValue(FIELDS.wantsToBuy, newValue) + trackFormInputChange('wantsToBuy', newValue) // If user selects "no", reset the first buy form fields and their errors if (newValue === 'no') { @@ -184,6 +217,7 @@ export const BuyCoinPage = ({ } const handleMaxClick = () => { + trackFirstBuyMaxButton(audioBalanceString) setFieldValue(FIELDS.payAmount, audioBalanceString) debouncedPayAmountChange(audioBalanceString) } @@ -253,7 +287,7 @@ export const BuyCoinPage = ({ {isBuyModalOpen ? ( setIsBuyModalOpen(false)} + onClose={handleBuyModalClose} /> ) : null} - setIsBuyModalOpen(true)} - > + {messages.buyAudio} diff --git a/packages/web/src/pages/artist-coins-launchpad-page/pages/ReviewPage.tsx b/packages/web/src/pages/artist-coins-launchpad-page/pages/ReviewPage.tsx index a6f858142ca..14dd1314863 100644 --- a/packages/web/src/pages/artist-coins-launchpad-page/pages/ReviewPage.tsx +++ b/packages/web/src/pages/artist-coins-launchpad-page/pages/ReviewPage.tsx @@ -1,3 +1,4 @@ +import type { LaunchpadFormValues } from '@audius/common/models' import { Artwork, Flex, @@ -14,7 +15,7 @@ import { useFormImageUrl } from 'hooks/useFormImageUrl' import { ArtistCoinsSubmitRow } from '../components/ArtistCoinsSubmitRow' import { StepHeader } from '../components/StepHeader' import { TokenInfoRow } from '../components/TokenInfoRow' -import type { PhasePageProps, SetupFormValues } from '../components/types' +import type { PhasePageProps } from '../components/types' import { AMOUNT_OF_STEPS } from '../constants' const messages = { @@ -96,7 +97,7 @@ const useStyles = makeResponsiveStyles(({ theme }) => ({ })) export const ReviewPage = ({ onContinue, onBack }: PhasePageProps) => { - const { values } = useFormikContext() + const { values } = useFormikContext() const imageUrl = useFormImageUrl(values.coinImage) const styles = useStyles() diff --git a/packages/web/src/pages/artist-coins-launchpad-page/pages/SetupPage.tsx b/packages/web/src/pages/artist-coins-launchpad-page/pages/SetupPage.tsx index 80481cdd290..597b2616060 100644 --- a/packages/web/src/pages/artist-coins-launchpad-page/pages/SetupPage.tsx +++ b/packages/web/src/pages/artist-coins-launchpad-page/pages/SetupPage.tsx @@ -1,9 +1,11 @@ import { useRef, useState } from 'react' +import { ErrorLevel, Feature, LaunchpadFormValues } from '@audius/common/models' import { Flex, Paper } from '@audius/harmony' import { useFormikContext } from 'formik' import { useFormImageUrl } from 'hooks/useFormImageUrl' +import { reportToSentry } from 'store/errors/reportToSentry' import { resizeImage, ALLOWED_IMAGE_FILE_TYPES @@ -13,8 +15,9 @@ import { ArtistCoinsSubmitRow } from '../components/ArtistCoinsSubmitRow' import { CoinFormFields } from '../components/CoinFormFields' import { ImageUploadArea } from '../components/ImageUploadArea' import { StepHeader } from '../components/StepHeader' -import type { SetupFormValues, PhasePageProps } from '../components/types' +import type { PhasePageProps } from '../components/types' import { AMOUNT_OF_STEPS, MAX_IMAGE_SIZE } from '../constants' +import { useLaunchpadAnalytics } from '../utils' const messages = { stepInfo: `STEP 1 of ${AMOUNT_OF_STEPS}`, @@ -33,7 +36,9 @@ export const SetupPage = ({ onContinue, onBack }: PhasePageProps) => { const [isProcessingImage, setIsProcessingImage] = useState(false) const [imageError, setImageError] = useState(null) const { handleSubmit, setFieldValue, values, errors, touched } = - useFormikContext() + useFormikContext() + + const { trackFormInputChange } = useLaunchpadAnalytics() const imageUrl = useFormImageUrl(values.coinImage) @@ -55,6 +60,7 @@ export const SetupPage = ({ onContinue, onBack }: PhasePageProps) => { const file = event.target.files?.[0] if (file) { await processFile(file) + trackFormInputChange('coinImage', file.name) } } @@ -62,6 +68,7 @@ export const SetupPage = ({ onContinue, onBack }: PhasePageProps) => { const file = files[0] if (file) { await processFile(file) + trackFormInputChange('coinImage', file.name) } } @@ -101,7 +108,12 @@ export const SetupPage = ({ onContinue, onBack }: PhasePageProps) => { setFieldValue('coinImage', processedFile) // Hook will automatically create blob URL from processed file } catch (error) { - console.error('Error processing image:', error) + reportToSentry({ + error: error instanceof Error ? error : new Error(error as string), + name: 'Launchpad Image Upload Processing Error', + feature: Feature.ArtistCoins, + level: ErrorLevel.Warning // not worth alerting on here + }) setImageError(messages.errors.processingError) } finally { setIsProcessingImage(false) diff --git a/packages/web/src/pages/artist-coins-launchpad-page/utils.ts b/packages/web/src/pages/artist-coins-launchpad-page/utils.ts index 058414dab30..adae9bf0a3a 100644 --- a/packages/web/src/pages/artist-coins-launchpad-page/utils.ts +++ b/packages/web/src/pages/artist-coins-launchpad-page/utils.ts @@ -1,5 +1,13 @@ +import { useCallback, useMemo } from 'react' + import { ConnectedWallet } from '@audius/common/api' -import { Chain } from '@audius/common/models' +import { useAnalytics } from '@audius/common/hooks' +import { Chain, Name, LaunchCoinResponse } from '@audius/common/models' +import type { LaunchpadFormValues } from '@audius/common/models' +import { useFormikContext } from 'formik' +import { omit } from 'lodash' + +import { make } from 'services/analytics' /** * Gets the most recently added connected wallet @@ -11,3 +19,258 @@ export const getLatestConnectedWallet = ( (wallet: ConnectedWallet) => wallet.chain === Chain.Sol )?.[0] } + +export const useLaunchpadAnalytics = (params?: { + externalWalletAddress?: string +}) => { + const { externalWalletAddress } = params ?? {} + const { track } = useAnalytics() + const { values: formValues, errors } = + useFormikContext() ?? {} + const formValuesForAnalytics = useMemo(() => { + return { + ...omit(formValues, 'coinImage'), // dont want to upload the entire blob + hasImage: !!formValues?.coinImage, + formErrors: errors, + externalWalletAddress + } + }, [formValues, errors, externalWalletAddress]) + + // Splash page events + const trackSplashGetStarted = useCallback(() => { + track( + make({ + eventName: Name.LAUNCHPAD_SPLASH_GET_STARTED + }) + ) + }, [track]) + + const trackSplashLearnMoreClicked = useCallback(() => { + track( + make({ + eventName: Name.LAUNCHPAD_SPLASH_LEARN_MORE_CLICKED + }) + ) + }, [track]) + + // Wallet connection events + const trackWalletConnectSuccess = useCallback( + (walletAddress: string, walletBalance: bigint) => { + track( + make({ + eventName: Name.LAUNCHPAD_WALLET_CONNECT_SUCCESS, + walletAddress, + walletSolBalance: Number(walletBalance) + }) + ) + }, + [track] + ) + + const trackWalletConnectError = useCallback( + (error: any) => { + track( + make({ + eventName: Name.LAUNCHPAD_WALLET_CONNECT_ERROR, + error + }) + ) + }, + [track] + ) + + const trackWalletInsufficientBalance = useCallback( + (walletAddress: string, walletBalance: bigint) => { + track( + make({ + eventName: Name.LAUNCHPAD_WALLET_INSUFFICIENT_BALANCE, + walletAddress, + walletSolBalance: Number(walletBalance) + }) + ) + }, + [track] + ) + + // Form progression events + const trackFormInputChange = useCallback( + (input: keyof LaunchpadFormValues, newValue: string) => { + track( + make({ + eventName: Name.LAUNCHPAD_FORM_INPUT_CHANGE, + ...formValuesForAnalytics, + input: input as string, + newValue + }) + ) + }, + [track, formValuesForAnalytics] + ) + + const trackFirstBuyQuoteReceived = useCallback( + ({ + payAmount, + receiveAmount, + usdcValue + }: { + payAmount: string + receiveAmount: string + usdcValue: string + }) => { + track( + make({ + eventName: Name.LAUNCHPAD_FIRST_BUY_QUOTE_RECEIVED, + ...formValuesForAnalytics, + payAmount, + receiveAmount, + usdcValue + }) + ) + }, + [track, formValuesForAnalytics] + ) + + const trackSetupContinue = useCallback(() => { + track( + make({ + eventName: Name.LAUNCHPAD_SETUP_CONTINUE, + ...formValuesForAnalytics + }) + ) + }, [track, formValuesForAnalytics]) + + const trackFormBack = useCallback(() => { + track( + make({ + eventName: Name.LAUNCHPAD_FORM_BACK, + ...formValuesForAnalytics + }) + ) + }, [track, formValuesForAnalytics]) + + const trackReviewContinue = useCallback(() => { + track( + make({ + eventName: Name.LAUNCHPAD_REVIEW_CONTINUE, + ...formValuesForAnalytics + }) + ) + }, [formValuesForAnalytics, track]) + + // Coin creation events + const trackCoinCreationStarted = useCallback( + (walletAddress: string, formValues: LaunchpadFormValues) => { + track( + make({ + eventName: Name.LAUNCHPAD_COIN_CREATION_STARTED, + ...formValues, + walletAddress + }) + ) + }, + [track] + ) + + const trackCoinCreationSuccess = useCallback( + ( + launchCoinResponse: LaunchCoinResponse, + formValues: LaunchpadFormValues + ) => { + track( + make({ + eventName: Name.LAUNCHPAD_COIN_CREATION_SUCCESS, + ...formValues, + launchCoinResponse + }) + ) + }, + [track] + ) + + const trackCoinCreationFailure = useCallback( + ( + launchCoinResponse: LaunchCoinResponse, + errorState: + | 'poolCreateFailed' + | 'sdkCoinFailed' + | 'firstBuyFailed' + | 'unknownError' + ) => { + track( + make({ + eventName: Name.LAUNCHPAD_COIN_CREATION_FAILURE, + errorState, + launchCoinResponse + }) + ) + }, + [track] + ) + + const trackFirstBuyRetry = useCallback( + (launchCoinResponse: LaunchCoinResponse) => { + track( + make({ + eventName: Name.LAUNCHPAD_FIRST_BUY_RETRY, + ...formValuesForAnalytics, + launchCoinResponse + }) + ) + }, + [track, formValuesForAnalytics] + ) + + const trackBuyModalOpen = useCallback(() => { + track( + make({ + eventName: Name.LAUNCHPAD_BUY_MODAL_OPEN + }) + ) + }, [track]) + + const trackBuyModalClose = useCallback(() => { + track( + make({ + eventName: Name.LAUNCHPAD_BUY_MODAL_CLOSE + }) + ) + }, [track]) + + const trackFirstBuyMaxButton = useCallback( + (maxValue: string) => { + track( + make({ + eventName: Name.LAUNCHPAD_FIRST_BUY_MAX_BUTTON, + ...formValuesForAnalytics, + maxValue + }) + ) + }, + [track, formValuesForAnalytics] + ) + + return { + // Splash page + trackSplashGetStarted, + trackSplashLearnMoreClicked, + // Wallet connection events + trackWalletConnectSuccess, + trackWalletConnectError, + trackWalletInsufficientBalance, + // Page progression events + trackSetupContinue, + trackFormInputChange, + trackFormBack, + trackReviewContinue, + // Coin creation flow + trackCoinCreationStarted, + trackCoinCreationSuccess, + trackCoinCreationFailure, + // First buy flow + trackFirstBuyRetry, + trackFirstBuyMaxButton, + trackBuyModalOpen, + trackBuyModalClose, + trackFirstBuyQuoteReceived + } +}