@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.
- package/.turbo/turbo-generate.log +3 -3
- package/.turbo/turbo-test.log +81 -75
- package/@generated/gql.ts +6 -6
- package/@generated/graphql.ts +46 -7
- package/@generated/persisted-documents.json +2 -2
- package/@generated/schema.graphql +44 -5
- package/CHANGELOG.md +12 -0
- package/cms/faststore/components/cms_component__recommendationshelf.jsonc +7 -1
- package/cms/faststore/schema.json +7 -1
- package/discovery.config.default.js +0 -3
- package/package.json +6 -6
- package/src/Layout.tsx +4 -5
- package/src/components/sections/EmptyState/EmptyState.tsx +4 -3
- package/src/components/sections/RecommendationShelf/README.md +163 -0
- package/src/components/sections/RecommendationShelf/RecommendationShelf.tsx +3 -4
- package/src/components/sections/RecommendationShelf/RecommendationShelf.types.ts +8 -1
- package/src/components/templates/ProductListingPage/ProductListingPage.tsx +43 -37
- package/src/components/ui/Breadcrumb/Breadcrumb.tsx +5 -12
- package/src/components/ui/LocalizationButton/LocalizationButton.tsx +5 -1
- package/src/experimental/index.ts +2 -0
- package/src/pages/[...slug].tsx +5 -0
- package/src/pages/[slug]/p.tsx +63 -11
- package/src/sdk/analytics/hooks/useStartRecommendationSession.ts +38 -18
- package/src/sdk/analytics/utils/hasEnabledRecommendationShelf.ts +49 -0
- package/src/sdk/localization/LocalizedProductContext.tsx +65 -0
- package/src/sdk/localization/useBindingSelector.ts +148 -5
- package/src/server/index.ts +10 -1
- package/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx +32 -45
- package/test/sdk/analytics/hasEnabledRecommendationShelf.test.ts +88 -0
- package/test/sdk/analytics/useStartRecommendationSession.test.ts +90 -15
- 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
|
|
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
|
|
28
|
-
*
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
//
|
|
185
|
-
|
|
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
|
-
`${
|
|
323
|
+
`${globalThis.location.pathname}${globalThis.location.search}${globalThis.location.hash}`
|
|
188
324
|
)
|
|
189
|
-
}, [
|
|
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
|
|
package/src/server/index.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
+
})
|