@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
@@ -1,8 +1,8 @@
1
- import { useEffect } from 'react'
1
+ import { useEffect, useRef } from 'react'
2
2
 
3
3
  import { gql } from '@faststore/core/api'
4
4
 
5
- import storeConfig from 'discovery.config'
5
+ import { hasEnabledRecommendationShelf } from 'src/sdk/analytics/utils/hasEnabledRecommendationShelf'
6
6
  import { useLazyQuery } from 'src/sdk/graphql/useLazyQuery'
7
7
  import { getCookie } from 'src/utils/getCookie'
8
8
  import { retry } from 'src/utils/retry'
@@ -20,12 +20,28 @@ const startRecommendationSessionMutation = gql(`
20
20
  type StartRecommendationSessionData = { startRecommendationSession: boolean }
21
21
  type StartRecommendationSessionVariables = Record<string, never>
22
22
 
23
+ // In-memory lock so Layout re-renders (e.g. multiple RecommendationShelves
24
+ // fetching) cannot start more than one session before the cookie lands.
25
+ // Survives effect cleanup / Strict Mode remounts for this JS realm.
26
+ let hasRequestedStartRecommendationSession = false
27
+
28
+ /** @internal Test-only: clears the in-memory session-start lock. */
29
+ export function resetStartRecommendationSessionLock() {
30
+ hasRequestedStartRecommendationSession = false
31
+ }
32
+
23
33
  /**
24
34
  * Starts the anonymous recommendation/personalization session once per browser
25
35
  * session.
26
36
  *
27
- * Mounted globally from `Layout` so it runs on every page, regardless of
28
- * whether a recommendation section is present.
37
+ * Mounted from `Layout` with the current page props. Opt-in is controlled by
38
+ * the CMS boolean prop `enableRecommendations` on `RecommendationShelf`
39
+ * (default `false`): if no shelf on the page has it enabled, this hook is a
40
+ * no-op and the store never hits the Recommendations API.
41
+ *
42
+ * Multiple enabled shelves on the same page still produce a single call: the
43
+ * hook runs once from Layout and is gated by an in-memory lock plus the
44
+ * session cookie.
29
45
  *
30
46
  * The product view is no longer reported through a mutation here: the PDP now
31
47
  * exposes the product data as `<meta property="product:*">` tags, which the
@@ -33,11 +49,10 @@ type StartRecommendationSessionVariables = Record<string, never>
33
49
  *
34
50
  * All work runs client-side in effects (after hydration), so it doesn't affect
35
51
  * SSR/TTFB or Lighthouse render metrics.
36
- *
37
- * Gated behind the `experimental.enableRecommendations` flag: stores that don't
38
- * opt into Recommendations never start a session nor hit the Recommendations API.
39
52
  */
40
- export function useStartRecommendationSession() {
53
+ export function useStartRecommendationSession(pageProps?: unknown) {
54
+ const enableRecommendations = hasEnabledRecommendationShelf(pageProps)
55
+
41
56
  const [runStartRecommendationSession] = useLazyQuery<
42
57
  StartRecommendationSessionData,
43
58
  StartRecommendationSessionVariables
@@ -49,30 +64,40 @@ export function useStartRecommendationSession() {
49
64
  }
50
65
  )
51
66
 
67
+ // useLazyQuery returns a new `execute` identity every render; keep a stable
68
+ // ref so the effect does not re-subscribe (and re-fire) on unrelated updates.
69
+ const runStartRecommendationSessionRef = useRef(runStartRecommendationSession)
70
+ runStartRecommendationSessionRef.current = runStartRecommendationSession
71
+
52
72
  useEffect(() => {
53
- if (!storeConfig.experimental.enableRecommendations) {
73
+ if (!enableRecommendations) {
54
74
  return
55
75
  }
56
76
 
57
77
  const startRecommendationSessionCookie = getCookie(
58
78
  VTEX_REC_USER_START_SESSION
59
79
  )
60
-
61
80
  if (startRecommendationSessionCookie) {
62
81
  return
63
82
  }
64
83
 
65
- const controller = new AbortController()
84
+ if (hasRequestedStartRecommendationSession) {
85
+ return
86
+ }
87
+ hasRequestedStartRecommendationSession = true
66
88
 
67
89
  // The session endpoint may not be ready on the first try, so we retry with
68
90
  // exponential backoff until it returns a defined result (or we give up).
69
91
  // Transient request/GraphQL rejections are treated as retryable: swallow
70
92
  // the error and return `undefined` so the backoff loop keeps going instead
71
93
  // of aborting on the first failure.
94
+ //
95
+ // Intentionally no AbortController: cleanup would cancel the only attempt
96
+ // (Strict Mode remount / Layout re-render) while the lock stays set.
72
97
  void retry(
73
98
  async () => {
74
99
  try {
75
- return await runStartRecommendationSession({})
100
+ return await runStartRecommendationSessionRef.current({})
76
101
  } catch {
77
102
  return undefined
78
103
  }
@@ -83,14 +108,9 @@ export function useStartRecommendationSession() {
83
108
  backoff: true,
84
109
  maxDelayMs: 3000,
85
110
  until: (result) => result !== undefined,
86
- signal: controller.signal,
87
111
  }
88
112
  // Guard the discarded promise so a give-up (or any unexpected rejection)
89
113
  // never surfaces as an unhandled promise rejection.
90
114
  ).catch(() => {})
91
-
92
- return () => {
93
- controller.abort()
94
- }
95
- }, [runStartRecommendationSession])
115
+ }, [enableRecommendations])
96
116
  }
@@ -0,0 +1,49 @@
1
+ type CmsSection = {
2
+ name?: string
3
+ $componentKey?: string
4
+ data?: {
5
+ enableRecommendations?: boolean
6
+ }
7
+ }
8
+
9
+ function pushSections(target: CmsSection[], value: unknown) {
10
+ if (Array.isArray(value)) {
11
+ for (const item of value) {
12
+ if (item && typeof item === 'object') {
13
+ target.push(item as CmsSection)
14
+ }
15
+ }
16
+ return
17
+ }
18
+
19
+ if (value && typeof value === 'object') {
20
+ const sections = (value as { sections?: unknown }).sections
21
+ if (Array.isArray(sections)) {
22
+ pushSections(target, sections)
23
+ }
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Returns true when the current page CMS data includes at least one
29
+ * `RecommendationShelf` with `enableRecommendations: true`.
30
+ */
31
+ export function hasEnabledRecommendationShelf(pageProps: unknown): boolean {
32
+ if (!pageProps || typeof pageProps !== 'object') {
33
+ return false
34
+ }
35
+
36
+ const props = pageProps as Record<string, unknown>
37
+ const sections: CmsSection[] = []
38
+
39
+ pushSections(sections, props.sections)
40
+ pushSections(sections, props.globalSections)
41
+ pushSections(sections, props.page)
42
+
43
+ return sections.some(
44
+ (section) =>
45
+ (section.name === 'RecommendationShelf' ||
46
+ section.$componentKey === 'RecommendationShelf') &&
47
+ section.data?.enableRecommendations === true
48
+ )
49
+ }
@@ -0,0 +1,65 @@
1
+ import { createContext, useContext, useMemo } from 'react'
2
+ import type { PropsWithChildren } from 'react'
3
+
4
+ export interface LocalizedProductLocale {
5
+ locale: string
6
+ slug: string
7
+ }
8
+
9
+ interface LocalizedProductData {
10
+ /**
11
+ * Localized slug entries for all available locales of the current page.
12
+ * Null when localization is disabled or the page type does not support it.
13
+ */
14
+ otherLocales: LocalizedProductLocale[] | null
15
+ /**
16
+ * Suffix appended after the localized slug when building the redirect URL.
17
+ * Use '/p' for product pages (PDP), '' for collection/PLP pages.
18
+ */
19
+ urlSuffix: string
20
+ }
21
+
22
+ const LocalizedProductContext = createContext<LocalizedProductData | null>(null)
23
+
24
+ type LocalizedProductProviderProps = Readonly<
25
+ PropsWithChildren<{
26
+ otherLocales: LocalizedProductLocale[] | null | undefined
27
+ /**
28
+ * Suffix to append to the localized slug when building the redirect URL.
29
+ * Defaults to '/p' (product pages). Pass '' for collection/PLP pages.
30
+ */
31
+ urlSuffix?: string
32
+ }>
33
+ >
34
+
35
+ /**
36
+ * Provides localized page data (otherLocales, urlSuffix) to any component in
37
+ * the tree — including global components like LocalizationButton that are
38
+ * not co-located with page sections.
39
+ *
40
+ * Set in p.tsx (PDP) and [...slug].tsx (PLP); returns null outside those pages.
41
+ */
42
+ export function LocalizedProductProvider({
43
+ otherLocales,
44
+ urlSuffix = '/p',
45
+ children,
46
+ }: LocalizedProductProviderProps) {
47
+ const value = useMemo(
48
+ () => ({ otherLocales: otherLocales ?? null, urlSuffix }),
49
+ [otherLocales, urlSuffix]
50
+ )
51
+
52
+ return (
53
+ <LocalizedProductContext.Provider value={value}>
54
+ {children}
55
+ </LocalizedProductContext.Provider>
56
+ )
57
+ }
58
+
59
+ /**
60
+ * Returns localized product data for the current page.
61
+ * Safe to call from any component — returns null outside a product page.
62
+ */
63
+ export function useLocalizedProduct(): LocalizedProductData | null {
64
+ return useContext(LocalizedProductContext)
65
+ }
@@ -9,8 +9,86 @@ import {
9
9
  isValidUrl,
10
10
  resolveBinding,
11
11
  } from './bindingSelector'
12
+ import type { LocalizedProductLocale } from './LocalizedProductContext'
12
13
  import type { BindingSelectorError, Locale } from './types'
13
14
 
15
+ const OTHER_LOCALES_STORAGE_PREFIX = 'fs:otherLocales:'
16
+
17
+ /**
18
+ * Extracts the SKU id from a PDP-shaped pathname ("/.../{slug}-{skuId}/p").
19
+ * Slugs always end with "-{skuId}" (see `getSlug` in the API), so the id is the
20
+ * last "-"-separated segment of the path segment that precedes "/p".
21
+ * Returns null when the path is not a product page or has no numeric id.
22
+ */
23
+ export function getSkuIdFromPdpPath(pathname: string): string | null {
24
+ const match = pathname.match(/\/([^/]+)\/p\/?$/) // NOSONAR
25
+ const skuId = match?.[1]?.split('-').pop()
26
+
27
+ return skuId && /^\d+$/.test(skuId) ? skuId : null
28
+ }
29
+
30
+ /**
31
+ * Persists the localized slug map for a product keyed by its SKU id. This lets
32
+ * the locale selector rebuild canonical localized product URLs even after the
33
+ * user lands on a context-less page (e.g. a 404 for a locale where the product
34
+ * is unavailable), where `otherLocales` is no longer provided by the page.
35
+ */
36
+ export function persistOtherLocales(
37
+ otherLocales: LocalizedProductLocale[]
38
+ ): void {
39
+ if (typeof window === 'undefined' || !otherLocales.length) return
40
+
41
+ const skuId = otherLocales[0]?.slug.split('-').pop()
42
+ if (!skuId) return
43
+
44
+ try {
45
+ globalThis.sessionStorage.setItem(
46
+ `${OTHER_LOCALES_STORAGE_PREFIX}${skuId}`,
47
+ JSON.stringify(otherLocales)
48
+ )
49
+ } catch {
50
+ // sessionStorage may be unavailable (private mode/quota); non-critical.
51
+ }
52
+ }
53
+
54
+ function isLocalizedProductLocaleArray(
55
+ value: unknown
56
+ ): value is LocalizedProductLocale[] {
57
+ return (
58
+ Array.isArray(value) &&
59
+ value.every(
60
+ (item) =>
61
+ typeof item === 'object' &&
62
+ item !== null &&
63
+ typeof (item as Record<string, unknown>).locale === 'string' &&
64
+ typeof (item as Record<string, unknown>).slug === 'string'
65
+ )
66
+ )
67
+ }
68
+
69
+ /**
70
+ * Recovers a previously persisted localized slug map for the product referenced
71
+ * by the current PDP URL. Returns null when not on a PDP or nothing is stored.
72
+ */
73
+ export function recoverOtherLocales(): LocalizedProductLocale[] | null {
74
+ if (typeof window === 'undefined') return null
75
+
76
+ const skuId = getSkuIdFromPdpPath(globalThis.location.pathname)
77
+ if (!skuId) return null
78
+
79
+ try {
80
+ const raw = globalThis.sessionStorage.getItem(
81
+ `${OTHER_LOCALES_STORAGE_PREFIX}${skuId}`
82
+ )
83
+
84
+ if (!raw) return null
85
+ const parsed: unknown = JSON.parse(raw)
86
+ return isLocalizedProductLocaleArray(parsed) ? parsed : null
87
+ } catch {
88
+ return null
89
+ }
90
+ }
91
+
14
92
  interface Currency {
15
93
  code: string
16
94
  name: string
@@ -64,9 +142,17 @@ export interface UseBindingSelectorReturn {
64
142
  * Hook that provides state and actions for the localization selector.
65
143
  * Manages locale selection, currency filtering, and binding resolution.
66
144
  *
145
+ * @param otherLocales - Optional list of localized slugs for the current page.
146
+ * When provided (e.g. on PDP or PLP), the save action navigates to the
147
+ * localized page URL instead of preserving the current page path verbatim.
148
+ * @param urlSuffix - Suffix appended after the slug when building the redirect URL.
149
+ * Use '/p' for product pages (default) and '' for collection/PLP pages.
67
150
  * @returns Object with languages, currencies, selections, and actions
68
151
  */
69
- export function useBindingSelector(): UseBindingSelectorReturn {
152
+ export function useBindingSelector(
153
+ otherLocales?: Array<{ locale: string; slug: string }> | null,
154
+ urlSuffix = '/p'
155
+ ): UseBindingSelectorReturn {
70
156
  const { locale: currentLocale, currency: currentCurrency } = useSession()
71
157
  const localizationConfig = storeConfig.localization as LocalizationConfig
72
158
 
@@ -96,6 +182,15 @@ export function useBindingSelector(): UseBindingSelectorReturn {
96
182
  }
97
183
  }, [currentCurrency?.code])
98
184
 
185
+ // Persist the product's localized slugs so a later locale switch can rebuild
186
+ // the canonical localized URL even from a context-less page (e.g. a 404).
187
+ // Only relevant on PDPs — PLP otherLocales are collection slugs, not products.
188
+ useEffect(() => {
189
+ if (urlSuffix === '/p' && otherLocales?.length) {
190
+ persistOtherLocales(otherLocales)
191
+ }
192
+ }, [otherLocales, urlSuffix])
193
+
99
194
  // Build language options with disambiguation - returns Record<localeCode, languageName>
100
195
  const languages = useMemo(
101
196
  () => buildLanguageOptions(localizationConfig.locales),
@@ -181,12 +276,60 @@ export function useBindingSelector(): UseBindingSelectorReturn {
181
276
  return
182
277
  }
183
278
 
184
- // Redirect to binding URL, preserving the current page path and query string
185
- window.location.href = buildRedirectUrl(
279
+ // Prefer the current page's localized slugs. When missing (e.g. we're on a
280
+ // 404 for a locale where the product is unavailable) recover the map that was
281
+ // persisted while on a working PDP, so we can still build the canonical
282
+ // localized product URL instead of dropping the user on the locale home.
283
+ const effectiveOtherLocales = otherLocales?.length
284
+ ? otherLocales
285
+ : recoverOtherLocales()
286
+
287
+ if (effectiveOtherLocales?.length) {
288
+ // 1. Target locale has a specific translation → use it
289
+ const localizedEntry = effectiveOtherLocales.find(
290
+ (e) => e.locale === localeCode
291
+ )
292
+
293
+ // 2. No translation for target locale → fall back to the default locale slug
294
+ // (IS linkText, always in the default locale) to avoid carrying over a
295
+ // translated slug from a different locale (e.g. Italian slug on es-ES).
296
+ // For an unavailable target this yields a 404 at the product URL (expected).
297
+ const fallbackEntry = effectiveOtherLocales.find(
298
+ (e) => e.locale === localizationConfig.defaultLocale
299
+ )
300
+
301
+ const entry = localizedEntry ?? fallbackEntry
302
+
303
+ if (entry) {
304
+ const baseUrl = binding.url.replace(/\/$/, '')
305
+ globalThis.location.href = `${baseUrl}/${entry.slug}${urlSuffix}${globalThis.location.search}${globalThis.location.hash}`
306
+ return
307
+ }
308
+ }
309
+
310
+ // otherLocales is empty/null but we're still on a PDP: strip the stale slug.
311
+ if (globalThis.location.pathname.endsWith('/p')) {
312
+ globalThis.location.href = binding.url
313
+ return
314
+ }
315
+
316
+ // No localized slugs available (not even persisted): preserve the current
317
+ // page path under the target binding instead of dropping the user on the
318
+ // locale home. For a default-locale slug this resolves the product; for an
319
+ // unavailable/untranslated slug it yields a 404 at the product URL, never the
320
+ // locale root.
321
+ globalThis.location.href = buildRedirectUrl(
186
322
  binding.url,
187
- `${window.location.pathname}${window.location.search}${window.location.hash}`
323
+ `${globalThis.location.pathname}${globalThis.location.search}${globalThis.location.hash}`
188
324
  )
189
- }, [localeCode, currencyCode, localizationConfig.locales])
325
+ }, [
326
+ localeCode,
327
+ currencyCode,
328
+ localizationConfig.locales,
329
+ localizationConfig.defaultLocale,
330
+ otherLocales,
331
+ urlSuffix,
332
+ ])
190
333
 
191
334
  const isSaveEnabled = Boolean(localeCode && currencyCode && !error)
192
335
 
@@ -34,6 +34,7 @@ interface ExecuteOptions<V = Record<string, unknown>> {
34
34
  operation: Operation
35
35
  variables: V
36
36
  query?: string | null
37
+ locale?: string
37
38
  }
38
39
 
39
40
  const persistedQueries = new Map()
@@ -98,7 +99,7 @@ export const execute = async <V extends Maybe<{ [key: string]: unknown }>, D>(
98
99
  cookies: Map<string, Record<string, string>> | null
99
100
  }
100
101
  }> => {
101
- const { operation, variables, query: maybeQuery } = options
102
+ const { operation, variables, query: maybeQuery, locale } = options
102
103
  const { operationHash, operationName } = operation['__meta__']
103
104
 
104
105
  if (!persistedQueries.size) {
@@ -127,6 +128,14 @@ export const execute = async <V extends Maybe<{ [key: string]: unknown }>, D>(
127
128
 
128
129
  const contextValue = await contextFactory(envelopContext)
129
130
 
131
+ // Override the store-default locale with the per-request locale (if any) so
132
+ // localization-aware resolvers read it from ctx.storage.locale. Resolvers that
133
+ // carry their own locale (e.g. product/search via locator facets) still
134
+ // override this default inside their own resolution.
135
+ if (locale) {
136
+ contextValue.storage.locale = locale
137
+ }
138
+
130
139
  const { data, errors } = (await run({
131
140
  schema,
132
141
  document: parse(query),
@@ -47,27 +47,6 @@ vi.mock(
47
47
  })
48
48
  )
49
49
 
50
- // Keep the real store config (the cart SDK reads `storeConfig.cart` at module
51
- // init) and only override the opt-in flag, which we toggle per test.
52
- const recommendationsFlag = vi.hoisted(() => ({ enabled: true }))
53
- vi.mock('discovery.config', async (original) => {
54
- const actual = (await original()) as { default?: Record<string, unknown> }
55
- const base = (actual.default ?? actual) as Record<string, unknown>
56
- const experimental = (base.experimental ?? {}) as Record<string, unknown>
57
-
58
- return {
59
- default: {
60
- ...base,
61
- experimental: {
62
- ...experimental,
63
- get enableRecommendations() {
64
- return recommendationsFlag.enabled
65
- },
66
- },
67
- },
68
- }
69
- })
70
-
71
50
  import { RecommendationShelf } from 'src/components/sections/RecommendationShelf/RecommendationShelf'
72
51
 
73
52
  const CAMPAIGN_VRN = 'vrn:recommendations:acc:rec-top-items-v2:campaign-1'
@@ -91,7 +70,6 @@ beforeEach(() => {
91
70
  loading: false,
92
71
  })
93
72
  useRecommendationUserId.mockReturnValue('user-1')
94
- recommendationsFlag.enabled = true
95
73
  })
96
74
 
97
75
  afterEach(() => {
@@ -99,7 +77,7 @@ afterEach(() => {
99
77
  })
100
78
 
101
79
  describe('RecommendationShelf', () => {
102
- it('renders a product card per recommended product', async () => {
80
+ it('renders a product card per recommended product when enabled', async () => {
103
81
  useRecommendations.mockReturnValue({
104
82
  data: recommendationData,
105
83
  isLoading: false,
@@ -107,7 +85,7 @@ describe('RecommendationShelf', () => {
107
85
  })
108
86
 
109
87
  const { getAllByTestId, getByText } = render(
110
- <RecommendationShelf campaignVrn={CAMPAIGN_VRN} />
88
+ <RecommendationShelf campaignVrn={CAMPAIGN_VRN} enableRecommendations />
111
89
  )
112
90
 
113
91
  await waitFor(() => {
@@ -126,7 +104,9 @@ describe('RecommendationShelf', () => {
126
104
  error: null,
127
105
  })
128
106
 
129
- render(<RecommendationShelf campaignVrn={CROSS_SELL_VRN} />)
107
+ render(
108
+ <RecommendationShelf campaignVrn={CROSS_SELL_VRN} enableRecommendations />
109
+ )
130
110
 
131
111
  await waitFor(() => {
132
112
  const lastArgs = useRecommendations.mock.calls.at(-1)?.[0]
@@ -146,7 +126,11 @@ describe('RecommendationShelf', () => {
146
126
  })
147
127
 
148
128
  const { getByText } = render(
149
- <RecommendationShelf campaignVrn={CAMPAIGN_VRN} title="Custom title" />
129
+ <RecommendationShelf
130
+ campaignVrn={CAMPAIGN_VRN}
131
+ enableRecommendations
132
+ title="Custom title"
133
+ />
150
134
  )
151
135
 
152
136
  await waitFor(() => {
@@ -162,6 +146,25 @@ describe('RecommendationShelf', () => {
162
146
  error: null,
163
147
  })
164
148
 
149
+ const { container, queryByTestId } = render(
150
+ <RecommendationShelf campaignVrn={CAMPAIGN_VRN} enableRecommendations />
151
+ )
152
+
153
+ await waitFor(() => {
154
+ const lastArgs = useRecommendations.mock.calls.at(-1)?.[0]
155
+ expect(lastArgs).toBeNull()
156
+ })
157
+ expect(queryByTestId('product-card')).toBeNull()
158
+ expect(container).toBeEmptyDOMElement()
159
+ })
160
+
161
+ it('skips the fetch when recommendations are disabled (default)', async () => {
162
+ useRecommendations.mockReturnValue({
163
+ data: undefined,
164
+ isLoading: false,
165
+ error: null,
166
+ })
167
+
165
168
  const { container, queryByTestId } = render(
166
169
  <RecommendationShelf campaignVrn={CAMPAIGN_VRN} />
167
170
  )
@@ -182,7 +185,7 @@ describe('RecommendationShelf', () => {
182
185
  })
183
186
 
184
187
  const { container } = render(
185
- <RecommendationShelf campaignVrn={CAMPAIGN_VRN} />
188
+ <RecommendationShelf campaignVrn={CAMPAIGN_VRN} enableRecommendations />
186
189
  )
187
190
 
188
191
  expect(container).toBeEmptyDOMElement()
@@ -196,7 +199,7 @@ describe('RecommendationShelf', () => {
196
199
  })
197
200
 
198
201
  const { container, queryByTestId } = render(
199
- <RecommendationShelf campaignVrn={CAMPAIGN_VRN} />
202
+ <RecommendationShelf campaignVrn={CAMPAIGN_VRN} enableRecommendations />
200
203
  )
201
204
 
202
205
  expect(queryByTestId('product-card')).toBeNull()
@@ -211,25 +214,9 @@ describe('RecommendationShelf', () => {
211
214
  })
212
215
 
213
216
  const { getByTestId } = render(
214
- <RecommendationShelf campaignVrn={CAMPAIGN_VRN} />
217
+ <RecommendationShelf campaignVrn={CAMPAIGN_VRN} enableRecommendations />
215
218
  )
216
219
 
217
220
  expect(getByTestId('skeleton').getAttribute('data-loading')).toBe('true')
218
221
  })
219
-
220
- it('skips the fetch when recommendations are disabled', async () => {
221
- recommendationsFlag.enabled = false
222
- useRecommendations.mockReturnValue({
223
- data: undefined,
224
- isLoading: false,
225
- error: null,
226
- })
227
-
228
- render(<RecommendationShelf campaignVrn={CROSS_SELL_VRN} />)
229
-
230
- await waitFor(() => {
231
- const lastArgs = useRecommendations.mock.calls.at(-1)?.[0]
232
- expect(lastArgs).toBeNull()
233
- })
234
- })
235
222
  })
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { hasEnabledRecommendationShelf } from 'src/sdk/analytics/utils/hasEnabledRecommendationShelf'
4
+
5
+ describe('hasEnabledRecommendationShelf', () => {
6
+ it('returns false for empty or unrelated page props', () => {
7
+ expect(hasEnabledRecommendationShelf(undefined)).toBe(false)
8
+ expect(hasEnabledRecommendationShelf({})).toBe(false)
9
+ expect(
10
+ hasEnabledRecommendationShelf({
11
+ sections: [{ name: 'ProductShelf', data: {} }],
12
+ })
13
+ ).toBe(false)
14
+ })
15
+
16
+ it('returns true when a page section enables recommendations', () => {
17
+ expect(
18
+ hasEnabledRecommendationShelf({
19
+ sections: [
20
+ {
21
+ name: 'RecommendationShelf',
22
+ data: { enableRecommendations: true, campaignVrn: 'vrn:x' },
23
+ },
24
+ ],
25
+ })
26
+ ).toBe(true)
27
+ })
28
+
29
+ it('returns true when a global section enables recommendations', () => {
30
+ expect(
31
+ hasEnabledRecommendationShelf({
32
+ globalSections: {
33
+ sections: [
34
+ {
35
+ $componentKey: 'RecommendationShelf',
36
+ data: { enableRecommendations: true },
37
+ },
38
+ ],
39
+ },
40
+ })
41
+ ).toBe(true)
42
+ })
43
+
44
+ it('returns true when page.sections enables recommendations', () => {
45
+ expect(
46
+ hasEnabledRecommendationShelf({
47
+ page: {
48
+ sections: [
49
+ {
50
+ name: 'RecommendationShelf',
51
+ data: { enableRecommendations: true },
52
+ },
53
+ ],
54
+ },
55
+ })
56
+ ).toBe(true)
57
+ })
58
+
59
+ it('returns false when the shelf is present but enableRecommendations is false', () => {
60
+ expect(
61
+ hasEnabledRecommendationShelf({
62
+ sections: [
63
+ {
64
+ name: 'RecommendationShelf',
65
+ data: { enableRecommendations: false },
66
+ },
67
+ ],
68
+ })
69
+ ).toBe(false)
70
+ })
71
+
72
+ it('returns true when at least one of multiple shelves enables recommendations', () => {
73
+ expect(
74
+ hasEnabledRecommendationShelf({
75
+ sections: [
76
+ {
77
+ name: 'RecommendationShelf',
78
+ data: { enableRecommendations: false },
79
+ },
80
+ {
81
+ name: 'RecommendationShelf',
82
+ data: { enableRecommendations: true },
83
+ },
84
+ ],
85
+ })
86
+ ).toBe(true)
87
+ })
88
+ })