@faststore/core 4.10.1-dev.6 → 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.
Files changed (60) hide show
  1. package/.turbo/turbo-generate.log +3 -3
  2. package/.turbo/turbo-test.log +176 -167
  3. package/@generated/graphql.ts +1 -0
  4. package/@generated/schema.graphql +1 -1
  5. package/CHANGELOG.md +29 -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 +11 -2
  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 +4 -3
  21. package/src/pages/pvt/account/cards/index.tsx +5 -5
  22. package/src/pages/pvt/account/orders/[id].tsx +5 -5
  23. package/src/pages/pvt/account/orders/index.tsx +5 -5
  24. package/src/pages/pvt/account/profile.tsx +5 -5
  25. package/src/pages/pvt/account/quotes/index.tsx +5 -5
  26. package/src/pages/pvt/account/security.tsx +5 -5
  27. package/src/pages/pvt/account/user-details.tsx +6 -6
  28. package/src/sdk/account/getB2BSessionClaims.ts +1 -1
  29. package/src/sdk/account/getIsB2BBuyer.ts +28 -0
  30. package/src/sdk/account/refreshToken.ts +89 -11
  31. package/src/sdk/account/refreshTokenPaths.ts +25 -0
  32. package/src/sdk/account/useRefreshToken.ts +10 -5
  33. package/src/sdk/product/isProductNotFound.ts +13 -0
  34. package/src/sdk/product/useProductNotFoundRedirect.ts +43 -0
  35. package/src/sdk/product/useProductQuery.ts +29 -0
  36. package/src/sdk/session/index.ts +4 -4
  37. package/src/utils/getCheckoutParentDomain.ts +53 -0
  38. package/src/utils/trustedForwardedHost.ts +18 -0
  39. package/test/components/account/Layout.test.tsx +1 -1
  40. package/test/components/account/Menu.browser.test.tsx +7 -5
  41. package/test/components/account/OrganizationDrawer.browser.test.tsx +13 -13
  42. package/test/components/account/OrganizationDrawerBody.test.tsx +3 -3
  43. package/test/components/account/OrganizationSignInButton.browser.test.tsx +19 -4
  44. package/test/components/navigation/Navbar.browser.test.tsx +3 -3
  45. package/test/components/navigation/NavbarAccountArea.browser.test.tsx +5 -5
  46. package/test/components/navigation/NavbarSlider.browser.test.tsx +3 -3
  47. package/test/experimental/myAccountCmsServerSideProps.test.ts +5 -5
  48. package/test/pages/api/logout.test.ts +106 -0
  49. package/test/pages/api/switch-contract.test.ts +443 -15
  50. package/test/pages/pvt/account/cards/index.test.ts +3 -3
  51. package/test/sdk/account/getIsB2BBuyer.test.ts +52 -0
  52. package/test/sdk/account/refreshToken.browser.test.ts +10 -1
  53. package/test/sdk/account/refreshToken.test.ts +150 -18
  54. package/test/sdk/account/useRefreshToken.browser.test.ts +4 -1
  55. package/test/sdk/product/useProductNotFoundRedirect.browser.test.ts +83 -0
  56. package/test/sdk/product/useProductQuery.browser.test.ts +154 -0
  57. package/test/sdk/session/refreshTokenRoute.browser.test.ts +106 -0
  58. package/test/sdk/session/validateSession.browser.test.ts +4 -1
  59. package/test/utils/trustedForwardedHost.test.ts +21 -0
  60. 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
- 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
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,
@@ -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 = String(
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
+ }
@@ -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
  }
@@ -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: String(
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,
@@ -60,7 +60,7 @@ describe('Layout', () => {
60
60
  mockUseSession.mockReturnValue({ b2b: { unitId: 'unit-1' } })
61
61
 
62
62
  render(
63
- <Layout accountName="Jane Doe" isRepresentative={false}>
63
+ <Layout accountName="Jane Doe" isB2BBuyer={false}>
64
64
  content
65
65
  </Layout>
66
66
  )
@@ -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', isRepresentative: true },
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
- isRepresentative: true,
82
+ isB2BBuyer: true,
83
83
  })
84
84
  )
85
85
  })
86
86
 
87
- it('passes isRepresentative as a boolean even when b2b.isRepresentative is undefined', () => {
88
- mockUseSession.mockReturnValue({ b2b: { unitId: 'unit-1' } })
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({ isRepresentative: false })
96
+ expect.objectContaining({ isB2BBuyer: true })
95
97
  )
96
98
  })
97
99
 
@@ -108,7 +108,7 @@ describe('OrganizationDrawer', () => {
108
108
  <OrganizationDrawer
109
109
  isOpen
110
110
  closeDrawer={mockCloseDrawer}
111
- isRepresentative={false}
111
+ isB2BBuyer={false}
112
112
  />
113
113
  )
114
114
 
@@ -124,7 +124,7 @@ describe('OrganizationDrawer', () => {
124
124
  <OrganizationDrawer
125
125
  isOpen
126
126
  closeDrawer={mockCloseDrawer}
127
- isRepresentative={false}
127
+ isB2BBuyer={false}
128
128
  />
129
129
  )
130
130
 
@@ -141,7 +141,7 @@ describe('OrganizationDrawer', () => {
141
141
  <OrganizationDrawer
142
142
  isOpen
143
143
  closeDrawer={mockCloseDrawer}
144
- isRepresentative={false}
144
+ isB2BBuyer={false}
145
145
  />
146
146
  )
147
147
 
@@ -152,7 +152,7 @@ describe('OrganizationDrawer', () => {
152
152
  <OrganizationDrawer
153
153
  isOpen={false}
154
154
  closeDrawer={mockCloseDrawer}
155
- isRepresentative={false}
155
+ isB2BBuyer={false}
156
156
  />
157
157
  )
158
158
 
@@ -160,7 +160,7 @@ describe('OrganizationDrawer', () => {
160
160
  <OrganizationDrawer
161
161
  isOpen
162
162
  closeDrawer={mockCloseDrawer}
163
- isRepresentative={false}
163
+ isB2BBuyer={false}
164
164
  />
165
165
  )
166
166
 
@@ -181,7 +181,7 @@ describe('OrganizationDrawer', () => {
181
181
  <OrganizationDrawer
182
182
  isOpen
183
183
  closeDrawer={mockCloseDrawer}
184
- isRepresentative={false}
184
+ isB2BBuyer={false}
185
185
  />
186
186
  )
187
187
 
@@ -200,7 +200,7 @@ describe('OrganizationDrawer', () => {
200
200
  <OrganizationDrawer
201
201
  isOpen
202
202
  closeDrawer={mockCloseDrawer}
203
- isRepresentative={false}
203
+ isB2BBuyer={false}
204
204
  />
205
205
  )
206
206
 
@@ -215,7 +215,7 @@ describe('OrganizationDrawer', () => {
215
215
  <OrganizationDrawer
216
216
  isOpen
217
217
  closeDrawer={vi.fn()}
218
- isRepresentative
218
+ isB2BBuyer
219
219
  initialView="switch"
220
220
  />
221
221
  )
@@ -258,7 +258,7 @@ describe('OrganizationDrawer', () => {
258
258
  <OrganizationDrawer
259
259
  isOpen
260
260
  closeDrawer={mockCloseDrawer}
261
- isRepresentative={false}
261
+ isB2BBuyer={false}
262
262
  />
263
263
  )
264
264
 
@@ -301,7 +301,7 @@ describe('OrganizationDrawer', () => {
301
301
  <OrganizationDrawer
302
302
  isOpen
303
303
  closeDrawer={mockCloseDrawer}
304
- isRepresentative={false}
304
+ isB2BBuyer={false}
305
305
  />
306
306
  )
307
307
 
@@ -313,7 +313,7 @@ describe('OrganizationDrawer', () => {
313
313
  <OrganizationDrawer
314
314
  isOpen={false}
315
315
  closeDrawer={mockCloseDrawer}
316
- isRepresentative={false}
316
+ isB2BBuyer={false}
317
317
  />
318
318
  )
319
319
 
@@ -323,7 +323,7 @@ describe('OrganizationDrawer', () => {
323
323
  <OrganizationDrawer
324
324
  isOpen
325
325
  closeDrawer={mockCloseDrawer}
326
- isRepresentative={false}
326
+ isB2BBuyer={false}
327
327
  />
328
328
  )
329
329
 
@@ -340,7 +340,7 @@ describe('OrganizationDrawer', () => {
340
340
  <OrganizationDrawer
341
341
  isOpen
342
342
  closeDrawer={mockCloseDrawer}
343
- isRepresentative={false}
343
+ isB2BBuyer={false}
344
344
  />
345
345
  )
346
346
 
@@ -27,7 +27,7 @@ describe('OrganizationDrawerBody', () => {
27
27
  it('hides the Quotes route when the user is not an org member', () => {
28
28
  mockUseAccountNavigationLabels.mockReturnValue(undefined)
29
29
 
30
- render(<OrganizationDrawerBody isRepresentative isOrgMember={false} />)
30
+ render(<OrganizationDrawerBody isB2BBuyer isOrgMember={false} />)
31
31
 
32
32
  expect(screen.queryByText('Quotes')).toBeNull()
33
33
  expect(screen.getByText('Profile')).toBeTruthy()
@@ -36,7 +36,7 @@ describe('OrganizationDrawerBody', () => {
36
36
  it('shows the Quotes route when the user belongs to an org unit', () => {
37
37
  mockUseAccountNavigationLabels.mockReturnValue(undefined)
38
38
 
39
- render(<OrganizationDrawerBody isRepresentative isOrgMember />)
39
+ render(<OrganizationDrawerBody isB2BBuyer isOrgMember />)
40
40
 
41
41
  expect(screen.getByText('Quotes')).toBeTruthy()
42
42
  })
@@ -44,7 +44,7 @@ describe('OrganizationDrawerBody', () => {
44
44
  it('hides User Details when not a representative, regardless of org membership', () => {
45
45
  mockUseAccountNavigationLabels.mockReturnValue(undefined)
46
46
 
47
- render(<OrganizationDrawerBody isRepresentative={false} isOrgMember />)
47
+ render(<OrganizationDrawerBody isB2BBuyer={false} isOrgMember />)
48
48
 
49
49
  expect(screen.queryByText('User Details')).toBeNull()
50
50
  expect(screen.getByText('Quotes')).toBeTruthy()
@@ -2,10 +2,13 @@
2
2
  * @vitest-environment jsdom
3
3
  */
4
4
  import '@testing-library/jest-dom/vitest'
5
- import { render, screen } from '@testing-library/react'
5
+ import { fireEvent, render, screen } from '@testing-library/react'
6
6
  import { describe, expect, it, vi } from 'vitest'
7
7
 
8
8
  const mockUseSession = vi.hoisted(() => vi.fn())
9
+ const mockOrganizationDrawer = vi.hoisted(() =>
10
+ vi.fn((_props: Record<string, unknown>) => null)
11
+ )
9
12
  vi.mock('src/sdk/session', () => ({ useSession: mockUseSession }))
10
13
  vi.mock('src/sdk/account/accountPageContext', () => ({
11
14
  useAccountNavigationLabels: () => ({
@@ -16,7 +19,7 @@ vi.mock('src/sdk/account/accountPageContext', () => ({
16
19
  vi.mock(
17
20
  'src/components/account/Drawer/OrganizationDrawer/OrganizationDrawer',
18
21
  () => ({
19
- OrganizationDrawer: () => null,
22
+ OrganizationDrawer: mockOrganizationDrawer,
20
23
  })
21
24
  )
22
25
 
@@ -30,7 +33,7 @@ describe('OrganizationSignInButton', () => {
30
33
  person: { id: 'p1' },
31
34
  b2b: { contractName: ' Caetano Corp ' },
32
35
  })
33
- render(<OrganizationSignInButton icon={icon} isRepresentative />)
36
+ render(<OrganizationSignInButton icon={icon} isB2BBuyer />)
34
37
  expect(screen.getByRole('button', { name: 'account' })).toHaveTextContent(
35
38
  'Caetano Corp'
36
39
  )
@@ -41,9 +44,21 @@ describe('OrganizationSignInButton', () => {
41
44
  person: { id: 'p1' },
42
45
  b2b: { contractName: '' },
43
46
  })
44
- render(<OrganizationSignInButton icon={icon} isRepresentative />)
47
+ render(<OrganizationSignInButton icon={icon} isB2BBuyer />)
45
48
  expect(screen.getByRole('button', { name: 'account' })).toHaveTextContent(
46
49
  'Company'
47
50
  )
48
51
  })
52
+
53
+ it.each([
54
+ ['isB2BBuyer', { isB2BBuyer: true }],
55
+ ['the deprecated isRepresentative', { isRepresentative: true }],
56
+ ])('opens the drawer as a B2B buyer from %s', (_label, props) => {
57
+ mockUseSession.mockReturnValue({ person: { id: 'p1' }, b2b: {} })
58
+ render(<OrganizationSignInButton icon={icon} {...props} />)
59
+ fireEvent.click(screen.getByRole('button', { name: 'account' }))
60
+ expect(mockOrganizationDrawer.mock.lastCall?.[0]).toMatchObject({
61
+ isB2BBuyer: true,
62
+ })
63
+ })
49
64
  })
@@ -146,11 +146,11 @@ describe('Navbar', () => {
146
146
  )
147
147
  })
148
148
 
149
- it('forwards isSignInResolved=true and isOrganizationEnabled for a resolved B2B representative session', () => {
149
+ it('forwards isSignInResolved=true and isOrganizationEnabled for a resolved B2B session', () => {
150
150
  mockStoreConfig.experimental.enableFaststoreMyAccount = true
151
151
  mockUseScreenResize.mockReturnValue({ isDesktop: true })
152
152
  mockUseSession.mockReturnValue({
153
- b2b: { isRepresentative: true },
153
+ b2b: { unitId: 'unit-1', customerId: 'contract-1' },
154
154
  isSessionReady: true,
155
155
  hasValidated: true,
156
156
  })
@@ -161,7 +161,7 @@ describe('Navbar', () => {
161
161
  expect.objectContaining({
162
162
  isSignInResolved: true,
163
163
  isOrganizationEnabled: true,
164
- isRepresentative: true,
164
+ isB2BBuyer: true,
165
165
  })
166
166
  )
167
167