@faststore/core 4.5.0-dev.3 → 4.5.0-dev.5

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.
@@ -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
 
@@ -5,7 +5,7 @@ import { fetcher } from './fetcher'
5
5
  import { bestOfferFirst } from './sort'
6
6
  export { getUrl as getOfferUrl } from './fetcher'
7
7
 
8
- const ERROR_DATA = { offers: {}, isValidating: false }
8
+ const ERROR_DATA = { offers: {}, priceToken: null, isValidating: false }
9
9
 
10
10
  export function useOffer(args: { skuId: string }) {
11
11
  const { data, error, isValidating } = useSWR(args.skuId, fetcher)
@@ -41,5 +41,10 @@ export function useOffer(args: { skuId: string }) {
41
41
 
42
42
  const offers = aggregateOffer(sellers)
43
43
 
44
- return { offers, isValidating }
44
+ // Fresh Pricing Fallback signed price token from the best seller. Fetched
45
+ // directly from Intelligent Search on the client, so it bypasses the SSG/CDN
46
+ // caches and respects the token's short (~30min) validity window.
47
+ const priceToken = sellers[0]?.PriceToken ?? null
48
+
49
+ return { offers, priceToken, isValidating }
45
50
  }
@@ -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),
@@ -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
  })