diff --git a/packages/pages-components/src/components/analytics/Analytics.ts b/packages/pages-components/src/components/analytics/Analytics.ts index 07933356..28d78038 100644 --- a/packages/pages-components/src/components/analytics/Analytics.ts +++ b/packages/pages-components/src/components/analytics/Analytics.ts @@ -111,7 +111,7 @@ export class Analytics implements AnalyticsMethods { } } - /** {@inheritDoc AnalyticsMethods.async} */ + /** {@inheritDoc AnalyticsMethods.optIn} */ async optIn(): Promise { this._optedIn = true; this.makeReporter(); @@ -121,7 +121,12 @@ export class Analytics implements AnalyticsMethods { } } - /** {@inheritDoc AnalyticsMethods.async} */ + /** {@inheritDoc AnalyticsMethods.optOut} */ + optOut(): void { + this._optedIn = false; + } + + /** {@inheritDoc AnalyticsMethods.pageView} */ async pageView(): Promise { if (!this.canTrack()) { return Promise.resolve(undefined); @@ -168,4 +173,9 @@ export class Analytics implements AnalyticsMethods { getDebugEnabled(): boolean { return this.enableDebugging || debuggingParamDetected(); } + + /** {@inheritDoc AnalyticsMethods.isYextAnalyticsEnabled} */ + isYextAnalyticsEnabled(): boolean { + return this._optedIn; + } } diff --git a/packages/pages-components/src/components/analytics/hooks.ts b/packages/pages-components/src/components/analytics/hooks.ts index b53144c1..178cf974 100644 --- a/packages/pages-components/src/components/analytics/hooks.ts +++ b/packages/pages-components/src/components/analytics/hooks.ts @@ -1,14 +1,6 @@ import { useContext } from "react"; -import { getRuntime } from "../../util/index.js"; import { AnalyticsContext } from "./context.js"; -import { AnalyticsMethods, TrackProps } from "./interfaces.js"; -import { useScope } from "./scope.js"; - -declare global { - interface Window { - setAnalyticsOptIn: () => void; - } -} +import { AnalyticsMethods } from "./interfaces.js"; /** * The useAnalytics hook can be used anywhere in the tree below a configured @@ -19,68 +11,5 @@ declare global { * @public */ export function useAnalytics(): AnalyticsMethods | null { - const ctx = useContext(AnalyticsContext); - - if (!ctx) { - return ctx; - } - - // TODO: is this the right way / place to expose a callback for use by a Cookie Management banner? - if (getRuntime().name === "browser" && !window.setAnalyticsOptIn) { - window.setAnalyticsOptIn = async () => { - await ctx.optIn(); - }; - } - - // eslint-disable-next-line react-hooks/rules-of-hooks - const scope = useScope(); - - // TODO: this is ugly, I imagine there is a more elegant way of doing this - return { - getDebugEnabled(): boolean { - return ctx.getDebugEnabled(); - }, - identify(visitor: Record): void { - return ctx.identify(visitor); - }, - optIn(): Promise { - return ctx.optIn(); - }, - pageView(): Promise { - return ctx.pageView(); - }, - track(props: TrackProps): Promise { - return ctx.track({ - ...props, - scope: props.scope ?? scope, // prefer specific scope over hook - }); - }, - }; + return useContext(AnalyticsContext); } - -/** - * Simpler hook that just returns the analytics track() method. - * - * @public - */ -export const useTrack = () => { - return useAnalytics()?.track; -}; - -/** - * Simpler hook that just returns the analytics pageView method - * - * @public - */ -export const usePageView = () => { - return useAnalytics()?.pageView; -}; - -/** - * Simpler hook that just returns the analytics identify method - * - * @public - */ -export const useIdentify = () => { - return useAnalytics()?.identify; -}; diff --git a/packages/pages-components/src/components/analytics/interfaces.ts b/packages/pages-components/src/components/analytics/interfaces.ts index 0491cab0..bd94da6f 100644 --- a/packages/pages-components/src/components/analytics/interfaces.ts +++ b/packages/pages-components/src/components/analytics/interfaces.ts @@ -44,10 +44,19 @@ export interface AnalyticsMethods { */ optIn(): Promise; + /** + * Allows you to opt a user out of analytics tracking, for example if they withdraw + * consent via a Consent Management Banner or other opt-out method. + */ + optOut(): void; + /** * Use the getDebugEnabled method to retrieve whether debugging is on or off. */ getDebugEnabled(): boolean; + + /** Returns whether analytics reporting is currently enabled */ + isYextAnalyticsEnabled(): boolean; } /** diff --git a/packages/pages-components/src/components/analytics/provider.tsx b/packages/pages-components/src/components/analytics/provider.tsx index 4b66a325..8648fd21 100644 --- a/packages/pages-components/src/components/analytics/provider.tsx +++ b/packages/pages-components/src/components/analytics/provider.tsx @@ -52,14 +52,22 @@ export function AnalyticsProvider( const analytics = analyticsRef.current; - // Adds enableYextAnalytics to the window. Typically used during consent banner implementation. + // Adds global callbacks typically used during consent banner implementation. useEffect(() => { - (window as any).enableYextAnalytics = () => { - analytics.optIn(); + const globalWindow = window as Window & { + enableYextAnalytics?: () => void; }; + const optIn = () => { + analytics.optIn().catch((err) => { + console.error("Yext Analytics optIn failed:", err); + }); + }; + + globalWindow.enableYextAnalytics = optIn; + return () => { - delete (window as any).enableYextAnalytics; + delete globalWindow.enableYextAnalytics; }; }, [analytics]); diff --git a/packages/pages-components/src/components/analytics/scope.tsx b/packages/pages-components/src/components/analytics/scope.tsx index d8a30f3f..34df6ae7 100644 --- a/packages/pages-components/src/components/analytics/scope.tsx +++ b/packages/pages-components/src/components/analytics/scope.tsx @@ -1,7 +1,7 @@ -import { createContext, useContext, useState, PropsWithChildren, useEffect } from "react"; +import { createContext, useContext, useState, PropsWithChildren, useEffect, useMemo } from "react"; +import { AnalyticsContext } from "./context.js"; import { concatScopes } from "./helpers.js"; -import { AnalyticsScopeProps } from "./interfaces.js"; -import { useAnalytics } from "./hooks.js"; +import { AnalyticsMethods, AnalyticsScopeProps, TrackProps } from "./interfaces.js"; const ScopeContext = createContext({ name: "" }); @@ -14,6 +14,23 @@ export const useScope = () => { return ctx.name; }; +function createScopedAnalytics(analytics: AnalyticsMethods, scope: string): AnalyticsMethods { + return new Proxy(analytics, { + get(target, prop, receiver) { + if (prop === "track") { + return (trackProps: TrackProps) => + target.track({ + ...trackProps, + scope: trackProps.scope ?? scope, + }); + } + + const value = Reflect.get(target, prop, receiver); + return typeof value === "function" ? value.bind(target) : value; + }, + }); +} + /** * The AnalyticsScopeProvider will allow you to pre-pend a given string to all * events that happen in the node tree below where setScope is called. @@ -30,20 +47,30 @@ export function AnalyticsScopeProvider( const [combinedScope] = useState({ name: concatScopes(parentScope, props.name), }); - const debugEnabled = useAnalytics()?.getDebugEnabled(); + const analytics = useContext(AnalyticsContext); + const scopedAnalytics = useMemo(() => { + if (!analytics) { + return analytics; + } + + return createScopedAnalytics(analytics, combinedScope.name); + }, [analytics, combinedScope.name]); + const debugEnabled = scopedAnalytics?.getDebugEnabled(); const [isClient, setIsClient] = useState(false); useEffect(() => { setIsClient(true); }, []); + const content = ( + + {props.children} + + ); + if (debugEnabled && isClient) { - return ( -
- {props.children} -
- ); + return
{content}
; } - return {props.children}; + return content; }