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