@tapcart/mobile-components 0.16.0 → 0.16.1
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/components/hooks/use-nosto-recommendation.d.ts +4 -0
- package/dist/components/hooks/use-nosto-recommendation.d.ts.map +1 -1
- package/dist/components/hooks/use-nosto-recommendation.js +116 -23
- package/dist/components/hooks/use-nosto-recommendation.queries.d.ts +0 -1
- package/dist/components/hooks/use-nosto-recommendation.queries.d.ts.map +1 -1
- package/dist/components/hooks/use-nosto-recommendation.queries.js +0 -3
- package/dist/components/hooks/use-nosto-recommendation.test.js +168 -171
- package/dist/components/hooks/use-products.d.ts.map +1 -1
- package/dist/components/hooks/use-products.js +24 -4
- package/dist/components/hooks/use-products.test.d.ts +2 -0
- package/dist/components/hooks/use-products.test.d.ts.map +1 -0
- package/dist/components/hooks/use-products.test.js +129 -0
- package/dist/components/libs/cache/ProductsLocalStorage.d.ts.map +1 -1
- package/dist/components/libs/cache/ProductsLocalStorage.js +32 -0
- package/dist/components/libs/cache/ProductsLocalStorage.test.d.ts +2 -0
- package/dist/components/libs/cache/ProductsLocalStorage.test.d.ts.map +1 -0
- package/dist/components/libs/cache/ProductsLocalStorage.test.js +110 -0
- package/package.json +1 -1
|
@@ -7,6 +7,9 @@ export declare enum NostoRecommendationsType {
|
|
|
7
7
|
BEST_SELLERS = "BEST_SELLERS",
|
|
8
8
|
VIEWED_CATEGORY = "VIEWED_CATEGORY"
|
|
9
9
|
}
|
|
10
|
+
export declare const NOSTO_CID_COOKIE_NAME = "2c.cId";
|
|
11
|
+
export declare const readCookie: (name: string) => string | null;
|
|
12
|
+
export type NostoPersonalization = "session" | "cookie" | "none";
|
|
10
13
|
export declare function useNostoRecommendations(integrations: Integrations, baseURL: string, slotId?: string, country?: string, recommendationsType?: NostoRecommendationsType | "", productIds?: string[], skip?: boolean, mockFallback?: boolean, mockFallbackAppId?: string, session?: {
|
|
11
14
|
sessionId: string;
|
|
12
15
|
lastSessionEventTimestamp: number;
|
|
@@ -15,5 +18,6 @@ export declare function useNostoRecommendations(integrations: Integrations, base
|
|
|
15
18
|
isLoading: boolean;
|
|
16
19
|
error: any;
|
|
17
20
|
isEmpty: boolean;
|
|
21
|
+
personalization: NostoPersonalization;
|
|
18
22
|
};
|
|
19
23
|
//# sourceMappingURL=use-nosto-recommendation.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-nosto-recommendation.d.ts","sourceRoot":"","sources":["../../../components/hooks/use-nosto-recommendation.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA;
|
|
1
|
+
{"version":3,"file":"use-nosto-recommendation.d.ts","sourceRoot":"","sources":["../../../components/hooks/use-nosto-recommendation.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA;AAc/C,oBAAY,wBAAwB;IAClC,eAAe,oBAAoB;IACnC,eAAe,oBAAoB;IACnC,qBAAqB,0BAA0B;IAC/C,WAAW,gBAAgB;IAC3B,YAAY,iBAAiB;IAC7B,eAAe,oBAAoB;CACpC;AA6CD,eAAO,MAAM,qBAAqB,WAAW,CAAA;AAM7C,eAAO,MAAM,UAAU,SAAU,MAAM,KAAG,MAAM,GAAG,IAgBlD,CAAA;AAiBD,MAAM,MAAM,oBAAoB,GAAG,SAAS,GAAG,QAAQ,GAAG,MAAM,CAAA;AAsVhE,wBAAgB,uBAAuB,CACrC,YAAY,EAAE,YAAY,EAC1B,OAAO,EAAE,MAAM,EACf,MAAM,SAAK,EACX,OAAO,SAAO,EACd,mBAAmB,CAAC,EAAE,wBAAwB,GAAG,EAAE,EACnD,UAAU,CAAC,EAAE,MAAM,EAAE,EACrB,IAAI,CAAC,EAAE,OAAO,EACd,YAAY,CAAC,EAAE,OAAO,EACtB,iBAAiB,CAAC,EAAE,MAAM,EAC1B,OAAO,CAAC,EAAE;IACR,SAAS,EAAE,MAAM,CAAA;IACjB,yBAAyB,EAAE,MAAM,CAAA;CAClC;;;;;;EAyDF"}
|
|
@@ -10,7 +10,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
10
10
|
};
|
|
11
11
|
import { useState, useEffect, useMemo } from "react";
|
|
12
12
|
import { useProducts } from "./use-products";
|
|
13
|
-
import { bestSellersRecommendationsQuery, categoryPageRecommendationsQuery, getFrontPageRecommendationsQuery, getProductPageRecommendationsQuery, getRelatedProductsRecommendationsQuery,
|
|
13
|
+
import { bestSellersRecommendationsQuery, categoryPageRecommendationsQuery, getFrontPageRecommendationsQuery, getProductPageRecommendationsQuery, getRelatedProductsRecommendationsQuery, } from "./use-nosto-recommendation.queries";
|
|
14
14
|
export var NostoRecommendationsType;
|
|
15
15
|
(function (NostoRecommendationsType) {
|
|
16
16
|
NostoRecommendationsType["BOUGHT_TOGETHER"] = "BOUGHT_TOGETHER";
|
|
@@ -40,35 +40,75 @@ const fetchNosto = (nostoKey, graphqlBody, mode) => __awaiter(void 0, void 0, vo
|
|
|
40
40
|
const getProductIdsFromPageResponse = (pageResponse) => {
|
|
41
41
|
return ((pageResponse === null || pageResponse === void 0 ? void 0 : pageResponse.flatMap((page) => page.primary).filter((item) => item && item.productId).map((item) => item.productId)) || []);
|
|
42
42
|
};
|
|
43
|
-
|
|
43
|
+
// The Nosto ev1 customer id cookie, minted by the WebView Nosto JS SDK.
|
|
44
|
+
// Exported as the single source of truth so app-side consumers read the exact
|
|
45
|
+
// same cookie with the exact same parsing semantics (TICKET-5763).
|
|
46
|
+
export const NOSTO_CID_COOKIE_NAME = "2c.cId";
|
|
47
|
+
// SSR-safe cookie reader. Returns null when `document` is unavailable, the
|
|
48
|
+
// cookie is absent, or its value is malformed (decodeURIComponent throws) —
|
|
49
|
+
// never the raw value, so a corrupt cookie routes into the designed no-cID
|
|
50
|
+
// fallbacks instead of minting a bogus server-side identity.
|
|
51
|
+
export const readCookie = (name) => {
|
|
52
|
+
if (typeof document === "undefined") {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
56
|
+
const match = document.cookie.match(new RegExp(`(?:^|; )${escapedName}=([^;]*)`));
|
|
57
|
+
if (!match) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
return decodeURIComponent(match[1]);
|
|
62
|
+
}
|
|
63
|
+
catch (_a) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
// Reads the Nosto ev1 customer id (2c.cId) minted by the WebView Nosto JS SDK.
|
|
68
|
+
const readCidCookie = () => readCookie(NOSTO_CID_COOKIE_NAME);
|
|
69
|
+
// Single source of truth for the Nosto session/customer id: the passed-in
|
|
70
|
+
// session (from native storage), else the ev1 `2c.cId` cookie, else null.
|
|
71
|
+
// Never mints a new session — that would split customer identity (TICKET-5763).
|
|
72
|
+
const resolveSessionId = (optionalSessionId) => {
|
|
73
|
+
return optionalSessionId || readCidCookie() || null;
|
|
74
|
+
};
|
|
75
|
+
const resolvePersonalization = (optionalSessionId) => {
|
|
44
76
|
if (optionalSessionId) {
|
|
45
|
-
return
|
|
77
|
+
return "session";
|
|
46
78
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return sessionData.data.newSession;
|
|
79
|
+
if (readCidCookie()) {
|
|
80
|
+
return "cookie";
|
|
50
81
|
}
|
|
51
|
-
|
|
82
|
+
return "none";
|
|
83
|
+
};
|
|
52
84
|
const fetchFrontPageRecommendations = (nostoKey, slotId, optionalSessionId) => __awaiter(void 0, void 0, void 0, function* () {
|
|
53
85
|
var _a, _b, _c;
|
|
54
|
-
|
|
55
|
-
|
|
86
|
+
const sessionId = resolveSessionId(optionalSessionId);
|
|
87
|
+
// No cID available yet: skip the session-based query so the caller's
|
|
88
|
+
// non-personalized fallback (bestSellers) engages.
|
|
89
|
+
if (!sessionId) {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
56
92
|
// Main recommendation query
|
|
57
93
|
const body = getFrontPageRecommendationsQuery(sessionId, slotId);
|
|
58
94
|
const response = yield fetchNosto(nostoKey, body, "cors");
|
|
59
95
|
return getProductIdsFromPageResponse((_c = (_b = (_a = response.data) === null || _a === void 0 ? void 0 : _a.updateSession) === null || _b === void 0 ? void 0 : _b.pages) === null || _c === void 0 ? void 0 : _c.forFrontPage);
|
|
60
96
|
});
|
|
61
|
-
const fetchProductPageRecommendations = (nostoKey, productId, slotId,
|
|
97
|
+
const fetchProductPageRecommendations = (nostoKey, productId, slotId, resolvedSessionId) => __awaiter(void 0, void 0, void 0, function* () {
|
|
62
98
|
var _d, _e, _f;
|
|
63
|
-
|
|
64
|
-
|
|
99
|
+
if (!resolvedSessionId) {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
const body = getProductPageRecommendationsQuery(resolvedSessionId, productId, slotId);
|
|
65
103
|
const response = yield fetchNosto(nostoKey, body, "cors");
|
|
66
104
|
return getProductIdsFromPageResponse((_f = (_e = (_d = response.data) === null || _d === void 0 ? void 0 : _d.updateSession) === null || _e === void 0 ? void 0 : _e.pages) === null || _f === void 0 ? void 0 : _f.forProductPage);
|
|
67
105
|
});
|
|
68
|
-
const fetchCategoryPageRecommendations = (nostoKey, sourceCollectionTitle,
|
|
106
|
+
const fetchCategoryPageRecommendations = (nostoKey, sourceCollectionTitle, resolvedSessionId) => __awaiter(void 0, void 0, void 0, function* () {
|
|
69
107
|
var _g, _h, _j;
|
|
70
|
-
|
|
71
|
-
|
|
108
|
+
if (!resolvedSessionId) {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
const body = categoryPageRecommendationsQuery(resolvedSessionId, sourceCollectionTitle);
|
|
72
112
|
const response = yield fetchNosto(nostoKey, body, "cors");
|
|
73
113
|
return getProductIdsFromPageResponse((_j = (_h = (_g = response.data) === null || _g === void 0 ? void 0 : _g.updateSession) === null || _h === void 0 ? void 0 : _h.pages) === null || _j === void 0 ? void 0 : _j.forCategoryPage);
|
|
74
114
|
});
|
|
@@ -86,10 +126,15 @@ const fetchRelatedProductsRecommendations = (nostoKey, recommendationsType, prod
|
|
|
86
126
|
const recommendedProductIds = ((_s = (_r = (_q = (_p = response === null || response === void 0 ? void 0 : response.data) === null || _p === void 0 ? void 0 : _p.recos) === null || _q === void 0 ? void 0 : _q.related) === null || _r === void 0 ? void 0 : _r.primary) === null || _s === void 0 ? void 0 : _s.map((item) => item.productId)) || [];
|
|
87
127
|
return recommendedProductIds;
|
|
88
128
|
});
|
|
89
|
-
const fetchPDPFallbackRecommendations = (nostoKey, productId, recommendationsType = NostoRecommendationsType.BOUGHT_TOGETHER,
|
|
129
|
+
const fetchPDPFallbackRecommendations = (nostoKey, productId, recommendationsType = NostoRecommendationsType.BOUGHT_TOGETHER, resolvedSessionId) => __awaiter(void 0, void 0, void 0, function* () {
|
|
90
130
|
switch (recommendationsType) {
|
|
91
131
|
case NostoRecommendationsType.VIEWED_CATEGORY:
|
|
92
|
-
|
|
132
|
+
// Category recos require a session; without a cID serve bestSellers
|
|
133
|
+
// rather than returning nothing.
|
|
134
|
+
if (!resolvedSessionId) {
|
|
135
|
+
return fetchBestSellersRecommendations(nostoKey);
|
|
136
|
+
}
|
|
137
|
+
return fetchCategoryPageRecommendations(nostoKey, productId, resolvedSessionId);
|
|
93
138
|
case NostoRecommendationsType.BEST_SELLERS:
|
|
94
139
|
return fetchBestSellersRecommendations(nostoKey);
|
|
95
140
|
default:
|
|
@@ -115,20 +160,24 @@ const fetchHomeRecommendations = (nostoKey, slotId, sessionId) => __awaiter(void
|
|
|
115
160
|
return productIdRecommendations;
|
|
116
161
|
});
|
|
117
162
|
const fetchPDPRecommendations = (nostoKey, productId, slotId, recommendationsType = NostoRecommendationsType.BOUGHT_TOGETHER, sessionId) => __awaiter(void 0, void 0, void 0, function* () {
|
|
163
|
+
// Resolve the session/cID once for the whole PDP fetch and thread it through
|
|
164
|
+
// both helpers, so a single cookie parse serves the product-page query and
|
|
165
|
+
// its category/bestSellers fallback.
|
|
166
|
+
const resolvedSessionId = resolveSessionId(sessionId);
|
|
118
167
|
let productIdRecommendations = [];
|
|
119
168
|
if (slotId) {
|
|
120
169
|
try {
|
|
121
|
-
productIdRecommendations = yield fetchProductPageRecommendations(nostoKey, productId, slotId,
|
|
170
|
+
productIdRecommendations = yield fetchProductPageRecommendations(nostoKey, productId, slotId, resolvedSessionId);
|
|
122
171
|
if (productIdRecommendations.length === 0) {
|
|
123
|
-
productIdRecommendations = yield fetchPDPFallbackRecommendations(nostoKey, productId, recommendationsType,
|
|
172
|
+
productIdRecommendations = yield fetchPDPFallbackRecommendations(nostoKey, productId, recommendationsType, resolvedSessionId);
|
|
124
173
|
}
|
|
125
174
|
}
|
|
126
175
|
catch (error) {
|
|
127
|
-
productIdRecommendations = yield fetchPDPFallbackRecommendations(nostoKey, productId, recommendationsType,
|
|
176
|
+
productIdRecommendations = yield fetchPDPFallbackRecommendations(nostoKey, productId, recommendationsType, resolvedSessionId);
|
|
128
177
|
}
|
|
129
178
|
}
|
|
130
179
|
else {
|
|
131
|
-
productIdRecommendations = yield fetchPDPFallbackRecommendations(nostoKey, productId, recommendationsType,
|
|
180
|
+
productIdRecommendations = yield fetchPDPFallbackRecommendations(nostoKey, productId, recommendationsType, resolvedSessionId);
|
|
132
181
|
}
|
|
133
182
|
return productIdRecommendations;
|
|
134
183
|
});
|
|
@@ -148,7 +197,38 @@ function useNostoRecommendationsProductIds(nostoKey, slotId = "", recommendation
|
|
|
148
197
|
const [recommendedProductIds, setRecommendedProductIds] = useState([]);
|
|
149
198
|
const [isLoading, setIsLoading] = useState(true);
|
|
150
199
|
const [error, setError] = useState(null);
|
|
200
|
+
const [personalization, setPersonalization] = useState("none");
|
|
201
|
+
// When there's no native session, identity comes from the ev1 `2c.cId`
|
|
202
|
+
// cookie — but the WebView Nosto SDK mints it asynchronously (the SSR
|
|
203
|
+
// initializer explicitly polls for it). Track it in state and bounded-poll
|
|
204
|
+
// for a late cookie so a carousel mounted before the cookie exists upgrades
|
|
205
|
+
// from the bestSellers fallback to personalized results once it appears,
|
|
206
|
+
// instead of settling on `personalization: "none"` for the mount's lifetime.
|
|
207
|
+
// This value is purely a re-render trigger: personalization and the fetch
|
|
208
|
+
// helpers each re-read the cookie themselves, preserving source labeling.
|
|
209
|
+
const [cookieCid, setCookieCid] = useState(() => readCidCookie());
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
// A native session is authoritative and a cookie already present needs no
|
|
212
|
+
// watching; only poll while we have no identity yet.
|
|
213
|
+
if (sessionId || cookieCid)
|
|
214
|
+
return;
|
|
215
|
+
// Mirror the SSR initializer's window: 250ms cadence, 60s hard stop.
|
|
216
|
+
let elapsed = 0;
|
|
217
|
+
const interval = setInterval(() => {
|
|
218
|
+
elapsed += 250;
|
|
219
|
+
const cId = readCidCookie();
|
|
220
|
+
if (cId) {
|
|
221
|
+
setCookieCid(cId);
|
|
222
|
+
clearInterval(interval);
|
|
223
|
+
}
|
|
224
|
+
else if (elapsed >= 60000) {
|
|
225
|
+
clearInterval(interval);
|
|
226
|
+
}
|
|
227
|
+
}, 250);
|
|
228
|
+
return () => clearInterval(interval);
|
|
229
|
+
}, [sessionId, cookieCid]);
|
|
151
230
|
useEffect(() => {
|
|
231
|
+
setPersonalization(resolvePersonalization(sessionId));
|
|
152
232
|
function fetchNostoData() {
|
|
153
233
|
return __awaiter(this, void 0, void 0, function* () {
|
|
154
234
|
try {
|
|
@@ -170,11 +250,23 @@ function useNostoRecommendationsProductIds(nostoKey, slotId = "", recommendation
|
|
|
170
250
|
});
|
|
171
251
|
}
|
|
172
252
|
fetchNostoData();
|
|
173
|
-
|
|
253
|
+
// `cookieCid` is a late-cookie refetch trigger; `sessionId` still drives
|
|
254
|
+
// personalization + resolution so the identity source stays correctly
|
|
255
|
+
// labeled (a cookie-sourced id must not read as "session").
|
|
256
|
+
}, [
|
|
257
|
+
nostoKey,
|
|
258
|
+
slotId,
|
|
259
|
+
recommendationsType,
|
|
260
|
+
productIds,
|
|
261
|
+
skip,
|
|
262
|
+
sessionId,
|
|
263
|
+
cookieCid,
|
|
264
|
+
]);
|
|
174
265
|
return {
|
|
175
266
|
recommendedProductIds,
|
|
176
267
|
isLoading,
|
|
177
268
|
error,
|
|
269
|
+
personalization,
|
|
178
270
|
};
|
|
179
271
|
}
|
|
180
272
|
export function useNostoRecommendations(integrations, baseURL, slotId = "", country = "US", recommendationsType, productIds, skip, mockFallback, mockFallbackAppId, session) {
|
|
@@ -189,7 +281,7 @@ export function useNostoRecommendations(integrations, baseURL, slotId = "", coun
|
|
|
189
281
|
Date.now() - NOSTO_SESSION_EXPIRATION_TIME) {
|
|
190
282
|
sessionId = session.sessionId;
|
|
191
283
|
}
|
|
192
|
-
const { recommendedProductIds, isLoading, error } = useNostoRecommendationsProductIds(nostoKey, slotId, recommendationsType, productIds, skip, sessionId);
|
|
284
|
+
const { recommendedProductIds, isLoading, error, personalization } = useNostoRecommendationsProductIds(nostoKey, slotId, recommendationsType, productIds, skip, sessionId);
|
|
193
285
|
const shouldMock = useMemo(() => recommendedProductIds.length === 0 && !isLoading && mockFallback, [recommendedProductIds, isLoading, mockFallback]);
|
|
194
286
|
const appId = shouldMock ? mockFallbackAppId : undefined;
|
|
195
287
|
const { products, error: productsError, isLoading: productsLoading, } = useProducts({
|
|
@@ -207,5 +299,6 @@ export function useNostoRecommendations(integrations, baseURL, slotId = "", coun
|
|
|
207
299
|
isLoading: isLoading || productsLoading,
|
|
208
300
|
error: error || productsError,
|
|
209
301
|
isEmpty: products.length === 0,
|
|
302
|
+
personalization,
|
|
210
303
|
};
|
|
211
304
|
}
|
|
@@ -43,6 +43,5 @@ export declare const getProductPageRecommendationsQuery: (sessionId: string, pro
|
|
|
43
43
|
export declare const categoryPageRecommendationsQuery: (sessionId: string, sourceCollectionTitle: string) => string;
|
|
44
44
|
export declare const bestSellersRecommendationsQuery: string;
|
|
45
45
|
export declare const getRelatedProductsRecommendationsQuery: (recommendationsType: NostoRecommendationsType, productIds: string[]) => string;
|
|
46
|
-
export declare const newSessionQuery: string;
|
|
47
46
|
export {};
|
|
48
47
|
//# sourceMappingURL=use-nosto-recommendation.queries.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-nosto-recommendation.queries.d.ts","sourceRoot":"","sources":["../../../components/hooks/use-nosto-recommendation.queries.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,wBAAwB,EAAE,MAAM,4BAA4B,CAAA;AAErE,KAAK,iBAAiB,CAAC,CAAC,SAAS,MAAM,IAAI;IACzC,IAAI,CAAC,EAAE;QACL,aAAa,CAAC,EAAE;YACd,KAAK,CAAC,EAAE;iBACL,CAAC,IAAI,CAAC,GAAG,KAAK,CAAC;oBACd,OAAO,EAAE,KAAK,CAAC;wBAAE,SAAS,EAAE,MAAM,CAAA;qBAAE,CAAC,CAAA;iBACtC,CAAC;aACH,CAAA;SACF,CAAA;KACF,CAAA;CACF,CAAA;AAED,MAAM,MAAM,sBAAsB,GAAG,iBAAiB,CAAC,cAAc,CAAC,CAAA;AACtE,MAAM,MAAM,wBAAwB,GAAG,iBAAiB,CAAC,gBAAgB,CAAC,CAAA;AAC1E,MAAM,MAAM,yBAAyB,GAAG,iBAAiB,CAAC,iBAAiB,CAAC,CAAA;AAE5E,MAAM,MAAM,wBAAwB,GAAG;IACrC,IAAI,CAAC,EAAE;QACL,KAAK,CAAC,EAAE;YACN,OAAO,CAAC,EAAE;gBACR,OAAO,EAAE,KAAK,CAAC;oBAAE,IAAI,EAAE,MAAM,CAAC;oBAAC,SAAS,EAAE,MAAM,CAAA;iBAAE,CAAC,CAAA;aACpD,CAAA;SACF,CAAA;KACF,CAAA;CACF,CAAA;AACD,MAAM,MAAM,4BAA4B,GAAG;IACzC,IAAI,CAAC,EAAE;QACL,KAAK,CAAC,EAAE;YACN,OAAO,CAAC,EAAE;gBACR,OAAO,EAAE,KAAK,CAAC;oBAAE,SAAS,EAAE,MAAM,CAAA;iBAAE,CAAC,CAAA;aACtC,CAAA;SACF,CAAA;KACF,CAAA;CACF,CAAA;AAED,eAAO,MAAM,gCAAgC,cAChC,MAAM,WACR,MAAM,WAkCb,CAAA;AAEJ,eAAO,MAAM,kCAAkC,cAClC,MAAM,aACN,MAAM,WACR,MAAM,WAsCb,CAAA;AAEJ,eAAO,MAAM,gCAAgC,cAChC,MAAM,yBACM,MAAM,WAsC3B,CAAA;AAEJ,eAAO,MAAM,+BAA+B,QAe1C,CAAA;AAEF,eAAO,MAAM,sCAAsC,wBAC5B,wBAAwB,cACjC,MAAM,EAAE,WAsBlB,CAAA
|
|
1
|
+
{"version":3,"file":"use-nosto-recommendation.queries.d.ts","sourceRoot":"","sources":["../../../components/hooks/use-nosto-recommendation.queries.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,wBAAwB,EAAE,MAAM,4BAA4B,CAAA;AAErE,KAAK,iBAAiB,CAAC,CAAC,SAAS,MAAM,IAAI;IACzC,IAAI,CAAC,EAAE;QACL,aAAa,CAAC,EAAE;YACd,KAAK,CAAC,EAAE;iBACL,CAAC,IAAI,CAAC,GAAG,KAAK,CAAC;oBACd,OAAO,EAAE,KAAK,CAAC;wBAAE,SAAS,EAAE,MAAM,CAAA;qBAAE,CAAC,CAAA;iBACtC,CAAC;aACH,CAAA;SACF,CAAA;KACF,CAAA;CACF,CAAA;AAED,MAAM,MAAM,sBAAsB,GAAG,iBAAiB,CAAC,cAAc,CAAC,CAAA;AACtE,MAAM,MAAM,wBAAwB,GAAG,iBAAiB,CAAC,gBAAgB,CAAC,CAAA;AAC1E,MAAM,MAAM,yBAAyB,GAAG,iBAAiB,CAAC,iBAAiB,CAAC,CAAA;AAE5E,MAAM,MAAM,wBAAwB,GAAG;IACrC,IAAI,CAAC,EAAE;QACL,KAAK,CAAC,EAAE;YACN,OAAO,CAAC,EAAE;gBACR,OAAO,EAAE,KAAK,CAAC;oBAAE,IAAI,EAAE,MAAM,CAAC;oBAAC,SAAS,EAAE,MAAM,CAAA;iBAAE,CAAC,CAAA;aACpD,CAAA;SACF,CAAA;KACF,CAAA;CACF,CAAA;AACD,MAAM,MAAM,4BAA4B,GAAG;IACzC,IAAI,CAAC,EAAE;QACL,KAAK,CAAC,EAAE;YACN,OAAO,CAAC,EAAE;gBACR,OAAO,EAAE,KAAK,CAAC;oBAAE,SAAS,EAAE,MAAM,CAAA;iBAAE,CAAC,CAAA;aACtC,CAAA;SACF,CAAA;KACF,CAAA;CACF,CAAA;AAED,eAAO,MAAM,gCAAgC,cAChC,MAAM,WACR,MAAM,WAkCb,CAAA;AAEJ,eAAO,MAAM,kCAAkC,cAClC,MAAM,aACN,MAAM,WACR,MAAM,WAsCb,CAAA;AAEJ,eAAO,MAAM,gCAAgC,cAChC,MAAM,yBACM,MAAM,WAsC3B,CAAA;AAEJ,eAAO,MAAM,+BAA+B,QAe1C,CAAA;AAEF,eAAO,MAAM,sCAAsC,wBAC5B,wBAAwB,cACjC,MAAM,EAAE,WAsBlB,CAAA"}
|
|
@@ -14,13 +14,13 @@ import { useProducts } from "./use-products";
|
|
|
14
14
|
jest.mock("./use-products", () => ({
|
|
15
15
|
useProducts: jest.fn(),
|
|
16
16
|
}));
|
|
17
|
-
// Mock fetch for GraphQL queries
|
|
17
|
+
// Mock fetch for GraphQL queries. `newSession` is intentionally NOT handled:
|
|
18
|
+
// after TICKET-5763 nothing may ever mint a session, so a newSession request
|
|
19
|
+
// would be a bug — fail loudly if one is ever issued.
|
|
18
20
|
global.fetch = jest.fn((url, options) => {
|
|
19
21
|
const body = JSON.parse(options === null || options === void 0 ? void 0 : options.body);
|
|
20
22
|
if (body.query.includes("newSession")) {
|
|
21
|
-
return Promise.
|
|
22
|
-
json: () => Promise.resolve({ data: { newSession: "mockSessionId" } }),
|
|
23
|
-
});
|
|
23
|
+
return Promise.reject(new Error("newSession must never be called (TICKET-5763)"));
|
|
24
24
|
}
|
|
25
25
|
if (body.query.includes("GetFrontPageRecommendations")) {
|
|
26
26
|
return Promise.resolve({
|
|
@@ -93,6 +93,31 @@ global.fetch = jest.fn((url, options) => {
|
|
|
93
93
|
}
|
|
94
94
|
return Promise.reject(new Error("Unknown query"));
|
|
95
95
|
});
|
|
96
|
+
// Asserts the invariant that must hold for EVERY test: no request ever asks
|
|
97
|
+
// Nosto to mint a new session.
|
|
98
|
+
const expectNoNewSessionCall = () => {
|
|
99
|
+
const calls = global.fetch.mock.calls;
|
|
100
|
+
const mintedSession = calls.some(([, options]) => {
|
|
101
|
+
const raw = options === null || options === void 0 ? void 0 : options.body;
|
|
102
|
+
return typeof raw === "string" && raw.includes("newSession");
|
|
103
|
+
});
|
|
104
|
+
expect(mintedSession).toBe(false);
|
|
105
|
+
};
|
|
106
|
+
const setCidCookie = (value) => {
|
|
107
|
+
document.cookie = `2c.cId=${value}`;
|
|
108
|
+
};
|
|
109
|
+
const clearCookies = () => {
|
|
110
|
+
document.cookie = "2c.cId=; expires=Thu, 01 Jan 1970 00:00:00 GMT";
|
|
111
|
+
};
|
|
112
|
+
const recentSession = (sessionId) => ({
|
|
113
|
+
sessionId,
|
|
114
|
+
lastSessionEventTimestamp: Date.now(),
|
|
115
|
+
});
|
|
116
|
+
const flush = () => __awaiter(void 0, void 0, void 0, function* () {
|
|
117
|
+
yield act(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
118
|
+
yield new Promise((resolve) => setTimeout(resolve, 0));
|
|
119
|
+
}));
|
|
120
|
+
});
|
|
96
121
|
describe("useNostoRecommendations", () => {
|
|
97
122
|
const mockIntegrations = [
|
|
98
123
|
{
|
|
@@ -104,233 +129,218 @@ describe("useNostoRecommendations", () => {
|
|
|
104
129
|
const mockBaseURL = "http://mockbaseurl.com";
|
|
105
130
|
beforeEach(() => {
|
|
106
131
|
jest.clearAllMocks();
|
|
132
|
+
clearCookies();
|
|
107
133
|
useProducts.mockReturnValue({
|
|
108
134
|
products: [{ id: "1", name: "Product 1" }],
|
|
109
135
|
isLoading: false,
|
|
110
136
|
error: null,
|
|
111
137
|
});
|
|
112
138
|
});
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
139
|
+
afterEach(() => {
|
|
140
|
+
// Invariant across every path: never mint a Nosto session.
|
|
141
|
+
expectNoNewSessionCall();
|
|
142
|
+
clearCookies();
|
|
143
|
+
});
|
|
144
|
+
it("uses the passed-in session for front page recommendations", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
145
|
+
const { result } = renderHook(() => useNostoRecommendations(mockIntegrations, mockBaseURL, "mockSlotId", "US", undefined, undefined, undefined, undefined, undefined, recentSession("passed-session")));
|
|
117
146
|
expect(result.current.isLoading).toBe(true);
|
|
118
|
-
|
|
119
|
-
yield act(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
120
|
-
yield new Promise((resolve) => setTimeout(resolve, 0));
|
|
121
|
-
}));
|
|
122
|
-
// Check final state
|
|
147
|
+
yield flush();
|
|
123
148
|
expect(result.current.isLoading).toBe(false);
|
|
124
149
|
expect(result.current.error).toBe(null);
|
|
150
|
+
// Native session was used -> personalization surfaced as "session".
|
|
151
|
+
expect(result.current.personalization).toBe("session");
|
|
125
152
|
expect(useProducts).toHaveBeenCalledWith({
|
|
126
153
|
productIds: ["front-page-id-1"],
|
|
127
154
|
baseURL: mockBaseURL,
|
|
128
155
|
productHandles: [],
|
|
129
|
-
queryVariables: {
|
|
130
|
-
appId: undefined,
|
|
131
|
-
country: "US",
|
|
132
|
-
},
|
|
156
|
+
queryVariables: { appId: undefined, country: "US" },
|
|
133
157
|
mock: false,
|
|
134
158
|
});
|
|
135
159
|
}));
|
|
136
|
-
it("
|
|
137
|
-
|
|
138
|
-
const { result } = renderHook(() => useNostoRecommendations(mockIntegrations, mockBaseURL,
|
|
139
|
-
// Initial state check
|
|
160
|
+
it("falls back to the 2c.cId cookie when no session is passed (front page)", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
161
|
+
setCidCookie("cookie-cid");
|
|
162
|
+
const { result } = renderHook(() => useNostoRecommendations(mockIntegrations, mockBaseURL, "mockSlotId", "US"));
|
|
140
163
|
expect(result.current.isLoading).toBe(true);
|
|
141
|
-
|
|
142
|
-
yield act(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
143
|
-
yield new Promise((resolve) => setTimeout(resolve, 0));
|
|
144
|
-
}));
|
|
145
|
-
// Check final state
|
|
164
|
+
yield flush();
|
|
146
165
|
expect(result.current.isLoading).toBe(false);
|
|
147
|
-
|
|
166
|
+
// Cookie-derived session resolves, so the personalized front page query runs.
|
|
167
|
+
expect(result.current.personalization).toBe("cookie");
|
|
148
168
|
expect(useProducts).toHaveBeenCalledWith({
|
|
149
|
-
productIds: ["
|
|
169
|
+
productIds: ["front-page-id-1"],
|
|
150
170
|
baseURL: mockBaseURL,
|
|
151
171
|
productHandles: [],
|
|
152
|
-
queryVariables: {
|
|
153
|
-
appId: undefined,
|
|
154
|
-
country: "US",
|
|
155
|
-
},
|
|
172
|
+
queryVariables: { appId: undefined, country: "US" },
|
|
156
173
|
mock: false,
|
|
157
174
|
});
|
|
158
175
|
}));
|
|
159
|
-
it("
|
|
160
|
-
const
|
|
161
|
-
const productIds = ["mockProductId"];
|
|
162
|
-
const { result } = renderHook(() => useNostoRecommendations(mockIntegrations, mockBaseURL, slotId, "US", NostoRecommendationsType.BEST_SELLERS, productIds));
|
|
163
|
-
// Initial state check
|
|
176
|
+
it("falls back to bestSellers on the front page when there is no session and no cookie", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
177
|
+
const { result } = renderHook(() => useNostoRecommendations(mockIntegrations, mockBaseURL, "mockSlotId", "US"));
|
|
164
178
|
expect(result.current.isLoading).toBe(true);
|
|
165
|
-
|
|
166
|
-
yield act(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
167
|
-
yield new Promise((resolve) => setTimeout(resolve, 0));
|
|
168
|
-
}));
|
|
169
|
-
// Check final state
|
|
179
|
+
yield flush();
|
|
170
180
|
expect(result.current.isLoading).toBe(false);
|
|
171
|
-
|
|
181
|
+
// No identity available -> personalization "none".
|
|
182
|
+
expect(result.current.personalization).toBe("none");
|
|
183
|
+
// No session-based query fired; bestSellers fallback engaged.
|
|
172
184
|
expect(useProducts).toHaveBeenCalledWith({
|
|
173
185
|
productIds: ["best-seller-1"],
|
|
174
186
|
baseURL: mockBaseURL,
|
|
175
187
|
productHandles: [],
|
|
176
|
-
queryVariables: {
|
|
177
|
-
appId: undefined,
|
|
178
|
-
country: "US",
|
|
179
|
-
},
|
|
188
|
+
queryVariables: { appId: undefined, country: "US" },
|
|
180
189
|
mock: false,
|
|
181
190
|
});
|
|
182
191
|
}));
|
|
183
|
-
it("
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
//
|
|
192
|
+
it("upgrades from bestSellers to personalized once a late 2c.cId cookie appears", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
193
|
+
// The WebView Nosto SDK mints the cookie asynchronously, so a carousel can
|
|
194
|
+
// mount before it exists. The bounded poll must pick up the late cookie and
|
|
195
|
+
// re-resolve from the bestSellers fallback to the personalized front page.
|
|
196
|
+
jest.useFakeTimers();
|
|
197
|
+
try {
|
|
198
|
+
const { result } = renderHook(() => useNostoRecommendations(mockIntegrations, mockBaseURL, "mockSlotId", "US"));
|
|
199
|
+
// No cookie at mount -> "none" + bestSellers fallback.
|
|
200
|
+
yield act(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
201
|
+
yield jest.advanceTimersByTimeAsync(0);
|
|
202
|
+
}));
|
|
203
|
+
expect(result.current.personalization).toBe("none");
|
|
204
|
+
expect(useProducts).toHaveBeenLastCalledWith(expect.objectContaining({ productIds: ["best-seller-1"] }));
|
|
205
|
+
// Cookie minted after mount; the 250ms poll picks it up and refetches.
|
|
206
|
+
setCidCookie("late-cookie-cid");
|
|
207
|
+
yield act(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
208
|
+
yield jest.advanceTimersByTimeAsync(250);
|
|
209
|
+
}));
|
|
210
|
+
expect(result.current.personalization).toBe("cookie");
|
|
211
|
+
expect(useProducts).toHaveBeenLastCalledWith(expect.objectContaining({ productIds: ["front-page-id-1"] }));
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
jest.useRealTimers();
|
|
215
|
+
}
|
|
216
|
+
}));
|
|
217
|
+
it("returns best sellers when productIds is empty and slotId is not provided", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
218
|
+
const { result } = renderHook(() => useNostoRecommendations(mockIntegrations, mockBaseURL, "", "US"));
|
|
187
219
|
expect(result.current.isLoading).toBe(true);
|
|
188
|
-
|
|
189
|
-
yield act(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
190
|
-
yield new Promise((resolve) => setTimeout(resolve, 0));
|
|
191
|
-
}));
|
|
192
|
-
// Check final state
|
|
220
|
+
yield flush();
|
|
193
221
|
expect(result.current.isLoading).toBe(false);
|
|
194
222
|
expect(result.current.error).toBe(null);
|
|
195
223
|
expect(useProducts).toHaveBeenCalledWith({
|
|
196
|
-
productIds: ["
|
|
224
|
+
productIds: ["best-seller-1"],
|
|
197
225
|
baseURL: mockBaseURL,
|
|
198
226
|
productHandles: [],
|
|
199
|
-
queryVariables: {
|
|
200
|
-
appId: undefined,
|
|
201
|
-
country: "US",
|
|
202
|
-
},
|
|
227
|
+
queryVariables: { appId: undefined, country: "US" },
|
|
203
228
|
mock: false,
|
|
204
229
|
});
|
|
205
230
|
}));
|
|
206
|
-
it("
|
|
207
|
-
|
|
208
|
-
|
|
231
|
+
it("returns best sellers when BEST_SELLERS type and productIds are provided but no slotId", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
232
|
+
// Stable reference so the hook's effect deps don't change across renders.
|
|
233
|
+
const productIds = ["mockProductId"];
|
|
234
|
+
const { result } = renderHook(() => useNostoRecommendations(mockIntegrations, mockBaseURL, "", "US", NostoRecommendationsType.BEST_SELLERS, productIds));
|
|
209
235
|
expect(result.current.isLoading).toBe(true);
|
|
210
|
-
|
|
211
|
-
yield act(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
212
|
-
yield new Promise((resolve) => setTimeout(resolve, 0));
|
|
213
|
-
}));
|
|
214
|
-
// Check final state
|
|
236
|
+
yield flush();
|
|
215
237
|
expect(result.current.isLoading).toBe(false);
|
|
216
|
-
expect(result.current.error).toBe(null);
|
|
217
238
|
expect(useProducts).toHaveBeenCalledWith({
|
|
218
239
|
productIds: ["best-seller-1"],
|
|
219
240
|
baseURL: mockBaseURL,
|
|
220
241
|
productHandles: [],
|
|
221
|
-
queryVariables: {
|
|
222
|
-
appId: undefined,
|
|
223
|
-
country: "US",
|
|
224
|
-
},
|
|
242
|
+
queryVariables: { appId: undefined, country: "US" },
|
|
225
243
|
mock: false,
|
|
226
244
|
});
|
|
227
245
|
}));
|
|
228
|
-
it("
|
|
246
|
+
it("returns product page recommendations when a session, productIds and slotId are provided", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
229
247
|
const productIds = ["mockProductId"];
|
|
230
|
-
const { result } = renderHook(() => useNostoRecommendations(mockIntegrations, mockBaseURL, "", "US", NostoRecommendationsType.VIEWED_TOGETHER, productIds, undefined));
|
|
231
|
-
// Initial state check
|
|
248
|
+
const { result } = renderHook(() => useNostoRecommendations(mockIntegrations, mockBaseURL, "mockSlotId", "US", NostoRecommendationsType.VIEWED_TOGETHER, productIds, undefined, undefined, undefined, recentSession("passed-session")));
|
|
232
249
|
expect(result.current.isLoading).toBe(true);
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
250
|
+
yield flush();
|
|
251
|
+
expect(result.current.isLoading).toBe(false);
|
|
252
|
+
expect(useProducts).toHaveBeenCalledWith({
|
|
253
|
+
productIds: ["product-page-id-1"],
|
|
254
|
+
baseURL: mockBaseURL,
|
|
255
|
+
productHandles: [],
|
|
256
|
+
queryVariables: { appId: undefined, country: "US" },
|
|
257
|
+
mock: false,
|
|
258
|
+
});
|
|
259
|
+
}));
|
|
260
|
+
it("returns the related products PDP fallback (no slotId, non-category type)", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
261
|
+
const productIds = ["mockProductId"];
|
|
262
|
+
const { result } = renderHook(() => useNostoRecommendations(mockIntegrations, mockBaseURL, "", "US", NostoRecommendationsType.VIEWED_TOGETHER, productIds));
|
|
263
|
+
expect(result.current.isLoading).toBe(true);
|
|
264
|
+
yield flush();
|
|
238
265
|
expect(result.current.isLoading).toBe(false);
|
|
239
|
-
expect(result.current.error).toBe(null);
|
|
240
266
|
expect(useProducts).toHaveBeenCalledWith({
|
|
241
267
|
productIds: ["related-1"],
|
|
242
268
|
baseURL: mockBaseURL,
|
|
243
269
|
productHandles: [],
|
|
244
|
-
queryVariables: {
|
|
245
|
-
appId: undefined,
|
|
246
|
-
country: "US",
|
|
247
|
-
},
|
|
270
|
+
queryVariables: { appId: undefined, country: "US" },
|
|
248
271
|
mock: false,
|
|
249
272
|
});
|
|
250
273
|
}));
|
|
251
|
-
it("
|
|
252
|
-
const slotId = "mockSlotId";
|
|
274
|
+
it("returns the category PDP fallback when a session is available and type is VIEWED_CATEGORY", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
253
275
|
const productIds = ["mockProductId"];
|
|
254
|
-
const { result } = renderHook(() => useNostoRecommendations(mockIntegrations, mockBaseURL,
|
|
255
|
-
// Initial state check
|
|
276
|
+
const { result } = renderHook(() => useNostoRecommendations(mockIntegrations, mockBaseURL, "", "US", NostoRecommendationsType.VIEWED_CATEGORY, productIds, undefined, undefined, undefined, recentSession("passed-session")));
|
|
256
277
|
expect(result.current.isLoading).toBe(true);
|
|
257
|
-
|
|
258
|
-
yield act(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
259
|
-
yield new Promise((resolve) => setTimeout(resolve, 0));
|
|
260
|
-
}));
|
|
261
|
-
// Check final state
|
|
278
|
+
yield flush();
|
|
262
279
|
expect(result.current.isLoading).toBe(false);
|
|
263
|
-
expect(result.current.error).toBe(null);
|
|
264
280
|
expect(useProducts).toHaveBeenCalledWith({
|
|
265
|
-
productIds: ["
|
|
281
|
+
productIds: ["category-page-id-1"],
|
|
266
282
|
baseURL: mockBaseURL,
|
|
267
283
|
productHandles: [],
|
|
268
|
-
queryVariables: {
|
|
269
|
-
appId: undefined,
|
|
270
|
-
country: "US",
|
|
271
|
-
},
|
|
284
|
+
queryVariables: { appId: undefined, country: "US" },
|
|
272
285
|
mock: false,
|
|
273
286
|
});
|
|
274
287
|
}));
|
|
275
|
-
it("
|
|
288
|
+
it("falls back to bestSellers for a VIEWED_CATEGORY PDP when there is no session", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
276
289
|
const productIds = ["mockProductId"];
|
|
277
|
-
const { result } = renderHook(() => useNostoRecommendations(mockIntegrations, mockBaseURL, "", "US", NostoRecommendationsType.
|
|
278
|
-
// Initial state check
|
|
290
|
+
const { result } = renderHook(() => useNostoRecommendations(mockIntegrations, mockBaseURL, "", "US", NostoRecommendationsType.VIEWED_CATEGORY, productIds));
|
|
279
291
|
expect(result.current.isLoading).toBe(true);
|
|
280
|
-
|
|
281
|
-
yield act(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
282
|
-
yield new Promise((resolve) => setTimeout(resolve, 0));
|
|
283
|
-
}));
|
|
284
|
-
// Check final state
|
|
292
|
+
yield flush();
|
|
285
293
|
expect(result.current.isLoading).toBe(false);
|
|
286
|
-
expect(result.current.error).toBe(null);
|
|
287
294
|
expect(useProducts).toHaveBeenCalledWith({
|
|
288
|
-
productIds: ["
|
|
295
|
+
productIds: ["best-seller-1"],
|
|
289
296
|
baseURL: mockBaseURL,
|
|
290
297
|
productHandles: [],
|
|
291
|
-
queryVariables: {
|
|
292
|
-
appId: undefined,
|
|
293
|
-
country: "US",
|
|
294
|
-
},
|
|
298
|
+
queryVariables: { appId: undefined, country: "US" },
|
|
295
299
|
mock: false,
|
|
296
300
|
});
|
|
297
301
|
}));
|
|
298
|
-
it("
|
|
302
|
+
it("serves bestSellers (not an error) for a VIEWED_CATEGORY PDP when the cId cookie is malformed", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
303
|
+
// A bad percent-encoding makes decodeURIComponent throw; the shared reader
|
|
304
|
+
// must return null (never the raw value) so this routes into the designed
|
|
305
|
+
// bestSellers fallback instead of escaping into the hook's error state.
|
|
306
|
+
document.cookie = "2c.cId=%";
|
|
299
307
|
const productIds = ["mockProductId"];
|
|
300
|
-
const { result } = renderHook(() => useNostoRecommendations(mockIntegrations, mockBaseURL, "", "US", NostoRecommendationsType.VIEWED_CATEGORY, productIds
|
|
301
|
-
// Initial state check
|
|
308
|
+
const { result } = renderHook(() => useNostoRecommendations(mockIntegrations, mockBaseURL, "", "US", NostoRecommendationsType.VIEWED_CATEGORY, productIds));
|
|
302
309
|
expect(result.current.isLoading).toBe(true);
|
|
303
|
-
|
|
304
|
-
yield act(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
305
|
-
yield new Promise((resolve) => setTimeout(resolve, 0));
|
|
306
|
-
}));
|
|
307
|
-
// Check final state
|
|
310
|
+
yield flush();
|
|
308
311
|
expect(result.current.isLoading).toBe(false);
|
|
309
312
|
expect(result.current.error).toBe(null);
|
|
313
|
+
expect(result.current.personalization).toBe("none");
|
|
310
314
|
expect(useProducts).toHaveBeenCalledWith({
|
|
311
|
-
productIds: ["
|
|
315
|
+
productIds: ["best-seller-1"],
|
|
312
316
|
baseURL: mockBaseURL,
|
|
313
317
|
productHandles: [],
|
|
314
|
-
queryVariables: {
|
|
315
|
-
appId: undefined,
|
|
316
|
-
country: "US",
|
|
317
|
-
},
|
|
318
|
+
queryVariables: { appId: undefined, country: "US" },
|
|
318
319
|
mock: false,
|
|
319
320
|
});
|
|
320
321
|
}));
|
|
321
|
-
it("
|
|
322
|
+
it("defaults to best sellers if productIds are empty but a non-BestSellers type is used", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
323
|
+
const productIds = [];
|
|
324
|
+
const { result } = renderHook(() => useNostoRecommendations(mockIntegrations, mockBaseURL, "", "US", NostoRecommendationsType.VIEWED_TOGETHER, productIds));
|
|
325
|
+
expect(result.current.isLoading).toBe(true);
|
|
326
|
+
yield flush();
|
|
327
|
+
expect(result.current.isLoading).toBe(false);
|
|
328
|
+
expect(useProducts).toHaveBeenCalledWith({
|
|
329
|
+
productIds: ["best-seller-1"],
|
|
330
|
+
baseURL: mockBaseURL,
|
|
331
|
+
productHandles: [],
|
|
332
|
+
queryVariables: { appId: undefined, country: "US" },
|
|
333
|
+
mock: false,
|
|
334
|
+
});
|
|
335
|
+
}));
|
|
336
|
+
it("handles loading state", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
322
337
|
const { result } = renderHook(() => useNostoRecommendations(mockIntegrations, mockBaseURL, "", "US"));
|
|
323
|
-
// Check initial loading state
|
|
324
338
|
expect(result.current.isLoading).toBe(true);
|
|
325
|
-
|
|
326
|
-
yield act(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
327
|
-
yield new Promise((resolve) => setTimeout(resolve, 0));
|
|
328
|
-
}));
|
|
329
|
-
// Verify loading has finished
|
|
339
|
+
yield flush();
|
|
330
340
|
expect(result.current.isLoading).toBe(false);
|
|
331
341
|
expect(result.current.error).toBe(null);
|
|
332
342
|
}));
|
|
333
|
-
it("
|
|
343
|
+
it("handles error state", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
334
344
|
const mockError = () => "Failed to fetch products";
|
|
335
345
|
useProducts.mockReturnValue({
|
|
336
346
|
products: [],
|
|
@@ -338,58 +348,45 @@ describe("useNostoRecommendations", () => {
|
|
|
338
348
|
error: mockError,
|
|
339
349
|
});
|
|
340
350
|
const { result } = renderHook(() => useNostoRecommendations(mockIntegrations, mockBaseURL));
|
|
341
|
-
// Initial state check
|
|
342
351
|
expect(result.current.isLoading).toBe(true);
|
|
343
|
-
|
|
344
|
-
yield act(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
345
|
-
yield new Promise((resolve) => setTimeout(resolve, 0));
|
|
346
|
-
}));
|
|
347
|
-
// Verify final error state
|
|
352
|
+
yield flush();
|
|
348
353
|
expect(result.current.isLoading).toBe(false);
|
|
349
354
|
expect(result.current.error).toBe(mockError);
|
|
350
355
|
expect(result.current.recommendations).toEqual([]);
|
|
351
356
|
}));
|
|
352
|
-
it("
|
|
353
|
-
const
|
|
354
|
-
const { result } = renderHook(() => useNostoRecommendations(mockIntegrations, mockBaseURL, "", "US", NostoRecommendationsType.VIEWED_TOGETHER, productIds, undefined));
|
|
355
|
-
// Initial state check
|
|
357
|
+
it("returns an empty array if skip is true", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
358
|
+
const { result } = renderHook(() => useNostoRecommendations(mockIntegrations, mockBaseURL, "", "US", NostoRecommendationsType.BEST_SELLERS, undefined, true));
|
|
356
359
|
expect(result.current.isLoading).toBe(true);
|
|
357
|
-
|
|
358
|
-
yield act(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
359
|
-
yield new Promise((resolve) => setTimeout(resolve, 0));
|
|
360
|
-
}));
|
|
361
|
-
// Check final state
|
|
360
|
+
yield flush();
|
|
362
361
|
expect(result.current.isLoading).toBe(false);
|
|
363
|
-
expect(result.current.error).toBe(null);
|
|
364
362
|
expect(useProducts).toHaveBeenCalledWith({
|
|
365
|
-
productIds: [
|
|
363
|
+
productIds: [],
|
|
366
364
|
baseURL: mockBaseURL,
|
|
367
365
|
productHandles: [],
|
|
368
|
-
queryVariables: {
|
|
369
|
-
appId: undefined,
|
|
370
|
-
country: "US",
|
|
371
|
-
},
|
|
366
|
+
queryVariables: { appId: undefined, country: "US" },
|
|
372
367
|
mock: false,
|
|
373
368
|
});
|
|
369
|
+
// skip must short-circuit before any Nosto request and before any
|
|
370
|
+
// bestSellers fallback resolves — the empty-productIds call above always
|
|
371
|
+
// matches on first render, so these guard against a broken skip.
|
|
372
|
+
expect(global.fetch).not.toHaveBeenCalled();
|
|
373
|
+
expect(useProducts).not.toHaveBeenCalledWith(expect.objectContaining({ productIds: ["best-seller-1"] }));
|
|
374
374
|
}));
|
|
375
|
-
it("
|
|
376
|
-
const
|
|
377
|
-
|
|
375
|
+
it("ignores an expired session and falls back without minting a new one", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
376
|
+
const staleSession = {
|
|
377
|
+
sessionId: "stale-session",
|
|
378
|
+
lastSessionEventTimestamp: Date.now() - 60 * 60 * 1000, // 1h ago
|
|
379
|
+
};
|
|
380
|
+
const { result } = renderHook(() => useNostoRecommendations(mockIntegrations, mockBaseURL, "mockSlotId", "US", undefined, undefined, undefined, undefined, undefined, staleSession));
|
|
378
381
|
expect(result.current.isLoading).toBe(true);
|
|
379
|
-
|
|
380
|
-
yield act(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
381
|
-
yield new Promise((resolve) => setTimeout(resolve, 0));
|
|
382
|
-
}));
|
|
383
|
-
// Check final state
|
|
382
|
+
yield flush();
|
|
384
383
|
expect(result.current.isLoading).toBe(false);
|
|
384
|
+
// Expired session is dropped by the hook -> null sessionId -> bestSellers.
|
|
385
385
|
expect(useProducts).toHaveBeenCalledWith({
|
|
386
|
-
productIds: [],
|
|
386
|
+
productIds: ["best-seller-1"],
|
|
387
387
|
baseURL: mockBaseURL,
|
|
388
388
|
productHandles: [],
|
|
389
|
-
queryVariables: {
|
|
390
|
-
appId: undefined,
|
|
391
|
-
country: "US",
|
|
392
|
-
},
|
|
389
|
+
queryVariables: { appId: undefined, country: "US" },
|
|
393
390
|
mock: false,
|
|
394
391
|
});
|
|
395
392
|
}));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-products.d.ts","sourceRoot":"","sources":["../../../components/hooks/use-products.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAA;AAK1C,KAAK,GAAG,GAAG,MAAM,CAAA;AACjB,KAAK,cAAc,GAAG;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAA;AACxD,KAAK,gBAAgB,GAAG;IACtB,UAAU,CAAC,EAAE,MAAM,EAAE,CAAA;IACrB,UAAU,CAAC,EAAE,MAAM,EAAE,CAAA;IACrB,cAAc,CAAC,EAAE,MAAM,EAAE,CAAA;IACzB,OAAO,EAAE,GAAG,CAAA;IACZ,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACpC,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,IAAI,KAAK,OAAO,CAAC,GAAG,CAAC,CAAA;IAC1E,UAAU,CAAC,EAAE,cAAc,EAAE,CAAA;IAC7B,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,qBAAqB,CAAC,EAAE,OAAO,CAAA;CAChC,CAAA;AACD,KAAK,iBAAiB,GAAG;IACvB,QAAQ,EAAE,OAAO,EAAE,CAAA;IACnB,KAAK,EAAE,GAAG,CAAA;IACV,SAAS,EAAE,OAAO,CAAA;IAClB,YAAY,EAAE,OAAO,CAAA;IACrB,YAAY,EAAE,CAAC,OAAO,EAAE,OAAO,KAC3B;QACE,EAAE,EAAE,MAAM,CAAA;QACV,MAAM,EAAE,MAAM,CAAA;KACf,GACD,SAAS,CAAA;CACd,CAAA;AAqDD,wBAAgB,WAAW,CAAC,KAAK,EAAE,gBAAgB,GAAG,IAAI,GAAG,iBAAiB,
|
|
1
|
+
{"version":3,"file":"use-products.d.ts","sourceRoot":"","sources":["../../../components/hooks/use-products.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAA;AAK1C,KAAK,GAAG,GAAG,MAAM,CAAA;AACjB,KAAK,cAAc,GAAG;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAA;AACxD,KAAK,gBAAgB,GAAG;IACtB,UAAU,CAAC,EAAE,MAAM,EAAE,CAAA;IACrB,UAAU,CAAC,EAAE,MAAM,EAAE,CAAA;IACrB,cAAc,CAAC,EAAE,MAAM,EAAE,CAAA;IACzB,OAAO,EAAE,GAAG,CAAA;IACZ,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACpC,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,IAAI,KAAK,OAAO,CAAC,GAAG,CAAC,CAAA;IAC1E,UAAU,CAAC,EAAE,cAAc,EAAE,CAAA;IAC7B,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,qBAAqB,CAAC,EAAE,OAAO,CAAA;CAChC,CAAA;AACD,KAAK,iBAAiB,GAAG;IACvB,QAAQ,EAAE,OAAO,EAAE,CAAA;IACnB,KAAK,EAAE,GAAG,CAAA;IACV,SAAS,EAAE,OAAO,CAAA;IAClB,YAAY,EAAE,OAAO,CAAA;IACrB,YAAY,EAAE,CAAC,OAAO,EAAE,OAAO,KAC3B;QACE,EAAE,EAAE,MAAM,CAAA;QACV,MAAM,EAAE,MAAM,CAAA;KACf,GACD,SAAS,CAAA;CACd,CAAA;AAqDD,wBAAgB,WAAW,CAAC,KAAK,EAAE,gBAAgB,GAAG,IAAI,GAAG,iBAAiB,CAqL7E"}
|
|
@@ -108,16 +108,36 @@ export function useProducts(props) {
|
|
|
108
108
|
const productsArray = Array.isArray(data) ? data : data.products || [];
|
|
109
109
|
const cachedProducts = productsArray
|
|
110
110
|
.filter((product) => !!product)
|
|
111
|
-
.map((product) =>
|
|
112
|
-
productsLocalStorage.setCacheItem(product);
|
|
113
|
-
return product;
|
|
114
|
-
});
|
|
111
|
+
.map((product) => productsLocalStorage.setCacheItem(product) || product);
|
|
115
112
|
// Return either just the products array or an object with spread contents and overridden products
|
|
116
113
|
return Array.isArray(data)
|
|
117
114
|
? cachedProducts
|
|
118
115
|
: Object.assign(Object.assign({}, data), { products: cachedProducts });
|
|
119
116
|
}));
|
|
120
117
|
const fetcher = (props === null || props === void 0 ? void 0 : props.fetcher) || defaultFetcher;
|
|
118
|
+
// Reset local product state when the requested product identity changes so
|
|
119
|
+
// stale or partial state from a previously viewed product cannot carry over.
|
|
120
|
+
const requestedProductKey = React.useMemo(() => {
|
|
121
|
+
var _a, _b, _c;
|
|
122
|
+
return JSON.stringify({
|
|
123
|
+
productIds: (_a = props === null || props === void 0 ? void 0 : props.productIds) !== null && _a !== void 0 ? _a : [],
|
|
124
|
+
productHandles: (_b = props === null || props === void 0 ? void 0 : props.productHandles) !== null && _b !== void 0 ? _b : [],
|
|
125
|
+
variantIds: (_c = props === null || props === void 0 ? void 0 : props.variantIds) !== null && _c !== void 0 ? _c : [],
|
|
126
|
+
});
|
|
127
|
+
}, [props === null || props === void 0 ? void 0 : props.productIds, props === null || props === void 0 ? void 0 : props.productHandles, props === null || props === void 0 ? void 0 : props.variantIds]);
|
|
128
|
+
const hasMountedRef = React.useRef(false);
|
|
129
|
+
React.useEffect(() => {
|
|
130
|
+
if (!hasMountedRef.current) {
|
|
131
|
+
hasMountedRef.current = true;
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
setProductResponse(productsLocalStorage.getCacheItems({
|
|
135
|
+
productIds: props === null || props === void 0 ? void 0 : props.productIds,
|
|
136
|
+
productHandles: props === null || props === void 0 ? void 0 : props.productHandles,
|
|
137
|
+
}));
|
|
138
|
+
setIsProductResponseReady(false);
|
|
139
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
140
|
+
}, [requestedProductKey]);
|
|
121
141
|
const { data, error, isLoading } = useSWR(url ? [url, body] : null, ([url, body]) => fetcher(url, body));
|
|
122
142
|
const cacheProduct = (product) => {
|
|
123
143
|
const cachedProduct = productsLocalStorage.setCacheItem(product);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-products.test.d.ts","sourceRoot":"","sources":["../../../components/hooks/use-products.test.tsx"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
11
|
+
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
12
|
+
import { SWRConfig } from "swr";
|
|
13
|
+
import ProductsLocalStorage from "../libs/cache/ProductsLocalStorage";
|
|
14
|
+
import { useProducts } from "./use-products";
|
|
15
|
+
const productId = "gid://shopify/Product/1";
|
|
16
|
+
const hydratedSellingPlanGroups = {
|
|
17
|
+
edges: [
|
|
18
|
+
{
|
|
19
|
+
node: {
|
|
20
|
+
id: "group-1",
|
|
21
|
+
sellingPlans: {
|
|
22
|
+
edges: [
|
|
23
|
+
{
|
|
24
|
+
node: {
|
|
25
|
+
id: "plan-1",
|
|
26
|
+
priceAdjustments: [
|
|
27
|
+
{ adjustmentValue: { adjustmentPercentage: 10 } },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
const wrapper = ({ children }) => (_jsx(SWRConfig, Object.assign({ value: {
|
|
38
|
+
provider: () => new Map(),
|
|
39
|
+
dedupingInterval: 0,
|
|
40
|
+
} }, { children: children })));
|
|
41
|
+
describe("useProducts", () => {
|
|
42
|
+
let originalFetch;
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
originalFetch = global.fetch;
|
|
45
|
+
localStorage.clear();
|
|
46
|
+
jest.restoreAllMocks();
|
|
47
|
+
});
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
global.fetch = originalFetch;
|
|
50
|
+
});
|
|
51
|
+
it("renders the merged cached product when a fetch returns partial selling plans", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
52
|
+
var _a;
|
|
53
|
+
const cache = new ProductsLocalStorage();
|
|
54
|
+
cache.setCacheItem({
|
|
55
|
+
id: productId,
|
|
56
|
+
handle: "subscription-product",
|
|
57
|
+
title: "Cached product",
|
|
58
|
+
sellingPlanGroups: hydratedSellingPlanGroups,
|
|
59
|
+
});
|
|
60
|
+
const fetchMock = jest.fn().mockResolvedValue({
|
|
61
|
+
json: jest.fn().mockResolvedValue([
|
|
62
|
+
{
|
|
63
|
+
id: productId,
|
|
64
|
+
handle: "subscription-product",
|
|
65
|
+
title: "Network product",
|
|
66
|
+
sellingPlanGroups: {
|
|
67
|
+
edges: [
|
|
68
|
+
{
|
|
69
|
+
node: {
|
|
70
|
+
id: "group-1",
|
|
71
|
+
sellingPlans: {
|
|
72
|
+
edges: [{ node: { id: "plan-1" } }],
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
]),
|
|
80
|
+
});
|
|
81
|
+
global.fetch = fetchMock;
|
|
82
|
+
const { result } = renderHook(() => useProducts({
|
|
83
|
+
baseURL: "https://example.test",
|
|
84
|
+
productIds: [productId],
|
|
85
|
+
}), { wrapper });
|
|
86
|
+
yield waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
|
87
|
+
yield waitFor(() => { var _a; return expect((_a = result.current.products[0]) === null || _a === void 0 ? void 0 : _a.title).toBe("Network product"); });
|
|
88
|
+
expect((_a = result.current.products[0]) === null || _a === void 0 ? void 0 : _a.sellingPlanGroups).toEqual(hydratedSellingPlanGroups);
|
|
89
|
+
}));
|
|
90
|
+
it("clears the previous product while a new product identity resolves", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
91
|
+
let resolveProductB = () => { };
|
|
92
|
+
const productBResponse = new Promise((resolve) => {
|
|
93
|
+
resolveProductB = resolve;
|
|
94
|
+
});
|
|
95
|
+
const fetcher = jest
|
|
96
|
+
.fn()
|
|
97
|
+
.mockResolvedValueOnce([
|
|
98
|
+
{
|
|
99
|
+
id: "gid://shopify/Product/A",
|
|
100
|
+
handle: "product-a",
|
|
101
|
+
title: "Product A",
|
|
102
|
+
},
|
|
103
|
+
])
|
|
104
|
+
.mockReturnValueOnce(productBResponse);
|
|
105
|
+
const { result, rerender } = renderHook(({ productId }) => useProducts({
|
|
106
|
+
baseURL: "https://example.test",
|
|
107
|
+
productIds: [productId],
|
|
108
|
+
fetcher,
|
|
109
|
+
}), {
|
|
110
|
+
initialProps: { productId: "gid://shopify/Product/A" },
|
|
111
|
+
wrapper,
|
|
112
|
+
});
|
|
113
|
+
yield waitFor(() => { var _a; return expect((_a = result.current.products[0]) === null || _a === void 0 ? void 0 : _a.title).toBe("Product A"); });
|
|
114
|
+
rerender({ productId: "gid://shopify/Product/B" });
|
|
115
|
+
expect(result.current.products).toEqual([]);
|
|
116
|
+
expect(result.current.isLoading).toBe(true);
|
|
117
|
+
yield act(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
118
|
+
resolveProductB([
|
|
119
|
+
{
|
|
120
|
+
id: "gid://shopify/Product/B",
|
|
121
|
+
handle: "product-b",
|
|
122
|
+
title: "Product B",
|
|
123
|
+
},
|
|
124
|
+
]);
|
|
125
|
+
yield productBResponse;
|
|
126
|
+
}));
|
|
127
|
+
yield waitFor(() => { var _a; return expect((_a = result.current.products[0]) === null || _a === void 0 ? void 0 : _a.title).toBe("Product B"); });
|
|
128
|
+
}));
|
|
129
|
+
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ProductsLocalStorage.d.ts","sourceRoot":"","sources":["../../../../components/libs/cache/ProductsLocalStorage.ts"],"names":[],"mappings":"AAAA,OAAO,cAAc,MAAM,kBAAkB,CAAA;
|
|
1
|
+
{"version":3,"file":"ProductsLocalStorage.d.ts","sourceRoot":"","sources":["../../../../components/libs/cache/ProductsLocalStorage.ts"],"names":[],"mappings":"AAAA,OAAO,cAAc,MAAM,kBAAkB,CAAA;AA0B7C,KAAK,aAAa,GAAG;IACnB,EAAE,EAAE,MAAM,CAAA;IACV,MAAM,EAAE,MAAM,CAAA;IACd,EAAE,EAAE,MAAM,CAAA;IACV,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;CACnB,CAAA;AAID,MAAM,CAAC,OAAO,OAAO,oBAAqB,SAAQ,cAAc;IAC9D,OAAO,EAAE,MAAM,CAA2B;IAC1C,WAAW,EAAE,MAAM,CAAe;IAElC,aAAa,CAAC,EAAE,EAAE,MAAM;IAIxB,iBAAiB,CAAC,MAAM,EAAE,MAAM;;IAShC,QAAQ,IAAI,aAAa,EAAE;IAiB3B,YAAY,CAAC,EACX,EAAE,EACF,MAAM,GACP,EAAE;QACD,EAAE,CAAC,EAAE,MAAM,CAAA;QACX,MAAM,CAAC,EAAE,MAAM,CAAA;KAChB,GAAG,MAAM,GAAG,IAAI;IAgBjB,aAAa,CAAC,QAAQ,EAAE;QACtB,UAAU,CAAC,EAAE,MAAM,EAAE,CAAA;QACrB,cAAc,CAAC,EAAE,MAAM,EAAE,CAAA;KAC1B,GAAG,CAAC,MAAM,GAAG,IAAI,CAAC,EAAE;IAYrB,YAAY,CACV,OAAO,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAA;KAAE,EACvC,KAAK,CAAC,EAAE,aAAa,EAAE,EACvB,YAAY,GAAE,OAAc;gBAFT,MAAM;YAAM,MAAM;;IA6EvC,aAAa,CAAC,KAAK,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAA;KAAE,EAAE;;;;IASrD,eAAe,CAAC,WAAW,GAAE,MAAU;IAWvC,UAAU;IAQV,eAAe,CAAC,CAAC,EAAE,GAAG;CASvB"}
|
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
import AppStudioCache from "./AppStudioCache";
|
|
2
2
|
import { getProductGidsFromIds } from "../../../lib/utils";
|
|
3
|
+
// Normalizes a Shopify connection ({ edges: [...] }) or a plain array into an array.
|
|
4
|
+
const normalizeConnection = (value) => Array.isArray(value) ? value : Array.isArray(value === null || value === void 0 ? void 0 : value.edges) ? value.edges : [];
|
|
5
|
+
const isConnection = (value) => Array.isArray(value) || Array.isArray(value === null || value === void 0 ? void 0 : value.edges);
|
|
6
|
+
// Empty connections are authoritative. Non-empty connections are complete only
|
|
7
|
+
// when every nested plan includes the priceAdjustments field requested by PDPs.
|
|
8
|
+
const hasCompleteSellingPlanData = (groups) => {
|
|
9
|
+
if (!isConnection(groups))
|
|
10
|
+
return false;
|
|
11
|
+
return normalizeConnection(groups).every((group) => {
|
|
12
|
+
var _a;
|
|
13
|
+
const groupNode = (_a = group === null || group === void 0 ? void 0 : group.node) !== null && _a !== void 0 ? _a : group;
|
|
14
|
+
if (!isConnection(groupNode === null || groupNode === void 0 ? void 0 : groupNode.sellingPlans))
|
|
15
|
+
return false;
|
|
16
|
+
return normalizeConnection(groupNode.sellingPlans).every((plan) => {
|
|
17
|
+
var _a;
|
|
18
|
+
const planNode = (_a = plan === null || plan === void 0 ? void 0 : plan.node) !== null && _a !== void 0 ? _a : plan;
|
|
19
|
+
return Array.isArray(planNode === null || planNode === void 0 ? void 0 : planNode.priceAdjustments);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
};
|
|
23
|
+
const FRESH_MS = 60 * 1000;
|
|
3
24
|
export default class ProductsLocalStorage extends AppStudioCache {
|
|
4
25
|
getIdCacheKey(id) {
|
|
5
26
|
return `${this.cachePrefix}-data-${id}`;
|
|
@@ -68,6 +89,17 @@ export default class ProductsLocalStorage extends AppStudioCache {
|
|
|
68
89
|
if (cachedProduct) {
|
|
69
90
|
// merge products, keeping populated arrays from cached product
|
|
70
91
|
productToCache = Object.entries(product).reduce((merged, [key, value]) => {
|
|
92
|
+
var _a;
|
|
93
|
+
// Keep the more-complete cached sellingPlanGroups when the incoming
|
|
94
|
+
// payload is partial/unhydrated (missing nested priceAdjustments),
|
|
95
|
+
// e.g. from PLP or carousel fetches. Prevents a shallow payload from
|
|
96
|
+
// poisoning a fully hydrated PDP record.
|
|
97
|
+
if (key === "sellingPlanGroups" &&
|
|
98
|
+
hasCompleteSellingPlanData(cachedProduct[key]) &&
|
|
99
|
+
!hasCompleteSellingPlanData(value) &&
|
|
100
|
+
Date.now() - ((_a = cachedProduct.ct) !== null && _a !== void 0 ? _a : 0) < FRESH_MS) {
|
|
101
|
+
return Object.assign(Object.assign({}, merged), { [key]: cachedProduct[key] });
|
|
102
|
+
}
|
|
71
103
|
// Keep non-empty arrays from cached product if current product has empty array
|
|
72
104
|
if (Array.isArray(value) &&
|
|
73
105
|
value.length === 0 &&
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ProductsLocalStorage.test.d.ts","sourceRoot":"","sources":["../../../../components/libs/cache/ProductsLocalStorage.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import ProductsLocalStorage from "./ProductsLocalStorage";
|
|
2
|
+
const productId = "gid://shopify/Product/1";
|
|
3
|
+
const sellingPlanGroups = (price) => ({
|
|
4
|
+
edges: [
|
|
5
|
+
{
|
|
6
|
+
node: {
|
|
7
|
+
id: "group-1",
|
|
8
|
+
sellingPlans: {
|
|
9
|
+
edges: [
|
|
10
|
+
{
|
|
11
|
+
node: {
|
|
12
|
+
id: "plan-1",
|
|
13
|
+
priceAdjustments: [{ adjustmentValue: { price } }],
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
});
|
|
22
|
+
const product = (groups) => ({
|
|
23
|
+
id: productId,
|
|
24
|
+
handle: "subscription-product",
|
|
25
|
+
sellingPlanGroups: groups,
|
|
26
|
+
});
|
|
27
|
+
describe("ProductsLocalStorage selling plan merges", () => {
|
|
28
|
+
let storage;
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
localStorage.clear();
|
|
31
|
+
storage = new ProductsLocalStorage();
|
|
32
|
+
});
|
|
33
|
+
it("keeps hydrated selling plans when an incoming payload is partial", () => {
|
|
34
|
+
storage.setCacheItem(product(sellingPlanGroups("10.00")));
|
|
35
|
+
const merged = storage.setCacheItem(product({
|
|
36
|
+
edges: [
|
|
37
|
+
{
|
|
38
|
+
node: {
|
|
39
|
+
id: "group-1",
|
|
40
|
+
sellingPlans: {
|
|
41
|
+
edges: [{ node: { id: "plan-1" } }],
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
}));
|
|
47
|
+
expect(merged.sellingPlanGroups.edges[0].node.sellingPlans.edges[0].node
|
|
48
|
+
.priceAdjustments).toEqual([{ adjustmentValue: { price: "10.00" } }]);
|
|
49
|
+
});
|
|
50
|
+
it("keeps hydrated selling plans when only some incoming plans are hydrated", () => {
|
|
51
|
+
storage.setCacheItem(product(sellingPlanGroups("10.00")));
|
|
52
|
+
const merged = storage.setCacheItem(product({
|
|
53
|
+
edges: [
|
|
54
|
+
{
|
|
55
|
+
node: {
|
|
56
|
+
id: "group-1",
|
|
57
|
+
sellingPlans: {
|
|
58
|
+
edges: [
|
|
59
|
+
{
|
|
60
|
+
node: {
|
|
61
|
+
id: "plan-1",
|
|
62
|
+
priceAdjustments: [
|
|
63
|
+
{ adjustmentValue: { price: "12.00" } },
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{ node: { id: "plan-2" } },
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
}));
|
|
74
|
+
expect(merged.sellingPlanGroups.edges[0].node.sellingPlans.edges).toHaveLength(1);
|
|
75
|
+
expect(merged.sellingPlanGroups.edges[0].node.sellingPlans.edges[0].node
|
|
76
|
+
.priceAdjustments).toEqual([{ adjustmentValue: { price: "10.00" } }]);
|
|
77
|
+
});
|
|
78
|
+
it("accepts an authoritative empty selling plan list", () => {
|
|
79
|
+
storage.setCacheItem(product(sellingPlanGroups("10.00")));
|
|
80
|
+
const merged = storage.setCacheItem(product({ edges: [] }));
|
|
81
|
+
expect(merged.sellingPlanGroups).toEqual({ edges: [] });
|
|
82
|
+
});
|
|
83
|
+
it("replaces cached selling plans with a complete incoming payload", () => {
|
|
84
|
+
storage.setCacheItem(product(sellingPlanGroups("10.00")));
|
|
85
|
+
const merged = storage.setCacheItem(product(sellingPlanGroups("12.00")));
|
|
86
|
+
expect(merged.sellingPlanGroups.edges[0].node.sellingPlans.edges[0].node
|
|
87
|
+
.priceAdjustments).toEqual([{ adjustmentValue: { price: "12.00" } }]);
|
|
88
|
+
});
|
|
89
|
+
it("lets a partial payload win once the cached entry is stale", () => {
|
|
90
|
+
const nowSpy = jest.spyOn(Date, "now");
|
|
91
|
+
nowSpy.mockReturnValue(1000000);
|
|
92
|
+
storage.setCacheItem(product(sellingPlanGroups("10.00")));
|
|
93
|
+
nowSpy.mockReturnValue(1000000 + 61 * 1000);
|
|
94
|
+
const merged = storage.setCacheItem(product({
|
|
95
|
+
edges: [
|
|
96
|
+
{
|
|
97
|
+
node: {
|
|
98
|
+
id: "group-1",
|
|
99
|
+
sellingPlans: {
|
|
100
|
+
edges: [{ node: { id: "plan-1" } }],
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
}));
|
|
106
|
+
expect(merged.sellingPlanGroups.edges[0].node.sellingPlans.edges[0].node
|
|
107
|
+
.priceAdjustments).toBeUndefined();
|
|
108
|
+
nowSpy.mockRestore();
|
|
109
|
+
});
|
|
110
|
+
});
|