@faststore/core 4.4.0-dev.3 → 4.4.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 +48 -44
- package/CHANGELOG.md +6 -0
- package/package.json +6 -6
- package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerBody.tsx +1 -1
- package/src/components/account/Menu/Menu.tsx +2 -1
- package/src/components/account/orders/ListOrders/FilterSlider/FilterSlider.tsx +5 -1
- package/src/components/account/orders/ListOrders/ListOrders.tsx +8 -3
- package/src/components/account/orders/ListOrders/ListOrdersTable/ListOrdersTable.tsx +6 -3
- package/src/components/account/orders/ListOrders/SelectedTags/SelectedTags.tsx +2 -2
- package/src/components/account/orders/OrderDetails/OrderDetailsHeader.tsx +6 -1
- package/src/components/account/utils/useFormatPrice.ts +3 -2
- package/src/components/ui/Link/Link.tsx +6 -0
- package/src/experimental/index.ts +3 -0
- package/src/experimental/myAccountServerSideProps.ts +6 -2
- package/src/pages/pvt/account/404.tsx +5 -1
- package/src/pages/pvt/account/[...unknown].tsx +6 -2
- package/src/pages/pvt/account/index.tsx +5 -4
- package/src/pages/pvt/account/profile.tsx +2 -1
- package/src/pages/pvt/account/user-details.tsx +4 -3
- package/src/proxy.ts +36 -0
- package/src/sdk/session/index.ts +9 -6
- package/src/sdk/session/initialSession.ts +31 -46
- package/src/utils/localization/localizeRedirectDestination.ts +66 -0
- package/src/utils/localization/withLocaleValidation.ts +0 -6
- package/test/proxy.localization.test.ts +122 -0
- package/test/sdk/session/reconcileSessionLocale.browser.test.ts +199 -0
- package/test/sdk/session/reconcileSessionLocale.disabled.browser.test.ts +38 -0
- package/test/utils/localization/localizeRedirectDestination.test.ts +176 -0
- package/test/sdk/session/installLocaleCorrector.browser.test.ts +0 -208
package/src/proxy.ts
CHANGED
|
@@ -48,6 +48,29 @@ const validLocales = new Set(
|
|
|
48
48
|
Object.keys(storeConfig.localization?.locales || {})
|
|
49
49
|
)
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Resolves the locale Next.js already assigned to this request (path prefix or
|
|
53
|
+
* domain routing). For _next/data routes the locale is embedded in the path.
|
|
54
|
+
*/
|
|
55
|
+
function getActiveLocaleFromRequest(request: NextRequest): string | undefined {
|
|
56
|
+
const { pathname } = request.nextUrl
|
|
57
|
+
const dataMatch = pathname.match(DATA_ROUTE_RE)
|
|
58
|
+
|
|
59
|
+
if (dataMatch) {
|
|
60
|
+
const localeFromData = dataMatch[1]
|
|
61
|
+
if (validLocales.has(localeFromData)) {
|
|
62
|
+
return localeFromData
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const { locale } = request.nextUrl
|
|
67
|
+
if (locale && validLocales.has(locale)) {
|
|
68
|
+
return locale
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
51
74
|
/**
|
|
52
75
|
* Rewrites the request to the subdomain locale.
|
|
53
76
|
* Without this, getStaticProps receives the wrong locale on subdomain domains
|
|
@@ -61,6 +84,19 @@ function rewriteSubdomainRequest(
|
|
|
61
84
|
request: NextRequest,
|
|
62
85
|
locale: string
|
|
63
86
|
): NextResponse | null {
|
|
87
|
+
const activeLocale = getActiveLocaleFromRequest(request)
|
|
88
|
+
|
|
89
|
+
// Root bindings share a hostname with path-prefixed locales (e.g. /pt-BR).
|
|
90
|
+
// When Next already resolved a different locale, skip injecting the subdomain
|
|
91
|
+
// locale to avoid double-prefix rewrites like /pt-BR/en-US/...
|
|
92
|
+
if (
|
|
93
|
+
activeLocale &&
|
|
94
|
+
validLocales.has(activeLocale) &&
|
|
95
|
+
activeLocale !== locale
|
|
96
|
+
) {
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
|
|
64
100
|
const { pathname, search } = request.nextUrl
|
|
65
101
|
|
|
66
102
|
const dataMatch = pathname.match(DATA_ROUTE_RE)
|
package/src/sdk/session/index.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { cartStore } from '../cart'
|
|
|
19
19
|
import { request } from '../graphql/request'
|
|
20
20
|
import { createValidationStore, useStore } from '../useStore'
|
|
21
21
|
import { getPostalCode } from '../userLocation/index'
|
|
22
|
-
import { getInitialSession,
|
|
22
|
+
import { getInitialSession, reconcileSessionLocale } from './initialSession'
|
|
23
23
|
import { RELOAD_AFTER_LOGOUT_KEY, SESSION_READY_KEY } from './storageKeys'
|
|
24
24
|
|
|
25
25
|
const isReloadAfterLogoutPending = (): boolean => {
|
|
@@ -195,11 +195,14 @@ const [validationStore, onValidate, hasValidatedStore] =
|
|
|
195
195
|
createValidationStore(validateSession)
|
|
196
196
|
|
|
197
197
|
const urlAwareInitialSession = getInitialSession()
|
|
198
|
-
const defaultStore = createSessionStore(
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
198
|
+
const defaultStore = createSessionStore(
|
|
199
|
+
urlAwareInitialSession,
|
|
200
|
+
onValidate,
|
|
201
|
+
'fs::session',
|
|
202
|
+
storeConfig.localization?.enabled
|
|
203
|
+
? { reconcile: reconcileSessionLocale }
|
|
204
|
+
: undefined
|
|
205
|
+
)
|
|
203
206
|
|
|
204
207
|
export const sessionStore = {
|
|
205
208
|
...defaultStore,
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import type { Session
|
|
2
|
-
import deepEqual from 'fast-deep-equal'
|
|
1
|
+
import type { Session } from '@faststore/sdk'
|
|
3
2
|
|
|
4
3
|
import storeConfig from '../../../discovery.config'
|
|
5
4
|
import { getSettings } from '../localization/settings'
|
|
@@ -31,10 +30,10 @@ function safeParseChannel(
|
|
|
31
30
|
* useLocalizationConfig detect the URL locale and correct the session —
|
|
32
31
|
* causing a second, redundant pair of validateSession + validateCart calls.
|
|
33
32
|
*
|
|
34
|
-
* By seeding the initial value with the URL locale we ensure
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
33
|
+
* By seeding the initial value with the URL locale we ensure readInitial()
|
|
34
|
+
* already contains the correct locale (fixes first-visit). IDB payloads that
|
|
35
|
+
* carry an outdated locale are corrected by `reconcileSessionLocale` (wired as
|
|
36
|
+
* the `persisted` reconcile seam in `createSessionStore`).
|
|
38
37
|
*/
|
|
39
38
|
export function getInitialSession(
|
|
40
39
|
defaults: Session = storeConfig.session
|
|
@@ -60,50 +59,36 @@ export function getInitialSession(
|
|
|
60
59
|
}
|
|
61
60
|
|
|
62
61
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
62
|
+
* Reconciles a session payload read from IDB (on hydration and on the
|
|
63
|
+
* `focus`/`visibilitychange` cross-tab sync) with the URL-derived localization
|
|
64
|
+
* settings, forcing `locale`/`currency`/`salesChannel` to follow the URL while
|
|
65
|
+
* preserving every other field from the persisted payload.
|
|
66
66
|
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* The base store invokes `cancelations.forEach(cancel)` which flips the
|
|
72
|
-
* `cancel` flag of the just-enqueued optimistic handler — preventing the
|
|
73
|
-
* stale `validateSession` from ever hitting the network — and broadcasts
|
|
74
|
-
* again, enqueueing a new optimistic handler with the corrected value.
|
|
67
|
+
* This is the SDK `persisted` reconcile seam (see `CreateStoreOptions`): the
|
|
68
|
+
* URL is the single source of truth for localization, so neither a stale IDB
|
|
69
|
+
* value nor a `validateSession` round-trip (which receives no locale hint in
|
|
70
|
+
* `search` on path-based bindings) can flip the locale back to the default.
|
|
75
71
|
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
* @returns Unsubscribe function (mainly useful in tests).
|
|
72
|
+
* When localization is disabled or there is no `window` (SSR), the payload is
|
|
73
|
+
* returned untouched.
|
|
80
74
|
*/
|
|
81
|
-
export function
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
let corrected = false
|
|
86
|
-
|
|
87
|
-
return store.subscribe((session: Session) => {
|
|
88
|
-
if (corrected) return
|
|
89
|
-
corrected = true
|
|
90
|
-
|
|
91
|
-
if (
|
|
92
|
-
session.locale === initialSession.locale &&
|
|
93
|
-
deepEqual(session.currency, initialSession.currency) &&
|
|
94
|
-
session.channel === initialSession.channel
|
|
95
|
-
) {
|
|
96
|
-
return
|
|
97
|
-
}
|
|
75
|
+
export function reconcileSessionLocale(fromIDB: Session): Session {
|
|
76
|
+
if (!storeConfig.localization?.enabled || typeof window === 'undefined') {
|
|
77
|
+
return fromIDB
|
|
78
|
+
}
|
|
98
79
|
|
|
99
|
-
|
|
100
|
-
|
|
80
|
+
try {
|
|
81
|
+
const settings = getSettings()
|
|
82
|
+
const channel = safeParseChannel(fromIDB.channel)
|
|
83
|
+
channel.salesChannel = settings.salesChannel
|
|
101
84
|
|
|
102
|
-
|
|
103
|
-
...
|
|
104
|
-
locale:
|
|
105
|
-
currency:
|
|
85
|
+
return {
|
|
86
|
+
...fromIDB,
|
|
87
|
+
locale: settings.locale,
|
|
88
|
+
currency: settings.currency,
|
|
106
89
|
channel: JSON.stringify(channel),
|
|
107
|
-
}
|
|
108
|
-
}
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
return fromIDB
|
|
93
|
+
}
|
|
109
94
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import storeConfig from 'discovery.config'
|
|
2
|
+
import { addCustomPathPrefix, extractCustomPathPrefix } from './bindingPaths'
|
|
3
|
+
|
|
4
|
+
type RedirectLocaleContext = {
|
|
5
|
+
locale?: string
|
|
6
|
+
defaultLocale?: string
|
|
7
|
+
/**
|
|
8
|
+
* Present in `getServerSideProps` (absent in `getStaticProps`). Its `url`
|
|
9
|
+
* keeps the original, pre-proxy-rewrite path — including custom binding
|
|
10
|
+
* prefixes like `/europe/it` — which `resolvedUrl` does not.
|
|
11
|
+
*/
|
|
12
|
+
req?: { url?: string }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Localizes an internal redirect `destination` for `getServerSideProps` /
|
|
17
|
+
* `getStaticProps`, mirroring the client-side `useLink.resolveLink` logic.
|
|
18
|
+
*
|
|
19
|
+
* Next.js (Pages Router) does NOT add the active locale to a
|
|
20
|
+
* `redirect.destination`, so without this the redirect drops to the default
|
|
21
|
+
* binding. Resolution order (same as `resolveLink`):
|
|
22
|
+
*
|
|
23
|
+
* 1. If the request is on a custom binding path (e.g. `/europe/it`, `/emea-eur`),
|
|
24
|
+
* preserve that prefix via `addCustomPathPrefix` — the `proxy.ts` rewrite maps
|
|
25
|
+
* the prefixed URL back to the right locale, keeping the user on the vanity path.
|
|
26
|
+
* 2. Otherwise, if on a non-default Next locale, prepend `/${locale}`.
|
|
27
|
+
* 3. Otherwise (default locale / subdomain binding), return as-is.
|
|
28
|
+
*
|
|
29
|
+
* The custom-path branch requires `context.req.url`; in `getStaticProps` (no
|
|
30
|
+
* request) only the locale branch applies.
|
|
31
|
+
*/
|
|
32
|
+
export function localizeRedirectDestination(
|
|
33
|
+
destination: string,
|
|
34
|
+
context: RedirectLocaleContext
|
|
35
|
+
): string {
|
|
36
|
+
if (!storeConfig.localization?.enabled) {
|
|
37
|
+
return destination
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Only internal, absolute paths are localized (skip external URLs and
|
|
41
|
+
// protocol-relative `//host` destinations).
|
|
42
|
+
if (!destination.startsWith('/') || destination.startsWith('//')) {
|
|
43
|
+
return destination
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const requestUrl = context.req?.url ?? ''
|
|
47
|
+
|
|
48
|
+
// 1. Custom binding path (e.g. `/europe/it`) — preserve it, matching resolveLink.
|
|
49
|
+
if (extractCustomPathPrefix(requestUrl) !== null) {
|
|
50
|
+
return addCustomPathPrefix(destination, requestUrl)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 2. Non-default Next locale (e.g. `/it-IT`) — Next won't add it on redirects.
|
|
54
|
+
const { locale, defaultLocale } = context
|
|
55
|
+
|
|
56
|
+
if (!locale || !defaultLocale || locale === defaultLocale) {
|
|
57
|
+
return destination
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Already prefixed (defensive — avoids `/it-IT/it-IT/...`).
|
|
61
|
+
if (destination === `/${locale}` || destination.startsWith(`/${locale}/`)) {
|
|
62
|
+
return destination
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return `/${locale}${destination}`
|
|
66
|
+
}
|
|
@@ -23,12 +23,6 @@ export function withLocaleValidationSSR<P extends Record<string, any>>(
|
|
|
23
23
|
return getServerSidePropsFn(context)
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
// Skip locale validation for private/authenticated routes
|
|
27
|
-
const pathname = req.url?.split('?')[0] ?? ''
|
|
28
|
-
if (pathname.startsWith('/pvt/')) {
|
|
29
|
-
return getServerSidePropsFn(context)
|
|
30
|
-
}
|
|
31
|
-
|
|
32
26
|
const isValid = validateLocaleForHostname(hostname, locale)
|
|
33
27
|
|
|
34
28
|
if (!isValid) {
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
const checkStoreProtectionMock = vi.hoisted(() => vi.fn())
|
|
5
|
+
const getSubdomainBindingsMock = vi.hoisted(() => vi.fn())
|
|
6
|
+
const getCustomPathsFromBindingsMock = vi.hoisted(() => vi.fn())
|
|
7
|
+
const isValidLocaleMock = vi.hoisted(() => vi.fn())
|
|
8
|
+
|
|
9
|
+
vi.mock('discovery.config', () => ({
|
|
10
|
+
__esModule: true,
|
|
11
|
+
default: {
|
|
12
|
+
localization: {
|
|
13
|
+
enabled: true,
|
|
14
|
+
locales: {
|
|
15
|
+
'en-US': { bindings: [] },
|
|
16
|
+
'pt-BR': { bindings: [] },
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
vi.mock('../src/server/password-protection-service', () => ({
|
|
23
|
+
PasswordProtectionService: class {
|
|
24
|
+
checkStoreProtection = checkStoreProtectionMock
|
|
25
|
+
},
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
vi.mock('src/utils/localization/bindingPaths', () => ({
|
|
29
|
+
getCustomPathsFromBindings: getCustomPathsFromBindingsMock,
|
|
30
|
+
getSubdomainBindings: getSubdomainBindingsMock,
|
|
31
|
+
isValidLocale: isValidLocaleMock,
|
|
32
|
+
}))
|
|
33
|
+
|
|
34
|
+
function localizedRequest(
|
|
35
|
+
pathname: string,
|
|
36
|
+
options?: { locale?: string; host?: string }
|
|
37
|
+
): NextRequest {
|
|
38
|
+
const host = options?.host ?? 'localhost:3000'
|
|
39
|
+
const req = new NextRequest(`http://${host}${pathname}`, {
|
|
40
|
+
headers: { host },
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
if (options?.locale) {
|
|
44
|
+
Object.defineProperty(req.nextUrl, 'locale', {
|
|
45
|
+
value: options.locale,
|
|
46
|
+
writable: true,
|
|
47
|
+
configurable: true,
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return req
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('proxy localization rewrite', () => {
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
vi.clearAllMocks()
|
|
57
|
+
vi.resetModules()
|
|
58
|
+
checkStoreProtectionMock.mockResolvedValue({
|
|
59
|
+
response: NextResponse.next(),
|
|
60
|
+
})
|
|
61
|
+
getSubdomainBindingsMock.mockReturnValue([
|
|
62
|
+
{ hostname: 'localhost', locale: 'en-US' },
|
|
63
|
+
])
|
|
64
|
+
getCustomPathsFromBindingsMock.mockReturnValue([])
|
|
65
|
+
isValidLocaleMock.mockImplementation((locale: string) =>
|
|
66
|
+
['en-US', 'pt-BR'].includes(locale)
|
|
67
|
+
)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('skips subdomain rewrite when request locale differs from root binding locale', async () => {
|
|
71
|
+
const { proxy } = await import('../src/proxy')
|
|
72
|
+
const response = (await proxy(
|
|
73
|
+
localizedRequest('/pvt/account/profile', { locale: 'pt-BR' })
|
|
74
|
+
)) as NextResponse
|
|
75
|
+
|
|
76
|
+
expect(response.headers.get('x-middleware-rewrite')).toBeNull()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('rewrites when request locale matches subdomain binding locale', async () => {
|
|
80
|
+
const { proxy } = await import('../src/proxy')
|
|
81
|
+
const response = (await proxy(
|
|
82
|
+
localizedRequest('/pvt/account/profile', { locale: 'en-US' })
|
|
83
|
+
)) as NextResponse
|
|
84
|
+
|
|
85
|
+
const rewrite = response.headers.get('x-middleware-rewrite')
|
|
86
|
+
expect(rewrite).toContain('/en-US/pvt/account/profile')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('rewrites paths without a locale segment when no active locale is set', async () => {
|
|
90
|
+
const { proxy } = await import('../src/proxy')
|
|
91
|
+
const response = (await proxy(
|
|
92
|
+
localizedRequest('/pvt/account/profile')
|
|
93
|
+
)) as NextResponse
|
|
94
|
+
|
|
95
|
+
const rewrite = response.headers.get('x-middleware-rewrite')
|
|
96
|
+
expect(rewrite).toContain('/en-US/pvt/account/profile')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('skips _next/data rewrite when data locale differs from subdomain locale', async () => {
|
|
100
|
+
const { proxy } = await import('../src/proxy')
|
|
101
|
+
const response = (await proxy(
|
|
102
|
+
localizedRequest(
|
|
103
|
+
'/_next/data/development/pt-BR/pvt/account/profile.json',
|
|
104
|
+
{ locale: 'pt-BR' }
|
|
105
|
+
)
|
|
106
|
+
)) as NextResponse
|
|
107
|
+
|
|
108
|
+
expect(response.headers.get('x-middleware-rewrite')).toBeNull()
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('does not rewrite _next/data when data locale matches subdomain locale', async () => {
|
|
112
|
+
const { proxy } = await import('../src/proxy')
|
|
113
|
+
const response = (await proxy(
|
|
114
|
+
localizedRequest(
|
|
115
|
+
'/_next/data/development/en-US/pvt/account/profile.json',
|
|
116
|
+
{ locale: 'en-US' }
|
|
117
|
+
)
|
|
118
|
+
)) as NextResponse
|
|
119
|
+
|
|
120
|
+
expect(response.headers.get('x-middleware-rewrite')).toBeNull()
|
|
121
|
+
})
|
|
122
|
+
})
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the localization reconcile seam.
|
|
3
|
+
*
|
|
4
|
+
* `reconcileSessionLocale` is wired into the SDK `persisted` middleware (via
|
|
5
|
+
* `createSessionStore({ reconcile })`) and runs every time a payload flows from
|
|
6
|
+
* IDB into memory — both on hydration and on the `focus`/`visibilitychange`
|
|
7
|
+
* cross-tab sync. It forces `locale`/`currency`/`salesChannel` to follow the
|
|
8
|
+
* URL (the single source of truth) while preserving every other field from the
|
|
9
|
+
* persisted payload. This replaces the previous one-shot `installLocaleCorrector`
|
|
10
|
+
* subscriber, which only fixed memory on the first hydration and never covered
|
|
11
|
+
* the focus sync.
|
|
12
|
+
*/
|
|
13
|
+
import type { Session } from '@faststore/sdk'
|
|
14
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
15
|
+
|
|
16
|
+
vi.mock(import('../../../discovery.config.js'), async (original) => ({
|
|
17
|
+
default: {
|
|
18
|
+
...((await original()).default ?? (await original())),
|
|
19
|
+
localization: {
|
|
20
|
+
enabled: true,
|
|
21
|
+
defaultLocale: 'pt-BR',
|
|
22
|
+
regions: {
|
|
23
|
+
BR: {
|
|
24
|
+
code: 'BR',
|
|
25
|
+
name: 'Brazil',
|
|
26
|
+
dateFormat: 'DD/MM/YYYY',
|
|
27
|
+
timeFormat: '24h',
|
|
28
|
+
timeFormatMask: 'HH:mm',
|
|
29
|
+
unitSystem: 'metric',
|
|
30
|
+
defaultTimezone: 'GMT-3',
|
|
31
|
+
},
|
|
32
|
+
US: {
|
|
33
|
+
code: 'US',
|
|
34
|
+
name: 'United States',
|
|
35
|
+
dateFormat: 'MM/DD/YYYY',
|
|
36
|
+
timeFormat: '12h',
|
|
37
|
+
timeFormatMask: 'hh:mm a',
|
|
38
|
+
unitSystem: 'imperial',
|
|
39
|
+
defaultTimezone: 'GMT-5',
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
locales: {
|
|
43
|
+
'pt-BR': {
|
|
44
|
+
code: 'pt-BR',
|
|
45
|
+
name: 'Português',
|
|
46
|
+
languageCode: 'pt',
|
|
47
|
+
languageName: 'Portuguese',
|
|
48
|
+
script: 'Latn',
|
|
49
|
+
textDirection: 'ltr',
|
|
50
|
+
regionCode: 'BR',
|
|
51
|
+
bindings: [
|
|
52
|
+
{
|
|
53
|
+
currencyCode: 'BRL',
|
|
54
|
+
url: 'https://store.example.com/pt-BR',
|
|
55
|
+
salesChannel: '1',
|
|
56
|
+
isDefault: true,
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
'en-US': {
|
|
61
|
+
code: 'en-US',
|
|
62
|
+
name: 'English',
|
|
63
|
+
languageCode: 'en',
|
|
64
|
+
languageName: 'English',
|
|
65
|
+
script: 'Latn',
|
|
66
|
+
textDirection: 'ltr',
|
|
67
|
+
regionCode: 'US',
|
|
68
|
+
bindings: [
|
|
69
|
+
{
|
|
70
|
+
currencyCode: 'USD',
|
|
71
|
+
url: 'https://store.example.com/en-US',
|
|
72
|
+
salesChannel: '2',
|
|
73
|
+
isDefault: false,
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
currencies: {
|
|
79
|
+
BRL: { code: 'BRL', name: 'Brazilian Real', symbol: 'R$' },
|
|
80
|
+
USD: { code: 'USD', name: 'US Dollar', symbol: '$' },
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
}))
|
|
85
|
+
|
|
86
|
+
import { reconcileSessionLocale } from '../../../src/sdk/session/initialSession'
|
|
87
|
+
|
|
88
|
+
const originalLocation = window.location
|
|
89
|
+
|
|
90
|
+
function stubHref(href: string) {
|
|
91
|
+
Object.defineProperty(window, 'location', {
|
|
92
|
+
configurable: true,
|
|
93
|
+
value: { ...originalLocation, href },
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const STALE: Session = {
|
|
98
|
+
locale: 'en-US',
|
|
99
|
+
currency: { code: 'USD', symbol: '$' },
|
|
100
|
+
channel: '{"salesChannel":"2","regionId":"region-xyz"}',
|
|
101
|
+
country: 'USA',
|
|
102
|
+
deliveryMode: null,
|
|
103
|
+
addressType: null,
|
|
104
|
+
city: null,
|
|
105
|
+
postalCode: null,
|
|
106
|
+
geoCoordinates: null,
|
|
107
|
+
b2b: null,
|
|
108
|
+
person: null,
|
|
109
|
+
marketingData: null,
|
|
110
|
+
refreshAfter: null,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
describe('reconcileSessionLocale (browser, localization enabled)', () => {
|
|
114
|
+
afterEach(() => {
|
|
115
|
+
Object.defineProperty(window, 'location', {
|
|
116
|
+
configurable: true,
|
|
117
|
+
value: originalLocation,
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('forces locale/currency from the URL when the IDB payload is stale', () => {
|
|
122
|
+
stubHref('https://store.example.com/pt-BR/pvt/some/path')
|
|
123
|
+
|
|
124
|
+
const result = reconcileSessionLocale(STALE)
|
|
125
|
+
|
|
126
|
+
expect(result.locale).toBe('pt-BR')
|
|
127
|
+
expect(result.currency).toEqual({ code: 'BRL', symbol: 'R$' })
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('preserves unrelated session fields from the payload (e.g. person)', () => {
|
|
131
|
+
stubHref('https://store.example.com/pt-BR/some/path')
|
|
132
|
+
|
|
133
|
+
const payload: Session = {
|
|
134
|
+
...STALE,
|
|
135
|
+
person: { id: 'p1', email: 'a@b.com', givenName: 'A', familyName: 'B' },
|
|
136
|
+
postalCode: '12345',
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const result = reconcileSessionLocale(payload)
|
|
140
|
+
|
|
141
|
+
expect(result.locale).toBe('pt-BR')
|
|
142
|
+
expect(result.postalCode).toBe('12345')
|
|
143
|
+
expect(result.person).toEqual({
|
|
144
|
+
id: 'p1',
|
|
145
|
+
email: 'a@b.com',
|
|
146
|
+
givenName: 'A',
|
|
147
|
+
familyName: 'B',
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('replaces salesChannel inside channel JSON while preserving non-locale channel fields', () => {
|
|
152
|
+
stubHref('https://store.example.com/pt-BR/some/path')
|
|
153
|
+
|
|
154
|
+
const payload: Session = {
|
|
155
|
+
...STALE,
|
|
156
|
+
channel:
|
|
157
|
+
'{"salesChannel":"2","regionId":"region-xyz","customKey":"keep-me"}',
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const result = reconcileSessionLocale(payload)
|
|
161
|
+
const channel = JSON.parse(result.channel ?? '{}')
|
|
162
|
+
|
|
163
|
+
expect(channel.salesChannel).toBe('1')
|
|
164
|
+
expect(channel.regionId).toBe('region-xyz')
|
|
165
|
+
expect(channel.customKey).toBe('keep-me')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('survives a malformed channel JSON in the payload (does not throw, still reconciles)', () => {
|
|
169
|
+
stubHref('https://store.example.com/pt-BR/some/path')
|
|
170
|
+
|
|
171
|
+
const payload: Session = { ...STALE, channel: '{not valid json' }
|
|
172
|
+
|
|
173
|
+
let result: Session | undefined
|
|
174
|
+
expect(() => {
|
|
175
|
+
result = reconcileSessionLocale(payload)
|
|
176
|
+
}).not.toThrow()
|
|
177
|
+
|
|
178
|
+
expect(result?.locale).toBe('pt-BR')
|
|
179
|
+
const channel = JSON.parse(result?.channel ?? '{}')
|
|
180
|
+
expect(channel.salesChannel).toBe('1')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('is idempotent when the payload already matches the URL locale', () => {
|
|
184
|
+
stubHref('https://store.example.com/pt-BR/some/path')
|
|
185
|
+
|
|
186
|
+
const aligned: Session = {
|
|
187
|
+
...STALE,
|
|
188
|
+
locale: 'pt-BR',
|
|
189
|
+
currency: { code: 'BRL', symbol: 'R$' },
|
|
190
|
+
channel: '{"salesChannel":"1","regionId":"region-xyz"}',
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const result = reconcileSessionLocale(aligned)
|
|
194
|
+
|
|
195
|
+
expect(result.locale).toBe('pt-BR')
|
|
196
|
+
expect(result.currency).toEqual({ code: 'BRL', symbol: 'R$' })
|
|
197
|
+
expect(JSON.parse(result.channel ?? '{}').salesChannel).toBe('1')
|
|
198
|
+
})
|
|
199
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When localization is disabled, `reconcileSessionLocale` must be a no-op so
|
|
3
|
+
* the persisted middleware behaves exactly as before (raw IDB payload flows
|
|
4
|
+
* into memory untouched).
|
|
5
|
+
*/
|
|
6
|
+
import type { Session } from '@faststore/sdk'
|
|
7
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
8
|
+
|
|
9
|
+
vi.mock(import('../../../discovery.config.js'), async (original) => ({
|
|
10
|
+
default: {
|
|
11
|
+
...((await original()).default ?? (await original())),
|
|
12
|
+
localization: undefined,
|
|
13
|
+
},
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
import { reconcileSessionLocale } from '../../../src/sdk/session/initialSession'
|
|
17
|
+
|
|
18
|
+
const PAYLOAD: Session = {
|
|
19
|
+
locale: 'en-US',
|
|
20
|
+
currency: { code: 'USD', symbol: '$' },
|
|
21
|
+
channel: '{"salesChannel":"2","regionId":"region-xyz"}',
|
|
22
|
+
country: 'USA',
|
|
23
|
+
deliveryMode: null,
|
|
24
|
+
addressType: null,
|
|
25
|
+
city: null,
|
|
26
|
+
postalCode: null,
|
|
27
|
+
geoCoordinates: null,
|
|
28
|
+
b2b: null,
|
|
29
|
+
person: null,
|
|
30
|
+
marketingData: null,
|
|
31
|
+
refreshAfter: null,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('reconcileSessionLocale (localization disabled)', () => {
|
|
35
|
+
it('returns the payload unchanged', () => {
|
|
36
|
+
expect(reconcileSessionLocale(PAYLOAD)).toBe(PAYLOAD)
|
|
37
|
+
})
|
|
38
|
+
})
|