diff --git a/.gitignore b/.gitignore index b60064f..43d450b 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,6 @@ yarn-debug.log* yarn-error.log* /dist -.vscode \ No newline at end of file +.vscode + +.idea diff --git a/.prettierrc b/.prettierrc index e698378..0981b7c 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,6 +1,4 @@ { - "trailingComma": "es5", - "tabWidth": 4, - "semi": true, - "singleQuote": true + "singleQuote": true, + "printWidth": 120 } diff --git a/doc/mixpanel_setup.md b/doc/mixpanel_setup.md index d3107b4..351a55e 100644 --- a/doc/mixpanel_setup.md +++ b/doc/mixpanel_setup.md @@ -13,7 +13,12 @@ import { MixpanelProvider } from '@freshheads/analytics-essentials'; const App = () => { return ( - + { + return sendTrackEvent(event); + }) + }> ); @@ -83,7 +88,13 @@ const defaultMixpanelEventContext = { const App = () => { return ( - + { + return sendTrackEvent(event); + }) + } + defaultEventContext={defaultMixpanelEventContext}> ); @@ -137,7 +148,12 @@ Then add this component to your app: const App = () => { return ( - + { + return sendTrackEvent(event); + }) + }> {children} @@ -150,6 +166,12 @@ const App = () => { UTM tags are automatically added to the context of the event if they are present in the URL. They will be remembered for the duration of the session. Even if the user navigates to a different page, the UTM tags will be added to new events. +#### Mobile + +On mobile, the UTM tags can not be stored in the session, use `disableSessionStorage` to disable this behaviour. + +```tsx + ## Mixpanel users Mixpanel events can be attached to a user. This is done in the backend on user login, see [FHMixpanelBundle](https://github.com/freshheads/FHMixpanelBundle) for more information. diff --git a/lib/mixpanel/context.test.tsx b/lib/mixpanel/context.test.tsx new file mode 100644 index 0000000..bda2403 --- /dev/null +++ b/lib/mixpanel/context.test.tsx @@ -0,0 +1,174 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck test file contains web apis that are not available in node environment for typescript + +import { describe, expect, test, vi } from 'vitest'; +import { fireEvent, render, renderHook, RenderOptions } from '@testing-library/react'; +import React, { useEffect } from 'react'; +import { MixpanelProvider, useMixpanelContext } from './context.tsx'; +import { WebTrackingService } from './tracking/WebTrackingService.ts'; + +describe('MixpanelContext', () => { + const eventApiClient = vi.fn(() => Promise.resolve()); + + const ContextWrapper = ({ + children, + defaultEventContext, + }: { + children: React.ReactNode; + defaultEventContext: MixpanelEvent; + }) => ( + + {children} + + ); + + const renderWithMixpanelProvider = (ui: React.ReactElement, options?: Omit) => { + return render(ui, { + wrapper: (props) => , + ...options?.testingLibraryOptions, + }); + }; + + function TrackEventTestingComponent({ defaultEventContext }: { defaultEventContext?: MixpanelEvent['context'] }) { + const { trackEvent, setEventContext } = useMixpanelContext(); + + useEffect(() => { + if (defaultEventContext) { + setEventContext(defaultEventContext); + } + }, [defaultEventContext]); + + return ( + + ); + } + + function TrackPageView() { + const { trackPageView } = useMixpanelContext(); + + useEffect(() => { + trackPageView({ + data: { + title: 'Example', + pathname: '/product/1', + route: '/product/:id', + }, + }); + }, []); + + return null; + } + + test('provides expected context with trackEvent function', () => { + const { result } = renderHook(() => useMixpanelContext(), { + wrapper: ContextWrapper, + }); + + expect(result.current).toHaveProperty('trackEvent'); + expect(typeof result.current.trackEvent).toBe('function'); + + expect(result.current).toHaveProperty('trackPageView'); + expect(typeof result.current.trackPageView).toBe('function'); + }); + + test('trackEvent sends correct data to api client', () => { + const { getByText } = renderWithMixpanelProvider(); + + fireEvent.click(getByText('button')); + + expect(eventApiClient).toHaveBeenCalledWith({ + name: 'event name', + context: { + title: 'Page title', + pathname: '/', + pwa: false, + }, + data: { + productId: '123', + }, + }); + }); + + test('provider can extend the default context for event tracking with provider prop', () => { + const defaultEventContext = { + href: 'https://example.com', + pathname: '/example', + audience: 'Consumer', + }; + + const { getByText } = renderWithMixpanelProvider(, { + contextWrapperProps: { defaultEventContext }, + }); + + fireEvent.click(getByText('button')); + + expect(eventApiClient).toHaveBeenCalledWith({ + name: 'event name', + context: { + title: 'Page title', + href: 'https://example.com', + pathname: '/example', + pwa: false, + audience: 'Consumer', + }, + data: { + productId: '123', + }, + }); + }); + + test('Default event context can be extended from a child component', () => { + const defaultEventContext = { + href: 'https://example.com', + pathname: '/example', + audience: 'Consumer', + }; + + const { getByText } = renderWithMixpanelProvider( + + ); + + fireEvent.click(getByText('button')); + + expect(eventApiClient).toHaveBeenCalledWith({ + name: 'event name', + context: { + title: 'Page title', + href: 'https://example.com', + pathname: '/example', + pwa: false, + audience: 'Consumer', + }, + data: { + productId: '123', + }, + }); + }); + + test('trackPageView sends correct data to api client', () => { + renderWithMixpanelProvider(); + + expect(eventApiClient).toHaveBeenCalledWith({ + name: 'Page view', + context: { + pwa: false, + }, + data: { + title: 'Example', + pathname: '/product/1', + route: '/product/:id', + }, + }); + }); +}); diff --git a/lib/mixpanel/context.tsx b/lib/mixpanel/context.tsx index 4d57c1e..a8f5afb 100644 --- a/lib/mixpanel/context.tsx +++ b/lib/mixpanel/context.tsx @@ -1,113 +1,107 @@ 'use client'; -import React, { - createContext, - useCallback, - useContext, - useEffect, - useState, -} from 'react'; -import { MixpanelEvent, MixpanelPageViewEvent } from './types'; -import { - extractUtmParams, - isStandalonePWA, - writeUtmParamsToSessionStorage, -} from './utils.ts'; +import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'; +import { writeUtmParamsToSessionStorage } from './web/utils.ts'; +import { TrackingService } from './tracking/TrackingService.ts'; +import { WebMixpanelEvent, WebMixpanelPageViewEvent } from './types/webTypes.ts'; +import { MobileMixpanelEvent, MobileMixpanelPageViewEvent } from './types/mobileTypes.ts'; interface MixpanelContextProps { - trackEvent: (event: MixpanelEvent) => void; - trackPageView: (event: MixpanelPageViewEvent) => void; - setEventContext: (context: MixpanelEvent['context']) => void; + trackEvent: (event: WebMixpanelEvent | MobileMixpanelEvent) => void; + trackPageView: (event: WebMixpanelPageViewEvent | MobileMixpanelPageViewEvent) => void; + setEventContext: (context: WebMixpanelEvent['context'] | MobileMixpanelEvent['context']) => void; } interface MixpanelProviderProps { - children: React.ReactNode; - eventApiClient: ( - args: MixpanelEvent | MixpanelPageViewEvent - ) => Promise; - defaultEventContext?: MixpanelEvent['context']; + /** + * Children to render + */ + children: React.ReactNode; + /** + * Tracking service to use included in this package are `WebTrackingService` and `MobileTrackingService` + * @see WebTrackingService use for web applications + * @see MobileTrackingService use for mobile applications, NOTE: set disableSessionStorage to true to disable session storage because it is not available in mobile + * + * if you want to use your own tracking service you can implement the `TrackingService` interface + * @see TrackingService + * + */ + trackingService: TrackingService; + /** + * Default event context to use for all events + * @see WebMixpanelEvent for web events + * @see MobileMixpanelEvent for mobile events + */ + defaultEventContext?: WebMixpanelEvent['context'] | MobileMixpanelEvent['context']; + /** + * Disables session storage for storing utm params + */ + disableSessionStorage?: boolean; } const MixpanelContext = createContext(null); export function useMixpanelContext() { - const context = useContext(MixpanelContext); + const context = useContext(MixpanelContext); - if (!context) { - throw new Error(' not found'); - } + if (!context) { + throw new Error(' not found'); + } - return context; + return context; } export function MixpanelProvider({ - children, - eventApiClient, - defaultEventContext, + children, + trackingService, + defaultEventContext, + disableSessionStorage = false, }: MixpanelProviderProps) { - const [eventContext, setEventContext] = useState( - defaultEventContext || {} - ); - - const trackEvent = useCallback( - (event: MixpanelEvent) => { - // only send events on the client - if (typeof window === 'undefined') { - return; - } - - const utmParams = extractUtmParams(window.location.search); - - eventApiClient({ - ...event, - context: { - title: document.title, - pathname: window.location.pathname, - pwa: isStandalonePWA(), - ...eventContext, - ...utmParams, - ...event.context, - }, - }).catch((e) => console.error(e)); + const [eventContext, setEventContext] = useState( + defaultEventContext || {} + ); + + const trackEvent = useCallback( + (event: WebMixpanelEvent | MobileMixpanelEvent) => { + trackingService.trackEvent({ + ...event, + context: { + ...eventContext, + ...event.context, }, - [eventApiClient, eventContext] - ); - - const trackPageView = useCallback( - (event: MixpanelPageViewEvent) => { - // only send events on the client - if (typeof window === 'undefined') { - return; - } - - const utmParams = extractUtmParams(window.location.search); - - eventApiClient({ - ...event, - name: 'Page view', - context: { - pwa: isStandalonePWA(), - ...utmParams, - ...event.context, - }, - }).catch((e) => console.error(e)); + }); + }, + [trackingService, eventContext] + ); + + const trackPageView = useCallback( + (event: WebMixpanelPageViewEvent | MobileMixpanelPageViewEvent) => { + trackingService.trackPageView({ + ...event, + context: { + ...event.context, }, - [eventApiClient] - ); - - useEffect(() => { - writeUtmParamsToSessionStorage(window.location.search); - }, []); + }); + }, + [trackingService] + ); + + useEffect(() => { + if (disableSessionStorage) { + return; + } - return ( - - {children} - - ); + writeUtmParamsToSessionStorage(window.location.search); + }, [disableSessionStorage]); + + return ( + + {children} + + ); } diff --git a/lib/mixpanel/index.ts b/lib/mixpanel/index.ts index 629f275..b98f54d 100644 --- a/lib/mixpanel/index.ts +++ b/lib/mixpanel/index.ts @@ -1,2 +1,9 @@ -export { MixpanelProvider, useMixpanelContext } from './context'; -export * from './types'; +export * from './context'; + +export * from './types/baseTypes.ts'; +export * from './types/webTypes.ts'; +export * from './types/mobileTypes.ts'; + +export type { TrackingService } from './tracking/TrackingService.ts'; +export { MobileTrackingService } from './tracking/MobileTrackingService.ts'; +export { WebTrackingService } from './tracking/WebTrackingService.ts'; diff --git a/lib/mixpanel/tracking/MobileTrackingService.test.ts b/lib/mixpanel/tracking/MobileTrackingService.test.ts new file mode 100644 index 0000000..27d4630 --- /dev/null +++ b/lib/mixpanel/tracking/MobileTrackingService.test.ts @@ -0,0 +1,46 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { MobileTrackingService } from './MobileTrackingService'; +import { MobileMixpanelEvent, MobileMixpanelPageViewEvent } from '../types/mobileTypes'; + +describe('MobileTrackingService', () => { + const mockEventApiClient = vi.fn(() => Promise.resolve()); + let service: MobileTrackingService; + let mockEvent: MobileMixpanelEvent; + let mockPageViewEvent: MobileMixpanelPageViewEvent; + + beforeEach(() => { + service = new MobileTrackingService(mockEventApiClient); + // Setup mock data + mockEvent = { + name: 'Test Event', + context: { screenName: 'HomeScreen', route: '/home' }, + data: { info: 'test' }, + }; + + mockPageViewEvent = { + context: { utm_source: 'google' }, + data: { title: 'Home Page', route: '/home', audience: 'users' }, + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should successfully track an event', async () => { + await service.trackEvent(mockEvent); + expect(mockEventApiClient).toHaveBeenCalledWith({ + ...mockEvent, + context: { ...mockEvent.context }, + }); + }); + + it('should successfully track a page view', async () => { + await service.trackPageView(mockPageViewEvent); + expect(mockEventApiClient).toHaveBeenCalledWith({ + ...mockPageViewEvent, + name: 'Page view', + context: { ...mockPageViewEvent.context }, + }); + }); +}); diff --git a/lib/mixpanel/tracking/MobileTrackingService.ts b/lib/mixpanel/tracking/MobileTrackingService.ts new file mode 100644 index 0000000..efaa695 --- /dev/null +++ b/lib/mixpanel/tracking/MobileTrackingService.ts @@ -0,0 +1,33 @@ +import { TrackingService } from './TrackingService.ts'; +import { MobileMixpanelEvent, MobileMixpanelPageViewEvent } from '../types/mobileTypes.ts'; + +interface EventApiClient { + (args: MobileMixpanelEvent | MobileMixpanelPageViewEvent): Promise; +} + +export class MobileTrackingService implements TrackingService { + private eventApiClient: EventApiClient; + + constructor(eventApiClient: EventApiClient) { + this.eventApiClient = eventApiClient; + } + + trackEvent(event: MobileMixpanelEvent): void { + this.eventApiClient({ + ...event, + context: { + ...event.context, + }, + }).catch((e) => console.error('Failed to track event:', e)); + } + + trackPageView(event: MobileMixpanelPageViewEvent): void { + this.eventApiClient({ + ...event, + name: 'Page view', + context: { + ...event.context, + }, + }).catch((e) => console.error('Failed to track page view:', e)); + } +} diff --git a/lib/mixpanel/tracking/TrackingService.ts b/lib/mixpanel/tracking/TrackingService.ts new file mode 100644 index 0000000..b908587 --- /dev/null +++ b/lib/mixpanel/tracking/TrackingService.ts @@ -0,0 +1,7 @@ +import { WebMixpanelEvent, WebMixpanelPageViewEvent } from '../types/webTypes.ts'; +import { MobileMixpanelEvent, MobileMixpanelPageViewEvent } from '../types/mobileTypes.ts'; + +export interface TrackingService { + trackEvent(event: WebMixpanelEvent | MobileMixpanelEvent): void; + trackPageView(event: WebMixpanelPageViewEvent | MobileMixpanelPageViewEvent): void; +} diff --git a/lib/mixpanel/tracking/WebTrackingService.test.ts b/lib/mixpanel/tracking/WebTrackingService.test.ts new file mode 100644 index 0000000..6a75af4 --- /dev/null +++ b/lib/mixpanel/tracking/WebTrackingService.test.ts @@ -0,0 +1,69 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck test file contains web apis that are not available in node environment for typescript + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { WebTrackingService } from './WebTrackingService'; +import { WebMixpanelEvent } from '../types/webTypes'; +import { extractUtmParams, isStandalonePWA } from '../web/utils'; + +vi.mock('../web/utils', () => ({ + extractUtmParams: vi.fn(), + isStandalonePWA: vi.fn(), +})); + +describe('WebTrackingService', () => { + const mockEventApiClient = vi.fn(() => Promise.resolve()); + let service: WebTrackingService; + + // Setup to simulate browser environment + global.window = Object.create(window); + const url = 'http://example.com?utm_source=test_source'; + Object.defineProperty(window, 'location', { + value: { + search: url.split('?')[1], + pathname: '/test', + }, + }); + + global.document = { + title: 'Test Page', + }; + + beforeEach(() => { + service = new WebTrackingService(mockEventApiClient); + (extractUtmParams as vi.Mock).mockReturnValue({ + utm_source: 'test_source', + }); + (isStandalonePWA as vi.Mock).mockReturnValue(true); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should track an event successfully', async () => { + const event: WebMixpanelEvent = { + name: 'Test Event', + context: { additional: 'data' }, + }; + + await service.trackEvent(event); + expect(mockEventApiClient).toHaveBeenCalledWith({ + ...event, + context: { + title: 'Test Page', + pathname: '/test', + pwa: true, + utm_source: 'test_source', + additional: 'data', + }, + }); + }); + + it('should not track an event if window is undefined', async () => { + delete global.window; + const event: WebMixpanelEvent = { name: 'Test Event' }; + await service.trackEvent(event); + expect(mockEventApiClient).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/mixpanel/tracking/WebTrackingService.ts b/lib/mixpanel/tracking/WebTrackingService.ts new file mode 100644 index 0000000..71f728b --- /dev/null +++ b/lib/mixpanel/tracking/WebTrackingService.ts @@ -0,0 +1,52 @@ +import { TrackingService } from './TrackingService.ts'; +import { extractUtmParams, isStandalonePWA } from '../web/utils.ts'; +import { WebMixpanelEvent, WebMixpanelPageViewEvent } from '../types/webTypes.ts'; + +interface EventApiClient { + (args: WebMixpanelEvent | WebMixpanelPageViewEvent): Promise; +} + +export class WebTrackingService implements TrackingService { + private eventApiClient: EventApiClient; + + constructor(eventApiClient: EventApiClient) { + this.eventApiClient = eventApiClient; + } + + trackEvent(event: WebMixpanelEvent): void { + if (typeof window === 'undefined') { + return; + } + + const utmParams = extractUtmParams(window.location.search); + + this.eventApiClient({ + ...event, + context: { + title: document.title, + pathname: window.location.pathname, + pwa: isStandalonePWA(), + ...utmParams, + ...event.context, + }, + }).catch((e) => console.error('Failed to track event:', e)); + } + + trackPageView(event: WebMixpanelPageViewEvent): void { + if (typeof window === 'undefined') { + return; + } + + const utmParams = extractUtmParams(window.location.search); + + this.eventApiClient({ + ...event, + name: 'Page view', + context: { + pwa: isStandalonePWA(), + ...utmParams, + ...event.context, + }, + }).catch((e) => console.error('Failed to track page view:', e)); + } +} diff --git a/lib/mixpanel/types.ts b/lib/mixpanel/types.ts deleted file mode 100644 index 69640c1..0000000 --- a/lib/mixpanel/types.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * @description - * Mixpanel event we will pass this event to our own backend that is used as a proxy for Mixpanel. Almost everything is optional. The only required field is `name`. - * the other properties are suggestions for a "normal" event. - * @example - * const event: MixpanelEvent = { - * name: 'Contact', // e.g. "Update profile", "Add to cart", "Purchase" - * context: { // Give some context to the event. Where is it triggered and by who - * title: 'Product Page', // What page is the event triggered on - * pathname: '/product/123', // Make sure there aren't any personal info in the path - * href: 'https://www.example.com/product/123', // Make sure there aren't any personal info in the href - * route: '/product/:id', - * audience: 'Freelancer', // Who is triggering this event e.g. a role or "new user" - * section: 'footer', // What section is the event triggered in - * pwa: true, // Is the event triggered in a PWA - * utm_source: 'Facebook', // track the source where traffic is coming from, including a website or advertiser - * utm_medium: 'advertising', // track the advertising medium, including email and banner ads - * utm_campaign: 'Black friday', // track the campaign name associated with the traffic - * utm_content: 'cta button', //track the specific link within in an ad that a user clicked - * utm_term: 'tv sale', // track keywords associated with campaigns - * }, - * data: { // Any other properties that you want to add to the event - * product_id: '123', - * } - * } - */ -export type MixpanelEvent = { - name: string; - context?: { - title?: string; - pathname?: string; - href?: string; - route?: string; - audience?: string; - section?: string; - pwa?: boolean; - utm_source?: string; - utm_medium?: string; - utm_campaign?: string; - utm_content?: string; - utm_term?: string; - [key: string]: unknown; - }; - data?: { - [key: string]: unknown; - }; -}; - -/** - * @description - * When sending a page view event to Mixpanel we will pass this event to our own backend that is used as a proxy for Mixpanel. - * It differs from the `MixpanelEvent` in that it has a fixed `name` and information about the page should be provided - * - * @example - * const event: MixpanelPageViewEvent = { - * data: { - * title: 'Product Page', // What page is the event triggered on - * pathname: '/product/123', // Make sure there aren't any personal info in the path - * href: 'https://www.example.com/product/123', // Make sure there aren't any personal info in the href - * route: '/product/:id', - * audience: 'Freelancer', // The audience that is viewing the page - * }, - * context: { // This is optional and will be mostly provided by the MixpanelProvider - * pwa: true, // Is the event triggered in a PWA - * utm_source: 'Facebook', // track the source where traffic is coming from, including a website or advertiser - * utm_medium: 'advertising', // track the advertising medium, including email and banner ads - * utm_campaign: 'Black friday', // track the campaign name associated with the traffic - * utm_content: 'cta button', //track the specific link within in an ad that a user clicked - * utm_term: 'tv sale', // track keywords associated with campaigns - * } - *} - */ -export type MixpanelPageViewEvent = { - context?: { - pwa?: boolean; - utm_source?: string; - utm_medium?: string; - utm_campaign?: string; - utm_content?: string; - utm_term?: string; - [key: string]: unknown; - }; - data?: { - title?: string; - pathname: string; - href?: string; - route?: string; - audience?: string; - [key: string]: unknown; - }; -}; diff --git a/lib/mixpanel/types/baseTypes.ts b/lib/mixpanel/types/baseTypes.ts new file mode 100644 index 0000000..0f8cbde --- /dev/null +++ b/lib/mixpanel/types/baseTypes.ts @@ -0,0 +1,28 @@ +export interface BaseEventContext { + utm_source?: string; + utm_medium?: string; + utm_campaign?: string; + utm_content?: string; + utm_term?: string; + [key: string]: unknown; +} + +export interface BaseEventData { + [key: string]: unknown; +} + +export interface MixpanelEventContext extends BaseEventContext { + title?: string; + audience?: string; + section?: string; +} + +export interface MixpanelBaseEventData extends BaseEventData { + audience?: string; +} + +export interface BaseMixpanelEvent { + name: string; + context?: BaseEventContext; + data?: BaseEventData; +} diff --git a/lib/mixpanel/types/mobileTypes.ts b/lib/mixpanel/types/mobileTypes.ts new file mode 100644 index 0000000..d1b376b --- /dev/null +++ b/lib/mixpanel/types/mobileTypes.ts @@ -0,0 +1,67 @@ +import { + BaseEventContext, + BaseEventData, + BaseMixpanelEvent, + MixpanelBaseEventData, + MixpanelEventContext, +} from './baseTypes.ts'; + +/** + * @description + * Mixpanel event we will pass this event to our own backend that is used as a proxy for Mixpanel. Almost everything is optional. The only required field is `name`. + * the other properties are suggestions for a "normal" event. + * @example + * const event: MobileMixpanelEvent = { + * name: 'Contact', // e.g. "Update profile", "Add to cart", "Purchase" + * context: { // Give some context to the event. Where is it triggered and by who + * pathname: '/product/123', // Make sure there aren't any personal info in the path + * audience: 'Freelancer', // Who is triggering this event e.g. a role or "new user" + * utm_source: 'Facebook', // track the source where traffic is coming from, including a website or advertiser + * utm_medium: 'advertising', // track the advertising medium, including email and banner ads + * utm_campaign: 'Black friday', // track the campaign name associated with the traffic + * utm_content: 'cta button', //track the specific link within in an ad that a user clicked + * utm_term: 'tv sale', // track keywords associated with campaigns + * }, + * data: { // Any other properties that you want to add to the event + * product_id: '123', + * } + * } + */ + +export interface MobileMixpanelEvent extends BaseMixpanelEvent { + context?: { + pathname?: string; + } & MixpanelEventContext; + data?: BaseEventData; +} + +/** + * @description + * When sending a page view event to Mixpanel we will pass this event to our own backend that is used as a proxy for Mixpanel. + * It differs from the `MixpanelEvent` in that it has a fixed `name` and information about the page should be provided + * + * + * For localSearchParams and globalSearchParams, we can use useLocalSearchParams and useGlobalSearchParams from expo-router + * See: https://docs.expo.dev/router/reference/search-parameters/ + * + * @example + * const event: MobileMixpanelPageViewEvent = { + * context: { + * utm_source: 'Facebook', // track the source where traffic is coming from, including a website or advertiser + * utm_medium: 'advertising', // track the advertising medium, including email and banner ads + * utm_campaign: 'Black friday', // track the campaign name associated with the traffic + * utm_content: 'cta button', //track the specific link within in an ad that a user clicked + * utm_term: 'tv sale', // track keywords associated with campaigns + * }, + * data: { + * pathname: '/product/detail', // The path of the page where the event is triggered + * audience: 'Freelancer', // The audience that is viewing the page + * }, + * }; + */ +export interface MobileMixpanelPageViewEvent { + context?: BaseEventContext; + data: { + pathname?: string; + } & MixpanelBaseEventData; +} diff --git a/lib/mixpanel/types/webTypes.ts b/lib/mixpanel/types/webTypes.ts new file mode 100644 index 0000000..0e8d99b --- /dev/null +++ b/lib/mixpanel/types/webTypes.ts @@ -0,0 +1,80 @@ +import { + BaseEventContext, + BaseEventData, + BaseMixpanelEvent, + MixpanelBaseEventData, + MixpanelEventContext, +} from './baseTypes.ts'; + +/** + * @description + * Mixpanel event we will pass this event to our own backend that is used as a proxy for Mixpanel. Almost everything is optional. The only required field is `name`. + * the other properties are suggestions for a "normal" event. + * @example + * const event: WebMixpanelEvent = { + * name: 'Contact', // e.g. "Update profile", "Add to cart", "Purchase" + * context: { // Give some context to the event. Where is it triggered and by who + * title: 'Product Page', // What page is the event triggered on + * pathname: '/product/123', // Make sure there aren't any personal info in the path + * href: 'https://www.example.com/product/123', // Make sure there aren't any personal info in the href + * route: '/product/:id', + * audience: 'Freelancer', // Who is triggering this event e.g. a role or "new user" + * section: 'footer', // What section is the event triggered in + * pwa: true, // Is the event triggered in a PWA + * utm_source: 'Facebook', // track the source where traffic is coming from, including a website or advertiser + * utm_medium: 'advertising', // track the advertising medium, including email and banner ads + * utm_campaign: 'Black friday', // track the campaign name associated with the traffic + * utm_content: 'cta button', //track the specific link within in an ad that a user clicked + * utm_term: 'tv sale', // track keywords associated with campaigns + * }, + * data: { // Any other properties that you want to add to the event + * product_id: '123', + * } + * } + */ +export interface WebMixpanelEvent extends BaseMixpanelEvent { + context?: { + pathname?: string; + href?: string; + route?: string; + pwa?: boolean; + } & MixpanelEventContext; + data?: BaseEventData; +} + +/** + * @description + * When sending a page view event to Mixpanel we will pass this event to our own backend that is used as a proxy for Mixpanel. + * It differs from the `MixpanelEvent` in that it has a fixed `name` and information about the page should be provided + * + * @example + * const event: WebMixpanelPageViewEvent = { + * context: { + * // This is optional and will be mostly provided by the MixpanelProvider + * pwa: true, // Is the event triggered in a PWA + * utm_source: 'Facebook', // track the source where traffic is coming from, including a website or advertiser + * utm_medium: 'advertising', // track the advertising medium, including email and banner ads + * utm_campaign: 'Black friday', // track the campaign name associated with the traffic + * utm_content: 'cta button', //track the specific link within in an ad that a user clicked + * utm_term: 'tv sale', // track keywords associated with campaigns + * }, + * data: { + * title: 'Product Page', // What page is the event triggered on + * pathname: '/product/123', // Make sure there aren't any personal info in the path + * href: 'https://www.example.com/product/123', // Make sure there aren't any personal info in the href + * route: '/product/:id', + * audience: 'Freelancer', // The audience that is viewing the page + * }, + * }; + */ +export interface WebMixpanelPageViewEvent { + context?: { + pwa?: boolean; + } & BaseEventContext; + data: { + title: string; + pathname: string; + href: string; + route: string; + } & MixpanelBaseEventData; +} diff --git a/lib/mixpanel/utils.ts b/lib/mixpanel/utils.ts deleted file mode 100644 index c11f6f1..0000000 --- a/lib/mixpanel/utils.ts +++ /dev/null @@ -1,49 +0,0 @@ -export const isStandalonePWA = () => - typeof window !== 'undefined' - ? window.matchMedia('(display-mode: standalone)').matches - : false; - -export const extractUtmParams = (paramsString: string) => { - const searchParams = new URLSearchParams(paramsString); - - return { - utm_source: - searchParams.get('utm_source') || - sessionStorage.getItem('utm_source') || - undefined, - utm_medium: - searchParams.get('utm_medium') || - sessionStorage.getItem('utm_medium') || - undefined, - utm_campaign: - searchParams.get('utm_campaign') || - sessionStorage.getItem('utm_campaign') || - undefined, - utm_content: - searchParams.get('utm_content') || - sessionStorage.getItem('utm_content') || - undefined, - utm_term: - searchParams.get('utm_term') || - sessionStorage.getItem('utm_term') || - undefined, - }; -}; - -export const writeUtmParamsToSessionStorage = (paramsString: string) => { - const searchParams = new URLSearchParams(paramsString); - - const utmSourceKeys = [ - 'utm_source', - 'utm_medium', - 'utm_campaign', - 'utm_content', - 'utm_term', - ]; - - utmSourceKeys.forEach((key) => { - if (searchParams.has(key)) { - sessionStorage.setItem(key, searchParams.get(key)); - } - }); -}; diff --git a/lib/mixpanel/web/utils.test.ts b/lib/mixpanel/web/utils.test.ts new file mode 100644 index 0000000..abb30c2 --- /dev/null +++ b/lib/mixpanel/web/utils.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, test } from 'vitest'; +import { extractUtmParams, writeUtmParamsToSessionStorage } from './utils.ts'; + +describe('UTM tags', () => { + const urlContainingUTMParams = new URL( + 'https://example.com?utm_source=source&utm_medium=medium&utm_campaign=campaign&utm_content=content&utm_term=term' + ); + + test('extracting utm tags from url', () => { + const result = extractUtmParams(urlContainingUTMParams.search); + + expect(result).toEqual({ + utm_source: 'source', + utm_medium: 'medium', + utm_campaign: 'campaign', + utm_content: 'content', + utm_term: 'term', + }); + }); + + test('utm tags are saved in session storage', () => { + writeUtmParamsToSessionStorage(urlContainingUTMParams.search); + + expect(sessionStorage.getItem('utm_source')).toBe('source'); + expect(sessionStorage.getItem('utm_medium')).toBe('medium'); + expect(sessionStorage.getItem('utm_campaign')).toBe('campaign'); + expect(sessionStorage.getItem('utm_content')).toBe('content'); + expect(sessionStorage.getItem('utm_term')).toBe('term'); + }); + + afterEach(() => { + sessionStorage.clear(); + }); +}); diff --git a/lib/mixpanel/web/utils.ts b/lib/mixpanel/web/utils.ts new file mode 100644 index 0000000..e178ee3 --- /dev/null +++ b/lib/mixpanel/web/utils.ts @@ -0,0 +1,26 @@ +export const isStandalonePWA = () => + typeof window !== 'undefined' ? window.matchMedia('(display-mode: standalone)').matches : false; + +export const extractUtmParams = (paramsString: string) => { + const searchParams = new URLSearchParams(paramsString); + + return { + utm_source: searchParams.get('utm_source') || sessionStorage.getItem('utm_source') || undefined, + utm_medium: searchParams.get('utm_medium') || sessionStorage.getItem('utm_medium') || undefined, + utm_campaign: searchParams.get('utm_campaign') || sessionStorage.getItem('utm_campaign') || undefined, + utm_content: searchParams.get('utm_content') || sessionStorage.getItem('utm_content') || undefined, + utm_term: searchParams.get('utm_term') || sessionStorage.getItem('utm_term') || undefined, + }; +}; + +export const writeUtmParamsToSessionStorage = (paramsString: string) => { + const searchParams = new URLSearchParams(paramsString); + + const utmSourceKeys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term']; + + utmSourceKeys.forEach((key) => { + if (searchParams.has(key)) { + sessionStorage.setItem(key, searchParams.get(key)); + } + }); +}; diff --git a/package-lock.json b/package-lock.json index 13e977f..1b010a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@freshheads/analytics-essentials", - "version": "1.0.0", + "version": "1.1.0-beta.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@freshheads/analytics-essentials", - "version": "1.0.0", + "version": "1.1.0-beta.8", "license": "MIT", "devDependencies": { "@testing-library/react": "^14.2.1", diff --git a/package.json b/package.json index 8c52c68..a37beb7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@freshheads/analytics-essentials", - "version": "1.0.0", + "version": "1.1.0", "keywords": [ "Analytics", "Tag Manager", diff --git a/test/mixpanel.test.tsx b/test/mixpanel.test.tsx deleted file mode 100644 index 09a8d69..0000000 --- a/test/mixpanel.test.tsx +++ /dev/null @@ -1,231 +0,0 @@ -// @ts-nocheck test file contains web api's that are not availalbe in node environment for typescript -import { afterEach, describe, expect, test, vi } from 'vitest'; -import { - extractUtmParams, - writeUtmParamsToSessionStorage, -} from '../lib/mixpanel/utils'; -import { MixpanelProvider, useMixpanelContext } from '../lib/mixpanel/context'; -import { - fireEvent, - render, - renderHook, - RenderOptions, -} from '@testing-library/react'; -import React, { useEffect } from 'react'; -import { MixpanelEvent } from '../lib/mixpanel'; - -describe('UTM tags', () => { - const urlContainingUTMParams = new URL( - 'https://example.com?utm_source=source&utm_medium=medium&utm_campaign=campaign&utm_content=content&utm_term=term' - ); - - test('extracting utm tags from url', () => { - const result = extractUtmParams(urlContainingUTMParams.search); - - expect(result).toEqual({ - utm_source: 'source', - utm_medium: 'medium', - utm_campaign: 'campaign', - utm_content: 'content', - utm_term: 'term', - }); - }); - - test('utm tags are saved in session storage', () => { - writeUtmParamsToSessionStorage(urlContainingUTMParams.search); - - expect(sessionStorage.getItem('utm_source')).toBe('source'); - expect(sessionStorage.getItem('utm_medium')).toBe('medium'); - expect(sessionStorage.getItem('utm_campaign')).toBe('campaign'); - expect(sessionStorage.getItem('utm_content')).toBe('content'); - expect(sessionStorage.getItem('utm_term')).toBe('term'); - }); - - afterEach(() => { - sessionStorage.clear(); - }); -}); - -describe('MixpanelContext', () => { - const eventApiClient = vi.fn(() => Promise.resolve()); - - const ContextWrapper = ({ - children, - defaultEventContext, - }: { - children: React.ReactNode; - defaultEventContext: MixpanelEvent; - }) => ( - - {children} - - ); - - const renderWithMixpanelProvider = ( - ui: React.ReactElement, - options?: Omit - ) => { - return render(ui, { - wrapper: (props: any) => ( - - ), - ...options?.testingLibraryOptions, - }); - }; - - function TrackEventTestingComponent({ - defaultEventContext, - }: { - defaultEventContext?: MixpanelEvent['context']; - }) { - const { trackEvent, setEventContext } = useMixpanelContext(); - - useEffect(() => { - if (defaultEventContext) { - setEventContext(defaultEventContext); - } - }, [defaultEventContext]); - - return ( - - ); - } - - function TrackPageView() { - const { trackPageView } = useMixpanelContext(); - - useEffect(() => { - trackPageView({ - data: { - title: 'Example', - pathname: '/product/1', - route: '/product/:id', - }, - }); - }, []); - - return null; - } - - test('provides expected context with trackEvent function', () => { - const { result } = renderHook(() => useMixpanelContext(), { - wrapper: ContextWrapper, - }); - - expect(result.current).toHaveProperty('trackEvent'); - expect(typeof result.current.trackEvent).toBe('function'); - - expect(result.current).toHaveProperty('trackPageView'); - expect(typeof result.current.trackPageView).toBe('function'); - }); - - test('trackEvent sends correct data to api client', () => { - const { getByText } = renderWithMixpanelProvider( - - ); - - fireEvent.click(getByText('button')); - - expect(eventApiClient).toHaveBeenCalledWith({ - name: 'event name', - context: { - title: 'Page title', - pathname: '/', - pwa: false, - }, - data: { - productId: '123', - }, - }); - }); - - test('provider can extend the default context for event tracking with provider prop', () => { - const defaultEventContext = { - href: 'https://example.com', - pathname: '/example', - audience: 'Consumer', - }; - - const { getByText } = renderWithMixpanelProvider( - , - { - contextWrapperProps: { defaultEventContext }, - } - ); - - fireEvent.click(getByText('button')); - - expect(eventApiClient).toHaveBeenCalledWith({ - name: 'event name', - context: { - title: 'Page title', - href: 'https://example.com', - pathname: '/example', - pwa: false, - audience: 'Consumer', - }, - data: { - productId: '123', - }, - }); - }); - - test('Default event context can be extended from a child component', () => { - const defaultEventContext = { - href: 'https://example.com', - pathname: '/example', - audience: 'Consumer', - }; - - const { getByText } = renderWithMixpanelProvider( - - ); - - fireEvent.click(getByText('button')); - - expect(eventApiClient).toHaveBeenCalledWith({ - name: 'event name', - context: { - title: 'Page title', - href: 'https://example.com', - pathname: '/example', - pwa: false, - audience: 'Consumer', - }, - data: { - productId: '123', - }, - }); - }); - - test('trackPageView sends correct data to api client', () => { - renderWithMixpanelProvider(); - - expect(eventApiClient).toHaveBeenCalledWith({ - name: 'Page view', - context: { - pwa: false, - }, - data: { - title: 'Example', - pathname: '/product/1', - route: '/product/:id', - }, - }); - }); -});