diff --git a/docs.json b/docs.json index 09eba7c4..03fd4181 100644 --- a/docs.json +++ b/docs.json @@ -78,6 +78,57 @@ } ] }, + { + "tab": "react", + "group": "Analytics", + "pages": [ + { + "title": "Getting Started", + "href": "/react/analytics" + }, + { + "group": "Hooks", + "pages": [ + { + "href": "/react/analytics/hooks/useGetGoogleAnalyticsClientIdQuery", + "title": "useGetGoogleAnalyticsClientIdQuery" + }, + { + "href": "/react/analytics/hooks/useIsSupportedQuery", + "title": "useIsSupportedQuery" + }, + { + "href": "/react/analytics/hooks/useLogEventMutation", + "title": "useLogEventMutation" + }, + { + "href": "/react/analytics/hooks/useSetAnalyticsCollectionEnabledMutation", + "title": "useSetAnalyticsCollectionEnabledMutation" + }, + { + "href": "/react/analytics/hooks/useSetConsentMutation", + "title": "useSetConsentMutation" + }, + { + "href": "/react/analytics/hooks/useSetCurrentScreenMutation", + "title": "useSetCurrentScreenMutation" + }, + { + "href": "/react/analytics/hooks/useSetDefaultEventParametersMutation", + "title": "useSetDefaultEventParametersMutation" + }, + { + "href": "/react/analytics/hooks/useSetUserIdMutation", + "title": "useSetUserIdMutation" + }, + { + "href": "/react/analytics/hooks/useSetUserPropertiesMutation", + "title": "useSetUserPropertiesMutation" + } + ] + } + ] + }, { "tab": "react", "group": "Authentication", diff --git a/docs/react/analytics/hooks/useGetGoogleAnalyticsClientIdQuery.mdx b/docs/react/analytics/hooks/useGetGoogleAnalyticsClientIdQuery.mdx new file mode 100644 index 00000000..92b637c1 --- /dev/null +++ b/docs/react/analytics/hooks/useGetGoogleAnalyticsClientIdQuery.mdx @@ -0,0 +1,30 @@ +--- +title: useGetGoogleAnalyticsClientIdQuery +--- + +Retrieve the Google Analytics client ID for a Firebase Analytics instance. + +## Usage + +```tsx +import { getAnalytics } from "firebase/analytics"; +import { + analyticsQueryKeys, + useGetGoogleAnalyticsClientIdQuery, +} from "@tanstack-query-firebase/react/analytics"; + +const analytics = getAnalytics(app); + +const { data: clientId, isLoading } = useGetGoogleAnalyticsClientIdQuery( + analytics, + { + queryKey: analyticsQueryKeys.googleAnalyticsClientId(analytics.app.name), + }, +); +``` + +## Notes + +- Wraps Firebase [`getGoogleAnalyticsClientId`](https://firebase.google.com/docs/reference/js/analytics.md#getgoogleanalyticsclientid). +- Requires a caller-provided `queryKey`. +- Disable the query until Analytics is initialized with `enabled: !!analytics` if needed. diff --git a/docs/react/analytics/hooks/useIsSupportedQuery.mdx b/docs/react/analytics/hooks/useIsSupportedQuery.mdx new file mode 100644 index 00000000..b4a99b5c --- /dev/null +++ b/docs/react/analytics/hooks/useIsSupportedQuery.mdx @@ -0,0 +1,29 @@ +--- +title: useIsSupportedQuery +--- + +Check whether Firebase Analytics is supported in the current environment. + +## Usage + +```tsx +import { + analyticsQueryKeys, + useIsSupportedQuery, +} from "@tanstack-query-firebase/react/analytics"; + +const { data: supported, isLoading } = useIsSupportedQuery({ + queryKey: analyticsQueryKeys.isSupported(), + staleTime: Number.POSITIVE_INFINITY, +}); + +if (supported) { + // Safe to call getAnalytics() +} +``` + +## Notes + +- Wraps Firebase [`isSupported`](https://firebase.google.com/docs/reference/js/analytics.md#issupported). +- Does not accept an `Analytics` instance. +- Requires a caller-provided `queryKey`. diff --git a/docs/react/analytics/hooks/useLogEventMutation.mdx b/docs/react/analytics/hooks/useLogEventMutation.mdx new file mode 100644 index 00000000..db705d09 --- /dev/null +++ b/docs/react/analytics/hooks/useLogEventMutation.mdx @@ -0,0 +1,46 @@ +--- +title: useLogEventMutation +--- + +Log a Google Analytics event via Firebase Analytics. + +## Usage + +```tsx +import { getAnalytics } from "firebase/analytics"; +import { useLogEventMutation } from "@tanstack-query-firebase/react/analytics"; + +const analytics = getAnalytics(app); +const { mutate: logAnalyticsEvent } = useLogEventMutation(analytics); + +logAnalyticsEvent({ + eventName: "login", + eventParams: { method: "email" }, +}); +``` + +## Screen tracking + +Prefer a `screen_view` event over the deprecated `useSetCurrentScreenMutation`: + +```tsx +logAnalyticsEvent({ + eventName: "screen_view", + eventParams: { + firebase_screen: "Home", + firebase_screen_class: "HomeScreen", + }, +}); +``` + +## Global events + +Apply an event to all Google Analytics properties on the page with `callOptions.global`: + +```tsx +logAnalyticsEvent({ + eventName: "purchase", + eventParams: { transaction_id: "T123", value: 9.99, currency: "USD" }, + callOptions: { global: true }, +}); +``` diff --git a/docs/react/analytics/hooks/useSetAnalyticsCollectionEnabledMutation.mdx b/docs/react/analytics/hooks/useSetAnalyticsCollectionEnabledMutation.mdx new file mode 100644 index 00000000..e69b8690 --- /dev/null +++ b/docs/react/analytics/hooks/useSetAnalyticsCollectionEnabledMutation.mdx @@ -0,0 +1,22 @@ +--- +title: useSetAnalyticsCollectionEnabledMutation +--- + +Enable or disable Google Analytics collection for an app on the current device. + +## Usage + +```tsx +import { getAnalytics } from "firebase/analytics"; +import { useSetAnalyticsCollectionEnabledMutation } from "@tanstack-query-firebase/react/analytics"; + +const analytics = getAnalytics(app); +const { mutate: setCollectionEnabled } = + useSetAnalyticsCollectionEnabledMutation(analytics); + +// Opt user out of analytics collection +setCollectionEnabled(false); + +// Re-enable after consent is granted +setCollectionEnabled(true); +``` diff --git a/docs/react/analytics/hooks/useSetConsentMutation.mdx b/docs/react/analytics/hooks/useSetConsentMutation.mdx new file mode 100644 index 00000000..1e6083db --- /dev/null +++ b/docs/react/analytics/hooks/useSetConsentMutation.mdx @@ -0,0 +1,23 @@ +--- +title: useSetConsentMutation +--- + +Set end-user consent state for Google Analytics across all gtag references on the page. + +## Usage + +```tsx +import { useSetConsentMutation } from "@tanstack-query-firebase/react/analytics"; + +const { mutate: setAnalyticsConsent } = useSetConsentMutation(); + +setAnalyticsConsent({ + analytics_storage: "granted", + ad_storage: "denied", +}); +``` + +## Notes + +- Wraps Firebase [`setConsent`](https://firebase.google.com/docs/reference/js/analytics.md#setconsent). +- This is a module-level API and does not require an `Analytics` instance. diff --git a/docs/react/analytics/hooks/useSetCurrentScreenMutation.mdx b/docs/react/analytics/hooks/useSetCurrentScreenMutation.mdx new file mode 100644 index 00000000..c6e42006 --- /dev/null +++ b/docs/react/analytics/hooks/useSetCurrentScreenMutation.mdx @@ -0,0 +1,34 @@ +--- +title: useSetCurrentScreenMutation +--- + +Set the current screen name via gtag config. + + +This hook wraps a deprecated Firebase API. Prefer [`useLogEventMutation`](/react/analytics/hooks/useLogEventMutation) with `eventName: "screen_view"` for new code. + + +## Usage + +```tsx +import { getAnalytics } from "firebase/analytics"; +import { useSetCurrentScreenMutation } from "@tanstack-query-firebase/react/analytics"; + +const analytics = getAnalytics(app); +const { mutate: setScreen } = useSetCurrentScreenMutation(analytics); + +setScreen({ screenName: "Home" }); +``` + +## Recommended alternative + +```tsx +import { useLogEventMutation } from "@tanstack-query-firebase/react/analytics"; + +const { mutate: logAnalyticsEvent } = useLogEventMutation(analytics); + +logAnalyticsEvent({ + eventName: "screen_view", + eventParams: { firebase_screen: "Home" }, +}); +``` diff --git a/docs/react/analytics/hooks/useSetDefaultEventParametersMutation.mdx b/docs/react/analytics/hooks/useSetDefaultEventParametersMutation.mdx new file mode 100644 index 00000000..8e92ddbc --- /dev/null +++ b/docs/react/analytics/hooks/useSetDefaultEventParametersMutation.mdx @@ -0,0 +1,22 @@ +--- +title: useSetDefaultEventParametersMutation +--- + +Set default event parameters applied to every Analytics event on the page. + +## Usage + +```tsx +import { useSetDefaultEventParametersMutation } from "@tanstack-query-firebase/react/analytics"; + +const { mutate: setDefaultParams } = useSetDefaultEventParametersMutation(); + +setDefaultParams({ session_id: "abc123", debug_mode: true }); +setDefaultParams({ campaign: "spring_sale" }); +``` + +## Notes + +- Wraps Firebase [`setDefaultEventParameters`](https://firebase.google.com/docs/reference/js/analytics.md#setdefaulteventparameters). +- This is a module-level API and does not require an `Analytics` instance. +- Parameters persist for subsequent automatic and manual events on the page. diff --git a/docs/react/analytics/hooks/useSetUserIdMutation.mdx b/docs/react/analytics/hooks/useSetUserIdMutation.mdx new file mode 100644 index 00000000..63d69ca1 --- /dev/null +++ b/docs/react/analytics/hooks/useSetUserIdMutation.mdx @@ -0,0 +1,28 @@ +--- +title: useSetUserIdMutation +--- + +Set or clear the Google Analytics user ID for a Firebase Analytics instance. + +## Usage + +```tsx +import { getAnalytics } from "firebase/analytics"; +import { useSetUserIdMutation } from "@tanstack-query-firebase/react/analytics"; + +const analytics = getAnalytics(app); +const { mutate: setAnalyticsUserId } = useSetUserIdMutation(analytics); + +setAnalyticsUserId({ id: user.uid }); + +// Clear user ID on sign-out +setAnalyticsUserId({ id: null }); +``` + +## Global user ID + +Apply the user ID across all gtag properties on the page: + +```tsx +setAnalyticsUserId({ id: user.uid, callOptions: { global: true } }); +``` diff --git a/docs/react/analytics/hooks/useSetUserPropertiesMutation.mdx b/docs/react/analytics/hooks/useSetUserPropertiesMutation.mdx new file mode 100644 index 00000000..d4ae14b5 --- /dev/null +++ b/docs/react/analytics/hooks/useSetUserPropertiesMutation.mdx @@ -0,0 +1,31 @@ +--- +title: useSetUserPropertiesMutation +--- + +Set Google Analytics user properties for a Firebase Analytics instance. + +## Usage + +```tsx +import { getAnalytics } from "firebase/analytics"; +import { useSetUserPropertiesMutation } from "@tanstack-query-firebase/react/analytics"; + +const analytics = getAnalytics(app); +const { mutate: setAnalyticsUserProperties } = + useSetUserPropertiesMutation(analytics); + +setAnalyticsUserProperties({ + properties: { plan: "premium", role: "admin" }, +}); +``` + +## Global properties + +Apply properties across all gtag properties on the page: + +```tsx +setAnalyticsUserProperties({ + properties: { plan: "free" }, + callOptions: { global: true }, +}); +``` diff --git a/docs/react/analytics/index.mdx b/docs/react/analytics/index.mdx new file mode 100644 index 00000000..ec65de4f --- /dev/null +++ b/docs/react/analytics/index.mdx @@ -0,0 +1,108 @@ +--- +title: Firebase Analytics +--- + +Firebase Analytics collects usage and behavior data for your app. These hooks wrap the Firebase Analytics web SDK with TanStack Query for reads and writes. + +Before using these hooks, ensure Analytics is enabled in your Firebase project and that the current environment supports it (Analytics is not available in all environments, such as some server-side contexts). + +## Setup + +```ts +import { initializeApp } from "firebase/app"; +import { getAnalytics } from "firebase/analytics"; + +// Initialize your Firebase app +initializeApp({ ... }); + +// Get the Analytics instance (client-side only) +const analytics = getAnalytics(app); +``` + +Use [`useIsSupportedQuery`](/react/analytics/hooks/useIsSupportedQuery) to check whether Analytics is supported before calling `getAnalytics()`. + +## Importing + +Hooks are exported from the `analytics` namespace: + +```ts +import { + analyticsQueryKeys, + useLogEventMutation, +} from "@tanstack-query-firebase/react/analytics"; +``` + +Most hooks accept an `Analytics` instance from `getAnalytics()` or `initializeAnalytics()`. Module-level Firebase APIs (`setConsent`, `setDefaultEventParameters`) are exposed as mutations that do not require an `Analytics` instance. + +## Query keys + +Analytics query hooks require a caller-provided `queryKey` (same as Firestore). Use `analyticsQueryKeys` for consistent keys: + +```tsx +import { analyticsQueryKeys, useIsSupportedQuery } from "@tanstack-query-firebase/react/analytics"; + +const { data: supported } = useIsSupportedQuery({ + queryKey: analyticsQueryKeys.isSupported(), + staleTime: Number.POSITIVE_INFINITY, +}); +``` + +## Logging events + +Use `useLogEventMutation` to send Google Analytics events. For screen tracking, prefer a `screen_view` event over the deprecated `useSetCurrentScreenMutation`: + +```tsx +import { getAnalytics } from "firebase/analytics"; +import { useLogEventMutation } from "@tanstack-query-firebase/react/analytics"; + +const analytics = getAnalytics(app); +const { mutate: logAnalyticsEvent } = useLogEventMutation(analytics); + +logAnalyticsEvent({ + eventName: "screen_view", + eventParams: { + firebase_screen: "Home", + firebase_screen_class: "HomeScreen", + }, +}); +``` + +## User identity and properties + +Set the Analytics user ID and custom user properties with mutation hooks. Clear the user ID on sign-out by passing `null`: + +```tsx +import { useSetUserIdMutation, useSetUserPropertiesMutation } from "@tanstack-query-firebase/react/analytics"; + +const { mutate: setAnalyticsUserId } = useSetUserIdMutation(analytics); +const { mutate: setAnalyticsUserProperties } = useSetUserPropertiesMutation(analytics); + +setAnalyticsUserId({ id: user.uid }); +setAnalyticsUserProperties({ properties: { plan: "premium" } }); + +// On sign-out +setAnalyticsUserId({ id: null }); +``` + +## Consent and collection + +- [`useSetConsentMutation`](/react/analytics/hooks/useSetConsentMutation) — set consent state across all gtag references on the page +- [`useSetAnalyticsCollectionEnabledMutation`](/react/analytics/hooks/useSetAnalyticsCollectionEnabledMutation) — enable or disable collection for the current device +- [`useSetDefaultEventParametersMutation`](/react/analytics/hooks/useSetDefaultEventParametersMutation) — attach default parameters to every event on the page + +## Available hooks + +### Queries + +- [`useGetGoogleAnalyticsClientIdQuery`](/react/analytics/hooks/useGetGoogleAnalyticsClientIdQuery) — retrieve the Google Analytics client ID +- [`useIsSupportedQuery`](/react/analytics/hooks/useIsSupportedQuery) — check whether Analytics is supported in the current environment + +### Mutations + +- [`useLogEventMutation`](/react/analytics/hooks/useLogEventMutation) — log a Google Analytics event +- [`useSetAnalyticsCollectionEnabledMutation`](/react/analytics/hooks/useSetAnalyticsCollectionEnabledMutation) — enable or disable analytics collection +- [`useSetConsentMutation`](/react/analytics/hooks/useSetConsentMutation) — set end-user consent settings +- [`useSetCurrentScreenMutation`](/react/analytics/hooks/useSetCurrentScreenMutation) — set the current screen name (deprecated) +- [`useSetDefaultEventParametersMutation`](/react/analytics/hooks/useSetDefaultEventParametersMutation) — set default event parameters +- [`useSetUserIdMutation`](/react/analytics/hooks/useSetUserIdMutation) — set or clear the Analytics user ID +- [`useSetUserPropertiesMutation`](/react/analytics/hooks/useSetUserPropertiesMutation) — set Analytics user properties diff --git a/docs/react/index.mdx b/docs/react/index.mdx index ce906320..e5fc1f61 100644 --- a/docs/react/index.mdx +++ b/docs/react/index.mdx @@ -89,6 +89,8 @@ function MyApplication() { } ``` -TanStack Query Firebase provides hooks for Firebase services including Authentication, Firestore, Realtime Database, and Data Connect, supporting both mutations and queries. +TanStack Query Firebase provides hooks for Firebase services including Analytics, Authentication, Firestore, Realtime Database, and Data Connect, supporting both mutations and queries. + +See the [Analytics documentation](/react/analytics) for event logging and user property hooks via `@tanstack-query-firebase/react/analytics`. See the [Realtime Database documentation](/react/database) for listener and write hooks via `@tanstack-query-firebase/react/database`. diff --git a/packages/react/package.json b/packages/react/package.json index b97b925e..db0b0daf 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -18,6 +18,10 @@ "import": "./dist/index.js", "types": "./dist/index.d.ts" }, + "./analytics": { + "import": "./dist/analytics/index.js", + "types": "./dist/analytics/index.d.ts" + }, "./auth": { "import": "./dist/auth/index.js", "types": "./dist/auth/index.d.ts" diff --git a/packages/react/src/analytics/index.ts b/packages/react/src/analytics/index.ts index 7460b873..b8016a48 100644 --- a/packages/react/src/analytics/index.ts +++ b/packages/react/src/analytics/index.ts @@ -1,3 +1,10 @@ -// useGetGoogleAnalyticsClientIdQuery -// useLogEventMutation -// useIsSupportedQuery +export { analyticsQueryKeys } from "./queryKeys"; +export { useGetGoogleAnalyticsClientIdQuery } from "./useGetGoogleAnalyticsClientIdQuery"; +export { useIsSupportedQuery } from "./useIsSupportedQuery"; +export { useLogEventMutation } from "./useLogEventMutation"; +export { useSetAnalyticsCollectionEnabledMutation } from "./useSetAnalyticsCollectionEnabledMutation"; +export { useSetConsentMutation } from "./useSetConsentMutation"; +export { useSetCurrentScreenMutation } from "./useSetCurrentScreenMutation"; +export { useSetDefaultEventParametersMutation } from "./useSetDefaultEventParametersMutation"; +export { useSetUserIdMutation } from "./useSetUserIdMutation"; +export { useSetUserPropertiesMutation } from "./useSetUserPropertiesMutation"; diff --git a/packages/react/src/analytics/queryKeys.test.ts b/packages/react/src/analytics/queryKeys.test.ts new file mode 100644 index 00000000..ef043806 --- /dev/null +++ b/packages/react/src/analytics/queryKeys.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "vitest"; +import { analyticsQueryKeys } from "./queryKeys"; + +describe("analyticsQueryKeys", () => { + test("builds a google analytics client id key from app name", () => { + expect(analyticsQueryKeys.googleAnalyticsClientId("my-app")).toEqual([ + "analytics", + "googleAnalyticsClientId", + "my-app", + ]); + }); + + test("builds an isSupported key", () => { + expect(analyticsQueryKeys.isSupported()).toEqual([ + "analytics", + "isSupported", + ]); + }); +}); diff --git a/packages/react/src/analytics/queryKeys.ts b/packages/react/src/analytics/queryKeys.ts new file mode 100644 index 00000000..db96836c --- /dev/null +++ b/packages/react/src/analytics/queryKeys.ts @@ -0,0 +1,22 @@ +/** + * Optional query key helpers for Analytics hooks. + * + * Analytics query hooks require a caller-provided `queryKey` (same as Firestore). + * Use these factories to build consistent keys for invalidation and deduplication. + * + * @example + * useGetGoogleAnalyticsClientIdQuery(analytics, { + * queryKey: analyticsQueryKeys.googleAnalyticsClientId(analytics.app.name), + * }); + * + * @example + * useIsSupportedQuery({ + * queryKey: analyticsQueryKeys.isSupported(), + * }); + */ +export const analyticsQueryKeys = { + all: ["analytics"] as const, + googleAnalyticsClientId: (appName: string) => + [...analyticsQueryKeys.all, "googleAnalyticsClientId", appName] as const, + isSupported: () => [...analyticsQueryKeys.all, "isSupported"] as const, +}; diff --git a/packages/react/src/analytics/types.ts b/packages/react/src/analytics/types.ts new file mode 100644 index 00000000..de75464f --- /dev/null +++ b/packages/react/src/analytics/types.ts @@ -0,0 +1,80 @@ +import type { + UseMutationOptions, + UseQueryOptions, +} from "@tanstack/react-query"; +import type { + AnalyticsCallOptions, + ConsentSettings, + CustomEventName, + CustomParams, + EventNameString, + EventParams, +} from "firebase/analytics"; + +/** + * TanStack Query options for Analytics read hooks. + * Caller must provide `queryKey` (same as Firestore hooks). + * Firebase Analytics read APIs expose no additional query parameters beyond + * the `Analytics` instance passed to the hook. + */ +export type AnalyticsUseQueryOptions = Omit< + UseQueryOptions, + "queryFn" +>; + +export type AnalyticsUseMutationOptions< + TData = unknown, + TError = Error, + TVariables = void, +> = Omit, "mutationFn">; + +/** + * Variables for {@link useLogEventMutation}. + * Mirrors `logEvent(analyticsInstance, eventName, eventParams?, callOptions?)`. + */ +export type LogEventVariables = { + eventName: EventNameString | CustomEventName; + eventParams?: EventParams; + callOptions?: AnalyticsCallOptions; +}; + +/** + * Variables for {@link useSetUserIdMutation}. + * Mirrors `setUserId(analyticsInstance, id, callOptions?)`. + */ +export type SetUserIdVariables = { + id: string | null; + callOptions?: AnalyticsCallOptions; +}; + +/** + * Variables for {@link useSetUserPropertiesMutation}. + * Mirrors `setUserProperties(analyticsInstance, properties, callOptions?)`. + */ +export type SetUserPropertiesVariables = { + properties: CustomParams; + callOptions?: AnalyticsCallOptions; +}; + +/** + * Variables for {@link useSetCurrentScreenMutation}. + * Mirrors `setCurrentScreen(analyticsInstance, screenName, callOptions?)`. + * + * @deprecated Prefer {@link useLogEventMutation} with `eventName: 'screen_view'`. + */ +export type SetCurrentScreenVariables = { + screenName: string; + callOptions?: AnalyticsCallOptions; +}; + +/** + * Variables for {@link useSetConsentMutation}. + * Mirrors `setConsent(consentSettings)`. + */ +export type SetConsentVariables = ConsentSettings; + +/** + * Variables for {@link useSetDefaultEventParametersMutation}. + * Mirrors `setDefaultEventParameters(customParams)`. + */ +export type SetDefaultEventParametersVariables = CustomParams; diff --git a/packages/react/src/analytics/useGetGoogleAnalyticsClientIdQuery.test.tsx b/packages/react/src/analytics/useGetGoogleAnalyticsClientIdQuery.test.tsx new file mode 100644 index 00000000..50f0c51a --- /dev/null +++ b/packages/react/src/analytics/useGetGoogleAnalyticsClientIdQuery.test.tsx @@ -0,0 +1,77 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import type { Analytics } from "firebase/analytics"; +import { getGoogleAnalyticsClientId } from "firebase/analytics"; +import type { FirebaseApp } from "firebase/app"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { queryClient, wrapper } from "../../utils"; +import { analyticsQueryKeys } from "./queryKeys"; +import { useGetGoogleAnalyticsClientIdQuery } from "./useGetGoogleAnalyticsClientIdQuery"; + +vi.mock("firebase/analytics", () => ({ + getGoogleAnalyticsClientId: vi.fn(), +})); + +const mockAnalytics = { + app: { name: "[DEFAULT]" } as FirebaseApp, +} as Analytics; + +describe("useGetGoogleAnalyticsClientIdQuery", () => { + beforeEach(() => { + vi.clearAllMocks(); + queryClient.clear(); + }); + + test("successfully retrieves a Google Analytics client id", async () => { + vi.mocked(getGoogleAnalyticsClientId).mockResolvedValueOnce("client-123"); + + const { result } = renderHook( + () => + useGetGoogleAnalyticsClientIdQuery(mockAnalytics, { + queryKey: analyticsQueryKeys.googleAnalyticsClientId( + mockAnalytics.app.name, + ), + }), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toBe("client-123"); + expect(getGoogleAnalyticsClientId).toHaveBeenCalledWith(mockAnalytics); + }); + + test("handles retrieval failure", async () => { + const error = new Error("Failed to get client id"); + vi.mocked(getGoogleAnalyticsClientId).mockRejectedValueOnce(error); + + const { result } = renderHook( + () => + useGetGoogleAnalyticsClientIdQuery(mockAnalytics, { + queryKey: analyticsQueryKeys.googleAnalyticsClientId( + mockAnalytics.app.name, + ), + }), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(result.current.error).toBe(error); + }); + + test("respects enabled option", async () => { + const { result } = renderHook( + () => + useGetGoogleAnalyticsClientIdQuery(mockAnalytics, { + queryKey: analyticsQueryKeys.googleAnalyticsClientId( + mockAnalytics.app.name, + ), + enabled: false, + }), + { wrapper }, + ); + + expect(result.current.status).toBe("pending"); + expect(getGoogleAnalyticsClientId).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react/src/analytics/useGetGoogleAnalyticsClientIdQuery.ts b/packages/react/src/analytics/useGetGoogleAnalyticsClientIdQuery.ts new file mode 100644 index 00000000..a72e2537 --- /dev/null +++ b/packages/react/src/analytics/useGetGoogleAnalyticsClientIdQuery.ts @@ -0,0 +1,42 @@ +import { useQuery } from "@tanstack/react-query"; +import { type Analytics, getGoogleAnalyticsClientId } from "firebase/analytics"; +import type { AnalyticsUseQueryOptions } from "./types"; + +/** + * Hook to retrieve the Google Analytics client ID for a Firebase Analytics instance. + * + * Wraps {@link getGoogleAnalyticsClientId}. The Firebase API accepts only an + * `Analytics` instance and returns `Promise` — there are no additional + * read options to configure (unlike Firestore's server/cache source selector). + * + * @param analytics - Firebase Analytics instance from `getAnalytics()` or `initializeAnalytics()` + * @param options - TanStack Query options; `queryKey` is required + * @returns TanStack Query result with the client ID string + * + * @example + * import { getAnalytics } from 'firebase/analytics'; + * import { analyticsQueryKeys, useGetGoogleAnalyticsClientIdQuery } from '@tanstack-query-firebase/react/analytics'; + * + * const analytics = getAnalytics(app); + * + * const { data: clientId, isLoading } = useGetGoogleAnalyticsClientIdQuery( + * analytics, + * { queryKey: analyticsQueryKeys.googleAnalyticsClientId(analytics.app.name) }, + * ); + * + * @example + * // Disable until analytics is initialized + * const { data: clientId } = useGetGoogleAnalyticsClientIdQuery(analytics, { + * queryKey: analyticsQueryKeys.googleAnalyticsClientId(analytics.app.name), + * enabled: !!analytics, + * }); + */ +export function useGetGoogleAnalyticsClientIdQuery( + analytics: Analytics, + options: AnalyticsUseQueryOptions, +) { + return useQuery({ + ...options, + queryFn: () => getGoogleAnalyticsClientId(analytics), + }); +} diff --git a/packages/react/src/analytics/useIsSupportedQuery.test.tsx b/packages/react/src/analytics/useIsSupportedQuery.test.tsx new file mode 100644 index 00000000..0f3e778c --- /dev/null +++ b/packages/react/src/analytics/useIsSupportedQuery.test.tsx @@ -0,0 +1,81 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { isSupported } from "firebase/analytics"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { queryClient, wrapper } from "../../utils"; +import { analyticsQueryKeys } from "./queryKeys"; +import { useIsSupportedQuery } from "./useIsSupportedQuery"; + +vi.mock("firebase/analytics", () => ({ + isSupported: vi.fn(), +})); + +describe("useIsSupportedQuery", () => { + beforeEach(() => { + vi.clearAllMocks(); + queryClient.clear(); + }); + + test("returns true when analytics is supported", async () => { + vi.mocked(isSupported).mockResolvedValueOnce(true); + + const { result } = renderHook( + () => + useIsSupportedQuery({ + queryKey: analyticsQueryKeys.isSupported(), + }), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toBe(true); + expect(isSupported).toHaveBeenCalledTimes(1); + }); + + test("returns false when analytics is not supported", async () => { + vi.mocked(isSupported).mockResolvedValueOnce(false); + + const { result } = renderHook( + () => + useIsSupportedQuery({ + queryKey: analyticsQueryKeys.isSupported(), + }), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toBe(false); + }); + + test("handles check failure", async () => { + const error = new Error("Support check failed"); + vi.mocked(isSupported).mockRejectedValueOnce(error); + + const { result } = renderHook( + () => + useIsSupportedQuery({ + queryKey: analyticsQueryKeys.isSupported(), + }), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(result.current.error).toBe(error); + }); + + test("respects enabled option", async () => { + const { result } = renderHook( + () => + useIsSupportedQuery({ + queryKey: analyticsQueryKeys.isSupported(), + enabled: false, + }), + { wrapper }, + ); + + expect(result.current.status).toBe("pending"); + expect(isSupported).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react/src/analytics/useIsSupportedQuery.ts b/packages/react/src/analytics/useIsSupportedQuery.ts new file mode 100644 index 00000000..3e49db5d --- /dev/null +++ b/packages/react/src/analytics/useIsSupportedQuery.ts @@ -0,0 +1,39 @@ +import { useQuery } from "@tanstack/react-query"; +import { isSupported } from "firebase/analytics"; +import type { AnalyticsUseQueryOptions } from "./types"; + +/** + * Hook to check whether Firebase Analytics is supported in the current environment. + * + * Wraps {@link isSupported}. The Firebase API takes no parameters and returns + * `Promise` — there are no additional read options to configure. + * + * @param options - TanStack Query options; `queryKey` is required + * @returns TanStack Query result with the support check boolean + * + * @example + * import { analyticsQueryKeys, useIsSupportedQuery } from '@tanstack-query-firebase/react/analytics'; + * + * const { data: supported, isLoading } = useIsSupportedQuery({ + * queryKey: analyticsQueryKeys.isSupported(), + * }); + * + * if (supported) { + * // Safe to call getAnalytics() + * } + * + * @example + * // Gate analytics initialization + * const { data: supported } = useIsSupportedQuery({ + * queryKey: analyticsQueryKeys.isSupported(), + * staleTime: Number.POSITIVE_INFINITY, + * }); + */ +export function useIsSupportedQuery( + options: AnalyticsUseQueryOptions, +) { + return useQuery({ + ...options, + queryFn: () => isSupported(), + }); +} diff --git a/packages/react/src/analytics/useLogEventMutation.test.tsx b/packages/react/src/analytics/useLogEventMutation.test.tsx new file mode 100644 index 00000000..ea9d709c --- /dev/null +++ b/packages/react/src/analytics/useLogEventMutation.test.tsx @@ -0,0 +1,105 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { Analytics } from "firebase/analytics"; +import { logEvent } from "firebase/analytics"; +import type { FirebaseApp } from "firebase/app"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { queryClient, wrapper } from "../../utils"; +import { useLogEventMutation } from "./useLogEventMutation"; + +vi.mock("firebase/analytics", () => ({ + logEvent: vi.fn(), +})); + +const mockAnalytics = { + app: { name: "[DEFAULT]" } as FirebaseApp, +} as Analytics; + +describe("useLogEventMutation", () => { + beforeEach(() => { + vi.clearAllMocks(); + queryClient.clear(); + }); + + test("successfully logs an event", async () => { + const { result } = renderHook(() => useLogEventMutation(mockAnalytics), { + wrapper, + }); + + await act(async () => { + result.current.mutate({ + eventName: "login", + eventParams: { method: "email" }, + }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(logEvent).toHaveBeenCalledWith( + mockAnalytics, + "login", + { + method: "email", + }, + undefined, + ); + }); + + test("passes analytics call options", async () => { + const { result } = renderHook(() => useLogEventMutation(mockAnalytics), { + wrapper, + }); + + await act(async () => { + result.current.mutate({ + eventName: "page_view", + eventParams: { page_title: "Home" }, + callOptions: { global: true }, + }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(logEvent).toHaveBeenCalledWith( + mockAnalytics, + "page_view", + { page_title: "Home" }, + { global: true }, + ); + }); + + test("handles log event failure", async () => { + const error = new Error("Failed to log event"); + vi.mocked(logEvent).mockImplementationOnce(() => { + throw error; + }); + + const { result } = renderHook(() => useLogEventMutation(mockAnalytics), { + wrapper, + }); + + await act(async () => { + result.current.mutate({ eventName: "login" }); + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(result.current.error).toBe(error); + }); + + test("calls onSuccess callback after successful log", async () => { + const onSuccessMock = vi.fn(); + + const { result } = renderHook( + () => useLogEventMutation(mockAnalytics, { onSuccess: onSuccessMock }), + { wrapper }, + ); + + await act(async () => { + result.current.mutate({ eventName: "sign_up" }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(onSuccessMock).toHaveBeenCalled(); + }); +}); diff --git a/packages/react/src/analytics/useLogEventMutation.ts b/packages/react/src/analytics/useLogEventMutation.ts new file mode 100644 index 00000000..1673699e --- /dev/null +++ b/packages/react/src/analytics/useLogEventMutation.ts @@ -0,0 +1,56 @@ +import { useMutation } from "@tanstack/react-query"; +import { type Analytics, logEvent } from "firebase/analytics"; +import type { AnalyticsUseMutationOptions, LogEventVariables } from "./types"; + +/** + * Hook to log a Google Analytics event via Firebase Analytics. + * + * Wraps {@link logEvent}. Pass event details as mutation variables; each field + * maps directly to the Firebase API: + * `logEvent(analyticsInstance, eventName, eventParams?, callOptions?)`. + * + * @param analytics - Firebase Analytics instance from `getAnalytics()` or `initializeAnalytics()` + * @param options - TanStack Query mutation options + * @returns TanStack Query mutation result + * + * @example + * import { getAnalytics, logEvent } from 'firebase/analytics'; + * import { useLogEventMutation } from '@tanstack-query-firebase/react/analytics'; + * + * const analytics = getAnalytics(app); + * const { mutate: logAnalyticsEvent } = useLogEventMutation(analytics); + * + * logAnalyticsEvent({ + * eventName: 'login', + * eventParams: { method: 'email' }, + * }); + * + * @example + * // Recommended screen tracking (replaces deprecated setCurrentScreen) + * logAnalyticsEvent({ + * eventName: 'screen_view', + * eventParams: { + * firebase_screen: 'Home', + * firebase_screen_class: 'HomeScreen', + * }, + * }); + * + * @example + * // Apply to all Google Analytics properties on the page + * logAnalyticsEvent({ + * eventName: 'purchase', + * eventParams: { transaction_id: 'T123', value: 9.99, currency: 'USD' }, + * callOptions: { global: true }, + * }); + */ +export function useLogEventMutation( + analytics: Analytics, + options?: AnalyticsUseMutationOptions, +) { + return useMutation({ + ...options, + mutationFn: async ({ eventName, eventParams, callOptions }) => { + logEvent(analytics, eventName, eventParams, callOptions); + }, + }); +} diff --git a/packages/react/src/analytics/useSetAnalyticsCollectionEnabledMutation.test.tsx b/packages/react/src/analytics/useSetAnalyticsCollectionEnabledMutation.test.tsx new file mode 100644 index 00000000..2e48e3b2 --- /dev/null +++ b/packages/react/src/analytics/useSetAnalyticsCollectionEnabledMutation.test.tsx @@ -0,0 +1,98 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { Analytics } from "firebase/analytics"; +import { setAnalyticsCollectionEnabled } from "firebase/analytics"; +import type { FirebaseApp } from "firebase/app"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { queryClient, wrapper } from "../../utils"; +import { useSetAnalyticsCollectionEnabledMutation } from "./useSetAnalyticsCollectionEnabledMutation"; + +vi.mock("firebase/analytics", () => ({ + setAnalyticsCollectionEnabled: vi.fn(), +})); + +const mockAnalytics = { + app: { name: "[DEFAULT]" } as FirebaseApp, +} as Analytics; + +describe("useSetAnalyticsCollectionEnabledMutation", () => { + beforeEach(() => { + vi.clearAllMocks(); + queryClient.clear(); + }); + + test("successfully enables analytics collection", async () => { + const { result } = renderHook( + () => useSetAnalyticsCollectionEnabledMutation(mockAnalytics), + { wrapper }, + ); + + await act(async () => { + result.current.mutate(true); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(setAnalyticsCollectionEnabled).toHaveBeenCalledWith( + mockAnalytics, + true, + ); + }); + + test("successfully disables analytics collection", async () => { + const { result } = renderHook( + () => useSetAnalyticsCollectionEnabledMutation(mockAnalytics), + { wrapper }, + ); + + await act(async () => { + result.current.mutate(false); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(setAnalyticsCollectionEnabled).toHaveBeenCalledWith( + mockAnalytics, + false, + ); + }); + + test("handles failure", async () => { + const error = new Error("Failed to set collection enabled"); + vi.mocked(setAnalyticsCollectionEnabled).mockImplementationOnce(() => { + throw error; + }); + + const { result } = renderHook( + () => useSetAnalyticsCollectionEnabledMutation(mockAnalytics), + { wrapper }, + ); + + await act(async () => { + result.current.mutate(true); + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(result.current.error).toBe(error); + }); + + test("calls onSuccess callback after successful update", async () => { + const onSuccessMock = vi.fn(); + + const { result } = renderHook( + () => + useSetAnalyticsCollectionEnabledMutation(mockAnalytics, { + onSuccess: onSuccessMock, + }), + { wrapper }, + ); + + await act(async () => { + result.current.mutate(true); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(onSuccessMock).toHaveBeenCalled(); + }); +}); diff --git a/packages/react/src/analytics/useSetAnalyticsCollectionEnabledMutation.ts b/packages/react/src/analytics/useSetAnalyticsCollectionEnabledMutation.ts new file mode 100644 index 00000000..f29d9b1f --- /dev/null +++ b/packages/react/src/analytics/useSetAnalyticsCollectionEnabledMutation.ts @@ -0,0 +1,44 @@ +import { useMutation } from "@tanstack/react-query"; +import { + type Analytics, + setAnalyticsCollectionEnabled, +} from "firebase/analytics"; +import type { AnalyticsUseMutationOptions } from "./types"; + +/** + * Hook to enable or disable Google Analytics collection for an app on the current device. + * + * Wraps {@link setAnalyticsCollectionEnabled}. The mutation variable is the + * `enabled` boolean passed directly to the Firebase API: + * `setAnalyticsCollectionEnabled(analyticsInstance, enabled)`. + * + * @param analytics - Firebase Analytics instance from `getAnalytics()` or `initializeAnalytics()` + * @param options - TanStack Query mutation options + * @returns TanStack Query mutation result + * + * @example + * import { getAnalytics } from 'firebase/analytics'; + * import { useSetAnalyticsCollectionEnabledMutation } from '@tanstack-query-firebase/react/analytics'; + * + * const analytics = getAnalytics(app); + * const { mutate: setCollectionEnabled } = + * useSetAnalyticsCollectionEnabledMutation(analytics); + * + * // Opt user out of analytics collection + * setCollectionEnabled(false); + * + * @example + * // Re-enable after consent is granted + * setCollectionEnabled(true); + */ +export function useSetAnalyticsCollectionEnabledMutation( + analytics: Analytics, + options?: AnalyticsUseMutationOptions, +) { + return useMutation({ + ...options, + mutationFn: async (enabled) => { + setAnalyticsCollectionEnabled(analytics, enabled); + }, + }); +} diff --git a/packages/react/src/analytics/useSetConsentMutation.test.tsx b/packages/react/src/analytics/useSetConsentMutation.test.tsx new file mode 100644 index 00000000..3425d51c --- /dev/null +++ b/packages/react/src/analytics/useSetConsentMutation.test.tsx @@ -0,0 +1,67 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { setConsent } from "firebase/analytics"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { queryClient, wrapper } from "../../utils"; +import { useSetConsentMutation } from "./useSetConsentMutation"; + +vi.mock("firebase/analytics", () => ({ + setConsent: vi.fn(), +})); + +describe("useSetConsentMutation", () => { + beforeEach(() => { + vi.clearAllMocks(); + queryClient.clear(); + }); + + test("successfully sets consent settings", async () => { + const consentSettings = { + analytics_storage: "granted" as const, + ad_storage: "denied" as const, + }; + + const { result } = renderHook(() => useSetConsentMutation(), { wrapper }); + + await act(async () => { + result.current.mutate(consentSettings); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(setConsent).toHaveBeenCalledWith(consentSettings); + }); + + test("handles failure", async () => { + const error = new Error("Failed to set consent"); + vi.mocked(setConsent).mockImplementationOnce(() => { + throw error; + }); + + const { result } = renderHook(() => useSetConsentMutation(), { wrapper }); + + await act(async () => { + result.current.mutate({ analytics_storage: "granted" }); + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(result.current.error).toBe(error); + }); + + test("calls onSuccess callback after successful update", async () => { + const onSuccessMock = vi.fn(); + + const { result } = renderHook( + () => useSetConsentMutation({ onSuccess: onSuccessMock }), + { wrapper }, + ); + + await act(async () => { + result.current.mutate({ analytics_storage: "granted" }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(onSuccessMock).toHaveBeenCalled(); + }); +}); diff --git a/packages/react/src/analytics/useSetConsentMutation.ts b/packages/react/src/analytics/useSetConsentMutation.ts new file mode 100644 index 00000000..54468949 --- /dev/null +++ b/packages/react/src/analytics/useSetConsentMutation.ts @@ -0,0 +1,43 @@ +import { useMutation } from "@tanstack/react-query"; +import { setConsent } from "firebase/analytics"; +import type { AnalyticsUseMutationOptions, SetConsentVariables } from "./types"; + +/** + * Hook to set end-user consent state for Google Analytics across all gtag references. + * + * Wraps {@link setConsent}. This is a module-level Firebase API (no `Analytics` + * instance). The mutation variable is the `ConsentSettings` object passed + * directly to the Firebase API: `setConsent(consentSettings)`. + * + * @param options - TanStack Query mutation options + * @returns TanStack Query mutation result + * + * @example + * import { useSetConsentMutation } from '@tanstack-query-firebase/react/analytics'; + * + * const { mutate: setAnalyticsConsent } = useSetConsentMutation(); + * + * setAnalyticsConsent({ + * analytics_storage: 'granted', + * ad_storage: 'denied', + * }); + * + * @example + * // Update consent after the user accepts a CMP banner + * setAnalyticsConsent({ + * analytics_storage: 'granted', + * ad_storage: 'granted', + * ad_user_data: 'granted', + * ad_personalization: 'denied', + * }); + */ +export function useSetConsentMutation( + options?: AnalyticsUseMutationOptions, +) { + return useMutation({ + ...options, + mutationFn: async (consentSettings) => { + setConsent(consentSettings); + }, + }); +} diff --git a/packages/react/src/analytics/useSetCurrentScreenMutation.test.tsx b/packages/react/src/analytics/useSetCurrentScreenMutation.test.tsx new file mode 100644 index 00000000..5cd0f9db --- /dev/null +++ b/packages/react/src/analytics/useSetCurrentScreenMutation.test.tsx @@ -0,0 +1,81 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { Analytics } from "firebase/analytics"; +import { setCurrentScreen } from "firebase/analytics"; +import type { FirebaseApp } from "firebase/app"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { queryClient, wrapper } from "../../utils"; +import { useSetCurrentScreenMutation } from "./useSetCurrentScreenMutation"; + +vi.mock("firebase/analytics", () => ({ + setCurrentScreen: vi.fn(), +})); + +const mockAnalytics = { + app: { name: "[DEFAULT]" } as FirebaseApp, +} as Analytics; + +describe("useSetCurrentScreenMutation", () => { + beforeEach(() => { + vi.clearAllMocks(); + queryClient.clear(); + }); + + test("successfully sets the current screen", async () => { + const { result } = renderHook( + () => useSetCurrentScreenMutation(mockAnalytics), + { wrapper }, + ); + + await act(async () => { + result.current.mutate({ screenName: "Home" }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(setCurrentScreen).toHaveBeenCalledWith( + mockAnalytics, + "Home", + undefined, + ); + }); + + test("passes analytics call options", async () => { + const { result } = renderHook( + () => useSetCurrentScreenMutation(mockAnalytics), + { wrapper }, + ); + + await act(async () => { + result.current.mutate({ + screenName: "Settings", + callOptions: { global: true }, + }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(setCurrentScreen).toHaveBeenCalledWith(mockAnalytics, "Settings", { + global: true, + }); + }); + + test("handles failure", async () => { + const error = new Error("Failed to set current screen"); + vi.mocked(setCurrentScreen).mockImplementationOnce(() => { + throw error; + }); + + const { result } = renderHook( + () => useSetCurrentScreenMutation(mockAnalytics), + { wrapper }, + ); + + await act(async () => { + result.current.mutate({ screenName: "Home" }); + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(result.current.error).toBe(error); + }); +}); diff --git a/packages/react/src/analytics/useSetCurrentScreenMutation.ts b/packages/react/src/analytics/useSetCurrentScreenMutation.ts new file mode 100644 index 00000000..72d76177 --- /dev/null +++ b/packages/react/src/analytics/useSetCurrentScreenMutation.ts @@ -0,0 +1,50 @@ +import { useMutation } from "@tanstack/react-query"; +import { type Analytics, setCurrentScreen } from "firebase/analytics"; +import type { + AnalyticsUseMutationOptions, + SetCurrentScreenVariables, +} from "./types"; + +/** + * Hook to set the current screen name via gtag config. + * + * Wraps {@link setCurrentScreen}. Mutation variables mirror the Firebase API: + * `setCurrentScreen(analyticsInstance, screenName, callOptions?)`. + * + * @deprecated Prefer {@link useLogEventMutation} with `eventName: 'screen_view'` + * and `eventParams.firebase_screen` / `eventParams.firebase_screen_class`. + * + * @param analytics - Firebase Analytics instance from `getAnalytics()` or `initializeAnalytics()` + * @param options - TanStack Query mutation options + * @returns TanStack Query mutation result + * + * @example + * import { getAnalytics } from 'firebase/analytics'; + * import { useSetCurrentScreenMutation } from '@tanstack-query-firebase/react/analytics'; + * + * const analytics = getAnalytics(app); + * const { mutate: setScreen } = useSetCurrentScreenMutation(analytics); + * + * setScreen({ screenName: 'Home' }); + * + * @example + * // Prefer screen_view events for new code + * import { useLogEventMutation } from '@tanstack-query-firebase/react/analytics'; + * + * const { mutate: logEvent } = useLogEventMutation(analytics); + * logEvent({ + * eventName: 'screen_view', + * eventParams: { firebase_screen: 'Home' }, + * }); + */ +export function useSetCurrentScreenMutation( + analytics: Analytics, + options?: AnalyticsUseMutationOptions, +) { + return useMutation({ + ...options, + mutationFn: async ({ screenName, callOptions }) => { + setCurrentScreen(analytics, screenName, callOptions); + }, + }); +} diff --git a/packages/react/src/analytics/useSetDefaultEventParametersMutation.test.tsx b/packages/react/src/analytics/useSetDefaultEventParametersMutation.test.tsx new file mode 100644 index 00000000..3c2dbf23 --- /dev/null +++ b/packages/react/src/analytics/useSetDefaultEventParametersMutation.test.tsx @@ -0,0 +1,70 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { setDefaultEventParameters } from "firebase/analytics"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { queryClient, wrapper } from "../../utils"; +import { useSetDefaultEventParametersMutation } from "./useSetDefaultEventParametersMutation"; + +vi.mock("firebase/analytics", () => ({ + setDefaultEventParameters: vi.fn(), +})); + +describe("useSetDefaultEventParametersMutation", () => { + beforeEach(() => { + vi.clearAllMocks(); + queryClient.clear(); + }); + + test("successfully sets default event parameters", async () => { + const customParams = { session_id: "abc123", debug_mode: true }; + + const { result } = renderHook( + () => useSetDefaultEventParametersMutation(), + { wrapper }, + ); + + await act(async () => { + result.current.mutate(customParams); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(setDefaultEventParameters).toHaveBeenCalledWith(customParams); + }); + + test("handles failure", async () => { + const error = new Error("Failed to set default event parameters"); + vi.mocked(setDefaultEventParameters).mockImplementationOnce(() => { + throw error; + }); + + const { result } = renderHook( + () => useSetDefaultEventParametersMutation(), + { wrapper }, + ); + + await act(async () => { + result.current.mutate({ session_id: "abc123" }); + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(result.current.error).toBe(error); + }); + + test("calls onSuccess callback after successful update", async () => { + const onSuccessMock = vi.fn(); + + const { result } = renderHook( + () => useSetDefaultEventParametersMutation({ onSuccess: onSuccessMock }), + { wrapper }, + ); + + await act(async () => { + result.current.mutate({ session_id: "abc123" }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(onSuccessMock).toHaveBeenCalled(); + }); +}); diff --git a/packages/react/src/analytics/useSetDefaultEventParametersMutation.ts b/packages/react/src/analytics/useSetDefaultEventParametersMutation.ts new file mode 100644 index 00000000..80409ace --- /dev/null +++ b/packages/react/src/analytics/useSetDefaultEventParametersMutation.ts @@ -0,0 +1,42 @@ +import { useMutation } from "@tanstack/react-query"; +import { setDefaultEventParameters } from "firebase/analytics"; +import type { + AnalyticsUseMutationOptions, + SetDefaultEventParametersVariables, +} from "./types"; + +/** + * Hook to set default event parameters applied to every Analytics event on the page. + * + * Wraps {@link setDefaultEventParameters}. This is a module-level Firebase API + * (no `Analytics` instance). The mutation variable is the `CustomParams` object + * passed directly to the Firebase API: `setDefaultEventParameters(customParams)`. + * + * @param options - TanStack Query mutation options + * @returns TanStack Query mutation result + * + * @example + * import { useSetDefaultEventParametersMutation } from '@tanstack-query-firebase/react/analytics'; + * + * const { mutate: setDefaultParams } = useSetDefaultEventParametersMutation(); + * + * setDefaultParams({ session_id: 'abc123', debug_mode: true }); + * + * @example + * // Parameters persist for subsequent automatic and manual events on this page + * setDefaultParams({ campaign: 'spring_sale' }); + */ +export function useSetDefaultEventParametersMutation( + options?: AnalyticsUseMutationOptions< + void, + Error, + SetDefaultEventParametersVariables + >, +) { + return useMutation({ + ...options, + mutationFn: async (customParams) => { + setDefaultEventParameters(customParams); + }, + }); +} diff --git a/packages/react/src/analytics/useSetUserIdMutation.test.tsx b/packages/react/src/analytics/useSetUserIdMutation.test.tsx new file mode 100644 index 00000000..d94037b3 --- /dev/null +++ b/packages/react/src/analytics/useSetUserIdMutation.test.tsx @@ -0,0 +1,89 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { Analytics } from "firebase/analytics"; +import { setUserId } from "firebase/analytics"; +import type { FirebaseApp } from "firebase/app"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { queryClient, wrapper } from "../../utils"; +import { useSetUserIdMutation } from "./useSetUserIdMutation"; + +vi.mock("firebase/analytics", () => ({ + setUserId: vi.fn(), +})); + +const mockAnalytics = { + app: { name: "[DEFAULT]" } as FirebaseApp, +} as Analytics; + +describe("useSetUserIdMutation", () => { + beforeEach(() => { + vi.clearAllMocks(); + queryClient.clear(); + }); + + test("successfully sets a user id", async () => { + const { result } = renderHook(() => useSetUserIdMutation(mockAnalytics), { + wrapper, + }); + + await act(async () => { + result.current.mutate({ id: "user-123" }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(setUserId).toHaveBeenCalledWith( + mockAnalytics, + "user-123", + undefined, + ); + }); + + test("successfully clears a user id", async () => { + const { result } = renderHook(() => useSetUserIdMutation(mockAnalytics), { + wrapper, + }); + + await act(async () => { + result.current.mutate({ id: null }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(setUserId).toHaveBeenCalledWith(mockAnalytics, null, undefined); + }); + + test("passes analytics call options", async () => { + const { result } = renderHook(() => useSetUserIdMutation(mockAnalytics), { + wrapper, + }); + + await act(async () => { + result.current.mutate({ id: "user-123", callOptions: { global: true } }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(setUserId).toHaveBeenCalledWith(mockAnalytics, "user-123", { + global: true, + }); + }); + + test("handles failure", async () => { + const error = new Error("Failed to set user id"); + vi.mocked(setUserId).mockImplementationOnce(() => { + throw error; + }); + + const { result } = renderHook(() => useSetUserIdMutation(mockAnalytics), { + wrapper, + }); + + await act(async () => { + result.current.mutate({ id: "user-123" }); + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(result.current.error).toBe(error); + }); +}); diff --git a/packages/react/src/analytics/useSetUserIdMutation.ts b/packages/react/src/analytics/useSetUserIdMutation.ts new file mode 100644 index 00000000..3b2840dd --- /dev/null +++ b/packages/react/src/analytics/useSetUserIdMutation.ts @@ -0,0 +1,42 @@ +import { useMutation } from "@tanstack/react-query"; +import { type Analytics, setUserId } from "firebase/analytics"; +import type { AnalyticsUseMutationOptions, SetUserIdVariables } from "./types"; + +/** + * Hook to set or clear the Google Analytics user ID for a Firebase Analytics instance. + * + * Wraps {@link setUserId}. Mutation variables mirror the Firebase API: + * `setUserId(analyticsInstance, id, callOptions?)`. + * + * @param analytics - Firebase Analytics instance from `getAnalytics()` or `initializeAnalytics()` + * @param options - TanStack Query mutation options + * @returns TanStack Query mutation result + * + * @example + * import { getAnalytics } from 'firebase/analytics'; + * import { useSetUserIdMutation } from '@tanstack-query-firebase/react/analytics'; + * + * const analytics = getAnalytics(app); + * const { mutate: setAnalyticsUserId } = useSetUserIdMutation(analytics); + * + * setAnalyticsUserId({ id: user.uid }); + * + * @example + * // Clear user ID on sign-out + * setAnalyticsUserId({ id: null }); + * + * @example + * // Apply user ID globally across all gtag properties on the page + * setAnalyticsUserId({ id: user.uid, callOptions: { global: true } }); + */ +export function useSetUserIdMutation( + analytics: Analytics, + options?: AnalyticsUseMutationOptions, +) { + return useMutation({ + ...options, + mutationFn: async ({ id, callOptions }) => { + setUserId(analytics, id, callOptions); + }, + }); +} diff --git a/packages/react/src/analytics/useSetUserPropertiesMutation.test.tsx b/packages/react/src/analytics/useSetUserPropertiesMutation.test.tsx new file mode 100644 index 00000000..17020051 --- /dev/null +++ b/packages/react/src/analytics/useSetUserPropertiesMutation.test.tsx @@ -0,0 +1,82 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { Analytics } from "firebase/analytics"; +import { setUserProperties } from "firebase/analytics"; +import type { FirebaseApp } from "firebase/app"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { queryClient, wrapper } from "../../utils"; +import { useSetUserPropertiesMutation } from "./useSetUserPropertiesMutation"; + +vi.mock("firebase/analytics", () => ({ + setUserProperties: vi.fn(), +})); + +const mockAnalytics = { + app: { name: "[DEFAULT]" } as FirebaseApp, +} as Analytics; + +describe("useSetUserPropertiesMutation", () => { + beforeEach(() => { + vi.clearAllMocks(); + queryClient.clear(); + }); + + test("successfully sets user properties", async () => { + const properties = { favorite_food: "pizza", plan: "premium" }; + + const { result } = renderHook( + () => useSetUserPropertiesMutation(mockAnalytics), + { wrapper }, + ); + + await act(async () => { + result.current.mutate({ properties }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(setUserProperties).toHaveBeenCalledWith( + mockAnalytics, + properties, + undefined, + ); + }); + + test("passes analytics call options", async () => { + const properties = { role: "admin" }; + + const { result } = renderHook( + () => useSetUserPropertiesMutation(mockAnalytics), + { wrapper }, + ); + + await act(async () => { + result.current.mutate({ properties, callOptions: { global: true } }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(setUserProperties).toHaveBeenCalledWith(mockAnalytics, properties, { + global: true, + }); + }); + + test("handles failure", async () => { + const error = new Error("Failed to set user properties"); + vi.mocked(setUserProperties).mockImplementationOnce(() => { + throw error; + }); + + const { result } = renderHook( + () => useSetUserPropertiesMutation(mockAnalytics), + { wrapper }, + ); + + await act(async () => { + result.current.mutate({ properties: { role: "admin" } }); + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(result.current.error).toBe(error); + }); +}); diff --git a/packages/react/src/analytics/useSetUserPropertiesMutation.ts b/packages/react/src/analytics/useSetUserPropertiesMutation.ts new file mode 100644 index 00000000..3a9f0694 --- /dev/null +++ b/packages/react/src/analytics/useSetUserPropertiesMutation.ts @@ -0,0 +1,50 @@ +import { useMutation } from "@tanstack/react-query"; +import { type Analytics, setUserProperties } from "firebase/analytics"; +import type { + AnalyticsUseMutationOptions, + SetUserPropertiesVariables, +} from "./types"; + +/** + * Hook to set Google Analytics user properties for a Firebase Analytics instance. + * + * Wraps {@link setUserProperties}. Mutation variables mirror the Firebase API: + * `setUserProperties(analyticsInstance, properties, callOptions?)`. + * + * @param analytics - Firebase Analytics instance from `getAnalytics()` or `initializeAnalytics()` + * @param options - TanStack Query mutation options + * @returns TanStack Query mutation result + * + * @example + * import { getAnalytics } from 'firebase/analytics'; + * import { useSetUserPropertiesMutation } from '@tanstack-query-firebase/react/analytics'; + * + * const analytics = getAnalytics(app); + * const { mutate: setAnalyticsUserProperties } = + * useSetUserPropertiesMutation(analytics); + * + * setAnalyticsUserProperties({ + * properties: { plan: 'premium', role: 'admin' }, + * }); + * + * @example + * setAnalyticsUserProperties({ + * properties: { plan: 'free' }, + * callOptions: { global: true }, + * }); + */ +export function useSetUserPropertiesMutation( + analytics: Analytics, + options?: AnalyticsUseMutationOptions< + void, + Error, + SetUserPropertiesVariables + >, +) { + return useMutation({ + ...options, + mutationFn: async ({ properties, callOptions }) => { + setUserProperties(analytics, properties, callOptions); + }, + }); +} diff --git a/packages/react/tsup.config.ts b/packages/react/tsup.config.ts index 6a55ddd7..d6214b3b 100644 --- a/packages/react/tsup.config.ts +++ b/packages/react/tsup.config.ts @@ -1,7 +1,13 @@ import * as fs from "node:fs/promises"; import { defineConfig } from "tsup"; -const supportedPackages = ["data-connect", "firestore", "auth", "database"]; +const supportedPackages = [ + "analytics", + "data-connect", + "firestore", + "auth", + "database", +]; export default defineConfig({ entry: [`src/(${supportedPackages.join("|")})/index.ts`, "src/index.ts"], format: ["esm"],