@shopify/hydrogen 1.4.4 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/esnext/components/CartLineProvider/tests/fixtures.d.ts +86 -0
  2. package/dist/esnext/components/CartLineProvider/tests/fixtures.js +34 -0
  3. package/dist/esnext/components/CartProvider/CartProvider.client.d.ts +20 -11
  4. package/dist/esnext/components/CartProvider/CartProvider.client.js +457 -477
  5. package/dist/esnext/components/CartProvider/cart-queries.d.ts +1 -1
  6. package/dist/esnext/components/CartProvider/cart-queries.js +4 -1
  7. package/dist/esnext/components/CartProvider/tests/fixtures.d.ts +254 -0
  8. package/dist/esnext/components/CartProvider/tests/fixtures.js +53 -0
  9. package/dist/esnext/components/Metafield/Metafield.client.js +1 -3
  10. package/dist/esnext/entry-server.js +11 -1
  11. package/dist/esnext/experimental.d.ts +0 -1
  12. package/dist/esnext/experimental.js +0 -1
  13. package/dist/esnext/foundation/Analytics/connectors/Shopify/ShopifyAnalytics.client.js +21 -14
  14. package/dist/esnext/foundation/Analytics/connectors/Shopify/ShopifyAnalytics.server.js +15 -9
  15. package/dist/esnext/foundation/Analytics/connectors/Shopify/const.d.ts +5 -0
  16. package/dist/esnext/foundation/Analytics/connectors/Shopify/const.js +5 -0
  17. package/dist/esnext/foundation/Analytics/connectors/Shopify/customer-events.client.d.ts +2 -0
  18. package/dist/esnext/foundation/Analytics/connectors/Shopify/customer-events.client.js +182 -0
  19. package/dist/esnext/foundation/Analytics/connectors/Shopify/utils.d.ts +3 -0
  20. package/dist/esnext/foundation/Analytics/connectors/Shopify/utils.js +69 -0
  21. package/dist/esnext/foundation/HydrogenRequest/HydrogenRequest.server.d.ts +1 -0
  22. package/dist/esnext/foundation/HydrogenRequest/HydrogenRequest.server.js +2 -8
  23. package/dist/esnext/framework/plugins/vite-plugin-hydrogen-rsc.js +2 -2
  24. package/dist/esnext/hooks/useShopQuery/hooks.js +10 -6
  25. package/dist/esnext/storefront-api-types.d.ts +334 -116
  26. package/dist/esnext/storefront-api-types.js +3 -1
  27. package/dist/esnext/testing.d.ts +2 -0
  28. package/dist/esnext/testing.js +2 -0
  29. package/dist/esnext/utilities/random.d.ts +1 -0
  30. package/dist/esnext/utilities/random.js +11 -0
  31. package/dist/esnext/utilities/tests/MockedServerRequestProvider.server.d.ts +6 -0
  32. package/dist/esnext/utilities/tests/MockedServerRequestProvider.server.js +9 -0
  33. package/dist/esnext/utilities/tests/price.d.ts +5 -0
  34. package/dist/esnext/utilities/tests/price.js +8 -0
  35. package/dist/esnext/utilities/tests/provider-helpers.d.ts +31 -0
  36. package/dist/esnext/utilities/tests/provider-helpers.js +36 -0
  37. package/dist/esnext/version.d.ts +1 -1
  38. package/dist/esnext/version.js +1 -1
  39. package/dist/node/framework/plugins/vite-plugin-hydrogen-rsc.js +2 -2
  40. package/package.json +3 -1
  41. package/dist/esnext/components/CartProvider/CartProviderV2.client.d.ts +0 -50
  42. package/dist/esnext/components/CartProvider/CartProviderV2.client.js +0 -483
@@ -0,0 +1,182 @@
1
+ import { buildUUID, addDataIf, getNavigationType, stripGId, stripId, } from './utils.js';
2
+ import { ShopifyAnalyticsConstants, PAGE_RENDERED_EVENT_NAME, COLLECTION_PAGE_RENDERED_EVENT_NAME, PRODUCT_PAGE_RENDERED_EVENT_NAME, PRODUCT_ADDED_TO_CART_EVENT_NAME, SEARCH_SUBMITTED_EVENT_NAME, } from './const.js';
3
+ import { flattenConnection } from '../../../../utilities/flattenConnection/index.js';
4
+ const DOC_URL = 'https://shopify.dev/api/hydrogen/components/framework/shopifyanalytics';
5
+ const requiredProductFields = [
6
+ {
7
+ column: 'product_gid',
8
+ gqlField: 'product.id',
9
+ },
10
+ {
11
+ column: 'variant_gid',
12
+ gqlField: 'variant.id',
13
+ },
14
+ {
15
+ column: 'name',
16
+ gqlField: 'product.title',
17
+ },
18
+ {
19
+ column: 'variant',
20
+ gqlField: 'variant.title',
21
+ },
22
+ {
23
+ column: 'brand',
24
+ gqlField: 'product.vendor',
25
+ },
26
+ {
27
+ column: 'price',
28
+ gqlField: 'variant.priceV2.amount',
29
+ },
30
+ ];
31
+ let customerId = '';
32
+ export function trackCustomerPageView(payload, sendToServer) {
33
+ const shopify = payload.shopify;
34
+ const canonicalUrl = shopify.canonicalPath
35
+ ? `${location.origin}${shopify.canonicalPath}`
36
+ : location.href;
37
+ // Only the /account/index route sets the `customerId`, so we persist this value when it's available
38
+ // and append it to analytics events only when a customer is in the logged in state (provided by `CartProvider`).
39
+ if (payload.shopify.customerId) {
40
+ customerId = payload.shopify.customerId;
41
+ }
42
+ sendToServer(customerEventSchema(payload, PAGE_RENDERED_EVENT_NAME, {
43
+ canonical_url: canonicalUrl,
44
+ }));
45
+ if (shopify.pageType === ShopifyAnalyticsConstants.pageType.product) {
46
+ sendToServer(customerEventSchema(payload, PRODUCT_PAGE_RENDERED_EVENT_NAME, {
47
+ products: formatProductsJSON(shopify.products),
48
+ canonical_url: canonicalUrl,
49
+ total_value: parseFloat(shopify.products[0].price),
50
+ }));
51
+ }
52
+ if (shopify.pageType === ShopifyAnalyticsConstants.pageType.collection) {
53
+ sendToServer(customerEventSchema(payload, COLLECTION_PAGE_RENDERED_EVENT_NAME, {
54
+ collection_name: shopify.collectionHandle,
55
+ canonical_url: canonicalUrl,
56
+ }));
57
+ }
58
+ if (shopify.pageType === ShopifyAnalyticsConstants.pageType.search) {
59
+ sendToServer(customerEventSchema(payload, SEARCH_SUBMITTED_EVENT_NAME, {
60
+ search_string: shopify.searchTerm,
61
+ }));
62
+ }
63
+ }
64
+ export function trackCustomerAddToCart(payload, sendToServer) {
65
+ const { totalValue, addedProducts } = getAddedProducts(payload);
66
+ sendToServer(customerEventSchema(payload, PRODUCT_ADDED_TO_CART_EVENT_NAME, {
67
+ total_value: totalValue,
68
+ products: formatProductsJSON(addedProducts),
69
+ cart_token: stripId(payload.cart.id),
70
+ }));
71
+ }
72
+ function customerEventSchema(payload, eventName, extraData) {
73
+ return {
74
+ schema_id: 'custom_storefront_customer_tracking/1.0',
75
+ payload: {
76
+ ...buildCustomerPayload(payload, extraData),
77
+ event_name: eventName,
78
+ },
79
+ metadata: {
80
+ event_created_at_ms: Date.now(),
81
+ },
82
+ };
83
+ }
84
+ function buildCustomerPayload(payload, extraData = {}) {
85
+ const location = document.location;
86
+ const shopify = payload.shopify;
87
+ const [navigation_type, navigation_api] = getNavigationType();
88
+ let formattedData = {
89
+ source: 'hydrogen',
90
+ shop_id: stripGId(shopify.shopId),
91
+ hydrogenSubchannelId: shopify.storefrontId || '0',
92
+ event_time: Date.now(),
93
+ event_id: buildUUID(),
94
+ unique_token: shopify.userId,
95
+ referrer: document.referrer,
96
+ event_source_url: location.href,
97
+ user_agent: navigator.userAgent,
98
+ navigation_type,
99
+ navigation_api,
100
+ currency: shopify.currency,
101
+ /**
102
+ * For now, all cookie consent management is manage by developers
103
+ *
104
+ * TO-DO: When we have access to consent api, implement is_persistent_cookie
105
+ * according to the definition below
106
+ *
107
+ * It references the state of consent for GDPR protected visitors.
108
+ * If persistent === FALSE, it means that the merchant has set
109
+ * “Partially collected before consent”, and the visitor has not consented.
110
+ * Until a user consents, we downgrade _shopify_y to a session cookie instead of 1yr expiry.
111
+ * It denotes a partially stable identifier (stable only for the length of the session,
112
+ * which should be until they close the browser).
113
+ */
114
+ is_persistent_cookie: true,
115
+ ccpa_enforced: false,
116
+ gdpr_enforced: false,
117
+ };
118
+ formattedData = addDataIf({
119
+ customer_id: shopify.isLoggedIn && stripGId(customerId),
120
+ }, formattedData);
121
+ formattedData = addDataIf(extraData, formattedData);
122
+ return formattedData;
123
+ }
124
+ function formatProductsJSON(products) {
125
+ if (!products || products.length === 0) {
126
+ throw Error(`Make sure useServerAnalytics returns "products"\n More details at ${DOC_URL}#product\n`);
127
+ }
128
+ const formattedProducts = products.map((p) => {
129
+ validateProductData(p, 'useServerAnalytics', 'column', 'product-page');
130
+ return JSON.stringify({
131
+ ...p,
132
+ product_id: stripGId(p.product_gid),
133
+ variant_id: stripGId(p.variant_gid),
134
+ quantity: Number(p.quantity || 0),
135
+ });
136
+ });
137
+ return formattedProducts;
138
+ }
139
+ function getAddedProducts(payload) {
140
+ const addedLines = payload.addedCartLines;
141
+ const cartLines = formatCartLinesByProductVariant(payload.cart.lines);
142
+ let totalValue = 0;
143
+ const addedProducts = addedLines.map((line) => {
144
+ const item = cartLines[line.merchandiseId];
145
+ totalValue += parseFloat(item.price) * (line.quantity || 0);
146
+ return {
147
+ ...item,
148
+ quantity: line.quantity,
149
+ };
150
+ });
151
+ return {
152
+ totalValue,
153
+ addedProducts,
154
+ };
155
+ }
156
+ function formatCartLinesByProductVariant(lines) {
157
+ const cartLines = flattenConnection(lines);
158
+ const cartItems = {};
159
+ cartLines.forEach((line) => {
160
+ const product = line.merchandise.product;
161
+ const variant = line.merchandise;
162
+ cartItems[line.merchandise.id] = {
163
+ product_gid: product.id,
164
+ variant_gid: variant.id,
165
+ name: product.title,
166
+ variant: variant.title,
167
+ brand: product.vendor,
168
+ category: product.productType,
169
+ price: variant.priceV2.amount,
170
+ sku: variant.sku,
171
+ };
172
+ validateProductData(cartItems[line.merchandise.id], 'cart fragment', 'gqlField', 'cart-fragment');
173
+ });
174
+ return cartItems;
175
+ }
176
+ function validateProductData(product, source, requireKey, docAnchor) {
177
+ requiredProductFields.forEach((field) => {
178
+ if (!product[field.column] || product[field.column] === '') {
179
+ throw Error(`Make sure ${source} returns "${field[requireKey]}"\n More details at ${DOC_URL}#${docAnchor}\n`);
180
+ }
181
+ });
182
+ }
@@ -1,3 +1,6 @@
1
1
  export declare function buildUUID(): string;
2
2
  export declare function hexTime(): string;
3
+ export declare function stripGId(text?: string): number;
4
+ export declare function stripId(text?: string): string;
3
5
  export declare function addDataIf(keyValuePairs: Record<string, string | number | Boolean>, formattedData: any): any;
6
+ export declare function getNavigationType(): any[];
@@ -46,6 +46,12 @@ export function hexTime() {
46
46
  .toLowerCase();
47
47
  return zeros.substr(0, 8 - output.length) + output;
48
48
  }
49
+ export function stripGId(text = '') {
50
+ return parseInt(stripId(text));
51
+ }
52
+ export function stripId(text = '') {
53
+ return text.substring(text.lastIndexOf('/') + 1);
54
+ }
49
55
  export function addDataIf(keyValuePairs, formattedData) {
50
56
  Object.entries(keyValuePairs).forEach(([key, value]) => {
51
57
  if (value) {
@@ -54,3 +60,66 @@ export function addDataIf(keyValuePairs, formattedData) {
54
60
  });
55
61
  return formattedData;
56
62
  }
63
+ function getNavigationTypeExperimental() {
64
+ try {
65
+ const navigationEntries = performance?.getEntriesByType &&
66
+ performance?.getEntriesByType('navigation');
67
+ if (navigationEntries && navigationEntries[0]) {
68
+ // https://developer.mozilla.org/en-US/docs/Web/API/PerformanceNavigationTiming
69
+ const rawType = window.performance.getEntriesByType('navigation')[0]['type'];
70
+ const navType = rawType && rawType.toString();
71
+ return navType;
72
+ }
73
+ }
74
+ catch (err) {
75
+ // Do nothing
76
+ }
77
+ return undefined;
78
+ }
79
+ function getNavigationTypeLegacy() {
80
+ try {
81
+ if (PerformanceNavigation &&
82
+ performance?.navigation?.type !== null &&
83
+ performance?.navigation?.type !== undefined) {
84
+ // https://developer.mozilla.org/en-US/docs/Web/API/Performance/navigation
85
+ const rawType = performance.navigation.type;
86
+ switch (rawType) {
87
+ case PerformanceNavigation.TYPE_NAVIGATE:
88
+ return 'navigate';
89
+ break;
90
+ case PerformanceNavigation.TYPE_RELOAD:
91
+ return 'reload';
92
+ break;
93
+ case PerformanceNavigation.TYPE_BACK_FORWARD:
94
+ return 'back_forward';
95
+ break;
96
+ default:
97
+ return `unknown: ${rawType}`;
98
+ }
99
+ }
100
+ }
101
+ catch (err) {
102
+ // do nothing
103
+ }
104
+ return undefined;
105
+ }
106
+ export function getNavigationType() {
107
+ try {
108
+ let navApi = 'PerformanceNavigationTiming';
109
+ let navType = getNavigationTypeExperimental();
110
+ if (!navType) {
111
+ navType = getNavigationTypeLegacy();
112
+ navApi = 'performance.navigation';
113
+ }
114
+ if (navType) {
115
+ return [navType, navApi];
116
+ }
117
+ else {
118
+ return ['unknown', 'unknown'];
119
+ }
120
+ }
121
+ catch (err) {
122
+ // do nothing
123
+ }
124
+ return ['error', 'error'];
125
+ }
@@ -50,6 +50,7 @@ export declare class HydrogenRequest extends Request {
50
50
  runtime?: RuntimeContext;
51
51
  scopes: Map<string, Record<string, any>>;
52
52
  localization?: LocalizationContextValue;
53
+ requestGroupID?: string;
53
54
  [key: string]: any;
54
55
  throttledRequests: Record<string, any>;
55
56
  };
@@ -3,13 +3,7 @@ import { hashKey } from '../../utilities/hash.js';
3
3
  import { HelmetData as HeadData } from 'react-helmet-async';
4
4
  import { RSC_PATHNAME } from '../../constants.js';
5
5
  import { parseJSON } from '../../utilities/parse.js';
6
- let reqCounter = 0; // For debugging
7
- const generateId = typeof crypto !== 'undefined' &&
8
- // @ts-ignore
9
- !!crypto.randomUUID
10
- ? // @ts-ignore
11
- () => crypto.randomUUID()
12
- : () => `req${++reqCounter}`;
6
+ import { generateUUID } from '../../utilities/random.js';
13
7
  // Stores queries by url or '*'
14
8
  const preloadCache = new Map();
15
9
  const previouslyLoadedUrls = {};
@@ -42,7 +36,7 @@ export class HydrogenRequest extends Request {
42
36
  super(getUrlFromNodeRequest(input), getInitFromNodeRequest(input));
43
37
  }
44
38
  this.time = getTime();
45
- this.id = generateId();
39
+ this.id = generateUUID();
46
40
  this.normalizedUrl = decodeURIComponent(this.isRscRequest() ? normalizeUrl(this.url) : this.url);
47
41
  this.ctx = {
48
42
  cache: new Map(),
@@ -13,10 +13,10 @@ export default function (options) {
13
13
  // Always allow the entry server (e.g. App.server.jsx) to be imported
14
14
  // in other files such as worker.js or server.js.
15
15
  source.includes(HYDROGEN_DEFAULT_SERVER_ENTRY) ||
16
- /(index|entry-server|hydrogen\.config)\.[jt]s/.test(importer) ||
16
+ /(index|provider-helpers|entry-server|testing|hydrogen\.config)\.[jt]s/.test(importer) ||
17
17
  // Support importing server components for testing
18
18
  // TODO: revisit this when RSC splits into two bundles
19
- /\.test\.[tj]sx?$/.test(importer));
19
+ /\.(test|vitest|spec)\.[tj]sx?$/.test(importer));
20
20
  },
21
21
  ...options,
22
22
  });
@@ -148,24 +148,28 @@ function useCreateShopRequest(body) {
148
148
  const { storeDomain, storefrontToken, storefrontApiVersion, storefrontId, privateStorefrontToken, } = useShop();
149
149
  const request = useServerRequest();
150
150
  const buyerIp = request.getBuyerIp();
151
+ let headers = {
152
+ 'X-SDK-Variant': 'hydrogen',
153
+ 'X-SDK-Version': storefrontApiVersion,
154
+ 'content-type': 'application/json',
155
+ };
156
+ if (request.ctx.requestGroupID) {
157
+ headers['Custom-Storefront-Request-Group-ID'] = request.ctx.requestGroupID;
158
+ }
151
159
  const extraHeaders = getStorefrontApiRequestHeaders({
152
160
  buyerIp,
153
161
  publicStorefrontToken: storefrontToken,
154
162
  privateStorefrontToken,
155
163
  storefrontId,
156
164
  });
165
+ headers = { ...headers, ...extraHeaders };
157
166
  return {
158
167
  key: [storeDomain, storefrontApiVersion, body],
159
168
  url: `https://${storeDomain}/api/${storefrontApiVersion}/graphql.json`,
160
169
  requestInit: {
161
170
  body,
162
171
  method: 'POST',
163
- headers: {
164
- 'X-SDK-Variant': 'hydrogen',
165
- 'X-SDK-Version': storefrontApiVersion,
166
- 'content-type': 'application/json',
167
- ...extraHeaders,
168
- },
172
+ headers,
169
173
  },
170
174
  };
171
175
  }