diff --git a/.changeset/wet-pandas-battle.md b/.changeset/wet-pandas-battle.md new file mode 100644 index 0000000000..482264a9f3 --- /dev/null +++ b/.changeset/wet-pandas-battle.md @@ -0,0 +1,6 @@ +--- +'skeleton': patch +'@shopify/hydrogen': patch +--- + +Add localization support to consent privacy banner diff --git a/examples/b2b/app/root.tsx b/examples/b2b/app/root.tsx index 64e9c5a48a..25fdae2074 100644 --- a/examples/b2b/app/root.tsx +++ b/examples/b2b/app/root.tsx @@ -124,6 +124,10 @@ export async function loader({context}: LoaderFunctionArgs) { consent: { checkoutDomain: env.PUBLIC_CHECKOUT_DOMAIN, storefrontAccessToken: env.PUBLIC_STOREFRONT_API_TOKEN, + withPrivacyBanner: true, + // localize the privacy banner + country: context.storefront.i18n.country, + language: context.storefront.i18n.language, }, }); } diff --git a/examples/gtm/app/root.tsx b/examples/gtm/app/root.tsx index 3e67aadf0a..c7dd429ce1 100644 --- a/examples/gtm/app/root.tsx +++ b/examples/gtm/app/root.tsx @@ -77,6 +77,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, }, }); } diff --git a/examples/legacy-customer-account-flow/app/root.tsx b/examples/legacy-customer-account-flow/app/root.tsx index 0cbe9dcaba..e9eadcdde0 100644 --- a/examples/legacy-customer-account-flow/app/root.tsx +++ b/examples/legacy-customer-account-flow/app/root.tsx @@ -109,6 +109,10 @@ export async function loader({context}: LoaderFunctionArgs) { consent: { checkoutDomain: env.PUBLIC_CHECKOUT_DOMAIN, storefrontAccessToken: env.PUBLIC_STOREFRONT_API_TOKEN, + withPrivacyBanner: true, + // localize the privacy banner + country: context.storefront.i18n.country, + language: context.storefront.i18n.language, }, }, /***********************************************/ diff --git a/examples/metaobjects/app/root.tsx b/examples/metaobjects/app/root.tsx index 5a4cc474e1..39626b9429 100644 --- a/examples/metaobjects/app/root.tsx +++ b/examples/metaobjects/app/root.tsx @@ -92,6 +92,10 @@ export async function loader({context}: LoaderFunctionArgs) { consent: { checkoutDomain: env.PUBLIC_CHECKOUT_DOMAIN, storefrontAccessToken: env.PUBLIC_STOREFRONT_API_TOKEN, + withPrivacyBanner: true, + // localize the privacy banner + country: context.storefront.i18n.country, + language: context.storefront.i18n.language, }, /***********************************************/ /********** EXAMPLE UPDATE STARTS ************/ diff --git a/examples/multipass/app/root.tsx b/examples/multipass/app/root.tsx index 0cbe9dcaba..e9eadcdde0 100644 --- a/examples/multipass/app/root.tsx +++ b/examples/multipass/app/root.tsx @@ -109,6 +109,10 @@ export async function loader({context}: LoaderFunctionArgs) { consent: { checkoutDomain: env.PUBLIC_CHECKOUT_DOMAIN, storefrontAccessToken: env.PUBLIC_STOREFRONT_API_TOKEN, + withPrivacyBanner: true, + // localize the privacy banner + country: context.storefront.i18n.country, + language: context.storefront.i18n.language, }, }, /***********************************************/ diff --git a/examples/partytown/app/root.tsx b/examples/partytown/app/root.tsx index adf3756d62..888e6adc36 100644 --- a/examples/partytown/app/root.tsx +++ b/examples/partytown/app/root.tsx @@ -84,6 +84,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, }, /***********************************************/ /********** EXAMPLE UPDATE STARTS ************/ diff --git a/packages/hydrogen/src/analytics-manager/AnalyticsProvider.example.jsx b/packages/hydrogen/src/analytics-manager/AnalyticsProvider.example.jsx index f7df114507..fcafd50ae0 100644 --- a/packages/hydrogen/src/analytics-manager/AnalyticsProvider.example.jsx +++ b/packages/hydrogen/src/analytics-manager/AnalyticsProvider.example.jsx @@ -12,6 +12,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/AnalyticsProvider.example.tsx b/packages/hydrogen/src/analytics-manager/AnalyticsProvider.example.tsx index 20d84fb1f9..2d7ec489cb 100644 --- a/packages/hydrogen/src/analytics-manager/AnalyticsProvider.example.tsx +++ b/packages/hydrogen/src/analytics-manager/AnalyticsProvider.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/analytics-manager/AnalyticsProvider.test.tsx b/packages/hydrogen/src/analytics-manager/AnalyticsProvider.test.tsx index dbcbec9b34..7c9d3b2eef 100644 --- a/packages/hydrogen/src/analytics-manager/AnalyticsProvider.test.tsx +++ b/packages/hydrogen/src/analytics-manager/AnalyticsProvider.test.tsx @@ -104,6 +104,9 @@ vi.mock('./PerfKit', () => ({ describe('', () => { beforeAll(() => { + global.document.cookie = `_cmp_a=%7B%22purposes%22%3A%7B%22p%22%3Afalse%2C%22a%22%3Afalse%2C%22m%22%3Afalse%2C%22t%22%3Atrue%7D%2C%22display_banner%22%3Afalse%2C%22sale_of_data_region%22%3Afalse%7D`; + global.document.cookie = `_tracking_consent=%7B%22con%22%3A%7B%22CMP%22%3A%7B%22a%22%3A%22%22%2C%22m%22%3A%22%22%2C%22p%22%3A%22%22%2C%22s%22%3A%22%22%7D%7D%2C%22v%22%3A%222.1%22%2C%22region%22%3A%22CAON%22%2C%22reg%22%3A%22%22%7D`; + vi.stubGlobal( 'fetch', function mockFetch(input: URL | RequestInfo): Promise { @@ -139,16 +142,27 @@ describe('', () => { }); describe('useAnalytics()', () => { - it('returns shop, cart, customData', async () => { + it('returns shop, cart, customData, privacyBanner and customerPrivacy', async () => { const {analytics} = await renderAnalyticsProvider({ initialCart: CART_DATA, customData: {test: 'test'}, + mockCanTrack: false, }); - expect(analytics?.canTrack()).toBe(true); + expect(analytics?.canTrack()).toBe(false); expect(analytics?.shop).toBe(SHOP_DATA); expect(analytics?.cart).toBe(CART_DATA); expect(analytics?.customData).toEqual({test: 'test'}); + expect(analytics?.privacyBanner).toEqual(null); + expect(analytics?.customerPrivacy).toEqual(null); + }); + + it('returns default canTrack true', async () => { + const {analytics} = await renderAnalyticsProvider({ + initialCart: CART_DATA, + customData: {test: 'test'}, + }); + expect(analytics?.canTrack()).toBe(true); }); it('returns prevCart with an updated cart', async () => { @@ -172,6 +186,7 @@ describe('', () => { analytics.subscribe('page_viewed', pageViewedEvent); ready(); }, + mockCanTrack: true, }); expect(analytics?.canTrack()).toBe(true); @@ -280,6 +295,7 @@ type RenderAnalyticsProviderProps = { ready: () => void, ) => void; children?: ReactNode; + mockCanTrack?: boolean; }; async function renderAnalyticsProvider({ @@ -287,6 +303,7 @@ async function renderAnalyticsProvider({ customData, registerCallback, children, + mockCanTrack = true, }: RenderAnalyticsProviderProps) { let analytics: AnalyticsContextValue | null = null; const getUpdatedAnalytics = () => analytics; @@ -309,7 +326,10 @@ async function renderAnalyticsProvider({ consent={CONSENT_DATA} customData={updateCustomData || customData} > - + {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 50da31e0bb..13d360633e 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}; } /**