diff --git a/frontend/src/components/common/CheckInStatusModal/index.tsx b/frontend/src/components/common/CheckInStatusModal/index.tsx index 1144c6185b..17f3825555 100644 --- a/frontend/src/components/common/CheckInStatusModal/index.tsx +++ b/frontend/src/components/common/CheckInStatusModal/index.tsx @@ -24,8 +24,6 @@ export const CheckInStatusModal = ({ }: CheckInStatusModalProps) => { const {data: checkInListsResponse, isLoading, ...rest} = useGetEventCheckInLists(eventId); - console.log('CheckInStatusModal - checkInListsResponse:', checkInListsResponse, 'isLoading:', isLoading, 'rest:', rest); - if (isLoading) { return ( diff --git a/frontend/src/components/common/StatusToggle/index.tsx b/frontend/src/components/common/StatusToggle/index.tsx index ed715a384f..8a54cb7c05 100644 --- a/frontend/src/components/common/StatusToggle/index.tsx +++ b/frontend/src/components/common/StatusToggle/index.tsx @@ -8,6 +8,7 @@ import {useUpdateEventStatus} from '../../../mutations/useUpdateEventStatus'; import {useUpdateOrganizerStatus} from '../../../mutations/useUpdateOrganizerStatus'; import {IdParam} from '../../../types'; import classes from './StatusToggle.module.scss'; +import {trackEvent, AnalyticsEvents} from '../../../utilites/analytics'; interface StatusToggleProps { entityType: 'event' | 'organizer'; @@ -47,6 +48,9 @@ export const StatusToggle: React.FC = ({ mutation.mutate(mutationParams as any, { onSuccess: () => { + if (entityType === 'event' && newStatus === 'LIVE') { + trackEvent(AnalyticsEvents.EVENT_PUBLISHED); + } const successMessage = entityType === 'event' ? t`Event status updated` : t`Organizer status updated`; diff --git a/frontend/src/components/forms/OrganizerForm/index.tsx b/frontend/src/components/forms/OrganizerForm/index.tsx index 7fbf935532..fa6c1aaf92 100644 --- a/frontend/src/components/forms/OrganizerForm/index.tsx +++ b/frontend/src/components/forms/OrganizerForm/index.tsx @@ -12,6 +12,7 @@ import {useFormErrorResponseHandler} from "../../../hooks/useFormErrorResponseHa import {useGetMe} from "../../../queries/useGetMe.ts"; import {IconBuilding} from "@tabler/icons-react"; import classes from "../../routes/welcome/Welcome.module.scss"; +import {trackEvent, AnalyticsEvents} from "../../../utilites/analytics.ts"; interface OrganizerFormProps { onSuccess?: (organizer: Organizer) => void; @@ -82,6 +83,7 @@ export const OrganizerCreateForm = ({onSuccess, onCancel}: OrganizerFormProps) = organizerData: values, }, { onSuccess: ({data: organizer}) => { + trackEvent(AnalyticsEvents.ORGANIZER_CREATED); if (onSuccess) { onSuccess(organizer); } diff --git a/frontend/src/components/modals/CreateWebhookModal/index.tsx b/frontend/src/components/modals/CreateWebhookModal/index.tsx index e3795ec242..9dacd4a19a 100644 --- a/frontend/src/components/modals/CreateWebhookModal/index.tsx +++ b/frontend/src/components/modals/CreateWebhookModal/index.tsx @@ -37,7 +37,6 @@ export const CreateWebhookModal = ({onClose}: GenericModalProps) => { const createMutation = useCreateWebhook(); const handleSubmit = (requestData: WebhookRequest) => { - console.log(eventId, requestData); createMutation.mutate({ eventId: eventId as IdParam, webhook: requestData diff --git a/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/index.tsx b/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/index.tsx index 9b1ad0326f..848967cc50 100644 --- a/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/index.tsx +++ b/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/index.tsx @@ -8,7 +8,7 @@ import {Anchor, Button, Grid, Group, Text, ThemeIcon, Title} from "@mantine/core import {Account, StripeConnectAccountsResponse} from "../../../../../../types.ts"; import paymentClasses from "./PaymentSettings.module.scss"; import classes from "../../ManageAccount.module.scss"; -import {useEffect, useState} from "react"; +import {useEffect, useRef, useState} from "react"; import {IconAlertCircle, IconBrandStripe, IconCheck, IconExternalLink, IconInfoCircle} from '@tabler/icons-react'; import {Card} from "../../../../../common/Card"; import {formatCurrency} from "../../../../../../utilites/currency.ts"; @@ -19,6 +19,7 @@ import {VatSettings} from './VatSettings'; import {VatSettingsModal} from './VatSettings/VatSettingsModal.tsx'; import {VatNotice, getVatInfo} from './VatNotice'; import {useGetAccountVatSetting} from '../../../../../../queries/useGetAccountVatSetting.ts'; +import {trackEvent, AnalyticsEvents} from "../../../../../../utilites/analytics.ts"; interface FeePlanDisplayProps { configuration?: { @@ -725,6 +726,27 @@ const PaymentSettings = () => { const [showVatModal, setShowVatModal] = useState(false); const [hasCheckedVatModal, setHasCheckedVatModal] = useState(false); + const hasTrackedStripeConnection = useRef(false); + + // Track Stripe connection when user returns with completed setup + useEffect(() => { + if (typeof window === 'undefined') return; + if (hasTrackedStripeConnection.current) return; + if (!stripeAccountsQuery.data) return; + + const urlParams = new URLSearchParams(window.location.search); + const isReturn = urlParams.get('is_return') === '1'; + if (!isReturn) return; + + const completedAccount = stripeAccountsQuery.data.stripe_connect_accounts.find( + acc => acc.is_setup_complete + ); + + if (completedAccount) { + hasTrackedStripeConnection.current = true; + trackEvent(AnalyticsEvents.STRIPE_CONNECTED); + } + }, [stripeAccountsQuery.data]); // Check if user is returning from Stripe and needs to fill VAT info // Only for Hi.Events Cloud - open-source doesn't have VAT handling diff --git a/frontend/src/components/routes/event/EventDashboard/index.tsx b/frontend/src/components/routes/event/EventDashboard/index.tsx index 8820dc3f8d..56f2a73086 100644 --- a/frontend/src/components/routes/event/EventDashboard/index.tsx +++ b/frontend/src/components/routes/event/EventDashboard/index.tsx @@ -22,6 +22,7 @@ import {useEffect, useState} from 'react'; import {StripePlatform} from "../../../../types.ts"; import {isHiEvents} from "../../../../utilites/helpers.ts"; import {StripeConnectButton} from "../../../common/StripeConnectButton"; +import {trackEvent, AnalyticsEvents} from "../../../../utilites/analytics.ts"; export const DashBoardSkeleton = () => { return ( @@ -67,6 +68,7 @@ export const EventDashboard = () => { }; const handleStatusToggle = () => { + const newStatus = event?.status === 'LIVE' ? 'DRAFT' : 'LIVE'; const message = event?.status === 'LIVE' ? t`Are you sure you want to make this event draft? This will make the event invisible to the public` : t`Are you sure you want to make this event public? This will make the event visible to the public`; @@ -74,9 +76,12 @@ export const EventDashboard = () => { confirmationDialog(message, () => { statusToggleMutation.mutate({ eventId, - status: event?.status === 'LIVE' ? 'DRAFT' : 'LIVE' + status: newStatus }, { onSuccess: () => { + if (newStatus === 'LIVE') { + trackEvent(AnalyticsEvents.EVENT_PUBLISHED); + } showSuccess(t`Event status updated`); }, onError: (error: any) => { diff --git a/frontend/src/components/routes/event/GettingStarted/index.tsx b/frontend/src/components/routes/event/GettingStarted/index.tsx index 2dba15b5ea..bfa019753c 100644 --- a/frontend/src/components/routes/event/GettingStarted/index.tsx +++ b/frontend/src/components/routes/event/GettingStarted/index.tsx @@ -15,6 +15,7 @@ import {getProductsFromEvent} from "../../../../utilites/helpers.ts"; import {useEffect, useState} from 'react'; import ConfettiAnimation from "./ConfettiAnimaiton"; import {Browser, useBrowser} from "../../../../hooks/useGetBrowser.ts"; +import {trackEvent, AnalyticsEvents} from "../../../../utilites/analytics.ts"; const GettingStarted = () => { const {eventId} = useParams(); @@ -52,11 +53,15 @@ const GettingStarted = () => { const statusToggleMutation = useUpdateEventStatus(); const handleStatusToggle = () => { + const newStatus = event?.status === 'LIVE' ? 'DRAFT' : 'LIVE'; statusToggleMutation.mutate({ eventId, - status: event?.status === 'LIVE' ? 'DRAFT' : 'LIVE' + status: newStatus }, { onSuccess: () => { + if (newStatus === 'LIVE') { + trackEvent(AnalyticsEvents.EVENT_PUBLISHED); + } showSuccess(t`Event status updated`); }, onError: (error: any) => { diff --git a/frontend/src/components/routes/product-widget/CollectInformation/index.tsx b/frontend/src/components/routes/product-widget/CollectInformation/index.tsx index da91288cb4..0b922028bd 100644 --- a/frontend/src/components/routes/product-widget/CollectInformation/index.tsx +++ b/frontend/src/components/routes/product-widget/CollectInformation/index.tsx @@ -31,6 +31,7 @@ import {eventCheckoutPath, eventHomepagePath} from "../../../../utilites/urlHelp import {showInfo} from "../../../../utilites/notifications.tsx"; import countries from "../../../../../data/countries.json"; import classes from "./CollectInformation.module.scss"; +import {trackEvent, AnalyticsEvents} from "../../../../utilites/analytics.ts"; const LoadingSkeleton = () => ( @@ -210,6 +211,9 @@ export const CollectInformation = () => { onSuccess: (data) => { const nextPage = order?.is_payment_required ? 'payment' : 'summary'; + if (nextPage === 'summary') { + trackEvent(AnalyticsEvents.PURCHASE_COMPLETED_FREE); + } navigate(eventCheckoutPath(eventId, data.data.short_id, nextPage)); }, diff --git a/frontend/src/components/routes/product-widget/Payment/index.tsx b/frontend/src/components/routes/product-widget/Payment/index.tsx index bd014d78d3..a5fe67c282 100644 --- a/frontend/src/components/routes/product-widget/Payment/index.tsx +++ b/frontend/src/components/routes/product-widget/Payment/index.tsx @@ -18,6 +18,7 @@ import {InlineOrderSummary} from "../../../common/InlineOrderSummary"; import {showError} from "../../../../utilites/notifications.tsx"; import {getConfig} from "../../../../utilites/config.ts"; import classes from "./Payment.module.scss"; +import {trackEvent, AnalyticsEvents} from "../../../../utilites/analytics.ts"; const Payment = () => { const navigate = useNavigate(); @@ -67,6 +68,8 @@ const Payment = () => { orderShortId }, { onSuccess: () => { + const totalCents = Math.round((order?.total_gross || 0) * 100); + trackEvent(AnalyticsEvents.PURCHASE_COMPLETED_OFFLINE, { value: totalCents }); navigate(`/checkout/${eventId}/${orderShortId}/summary`); }, onError: (error: any) => { diff --git a/frontend/src/components/routes/product-widget/PaymentReturn/index.tsx b/frontend/src/components/routes/product-widget/PaymentReturn/index.tsx index fb2b5a7071..2a0f060e21 100644 --- a/frontend/src/components/routes/product-widget/PaymentReturn/index.tsx +++ b/frontend/src/components/routes/product-widget/PaymentReturn/index.tsx @@ -1,6 +1,6 @@ import {usePollGetOrderPublic} from "../../../../queries/usePollGetOrderPublic.ts"; import {useNavigate, useParams} from "react-router"; -import {useEffect, useState} from "react"; +import {useEffect, useRef, useState} from "react"; import classes from './PaymentReturn.module.scss'; import {t} from "@lingui/macro"; import {useGetOrderStripePaymentIntentPublic} from "../../../../queries/useGetOrderStripePaymentIntentPublic.ts"; @@ -8,6 +8,7 @@ import {CheckoutContent} from "../../../layouts/Checkout/CheckoutContent"; import {eventCheckoutPath} from "../../../../utilites/urlHelper.ts"; import {HomepageInfoMessage} from "../../../common/HomepageInfoMessage"; import {isSsr} from "../../../../utilites/helpers.ts"; +import {trackEvent, AnalyticsEvents} from "../../../../utilites/analytics.ts"; /** * This component is responsible for handling the return from the payment provider. @@ -24,6 +25,7 @@ export const PaymentReturn = () => { const [attemptManualConfirmation, setAttemptManualConfirmation] = useState(false); const paymentIntentQuery = useGetOrderStripePaymentIntentPublic(eventId, orderShortId, attemptManualConfirmation); const [cannotConfirmPayment, setCannotConfirmPayment] = useState(false); + const hasTrackedPurchase = useRef(false); useEffect( () => { @@ -44,6 +46,11 @@ export const PaymentReturn = () => { return; } if (paymentIntentQuery.data?.status === 'succeeded') { + if (!hasTrackedPurchase.current && order) { + hasTrackedPurchase.current = true; + const totalCents = Math.round((order.total_gross || 0) * 100); + trackEvent(AnalyticsEvents.PURCHASE_COMPLETED_PAID, { value: totalCents }); + } navigate(eventCheckoutPath(eventId, orderShortId, 'summary')); } else { // At this point we've tried multiple times to confirm the payment and failed. @@ -59,6 +66,11 @@ export const PaymentReturn = () => { } if (order?.status === 'COMPLETED') { + if (!hasTrackedPurchase.current) { + hasTrackedPurchase.current = true; + const totalCents = Math.round((order.total_gross || 0) * 100); + trackEvent(AnalyticsEvents.PURCHASE_COMPLETED_PAID, { value: totalCents }); + } navigate(eventCheckoutPath(eventId, orderShortId, 'summary')); } if (order?.payment_status === 'PAYMENT_FAILED' || (typeof window !== 'undefined' && window?.location.search.includes('failed'))) { diff --git a/frontend/src/components/routes/welcome/index.tsx b/frontend/src/components/routes/welcome/index.tsx index c1cecad4c3..94987299a0 100644 --- a/frontend/src/components/routes/welcome/index.tsx +++ b/frontend/src/components/routes/welcome/index.tsx @@ -9,7 +9,7 @@ import {useDebouncedValue, useMediaQuery} from "@mantine/hooks"; import {Event} from "../../../types.ts"; import {useCreateEvent} from "../../../mutations/useCreateEvent.ts"; import {NavLink, useNavigate} from "react-router"; -import {useEffect, useState} from "react"; +import {useEffect, useRef, useState} from "react"; import {useGetEvents} from "../../../queries/useGetEvents.ts"; import {LoadingContainer} from "../../common/LoadingContainer"; import {OrganizerCreateForm} from "../../forms/OrganizerForm"; @@ -21,6 +21,7 @@ import {DateTimePicker} from "@mantine/dates"; import dayjs from "dayjs"; import {EventCategories} from "../../../constants/eventCategories.ts"; import {getConfig} from "../../../utilites/config.ts"; +import {trackEvent, AnalyticsEvents} from "../../../utilites/analytics.ts"; export const CreateOrganizer = ({progressInfo}: { progressInfo?: { currentStep: number, totalSteps: number, progressPercentage: number } @@ -92,6 +93,7 @@ const ConfirmVerificationPin = ({progressInfo}: { code: values.pin, }, { onSuccess: () => { + trackEvent(AnalyticsEvents.SIGNUP_COMPLETED); showSuccess(t`Email verified successfully!`); form.reset(); setCompletedPin(''); @@ -254,6 +256,7 @@ export const CreateEvent = ({progressInfo}: { eventData: submitData, }, { onSuccess: (values) => { + trackEvent(AnalyticsEvents.FIRST_EVENT_CREATED); navigate(`/manage/event/${values.data.id}/getting-started?new_event=true`) } }); @@ -441,11 +444,24 @@ const Welcome = () => { const organizersQuery = useGetOrganizers(); const organizers = organizersQuery?.data?.data; const organizerExists = organizersQuery.isFetched && Number(organizers?.length) > 0; + const hasTrackedSignup = useRef(false); const requiresVerification = userData && userData.enforce_email_confirmation_during_registration && !userData.is_email_verified; + useEffect(() => { + if (!userData || hasTrackedSignup.current) { + return; + } + // Only track if email verification was NEVER required for this account + // Users who needed verification are tracked in ConfirmVerificationPin's onSuccess + if (!userData.enforce_email_confirmation_during_registration) { + hasTrackedSignup.current = true; + trackEvent(AnalyticsEvents.SIGNUP_COMPLETED); + } + }, [userData]); + return (
diff --git a/frontend/src/mutations/useCreateProductCategory.ts b/frontend/src/mutations/useCreateProductCategory.ts index a35e838c3d..5fc118a505 100644 --- a/frontend/src/mutations/useCreateProductCategory.ts +++ b/frontend/src/mutations/useCreateProductCategory.ts @@ -13,11 +13,6 @@ export const useCreateProductCategory = () => { }) => productCategoryClient.create(eventId, productCategoryData), onSuccess: (_, variables) => { - console.log('Product category created successfully', { - eventId: variables.eventId, - productCategoryData: variables.productCategoryData, - }); - return queryClient .invalidateQueries({queryKey: [GET_EVENT_PRODUCT_CATEGORIES_QUERY_KEY, variables.eventId]}); } diff --git a/frontend/src/utilites/analytics.ts b/frontend/src/utilites/analytics.ts new file mode 100644 index 0000000000..92471740bf --- /dev/null +++ b/frontend/src/utilites/analytics.ts @@ -0,0 +1,43 @@ +declare global { + interface Window { + fathom?: { + trackEvent: (eventName: string, options?: { _value?: number }) => void; + trackPageview: () => void; + }; + } +} + +interface TrackEventOptions { + value?: number; +} + +export const AnalyticsEvents = { + SIGNUP_COMPLETED: 'signup_completed', + ORGANIZER_CREATED: 'organizer_created', + FIRST_EVENT_CREATED: 'first_event_created', + EVENT_PUBLISHED: 'event_published', + STRIPE_CONNECTED: 'stripe_connected', + PURCHASE_COMPLETED_PAID: 'purchase_completed_paid', + PURCHASE_COMPLETED_OFFLINE: 'purchase_completed_offline', + PURCHASE_COMPLETED_FREE: 'purchase_completed_free', +} as const; + +export type AnalyticsEventName = typeof AnalyticsEvents[keyof typeof AnalyticsEvents]; + +export function trackEvent(eventName: AnalyticsEventName | string, options?: TrackEventOptions): void { + if (typeof window === 'undefined') { + return; + } + // Fathom Analytics + if (window.fathom?.trackEvent) { + const fathomOptions = options?.value ? { _value: options.value } : undefined; + window.fathom.trackEvent(eventName, fathomOptions); + } + + // Future: Google Analytics 4 + // if (window.gtag) { + // window.gtag('event', eventName, { + // value: options?.value ? options.value / 100 : undefined, + // }); + // } +}