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

Add localization support for privacy banner + useCustomerPrivacy updates #2457

Merged
merged 18 commits into from
Sep 4, 2024
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
});
}
Expand Down
46 changes: 41 additions & 5 deletions packages/hydrogen/src/analytics-manager/AnalyticsProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ vi.mock('./PerfKit', () => ({

describe('<Analytics.Provider />', () => {
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<Response> {
Expand Down Expand Up @@ -139,16 +142,27 @@ describe('<Analytics.Provider />', () => {
});

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 () => {
Expand All @@ -172,6 +186,7 @@ describe('<Analytics.Provider />', () => {
analytics.subscribe('page_viewed', pageViewedEvent);
ready();
},
mockCanTrack: true,
});

expect(analytics?.canTrack()).toBe(true);
Expand Down Expand Up @@ -280,13 +295,15 @@ type RenderAnalyticsProviderProps = {
ready: () => void,
) => void;
children?: ReactNode;
mockCanTrack?: boolean;
};

async function renderAnalyticsProvider({
initialCart,
customData,
registerCallback,
children,
mockCanTrack = true,
}: RenderAnalyticsProviderProps) {
let analytics: AnalyticsContextValue | null = null;
const getUpdatedAnalytics = () => analytics;
Expand All @@ -309,7 +326,10 @@ async function renderAnalyticsProvider({
consent={CONSENT_DATA}
customData={updateCustomData || customData}
>
<LoopAnalytics registerCallback={registerCallback}>
<LoopAnalytics
registerCallback={registerCallback}
mockCanTrack={mockCanTrack}
>
{loopAnalyticsFn}
</LoopAnalytics>
{children}
Expand Down Expand Up @@ -345,6 +365,7 @@ async function triggerCartUpdate({
initialCart,
customData,
registerCallback,
mockCanTrack: true,
});

// Triggers a cart update
Expand All @@ -363,30 +384,45 @@ 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 {
ready();
}
});

customerPrivacyReady();
perfKitReady();
customerPrivacyReady();
analyticsReady();

return (
<div>{typeof children === 'function' ? children(analytics) : children}</div>
Expand Down
64 changes: 47 additions & 17 deletions packages/hydrogen/src/analytics-manager/AnalyticsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand All @@ -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> | 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`. **/
Expand Down Expand Up @@ -97,6 +105,10 @@ export type AnalyticsContextValue = {
shop: Awaited<AnalyticsProviderProps['shop']>;
/** 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 = {
Expand All @@ -108,6 +120,8 @@ export const defaultAnalyticsContext: AnalyticsContextValue = {
shop: null,
subscribe: () => {},
register: () => ({ready: () => {}}),
customerPrivacy: null,
privacyBanner: null,
};

const AnalyticsContext = createContext<AnalyticsContextValue>(
Expand Down Expand Up @@ -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<Carts>({cart: null, prevCart: null});
Expand Down Expand Up @@ -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;
}
}
}

Expand All @@ -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,
Expand All @@ -339,6 +365,8 @@ function AnalyticsProvider({
shop,
register,
JSON.stringify(registers),
getCustomerPrivacy,
getPrivacyBanner,
]);

return (
Expand All @@ -353,7 +381,7 @@ function AnalyticsProvider({
consent={consent}
onReady={() => {
listenerSet.current = true;
setConsentLoaded(true);
setAnalyticsLoaded(true);
setCanTrack(() => shopifyCanTrack);
}}
domain={cookieDomain}
Expand Down Expand Up @@ -452,15 +480,17 @@ export const Analytics = {
SearchView: AnalyticsSearchView,
};

export type AnalyticsContextValueForDoc = {
type DefaultCart = Promise<CartReturn | null> | CartReturn | null;

export type AnalyticsContextValueForDoc<UserCart> = {
/** 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;
juanpprieto marked this conversation as resolved.
Show resolved Hide resolved
/** 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<string, unknown>;
/** The previous cart state. */
prevCart?: Promise<CartReturn | null> | 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). */
Expand Down
40 changes: 16 additions & 24 deletions packages/hydrogen/src/analytics-manager/ShopifyAnalytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
});
}
Expand Down
Loading
Loading