@faststore/core 4.8.1-dev.0 → 4.8.1-dev.2

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 (89) hide show
  1. package/.turbo/turbo-generate.log +3 -3
  2. package/.turbo/turbo-test.log +146 -105
  3. package/@generated/gql.ts +3 -3
  4. package/@generated/graphql.ts +4 -2
  5. package/@generated/persisted-documents.json +1 -1
  6. package/@generated/schema.graphql +4 -0
  7. package/CHANGELOG.md +16 -0
  8. package/cms/faststore/my-account/components/cms_component__accountnavigation.jsonc +10 -0
  9. package/cms/faststore/my-account/components/cms_component__accountorderdelivery.jsonc +80 -0
  10. package/cms/faststore/my-account/components/cms_component__accountorderdetails.jsonc +25 -0
  11. package/cms/faststore/my-account/components/cms_component__accountorderslist.jsonc +10 -0
  12. package/cms/faststore/my-account/components/cms_component__accountorderstatus.jsonc +90 -0
  13. package/cms/faststore/my-account/components/cms_component__accountordersummary.jsonc +25 -0
  14. package/package.json +6 -6
  15. package/src/components/account/Drawer/OrganizationDrawer/ContractSwitcher/ContractSwitcher.tsx +4 -6
  16. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawer.tsx +18 -3
  17. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerFooter.tsx +13 -2
  18. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerHeader.tsx +1 -0
  19. package/src/components/account/Drawer/OrganizationDrawer/section.module.scss +7 -0
  20. package/src/components/account/Drawer/OrganizationSignInButton/OrganizationSignInButton.tsx +7 -9
  21. package/src/components/account/Menu/Menu.tsx +24 -7
  22. package/src/components/account/orders/ListOrders/FilterSlider/FilterFacetPendingApproval/FilterFacetPendingApproval.tsx +8 -3
  23. package/src/components/account/orders/ListOrders/FilterSlider/FilterSlider.tsx +1 -0
  24. package/src/components/account/orders/ListOrders/ListOrders.tsx +2 -2
  25. package/src/components/account/orders/ListOrders/listOrdersLabels.ts +4 -0
  26. package/src/components/account/orders/OrderDetails/BuyingPolicyAlert/BuyingPolicyAlert.tsx +45 -9
  27. package/src/components/account/orders/OrderDetails/DeliveryCard/DeliveryCard.tsx +14 -3
  28. package/src/components/account/orders/OrderDetails/DeliveryOptionAccordion/DeliveryOptionAccordion.tsx +4 -1
  29. package/src/components/account/orders/OrderDetails/DeliveryOptionAccordion/DeliveryOptionAccordionDeliveryInfo.tsx +14 -7
  30. package/src/components/account/orders/OrderDetails/DeliveryOptionAccordion/DeliveryOptionAccordionProducts.tsx +8 -5
  31. package/src/components/account/orders/OrderDetails/OrderDetailsHeader.tsx +5 -0
  32. package/src/components/account/orders/OrderDetails/StatusCard/StatusCard.tsx +26 -89
  33. package/src/components/account/orders/OrderDetails/StatusCard/statusCardLocalization.ts +114 -0
  34. package/src/components/account/orders/OrderDetails/SummaryCard/SummaryCard.tsx +2 -1
  35. package/src/components/account/orders/OrderDetails/SummaryCard/getOrderTotalLabel.ts +19 -0
  36. package/src/components/account/orders/OrderDetails/getDeliveryOptionLabel.ts +135 -0
  37. package/src/components/account/orders/OrderDetails/orderDetailsLabels.ts +73 -0
  38. package/src/components/navigation/Navbar/Navbar.tsx +17 -19
  39. package/src/components/navigation/Navbar/NavbarAccountArea.tsx +57 -0
  40. package/src/components/navigation/NavbarSlider/NavbarSlider.tsx +16 -18
  41. package/src/components/sections/Account/AccountOrderDelivery.tsx +3 -1
  42. package/src/components/sections/Account/AccountOrderDetails.tsx +16 -0
  43. package/src/components/sections/Account/AccountOrderStatus.tsx +27 -3
  44. package/src/components/sections/Account/AccountOrdersList.tsx +4 -0
  45. package/src/pages/api/fs/switch-contract.ts +194 -0
  46. package/src/pages/api/graphql.ts +108 -69
  47. package/src/sdk/account/accountPageContext.ts +14 -3
  48. package/src/sdk/account/changeContractToken.ts +19 -63
  49. package/src/sdk/account/clearPersistedSessionState.ts +13 -7
  50. package/src/sdk/account/getMyAccountRoutes.ts +2 -0
  51. package/src/sdk/account/useAvailableContracts.ts +1 -0
  52. package/src/sdk/account/useSwitchContract.ts +11 -3
  53. package/src/sdk/overrides/PageProvider.tsx +1 -1
  54. package/src/sdk/session/index.ts +2 -1
  55. package/src/sdk/session/isSignInAreaResolved.ts +12 -0
  56. package/test/components/account/ContractSwitcher.browser.test.tsx +42 -0
  57. package/test/components/account/Menu.browser.test.tsx +123 -0
  58. package/test/components/account/OrganizationDrawer.browser.test.tsx +148 -0
  59. package/test/components/account/OrganizationDrawerFooter.browser.test.tsx +95 -1
  60. package/test/components/account/OrganizationDrawerHeader.browser.test.tsx +2 -0
  61. package/test/components/account/OrganizationSignInButton.browser.test.tsx +49 -0
  62. package/test/components/account/orders/BuyingPolicyAlert.browser.test.tsx +342 -0
  63. package/test/components/account/orders/DeliveryCard.browser.test.tsx +36 -0
  64. package/test/components/account/orders/DeliveryOptionAccordion.browser.test.tsx +76 -0
  65. package/test/components/account/orders/DeliveryOptionAccordionDeliveryInfo.browser.test.tsx +41 -0
  66. package/test/components/account/orders/DeliveryOptionAccordionProducts.browser.test.tsx +35 -0
  67. package/test/components/account/orders/FilterFacetPendingApproval.browser.test.tsx +25 -0
  68. package/test/components/account/orders/StatusCard.browser.test.tsx +37 -0
  69. package/test/components/account/orders/StatusCard.test.ts +101 -0
  70. package/test/components/account/orders/SummaryCard.browser.test.tsx +59 -0
  71. package/test/components/account/orders/getDeliveryOptionLabel.test.ts +129 -0
  72. package/test/components/account/orders/getOrderTotalLabel.test.ts +35 -0
  73. package/test/components/account/orders/listOrdersLabels.test.ts +23 -0
  74. package/test/components/account/orders/orderDetailsLabels.test.ts +13 -0
  75. package/test/components/navigation/Navbar.browser.test.tsx +183 -0
  76. package/test/components/navigation/NavbarAccountArea.browser.test.tsx +115 -0
  77. package/test/components/navigation/NavbarSlider.browser.test.tsx +143 -0
  78. package/test/components/sections/Account/AccountOrderDelivery.browser.test.tsx +72 -0
  79. package/test/components/sections/Account/AccountOrderDetails.browser.test.tsx +151 -0
  80. package/test/components/sections/Account/AccountOrderStatus.browser.test.tsx +59 -0
  81. package/test/components/sections/Account/AccountOrdersList.browser.test.tsx +67 -0
  82. package/test/pages/api/graphql.test.ts +27 -1
  83. package/test/pages/api/switch-contract.test.ts +385 -0
  84. package/test/sdk/account/changeContractToken.browser.test.ts +23 -71
  85. package/test/sdk/account/clearPersistedSessionState.browser.test.ts +24 -0
  86. package/test/sdk/account/getMyAccountRoutes.test.ts +11 -1
  87. package/test/sdk/account/useSwitchContract.browser.test.ts +21 -0
  88. package/test/sdk/session/isSignInAreaResolved.test.ts +22 -0
  89. package/test/utils/userOrderStatus.test.ts +22 -0
@@ -0,0 +1,194 @@
1
+ import { parse } from 'cookie'
2
+ import type { NextApiHandler, NextApiRequest, NextApiResponse } from 'next'
3
+
4
+ import storeConfig from 'discovery.config'
5
+ import { expireCookieServer, getCookieDomains } from 'src/utils/clearCookies'
6
+ import { getRequestHostname } from 'src/utils/getRequestHostname'
7
+
8
+ /**
9
+ * Cookies holding the checkout orderForm of the previous commercial context.
10
+ * Both are `HttpOnly`, so they are unreachable from `document.cookie` and can
11
+ * only be expired from the server (B2BTEAM-3827).
12
+ */
13
+ const ORDER_FORM_COOKIES = [
14
+ 'checkout.vtex.com',
15
+ 'CheckoutOrderFormOwnership',
16
+ ] as const
17
+
18
+ const SWITCH_PROPERTIES_TIMEOUT_MS = 30_000
19
+ const DEFAULT_AUTH_COOKIE_MAX_AGE = 86_400
20
+
21
+ type VtexAuthCookie = {
22
+ Name: string
23
+ Value: string
24
+ }
25
+
26
+ /** Untrusted upstream payload: every field is validated before use. */
27
+ type SwitchPropertiesResponse = {
28
+ authStatus?: string
29
+ expiresIn?: unknown
30
+ authCookie?: VtexAuthCookie | null
31
+ accountAuthCookie?: VtexAuthCookie | null
32
+ }
33
+
34
+ export type SwitchContractResponse = {
35
+ success: boolean
36
+ error?: string
37
+ }
38
+
39
+ const isAuthCookie = (cookie: unknown): cookie is VtexAuthCookie => {
40
+ const candidate = cookie as VtexAuthCookie | null
41
+
42
+ return Boolean(candidate?.Name?.trim() && candidate?.Value)
43
+ }
44
+
45
+ const isSecureRequest = (request: NextApiRequest): boolean => {
46
+ const forwarded = request.headers['x-forwarded-proto']
47
+ const proto = Array.isArray(forwarded) ? forwarded[0] : forwarded
48
+
49
+ if (proto) {
50
+ return proto.split(',')[0].trim() === 'https'
51
+ }
52
+
53
+ const hostname = getRequestHostname(request.headers.host)
54
+
55
+ return hostname !== 'localhost' && hostname !== '127.0.0.1'
56
+ }
57
+
58
+ /**
59
+ * A non-positive `expiresIn` would turn into `Max-Age=0`, which expires the
60
+ * credential the response is supposed to install — logging the buyer out while
61
+ * the route reports success. Anything that is not a positive integer falls back
62
+ * to the default.
63
+ */
64
+ const resolveMaxAge = (expiresIn: unknown): number =>
65
+ typeof expiresIn === 'number' && Number.isInteger(expiresIn) && expiresIn > 0
66
+ ? expiresIn
67
+ : DEFAULT_AUTH_COOKIE_MAX_AGE
68
+
69
+ /**
70
+ * Mirrors the attributes the storefront used to write via `document.cookie`.
71
+ * `HttpOnly` is deliberately left out: the cookie lives on a domain shared
72
+ * with VTEX's own checkout, so hardening it is a separate, explicitly tested
73
+ * change.
74
+ */
75
+ const buildAuthCookies = (
76
+ payload: SwitchPropertiesResponse,
77
+ secure: boolean
78
+ ): string[] => {
79
+ const maxAge = resolveMaxAge(payload.expiresIn)
80
+ const secureAttr = secure ? '; Secure' : ''
81
+
82
+ return [payload.authCookie, payload.accountAuthCookie]
83
+ .filter(isAuthCookie)
84
+ .map(
85
+ (cookie) =>
86
+ `${cookie.Name}=${cookie.Value}; Path=/; Max-Age=${maxAge}; SameSite=Lax${secureAttr}`
87
+ )
88
+ }
89
+
90
+ const buildOrderFormExpiryCookies = (request: NextApiRequest): string[] => {
91
+ const hostname = getRequestHostname(request.headers.host)
92
+ const domains = hostname ? getCookieDomains(hostname) : [undefined]
93
+ const cookies = parse(request.headers.cookie ?? '')
94
+
95
+ return ORDER_FORM_COOKIES.filter((name) => cookies[name]).flatMap((name) =>
96
+ domains.map((domain) => expireCookieServer({ name, path: '/', domain }))
97
+ )
98
+ }
99
+
100
+ /**
101
+ * Switches the buyer's active contract and drops the previous contract's
102
+ * checkout orderForm in a single response.
103
+ *
104
+ * Relays to VTEX Identity server-side, forwarding the buyer cookie as the only
105
+ * credential — no app keys involved, same as `commerce.storeFront`. On success
106
+ * the new auth cookie and the orderForm expiry travel in the same `Set-Cookie`
107
+ * batch, so the browser can never end up authenticated as the new contract
108
+ * while still carrying the old contract's orderForm. On any failure nothing is
109
+ * emitted at all and the previous contract stays active.
110
+ */
111
+ const handler: NextApiHandler<SwitchContractResponse> = async (
112
+ request: NextApiRequest,
113
+ response: NextApiResponse<SwitchContractResponse>
114
+ ) => {
115
+ if (request.method !== 'POST') {
116
+ response.status(405).end()
117
+ return
118
+ }
119
+
120
+ const { contractId } = request.body ?? {}
121
+
122
+ if (!contractId || typeof contractId !== 'string') {
123
+ response
124
+ .status(400)
125
+ .json({ success: false, error: 'contractId is required' })
126
+ return
127
+ }
128
+
129
+ const account = storeConfig.api.storeId
130
+ const an = encodeURIComponent(account)
131
+ const url = `https://${account}.myvtex.com/api/authenticator/storefront/credential/switch-properties?an=${an}`
132
+
133
+ try {
134
+ const switchResponse = await fetch(url, {
135
+ method: 'POST',
136
+ headers: {
137
+ 'Content-Type': 'application/json',
138
+ cookie: request.headers.cookie ?? '',
139
+ },
140
+ body: JSON.stringify({ properties: { customerId: contractId } }),
141
+ signal: AbortSignal.timeout(SWITCH_PROPERTIES_TIMEOUT_MS),
142
+ // A redirect is never a successful switch, and following one would carry
143
+ // the buyer's cookie to whatever target the upstream names (CVE-2023-45143
144
+ // on Node < 20.8.1). Fail closed instead.
145
+ redirect: 'error',
146
+ })
147
+
148
+ if (!switchResponse.ok) {
149
+ const unauthorized =
150
+ switchResponse.status === 401 || switchResponse.status === 403
151
+
152
+ response
153
+ .status(unauthorized ? 401 : 502)
154
+ .json({ success: false, error: 'Failed to switch contract' })
155
+ return
156
+ }
157
+
158
+ const payload = (await switchResponse.json()) as SwitchPropertiesResponse
159
+
160
+ if (
161
+ payload.authStatus?.toLowerCase() !== 'success' ||
162
+ !isAuthCookie(payload.authCookie)
163
+ ) {
164
+ response
165
+ .status(502)
166
+ .json({ success: false, error: 'Contract switch was not successful' })
167
+ return
168
+ }
169
+
170
+ response.setHeader('Set-Cookie', [
171
+ ...buildAuthCookies(payload, isSecureRequest(request)),
172
+ ...buildOrderFormExpiryCookies(request),
173
+ ])
174
+
175
+ response.status(200).json({ success: true })
176
+ } catch (error) {
177
+ const timedOut =
178
+ error instanceof Error &&
179
+ (error.name === 'TimeoutError' || error.name === 'AbortError')
180
+
181
+ // Only the error name is logged: the request and response carry the buyer's
182
+ // credentials.
183
+ console.error(
184
+ 'switch-contract: upstream failure',
185
+ error instanceof Error ? error.name : 'unknown'
186
+ )
187
+
188
+ response
189
+ .status(timedOut ? 504 : 502)
190
+ .json({ success: false, error: 'Failed to switch contract' })
191
+ }
192
+ }
193
+
194
+ export default handler
@@ -150,6 +150,112 @@ const hasVtexIdclientAutCookie = (request: NextApiRequest): boolean => {
150
150
  )
151
151
  }
152
152
 
153
+ /**
154
+ * Responds to a GraphQL execution that returned errors: recovers the
155
+ * upstream status from a FastStoreError when possible instead of collapsing
156
+ * everything to 500, and logs every error for observability.
157
+ */
158
+ const respondWithGraphqlErrors = (
159
+ errors: readonly unknown[],
160
+ response: Parameters<NextApiHandler>[1]
161
+ ) => {
162
+ // After error masking, entries are GraphQLError instances whose `name` is
163
+ // "GraphQLError" — the original FastStoreError (carrying the upstream
164
+ // status) is nested in `originalError`. Recover it so the BFF propagates
165
+ // the real status instead of collapsing everything to 500.
166
+ const fastStoreError = errors.map(recoverFastStoreError).find(Boolean)
167
+ const reportedErrors = errors.map((graphqlError) => {
168
+ const fsError = recoverFastStoreError(graphqlError)
169
+
170
+ return {
171
+ message: (graphqlError as { message?: string })?.message,
172
+ status: fsError?.extensions.status,
173
+ type: fsError?.extensions.type,
174
+ }
175
+ })
176
+
177
+ console.error('Graphql execution returned with error: ', reportedErrors)
178
+ OTELLogger(
179
+ 'error',
180
+ 'Graphql execution returned with error: %o',
181
+ reportedErrors
182
+ )
183
+
184
+ const status = fastStoreError?.extensions.status ?? 500
185
+
186
+ // Error responses are never cacheable: some upstream statuses (404, 410,
187
+ // ...) are heuristically cacheable by intermediaries per RFC 9111 §4.2.2,
188
+ // which would let a CDN cache an error for this operation.
189
+ response.setHeader('cache-control', 'no-store')
190
+
191
+ // No recoverable FastStoreError: keep the masked, body-less 500.
192
+ if (!fastStoreError) {
193
+ response.status(status).end()
194
+ return
195
+ }
196
+
197
+ // Only FastStoreError-derived details are exposed. `type`/`status` are a
198
+ // closed enum (safe everywhere); the free-text `message` may echo raw
199
+ // upstream text, so it is restricted to non-production responses. The
200
+ // full message is still available server-side via the log above.
201
+ const responseError = {
202
+ extensions: {
203
+ type: fastStoreError.extensions.type,
204
+ status: fastStoreError.extensions.status,
205
+ },
206
+ ...(process.env.NODE_ENV === 'production'
207
+ ? {}
208
+ : { message: fastStoreError.message }),
209
+ }
210
+
211
+ response.status(status)
212
+ response.setHeader('content-type', 'application/json')
213
+ response.send(JSON.stringify({ errors: [responseError] }))
214
+ }
215
+
216
+ /**
217
+ * Responds to an error thrown out of the main handler try-block (a rejected
218
+ * `parseRequest`/`execute`, or anything else unexpected).
219
+ */
220
+ const respondToUnexpectedError = (
221
+ err: unknown,
222
+ response: Parameters<NextApiHandler>[1]
223
+ ) => {
224
+ // Same rationale as `respondWithGraphqlErrors`: a 400/401/500 here must
225
+ // never be cached. This path can run after the success branch has already
226
+ // set a cacheable cache-control (e.g. `setHeader('set-cookie', ...)`
227
+ // rejecting a malformed upstream cookie, or `JSON.stringify` throwing
228
+ // after cache-control was set but before `send` completed). Guarded by
229
+ // `headersSent`: if the throw came from `send()` itself after headers were
230
+ // already flushed, `setHeader` would throw ERR_HTTP_HEADERS_SENT and this
231
+ // would fail to produce a handled response.
232
+ if (!response.headersSent) {
233
+ response.setHeader('cache-control', 'no-store')
234
+ }
235
+
236
+ console.error(
237
+ 'Something unexpected occurred querying Graphql endpoint: \n',
238
+ err
239
+ )
240
+ OTELLogger(
241
+ 'error',
242
+ 'Something unexpected occurred querying Graphql endpoint: %o',
243
+ err
244
+ )
245
+
246
+ if (err instanceof BadRequestError) {
247
+ response.status(400).end()
248
+ return
249
+ }
250
+
251
+ if (err instanceof UnauthorizedError) {
252
+ response.status(401).end()
253
+ return
254
+ }
255
+
256
+ response.status(500).end()
257
+ }
258
+
153
259
  const handler: NextApiHandler = async (request, response) => {
154
260
  if (request.method !== 'POST' && request.method !== 'GET') {
155
261
  response.status(405).end()
@@ -222,53 +328,7 @@ const handler: NextApiHandler = async (request, response) => {
222
328
  const hasErrors = Array.isArray(errors) && errors.length > 0
223
329
 
224
330
  if (hasErrors) {
225
- // After error masking, entries are GraphQLError instances whose
226
- // `name` is "GraphQLError" — the original FastStoreError (carrying the
227
- // upstream status) is nested in `originalError`. Recover it so the BFF
228
- // propagates the real status instead of collapsing everything to 500.
229
- const fastStoreError = errors.map(recoverFastStoreError).find(Boolean)
230
- const reportedErrors = errors.map((graphqlError) => {
231
- const fsError = recoverFastStoreError(graphqlError)
232
-
233
- return {
234
- message: (graphqlError as { message?: string })?.message,
235
- status: fsError?.extensions.status,
236
- type: fsError?.extensions.type,
237
- }
238
- })
239
-
240
- console.error('Graphql execution returned with error: ', reportedErrors)
241
- OTELLogger(
242
- 'error',
243
- 'Graphql execution returned with error: %o',
244
- reportedErrors
245
- )
246
-
247
- const status = fastStoreError?.extensions.status ?? 500
248
-
249
- // No recoverable FastStoreError: keep the masked, body-less 500.
250
- if (!fastStoreError) {
251
- response.status(status).end()
252
- return
253
- }
254
-
255
- // Only FastStoreError-derived details are exposed. `type`/`status` are a
256
- // closed enum (safe everywhere); the free-text `message` may echo raw
257
- // upstream text, so it is restricted to non-production responses. The
258
- // full message is still available server-side via the log above.
259
- const responseError = {
260
- extensions: {
261
- type: fastStoreError.extensions.type,
262
- status: fastStoreError.extensions.status,
263
- },
264
- ...(process.env.NODE_ENV !== 'production'
265
- ? { message: fastStoreError.message }
266
- : {}),
267
- }
268
-
269
- response.status(status)
270
- response.setHeader('content-type', 'application/json')
271
- response.send(JSON.stringify({ errors: [responseError] }))
331
+ respondWithGraphqlErrors(errors, response)
272
332
  return
273
333
  }
274
334
 
@@ -321,28 +381,7 @@ const handler: NextApiHandler = async (request, response) => {
321
381
  response.setHeader('content-type', 'application/json')
322
382
  response.send(JSON.stringify({ data, errors }))
323
383
  } catch (err) {
324
- console.error(
325
- 'Something unexpected occurred querying Graphql endpoint: \n',
326
- err
327
- )
328
- OTELLogger(
329
- 'error',
330
- 'Something unexpected occurred querying Graphql endpoint: %o',
331
- err
332
- )
333
-
334
- if (err instanceof BadRequestError) {
335
- response.status(400).end()
336
- return
337
- }
338
-
339
- if (err instanceof UnauthorizedError) {
340
- response.status(401).end()
341
- return
342
- }
343
-
344
- response.status(500).end()
345
- return
384
+ respondToUnexpectedError(err, response)
346
385
  }
347
386
  }
348
387
 
@@ -4,8 +4,9 @@ import type {
4
4
  ServerOrderDetailsQueryQuery,
5
5
  ServerProfileQueryQuery,
6
6
  } from '@generated/graphql'
7
+ import { useContext } from 'react'
7
8
  import type { PageGlobalContext } from 'src/sdk/overrides/PageProvider'
8
- import { usePage } from 'src/sdk/overrides/PageProvider'
9
+ import { PageContext, usePage } from 'src/sdk/overrides/PageProvider'
9
10
  import type { OrderStatusCmsLabels } from 'src/utils/userOrderStatus'
10
11
  import type { AccountNavigationLabels } from './getMyAccountRoutes'
11
12
 
@@ -76,9 +77,19 @@ export interface AccountPageContext extends PageGlobalContext {
76
77
  navigationLabels?: AccountNavigationLabels
77
78
  }
78
79
 
80
+ /**
81
+ * Same data as `usePage<AccountPageContext>().navigationLabels`, but safe to
82
+ * call from components (e.g. the B2B organization drawer) that render both
83
+ * inside and outside a My Account `PageProvider` tree — returns `undefined`
84
+ * instead of throwing when there is no provider, so callers fall back to
85
+ * their hardcoded default label.
86
+ */
79
87
  export function useAccountNavigationLabels() {
80
- const ctx = usePage<AccountPageContext>()
81
- return ctx.navigationLabels
88
+ const context = useContext(PageContext)?.context
89
+
90
+ return context && 'navigationLabels' in context
91
+ ? context.navigationLabels
92
+ : undefined
82
93
  }
83
94
 
84
95
  export function useAccountPageData<
@@ -1,7 +1,5 @@
1
1
  import fetch from 'isomorphic-unfetch'
2
2
 
3
- import storeConfig from '../../../discovery.config'
4
-
5
3
  export class ContractSwitchError extends Error {
6
4
  constructor(message: string) {
7
5
  super(message)
@@ -11,51 +9,20 @@ export class ContractSwitchError extends Error {
11
9
 
12
10
  export const isContractSwitchEnabled = true
13
11
 
14
- type VtexAuthCookie = {
15
- Name: string
16
- Value: string
17
- }
18
-
19
- export type SwitchPropertiesResponse = {
20
- authStatus?: string
21
- expiresIn?: number
22
- authCookie?: VtexAuthCookie | null
23
- accountAuthCookie?: VtexAuthCookie | null
24
- }
25
-
26
- const DEFAULT_AUTH_COOKIE_MAX_AGE = 86_400
27
- const SWITCH_PROPERTIES_TIMEOUT_MS = 30_000
28
-
29
- export function applyVtexAuthCookieFromSwitchResponse(
30
- response: SwitchPropertiesResponse
31
- ): void {
32
- if (globalThis.window === undefined) {
33
- return
34
- }
35
-
36
- const maxAge = response.expiresIn ?? DEFAULT_AUTH_COOKIE_MAX_AGE
37
- const secure =
38
- globalThis.window.location.protocol === 'https:' ? '; secure' : ''
39
-
40
- const cookies = [response.authCookie, response.accountAuthCookie].filter(
41
- (cookie): cookie is VtexAuthCookie =>
42
- Boolean(cookie?.Name?.trim() && cookie?.Value)
43
- )
44
-
45
- for (const cookie of cookies) {
46
- document.cookie = `${cookie.Name}=${cookie.Value}; path=/; max-age=${maxAge}; samesite=lax${secure}`
47
- }
48
- }
12
+ const SWITCH_CONTRACT_TIMEOUT_MS = 30_000
49
13
 
50
14
  /**
51
- * Switches the buyer's active contract via VTEX Identity storefront credential API.
15
+ * Switches the buyer's active contract through `/api/fs/switch-contract`.
52
16
  *
53
- * POST /api/authenticator/storefront/credential/switch-properties?an={account}
54
- * Body: { properties: { customerId: contractId } }
17
+ * The route relays the call to VTEX Identity and answers with a single
18
+ * `Set-Cookie` batch: the new auth cookie plus the expiry of the previous
19
+ * contract's checkout orderForm cookies, which are `HttpOnly` and therefore
20
+ * unreachable from here. Because both travel in the same response, there is no
21
+ * window where the browser holds the new credential alongside the old
22
+ * orderForm (B2BTEAM-3827).
55
23
  *
56
- * Applies the returned `authCookie` (and optional `accountAuthCookie`) so the
57
- * storefront JWT reflects the new commercial context. Throws `ContractSwitchError`
58
- * on hard failures so `switchContract` can keep the previous contract active.
24
+ * Throws `ContractSwitchError` on any failure so `switchContract` can keep the
25
+ * previous contract active.
59
26
  */
60
27
  export async function changeContractToken(
61
28
  contractId: string
@@ -64,23 +31,16 @@ export async function changeContractToken(
64
31
  throw new ContractSwitchError('Missing contractId for contract switch')
65
32
  }
66
33
 
67
- const an = encodeURIComponent(storeConfig.api.storeId)
68
- const url = `/api/authenticator/storefront/credential/switch-properties?an=${an}`
69
-
70
34
  let response: Response
71
35
  try {
72
- response = await fetch(url, {
36
+ response = await fetch('/api/fs/switch-contract', {
73
37
  method: 'POST',
74
38
  credentials: 'include',
75
39
  headers: {
76
40
  'Content-Type': 'application/json',
77
41
  },
78
- body: JSON.stringify({
79
- properties: {
80
- customerId: contractId,
81
- },
82
- }),
83
- signal: AbortSignal.timeout(SWITCH_PROPERTIES_TIMEOUT_MS),
42
+ body: JSON.stringify({ contractId }),
43
+ signal: AbortSignal.timeout(SWITCH_CONTRACT_TIMEOUT_MS),
84
44
  })
85
45
  } catch (error) {
86
46
  if (
@@ -101,19 +61,15 @@ export async function changeContractToken(
101
61
  )
102
62
  }
103
63
 
104
- const payload = (await response.json()) as SwitchPropertiesResponse
64
+ // Reading the body to completion guarantees the `Set-Cookie` headers are
65
+ // committed before `switchContract` reloads the page.
66
+ const payload = (await response.json().catch(() => null)) as {
67
+ success?: boolean
68
+ } | null
105
69
 
106
- if (payload.authStatus?.toLowerCase() !== 'success') {
70
+ if (!payload?.success) {
107
71
  throw new ContractSwitchError('Contract switch was not successful')
108
72
  }
109
73
 
110
- if (!payload.authCookie?.Name || !payload.authCookie?.Value) {
111
- throw new ContractSwitchError(
112
- 'Contract switch succeeded but no auth cookie was returned'
113
- )
114
- }
115
-
116
- applyVtexAuthCookieFromSwitchResponse(payload)
117
-
118
74
  return true
119
75
  }
@@ -4,20 +4,26 @@ import {
4
4
  STORAGE_KEY_PERSON_ID,
5
5
  } from 'src/utils/cookieCacheBusting'
6
6
 
7
+ /** Persisted `@faststore/sdk` stores that belong to the previous commercial context. */
8
+ export const PERSISTED_STORE_KEYS = ['fs::session', 'fs::cart'] as const
9
+
7
10
  /**
8
- * Clears client-side session persistence so a hard reload rehydrates from
9
- * validateSession instead of stale IndexedDB / sessionStorage values.
11
+ * Clears client-side persistence of the previous commercial context so a hard
12
+ * reload rehydrates from validateSession / validateCart under the new
13
+ * contract.
14
+ *
15
+ * The checkout orderForm cookies are `HttpOnly` and are expired by
16
+ * `/api/fs/switch-contract` instead (B2BTEAM-3827).
10
17
  */
11
18
  export async function clearPersistedSessionState(): Promise<void> {
12
19
  try {
13
20
  const { del } = await import('idb-keyval')
14
- await del('fs::session').catch(() => {})
21
+ await Promise.all(
22
+ PERSISTED_STORE_KEYS.map((key) => del(key).catch(() => {}))
23
+ )
15
24
  } catch {}
16
25
 
17
- if (typeof sessionStorage === 'undefined') {
18
- return
19
- }
20
-
26
+ if (typeof sessionStorage === 'undefined') return
21
27
  try {
22
28
  sessionStorage.removeItem(SESSION_READY_KEY)
23
29
  sessionStorage.removeItem(STORAGE_KEY_PERSON_ID)
@@ -22,6 +22,7 @@ export type AccountNavigationLabels = Partial<{
22
22
  companyLabel: string
23
23
  contractLabel: string
24
24
  cardsLabel: string
25
+ quotesLabel: string
25
26
  }>
26
27
 
27
28
  interface GetMyAccountRouteParams {
@@ -45,6 +46,7 @@ const ROUTE_LABEL_KEYS: Record<string, keyof AccountNavigationLabels> = {
45
46
  [USER_DETAILS_ROUTE]: 'userDetailsLabel',
46
47
  [SECURITY_ROUTE]: 'securityLabel',
47
48
  [CARDS_ROUTE]: 'cardsLabel',
49
+ [QUOTES_ROUTE]: 'quotesLabel',
48
50
  }
49
51
 
50
52
  // This is the default route list for My Account, we should add then as the feature is implemented
@@ -13,6 +13,7 @@ export const AvailableContractsQuery = gql(`
13
13
  id
14
14
  corporateName
15
15
  isActive
16
+ isDefault
16
17
  }
17
18
  }
18
19
  `)
@@ -11,8 +11,11 @@ import {
11
11
  * Orchestrates a full change of commercial context (REQ-06).
12
12
  *
13
13
  * Steps:
14
- * 1. POST switch-properties with the target contract id and apply the returned auth cookie.
15
- * 2. Clear persisted session state and reload the page.
14
+ * 1. POST `/api/fs/switch-contract`, which swaps the auth cookie and expires
15
+ * the previous contract's checkout orderForm in one response. A failure
16
+ * here changes nothing, so the previous contract stays active.
17
+ * 2. Clear persisted session and cart state and reload the page so
18
+ * validateSession/validateCart run under the new contract.
16
19
  */
17
20
  export const useSwitchContract = () => {
18
21
  const [loading, setLoading] = useState(false)
@@ -31,7 +34,12 @@ export const useSwitchContract = () => {
31
34
  return false
32
35
  }
33
36
 
34
- await clearPersistedSessionState()
37
+ // The cookies were already swapped, so the reload has to happen even if
38
+ // clearing the local stores fails: from here on the server session is
39
+ // the authoritative source and validateCart rebuilds the cart.
40
+ try {
41
+ await clearPersistedSessionState()
42
+ } catch {}
35
43
 
36
44
  if (globalThis.window !== undefined) {
37
45
  globalThis.window.location.reload()
@@ -62,7 +62,7 @@ export const isSearchPage = (x: any): x is SearchPageContext =>
62
62
  x?.data?.title != undefined ||
63
63
  x?.data?.searchTerm != undefined
64
64
 
65
- const PageContext = createContext<PageProviderContextValue | null>(null)
65
+ export const PageContext = createContext<PageProviderContextValue | null>(null)
66
66
 
67
67
  function PageProvider({
68
68
  context,
@@ -282,8 +282,9 @@ export const useSession = ({ filter }: SessionOptions = { filter: true }) => {
282
282
  channel,
283
283
  isValidating,
284
284
  isSessionReady,
285
+ hasValidated,
285
286
  }),
286
- [isValidating, session, channel, isSessionReady]
287
+ [isValidating, session, channel, isSessionReady, hasValidated]
287
288
  )
288
289
  }
289
290
 
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The Navbar account area must not render "Sign in" for a B2B buyer whose
3
+ * session has not been validated yet on this page load (Design QA 26/Aug
4
+ * 00:02:55–00:03:52). `isSessionReady` alone is pre-seeded from sessionStorage.
5
+ */
6
+ export const isSignInAreaResolved = ({
7
+ isSessionReady,
8
+ hasValidated,
9
+ }: {
10
+ isSessionReady: boolean
11
+ hasValidated: boolean
12
+ }): boolean => isSessionReady && hasValidated