@faststore/core 4.10.1 → 4.10.2-dev.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/.turbo/turbo-generate.log +3 -3
  2. package/.turbo/turbo-test.log +304 -252
  3. package/@generated/graphql.ts +1 -0
  4. package/@generated/schema.graphql +1 -1
  5. package/CHANGELOG.md +82 -0
  6. package/package.json +6 -6
  7. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawer.tsx +3 -3
  8. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerBody.tsx +5 -7
  9. package/src/components/account/Drawer/OrganizationSignInButton/OrganizationSignInButton.tsx +4 -1
  10. package/src/components/account/Layout/Layout.tsx +5 -7
  11. package/src/components/account/Menu/Menu.tsx +1 -1
  12. package/src/components/navigation/Navbar/Navbar.tsx +3 -3
  13. package/src/components/navigation/Navbar/NavbarAccountArea.tsx +5 -5
  14. package/src/components/navigation/NavbarSlider/NavbarSlider.tsx +3 -3
  15. package/src/experimental/myAccountCmsServerSideProps.ts +3 -3
  16. package/src/experimental/myAccountServerSideProps.ts +4 -4
  17. package/src/pages/[slug]/p.tsx +18 -4
  18. package/src/pages/api/fs/logout.ts +18 -10
  19. package/src/pages/api/fs/switch-contract.ts +170 -21
  20. package/src/pages/api/graphql.ts +101 -60
  21. package/src/pages/pvt/account/403.tsx +5 -4
  22. package/src/pages/pvt/account/cards/index.tsx +5 -5
  23. package/src/pages/pvt/account/orders/[id].tsx +5 -5
  24. package/src/pages/pvt/account/orders/index.tsx +5 -5
  25. package/src/pages/pvt/account/profile.tsx +5 -5
  26. package/src/pages/pvt/account/quotes/index.tsx +5 -5
  27. package/src/pages/pvt/account/security.tsx +5 -5
  28. package/src/pages/pvt/account/user-details.tsx +6 -6
  29. package/src/sdk/account/getB2BSessionClaims.ts +1 -1
  30. package/src/sdk/account/getIsB2BBuyer.ts +28 -0
  31. package/src/sdk/account/refreshToken.ts +89 -11
  32. package/src/sdk/account/refreshTokenPaths.ts +25 -0
  33. package/src/sdk/account/useRefreshToken.ts +15 -11
  34. package/src/sdk/product/isProductNotFound.ts +13 -0
  35. package/src/sdk/product/usePageProductsQuery.ts +80 -21
  36. package/src/sdk/product/useProductNotFoundRedirect.ts +43 -0
  37. package/src/sdk/product/useProductQuery.ts +29 -0
  38. package/src/sdk/session/index.ts +46 -34
  39. package/src/server/cms/index.ts +59 -7
  40. package/src/server/cms/pdp.ts +10 -5
  41. package/src/server/cms/plp.ts +10 -5
  42. package/src/server/content/promiseCache.ts +68 -0
  43. package/src/server/content/service.ts +71 -51
  44. package/src/utils/getCheckoutParentDomain.ts +53 -0
  45. package/src/utils/isLocalHost.ts +2 -16
  46. package/src/utils/shouldBypassRefreshToken.ts +63 -0
  47. package/src/utils/trustedForwardedHost.ts +18 -0
  48. package/test/components/account/Layout.test.tsx +1 -1
  49. package/test/components/account/Menu.browser.test.tsx +7 -5
  50. package/test/components/account/OrganizationDrawer.browser.test.tsx +13 -13
  51. package/test/components/account/OrganizationDrawerBody.test.tsx +3 -3
  52. package/test/components/account/OrganizationSignInButton.browser.test.tsx +19 -4
  53. package/test/components/navigation/Navbar.browser.test.tsx +3 -3
  54. package/test/components/navigation/NavbarAccountArea.browser.test.tsx +5 -5
  55. package/test/components/navigation/NavbarSlider.browser.test.tsx +3 -3
  56. package/test/experimental/myAccountCmsServerSideProps.test.ts +5 -5
  57. package/test/pages/api/graphql.test.ts +175 -0
  58. package/test/pages/api/logout.test.ts +106 -0
  59. package/test/pages/api/switch-contract.test.ts +443 -15
  60. package/test/pages/pvt/account/403.test.ts +67 -0
  61. package/test/pages/pvt/account/cards/index.test.ts +3 -3
  62. package/test/sdk/account/getIsB2BBuyer.test.ts +52 -0
  63. package/test/sdk/account/refreshToken.browser.test.ts +10 -1
  64. package/test/sdk/account/refreshToken.test.ts +150 -18
  65. package/test/sdk/account/useRefreshToken.browser.test.ts +72 -0
  66. package/test/sdk/product/usePageProductsQuery.browser.test.ts +260 -0
  67. package/test/sdk/product/usePageProductsQuery.deliveryPromise.browser.test.ts +130 -0
  68. package/test/sdk/product/useProductNotFoundRedirect.browser.test.ts +83 -0
  69. package/test/sdk/product/useProductQuery.browser.test.ts +154 -0
  70. package/test/sdk/session/refreshTokenRoute.browser.test.ts +106 -0
  71. package/test/sdk/session/validateSession.browser.test.ts +111 -5
  72. package/test/server/cms/index.test.ts +102 -9
  73. package/test/server/content/promiseCache.test.ts +150 -0
  74. package/test/server/content/service.test.ts +277 -11
  75. package/test/utils/shouldBypassRefreshToken.browser.test.ts +38 -0
  76. package/test/utils/shouldBypassRefreshToken.test.ts +98 -0
  77. package/test/utils/trustedForwardedHost.test.ts +21 -0
  78. package/src/sdk/account/getIsRepresentative.ts +0 -20
  79. package/test/utils/isLocalHost.browser.test.ts +0 -37
@@ -27,7 +27,7 @@ import BeforeSection from 'src/customizations/src/myAccount/extensions/quotes/be
27
27
  import type { MyAccountProps } from 'src/experimental/myAccountServerSideProps'
28
28
  import type { AccountQuotesPageData } from 'src/sdk/account/accountPageContext'
29
29
  import type { AccountNavigationLabels } from 'src/sdk/account/getMyAccountRoutes'
30
- import { getIsRepresentative } from 'src/sdk/account/getIsRepresentative'
30
+ import { getIsB2BBuyer } from 'src/sdk/account/getIsB2BBuyer'
31
31
  import PageProvider from 'src/sdk/overrides/PageProvider'
32
32
  import { execute } from 'src/server'
33
33
  import { injectGlobalSections } from 'src/server/cms/global'
@@ -56,7 +56,7 @@ export default function ListQuotesPage({
56
56
  navigationLabels,
57
57
  accountPageData,
58
58
  accountName,
59
- isRepresentative,
59
+ isB2BBuyer,
60
60
  }: ListQuotesPageProps) {
61
61
  const { sections: globalSections, settings: globalSettings } =
62
62
  globalSectionsProp ?? {}
@@ -73,7 +73,7 @@ export default function ListQuotesPage({
73
73
  <NextSeo noindex nofollow />
74
74
 
75
75
  <Layout
76
- isRepresentative={isRepresentative}
76
+ isB2BBuyer={isB2BBuyer}
77
77
  accountName={accountName}
78
78
  navigationLabels={navigationLabels}
79
79
  >
@@ -122,7 +122,7 @@ const getServerSidePropsBase: GetServerSideProps<
122
122
  Record<string, string>,
123
123
  Locator
124
124
  > = async (context) => {
125
- const isRepresentative = getIsRepresentative({
125
+ const isB2BBuyer = getIsB2BBuyer({
126
126
  headers: context.req.headers as Record<string, string>,
127
127
  account: storeConfig.api.storeId,
128
128
  })
@@ -273,7 +273,7 @@ const getServerSidePropsBase: GetServerSideProps<
273
273
  },
274
274
  },
275
275
  pageSections,
276
- isRepresentative,
276
+ isB2BBuyer,
277
277
  },
278
278
  }
279
279
  }
@@ -22,7 +22,7 @@ import { default as BeforeSection } from 'src/customizations/src/myAccount/exten
22
22
  import type { MyAccountProps } from 'src/experimental/myAccountServerSideProps'
23
23
  import type { AccountSecurityPageData } from 'src/sdk/account/accountPageContext'
24
24
  import type { AccountNavigationLabels } from 'src/sdk/account/getMyAccountRoutes'
25
- import { getIsRepresentative } from 'src/sdk/account/getIsRepresentative'
25
+ import { getIsB2BBuyer } from 'src/sdk/account/getIsB2BBuyer'
26
26
  import { execute } from 'src/server'
27
27
  import { injectGlobalSections } from 'src/server/cms/global'
28
28
  import { extractAccountNavigationData } from 'src/server/cms/myAccountDefaultSections'
@@ -51,7 +51,7 @@ export default function Page({
51
51
  navigationLabels,
52
52
  accountPageData,
53
53
  accountName,
54
- isRepresentative,
54
+ isB2BBuyer,
55
55
  }: SecurityPageProps) {
56
56
  const { sections: globalSections, settings: globalSettings } =
57
57
  globalSectionsProp ?? {}
@@ -68,7 +68,7 @@ export default function Page({
68
68
  <NextSeo noindex nofollow />
69
69
 
70
70
  <Layout
71
- isRepresentative={isRepresentative}
71
+ isB2BBuyer={isB2BBuyer}
72
72
  accountName={accountName}
73
73
  navigationLabels={navigationLabels}
74
74
  >
@@ -101,7 +101,7 @@ const getServerSidePropsBase: GetServerSideProps<
101
101
  Record<string, string>,
102
102
  Locator
103
103
  > = async (context) => {
104
- const isRepresentative = getIsRepresentative({
104
+ const isB2BBuyer = getIsB2BBuyer({
105
105
  headers: context.req.headers as Record<string, string>,
106
106
  account: storeConfig.api.storeId,
107
107
  })
@@ -185,7 +185,7 @@ const getServerSidePropsBase: GetServerSideProps<
185
185
  },
186
186
  globalSections: globalSectionsResult,
187
187
  pageSections,
188
- isRepresentative,
188
+ isB2BBuyer,
189
189
  },
190
190
  }
191
191
  }
@@ -23,7 +23,7 @@ import { default as BeforeSection } from 'src/customizations/src/myAccount/exten
23
23
  import type { MyAccountProps } from 'src/experimental/myAccountServerSideProps'
24
24
  import type { AccountUserDetailsPageData } from 'src/sdk/account/accountPageContext'
25
25
  import type { AccountNavigationLabels } from 'src/sdk/account/getMyAccountRoutes'
26
- import { getIsRepresentative } from 'src/sdk/account/getIsRepresentative'
26
+ import { getIsB2BBuyer } from 'src/sdk/account/getIsB2BBuyer'
27
27
  import PageProvider from 'src/sdk/overrides/PageProvider'
28
28
  import { execute } from 'src/server'
29
29
  import { injectGlobalSections } from 'src/server/cms/global'
@@ -50,7 +50,7 @@ export default function Page({
50
50
  navigationLabels,
51
51
  accountPageData,
52
52
  accountName,
53
- isRepresentative,
53
+ isB2BBuyer,
54
54
  }: UserDetailsPageProps) {
55
55
  const { sections: globalSections, settings: globalSettings } =
56
56
  globalSectionsProp ?? {}
@@ -67,7 +67,7 @@ export default function Page({
67
67
  <NextSeo noindex nofollow />
68
68
 
69
69
  <Layout
70
- isRepresentative={isRepresentative}
70
+ isB2BBuyer={isB2BBuyer}
71
71
  accountName={accountName}
72
72
  navigationLabels={navigationLabels}
73
73
  >
@@ -105,7 +105,7 @@ const getServerSidePropsBase: GetServerSideProps<
105
105
  Record<string, string>,
106
106
  Locator
107
107
  > = async (context) => {
108
- const isRepresentative = getIsRepresentative({
108
+ const isB2BBuyer = getIsB2BBuyer({
109
109
  headers: context.req.headers as Record<string, string>,
110
110
  account: storeConfig.api.storeId,
111
111
  })
@@ -118,7 +118,7 @@ const getServerSidePropsBase: GetServerSideProps<
118
118
  return { redirect }
119
119
  }
120
120
 
121
- if (!isRepresentative) {
121
+ if (!isB2BBuyer) {
122
122
  return {
123
123
  redirect: {
124
124
  destination: localizeRedirectDestination('/pvt/account', context),
@@ -209,7 +209,7 @@ const getServerSidePropsBase: GetServerSideProps<
209
209
  },
210
210
  },
211
211
  pageSections,
212
- isRepresentative,
212
+ isB2BBuyer,
213
213
  },
214
214
  }
215
215
  }
@@ -10,7 +10,7 @@ export type B2BSessionClaims = {
10
10
  /**
11
11
  * Whether the buyer is associated with a Unit/Contract. Per the Personal
12
12
  * Cards PRD (REQ-2), this is detected by the presence of a `unitId` claim in
13
- * the VTEX ID JWT — the same cookie `getIsRepresentative` already decodes.
13
+ * the VTEX ID JWT — the same cookie `getIsB2BBuyer` already decodes.
14
14
  * Gates the Shared tab (FR-5).
15
15
  *
16
16
  * Note this is the only part of the gate that lives in the token. The
@@ -0,0 +1,28 @@
1
+ import { parse } from 'cookie'
2
+ import { parseJwt } from 'src/utils/getCookie'
3
+
4
+ type GetIsB2BBuyerParams = {
5
+ headers?: Record<string, string>
6
+ account: string
7
+ }
8
+
9
+ /**
10
+ * Whether the buyer is a B2B member: the VTEX ID token carries both an
11
+ * organizational unit and a contract, the rule Buyer Portal uses. The
12
+ * `isRepresentative` claim is not read, since it can be true outside B2B.
13
+ */
14
+ export function getIsB2BBuyer({
15
+ headers,
16
+ account,
17
+ }: GetIsB2BBuyerParams): boolean {
18
+ const authCookie = parse(headers?.cookie ?? '')?.[
19
+ 'VtexIdclientAutCookie_' + account
20
+ ]
21
+ const jwt = parseJwt(authCookie)
22
+
23
+ return isNonBlankString(jwt?.unitId) && isNonBlankString(jwt?.customerId)
24
+ }
25
+
26
+ function isNonBlankString(value: unknown): boolean {
27
+ return typeof value === 'string' && value.trim() !== ''
28
+ }
@@ -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
- refreshAfter?: string
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 '/api/vtexid/refreshtoken/webstore'
41
+ return path
31
42
  }
32
43
 
33
- return `${new URL(getStoreURL()).origin}/api/vtexid/refreshtoken/webstore`
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 refreshTokenRequest = async (): Promise<
49
- RefreshTokenResponse | undefined
50
- > => {
51
- return fetchWithRetry(getRefreshTokenUrl(), {
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
- export const isRefreshTokenSuccessful = (
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
- ): boolean => {
62
- return result?.status?.toLowerCase?.() === 'success'
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
- import { isLocalHostBrowser } from 'src/utils/isLocalHost'
2
+ import { shouldBypassRefreshTokenBrowser } from 'src/utils/shouldBypassRefreshToken'
3
3
  import { logoutAndClearSession, sessionStore } from '../session'
4
- import { isRefreshTokenSuccessful, refreshTokenRequest } from './refreshToken'
4
+ import {
5
+ getNextRefreshAfter,
6
+ isB2BSession,
7
+ isRefreshTokenSuccessful,
8
+ refreshTokenRequest,
9
+ } from './refreshToken'
5
10
 
6
11
  export const useRefreshToken = (
7
12
  needsRefreshToken?: boolean,
@@ -13,13 +18,12 @@ export const useRefreshToken = (
13
18
  const handleRefreshTokenAndUpdateSession = async () => {
14
19
  if (!needsRefreshToken) return
15
20
 
16
- // The refresh-token endpoint lives on the production origin and relies on
17
- // the `vid_rt` cookie scoped to that origin. From localhost the request is
18
- // cross-origin, so the cookie is not sent and the refresh always fails —
19
- // which would also wipe the manually injected `VtexIdclientAutCookie_<account>`
21
+ // The refresh-token endpoint relies on VTEX ID, which neither localhost
22
+ // nor WebOps previews proxy, so the refresh always fails there — which
23
+ // would also wipe the manually injected `VtexIdclientAutCookie_<account>`
20
24
  // via `logoutAndClearSession`. Bail out and fall back to the static 403
21
25
  // view so developers can keep testing logged-in flows.
22
- if (isLocalHostBrowser()) {
26
+ if (shouldBypassRefreshTokenBrowser()) {
23
27
  setShouldShow403(true)
24
28
  return
25
29
  }
@@ -27,13 +31,13 @@ export const useRefreshToken = (
27
31
  const currentSession = sessionStore.read() ?? sessionStore.readInitial()
28
32
 
29
33
  try {
30
- const result = await refreshTokenRequest()
34
+ const result = await refreshTokenRequest({
35
+ isB2B: isB2BSession(currentSession),
36
+ })
31
37
 
32
38
  if (isRefreshTokenSuccessful(result)) {
33
39
  // Update session with new refreshAfter timestamp
34
- const refreshAfter = String(
35
- Math.floor(new Date(result?.refreshAfter).getTime() / 1000)
36
- )
40
+ const refreshAfter = getNextRefreshAfter(result)
37
41
 
38
42
  sessionStore.set({
39
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 region-aware key shape that useGalleryPage uses,
86
- // so hasSameVariables correctly matches on first mount and avoids an unnecessary
87
- // re-fetch of the SSR-loaded page.
88
- const isDeliveryPromiseEnabled = storeConfig.deliveryPromise?.enabled ?? false
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
- isDeliveryPromiseEnabled
94
- ? {
95
- ...params.serverManyProductsVariables,
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
- // Include postalCode in the cache key so pages are invalidated on region change.
127
- // _postalCode is not sent to the API — it only affects the local cache comparison.
128
- // Only applied when deliveryPromise is enabled to avoid unnecessary cache fragmentation.
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
- isDeliveryPromiseEnabled
132
- ? { ...localizedVariables, _postalCode: postalCode ?? '' }
133
- : localizedVariables,
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
  }