Skip to content

Commit

Permalink
Add localization support for privacy banner + useCustomerPrivacy upda…
Browse files Browse the repository at this point in the history
…tes (#2457)

* Add support for country and language on Analytics and CustomerPrivacy

* WIP testing skeleton

* add getPrivacyBanner, refactor useCustomerPrivacy

* format

* simplify useCustomerPrivacy internals and rework onReady logic

* .

* lint

* deprecate getCustomerPrivacy

* add useCustomerPrivacy tests

* update AnalyticsProvider tests for updated logic

* update docs

* format

* remove skeleton tests

* init useShopifyCookies to true

* prettier

* fix customerPrivacy loading

* add updated root consent to examples

* Add changeset
  • Loading branch information
juanpprieto authored Sep 4, 2024
1 parent 81f2b54 commit eefa820
Show file tree
Hide file tree
Showing 20 changed files with 840 additions and 123 deletions.
6 changes: 6 additions & 0 deletions .changeset/wet-pandas-battle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'skeleton': patch
'@shopify/hydrogen': patch
---

Add localization support to consent privacy banner
4 changes: 4 additions & 0 deletions examples/b2b/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
});
}
Expand Down
4 changes: 4 additions & 0 deletions examples/gtm/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
});
}
Expand Down
4 changes: 4 additions & 0 deletions examples/legacy-customer-account-flow/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
/***********************************************/
Expand Down
4 changes: 4 additions & 0 deletions examples/metaobjects/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ************/
Expand Down
4 changes: 4 additions & 0 deletions examples/multipass/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
/***********************************************/
Expand Down
4 changes: 4 additions & 0 deletions examples/partytown/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ************/
Expand Down
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;
/** 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
Loading

0 comments on commit eefa820

Please sign in to comment.