Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Analytics feedbacks #2025

Merged
merged 15 commits into from May 2, 2024
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.1.1",
"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
83 changes: 57 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';
wizardlyhel marked this conversation as resolved.
Show resolved Hide resolved

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,15 +253,25 @@ 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 {
// eslint-disable-next-line no-undef
if (
typeof window !== 'undefined' &&
typeof window?.Shopify === 'object' &&
typeof window?.Shopify?.customerPrivacy === 'object' &&
typeof window?.Shopify?.customerPrivacy?.analyticsProcessingAllowed ===
'function'
) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit picky, but could this whole if statement just be typeof window?.Shopify?.customerPrivacy?.analyticsProcessingAllowed === 'function'? If any property along the way is undefined, typeof undefined will not be function.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good call .. in fact, since the whole thing is in a try-catch, the if statement is not needed

The try catch is needed because this function may be run at server side

return window.Shopify.customerPrivacy.analyticsProcessingAllowed();
}
return false;
} catch (error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit picky, but worth printing the error? Or maybe just killing the param altogether?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ts doesn't like catch() {}. End with catch(e) {}

return false;
}
return false;
}

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

function AnalyticsProvider({
Expand All @@ -268,7 +281,28 @@ function AnalyticsProvider({
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 +313,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 +342,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 +451,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