diff --git a/.changeset/curly-phones-search.md b/.changeset/curly-phones-search.md new file mode 100644 index 0000000000..ef9ea805ac --- /dev/null +++ b/.changeset/curly-phones-search.md @@ -0,0 +1,31 @@ +--- +'@shopify/hydrogen': minor +'template-hydrogen-default': minor +--- + +- Fix clientAnalytics not waiting for all server analytics data before sending page view event +- Fix server analytics connector erroring out after more than 1 server analytics connectors are attached +- Shopify analytics components + +# Updates to server analytics connectors + +The server analytics connector interface has updated to + +```jsx +export function request( + requestUrl: string, + requestHeader: Headers, + data?: any, + contentType?: string +): void { + // Do something with the analytic event +} +``` + +# Introducing Shopify analytics + +Optional analytics components that allows you to send ecommerce related analytics to +Shopify. Adding the Shopify analytics components will allow the Shopify admin - Analytics +dashboard to work. + +For information, see [Shopify Analytics](https://shopify.dev/api/hydrogen/components/framework/shopifyanalytics) diff --git a/docs/components/framework/shopifyanalytics.md b/docs/components/framework/shopifyanalytics.md new file mode 100644 index 0000000000..6f15de9d65 --- /dev/null +++ b/docs/components/framework/shopifyanalytics.md @@ -0,0 +1,128 @@ +--- +gid: 398e9916-c99c-4d92-af5c-6681dd5e37d5 +title: ShopifyAnalytics +description: The ShopifyAnalytics component sends commerce-related analytics to Shopify. +--- + +The `ShopifyAnalytics` component sends commerce-related analytics to Shopify. By adding the `ShopifyAnalytics` component to your Hydrogen app, you can view key sales, orders, and online store visitor data from the [Analytics dashboard in your Shopify admin](https://help.shopify.com/en/manual/reports-and-analytics/shopify-reports/overview-dashboard). + +> Note: +> Currently, only Online Store page view and session-related analytic reports can be configured with the `ShopifyAnalytics` component. Additional analytics functionality will be available in the coming weeks. + +## Configuration + +To set up the `ShopifyAnalytics` component in your Hydrogen app, add the `ShopifyAnalyticsServerConnector` property to your [Hydrogen configuration file](https://shopify.dev/custom-storefronts/hydrogen/framework/hydrogen-config): + +{% codeblock file, filename: 'hydrogen.config.js' %} + +```jsx +import { + ... + ShopifyAnalyticsServerConnector, +} from '@shopify/hydrogen'; +... +export default defineConfig({ + ... + serverAnalyticsConnectors: [ + PerformanceMetricsServerAnalyticsConnector, + // The `serverAnalyticsConnectors` property allows you to send analytics data from the server in your Hydrogen app. + ShopifyAnalyticsServerConnector, + ], +}); +``` + +{% endcodeblock %} + +After you've updated your Hydrogen configuration file, add the `ShopifyAnalytics` component in `App.server.jsx`. + +{% codeblock file, filename: 'App.server.jsx' %} + +```jsx +function App() { + return ( + }> + + ... + + + + ); +} +``` + +{% endcodeblock %} + +If you have a custom domain or you're using sub-domains, then you can set the cookie domain of +the `ShopifyAnalytics` component so that cookies persists for your root domain: + +{% codeblock file, filename: 'App.server.jsx' %} + +```jsx + +``` + +{% endcodeblock %} + +If you're not using custom domains or sub-domains, then the `ShopifyAnalytics` component uses the `storeDomain` value in the Hydrogen configuration file as the default cookie domain or leaves it blank when the specified cookie domain doesn't match `window.location.hostname`. + +### Connecting Hydrogen analytics with Shopify checkout + +Analytic cookies must be set at the first-party domain. This means that when a buyer navigates from your Hydrogen storefront to Shopify checkout, the domain name must stay the same. + +You can achieve this by assigning a sub-domain to your online store. For example, you can do the following tasks: + +- Set your Hydrogen store domain to `https://www.my-awesome-hydrogen-store.com`. +- Attach a new sub-domain to your online store at `https://checkout.my-awesome-hydrogen-store.com`. +- Set the `cookieDomain` to the same root domain at ``. + +> Note: +> Hydrogen analytics and Shopify checkout can only be connected in production. They can't be connected in development and preview modes. + +## Shopify Analytics data + +Provide the following data to `useServerAnalytics` to view information from the Analytics dashboard in your Shopify admin: + +| Prop | Description | Example code | +| -------- | ------------------- | ------------------- | +| shopId | The ID of your Shopify store. | [DefaultSeo.server.jsx](https://github.com/Shopify/hydrogen/blob/main/templates/template-hydrogen-default/src/components/DefaultSeo.server.jsx) | +| currency | The currency being presented to the buyer on the webpage. | [DefaultSeo.server.jsx](https://github.com/Shopify/hydrogen/blob/main/templates/template-hydrogen-default/src/components/DefaultSeo.server.jsx) | +| pageType? | The page template type for your routes. For a list of valid values, refer to [ShopifyAnalytics constants](#shopifyanalytics-constants). | [collections/[handle].server.jsx](https://github.com/Shopify/hydrogen/blob/main/templates/template-hydrogen-default/src/routes/collections/%5Bhandle%5D.server.jsx) | +| resourceId? | The ID of the page template type for the routes that use Shopify resources.

This only applies to the following routes: `article`, `blog`, `collection`, `page`, `product`. | [products/[handle].server.jsx](https://github.com/Shopify/hydrogen/blob/main/templates/template-hydrogen-default/src/routes/products/%5Bhandle%5D.server.jsx) | + +### `ShopifyAnalytics` constants + +The following table provides a list of valid values for the `pageType` property: + +| Value | Description | +| -------- | --------------------- | +| `article` | A page that displays an article in an online store blog. | +| `blog` | A page that displays an online store blog. | +| `captcha` | A page that uses Google's [reCAPTCHA v3](https://developers.google.com/recaptcha/docs/v3) to help prevent spam through customer, contact, and blog comment forms. | +| `cart` | A page that displays the merchandise that a buyer intends to purchase, and the estimated cost associated with the cart. | +| `collection` | A page that displays a grouping of products. | +| `customersAccount` | A page that provides details about a customer's account. | +| `customersActivateAccount` | A page that enables a customer to activate their account. | +| `customersAddresses` | A page that displays a customer's addresses. | +| `customersLogin` | A page that enables a customer to log in to a storefront. | +| `customersOrder` | A page that displays a customer's orders. | +| `customersRegister` | A page that enables a customer to create and register their account. | +| `customersResetPassword` | A page that enables a customer to reset the password associated with their account. | +| `giftCard` | A page that displays an issued gift card. | +| `home` | The homepage of the online store. | +| `listCollections` | A page that displays a list of collections, which each contain a grouping of products. | +| `forbidden` | A page that users can't access due to insufficient permissions. | +| `notFound` | A page no longer exists or is inaccessible. | +| `page` | A page that holds static HTML content. Each `page` object represents a custom page on the online store. | +| `password` | A page that's shown when [password protection is applied to the store](https://help.shopify.com/en/manual/online-store/themes/password-page). | +| `product` | A page that represents an individual item for sale in a store. | +| `policy` | A page that provides the storefront's policy. | +| `search` | A page that displays the results of a [storefront search](https://help.shopify.com/en/manual/online-store/storefront-search). | + +## Component type + +The `ShopifyAnalytics` component is a server component, which means that it renders on the server. For more information about component types, refer to [React Server Components](https://shopify.dev/custom-storefronts/hydrogen/framework/react-server-components). + +## Related framework topics + +- [Analytics](https://shopify.dev/custom-storefronts/hydrogen/framework/analytics) +- [Session management](https://shopify.dev/custom-storefronts/hydrogen/framework/sessions) \ No newline at end of file diff --git a/docs/framework/analytics.md b/docs/framework/analytics.md index 93a9ec8640..83409ab4ca 100644 --- a/docs/framework/analytics.md +++ b/docs/framework/analytics.md @@ -256,7 +256,7 @@ To send analytics data from the server-side, complete the following steps: {% codeblock file, filename: 'MyServerAnalyticsConnector.jsx' %} ```jsx - export function request(request, data, contentType) { + export function request(requestUrl, requestHeader, data, contentType) { // Send your analytics request to third-party analytics } ``` @@ -283,11 +283,12 @@ To send analytics data from the server-side, complete the following steps: The following table describes the request function parameters for `ServerAnalyticsConnector`: -| Parameter | Type | Description | -| ------------- | -------------- | ------------------------------------------------- | -| `request` | request | The analytics request object. | -| `data` | object or text | The result from `.json()` or `.text()`. | -| `contentType` | string | The content type. Valid values: `json` or `text`. | +| Parameter | Type | Description | +| --------------- | -------------- | ------------------------------------------------- | +| `requestUrl` | string | The analytics request url. | +| `requestHeader` | Headers | The analytics request headers object. | +| `data` | object or text | The result from `.json()` or `.text()`. | +| `contentType` | string | The content type. Valid values: `json` or `text`. | ## Unsubscribe from an event @@ -486,6 +487,10 @@ describe('Google Analytics 4', () => { {% endcodeblock %} +## Related components + +- [`ShopifyAnalytics`](https://shopify.dev/api/hydrogen/components/framework/shopifyanalytics) + ## Next steps - Learn how to [configure queries to preload](https://shopify.dev/custom-storefronts/hydrogen/framework/preloaded-queries) in your Hydrogen app. diff --git a/packages/hydrogen/src/foundation/Analytics/Analytics.client.tsx b/packages/hydrogen/src/foundation/Analytics/Analytics.client.tsx index 1a009502c8..b2979dee68 100644 --- a/packages/hydrogen/src/foundation/Analytics/Analytics.client.tsx +++ b/packages/hydrogen/src/foundation/Analytics/Analytics.client.tsx @@ -9,19 +9,12 @@ export function Analytics({ useEffect(() => { const urlParams = new URLSearchParams(window.location.search); - if (urlParams.has('utm_source')) { - ClientAnalytics.pushToPageAnalyticsData( - { - id: urlParams.get('utm_id'), - source: urlParams.get('utm_source'), - campaign: urlParams.get('utm_campaign'), - medium: urlParams.get('utm_medium'), - content: urlParams.get('utm_content'), - term: urlParams.get('utm_term'), - }, - 'utm' - ); - } + addUTMData(urlParams, 'id'); + addUTMData(urlParams, 'source'); + addUTMData(urlParams, 'campaign'); + addUTMData(urlParams, 'medium'); + addUTMData(urlParams, 'content'); + addUTMData(urlParams, 'term'); ClientAnalytics.pushToPageAnalyticsData(analyticsDataFromServer); ClientAnalytics.publish(ClientAnalytics.eventNames.PAGE_VIEW, true); @@ -32,11 +25,17 @@ export function Analytics({ } ); } - - return function cleanup() { - ClientAnalytics.resetPageAnalyticsData(); - }; }, [analyticsDataFromServer]); return null; } + +function addUTMData(urlParams: URLSearchParams, key: string) { + if (urlParams.has(`utm_${key}`)) { + ClientAnalytics.pushToPageAnalyticsData({ + utm: { + [key]: urlParams.get(`utm_${key}`), + }, + }); + } +} diff --git a/packages/hydrogen/src/foundation/Analytics/Analytics.server.tsx b/packages/hydrogen/src/foundation/Analytics/Analytics.server.tsx index 50779d3ab3..8ca4338709 100644 --- a/packages/hydrogen/src/foundation/Analytics/Analytics.server.tsx +++ b/packages/hydrogen/src/foundation/Analytics/Analytics.server.tsx @@ -3,35 +3,23 @@ import {useServerAnalytics} from './hook'; import {Analytics as AnalyticsClient} from './Analytics.client'; import {useServerRequest} from '../ServerRequestProvider'; import AnalyticsErrorBoundary from '../AnalyticsErrorBoundary.client'; +import {wrapPromise} from '../../utilities'; -const DELAY_KEY = 'analytics-delay'; +const DELAY_KEY_1 = 'analytics-delay-1'; +const DELAY_KEY_2 = 'analytics-delay-2'; export function Analytics() { const cache = useServerRequest().ctx.cache; // If render cache is empty, create a 50 ms delay so that React doesn't resolve this // component too early and potentially cause a mismatch in hydration - if (cache.size === 0 && !cache.has(DELAY_KEY)) { - let result: boolean; - let promise: Promise; - - cache.set(DELAY_KEY, () => { - if (result !== undefined) { - return result; - } - - if (!promise) { - promise = new Promise((resolve) => { - setTimeout(() => { - result = true; - resolve(true); - }, 50); - }); - } - - throw promise; - }); + if (cache.size === 0 && !cache.has(DELAY_KEY_1)) { + analyticsDelay(cache, DELAY_KEY_1, 50); } + // If this delay is created, execute it + cache.has(DELAY_KEY_1) && cache.get(DELAY_KEY_1).read(); + // clean up this key so that it won't be saved to the preload cache + cache.delete(DELAY_KEY_1); // Make sure all queries have returned before rendering the Analytics server component cache.forEach((cacheFn: any) => { @@ -41,10 +29,35 @@ export function Analytics() { } }); - const analyticsData = useServerAnalytics(); + // If all queries has returned (could be from cached queries), + // delay Analytic component by another 1ms (put this component + // to the end of the render queue) so that other scheduled + // render work can be processed by React's concurrent render first + if (cache.size > 1 && !cache.has(DELAY_KEY_2)) { + analyticsDelay(cache, DELAY_KEY_2, 1); + } + cache.has(DELAY_KEY_2) && cache.get(DELAY_KEY_2).read(); + cache.delete(DELAY_KEY_2); + return ( - + ); } + +function analyticsDelay( + cache: Map, + delayKey: string, + delay: number +) { + const delayPromise = wrapPromise( + new Promise((resolve) => { + setTimeout(() => { + resolve(true); + }, delay); + }) + ); + + cache.set(delayKey, delayPromise); +} diff --git a/packages/hydrogen/src/foundation/Analytics/ClientAnalytics.tsx b/packages/hydrogen/src/foundation/Analytics/ClientAnalytics.tsx index f224711e91..f201c59fe6 100644 --- a/packages/hydrogen/src/foundation/Analytics/ClientAnalytics.tsx +++ b/packages/hydrogen/src/foundation/Analytics/ClientAnalytics.tsx @@ -1,4 +1,4 @@ -import {getNamedspacedEventname} from './utils'; +import {getNamedspacedEventname, mergeDeep} from './utils'; import type {Subscriber, Subscribers, SubscriberFunction} from './types'; import {eventNames} from './const'; import {EVENT_PATHNAME} from '../../constants'; @@ -8,6 +8,7 @@ type EventGuard = Record; const subscribers: Subscribers = {}; let pageAnalyticsData: any = {}; +let isFirstPageViewSent: Boolean = false; const guardDupEvents: EventGuard = {}; const USAGE_ERROR = @@ -21,18 +22,10 @@ function isInvokedFromServer(): boolean { return false; } -function pushToPageAnalyticsData(data: any, namespace?: string): void { +function pushToPageAnalyticsData(data: any): void { if (isInvokedFromServer()) return; - if (namespace) { - pageAnalyticsData[namespace] = Object.assign( - {}, - pageAnalyticsData[namespace] || {}, - data - ); - } else { - pageAnalyticsData = Object.assign({}, pageAnalyticsData, data); - } + pageAnalyticsData = mergeDeep(pageAnalyticsData, data); } function getPageAnalyticsData(): any { @@ -47,12 +40,11 @@ function resetPageAnalyticsData(): void { pageAnalyticsData = {}; } -function publish(eventname: string, guardDup = false, payload?: any) { +function publish(eventname: string, guardDup = false, payload = {}) { if (isInvokedFromServer()) return; const namedspacedEventname = getNamedspacedEventname(eventname); const subs = subscribers[namedspacedEventname]; - const combinedPayload = Object.assign({}, pageAnalyticsData, payload); // De-dup events due to re-renders if (guardDup) { @@ -63,15 +55,31 @@ function publish(eventname: string, guardDup = false, payload?: any) { } const namespacedTimeout = setTimeout(() => { - publishEvent(subs, combinedPayload); + publishEvent( + namedspacedEventname, + subs, + mergeDeep(pageAnalyticsData, payload) + ); }, 100); guardDupEvents[namedspacedEventname] = namespacedTimeout; } else { - publishEvent(subs, combinedPayload); + publishEvent( + namedspacedEventname, + subs, + mergeDeep(pageAnalyticsData, payload) + ); } } -function publishEvent(subs: Record, payload: any) { +function publishEvent( + eventname: string, + subs: Record, + payload: any +) { + if (!isFirstPageViewSent && eventname === eventNames.PAGE_VIEW) { + isFirstPageViewSent = true; + } + if (subs) { Object.keys(subs).forEach((key) => { subs[key](payload); @@ -118,6 +126,10 @@ function pushToServer(init?: RequestInit, searchParam?: string) { ); } +function hasSentFirstPageView() { + return isFirstPageViewSent; +} + export const ClientAnalytics = { pushToPageAnalyticsData, getPageAnalyticsData, @@ -126,4 +138,5 @@ export const ClientAnalytics = { subscribe, pushToServer, eventNames, + hasSentFirstPageView, }; diff --git a/packages/hydrogen/src/foundation/Analytics/ServerAnalyticsRoute.server.tsx b/packages/hydrogen/src/foundation/Analytics/ServerAnalyticsRoute.server.tsx index 8e4a73f14e..a66669865c 100644 --- a/packages/hydrogen/src/foundation/Analytics/ServerAnalyticsRoute.server.tsx +++ b/packages/hydrogen/src/foundation/Analytics/ServerAnalyticsRoute.server.tsx @@ -5,15 +5,18 @@ export function ServerAnalyticsRoute( request: Request, serverAnalyticsConnectors?: Array ) { - if (request.headers.get('Content-Length') === '0') { + const requestHeader = request.headers; + const requestUrl = request.url; + + if (requestHeader.get('Content-Length') === '0') { serverAnalyticsConnectors?.forEach((connector) => { - connector.request(request); + connector.request(requestUrl, request.headers); }); - } else if (request.headers.get('Content-Type') === 'application/json') { + } else if (requestHeader.get('Content-Type') === 'application/json') { Promise.resolve(request.json()) .then((data) => { serverAnalyticsConnectors?.forEach((connector) => { - connector.request(request, data, 'json'); + connector.request(requestUrl, requestHeader, data, 'json'); }); }) .catch((error) => { @@ -23,7 +26,7 @@ export function ServerAnalyticsRoute( Promise.resolve(request.text()) .then((data) => { serverAnalyticsConnectors?.forEach((connector) => { - connector.request(request, data, 'text'); + connector.request(requestUrl, requestHeader, data, 'text'); }); }) .catch((error) => { diff --git a/packages/hydrogen/src/foundation/Analytics/connectors/PerformanceMetrics/PerformanceMetrics.server.tsx b/packages/hydrogen/src/foundation/Analytics/connectors/PerformanceMetrics/ServerAnalyticsConnector.server.tsx similarity index 65% rename from packages/hydrogen/src/foundation/Analytics/connectors/PerformanceMetrics/PerformanceMetrics.server.tsx rename to packages/hydrogen/src/foundation/Analytics/connectors/PerformanceMetrics/ServerAnalyticsConnector.server.tsx index f279fd033d..956c7dc939 100644 --- a/packages/hydrogen/src/foundation/Analytics/connectors/PerformanceMetrics/PerformanceMetrics.server.tsx +++ b/packages/hydrogen/src/foundation/Analytics/connectors/PerformanceMetrics/ServerAnalyticsConnector.server.tsx @@ -1,9 +1,12 @@ +import {log} from '../../../../utilities/log'; + export function request( - request: Request, + requestUrl: string, + requestHeader: Headers, data?: any, contentType?: string ): void { - const url = new URL(request.url); + const url = new URL(requestUrl); if (url.search === '?performance' && contentType === 'json') { const initTime = new Date().getTime(); @@ -11,8 +14,8 @@ export function request( method: 'post', headers: { 'content-type': 'text/plain', - 'x-forwarded-for': request.headers.get('x-forwarded-for') || '', - 'user-agent': request.headers.get('user-agent') || '', + 'x-forwarded-for': requestHeader.get('x-forwarded-for') || '', + 'user-agent': requestHeader.get('user-agent') || '', }, body: JSON.stringify({ schema_id: 'hydrogen_buyer_performance/2.0', @@ -22,8 +25,8 @@ export function request( event_sent_at_ms: new Date().getTime(), }, }), - }).catch((error) => { - // send to bugsnag? oxygen? + }).catch((err) => { + log.error(err); }); } } diff --git a/packages/hydrogen/src/foundation/Analytics/connectors/Shopify/ServerAnalyticsConnector.server.tsx b/packages/hydrogen/src/foundation/Analytics/connectors/Shopify/ServerAnalyticsConnector.server.tsx new file mode 100644 index 0000000000..f741866533 --- /dev/null +++ b/packages/hydrogen/src/foundation/Analytics/connectors/Shopify/ServerAnalyticsConnector.server.tsx @@ -0,0 +1,26 @@ +import {log} from '../../../../utilities/log'; + +export function request( + requestUrl: string, + requestHeader: Headers, + data?: any, + contentType?: string +): void { + const url = new URL(requestUrl); + if (url.search === '?shopify' && contentType === 'json') { + data.events.forEach((event: any) => { + event.payload.client_ip_address = requestHeader.get('x-forwarded-for'); + event.payload.client_user_agent = requestHeader.get('user-agent'); + }); + + fetch('https://monorail-edge.shopifysvc.com/unstable/produce_batch', { + method: 'post', + headers: { + 'content-type': 'text/plain', + }, + body: JSON.stringify(data), + }).catch((err) => { + log.error(err); + }); + } +} diff --git a/packages/hydrogen/src/foundation/Analytics/connectors/Shopify/ShopifyAnalytics.client.tsx b/packages/hydrogen/src/foundation/Analytics/connectors/Shopify/ShopifyAnalytics.client.tsx new file mode 100644 index 0000000000..af5cf697ec --- /dev/null +++ b/packages/hydrogen/src/foundation/Analytics/connectors/Shopify/ShopifyAnalytics.client.tsx @@ -0,0 +1,239 @@ +import {useEffect} from 'react'; +import {parse, stringify} from 'worktop/cookie'; +import {ClientAnalytics} from '../../index'; +import {buildUUID, addDataIf} from './utils'; +import {SHOPIFY_S, SHOPIFY_Y} from './const'; + +const longTermLength = 60 * 60 * 24 * 360 * 2; // ~2 year expiry +const shortTermLength = 60 * 30; // 30 mins +const myShopifyDomain = 'myshopify.com'; +const oxygenDomain = 'myshopify.dev'; + +let isInit = false; +let microSessionCount = 0; + +export function ShopifyAnalyticsClient({cookieDomain}: {cookieDomain: string}) { + useEffect(() => { + try { + // Find Shopify cookies + const cookieData = parse(document.cookie); + const shopifyYCookie = cookieData[SHOPIFY_Y] || buildUUID(); + const shopifySCookie = cookieData[SHOPIFY_S] || buildUUID(); + + /** + * Set user and session cookies and refresh the expiry time + */ + updateCookie(SHOPIFY_Y, shopifyYCookie, longTermLength, cookieDomain); + updateCookie(SHOPIFY_S, shopifySCookie, shortTermLength, cookieDomain); + + ClientAnalytics.pushToPageAnalyticsData({ + shopify: { + pageId: buildUUID(), + userId: shopifyYCookie, + sessionId: shopifySCookie, + }, + }); + + microSessionCount = 0; + + // TODO: Fix with useEvent when ready + // RFC: https://github.com/reactjs/rfcs/blob/useevent/text/0000-useevent.md + if (!isInit) { + isInit = true; + + const eventNames = ClientAnalytics.eventNames; + + ClientAnalytics.subscribe(eventNames.PAGE_VIEW, trackPageView); + + // On a slow network, the pageview event could be already fired before + // we subscribed to the pageview event + if (ClientAnalytics.hasSentFirstPageView()) { + trackPageView(ClientAnalytics.getPageAnalyticsData()); + } + } + } catch (err) { + // Do nothing + } + }); + + return null; +} + +function updateCookie( + cookieName: string, + value: string, + maxage: number, + cookieDomain: string +) { + const cookieString = stringify(cookieName, value, { + maxage, + domain: getCookieDomain(cookieDomain), + secure: process.env.NODE_ENV === 'production', + samesite: 'Lax', + path: '/', + }); + + document.cookie = cookieString; + return cookieString; +} + +function getCookieDomain(cookieDomain: string): string { + const hostname = location.hostname; + + if (hostname.indexOf(myShopifyDomain) !== -1) { + return `.${hostname.split('.').slice(-3).join('.')}`; + } else if (hostname.indexOf(cookieDomain) !== -1) { + return `.${cookieDomain}`; + } else { + return ''; + } +} + +function trackPageView(payload: any): void { + microSessionCount += 1; + try { + sendToServer(storefrontPageViewSchema(payload)); + } catch (error) { + console.error( + `Error Shopify analytics: ${ClientAnalytics.eventNames.PAGE_VIEW}`, + error + ); + } +} + +function storefrontPageViewSchema(payload: any): any { + return { + schema_id: 'trekkie_storefront_page_view/1.4', + payload: buildStorefrontPageViewPayload(payload), + metadata: { + event_created_at_ms: Date.now(), + }, + }; +} + +function buildStorefrontPageViewPayload(payload: any): any { + const location = document.location; + const shopify = payload.shopify; + let formattedData = { + appClientId: '6167201', + hydrogenSubchannelId: shopify.storefrontId, + + isPersistentCookie: shopify.isPersistentCookie, + uniqToken: shopify.userId, + visitToken: shopify.sessionId, + microSessionId: shopify.pageId, + microSessionCount, + + url: location.href, + path: location.pathname, + search: location.search, + referrer: document.referrer, + title: document.title, + + shopId: stripGId(shopify.shopId), + currency: shopify.currency, + contentLanguage: shopify.acceptedLanguage, + }; + + formattedData = addDataIf( + { + isMerchantRequest: isMerchantRequest(), + }, + formattedData + ); + + formattedData = addDataIf( + { + pageType: shopify.pageType, + }, + formattedData + ); + + if (shopify.resourceId) { + try { + formattedData = addDataIf( + { + resourceType: getResourceType(shopify.resourceId), + resourceId: stripGId(shopify.resourceId), + }, + formattedData + ); + } catch (err) { + // do nothing + } + } + + formattedData = addDataIf( + { + customerId: shopify.customerId, + }, + formattedData + ); + + return formattedData; +} + +function isMerchantRequest(): Boolean { + const hostname = location.hostname; + if (hostname.indexOf(oxygenDomain) !== -1 || hostname === 'localhost') { + return true; + } + return false; +} + +function stripGId(text: string): number { + return parseInt(text.substring(text.lastIndexOf('/') + 1)); +} + +function getResourceType(text: string): string { + return text + .substring(0, text.lastIndexOf('/')) + .replace(/.*shopify\//, '') + .toLowerCase(); +} + +const BATCH_SENT_TIMEOUT = 500; +let batchedData: any[] = []; +let batchedTimeout: NodeJS.Timeout | null; + +function sendToServer(data: any) { + batchedData.push(data); + + if (batchedTimeout) { + clearTimeout(batchedTimeout); + batchedTimeout = null; + } + + batchedTimeout = setTimeout(() => { + const batchedDataToBeSent = { + events: batchedData, + metadata: { + event_sent_at_ms: Date.now(), + }, + }; + + batchedData = []; + batchedTimeout = null; + + // Send to server + try { + fetch('/__event?shopify', { + method: 'post', + headers: { + 'cache-control': 'no-cache', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(batchedDataToBeSent), + }); + } catch (error) { + // Fallback to client-side + fetch('https://monorail-edge.shopifysvc.com/unstable/produce_batch', { + method: 'post', + headers: { + 'content-type': 'text/plain', + }, + body: JSON.stringify(batchedDataToBeSent), + }); + } + }, BATCH_SENT_TIMEOUT); +} diff --git a/packages/hydrogen/src/foundation/Analytics/connectors/Shopify/ShopifyAnalytics.server.tsx b/packages/hydrogen/src/foundation/Analytics/connectors/Shopify/ShopifyAnalytics.server.tsx new file mode 100644 index 0000000000..225c865df3 --- /dev/null +++ b/packages/hydrogen/src/foundation/Analytics/connectors/Shopify/ShopifyAnalytics.server.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import {parse} from 'worktop/cookie'; +import AnalyticsErrorBoundary from '../../../AnalyticsErrorBoundary.client'; +import {useServerRequest} from '../../../ServerRequestProvider'; +import {useServerAnalytics} from '../../hook'; +import {useShop} from '../../../useShop'; +import {SHOPIFY_S, SHOPIFY_Y} from './const'; +import {ShopifyAnalyticsClient} from './ShopifyAnalytics.client'; + +export function ShopifyAnalytics({cookieDomain}: {cookieDomain?: string}) { + const {storeDomain} = useShop(); + const request = useServerRequest(); + const cookies = parse(request.headers.get('Cookie') || ''); + const domain = cookieDomain || storeDomain; + + useServerAnalytics({ + shopify: { + storefrontId: globalThis.Oxygen?.env?.SHOPIFY_STOREFRONT_ID || '0', + acceptedLanguage: + request.headers.get('Accept-Language')?.replace(/-.*/, '') || 'en', + isPersistentCookie: !!cookies[SHOPIFY_S] || !!cookies[SHOPIFY_Y], + }, + }); + + return ( + + + + ); +} diff --git a/packages/hydrogen/src/foundation/Analytics/connectors/Shopify/const.tsx b/packages/hydrogen/src/foundation/Analytics/connectors/Shopify/const.tsx new file mode 100644 index 0000000000..f1f7512584 --- /dev/null +++ b/packages/hydrogen/src/foundation/Analytics/connectors/Shopify/const.tsx @@ -0,0 +1,53 @@ +export const SHOPIFY_Y = '_shopify_y'; +export const SHOPIFY_S = '_shopify_s'; + +// Shopify analytics constants +const article = 'article'; +const blog = 'blog'; +const captcha = 'captcha'; +const cart = 'cart'; +const collection = 'collection'; +const customersAccount = 'customers/account'; +const customersActivateAccount = 'customers/activate_account'; +const customersAddresses = 'customers/addresses'; +const customersLogin = 'customers/login'; +const customersOrder = 'customers/order'; +const customersRegister = 'customers/register'; +const customersResetPassword = 'customers/reset_password'; +const giftCard = 'gift_card'; +const home = 'index'; +const listCollections = 'list-collections'; +const forbidden = '403'; +const notFound = '404'; +const page = 'page'; +const password = 'password'; +const product = 'product'; +const policy = 'policy'; +const search = 'search'; + +export const ShopifyAnalyticsConstants = { + pageType: { + article, + blog, + captcha, + cart, + collection, + customersAccount, + customersActivateAccount, + customersAddresses, + customersLogin, + customersOrder, + customersRegister, + customersResetPassword, + giftCard, + home, + listCollections, + forbidden, + notFound, + page, + password, + product, + policy, + search, + }, +}; diff --git a/packages/hydrogen/src/foundation/Analytics/connectors/Shopify/utils.tsx b/packages/hydrogen/src/foundation/Analytics/connectors/Shopify/utils.tsx new file mode 100644 index 0000000000..ba79f6f142 --- /dev/null +++ b/packages/hydrogen/src/foundation/Analytics/connectors/Shopify/utils.tsx @@ -0,0 +1,67 @@ +const zeros = '00000000'; +const tokenHash = 'xxxx-4xxx-xxxx-xxxxxxxxxxxx'; + +export function buildUUID(): string { + let hash = ''; + + try { + const crypto: Crypto = window.crypto; + const randomValuesArray = new Uint16Array(31); + crypto.getRandomValues(randomValuesArray); + + // Generate a strong UUID + let i = 0; + hash = tokenHash + .replace(/[x]/g, (c: string, ...args: any[]): string => { + const r = randomValuesArray[i] % 16; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + i++; + return v.toString(16); + }) + .toUpperCase(); + } catch (err) { + // crypto not available, generate weak UUID + hash = tokenHash + .replace(/[x]/g, (c: string, ...args: any[]): string => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }) + .toUpperCase(); + } + + return `${hexTime()}-${hash}`; +} + +export function hexTime(): string { + // 32 bit representations of new Date().getTime() and performance.now() + let dateNumber = 0; + let perfNumber = 0; + + // Result of zero-fill right shift is always positive + dateNumber = new Date().getTime() >>> 0; + + try { + perfNumber = performance.now() >>> 0; + } catch (err) { + perfNumber = 0; + } + + const output = Math.abs(dateNumber + perfNumber) + .toString(16) + .toLowerCase(); + + return zeros.substr(0, 8 - output.length) + output; +} + +export function addDataIf( + keyValuePairs: Record, + formattedData: any +): any { + Object.entries(keyValuePairs).forEach(([key, value]) => { + if (value) { + formattedData[key] = value; + } + }); + return formattedData; +} diff --git a/packages/hydrogen/src/foundation/Analytics/hook.tsx b/packages/hydrogen/src/foundation/Analytics/hook.tsx index 82664eaaf7..c7f5368db4 100644 --- a/packages/hydrogen/src/foundation/Analytics/hook.tsx +++ b/packages/hydrogen/src/foundation/Analytics/hook.tsx @@ -1,12 +1,12 @@ import {useServerRequest} from '../ServerRequestProvider'; +import {mergeDeep} from './utils'; export function useServerAnalytics(data?: any): any { const request = useServerRequest(); - if (data) - request.ctx.analyticsData = Object.assign( - {}, - request.ctx.analyticsData, - data - ); + + if (data) { + request.ctx.analyticsData = mergeDeep(request.ctx.analyticsData, data); + } + return request.ctx.analyticsData; } diff --git a/packages/hydrogen/src/foundation/Analytics/tests/Analytics.server.test.tsx b/packages/hydrogen/src/foundation/Analytics/tests/Analytics.server.test.tsx index 1ddf7e035e..ff208ee028 100644 --- a/packages/hydrogen/src/foundation/Analytics/tests/Analytics.server.test.tsx +++ b/packages/hydrogen/src/foundation/Analytics/tests/Analytics.server.test.tsx @@ -74,7 +74,6 @@ describe('Analytics.server', () => { const cache = request.ctx.cache; expect(cache.size).toEqual(1); - expect(cache.has('analytics-delay')).toEqual(true); expect(request.ctx.analyticsData).toEqual({ url: 'https://examples.com/', normalizedRscUrl: 'https://examples.com/', @@ -98,7 +97,6 @@ describe('Analytics.server', () => { const cache = request.ctx.cache; expect(cache.size).toEqual(2); - expect(cache.has('analytics-delay')).toEqual(false); expect(request.ctx.analyticsData).toEqual({ url: 'https://examples.com/', normalizedRscUrl: 'https://examples.com/', diff --git a/packages/hydrogen/src/foundation/Analytics/tests/ServerAnalyticsRoute.test.tsx b/packages/hydrogen/src/foundation/Analytics/tests/ServerAnalyticsRoute.test.tsx index 2f285248f4..93a5995305 100644 --- a/packages/hydrogen/src/foundation/Analytics/tests/ServerAnalyticsRoute.test.tsx +++ b/packages/hydrogen/src/foundation/Analytics/tests/ServerAnalyticsRoute.test.tsx @@ -25,7 +25,7 @@ describe('Analytics - ServerAnalyticsRoute', () => { expect(response.status).toEqual(200); expect(mockServerAnalyticsConnector).toHaveBeenCalled(); - expect(mockServerAnalyticsConnector.mock.calls[0][0]).toEqual(request); + expect(mockServerAnalyticsConnector.mock.calls[0][0]).toEqual(request.url); }); it('should delegate request to multiple server analytics connectors', () => { @@ -43,9 +43,9 @@ describe('Analytics - ServerAnalyticsRoute', () => { expect(response.status).toEqual(200); expect(mockServerAnalyticsConnector1).toHaveBeenCalled(); - expect(mockServerAnalyticsConnector1.mock.calls[0][0]).toEqual(request); + expect(mockServerAnalyticsConnector1.mock.calls[0][0]).toEqual(request.url); expect(mockServerAnalyticsConnector2).toHaveBeenCalled(); - expect(mockServerAnalyticsConnector2.mock.calls[0][0]).toEqual(request); + expect(mockServerAnalyticsConnector2.mock.calls[0][0]).toEqual(request.url); }); it('should delegate json request', async () => { @@ -60,11 +60,13 @@ describe('Analytics - ServerAnalyticsRoute', () => { }), }); const mockServerAnalyticsConnector = ( - request: Request, + requestUrl: string, + requestHeader: Headers, data?: any, type?: string ): void => { - expect(request).toEqual(testRequest); + expect(requestUrl).toEqual(testRequest.url); + expect(requestHeader).toEqual(testRequest.headers); expect(data).toEqual({ test: '123', }); @@ -88,11 +90,13 @@ describe('Analytics - ServerAnalyticsRoute', () => { body: 'test123', }); const mockServerAnalyticsConnector = ( - request: Request, + requestUrl: string, + requestHeader: Headers, data?: any, type?: string ): void => { - expect(request).toEqual(testRequest); + expect(requestUrl).toEqual(testRequest.url); + expect(requestHeader).toEqual(testRequest.headers); expect(data).toEqual('test123'); expect(type).toEqual('text'); resolve(); diff --git a/packages/hydrogen/src/foundation/Analytics/utils.tsx b/packages/hydrogen/src/foundation/Analytics/utils.tsx index d7675c4267..1cbed311b4 100644 --- a/packages/hydrogen/src/foundation/Analytics/utils.tsx +++ b/packages/hydrogen/src/foundation/Analytics/utils.tsx @@ -7,3 +7,25 @@ export function getNamedspacedEventname(eventname: string): string { ? `c-${eventname}` : eventname; } + +export function isObject(item: any) { + return item && typeof item === 'object' && !Array.isArray(item); +} + +export function mergeDeep(target: any, ...sources: any[]): any { + if (!sources.length) return target; + const source = sources.shift(); + + if (isObject(target) && isObject(source)) { + for (const key in source) { + if (isObject(source[key])) { + if (!target[key]) Object.assign(target, {[key]: {}}); + mergeDeep(target[key], source[key]); + } else { + Object.assign(target, {[key]: source[key]}); + } + } + } + + return mergeDeep(target, ...sources); +} diff --git a/packages/hydrogen/src/framework/Hydration/rsc.ts b/packages/hydrogen/src/framework/Hydration/rsc.ts new file mode 100644 index 0000000000..f48b9697df --- /dev/null +++ b/packages/hydrogen/src/framework/Hydration/rsc.ts @@ -0,0 +1,128 @@ +// TODO should we move this file to src/foundation +// so it is considered ESM instead of CJS? + +import { + createFromFetch, + createFromReadableStream, + // @ts-ignore +} from '@shopify/hydrogen/vendor/react-server-dom-vite'; +import {ClientAnalytics} from '../../client'; +import {RSC_PATHNAME} from '../../constants'; + +let rscReader: ReadableStream | null; + +// Hydrate an SSR response from tags placed in the DOM. +const flightChunks: string[] = []; +const FLIGHT_ATTRIBUTE = 'data-flight'; + +function addElementToFlightChunks(el: Element) { + // We don't need to decode, because `.getAttribute` already decodes + const chunk = el.getAttribute(FLIGHT_ATTRIBUTE); + if (chunk) { + flightChunks.push(chunk); + } +} + +// Get initial payload +document + .querySelectorAll('[' + FLIGHT_ATTRIBUTE + ']') + .forEach(addElementToFlightChunks); + +// Create a mutation observer on the document to detect when new +// tags are added, and add them to the array. +const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if ( + node instanceof HTMLElement && + node.tagName === 'META' && + node.hasAttribute(FLIGHT_ATTRIBUTE) + ) { + addElementToFlightChunks(node); + } + }); + }); +}); + +observer.observe(document.documentElement, { + childList: true, + subtree: true, +}); + +if (flightChunks.length > 0) { + const contentLoaded = new Promise((resolve) => + document.addEventListener('DOMContentLoaded', resolve) + ); + + try { + rscReader = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + const write = (chunk: string) => { + controller.enqueue(encoder.encode(chunk)); + return 0; + }; + + flightChunks.forEach(write); + flightChunks.push = write; + + contentLoaded.then(() => { + controller.close(); + observer.disconnect(); + }); + }, + }); + } catch (_) { + // Old browser, will try a new hydration request later + } +} + +const cache = new Map(); + +/** + * Much of this is borrowed from React's demo implementation: + * @see https://github.com/reactjs/server-components-demo/blob/main/src/Cache.client.js + * + * Note that we'd want to add some other constraints and controls around caching here. + */ +export function useServerResponse(state: any) { + const key = JSON.stringify(state); + + let response = cache.get(key); + if (response) { + return response; + } + + if (rscReader) { + // The flight response was inlined during SSR, use it directly. + response = createFromReadableStream(rscReader); + rscReader = null; + } else { + if ( + /* @ts-ignore */ + window.BOOMR && + /* @ts-ignore */ + window.BOOMR.plugins && + /* @ts-ignore */ + window.BOOMR.plugins.Hydrogen + ) { + /* @ts-ignore */ + window.BOOMR.plugins.Hydrogen.trackSubPageLoadPerformance(); + } + + ClientAnalytics.resetPageAnalyticsData(); + + // Request a new flight response. + response = createFromFetch( + fetch(`${RSC_PATHNAME}?state=` + encodeURIComponent(key)) + ); + } + + cache.clear(); + cache.set(key, response); + return response; +} + +export function useRefresh() { + cache.clear(); +} diff --git a/packages/hydrogen/src/index.ts b/packages/hydrogen/src/index.ts index 7d4b3380d8..d1f02b9c3a 100644 --- a/packages/hydrogen/src/index.ts +++ b/packages/hydrogen/src/index.ts @@ -32,7 +32,10 @@ export { CacheCustom, } from './foundation/Cache/strategies'; export {useServerAnalytics} from './foundation/Analytics/hook'; -export * as PerformanceMetricsServerAnalyticsConnector from './foundation/Analytics/connectors/PerformanceMetrics/PerformanceMetrics.server'; +export {ShopifyAnalytics} from './foundation/Analytics/connectors/Shopify/ShopifyAnalytics.server'; +export {ShopifyAnalyticsConstants} from './foundation/Analytics/connectors/Shopify/const'; +export * as ShopifyServerAnalyticsConnector from './foundation/Analytics/connectors/Shopify/ServerAnalyticsConnector.server'; +export * as PerformanceMetricsServerAnalyticsConnector from './foundation/Analytics/connectors/PerformanceMetrics/ServerAnalyticsConnector.server'; export {useSession} from './foundation/useSession/useSession'; export {CookieSessionStorage} from './foundation/CookieSessionStorage/CookieSessionStorage'; export {MemorySessionStorage} from './foundation/MemorySessionStorage/MemorySessionStorage'; diff --git a/packages/hydrogen/src/types.ts b/packages/hydrogen/src/types.ts index 1687ba229c..3e45cedbf0 100644 --- a/packages/hydrogen/src/types.ts +++ b/packages/hydrogen/src/types.ts @@ -75,7 +75,8 @@ export type ShopifyConfigFetcher = ConfigFetcher; export type ServerAnalyticsConnector = { request: ( - request: Request, + requestUrl: string, + requestHeader: Headers, data?: any, contentType?: 'json' | 'text' ) => void; diff --git a/templates/demo-store/hydrogen.config.js b/templates/demo-store/hydrogen.config.js index 05a6b61b14..6814164442 100644 --- a/templates/demo-store/hydrogen.config.js +++ b/templates/demo-store/hydrogen.config.js @@ -2,6 +2,7 @@ import {defineConfig} from '@shopify/hydrogen/config'; import { CookieSessionStorage, PerformanceMetricsServerAnalyticsConnector, + ShopifyServerAnalyticsConnector, } from '@shopify/hydrogen'; export default defineConfig({ @@ -17,5 +18,8 @@ export default defineConfig({ sameSite: 'strict', maxAge: 60 * 60 * 24 * 30, }), - serverAnalyticsConnectors: [PerformanceMetricsServerAnalyticsConnector], + serverAnalyticsConnectors: [ + PerformanceMetricsServerAnalyticsConnector, + ShopifyServerAnalyticsConnector, + ], }); diff --git a/templates/demo-store/src/App.server.jsx b/templates/demo-store/src/App.server.jsx index 80e81cedf8..8778329064 100644 --- a/templates/demo-store/src/App.server.jsx +++ b/templates/demo-store/src/App.server.jsx @@ -4,6 +4,7 @@ import { Route, FileRoutes, ShopifyProvider, + ShopifyAnalytics, PerformanceMetrics, PerformanceMetricsDebug, } from '@shopify/hydrogen'; @@ -26,6 +27,7 @@ function App() { {import.meta.env.DEV && } + ); diff --git a/templates/demo-store/src/components/DefaultSeo.server.jsx b/templates/demo-store/src/components/DefaultSeo.server.jsx index d0653a39fe..b0a4304915 100644 --- a/templates/demo-store/src/components/DefaultSeo.server.jsx +++ b/templates/demo-store/src/components/DefaultSeo.server.jsx @@ -1,4 +1,10 @@ -import {useShopQuery, Seo, CacheDays, gql} from '@shopify/hydrogen'; +import { + useShopQuery, + Seo, + CacheDays, + useServerAnalytics, + gql, +} from '@shopify/hydrogen'; /** * A server component that fetches a `shop.name` and sets default values and templates for every page on a website @@ -6,7 +12,12 @@ import {useShopQuery, Seo, CacheDays, gql} from '@shopify/hydrogen'; export default function DefaultSeo() { const { data: { - shop: {name, description}, + shop: { + name, + description, + id, + paymentSettings: {currencyCode}, + }, }, } = useShopQuery({ query: QUERY, @@ -14,6 +25,13 @@ export default function DefaultSeo() { preload: '*', }); + useServerAnalytics({ + shopify: { + shopId: id, + currency: currencyCode, + }, + }); + return ( ; } @@ -72,6 +85,7 @@ const QUERY = gql` $numProducts: Int! ) @inContext(country: $country, language: $language) { collection(handle: $handle) { + id title descriptionHtml description diff --git a/templates/demo-store/src/routes/index.server.jsx b/templates/demo-store/src/routes/index.server.jsx index 951ee6ccb3..4ba1753c4a 100644 --- a/templates/demo-store/src/routes/index.server.jsx +++ b/templates/demo-store/src/routes/index.server.jsx @@ -6,6 +6,8 @@ import { Seo, CacheDays, useSession, + useServerAnalytics, + ShopifyAnalyticsConstants, gql, } from '@shopify/hydrogen'; @@ -18,6 +20,12 @@ import {Suspense} from 'react'; export default function Index() { const {countryCode = 'US'} = useSession(); + useServerAnalytics({ + shopify: { + pageType: ShopifyAnalyticsConstants.pageType.home, + }, + }); + return ( }> diff --git a/templates/demo-store/src/routes/pages/[handle].server.jsx b/templates/demo-store/src/routes/pages/[handle].server.jsx index 5c086ab8cc..f9cc533386 100644 --- a/templates/demo-store/src/routes/pages/[handle].server.jsx +++ b/templates/demo-store/src/routes/pages/[handle].server.jsx @@ -1,4 +1,11 @@ -import {useShop, useShopQuery, Seo, gql} from '@shopify/hydrogen'; +import { + useShop, + useShopQuery, + Seo, + useServerAnalytics, + ShopifyAnalyticsConstants, + gql, +} from '@shopify/hydrogen'; import Layout from '../../components/Layout.server'; import NotFound from '../../components/NotFound.server'; @@ -12,6 +19,17 @@ export default function Page({params}) { variables: {language: languageCode, handle}, }); + useServerAnalytics( + data.pageByHandle + ? { + shopify: { + pageType: ShopifyAnalyticsConstants.pageType.page, + resourceId: data.pageByHandle.id, + }, + } + : null, + ); + if (!data.pageByHandle) { return ; } @@ -34,6 +52,7 @@ const QUERY = gql` query PageDetails($language: LanguageCode, $handle: String!) @inContext(language: $language) { pageByHandle(handle: $handle) { + id title body title diff --git a/templates/demo-store/src/routes/products/[handle].server.jsx b/templates/demo-store/src/routes/products/[handle].server.jsx index 97edfed6d4..2c592066e1 100644 --- a/templates/demo-store/src/routes/products/[handle].server.jsx +++ b/templates/demo-store/src/routes/products/[handle].server.jsx @@ -4,6 +4,8 @@ import { useShopQuery, Seo, useRouteParams, + useServerAnalytics, + ShopifyAnalyticsConstants, gql, } from '@shopify/hydrogen'; @@ -29,6 +31,17 @@ export default function Product() { preload: true, }); + useServerAnalytics( + product + ? { + shopify: { + pageType: ShopifyAnalyticsConstants.pageType.product, + resourceId: product.id, + }, + } + : null, + ); + if (!product) { return ; } diff --git a/templates/demo-store/tests/e2e/shopify-analytics.test.js b/templates/demo-store/tests/e2e/shopify-analytics.test.js new file mode 100644 index 0000000000..b84d856f45 --- /dev/null +++ b/templates/demo-store/tests/e2e/shopify-analytics.test.js @@ -0,0 +1,75 @@ +import {DEFAULT_DELAY, startHydrogenServer} from '../utils'; + +const SHOPIFY_ANALYTICS_ENDPOINT = '/__event?shopify'; + +describe('analytics', () => { + let hydrogen; + let eventsEndpoint; + let session; + + beforeAll(async () => { + hydrogen = await startHydrogenServer(); + eventsEndpoint = hydrogen.url(SHOPIFY_ANALYTICS_ENDPOINT); + }); + + beforeEach(async () => { + session = await hydrogen.newPage(); + }); + + afterAll(async () => { + await hydrogen.cleanUp(); + }); + + it( + 'should emit page-view event', + async () => { + const [request] = await Promise.all([ + session.page.waitForRequest(eventsEndpoint), + session.visit('/'), + ]); + + const shopifyEvents = request.postDataJSON(); + expect(request.url()).toEqual(eventsEndpoint); + + const event = shopifyEvents.events[0]; + const payload = event.payload; + + expect(event.schema_id).toContain('trekkie_storefront_page_view'); + expect(payload.shopId).not.toEqual(0); + expect(payload.pageType).toEqual('index'); + expect(payload.isMerchantRequest).toEqual(true); + }, + DEFAULT_DELAY, + ); + + it( + 'should emit page-view on sub load', + async () => { + const collectionPath = '/collections/freestyle-collection'; + // Full load + await Promise.all([ + session.page.waitForRequest(eventsEndpoint), + session.visit('/'), + ]); + + // Sub load + const [request] = await Promise.all([ + session.page.waitForRequest(eventsEndpoint), + session.page.click(`a[href="${collectionPath}"]`), + ]); + + const shopifyEvents = request.postDataJSON(); + expect(request.url()).toEqual(eventsEndpoint); + + const event = shopifyEvents.events[0]; + const payload = event.payload; + + expect(event.schema_id).toContain('trekkie_storefront_page_view'); + expect(payload.shopId).not.toEqual(0); + expect(payload.pageType).toEqual('collection'); + expect(payload.resourceType).toEqual('collection'); + expect(payload.isMerchantRequest).toEqual(true); + }, + DEFAULT_DELAY, + ); +}); diff --git a/templates/demo-store/tests/utils.ts b/templates/demo-store/tests/utils.ts index f3d805e115..9b224ddba4 100644 --- a/templates/demo-store/tests/utils.ts +++ b/templates/demo-store/tests/utils.ts @@ -2,6 +2,8 @@ import {chromium} from 'playwright'; import type {Server} from 'http'; import {createServer as createViteDevServer} from 'vite'; +export const DEFAULT_DELAY = 60000; + export async function startHydrogenServer() { // @ts-ignore const app = import.meta.env.WATCH