+
{loopAnalyticsFn}
{children}
@@ -345,6 +365,7 @@ async function triggerCartUpdate({
initialCart,
customData,
registerCallback,
+ mockCanTrack: true,
});
// Triggers a cart update
@@ -363,21 +384,35 @@ async function triggerCartUpdate({
function LoopAnalytics({
children,
registerCallback,
+ mockCanTrack = true,
}: {
children: ReactNode | ((analytics: AnalyticsContextValue) => ReactNode);
registerCallback?: (
analytics: AnalyticsContextValue,
ready: () => void,
) => void;
+ mockCanTrack?: boolean;
}): JSX.Element {
const analytics = useAnalytics();
const {ready} = analytics.register('loopAnalytics');
const {ready: customerPrivacyReady} = analytics.register(
- 'Internal_Shopify_CustomerPrivacy',
+ 'Internal_Shopify_Customer_Privacy',
);
const {ready: perfKitReady} = analytics.register('Internal_Shopify_Perf_Kit');
+ const {ready: analyticsReady} = analytics.register(
+ 'Internal_Shopify_Analytics',
+ );
useEffect(() => {
+ // Mock the original customerPrivacy script injected APIs.
+ if (mockCanTrack) {
+ //@ts-ignore
+ global.window.Shopify = {};
+ global.window.Shopify.customerPrivacy = {
+ setTrackingConsent: () => {},
+ analyticsProcessingAllowed: () => true,
+ };
+ }
if (registerCallback) {
registerCallback(analytics, ready);
} else {
@@ -385,8 +420,9 @@ function LoopAnalytics({
}
});
- customerPrivacyReady();
perfKitReady();
+ customerPrivacyReady();
+ analyticsReady();
return (
{typeof children === 'function' ? children(analytics) : children}
diff --git a/packages/hydrogen/src/analytics-manager/AnalyticsProvider.tsx b/packages/hydrogen/src/analytics-manager/AnalyticsProvider.tsx
index 9518678378..91ad25035b 100644
--- a/packages/hydrogen/src/analytics-manager/AnalyticsProvider.tsx
+++ b/packages/hydrogen/src/analytics-manager/AnalyticsProvider.tsx
@@ -35,7 +35,13 @@ import type {
import {AnalyticsEvent} from './events';
import {ShopifyAnalytics} from './ShopifyAnalytics';
import {CartAnalytics} from './CartAnalytics';
-import type {CustomerPrivacyApiProps} from '../customer-privacy/ShopifyCustomerPrivacy';
+import {
+ type PrivacyBanner,
+ getCustomerPrivacy,
+ getPrivacyBanner,
+ type CustomerPrivacy,
+ type CustomerPrivacyApiProps,
+} from '../customer-privacy/ShopifyCustomerPrivacy';
import type {Storefront} from '../storefront';
import {PerfKit} from './PerfKit';
import {errorOnce, warnOnce} from '../utils/warning';
@@ -51,6 +57,13 @@ export type ShopAnalytics = {
hydrogenSubchannelId: string | '0';
};
+export type Consent = Partial<
+ Pick<
+ CustomerPrivacyApiProps,
+ 'checkoutDomain' | 'storefrontAccessToken' | 'withPrivacyBanner' | 'country'
+ >
+> & {language?: LanguageCode}; // the privacyBanner SDKs refers to "language" as "locale" :(
+
export type AnalyticsProviderProps = {
/** React children to render. */
children?: ReactNode;
@@ -63,12 +76,7 @@ export type AnalyticsProviderProps = {
/** The shop configuration required to publish analytics events to Shopify. Use [`getShopAnalytics`](/docs/api/hydrogen/2024-07/utilities/getshopanalytics). */
shop: Promise | ShopAnalytics | null;
/** The customer privacy consent configuration and options. */
- consent: Partial<
- Pick<
- CustomerPrivacyApiProps,
- 'checkoutDomain' | 'storefrontAccessToken' | 'withPrivacyBanner'
- >
- >;
+ consent: Consent;
/** @deprecated Disable throwing errors when required props are missing. */
disableThrowOnError?: boolean;
/** The domain scope of the cookie set with `useShopifyCookies`. **/
@@ -97,6 +105,10 @@ export type AnalyticsContextValue = {
shop: Awaited;
/** A function to subscribe to analytics events. */
subscribe: typeof subscribe;
+ /** The privacy banner SDK methods with the config applied */
+ privacyBanner: PrivacyBanner | null;
+ /** The customer privacy SDK methods with the config applied */
+ customerPrivacy: CustomerPrivacy | null;
};
export const defaultAnalyticsContext: AnalyticsContextValue = {
@@ -108,6 +120,8 @@ export const defaultAnalyticsContext: AnalyticsContextValue = {
shop: null,
subscribe: () => {},
register: () => ({ready: () => {}}),
+ customerPrivacy: null,
+ privacyBanner: null,
};
const AnalyticsContext = createContext(
@@ -282,7 +296,7 @@ function AnalyticsProvider({
}: AnalyticsProviderProps): JSX.Element {
const listenerSet = useRef(false);
const {shop} = useShopAnalytics(shopProp);
- const [consentLoaded, setConsentLoaded] = useState(
+ const [analyticsLoaded, setAnalyticsLoaded] = useState(
customCanTrack ? true : false,
);
const [carts, setCarts] = useState({cart: null, prevCart: null});
@@ -312,6 +326,18 @@ function AnalyticsProvider({
);
errorOnce(errorMsg);
}
+
+ if (!consent?.country) {
+ consent.country = 'US';
+ }
+
+ if (!consent?.language) {
+ consent.language = 'EN';
+ }
+
+ if (consent.withPrivacyBanner === undefined) {
+ consent.withPrivacyBanner = true;
+ }
}
}
@@ -324,12 +350,12 @@ function AnalyticsProvider({
shop,
subscribe,
register,
+ customerPrivacy: getCustomerPrivacy(),
+ privacyBanner: getPrivacyBanner(),
};
}, [
- consentLoaded,
- canTrack(),
+ analyticsLoaded,
canTrack,
- JSON.stringify(canTrack),
carts,
carts.cart?.updatedAt,
carts.prevCart,
@@ -339,6 +365,8 @@ function AnalyticsProvider({
shop,
register,
JSON.stringify(registers),
+ getCustomerPrivacy,
+ getPrivacyBanner,
]);
return (
@@ -353,7 +381,7 @@ function AnalyticsProvider({
consent={consent}
onReady={() => {
listenerSet.current = true;
- setConsentLoaded(true);
+ setAnalyticsLoaded(true);
setCanTrack(() => shopifyCanTrack);
}}
domain={cookieDomain}
@@ -452,15 +480,17 @@ export const Analytics = {
SearchView: AnalyticsSearchView,
};
-export type AnalyticsContextValueForDoc = {
+type DefaultCart = Promise | CartReturn | null;
+
+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.analyticsProcessingAllowed()`. */
canTrack?: () => boolean;
- /** The current cart state. */
- cart?: Promise | CartReturn | null;
+ /** The current cart state. You can overwrite the type by passing a generic */
+ cart?: UserCart | DefaultCart;
/** The custom data passed in from the `AnalyticsProvider`. */
customData?: Record;
- /** The previous cart state. */
- prevCart?: Promise | CartReturn | null;
+ /** The previous cart state. You can overwrite the type by passing a generic */
+ prevCart?: UserCart | DefaultCart;
/** A function to publish an analytics event. */
publish?: AnalyticsContextPublishForDoc;
/** A function to register with the analytics provider. It holds the first browser load events until all registered key has executed the supplied `ready` function. [See example register usage](/docs/api/hydrogen/2024-07/hooks/useanalytics#example-useanalytics.register). */
diff --git a/packages/hydrogen/src/analytics-manager/ShopifyAnalytics.tsx b/packages/hydrogen/src/analytics-manager/ShopifyAnalytics.tsx
index 649b3fa2dd..8b50239b14 100644
--- a/packages/hydrogen/src/analytics-manager/ShopifyAnalytics.tsx
+++ b/packages/hydrogen/src/analytics-manager/ShopifyAnalytics.tsx
@@ -64,37 +64,24 @@ export function ShopifyAnalytics({
const [shopifyReady, setShopifyReady] = useState(false);
const [privacyReady, setPrivacyReady] = useState(false);
const init = useRef(false);
+ const {checkoutDomain, storefrontAccessToken, language} = consent;
const {ready: shopifyAnalyticsReady} = register('Internal_Shopify_Analytics');
- const {ready: customerPrivacyReady} = register(
- 'Internal_Shopify_CustomerPrivacy',
- );
- const analyticsReady = () => {
- shopifyReady && privacyReady && onReady();
- };
-
- const setCustomerPrivacyReady = () => {
- setPrivacyReady(true);
- customerPrivacyReady();
- analyticsReady();
- };
-
- const {checkoutDomain, storefrontAccessToken, withPrivacyBanner} = consent;
+ // load customer privacy and (optionally) the privacy banner APIs
useCustomerPrivacy({
+ ...consent,
+ locale: language,
checkoutDomain: !checkoutDomain ? 'mock.shop' : checkoutDomain,
storefrontAccessToken: !storefrontAccessToken
? 'abcdefghijklmnopqrstuvwxyz123456'
: storefrontAccessToken,
- withPrivacyBanner,
- onVisitorConsentCollected: setCustomerPrivacyReady,
- onReady: () => {
- // Set customer privacy ready 3 seconds after load
- setTimeout(setCustomerPrivacyReady, 3000);
- },
+ onVisitorConsentCollected: () => setPrivacyReady(true),
+ onReady: () => setPrivacyReady(true),
});
+ // set up shopify_Y and shopify_S cookies
useShopifyCookies({
- hasUserConsent: shopifyReady && privacyReady ? canTrack() : true,
+ hasUserConsent: privacyReady ? canTrack() : true, // must be initialized with true
domain,
checkoutDomain,
});
@@ -112,10 +99,15 @@ export function ShopifyAnalytics({
// Cart
subscribe(AnalyticsEvent.PRODUCT_ADD_TO_CART, productAddedToCartHandler);
- shopifyAnalyticsReady();
setShopifyReady(true);
- analyticsReady();
- }, [subscribe, shopifyAnalyticsReady]);
+ }, [subscribe]);
+
+ useEffect(() => {
+ if (shopifyReady && privacyReady) {
+ shopifyAnalyticsReady();
+ onReady();
+ }
+ }, [shopifyReady, privacyReady, onReady]);
return null;
}
diff --git a/packages/hydrogen/src/analytics-manager/getShopAnalytics.example.jsx b/packages/hydrogen/src/analytics-manager/getShopAnalytics.example.jsx
index 802d0274d9..ac4a64ab8f 100644
--- a/packages/hydrogen/src/analytics-manager/getShopAnalytics.example.jsx
+++ b/packages/hydrogen/src/analytics-manager/getShopAnalytics.example.jsx
@@ -15,6 +15,10 @@ export async function loader({context}) {
consent: {
checkoutDomain: env.PUBLIC_CHECKOUT_DOMAIN,
storefrontAccessToken: env.PUBLIC_STOREFRONT_API_TOKEN,
+ withPrivacyBanner: true, // false stops the privacy banner from being displayed
+ // localize the privacy banner
+ country: context.storefront.i18n.country,
+ language: context.storefront.i18n.language,
},
});
}
diff --git a/packages/hydrogen/src/analytics-manager/getShopAnalytics.example.tsx b/packages/hydrogen/src/analytics-manager/getShopAnalytics.example.tsx
index 20d84fb1f9..2d7ec489cb 100644
--- a/packages/hydrogen/src/analytics-manager/getShopAnalytics.example.tsx
+++ b/packages/hydrogen/src/analytics-manager/getShopAnalytics.example.tsx
@@ -15,6 +15,10 @@ export async function loader({context}: LoaderFunctionArgs) {
consent: {
checkoutDomain: env.PUBLIC_CHECKOUT_DOMAIN,
storefrontAccessToken: env.PUBLIC_STOREFRONT_API_TOKEN,
+ withPrivacyBanner: true, // false stops the privacy banner from being displayed
+ // localize the privacy banner
+ country: context.storefront.i18n.country,
+ language: context.storefront.i18n.language,
},
});
}
diff --git a/packages/hydrogen/src/customer-privacy/ShopifyCustomerPrivacy.tsx b/packages/hydrogen/src/customer-privacy/ShopifyCustomerPrivacy.tsx
index 88ab65cb1c..a67ffbe303 100644
--- a/packages/hydrogen/src/customer-privacy/ShopifyCustomerPrivacy.tsx
+++ b/packages/hydrogen/src/customer-privacy/ShopifyCustomerPrivacy.tsx
@@ -1,5 +1,9 @@
import {useLoadScript} from '@shopify/hydrogen-react';
-import {useEffect, useRef} from 'react';
+import {
+ CountryCode,
+ LanguageCode,
+} from '@shopify/hydrogen-react/storefront-api-types';
+import {useEffect, useMemo, useRef, useState} from 'react';
export type ConsentStatus = boolean | undefined;
@@ -22,9 +26,12 @@ export type VisitorConsentCollected = {
export type CustomerPrivacyApiLoaded = boolean;
export type CustomerPrivacyConsentConfig = {
- checkoutRootDomain?: string;
+ checkoutRootDomain: string;
storefrontRootDomain?: string;
- storefrontAccessToken?: string;
+ storefrontAccessToken: string;
+ country?: CountryCode;
+ /** The privacyBanner refers to `language` as `locale` */
+ locale?: LanguageCode;
};
export type SetConsentHeadlessParams = VisitorConsent &
@@ -54,7 +61,7 @@ export type SetConsentHeadlessParams = VisitorConsent &
shouldShowGDPRBanner
thirdPartyMarketingAllowed
**/
-export type CustomerPrivacy = {
+export type OriginalCustomerPrivacy = {
currentVisitorConsent: () => VisitorConsent;
preferencesProcessingAllowed: () => boolean;
saleOfDataAllowed: () => boolean;
@@ -62,12 +69,26 @@ export type CustomerPrivacy = {
analyticsProcessingAllowed: () => boolean;
setTrackingConsent: (
consent: SetConsentHeadlessParams,
- callback: () => void,
+ callback: (data: {error: string} | undefined) => void,
) => void;
};
+export type CustomerPrivacy = Omit<
+ OriginalCustomerPrivacy,
+ 'setTrackingConsent'
+> & {
+ setTrackingConsent: (
+ consent: VisitorConsent, // we have already applied the headlessStorefront in the override
+ callback: (data: {error: string} | undefined) => void,
+ ) => void;
+};
+
+// NOTE: options is optional because we override these method(s) with pre-applied options
export type PrivacyBanner = {
- loadBanner: (options: CustomerPrivacyConsentConfig) => void;
+ /* Display the privacy banner */
+ loadBanner: (options?: Partial) => void;
+ /* Display the consent preferences banner */
+ showPreferences: (options?: Partial) => void;
};
export interface CustomEventMap {
@@ -82,15 +103,19 @@ export type CustomerPrivacyApiProps = {
storefrontAccessToken: string;
/** Whether to load the Shopify privacy banner as configured in Shopify admin. Defaults to true. */
withPrivacyBanner?: boolean;
+ /** Country code for the shop. */
+ country?: CountryCode;
+ /** Language code for the shop. */
+ locale?: LanguageCode;
/** 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 =
+export const CONSENT_API =
'https://cdn.shopify.com/shopifycloud/consent-tracking-api/v0.1/consent-tracking-api.js';
-const CONSENT_API_WITH_BANNER =
+export const CONSENT_API_WITH_BANNER =
'https://cdn.shopify.com/shopifycloud/privacy-banner/storefront-banner.js';
function logMissingConfig(fieldName: string) {
@@ -107,16 +132,50 @@ export function useCustomerPrivacy(props: CustomerPrivacyApiProps) {
onReady,
...consentConfig
} = props;
- const loadedEvent = useRef(false);
- const scriptStatus = useLoadScript(
- withPrivacyBanner ? CONSENT_API_WITH_BANNER : CONSENT_API,
- {
- attributes: {
- id: 'customer-privacy-api',
- },
+
+ // Load the Shopify customer privacy API with or without the privacy banner
+ // NOTE: We no longer use the status because we need `ready` to be not when the script is loaded
+ // but instead when both `privacyBanner` (optional) and customerPrivacy are loaded in the window
+ useLoadScript(withPrivacyBanner ? CONSENT_API_WITH_BANNER : CONSENT_API, {
+ attributes: {
+ id: 'customer-privacy-api',
},
- );
+ });
+
+ const {observing, setLoaded} = useApisLoaded({
+ withPrivacyBanner,
+ onLoaded: onReady,
+ });
+
+ const config = useMemo(() => {
+ const {checkoutDomain, storefrontAccessToken} = consentConfig;
+
+ if (!checkoutDomain) logMissingConfig('checkoutDomain');
+ if (!storefrontAccessToken) logMissingConfig('storefrontAccessToken');
+
+ // validate that the storefront access token is not a server API token
+ if (
+ storefrontAccessToken.startsWith('shpat_') ||
+ storefrontAccessToken.length !== 32
+ ) {
+ // eslint-disable-next-line no-console
+ console.error(
+ `[h2:error:useCustomerPrivacy] It looks like you passed a private access token, make sure to use the public token`,
+ );
+ }
+
+ const config: CustomerPrivacyConsentConfig = {
+ checkoutRootDomain: checkoutDomain,
+ storefrontAccessToken,
+ storefrontRootDomain: parseStoreDomain(checkoutDomain),
+ country: consentConfig.country,
+ locale: consentConfig.locale,
+ };
+ return config;
+ }, [consentConfig, parseStoreDomain, logMissingConfig]);
+
+ // settings event listeners for visitorConsentCollected
useEffect(() => {
const consentCollectedHandler = (
event: CustomEvent,
@@ -139,85 +198,353 @@ export function useCustomerPrivacy(props: CustomerPrivacyApiProps) {
};
}, [onVisitorConsentCollected]);
+ // monitor when the `privacyBanner` is in the window and override it's methods with config
+ // pre-applied versions
useEffect(() => {
- if (scriptStatus !== 'done' || loadedEvent.current) return;
- loadedEvent.current = true;
+ if (!withPrivacyBanner || observing.current.privacyBanner) return;
+ observing.current.privacyBanner = true;
- const {checkoutDomain, storefrontAccessToken} = consentConfig;
- if (!checkoutDomain) logMissingConfig('checkoutDomain');
- if (!storefrontAccessToken) logMissingConfig('storefrontAccessToken');
+ let customPrivacyBanner: PrivacyBanner | undefined = undefined;
- // validate that the storefront access token is not a server API token
- if (
- storefrontAccessToken.startsWith('shpat_') ||
- storefrontAccessToken.length !== 32
- ) {
- // eslint-disable-next-line no-console
- console.error(
- `[h2:error:useCustomerPrivacy] It looks like you passed a private access token, make sure to use the public token`,
- );
- }
+ const privacyBannerWatcher = {
+ configurable: true,
+ get() {
+ return customPrivacyBanner;
+ },
+ set(value: unknown) {
+ if (
+ typeof value === 'object' &&
+ value !== null &&
+ 'showPreferences' in value &&
+ 'loadBanner' in value
+ ) {
+ const privacyBanner = value as PrivacyBanner;
- const config: CustomerPrivacyConsentConfig = {
- checkoutRootDomain: checkoutDomain,
- storefrontAccessToken,
+ // auto load the banner if applicable
+ privacyBanner.loadBanner(config);
+
+ // overwrite the privacyBanner methods
+ customPrivacyBanner = overridePrivacyBannerMethods({
+ privacyBanner,
+ config,
+ });
+
+ // set the loaded state for the privacyBanner
+ setLoaded.privacyBanner();
+ }
+ },
};
- if (checkoutDomain) {
- let storefrontRootDomain = window.document.location.host;
- const checkoutDomainParts = checkoutDomain.split('.').reverse();
- const currentDomainParts = storefrontRootDomain.split('.').reverse();
- const sameDomainParts: Array = [];
- checkoutDomainParts.forEach((part, index) => {
- if (part === currentDomainParts[index]) {
- sameDomainParts.push(part);
+ Object.defineProperty(window, 'privacyBanner', privacyBannerWatcher);
+ }, [
+ withPrivacyBanner,
+ config,
+ overridePrivacyBannerMethods,
+ setLoaded.privacyBanner,
+ ]);
+
+ // monitor when the Shopify.customerPrivacy is added to the window and override the
+ // setTracking consent method with the config pre-applied
+ useEffect(() => {
+ if (observing.current.customerPrivacy) return;
+ observing.current.customerPrivacy = true;
+
+ let customCustomerPrivacy: CustomerPrivacy | null = null;
+ let customShopify: {customerPrivacy: CustomerPrivacy} | undefined | object =
+ undefined;
+
+ // monitor for when window.Shopify = {} is first set
+ Object.defineProperty(window, 'Shopify', {
+ configurable: true,
+ get() {
+ return customShopify;
+ },
+ set(value: unknown) {
+ // monitor for when window.Shopify = {} is first set
+ if (
+ typeof value === 'object' &&
+ value !== null &&
+ Object.keys(value).length === 0
+ ) {
+ customShopify = value as object;
+
+ // monitor for when window.Shopify.customerPrivacy is set
+ Object.defineProperty(window.Shopify, 'customerPrivacy', {
+ configurable: true,
+ get() {
+ return customCustomerPrivacy;
+ },
+ set(value: unknown) {
+ if (
+ typeof value === 'object' &&
+ value !== null &&
+ 'setTrackingConsent' in value
+ ) {
+ const customerPrivacy = value as CustomerPrivacy;
+
+ // overwrite the tracking consent method
+ customCustomerPrivacy = {
+ ...customerPrivacy,
+ setTrackingConsent: overrideCustomerPrivacySetTrackingConsent(
+ {customerPrivacy, config},
+ ),
+ };
+
+ customShopify = {
+ ...customShopify,
+ customerPrivacy: customCustomerPrivacy,
+ };
+
+ setLoaded.customerPrivacy();
+ }
+ },
+ });
}
- });
+ },
+ });
+ }, [
+ config,
+ overrideCustomerPrivacySetTrackingConsent,
+ setLoaded.customerPrivacy,
+ ]);
+
+ // return the customerPrivacy and privacyBanner (optional) modified APIs
+ const result = {
+ customerPrivacy: getCustomerPrivacy(),
+ } as {
+ customerPrivacy: CustomerPrivacy | null;
+ privacyBanner?: PrivacyBanner | null;
+ };
+
+ if (withPrivacyBanner) {
+ result.privacyBanner = getPrivacyBanner();
+ }
+
+ return result;
+}
- storefrontRootDomain = sameDomainParts.reverse().join('.');
+function useApisLoaded({
+ withPrivacyBanner,
+ onLoaded,
+}: {
+ withPrivacyBanner: boolean;
+ onLoaded?: () => void;
+}) {
+ // used to help run the watchers only once
+ const observing = useRef({customerPrivacy: false, privacyBanner: false});
- if (storefrontRootDomain) {
- config.storefrontRootDomain = storefrontRootDomain;
+ // [customerPrivacy, privacyBanner]
+ const [apisLoaded, setApisLoaded] = useState(
+ withPrivacyBanner ? [false, false] : [false],
+ );
+
+ // combined loaded state for both APIs
+ const loaded = apisLoaded.every(Boolean);
+
+ const setLoaded = {
+ customerPrivacy: () => {
+ if (withPrivacyBanner) {
+ setApisLoaded((prev) => [true, prev[1]]);
+ } else {
+ setApisLoaded(() => [true]);
}
- }
+ },
+ privacyBanner: () => {
+ if (!withPrivacyBanner) {
+ return;
+ }
+ setApisLoaded((prev) => [prev[0], true]);
+ },
+ };
- if (withPrivacyBanner && window?.privacyBanner) {
- window.privacyBanner?.loadBanner(config);
+ useEffect(() => {
+ if (loaded && onLoaded) {
+ // both APIs are loaded in the window
+ onLoaded();
}
+ }, [loaded, onLoaded]);
- if (!window.Shopify?.customerPrivacy) return;
+ return {observing, setLoaded};
+}
- // Override the setTrackingConsent method to include the headless storefront configuration
- const originalSetTrackingConsent =
- window.Shopify.customerPrivacy.setTrackingConsent;
+/**
+ * Extracts the root domain from the checkout domain otherwise returns the checkout domain.
+ */
+function parseStoreDomain(checkoutDomain: string) {
+ if (typeof window === 'undefined') return;
- function overrideSetTrackingConsent(
- consent: VisitorConsent,
- callback: (data: {error: string} | undefined) => void,
- ) {
- originalSetTrackingConsent(
- {
- ...consent,
- headlessStorefront: true,
- ...config,
- },
- callback,
- );
+ const host = window.document.location.host;
+ const checkoutDomainParts = checkoutDomain.split('.').reverse();
+ const currentDomainParts = host.split('.').reverse();
+ const sameDomainParts: Array = [];
+ checkoutDomainParts.forEach((part, index) => {
+ if (part === currentDomainParts[index]) {
+ sameDomainParts.push(part);
}
+ });
- window.Shopify.customerPrivacy.setTrackingConsent =
- overrideSetTrackingConsent;
+ return sameDomainParts.reverse().join('.');
+}
- onReady && onReady();
- }, [scriptStatus, withPrivacyBanner, consentConfig]);
+/**
+ * Overrides the customerPrivacy.setTrackingConsent method to include the headless storefront configuration.
+ */
+function overrideCustomerPrivacySetTrackingConsent({
+ customerPrivacy,
+ config,
+}: {
+ customerPrivacy: OriginalCustomerPrivacy;
+ config: CustomerPrivacyConsentConfig;
+}) {
+ // Override the setTrackingConsent method to include the headless storefront configuration
+ const original = customerPrivacy.setTrackingConsent;
- return;
+ function updatedSetTrackingConsent(
+ consent: VisitorConsent,
+ callback: (data: {error: string} | undefined) => void,
+ ) {
+ original(
+ {
+ ...consent,
+ headlessStorefront: true,
+ ...config,
+ },
+ callback,
+ );
+ }
+ return updatedSetTrackingConsent;
}
-export function getCustomerPrivacy(): CustomerPrivacy | null {
+/**
+ * Overrides the privacyBanner methods to include the config
+ */
+function overridePrivacyBannerMethods({
+ privacyBanner,
+ config,
+}: {
+ privacyBanner: PrivacyBanner;
+ config: CustomerPrivacyConsentConfig;
+}) {
+ const originalLoadBanner = privacyBanner.loadBanner;
+ const originalShowPreferences = privacyBanner.showPreferences;
+
+ function loadBanner(userConfig?: Partial) {
+ if (typeof userConfig === 'object') {
+ originalLoadBanner({...config, ...userConfig});
+ return;
+ }
+ originalLoadBanner(config);
+ }
+
+ function showPreferences(userConfig?: Partial) {
+ if (typeof userConfig === 'object') {
+ originalShowPreferences({...config, ...userConfig});
+ return;
+ }
+ originalShowPreferences(config);
+ }
+ return {loadBanner, showPreferences} as PrivacyBanner;
+}
+
+/*
+ * Returns Shopify's customerPrivacy methods if loaded in the `window` object.
+ * @returns CustomerPrivacy | null
+ * @example
+ * ```ts
+ * const customerPrivacy = getCustomerPrivacy()
+ *
+ * if (customerPrivacy) {
+ * // get the current visitor consent
+ * const visitorConsent = customerPrivacy.currentVisitorConsent()
+ *
+ * // set the tracking consent
+ * customerPrivacy.setTrackingConsent({marketing: true...}, () => {
+ * // do something after the consent is set
+ * })
+ *
+ * // check if marketing is allowed
+ * const marketingAllowed = customerPrivacy.marketingAllowed()
+ * console.log(marketingAllowed)
+ *
+ * // check if analytics is allowed
+ * const analyticsAllowed = customerPrivacy.analyticsProcessingAllowed()
+ * console.log(analyticsAllowed)
+ *
+ * // check if preferences are allowed
+ * const preferencesAllowed = customerPrivacy.preferencesProcessingAllowed()
+ * console.log(preferencesAllowed)
+ *
+ * // check if sale of data is allowed
+ * const saleOfDataAllowed = customerPrivacy.saleOfDataAllowed()
+ *
+ * // check if third party marketing is allowed
+ * const thirdPartyMarketingAllowed = customerPrivacy.thirdPartyMarketingAllowed()
+ *
+ * // check if first party marketing is allowed
+ * const firstPartyMarketingAllowed = customerPrivacy.firstPartyMarketingAllowed()
+ *
+ * // check if the banner should be shown
+ * const shouldShowBanner = customerPrivacy.shouldShowBanner()
+ *
+ * // check if the GDPR banner should be shown
+ * const shouldShowGDPRBanner = customerPrivacy.shouldShowGDPRBanner()
+ *
+ * // check if the CCPA banner should be shown
+ * const shouldShowCCPABanner = customerPrivacy.shouldShowCCPABanner()
+ *
+ * // check if the regulation is enforced
+ * const isRegulationEnforced = customerPrivacy.isRegulationEnforced()
+ *
+ * // get the regulation
+ * const regulation = customerPrivacy.getRegulation()
+ *
+ * // get the sale of data region
+ * const saleOfDataRegion = customerPrivacy.saleOfDataRegion()
+ *
+ * // get the shop preferences
+ * const shopPrefs = customerPrivacy.getShopPrefs()
+ *
+ * // get the tracking consent
+ * const trackingConsent = customerPrivacy.getTrackingConsent()
+ *
+ * // get the CCPA consent
+ * const ccpaConsent = customerPrivacy.getCCPAConsent()
+ *
+ * // check if the merchant supports granular consent
+ * const doesMerchantSupportGranularConsent = customerPrivacy.doesMerchantSupportGranularConsent()
+ * }
+ * ```
+ */
+export function getCustomerPrivacy() {
try {
return window.Shopify && window.Shopify.customerPrivacy
- ? window.Shopify?.customerPrivacy
+ ? (window.Shopify?.customerPrivacy as CustomerPrivacy)
+ : null;
+ } catch (e) {
+ return null;
+ }
+}
+
+/**
+ * Returns Shopify's privacyBanner methods if loaded in the `window` object.
+ * @returns PrivacyBanner | null
+ * @example
+ * ```ts
+ * const privacyBanner = getPrivacyBanner()
+ *
+ * if (privacyBanner) {
+ * // show the banner
+ * privacyBanner.loadBanner()
+ *
+ * // show the preferences
+ * privacyBanner.showPreferences()
+ * }
+ * ```
+ */
+export function getPrivacyBanner() {
+ try {
+ return window && window?.privacyBanner
+ ? (window.privacyBanner as PrivacyBanner)
: null;
} catch (e) {
return null;
diff --git a/packages/hydrogen/src/customer-privacy/useCustomerPrivacy.example.jsx b/packages/hydrogen/src/customer-privacy/useCustomerPrivacy.example.jsx
index d06b724a4b..def3aaa73b 100644
--- a/packages/hydrogen/src/customer-privacy/useCustomerPrivacy.example.jsx
+++ b/packages/hydrogen/src/customer-privacy/useCustomerPrivacy.example.jsx
@@ -1,11 +1,52 @@
import {useCustomerPrivacy} from '@shopify/hydrogen';
+import {useEffect} from 'react';
export function MyComponent() {
- useCustomerPrivacy({
+ const {customerPrivacy, privacyBanner = null} = useCustomerPrivacy({
storefrontAccessToken: '12345',
checkoutDomain: 'checkout.example.com',
onVisitorConsentCollected: (consent) => {
console.log('Visitor consent collected:', consent);
},
});
+
+ useEffect(() => {
+ if (customerPrivacy) {
+ // check if user has marketing consent
+ console.log(
+ 'User marketing consent:',
+ customerPrivacy.analyticsProcessingAllowed(),
+ );
+
+ // or set tracking consent
+ customerPrivacy.setTrackingConsent(
+ {
+ marketing: true,
+ analytics: true,
+ preferences: true,
+ sale_of_data: true,
+ },
+ (data) => {
+ if (data?.error) {
+ console.error('Error setting tracking consent:', data.error);
+ return;
+ }
+ console.log('Tracking consent set');
+ },
+ );
+ }
+
+ if (privacyBanner) {
+ privacyBanner.loadBanner();
+
+ // or show banner with specific locale and country
+ // privacyBanner.loadBanner({locale: 'FR', country: 'CA'});
+
+ // or show consent preferences banner
+ // privacyBanner.showPreferences()
+
+ // or show consent preferences banner with specific locale and country
+ // privacyBanner.showPreferences({locale: 'FR', country: 'CA'});
+ }
+ }, [customerPrivacy, privacyBanner]);
}
diff --git a/packages/hydrogen/src/customer-privacy/useCustomerPrivacy.example.tsx b/packages/hydrogen/src/customer-privacy/useCustomerPrivacy.example.tsx
index fe56bb1c45..ade997c28c 100644
--- a/packages/hydrogen/src/customer-privacy/useCustomerPrivacy.example.tsx
+++ b/packages/hydrogen/src/customer-privacy/useCustomerPrivacy.example.tsx
@@ -2,13 +2,54 @@ import {
type VisitorConsentCollected,
useCustomerPrivacy,
} from '@shopify/hydrogen';
+import {useEffect} from 'react';
export function MyComponent() {
- useCustomerPrivacy({
+ const {customerPrivacy, privacyBanner = null} = useCustomerPrivacy({
storefrontAccessToken: '12345',
checkoutDomain: 'checkout.example.com',
onVisitorConsentCollected: (consent: VisitorConsentCollected) => {
console.log('Visitor consent collected:', consent);
},
});
+
+ useEffect(() => {
+ if (customerPrivacy) {
+ // check if user has marketing consent
+ console.log(
+ 'User marketing consent:',
+ customerPrivacy.analyticsProcessingAllowed(),
+ );
+
+ // or set tracking consent
+ customerPrivacy.setTrackingConsent(
+ {
+ marketing: true,
+ analytics: true,
+ preferences: true,
+ sale_of_data: true,
+ },
+ (data) => {
+ if (data?.error) {
+ console.error('Error setting tracking consent:', data.error);
+ return;
+ }
+ console.log('Tracking consent set');
+ },
+ );
+ }
+
+ if (privacyBanner) {
+ privacyBanner.loadBanner();
+
+ // or show banner with specific locale and country
+ // privacyBanner.loadBanner({locale: 'FR', country: 'CA'});
+
+ // or show consent preferences banner
+ // privacyBanner.showPreferences()
+
+ // or show consent preferences banner with specific locale and country
+ // privacyBanner.showPreferences({locale: 'FR', country: 'CA'});
+ }
+ }, [customerPrivacy, privacyBanner]);
}
diff --git a/packages/hydrogen/src/customer-privacy/useCustomerPrivacy.test.tsx b/packages/hydrogen/src/customer-privacy/useCustomerPrivacy.test.tsx
new file mode 100644
index 0000000000..8f22a500e4
--- /dev/null
+++ b/packages/hydrogen/src/customer-privacy/useCustomerPrivacy.test.tsx
@@ -0,0 +1,199 @@
+import {vi, describe, it, beforeEach, afterEach, expect} from 'vitest';
+import {renderHook, act} from '@testing-library/react';
+import {
+ useCustomerPrivacy,
+ CONSENT_API,
+ CONSENT_API_WITH_BANNER,
+} from './ShopifyCustomerPrivacy.js';
+
+let html: HTMLHtmlElement;
+let head: HTMLHeadElement;
+let body: HTMLBodyElement;
+
+const CUSTOMER_PRIVACY_PROPS = {
+ checkoutDomain: 'checkout.shopify.com',
+ storefrontAccessToken: 'test-token',
+};
+
+describe(`useCustomerPrivacy`, () => {
+ beforeEach(() => {
+ html = document.createElement('html');
+ head = document.createElement('head');
+ body = document.createElement('body');
+
+ vi.spyOn(document.head, 'appendChild').mockImplementation((node: Node) => {
+ head.appendChild(node);
+ return node;
+ });
+
+ vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => {
+ body.appendChild(node);
+ return node;
+ });
+
+ html.appendChild(head);
+ html.appendChild(body);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ head.innerHTML = '';
+ body.innerHTML = '';
+ document.querySelectorAll('script').forEach((node) => node.remove());
+ });
+
+ it('loads the customerPrivacy with privacyBanner script', () => {
+ renderHook(() => useCustomerPrivacy(CUSTOMER_PRIVACY_PROPS));
+ const script = html.querySelector('body script');
+ expect(script).toContainHTML(`src="${CONSENT_API_WITH_BANNER}"`);
+ expect(script).toContainHTML('type="text/javascript"');
+ });
+
+ it('loads just the customerPrivacy script', () => {
+ renderHook(() =>
+ useCustomerPrivacy({
+ ...CUSTOMER_PRIVACY_PROPS,
+ withPrivacyBanner: false,
+ }),
+ );
+ const script = html.querySelector('body script');
+ expect(script).toContainHTML(`src="${CONSENT_API}"`);
+ expect(script).toContainHTML('type="text/javascript"');
+ });
+
+ it('returns just customerPrivacy initiallly as null', () => {
+ let cp;
+ renderHook(() => {
+ cp = useCustomerPrivacy({
+ ...CUSTOMER_PRIVACY_PROPS,
+ withPrivacyBanner: false,
+ });
+ });
+ expect(cp).toEqual({customerPrivacy: null});
+ });
+
+ it('returns both customerPrivacy and privacyBanner initially as null', async () => {
+ let cp;
+ renderHook(() => {
+ cp = useCustomerPrivacy({
+ ...CUSTOMER_PRIVACY_PROPS,
+ withPrivacyBanner: true,
+ });
+ });
+
+ // Wait until idle
+ await act(async () => {});
+
+ expect(cp).toEqual({customerPrivacy: null, privacyBanner: null});
+ });
+
+ it('returns only customerPrivacy', async () => {
+ let cp;
+
+ const initialProps = {
+ ...CUSTOMER_PRIVACY_PROPS,
+ withPrivacyBanner: false,
+ };
+
+ const {rerender} = renderHook(
+ (props) => {
+ cp = useCustomerPrivacy(props);
+ },
+ {initialProps},
+ );
+
+ rerender(initialProps);
+
+ // mock the original customerPrivacy script injected APIs. It first defines the global object
+ // @ts-ignore
+ global.window.Shopify = {};
+ global.window.Shopify.customerPrivacy = {
+ setTrackingConsent: () => {},
+ };
+
+ // mock the original privacyBanner script injected APIs
+ rerender(initialProps);
+
+ expect(cp).toEqual({
+ customerPrivacy: expect.objectContaining({
+ setTrackingConsent: expect.any(Function),
+ }),
+ });
+ });
+
+ it('returns both customerPrivacy and privaceBanner', async () => {
+ let cp;
+
+ const {rerender} = renderHook(
+ (props) => {
+ cp = useCustomerPrivacy(props);
+ },
+ {initialProps: CUSTOMER_PRIVACY_PROPS},
+ );
+
+ rerender(CUSTOMER_PRIVACY_PROPS);
+
+ // mock the original customerPrivacy script injected APIs. It first defines the global object
+ // @ts-ignore
+ global.window.Shopify = {};
+ global.window.Shopify.customerPrivacy = {
+ setTrackingConsent: () => {},
+ };
+
+ // mock the original privacyBanner script injected APIs
+ rerender(CUSTOMER_PRIVACY_PROPS);
+
+ // mock the original privacyBanner script injected APIs
+ global.window.privacyBanner = {
+ loadBanner: () => {},
+ showPreferences: () => {},
+ };
+
+ // mock the original privacyBanner script injected APIs
+ rerender(CUSTOMER_PRIVACY_PROPS);
+
+ expect(cp).toEqual({
+ customerPrivacy: expect.objectContaining({
+ setTrackingConsent: expect.any(Function),
+ }),
+ privacyBanner: expect.objectContaining({
+ loadBanner: expect.any(Function),
+ showPreferences: expect.any(Function),
+ }),
+ });
+ });
+
+ it('triggers the onReady callback when both APIs are ready', async () => {
+ const onReady = vi.fn();
+
+ let cp;
+ const {rerender} = renderHook(
+ (props) => {
+ cp = useCustomerPrivacy(props);
+ },
+ {initialProps: {...CUSTOMER_PRIVACY_PROPS, onReady}},
+ );
+
+ rerender({...CUSTOMER_PRIVACY_PROPS, onReady});
+
+ // mock the original customerPrivacy script injected APIs. It first defines the global object
+ // @ts-ignore
+ global.window.Shopify = {};
+ global.window.Shopify.customerPrivacy = {
+ setTrackingConsent: () => {},
+ };
+
+ // mock the original privacyBanner script injected APIs
+ rerender({...CUSTOMER_PRIVACY_PROPS, onReady});
+
+ // mock the original privacyBanner script injected APIs
+ global.window.privacyBanner = {
+ loadBanner: () => {},
+ showPreferences: () => {},
+ };
+
+ rerender({...CUSTOMER_PRIVACY_PROPS, onReady});
+
+ expect(onReady).toHaveBeenCalled();
+ });
+});
diff --git a/packages/hydrogen/src/index.ts b/packages/hydrogen/src/index.ts
index 5f39f4cc09..539aee7ebf 100644
--- a/packages/hydrogen/src/index.ts
+++ b/packages/hydrogen/src/index.ts
@@ -116,6 +116,9 @@ export {
type CustomEventMap,
type CustomerPrivacyApiProps,
useCustomerPrivacy,
+ /*
+ @deprecated use useAnalytics or useCustomerPrivacy instead
+ */
getCustomerPrivacy,
} from './customer-privacy/ShopifyCustomerPrivacy';
diff --git a/templates/skeleton/app/root.tsx b/templates/skeleton/app/root.tsx
index 9db06aab1f..e0f69874db 100644
--- a/templates/skeleton/app/root.tsx
+++ b/templates/skeleton/app/root.tsx
@@ -73,6 +73,10 @@ export async function loader(args: LoaderFunctionArgs) {
consent: {
checkoutDomain: env.PUBLIC_CHECKOUT_DOMAIN,
storefrontAccessToken: env.PUBLIC_STOREFRONT_API_TOKEN,
+ withPrivacyBanner: true,
+ // localize the privacy banner
+ country: args.context.storefront.i18n.country,
+ language: args.context.storefront.i18n.language,
},
});
}
@@ -94,9 +98,7 @@ async function loadCriticalData({context}: LoaderFunctionArgs) {
// Add other queries here, so that they are loaded in parallel
]);
- return {
- header,
- };
+ return {header};
}
/**