@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.
@@ -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;AAe/C,oBAAY,wBAAwB;IAClC,eAAe,oBAAoB;IACnC,eAAe,oBAAoB;IACnC,qBAAqB,0BAA0B;IAC/C,WAAW,gBAAgB;IAC3B,YAAY,iBAAiB;IAC7B,eAAe,oBAAoB;CACpC;AAwTD,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;;;;;EAwDF"}
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, newSessionQuery, } from "./use-nosto-recommendation.queries";
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
- const resolveSessionId = (nostoKey, optionalSessionId) => __awaiter(void 0, void 0, void 0, function* () {
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 optionalSessionId;
77
+ return "session";
46
78
  }
47
- else {
48
- const sessionData = yield fetchNosto(nostoKey, newSessionQuery);
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
- // Fetch sessionId
55
- const sessionId = yield resolveSessionId(nostoKey, optionalSessionId);
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, optionalSessionId) => __awaiter(void 0, void 0, void 0, function* () {
97
+ const fetchProductPageRecommendations = (nostoKey, productId, slotId, resolvedSessionId) => __awaiter(void 0, void 0, void 0, function* () {
62
98
  var _d, _e, _f;
63
- const sessionId = yield resolveSessionId(nostoKey, optionalSessionId);
64
- const body = getProductPageRecommendationsQuery(sessionId, productId, slotId);
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, optionalSessionId) => __awaiter(void 0, void 0, void 0, function* () {
106
+ const fetchCategoryPageRecommendations = (nostoKey, sourceCollectionTitle, resolvedSessionId) => __awaiter(void 0, void 0, void 0, function* () {
69
107
  var _g, _h, _j;
70
- const sessionId = yield resolveSessionId(nostoKey, optionalSessionId);
71
- const body = categoryPageRecommendationsQuery(sessionId, sourceCollectionTitle);
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, sessionId) => __awaiter(void 0, void 0, void 0, function* () {
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
- return fetchCategoryPageRecommendations(nostoKey, productId, sessionId);
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, sessionId);
170
+ productIdRecommendations = yield fetchProductPageRecommendations(nostoKey, productId, slotId, resolvedSessionId);
122
171
  if (productIdRecommendations.length === 0) {
123
- productIdRecommendations = yield fetchPDPFallbackRecommendations(nostoKey, productId, recommendationsType, sessionId);
172
+ productIdRecommendations = yield fetchPDPFallbackRecommendations(nostoKey, productId, recommendationsType, resolvedSessionId);
124
173
  }
125
174
  }
126
175
  catch (error) {
127
- productIdRecommendations = yield fetchPDPFallbackRecommendations(nostoKey, productId, recommendationsType, sessionId);
176
+ productIdRecommendations = yield fetchPDPFallbackRecommendations(nostoKey, productId, recommendationsType, resolvedSessionId);
128
177
  }
129
178
  }
130
179
  else {
131
- productIdRecommendations = yield fetchPDPFallbackRecommendations(nostoKey, productId, recommendationsType, sessionId);
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
- }, [nostoKey, slotId, recommendationsType, productIds, skip, sessionId]);
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;AAEJ,eAAO,MAAM,eAAe,QAE1B,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"}
@@ -138,6 +138,3 @@ export const getRelatedProductsRecommendationsQuery = (recommendationsType, prod
138
138
  productIds: productIds,
139
139
  },
140
140
  });
141
- export const newSessionQuery = JSON.stringify({
142
- query: `mutation { newSession }`,
143
- });
@@ -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.resolve({
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
- it("should return front page recommendations if productIds is empty and slotId is provided", () => __awaiter(void 0, void 0, void 0, function* () {
114
- const slotId = "mockSlotId";
115
- const { result } = renderHook(() => useNostoRecommendations(mockIntegrations, mockBaseURL, slotId, "US", undefined, undefined, undefined));
116
- // Initial state check
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
- // Wait for all promises with act
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("should return best sellers recommendations if productIds is empty and slotId is not provided", () => __awaiter(void 0, void 0, void 0, function* () {
137
- const slotId = "";
138
- const { result } = renderHook(() => useNostoRecommendations(mockIntegrations, mockBaseURL, slotId, "US", undefined, undefined));
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
- // Wait for all promises with act
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
- expect(result.current.error).toBe(null);
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: ["best-seller-1"],
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("should return best sellers recommendations if productIds is not empty, Best Sellers is the recommendation type and slotId is not provided", () => __awaiter(void 0, void 0, void 0, function* () {
160
- const slotId = "";
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
- // Wait for all promises with act
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
- expect(result.current.error).toBe(null);
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("should return default front page recommendations if slotId is provided but layoutType is not", () => __awaiter(void 0, void 0, void 0, function* () {
184
- const slotId = "mockSlotId";
185
- const { result } = renderHook(() => useNostoRecommendations(mockIntegrations, mockBaseURL, slotId, "US"));
186
- // Initial state check
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
- // Wait for all promises with act
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: ["front-page-id-1"],
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("should return the correct recommendations for best sellers", () => __awaiter(void 0, void 0, void 0, function* () {
207
- const { result } = renderHook(() => useNostoRecommendations(mockIntegrations, mockBaseURL, "", "US", NostoRecommendationsType.BEST_SELLERS, undefined, undefined));
208
- // Initial state check
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
- // Wait for all promises with act
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("should return the related recommendations when only productIds and a recommendation type are provided on a non-home layout", () => __awaiter(void 0, void 0, void 0, function* () {
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
- // Wait for all promises with act
234
- yield act(() => __awaiter(void 0, void 0, void 0, function* () {
235
- yield new Promise((resolve) => setTimeout(resolve, 0));
236
- }));
237
- // Check final state
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("should return the correct recommendations when productIds and slotIds are provided", () => __awaiter(void 0, void 0, void 0, function* () {
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, slotId, "US", NostoRecommendationsType.VIEWED_TOGETHER, productIds, undefined));
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
- // Wait for all promises with act
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: ["product-page-id-1"],
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("should return the related products fallback recommendations when productIds are provided and no slotId and a type other than viewed_category and best_sellers is provided", () => __awaiter(void 0, void 0, void 0, function* () {
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.VIEWED_TOGETHER, productIds, undefined));
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
- // Wait for all promises with act
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: ["related-1"],
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("should return the category fallback recommendations when no slotId, productIds are provided and a recommendation type of viewed_category is provided", () => __awaiter(void 0, void 0, void 0, function* () {
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, undefined));
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
- // Wait for all promises with act
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: ["category-page-id-1"],
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("should handle loading state", () => __awaiter(void 0, void 0, void 0, function* () {
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
- // Wait for all state updates to complete
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("should handle error state", () => __awaiter(void 0, void 0, void 0, function* () {
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
- // Wait for all promises and state updates to resolve
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("should default to best sellers if productIds are empty but a recommendation type other than Best Sellers is used", () => __awaiter(void 0, void 0, void 0, function* () {
353
- const productIds = [];
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
- // Wait for all promises with act
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: ["best-seller-1"],
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("should return an empty array if skip is true", () => __awaiter(void 0, void 0, void 0, function* () {
376
- const { result } = renderHook(() => useNostoRecommendations(mockIntegrations, mockBaseURL, "", "US", NostoRecommendationsType.BEST_SELLERS, undefined, true));
377
- // Initial state check
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
- // Wait for all promises with act
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,CA0J7E"}
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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=use-products.test.d.ts.map
@@ -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;AAG7C,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;AAED,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;;IAiEvC,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
+ {"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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=ProductsLocalStorage.test.d.ts.map
@@ -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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tapcart/mobile-components",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "style": "dist/styles.css",