@faststore/core 4.5.0-dev.2 → 4.5.0-dev.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/.turbo/turbo-generate.log +3 -3
  2. package/.turbo/turbo-test.log +81 -75
  3. package/@generated/gql.ts +6 -6
  4. package/@generated/graphql.ts +46 -7
  5. package/@generated/persisted-documents.json +2 -2
  6. package/@generated/schema.graphql +44 -5
  7. package/CHANGELOG.md +12 -0
  8. package/cms/faststore/components/cms_component__recommendationshelf.jsonc +7 -1
  9. package/cms/faststore/schema.json +7 -1
  10. package/discovery.config.default.js +0 -3
  11. package/package.json +6 -6
  12. package/src/Layout.tsx +4 -5
  13. package/src/components/sections/EmptyState/EmptyState.tsx +4 -3
  14. package/src/components/sections/RecommendationShelf/README.md +163 -0
  15. package/src/components/sections/RecommendationShelf/RecommendationShelf.tsx +3 -4
  16. package/src/components/sections/RecommendationShelf/RecommendationShelf.types.ts +8 -1
  17. package/src/components/templates/ProductListingPage/ProductListingPage.tsx +43 -37
  18. package/src/components/ui/Breadcrumb/Breadcrumb.tsx +5 -12
  19. package/src/components/ui/LocalizationButton/LocalizationButton.tsx +5 -1
  20. package/src/experimental/index.ts +2 -0
  21. package/src/pages/[...slug].tsx +5 -0
  22. package/src/pages/[slug]/p.tsx +63 -11
  23. package/src/sdk/analytics/hooks/useStartRecommendationSession.ts +38 -18
  24. package/src/sdk/analytics/utils/hasEnabledRecommendationShelf.ts +49 -0
  25. package/src/sdk/localization/LocalizedProductContext.tsx +65 -0
  26. package/src/sdk/localization/useBindingSelector.ts +148 -5
  27. package/src/server/index.ts +10 -1
  28. package/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx +32 -45
  29. package/test/sdk/analytics/hasEnabledRecommendationShelf.test.ts +88 -0
  30. package/test/sdk/analytics/useStartRecommendationSession.test.ts +90 -15
  31. package/test/sdk/localization/useBindingSelector.test.tsx +144 -1
@@ -18,17 +18,50 @@ vi.mock('src/sdk/graphql/useLazyQuery', () => ({
18
18
  const getCookie = vi.hoisted(() => vi.fn())
19
19
  vi.mock('src/utils/getCookie', () => ({ getCookie }))
20
20
 
21
- const storeConfigMock = vi.hoisted(() => ({
22
- experimental: { enableRecommendations: true },
23
- }))
24
- vi.mock('discovery.config', () => ({ default: storeConfigMock }))
25
-
26
- import { useStartRecommendationSession } from 'src/sdk/analytics/hooks/useStartRecommendationSession'
21
+ import {
22
+ resetStartRecommendationSessionLock,
23
+ useStartRecommendationSession,
24
+ } from 'src/sdk/analytics/hooks/useStartRecommendationSession'
25
+
26
+ const enabledPageProps = {
27
+ sections: [
28
+ {
29
+ name: 'RecommendationShelf',
30
+ data: { enableRecommendations: true },
31
+ },
32
+ ],
33
+ }
34
+
35
+ const multipleEnabledShelvesPageProps = {
36
+ sections: [
37
+ {
38
+ name: 'RecommendationShelf',
39
+ data: { enableRecommendations: true, campaignVrn: 'vrn:a' },
40
+ },
41
+ {
42
+ name: 'RecommendationShelf',
43
+ data: { enableRecommendations: true, campaignVrn: 'vrn:b' },
44
+ },
45
+ {
46
+ name: 'ProductShelf',
47
+ data: {},
48
+ },
49
+ ],
50
+ }
51
+
52
+ const disabledPageProps = {
53
+ sections: [
54
+ {
55
+ name: 'RecommendationShelf',
56
+ data: { enableRecommendations: false },
57
+ },
58
+ ],
59
+ }
27
60
 
28
61
  beforeEach(() => {
62
+ resetStartRecommendationSessionLock()
29
63
  useLazyQueryMock.mockReturnValue([runStartRecommendationSession, {}])
30
64
  runStartRecommendationSession.mockResolvedValue(true)
31
- storeConfigMock.experimental.enableRecommendations = true
32
65
  })
33
66
 
34
67
  afterEach(() => {
@@ -36,34 +69,76 @@ afterEach(() => {
36
69
  })
37
70
 
38
71
  describe('useStartRecommendationSession', () => {
72
+ it('does not start a session when no RecommendationShelf is enabled in CMS', async () => {
73
+ getCookie.mockReturnValue(undefined)
74
+
75
+ renderHook(() => useStartRecommendationSession(disabledPageProps))
76
+
77
+ await waitFor(() => {
78
+ expect(runStartRecommendationSession).not.toHaveBeenCalled()
79
+ })
80
+ })
81
+
82
+ it('does not start a session when page props are missing', async () => {
83
+ getCookie.mockReturnValue(undefined)
84
+
85
+ renderHook(() => useStartRecommendationSession())
86
+
87
+ await waitFor(() => {
88
+ expect(runStartRecommendationSession).not.toHaveBeenCalled()
89
+ })
90
+ })
91
+
39
92
  it('does not start a session when the session cookie already exists', async () => {
40
93
  getCookie.mockReturnValue('already-started')
41
94
 
42
- renderHook(() => useStartRecommendationSession())
95
+ renderHook(() => useStartRecommendationSession(enabledPageProps))
43
96
 
44
97
  await waitFor(() => {
45
98
  expect(runStartRecommendationSession).not.toHaveBeenCalled()
46
99
  })
47
100
  })
48
101
 
49
- it('starts a session when no session cookie is present', async () => {
102
+ it('starts a session when CMS enableRecommendations is true and no session cookie is present', async () => {
50
103
  getCookie.mockReturnValue(undefined)
51
104
 
52
- renderHook(() => useStartRecommendationSession())
105
+ renderHook(() => useStartRecommendationSession(enabledPageProps))
53
106
 
54
107
  await waitFor(() => {
55
- expect(runStartRecommendationSession).toHaveBeenCalled()
108
+ expect(runStartRecommendationSession).toHaveBeenCalledTimes(1)
56
109
  })
57
110
  })
58
111
 
59
- it('does not start a session when recommendations are disabled', async () => {
60
- storeConfigMock.experimental.enableRecommendations = false
112
+ it('starts a session only once when the page has multiple enabled RecommendationShelves', async () => {
61
113
  getCookie.mockReturnValue(undefined)
62
114
 
63
- renderHook(() => useStartRecommendationSession())
115
+ const { rerender } = renderHook(
116
+ ({ pageProps }) => useStartRecommendationSession(pageProps),
117
+ { initialProps: { pageProps: multipleEnabledShelvesPageProps } }
118
+ )
119
+
120
+ // Simulate Layout re-renders caused by multiple shelves updating.
121
+ rerender({ pageProps: multipleEnabledShelvesPageProps })
122
+ rerender({ pageProps: multipleEnabledShelvesPageProps })
64
123
 
65
124
  await waitFor(() => {
66
- expect(runStartRecommendationSession).not.toHaveBeenCalled()
125
+ expect(runStartRecommendationSession).toHaveBeenCalledTimes(1)
126
+ })
127
+ })
128
+
129
+ it('does not start a second session when the hook mounts again without a cookie', async () => {
130
+ getCookie.mockReturnValue(undefined)
131
+
132
+ renderHook(() => useStartRecommendationSession(enabledPageProps))
133
+
134
+ await waitFor(() => {
135
+ expect(runStartRecommendationSession).toHaveBeenCalledTimes(1)
136
+ })
137
+
138
+ renderHook(() => useStartRecommendationSession(enabledPageProps))
139
+
140
+ await waitFor(() => {
141
+ expect(runStartRecommendationSession).toHaveBeenCalledTimes(1)
67
142
  })
68
143
  })
69
144
  })
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from 'vitest'
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
2
 
3
3
  import {
4
4
  buildLanguageOptions,
@@ -6,7 +6,47 @@ import {
6
6
  isValidUrl,
7
7
  resolveBinding,
8
8
  } from '../../../src/sdk/localization/bindingSelector'
9
+ import type { LocalizedProductLocale } from '../../../src/sdk/localization/LocalizedProductContext'
9
10
  import type { Locale } from '../../../src/sdk/localization/types'
11
+ import {
12
+ getSkuIdFromPdpPath,
13
+ persistOtherLocales,
14
+ recoverOtherLocales,
15
+ } from '../../../src/sdk/localization/useBindingSelector'
16
+
17
+ /** In-memory Storage stub matching the subset used by the helpers. */
18
+ function createFakeStorage() {
19
+ const map = new Map<string, string>()
20
+
21
+ return {
22
+ getItem: (key: string) => (map.has(key) ? (map.get(key) as string) : null),
23
+ setItem: (key: string, value: string) => {
24
+ map.set(key, value)
25
+ },
26
+ removeItem: (key: string) => {
27
+ map.delete(key)
28
+ },
29
+ clear: () => map.clear(),
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Stubs a browser-like global scope with the given pathname and storage.
35
+ *
36
+ * The production helpers guard on `typeof window` but read `globalThis.location`
37
+ * / `globalThis.sessionStorage` (which coincide with `window` in a real browser).
38
+ * Under the node test environment those are distinct, so we stub all three: the
39
+ * `window` keeps the guard passing while `location`/`sessionStorage` back the reads.
40
+ * Pass an existing `sessionStorage` to simulate navigation while storage persists.
41
+ */
42
+ function stubWindow(pathname: string, sessionStorage = createFakeStorage()) {
43
+ const location = { pathname }
44
+ vi.stubGlobal('window', { location, sessionStorage })
45
+ vi.stubGlobal('location', location)
46
+ vi.stubGlobal('sessionStorage', sessionStorage)
47
+
48
+ return sessionStorage
49
+ }
10
50
 
11
51
  // Test data that mirrors what the hook would receive from discovery.config
12
52
  const mockLocales: Record<string, Locale> = {
@@ -216,4 +256,107 @@ describe('useBindingSelector integration scenarios', () => {
216
256
  expect(newCurrencies[0]).toBe('EUR')
217
257
  })
218
258
  })
259
+
260
+ describe('getSkuIdFromPdpPath', () => {
261
+ afterEach(() => {
262
+ vi.unstubAllGlobals()
263
+ })
264
+
265
+ it('extracts the SKU id from a locale-prefixed PDP path', () => {
266
+ expect(
267
+ getSkuIdFromPdpPath(
268
+ '/en-CA/adidas-mens-performance-polo-blast-blue-65/p'
269
+ )
270
+ ).toBe('65')
271
+ })
272
+
273
+ it('extracts the SKU id from a default-locale (root) PDP path', () => {
274
+ expect(
275
+ getSkuIdFromPdpPath('/adidas-mens-performance-polo-blast-blue-65/p')
276
+ ).toBe('65')
277
+ })
278
+
279
+ it('extracts the SKU id from a custom-path binding PDP', () => {
280
+ expect(getSkuIdFromPdpPath('/europe/it/some-product-slug-12/p')).toBe(
281
+ '12'
282
+ )
283
+ })
284
+
285
+ it('handles a trailing slash after /p', () => {
286
+ expect(getSkuIdFromPdpPath('/pt-BR/some-slug-9/p/')).toBe('9')
287
+ })
288
+
289
+ it('returns null for non-PDP paths', () => {
290
+ expect(getSkuIdFromPdpPath('/en-CA')).toBeNull()
291
+ expect(getSkuIdFromPdpPath('/en-CA/office')).toBeNull()
292
+ expect(getSkuIdFromPdpPath('/')).toBeNull()
293
+ })
294
+
295
+ it('returns null when the slug has no numeric id', () => {
296
+ expect(getSkuIdFromPdpPath('/en-CA/no-numeric-id/p')).toBeNull()
297
+ })
298
+ })
299
+
300
+ describe('persist / recover otherLocales (sessionStorage)', () => {
301
+ afterEach(() => {
302
+ vi.unstubAllGlobals()
303
+ })
304
+
305
+ const otherLocales: LocalizedProductLocale[] = [
306
+ { locale: 'en-US', slug: 'adidas-mens-performance-polo-blast-blue-65' },
307
+ {
308
+ locale: 'pt-BR',
309
+ slug: 'adidas-polo-performance-masculina-azul-blast-65',
310
+ },
311
+ {
312
+ locale: 'fr-CA',
313
+ slug: 'adidas-polo-performance-homme-bleu-blast-ca-65',
314
+ },
315
+ ]
316
+
317
+ it('round-trips the map for the SKU referenced by the current path', () => {
318
+ // Persisted while on a working PDP (SKU 65).
319
+ const storage = stubWindow(
320
+ '/adidas-mens-performance-polo-blast-blue-65/p'
321
+ )
322
+ persistOtherLocales(otherLocales)
323
+
324
+ // Later, on a 404 for en-CA (same SKU 65, default-locale slug in the URL).
325
+ stubWindow('/en-CA/adidas-mens-performance-polo-blast-blue-65/p', storage)
326
+
327
+ expect(recoverOtherLocales()).toEqual(otherLocales)
328
+ })
329
+
330
+ it('returns null when the current path references a different SKU', () => {
331
+ const storage = stubWindow(
332
+ '/adidas-mens-performance-polo-blast-blue-65/p'
333
+ )
334
+ persistOtherLocales(otherLocales)
335
+
336
+ stubWindow('/en-CA/some-other-product-99/p', storage)
337
+
338
+ expect(recoverOtherLocales()).toBeNull()
339
+ })
340
+
341
+ it('returns null when the current path is not a PDP', () => {
342
+ const storage = stubWindow(
343
+ '/adidas-mens-performance-polo-blast-blue-65/p'
344
+ )
345
+ persistOtherLocales(otherLocales)
346
+
347
+ stubWindow('/en-CA', storage)
348
+
349
+ expect(recoverOtherLocales()).toBeNull()
350
+ })
351
+
352
+ it('does not persist when otherLocales is empty', () => {
353
+ const storage = stubWindow(
354
+ '/adidas-mens-performance-polo-blast-blue-65/p'
355
+ )
356
+ persistOtherLocales([])
357
+
358
+ expect(recoverOtherLocales()).toBeNull()
359
+ expect(storage.getItem('fs:otherLocales:65')).toBeNull()
360
+ })
361
+ })
219
362
  })