@faststore/core 4.10.1-dev.5 → 4.10.1-dev.7
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 +169 -157
- package/@generated/graphql.ts +1 -0
- package/@generated/schema.graphql +1 -1
- package/CHANGELOG.md +39 -0
- package/package.json +6 -6
- package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawer.tsx +3 -3
- package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerBody.tsx +5 -7
- package/src/components/account/Drawer/OrganizationSignInButton/OrganizationSignInButton.tsx +4 -1
- package/src/components/account/Layout/Layout.tsx +5 -7
- package/src/components/account/Menu/Menu.tsx +1 -1
- package/src/components/navigation/Navbar/Navbar.tsx +3 -3
- package/src/components/navigation/Navbar/NavbarAccountArea.tsx +5 -5
- package/src/components/navigation/NavbarSlider/NavbarSlider.tsx +3 -3
- package/src/experimental/myAccountCmsServerSideProps.ts +3 -3
- package/src/experimental/myAccountServerSideProps.ts +4 -4
- package/src/pages/[slug]/p.tsx +11 -2
- package/src/pages/api/fs/logout.ts +18 -10
- package/src/pages/api/fs/switch-contract.ts +170 -21
- package/src/pages/api/graphql.ts +4 -3
- package/src/pages/pvt/account/cards/index.tsx +5 -5
- package/src/pages/pvt/account/orders/[id].tsx +5 -5
- package/src/pages/pvt/account/orders/index.tsx +5 -5
- package/src/pages/pvt/account/profile.tsx +5 -5
- package/src/pages/pvt/account/quotes/index.tsx +5 -5
- package/src/pages/pvt/account/security.tsx +5 -5
- package/src/pages/pvt/account/user-details.tsx +6 -6
- package/src/sdk/account/getB2BSessionClaims.ts +1 -1
- package/src/sdk/account/getIsB2BBuyer.ts +28 -0
- package/src/sdk/account/refreshToken.ts +89 -11
- package/src/sdk/account/refreshTokenPaths.ts +25 -0
- package/src/sdk/account/useRefreshToken.ts +10 -5
- package/src/sdk/product/isProductNotFound.ts +13 -0
- package/src/sdk/product/usePageProductsQuery.ts +80 -21
- package/src/sdk/product/useProductNotFoundRedirect.ts +43 -0
- package/src/sdk/product/useProductQuery.ts +29 -0
- package/src/sdk/session/index.ts +4 -4
- package/src/utils/getCheckoutParentDomain.ts +53 -0
- package/src/utils/trustedForwardedHost.ts +18 -0
- package/test/components/account/Layout.test.tsx +1 -1
- package/test/components/account/Menu.browser.test.tsx +7 -5
- package/test/components/account/OrganizationDrawer.browser.test.tsx +13 -13
- package/test/components/account/OrganizationDrawerBody.test.tsx +3 -3
- package/test/components/account/OrganizationSignInButton.browser.test.tsx +19 -4
- package/test/components/navigation/Navbar.browser.test.tsx +3 -3
- package/test/components/navigation/NavbarAccountArea.browser.test.tsx +5 -5
- package/test/components/navigation/NavbarSlider.browser.test.tsx +3 -3
- package/test/experimental/myAccountCmsServerSideProps.test.ts +5 -5
- package/test/pages/api/logout.test.ts +106 -0
- package/test/pages/api/switch-contract.test.ts +443 -15
- package/test/pages/pvt/account/cards/index.test.ts +3 -3
- package/test/sdk/account/getIsB2BBuyer.test.ts +52 -0
- package/test/sdk/account/refreshToken.browser.test.ts +10 -1
- package/test/sdk/account/refreshToken.test.ts +150 -18
- package/test/sdk/account/useRefreshToken.browser.test.ts +4 -1
- package/test/sdk/product/usePageProductsQuery.browser.test.ts +260 -0
- package/test/sdk/product/usePageProductsQuery.deliveryPromise.browser.test.ts +130 -0
- package/test/sdk/product/useProductNotFoundRedirect.browser.test.ts +83 -0
- package/test/sdk/product/useProductQuery.browser.test.ts +154 -0
- package/test/sdk/session/refreshTokenRoute.browser.test.ts +106 -0
- package/test/sdk/session/validateSession.browser.test.ts +4 -1
- package/test/utils/trustedForwardedHost.test.ts +21 -0
- package/src/sdk/account/getIsRepresentative.ts +0 -20
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
import fetch from 'isomorphic-unfetch'
|
|
2
2
|
import { getStoreURL } from 'src/sdk/localization/useLocalizationConfig'
|
|
3
3
|
import { sanitizeHost } from 'src/utils/utilities'
|
|
4
|
+
import {
|
|
5
|
+
AUTHENTICATOR_REFRESH_TOKEN_PATH,
|
|
6
|
+
LEGACY_REFRESH_TOKEN_PATH,
|
|
7
|
+
} from './refreshTokenPaths'
|
|
4
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Covers both refresh routes:
|
|
11
|
+
* - Authenticator: `{ authStatus: 'Success', refreshAfter: 1792717383 }`
|
|
12
|
+
* (epoch seconds).
|
|
13
|
+
* - Legacy VTEX ID: `{ status: 'Success', refreshAfter: '2026-03-26T02:53:39+00:00' }`.
|
|
14
|
+
*/
|
|
5
15
|
export interface RefreshTokenResponse {
|
|
6
16
|
status?: string
|
|
7
|
-
|
|
17
|
+
authStatus?: string
|
|
18
|
+
refreshAfter?: string | number
|
|
8
19
|
}
|
|
9
20
|
|
|
10
21
|
async function fetchWithRetry(
|
|
@@ -25,12 +36,12 @@ async function fetchWithRetry(
|
|
|
25
36
|
return undefined
|
|
26
37
|
}
|
|
27
38
|
|
|
28
|
-
function getRefreshTokenUrl(): string {
|
|
39
|
+
function getRefreshTokenUrl(path: string): string {
|
|
29
40
|
if (globalThis.window !== undefined) {
|
|
30
|
-
return
|
|
41
|
+
return path
|
|
31
42
|
}
|
|
32
43
|
|
|
33
|
-
return `${new URL(getStoreURL()).origin}
|
|
44
|
+
return `${new URL(getStoreURL()).origin}${path}`
|
|
34
45
|
}
|
|
35
46
|
|
|
36
47
|
function getRefreshTokenHeaders(): HeadersInit {
|
|
@@ -45,10 +56,43 @@ function getRefreshTokenHeaders(): HeadersInit {
|
|
|
45
56
|
return headers
|
|
46
57
|
}
|
|
47
58
|
|
|
48
|
-
export const
|
|
49
|
-
RefreshTokenResponse | undefined
|
|
50
|
-
|
|
51
|
-
return
|
|
59
|
+
export const isRefreshTokenSuccessful = (
|
|
60
|
+
result: RefreshTokenResponse | undefined
|
|
61
|
+
): boolean => {
|
|
62
|
+
return (result?.authStatus ?? result?.status)?.toLowerCase?.() === 'success'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Whether the session belongs to a B2B buyer: one with an organizational unit
|
|
67
|
+
* (`unitId`) and a contract (`customerId`), the same rule the API uses to
|
|
68
|
+
* fill `session.b2b`.
|
|
69
|
+
*/
|
|
70
|
+
export const isB2BSession = (
|
|
71
|
+
session:
|
|
72
|
+
| {
|
|
73
|
+
b2b?: {
|
|
74
|
+
unitId?: string | null
|
|
75
|
+
customerId?: string | null
|
|
76
|
+
} | null
|
|
77
|
+
}
|
|
78
|
+
| null
|
|
79
|
+
| undefined
|
|
80
|
+
): boolean => Boolean(session?.b2b?.unitId && session?.b2b?.customerId)
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Refresh route for the session. B2B sessions use the Authenticator route,
|
|
84
|
+
* which keeps the selected contract (`customerId`, `unitId`,
|
|
85
|
+
* `isRepresentative`). Every other session keeps the legacy VTEX ID route, as
|
|
86
|
+
* VTEX ID recommends. There is no fallback between them: like before, a failed
|
|
87
|
+
* refresh logs the shopper out.
|
|
88
|
+
*/
|
|
89
|
+
export const getRefreshTokenPath = (isB2B: boolean): string =>
|
|
90
|
+
isB2B ? AUTHENTICATOR_REFRESH_TOKEN_PATH : LEGACY_REFRESH_TOKEN_PATH
|
|
91
|
+
|
|
92
|
+
export const refreshTokenRequest = async ({
|
|
93
|
+
isB2B = false,
|
|
94
|
+
}: { isB2B?: boolean } = {}): Promise<RefreshTokenResponse | undefined> => {
|
|
95
|
+
return fetchWithRetry(getRefreshTokenUrl(getRefreshTokenPath(isB2B)), {
|
|
52
96
|
credentials: 'include',
|
|
53
97
|
headers: getRefreshTokenHeaders(),
|
|
54
98
|
body: JSON.stringify({}),
|
|
@@ -56,8 +100,42 @@ export const refreshTokenRequest = async (): Promise<
|
|
|
56
100
|
})
|
|
57
101
|
}
|
|
58
102
|
|
|
59
|
-
|
|
103
|
+
/**
|
|
104
|
+
* Normalizes `refreshAfter` to epoch seconds as a string, the format stored in
|
|
105
|
+
* the session. Accepts epoch seconds (number or numeric string) and ISO dates.
|
|
106
|
+
* Returns `null` when the value is missing or unparseable.
|
|
107
|
+
*/
|
|
108
|
+
export const getRefreshAfterSeconds = (
|
|
60
109
|
result: RefreshTokenResponse | undefined
|
|
61
|
-
):
|
|
62
|
-
|
|
110
|
+
): string | null => {
|
|
111
|
+
const value = result?.refreshAfter
|
|
112
|
+
|
|
113
|
+
if (value === undefined || value === null || value === '') {
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const seconds =
|
|
118
|
+
typeof value === 'number' || /^\d+$/.test(value)
|
|
119
|
+
? Number(value)
|
|
120
|
+
: new Date(value).getTime() / 1000
|
|
121
|
+
|
|
122
|
+
return Number.isFinite(seconds) ? String(Math.floor(seconds)) : null
|
|
63
123
|
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* How long to wait before refreshing again when a successful refresh carries
|
|
127
|
+
* no usable `refreshAfter`. Keeping the old, already-expired value would make
|
|
128
|
+
* every following session validation call the refresh route again.
|
|
129
|
+
*/
|
|
130
|
+
export const REFRESH_AFTER_FALLBACK_SECONDS = 60 * 60
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* `refreshAfter` to store after a successful refresh: the value from the
|
|
134
|
+
* response, or a short fallback from now when the response has none.
|
|
135
|
+
*/
|
|
136
|
+
export const getNextRefreshAfter = (
|
|
137
|
+
result: RefreshTokenResponse | undefined,
|
|
138
|
+
nowSeconds = Math.floor(Date.now() / 1000)
|
|
139
|
+
): string =>
|
|
140
|
+
getRefreshAfterSeconds(result) ??
|
|
141
|
+
String(nowSeconds + REFRESH_AFTER_FALLBACK_SECONDS)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VTEX ID refresh route. The recommended route for every non-B2B session.
|
|
3
|
+
* For B2B it drops the contract claims set by `switch-properties` and also
|
|
4
|
+
* writes the auth cookie on the parent domain.
|
|
5
|
+
*/
|
|
6
|
+
export const LEGACY_REFRESH_TOKEN_PATH = '/api/vtexid/refreshtoken/webstore'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Authenticator refresh route, recommended for the B2B flow only. It keeps the
|
|
10
|
+
* contract claims (`customerId`, `unitId`, `isRepresentative`) and writes the
|
|
11
|
+
* auth cookie only on the request host.
|
|
12
|
+
*/
|
|
13
|
+
export const AUTHENTICATOR_REFRESH_TOKEN_PATH =
|
|
14
|
+
'/api/authenticator/v1/refreshtoken/webstore'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Every path `vid_rt` can be scoped to. VTEX ID writes it only on the legacy
|
|
18
|
+
* path; Authenticator writes it on all three. The browser only sends it to
|
|
19
|
+
* these paths, so it never reaches any other route.
|
|
20
|
+
*/
|
|
21
|
+
export const VID_RT_PATHS = [
|
|
22
|
+
LEGACY_REFRESH_TOKEN_PATH,
|
|
23
|
+
'/api/authenticator/refreshtoken/webstore',
|
|
24
|
+
AUTHENTICATOR_REFRESH_TOKEN_PATH,
|
|
25
|
+
] as const
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react'
|
|
2
2
|
import { shouldBypassRefreshTokenBrowser } from 'src/utils/shouldBypassRefreshToken'
|
|
3
3
|
import { logoutAndClearSession, sessionStore } from '../session'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
getNextRefreshAfter,
|
|
6
|
+
isB2BSession,
|
|
7
|
+
isRefreshTokenSuccessful,
|
|
8
|
+
refreshTokenRequest,
|
|
9
|
+
} from './refreshToken'
|
|
5
10
|
|
|
6
11
|
export const useRefreshToken = (
|
|
7
12
|
needsRefreshToken?: boolean,
|
|
@@ -26,13 +31,13 @@ export const useRefreshToken = (
|
|
|
26
31
|
const currentSession = sessionStore.read() ?? sessionStore.readInitial()
|
|
27
32
|
|
|
28
33
|
try {
|
|
29
|
-
const result = await refreshTokenRequest(
|
|
34
|
+
const result = await refreshTokenRequest({
|
|
35
|
+
isB2B: isB2BSession(currentSession),
|
|
36
|
+
})
|
|
30
37
|
|
|
31
38
|
if (isRefreshTokenSuccessful(result)) {
|
|
32
39
|
// Update session with new refreshAfter timestamp
|
|
33
|
-
const refreshAfter =
|
|
34
|
-
Math.floor(new Date(result?.refreshAfter).getTime() / 1000)
|
|
35
|
-
)
|
|
40
|
+
const refreshAfter = getNextRefreshAfter(result)
|
|
36
41
|
|
|
37
42
|
sessionStore.set({
|
|
38
43
|
...currentSession,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a product request failed because the product is not available to
|
|
3
|
+
* the shopper. A non-2xx BFF response reaches the client as `{ status }` (see
|
|
4
|
+
* `ParseInvalidRequest`), while a 2xx one keeps the GraphQL `extensions`.
|
|
5
|
+
*/
|
|
6
|
+
export const isProductNotFound = (error: unknown) => {
|
|
7
|
+
const { status, extensions } = (error ?? {}) as {
|
|
8
|
+
status?: number
|
|
9
|
+
extensions?: { type?: string }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return status === 404 || extensions?.type === 'NotFoundError'
|
|
13
|
+
}
|
|
@@ -69,6 +69,69 @@ export const query = gql(`
|
|
|
69
69
|
|
|
70
70
|
const getKey = (object: any) => JSON.stringify(object)
|
|
71
71
|
|
|
72
|
+
const isDeliveryPromiseEnabled = storeConfig.deliveryPromise?.enabled ?? false
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Cache-only keys that scope gallery pages to the shopper context. They make
|
|
76
|
+
* the local cache comparison (and the SWR key) miss when a page was loaded for
|
|
77
|
+
* another context (e.g. the anonymous SSG page). They are not declared by the
|
|
78
|
+
* operation, so the API ignores them, but they are still sent with the
|
|
79
|
+
* variables, which go in the URL because this query is a GET operation.
|
|
80
|
+
* - `_postalCode`: pages are invalidated on region change. Only applied when
|
|
81
|
+
* deliveryPromise is enabled to avoid unnecessary cache fragmentation.
|
|
82
|
+
* - `_contract`: the B2B contract assortment is applied server-side from the
|
|
83
|
+
* session cookie, so it never shows up in the query variables. Keyed off
|
|
84
|
+
* the presence of a `b2b` session (not `customerId`'s value) because
|
|
85
|
+
* `customerId` can resolve to an empty string for a legitimate B2B buyer
|
|
86
|
+
* (see `buildB2bSession` in `@faststore/api`). The fallback chain still
|
|
87
|
+
* forces the re-fetch for them: the real `customerId`, when present, keeps
|
|
88
|
+
* invalidating the cache on contract switch; otherwise the organizational
|
|
89
|
+
* unit (`unitId`) tells buyers of different units apart; a generic marker is
|
|
90
|
+
* the last resort. Since the value ends up in the URL, it is hashed so the
|
|
91
|
+
* raw contract or unit id is not written to access, CDN or RUM logs as is.
|
|
92
|
+
* These ids are not secrets (the session already hands them to the
|
|
93
|
+
* browser), so the hash is cosmetic, not a privacy control: it is unkeyed
|
|
94
|
+
* and a guessable id can be recovered from it. The URL still differs per
|
|
95
|
+
* contract, so a contract page is never served from the anonymous page's
|
|
96
|
+
* cache entry.
|
|
97
|
+
*/
|
|
98
|
+
const getShopperScope = ({
|
|
99
|
+
postalCode,
|
|
100
|
+
contract,
|
|
101
|
+
}: {
|
|
102
|
+
postalCode?: string | null
|
|
103
|
+
contract?: string | null
|
|
104
|
+
}) => ({
|
|
105
|
+
...(isDeliveryPromiseEnabled && { _postalCode: postalCode ?? '' }),
|
|
106
|
+
...(contract && { _contract: hashContractScope(contract) }),
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
/** The id a B2B session is scoped by, or `null` outside a B2B session. */
|
|
110
|
+
const getContractScope = (
|
|
111
|
+
b2b?: { customerId?: string | null; unitId?: string | null } | null
|
|
112
|
+
) => (b2b ? b2b.customerId || b2b.unitId || 'b2b' : null)
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 53-bit string hash (cyrb53): wide enough that two contracts in the same
|
|
116
|
+
* browser never share a cache key in practice, without exposing the raw id.
|
|
117
|
+
*/
|
|
118
|
+
export function hashContractScope(value: string) {
|
|
119
|
+
let h1 = 0xdeadbeef
|
|
120
|
+
let h2 = 0x41c6ce57
|
|
121
|
+
for (const char of value) {
|
|
122
|
+
const code = char.codePointAt(0) ?? 0
|
|
123
|
+
h1 = Math.imul(h1 ^ code, 2654435761)
|
|
124
|
+
h2 = Math.imul(h2 ^ code, 1597334677)
|
|
125
|
+
}
|
|
126
|
+
h1 =
|
|
127
|
+
Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^
|
|
128
|
+
Math.imul(h2 ^ (h2 >>> 13), 3266489909)
|
|
129
|
+
h2 =
|
|
130
|
+
Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^
|
|
131
|
+
Math.imul(h1 ^ (h1 >>> 13), 3266489909)
|
|
132
|
+
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36)
|
|
133
|
+
}
|
|
134
|
+
|
|
72
135
|
interface UseCreateUseGalleryPageProps {
|
|
73
136
|
initialPages: ClientManyProductsQueryWithSearchIdQuery
|
|
74
137
|
serverManyProductsVariables: ClientManyProductsQueryWithSearchIdQueryVariables
|
|
@@ -82,21 +145,17 @@ export const useCreateUseGalleryPage = (
|
|
|
82
145
|
) => {
|
|
83
146
|
const initialPages = params?.initialPages?.search ? [params.initialPages] : []
|
|
84
147
|
|
|
85
|
-
// Seed the cache with the same
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
148
|
+
// Seed the cache with the same key shape that useGalleryPage uses, so
|
|
149
|
+
// hasSameVariables matches on first mount and avoids an unnecessary re-fetch
|
|
150
|
+
// of the SSR-loaded page. The SSG page is built without a buyer, so it is
|
|
151
|
+
// seeded without a contract: B2B buyers re-fetch it with their assortment.
|
|
89
152
|
const { postalCode } = useSession()
|
|
90
153
|
const initialVariables = params?.serverManyProductsVariables
|
|
91
154
|
? [
|
|
92
|
-
getKey(
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
_postalCode: postalCode ?? '',
|
|
97
|
-
}
|
|
98
|
-
: params.serverManyProductsVariables
|
|
99
|
-
),
|
|
155
|
+
getKey({
|
|
156
|
+
...params.serverManyProductsVariables,
|
|
157
|
+
...getShopperScope({ postalCode }),
|
|
158
|
+
}),
|
|
100
159
|
]
|
|
101
160
|
: []
|
|
102
161
|
|
|
@@ -113,7 +172,7 @@ export const useCreateUseGalleryPage = (
|
|
|
113
172
|
itemsPerPage,
|
|
114
173
|
} = useSearch()
|
|
115
174
|
|
|
116
|
-
const { postalCode, isValidating: isSessionValidating } = useSession()
|
|
175
|
+
const { postalCode, b2b, isValidating: isSessionValidating } = useSession()
|
|
117
176
|
|
|
118
177
|
const localizedVariables = useLocalizedVariables({
|
|
119
178
|
first: itemsPerPage,
|
|
@@ -123,15 +182,15 @@ export const useCreateUseGalleryPage = (
|
|
|
123
182
|
selectedFacets,
|
|
124
183
|
})
|
|
125
184
|
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
185
|
+
// Keyed on the contract id, not the `b2b` object, which is rebuilt on
|
|
186
|
+
// every session update.
|
|
187
|
+
const contract = getContractScope(b2b)
|
|
129
188
|
const localizedVariablesWithRegion = useMemo(
|
|
130
|
-
() =>
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
[localizedVariables, postalCode]
|
|
189
|
+
() => ({
|
|
190
|
+
...localizedVariables,
|
|
191
|
+
...getShopperScope({ postalCode, contract }),
|
|
192
|
+
}),
|
|
193
|
+
[localizedVariables, postalCode, contract]
|
|
135
194
|
)
|
|
136
195
|
|
|
137
196
|
const hasSameVariables = deepEquals(
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useRouter } from 'next/router'
|
|
2
|
+
import { useEffect, useRef } from 'react'
|
|
3
|
+
|
|
4
|
+
import { isProductNotFound } from './isProductNotFound'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The PDP is statically generated without the shopper's session, so an
|
|
8
|
+
* assortment restriction (e.g. a B2B contract limited to a collection) is only
|
|
9
|
+
* enforced when the client refetches the product. Replaces the PDP with the
|
|
10
|
+
* store's 404 page when that refetch says the product is not available to the
|
|
11
|
+
* shopper. The URL becomes `/404`: passing the product path as the `as`
|
|
12
|
+
* argument would make Next load the PDP's data for the 404 page, which expects
|
|
13
|
+
* a different shape and crashes.
|
|
14
|
+
*
|
|
15
|
+
* Applies to every shopper: the API rejects the product from the
|
|
16
|
+
* `vtex_segment` assortment, the same rule Intelligent Search applies to
|
|
17
|
+
* listings, whether or not the session is B2B.
|
|
18
|
+
*
|
|
19
|
+
* Returns whether the PDP must keep its loading state instead of the product's
|
|
20
|
+
* price and buy button while the redirect is pending. Without it, the failed
|
|
21
|
+
* refetch falls back to the static product and its price shows until the 404
|
|
22
|
+
* page loads.
|
|
23
|
+
*/
|
|
24
|
+
export const useProductNotFoundRedirect = (error: unknown) => {
|
|
25
|
+
const router = useRouter()
|
|
26
|
+
const shouldRedirect = isProductNotFound(error)
|
|
27
|
+
// The router changes identity on route events, which re-runs the effect.
|
|
28
|
+
// Navigate once per not-found so a pending redirect is not restarted.
|
|
29
|
+
const hasRedirected = useRef(false)
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!shouldRedirect) {
|
|
33
|
+
hasRedirected.current = false
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
if (hasRedirected.current) return
|
|
37
|
+
|
|
38
|
+
hasRedirected.current = true
|
|
39
|
+
router.replace('/404')
|
|
40
|
+
}, [shouldRedirect, router])
|
|
41
|
+
|
|
42
|
+
return shouldRedirect
|
|
43
|
+
}
|
|
@@ -6,8 +6,11 @@ import type {
|
|
|
6
6
|
ClientProductQueryQueryVariables,
|
|
7
7
|
} from '@generated/graphql'
|
|
8
8
|
|
|
9
|
+
import { request } from '../graphql/request'
|
|
10
|
+
import { onErrorRetry } from '../graphql/retryPolicy'
|
|
9
11
|
import { useQuery } from '../graphql/useQuery'
|
|
10
12
|
import { useSession } from '../session'
|
|
13
|
+
import { isProductNotFound } from './isProductNotFound'
|
|
11
14
|
|
|
12
15
|
const query = gql(`
|
|
13
16
|
query ClientProductQuery($locator: [IStoreSelectedFacet!]!) {
|
|
@@ -18,6 +21,19 @@ const query = gql(`
|
|
|
18
21
|
}
|
|
19
22
|
`)
|
|
20
23
|
|
|
24
|
+
const nextTick = () => new Promise<void>((resolve) => setTimeout(resolve))
|
|
25
|
+
|
|
26
|
+
const fetchProduct = async <T extends ClientProductQueryQuery>(
|
|
27
|
+
variables: ClientProductQueryQueryVariables
|
|
28
|
+
) => {
|
|
29
|
+
await nextTick()
|
|
30
|
+
|
|
31
|
+
return (await request<
|
|
32
|
+
ClientProductQueryQuery & T,
|
|
33
|
+
ClientProductQueryQueryVariables
|
|
34
|
+
>(query, variables)) as ClientProductQueryQuery & T
|
|
35
|
+
}
|
|
36
|
+
|
|
21
37
|
export const useProductQuery = <T extends ClientProductQueryQuery>(
|
|
22
38
|
productID: string,
|
|
23
39
|
fallbackData?: T
|
|
@@ -45,5 +61,18 @@ export const useProductQuery = <T extends ClientProductQueryQuery>(
|
|
|
45
61
|
>(query, variables, {
|
|
46
62
|
fallbackData,
|
|
47
63
|
revalidateOnMount: true,
|
|
64
|
+
// `useQuery`'s default fetcher never settles when `request` throws, so SWR
|
|
65
|
+
// never exposes `error` (e.g. the B2B assortment 404 the PDP reacts to).
|
|
66
|
+
// This fetcher keeps the same deferral but forwards the rejection.
|
|
67
|
+
fetcher: () => fetchProduct<T>(variables),
|
|
68
|
+
// A not-found is the answer (the product is not available to this
|
|
69
|
+
// shopper), not a transient failure, so it is not retried. It is matched
|
|
70
|
+
// the same way the PDP redirect matches it. Other errors follow the shared
|
|
71
|
+
// retry policy.
|
|
72
|
+
onErrorRetry: (error, key, config, revalidate, opts) => {
|
|
73
|
+
if (isProductNotFound(error)) return
|
|
74
|
+
|
|
75
|
+
onErrorRetry(error, key, config, revalidate, opts)
|
|
76
|
+
},
|
|
48
77
|
})
|
|
49
78
|
}
|
package/src/sdk/session/index.ts
CHANGED
|
@@ -12,6 +12,8 @@ import { shouldBypassRefreshTokenBrowser } from 'src/utils/shouldBypassRefreshTo
|
|
|
12
12
|
import { filterChannel } from 'src/utils/utilities'
|
|
13
13
|
import storeConfig from '../../../discovery.config'
|
|
14
14
|
import {
|
|
15
|
+
getNextRefreshAfter,
|
|
16
|
+
isB2BSession,
|
|
15
17
|
isRefreshTokenSuccessful,
|
|
16
18
|
refreshTokenRequest,
|
|
17
19
|
} from '../account/refreshToken'
|
|
@@ -94,14 +96,12 @@ export const mutation = gql(`
|
|
|
94
96
|
`)
|
|
95
97
|
|
|
96
98
|
async function handleRefreshToken(session: Session): Promise<Session | null> {
|
|
97
|
-
const result = await refreshTokenRequest()
|
|
99
|
+
const result = await refreshTokenRequest({ isB2B: isB2BSession(session) })
|
|
98
100
|
|
|
99
101
|
if (isRefreshTokenSuccessful(result)) {
|
|
100
102
|
return {
|
|
101
103
|
...session,
|
|
102
|
-
refreshAfter:
|
|
103
|
-
Math.floor(new Date(result?.refreshAfter).getTime() / 1000)
|
|
104
|
-
),
|
|
104
|
+
refreshAfter: getNextRefreshAfter(result),
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import storeConfig from 'discovery.config'
|
|
2
|
+
|
|
3
|
+
const parseHostname = (url: unknown): string | undefined => {
|
|
4
|
+
if (typeof url !== 'string' || !url) {
|
|
5
|
+
return undefined
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
return new URL(url).hostname.toLowerCase()
|
|
10
|
+
} catch {
|
|
11
|
+
return undefined
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Common parent of the store host and the checkout host (`secureSubdomain`)
|
|
17
|
+
* when checkout runs on a sibling host: `www.store.com` and `secure.store.com`
|
|
18
|
+
* share `store.com`.
|
|
19
|
+
*
|
|
20
|
+
* Returns `undefined` when a cookie scoped to the store host already reaches
|
|
21
|
+
* checkout (same host, or checkout on a subdomain of it), and when the hosts
|
|
22
|
+
* share fewer than two labels.
|
|
23
|
+
*/
|
|
24
|
+
export const getCheckoutParentDomain = (
|
|
25
|
+
hostname: string
|
|
26
|
+
): string | undefined => {
|
|
27
|
+
const checkoutHostname = parseHostname(storeConfig.secureSubdomain)
|
|
28
|
+
|
|
29
|
+
if (!checkoutHostname) {
|
|
30
|
+
return undefined
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const storeLabels = hostname.toLowerCase().split('.').reverse()
|
|
34
|
+
const checkoutLabels = checkoutHostname.split('.').reverse()
|
|
35
|
+
const common: string[] = []
|
|
36
|
+
|
|
37
|
+
for (
|
|
38
|
+
let index = 0;
|
|
39
|
+
index < Math.min(storeLabels.length, checkoutLabels.length) &&
|
|
40
|
+
storeLabels[index] === checkoutLabels[index];
|
|
41
|
+
index++
|
|
42
|
+
) {
|
|
43
|
+
common.unshift(storeLabels[index])
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (common.length < 2) {
|
|
47
|
+
return undefined
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const parent = common.join('.')
|
|
51
|
+
|
|
52
|
+
return parent === hostname.toLowerCase() ? undefined : parent
|
|
53
|
+
}
|
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
// characters such as ";" or spaces would inject cookie attributes.
|
|
3
3
|
const HOSTNAME_PATTERN = /^[a-z0-9.-]+$/
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Hosts whose upstream cookie domain does not match the browser host (previews
|
|
7
|
+
* and local development). Cookies served there are written host-only.
|
|
8
|
+
*/
|
|
9
|
+
export const HOST_ONLY_COOKIE_HOST_SUFFIXES = [
|
|
10
|
+
'localhost',
|
|
11
|
+
'.vtex.app',
|
|
12
|
+
'.localhost',
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Whether `host` can be written as a Set-Cookie Domain attribute without
|
|
17
|
+
* injecting extra attributes.
|
|
18
|
+
*/
|
|
19
|
+
export function isCookieSafeHostname(host: string): boolean {
|
|
20
|
+
return HOSTNAME_PATTERN.test(host.toLowerCase())
|
|
21
|
+
}
|
|
22
|
+
|
|
5
23
|
/**
|
|
6
24
|
* An entry starting with "." matches subdomains only; any other entry matches
|
|
7
25
|
* the host itself and its subdomains. Matching is anchored on a label boundary,
|
|
@@ -65,7 +65,7 @@ describe('Menu', () => {
|
|
|
65
65
|
|
|
66
66
|
it('mounts the OrganizationDrawer with initialView "switch" when Switch is clicked', () => {
|
|
67
67
|
mockUseSession.mockReturnValue({
|
|
68
|
-
b2b: { unitId: 'unit-1',
|
|
68
|
+
b2b: { unitId: 'unit-1', customerId: 'contract-1' },
|
|
69
69
|
})
|
|
70
70
|
|
|
71
71
|
render(<Menu accountName="Jane" items={items} />)
|
|
@@ -79,19 +79,21 @@ describe('Menu', () => {
|
|
|
79
79
|
expect.objectContaining({
|
|
80
80
|
isOpen: true,
|
|
81
81
|
initialView: 'switch',
|
|
82
|
-
|
|
82
|
+
isB2BBuyer: true,
|
|
83
83
|
})
|
|
84
84
|
)
|
|
85
85
|
})
|
|
86
86
|
|
|
87
|
-
it('passes
|
|
88
|
-
mockUseSession.mockReturnValue({
|
|
87
|
+
it('passes isB2BBuyer for a B2B session without the isRepresentative flag', () => {
|
|
88
|
+
mockUseSession.mockReturnValue({
|
|
89
|
+
b2b: { unitId: 'unit-1', isRepresentative: false },
|
|
90
|
+
})
|
|
89
91
|
|
|
90
92
|
render(<Menu accountName="Jane" items={items} />)
|
|
91
93
|
fireEvent.click(screen.getByRole('button', { name: 'Switch' }))
|
|
92
94
|
|
|
93
95
|
expect(mockOrganizationDrawer).toHaveBeenCalledWith(
|
|
94
|
-
expect.objectContaining({
|
|
96
|
+
expect.objectContaining({ isB2BBuyer: true })
|
|
95
97
|
)
|
|
96
98
|
})
|
|
97
99
|
|