@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.
- package/dist/esnext/components/CartLineProvider/tests/fixtures.d.ts +86 -0
- package/dist/esnext/components/CartLineProvider/tests/fixtures.js +34 -0
- package/dist/esnext/components/CartProvider/CartProvider.client.d.ts +20 -11
- package/dist/esnext/components/CartProvider/CartProvider.client.js +457 -477
- package/dist/esnext/components/CartProvider/cart-queries.d.ts +1 -1
- package/dist/esnext/components/CartProvider/cart-queries.js +4 -1
- package/dist/esnext/components/CartProvider/tests/fixtures.d.ts +254 -0
- package/dist/esnext/components/CartProvider/tests/fixtures.js +53 -0
- package/dist/esnext/components/Metafield/Metafield.client.js +1 -3
- package/dist/esnext/entry-server.js +11 -1
- package/dist/esnext/experimental.d.ts +0 -1
- package/dist/esnext/experimental.js +0 -1
- package/dist/esnext/foundation/Analytics/connectors/Shopify/ShopifyAnalytics.client.js +21 -14
- package/dist/esnext/foundation/Analytics/connectors/Shopify/ShopifyAnalytics.server.js +15 -9
- package/dist/esnext/foundation/Analytics/connectors/Shopify/const.d.ts +5 -0
- package/dist/esnext/foundation/Analytics/connectors/Shopify/const.js +5 -0
- package/dist/esnext/foundation/Analytics/connectors/Shopify/customer-events.client.d.ts +2 -0
- package/dist/esnext/foundation/Analytics/connectors/Shopify/customer-events.client.js +182 -0
- package/dist/esnext/foundation/Analytics/connectors/Shopify/utils.d.ts +3 -0
- package/dist/esnext/foundation/Analytics/connectors/Shopify/utils.js +69 -0
- package/dist/esnext/foundation/HydrogenRequest/HydrogenRequest.server.d.ts +1 -0
- package/dist/esnext/foundation/HydrogenRequest/HydrogenRequest.server.js +2 -8
- package/dist/esnext/framework/plugins/vite-plugin-hydrogen-rsc.js +2 -2
- package/dist/esnext/hooks/useShopQuery/hooks.js +10 -6
- package/dist/esnext/storefront-api-types.d.ts +334 -116
- package/dist/esnext/storefront-api-types.js +3 -1
- package/dist/esnext/testing.d.ts +2 -0
- package/dist/esnext/testing.js +2 -0
- package/dist/esnext/utilities/random.d.ts +1 -0
- package/dist/esnext/utilities/random.js +11 -0
- package/dist/esnext/utilities/tests/MockedServerRequestProvider.server.d.ts +6 -0
- package/dist/esnext/utilities/tests/MockedServerRequestProvider.server.js +9 -0
- package/dist/esnext/utilities/tests/price.d.ts +5 -0
- package/dist/esnext/utilities/tests/price.js +8 -0
- package/dist/esnext/utilities/tests/provider-helpers.d.ts +31 -0
- package/dist/esnext/utilities/tests/provider-helpers.js +36 -0
- package/dist/esnext/version.d.ts +1 -1
- package/dist/esnext/version.js +1 -1
- package/dist/node/framework/plugins/vite-plugin-hydrogen-rsc.js +2 -2
- package/package.json +3 -1
- package/dist/esnext/components/CartProvider/CartProviderV2.client.d.ts +0 -50
- 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
|
-
|
|
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 =
|
|
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
|
}
|