Skip to content

Commit

Permalink
Analytics feedbacks (#2025)
Browse files Browse the repository at this point in the history
  • Loading branch information
wizardlyhel committed May 2, 2024
1 parent b70f9c2 commit 58ea9bb
Show file tree
Hide file tree
Showing 8 changed files with 123 additions and 49 deletions.
5 changes: 5 additions & 0 deletions .changeset/violet-squids-tan.md
@@ -0,0 +1,5 @@
---
'@shopify/hydrogen': patch
---

Fix Analytics.Provider for error checking and working without privacy banner
2 changes: 2 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

44 changes: 34 additions & 10 deletions packages/hydrogen/docs/generated/generated_docs_data.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/hydrogen/package.json
Expand Up @@ -67,6 +67,7 @@
"content-security-policy-builder": "^2.2.0",
"type-fest": "^4.5.0",
"source-map-support": "^0.5.21",
"tiny-invariant": "^1.3.1",
"use-resize-observer": "^9.1.0"
},
"devDependencies": {
Expand Down
72 changes: 46 additions & 26 deletions packages/hydrogen/src/analytics-manager/AnalyticsProvider.tsx
Expand Up @@ -37,6 +37,7 @@ import {ShopifyAnalytics} from './ShopifyAnalytics';
import {CartAnalytics} from './CartAnalytics';
import type {CustomerPrivacyApiProps} from '../customer-privacy/ShopifyCustomerPrivacy';
import type {Storefront} from '../storefront';
import invariant from 'tiny-invariant';

export type ShopAnalytics = {
/** The shop ID. */
Expand All @@ -54,14 +55,16 @@ export type AnalyticsProviderProps = {
children?: ReactNode;
/** The cart or cart promise to track for cart analytics. When there is a difference between the state of the cart, `AnalyticsProvider` will trigger a `cart_updated` event. It will also produce `product_added_to_cart` and `product_removed_from_cart` based on cart line quantity and cart line id changes. */
cart: Promise<CartReturn | null> | CartReturn | null;
/** An optional function to set wether the user can be tracked. Defaults to Customer Privacy API's `window.Shopify.customerPrivacy.userCanBeTracked()`. */
/** An optional function to set wether the user can be tracked. Defaults to Customer Privacy API's `window.Shopify.customerPrivacy.analyticsProcessingAllowed()`. */
canTrack?: () => boolean;
/** An optional custom payload to pass to all events. e.g language/locale/currency. */
customData?: Record<string, unknown>;
/** The shop configuration required to publish analytics events to Shopify. Use [`getShopAnalytics`](/docs/api/hydrogen/2024-04/utilities/getshopanalytics). */
shop: Promise<ShopAnalytics | null> | ShopAnalytics | null;
/** The customer privacy consent configuration and options. */
consent: CustomerPrivacyApiProps;
/** Disable throwing errors when required props are missing. */
disableThrowOnError?: boolean;
};

export type Carts = {
Expand All @@ -70,7 +73,7 @@ export type Carts = {
};

export type AnalyticsContextValue = {
/** A function to tell you the current state of if the user can be tracked by analytics. Defaults to Customer Privacy API's `window.Shopify.customerPrivacy.userCanBeTracked()`. */
/** A function to tell you the current state of if the user can be tracked by analytics. Defaults to Customer Privacy API's `window.Shopify.customerPrivacy.analyticsProcessingAllowed()`. */
canTrack: NonNullable<AnalyticsProviderProps['canTrack']>;
/** The current cart state. */
cart: Awaited<AnalyticsProviderProps['cart']>;
Expand Down Expand Up @@ -250,25 +253,45 @@ function register(key: string) {
// This functions attempts to automatically determine if the user can be tracked if the
// customer privacy API is available. If not, it will default to false.
function shopifyCanTrack(): boolean {
if (
typeof window !== 'undefined' &&
typeof window?.Shopify === 'object' &&
typeof window?.Shopify?.customerPrivacy === 'object' &&
typeof window?.Shopify?.customerPrivacy?.userCanBeTracked === 'function'
) {
return window.Shopify.customerPrivacy.userCanBeTracked();
}
try {
return window.Shopify.customerPrivacy.analyticsProcessingAllowed();
} catch (e) {}
return false;
}

function messageOnError(field: string) {
return `[h2:error:Analytics.Provider] - ${field} is required`;
}

function AnalyticsProvider({
canTrack: customCanTrack,
cart: currentCart,
children,
consent,
customData = {},
shop: shopProp = null,
disableThrowOnError = false,
}: AnalyticsProviderProps): JSX.Element {
if (!consent.checkoutDomain) {
const errorMsg = messageOnError('consent.checkoutDomain');
if (disableThrowOnError) {
// eslint-disable-next-line no-console
console.error(errorMsg);
} else {
invariant(false, errorMsg);
}
}

if (!consent.storefrontAccessToken) {
const errorMsg = messageOnError('consent.storefrontAccessToken');
if (disableThrowOnError) {
// eslint-disable-next-line no-console
console.error(errorMsg);
} else {
invariant(false, errorMsg);
}
}

const listenerSet = useRef(false);
const {shop} = useShopAnalytics(shopProp);
const [consentLoaded, setConsentLoaded] = useState(
Expand All @@ -279,18 +302,6 @@ function AnalyticsProvider({
customCanTrack ? () => customCanTrack : () => shopifyCanTrack,
);

// Force a re-render of the value when
useEffect(() => {
if (customCanTrack) return;
if (listenerSet.current) return;
listenerSet.current = true;

document.addEventListener('visitorConsentCollected', () => {
setConsentLoaded(true);
setCanTrack(() => shopifyCanTrack);
});
}, [setConsentLoaded, setCanTrack, customCanTrack]);

const value = useMemo<AnalyticsContextValue>(() => {
return {
canTrack,
Expand Down Expand Up @@ -320,11 +331,20 @@ function AnalyticsProvider({
return (
<AnalyticsContext.Provider value={value}>
{children}
{shop && <AnalyticsPageView />}
{shop && currentCart && (
{!!shop && <AnalyticsPageView />}
{!!shop && !!currentCart && (
<CartAnalytics cart={currentCart} setCarts={setCarts} />
)}
{shop && consent && <ShopifyAnalytics consent={consent} />}
{!!shop && (
<ShopifyAnalytics
consent={consent}
onReady={() => {
listenerSet.current = true;
setConsentLoaded(true);
setCanTrack(() => shopifyCanTrack);
}}
/>
)}
</AnalyticsContext.Provider>
);
}
Expand Down Expand Up @@ -420,7 +440,7 @@ export const Analytics = {
};

export type AnalyticsContextValueForDoc = {
/** A function to tell you the current state of if the user can be tracked by analytics. Defaults to Customer Privacy API's `window.Shopify.customerPrivacy.userCanBeTracked()`. */
/** A function to tell you the current state of if the user can be tracked by analytics. Defaults to Customer Privacy API's `window.Shopify.customerPrivacy.analyticsProcessingAllowed()`. */
canTrack?: () => boolean;
/** The current cart state. */
cart?: Promise<CartReturn | null> | CartReturn | null;
Expand Down
14 changes: 10 additions & 4 deletions packages/hydrogen/src/analytics-manager/CartAnalytics.tsx
Expand Up @@ -5,6 +5,7 @@ import {
type Carts,
} from './AnalyticsProvider';
import {type CartUpdatePayload} from './AnalyticsView';
import {flattenConnection} from '@shopify/hydrogen-react';

function logMissingField(fieldName: string) {
// eslint-disable-next-line no-console
Expand Down Expand Up @@ -96,9 +97,14 @@ export function CartAnalytics({
}),
);

const previousCartLines = prevCart?.lines
? flattenConnection(prevCart?.lines)
: [];
const currentCartLines = cart.lines ? flattenConnection(cart.lines) : [];

// Detect quantity changes and missing cart lines
prevCart?.lines?.nodes?.forEach((prevLine) => {
const matchedLineId = cart?.lines.nodes.filter(
previousCartLines?.forEach((prevLine) => {
const matchedLineId = currentCartLines.filter(
(line) => prevLine.id === line.id,
);
if (matchedLineId?.length === 1) {
Expand All @@ -125,8 +131,8 @@ export function CartAnalytics({
});

// Detect added to cart
cart?.lines?.nodes?.forEach((line) => {
const matchedLineId = prevCart?.lines.nodes.filter(
currentCartLines?.forEach((line) => {
const matchedLineId = previousCartLines.filter(
(previousLine) => line.id === previousLine.id,
);
if (!matchedLineId || matchedLineId.length === 0) {
Expand Down
25 changes: 17 additions & 8 deletions packages/hydrogen/src/analytics-manager/ShopifyAnalytics.tsx
Expand Up @@ -53,21 +53,30 @@ function getCustomerPrivacyRequired() {
*/
export function ShopifyAnalytics({
consent,
onReady,
}: {
consent: AnalyticsProviderProps['consent'];
onReady: () => void;
}) {
const {subscribe, register, canTrack} = useAnalytics();
const {ready: shopifyAnalyticsReady} = register('Internal_Shopify_Analytics');
const {ready: customerPrivacyReady} = register(
'Internal_Shopify_CustomerPrivacy',
);
const {checkoutDomain, storefrontAccessToken} = consent;
checkoutDomain &&
storefrontAccessToken &&
useCustomerPrivacy({
...consent,
onVisitorConsentCollected: customerPrivacyReady,
});
const analyticsReady = () => {
customerPrivacyReady();
onReady();
};

useCustomerPrivacy({
...consent,
onVisitorConsentCollected: analyticsReady,
onReady: () => {
if (!consent.withPrivacyBanner) {
analyticsReady();
}
},
});

useShopifyCookies({hasUserConsent: canTrack()});

Expand Down Expand Up @@ -103,7 +112,7 @@ function prepareBasePageViewPayload(
| CartUpdatePayload,
): ShopifyPageViewPayload | undefined {
const customerPrivacy = getCustomerPrivacyRequired();
const hasUserConsent = customerPrivacy.userCanBeTracked();
const hasUserConsent = customerPrivacy.analyticsProcessingAllowed();

if (!payload?.shop?.shopId) {
logMissingConfig('shopId');
Expand Down
Expand Up @@ -56,7 +56,7 @@ export type SetConsentHeadlessParams = VisitorConsent &
**/
export type CustomerPrivacy = {
currentVisitorConsent: () => VisitorConsent;
userCanBeTracked: () => boolean;
preferencesProcessingAllowed: () => boolean;
saleOfDataAllowed: () => boolean;
marketingAllowed: () => boolean;
analyticsProcessingAllowed: () => boolean;
Expand Down Expand Up @@ -84,6 +84,8 @@ export type CustomerPrivacyApiProps = {
withPrivacyBanner?: boolean;
/** Callback to be called when visitor consent is collected. */
onVisitorConsentCollected?: (consent: VisitorConsentCollected) => void;
/** Callback to be call when customer privacy api is ready. */
onReady?: () => void;
};

const CONSENT_API =
Expand All @@ -102,6 +104,7 @@ export function useCustomerPrivacy(props: CustomerPrivacyApiProps) {
const {
withPrivacyBanner = true,
onVisitorConsentCollected,
onReady,
...consentConfig
} = props;
const loadedEvent = useRef(false);
Expand Down Expand Up @@ -171,6 +174,10 @@ export function useCustomerPrivacy(props: CustomerPrivacyApiProps) {
);
};
}

if (onReady && !withPrivacyBanner) {
onReady();
}
}, [scriptStatus, withPrivacyBanner, consentConfig]);

return;
Expand Down

0 comments on commit 58ea9bb

Please sign in to comment.