@faststore/core 4.4.1-dev.0 → 4.5.0-dev.1

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 +4 -4
  2. package/.turbo/turbo-test.log +137 -51
  3. package/@generated/cached-operations.json +4 -0
  4. package/@generated/gql.ts +30 -0
  5. package/@generated/graphql.ts +141 -0
  6. package/@generated/persisted-documents.json +5 -0
  7. package/@generated/schema.graphql +100 -0
  8. package/CHANGELOG.md +26 -0
  9. package/cms/faststore/components/cms_component__recommendationshelf.jsonc +86 -0
  10. package/cms/faststore/schema.json +89 -0
  11. package/discovery.config.default.js +3 -0
  12. package/discovery.config.js +10 -1
  13. package/discovery.config.local.example.js +62 -0
  14. package/package.json +6 -6
  15. package/public/icons.svg +4 -0
  16. package/src/Layout.tsx +6 -0
  17. package/src/components/account/Drawer/OrganizationDrawer/ContractSwitcher/ContractSwitcher.tsx +376 -0
  18. package/src/components/account/Drawer/OrganizationDrawer/ContractSwitcher/index.ts +2 -0
  19. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawer.tsx +52 -23
  20. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerBody.tsx +16 -6
  21. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerFooter.tsx +55 -0
  22. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerHeader.tsx +45 -5
  23. package/src/components/account/Drawer/OrganizationDrawer/index.ts +5 -0
  24. package/src/components/account/Drawer/OrganizationDrawer/section.module.scss +452 -54
  25. package/src/components/account/Layout/Layout.tsx +14 -5
  26. package/src/components/account/quotes/MyAccountListQuotes/MyAccountListQuotes.tsx +170 -0
  27. package/src/components/account/quotes/MyAccountListQuotes/MyAccountListQuotesTable/MyAccountListQuotesTable.tsx +194 -0
  28. package/src/components/account/quotes/MyAccountListQuotes/MyAccountListQuotesTable/MyAccountQuoteStatusBadge.tsx +15 -0
  29. package/src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesFilterSlider/MyAccountQuotesFilterSlider.tsx +162 -0
  30. package/src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesFilterSlider/section.module.scss +49 -0
  31. package/src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesSelectedTags/MyAccountQuotesSelectedTags.tsx +125 -0
  32. package/src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesStatusSelector/MyAccountQuotesStatusSelector.tsx +107 -0
  33. package/src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesStatusSelector/styles.module.scss +144 -0
  34. package/src/components/account/quotes/MyAccountListQuotes/index.ts +2 -0
  35. package/src/components/account/quotes/MyAccountListQuotes/quoteFilters.ts +89 -0
  36. package/src/components/account/quotes/MyAccountListQuotes/styles.module.scss +174 -0
  37. package/src/components/cms/global/Components.ts +10 -0
  38. package/src/components/sections/ProductGallery/ProductGallery.tsx +1 -1
  39. package/src/components/sections/RecommendationShelf/RecommendationShelf.tsx +223 -0
  40. package/src/components/sections/RecommendationShelf/RecommendationShelf.types.ts +111 -0
  41. package/src/components/sections/RecommendationShelf/index.ts +1 -0
  42. package/src/components/sections/RecommendationShelf/section.module.scss +49 -0
  43. package/src/components/sections/RecommendationShelf/useRecommendationUserId.ts +49 -0
  44. package/src/components/sections/RecommendationShelf/useRecommendations.ts +55 -0
  45. package/src/components/sections/RecommendationShelf/vrn.ts +55 -0
  46. package/src/customizations/src/myAccount/extensions/quotes/after.tsx +5 -0
  47. package/src/customizations/src/myAccount/extensions/quotes/before.tsx +5 -0
  48. package/src/pages/[slug]/p.tsx +45 -0
  49. package/src/pages/pvt/account/quotes/index.tsx +264 -0
  50. package/src/sdk/account/changeContractToken.ts +119 -0
  51. package/src/sdk/account/clearPersistedSessionState.ts +26 -0
  52. package/src/sdk/account/getMyAccountRoutes.ts +7 -0
  53. package/src/sdk/account/refreshToken.ts +21 -8
  54. package/src/sdk/account/useAvailableContracts.ts +49 -0
  55. package/src/sdk/account/useSwitchContract.ts +60 -0
  56. package/src/sdk/analytics/hooks/useStartRecommendationSession.ts +96 -0
  57. package/src/sdk/analytics/utils.ts +9 -0
  58. package/src/utils/quoteStatus.ts +27 -0
  59. package/src/utils/retry.ts +69 -0
  60. package/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx +235 -0
  61. package/test/components/RecommendationShelf/useRecommendationUserId.test.ts +69 -0
  62. package/test/components/RecommendationShelf/useRecommendations.test.ts +52 -0
  63. package/test/components/RecommendationShelf/vrn.test.ts +56 -0
  64. package/test/components/account/ContractSwitcher.browser.test.tsx +236 -0
  65. package/test/components/account/Layout.test.tsx +62 -0
  66. package/test/components/account/OrganizationDrawer.browser.test.tsx +197 -0
  67. package/test/components/account/OrganizationDrawerBody.test.tsx +52 -0
  68. package/test/components/account/OrganizationDrawerFooter.browser.test.tsx +54 -0
  69. package/test/components/account/OrganizationDrawerHeader.browser.test.tsx +55 -0
  70. package/test/components/account/doLogout.browser.test.ts +102 -0
  71. package/test/components/quotes/MyAccountListQuotes.test.tsx +232 -0
  72. package/test/components/quotes/MyAccountListQuotesTable.test.tsx +240 -0
  73. package/test/components/quotes/MyAccountQuoteStatusBadge.test.tsx +28 -0
  74. package/test/components/quotes/MyAccountQuotesFilterSlider.test.tsx +152 -0
  75. package/test/components/quotes/MyAccountQuotesSelectedTags.test.tsx +122 -0
  76. package/test/components/quotes/MyAccountQuotesStatusSelector.test.tsx +85 -0
  77. package/test/components/quotes/quoteFilters.test.ts +185 -0
  78. package/test/sdk/account/changeContractToken.browser.test.ts +157 -0
  79. package/test/sdk/account/clearPersistedSessionState.browser.test.ts +41 -0
  80. package/test/sdk/account/clearPersistedSessionState.test.ts +20 -0
  81. package/test/sdk/account/getMyAccountRoutes.test.ts +90 -0
  82. package/test/sdk/account/refreshToken.browser.test.ts +34 -0
  83. package/test/sdk/account/refreshToken.test.ts +87 -0
  84. package/test/sdk/account/useAvailableContracts.browser.test.ts +124 -0
  85. package/test/sdk/account/useSwitchContract.browser.test.ts +111 -0
  86. package/test/sdk/analytics/useStartRecommendationSession.test.ts +69 -0
  87. package/test/sdk/analytics/utils.test.ts +23 -0
  88. package/test/server/index.test.ts +5 -0
  89. package/test/utils/retry.test.ts +59 -0
@@ -0,0 +1,264 @@
1
+ import type { Locator } from '@vtex/client-cms'
2
+ import type { GetServerSideProps } from 'next'
3
+ import { NextSeo } from 'next-seo'
4
+ import type { ComponentType } from 'react'
5
+ import { Layout } from 'src/components/account'
6
+ import RenderSections from 'src/components/cms/RenderSections'
7
+ import GLOBAL_COMPONENTS from 'src/components/cms/global/Components'
8
+ import CUSTOM_COMPONENTS from 'src/customizations/src/components'
9
+
10
+ import { getGlobalSectionsData } from 'src/components/cms/GlobalSections'
11
+
12
+ import { gql } from '@generated/gql'
13
+ import type {
14
+ ServerAccountPageQueryQuery,
15
+ ServerAccountPageQueryQueryVariables,
16
+ ServerListQuotesQueryQuery,
17
+ ServerListQuotesQueryQueryVariables,
18
+ ServerQuotesOrganizationMemberQueryQuery,
19
+ ServerQuotesOrganizationMemberQueryQueryVariables,
20
+ } from '@generated/graphql'
21
+ import { ServerAccountPageQueryDocument } from '@generated/graphql'
22
+ import AfterSection from 'src/customizations/src/myAccount/extensions/quotes/after'
23
+ import BeforeSection from 'src/customizations/src/myAccount/extensions/quotes/before'
24
+ import type { MyAccountProps } from 'src/experimental/myAccountServerSideProps'
25
+ import { execute } from 'src/server'
26
+ import { injectGlobalSections } from 'src/server/cms/global'
27
+ import { withLocaleValidationSSR } from 'src/utils/localization/withLocaleValidation'
28
+ import { getMyAccountRedirect } from 'src/utils/myAccountRedirect'
29
+
30
+ import storeConfig from 'discovery.config'
31
+ import { MyAccountListQuotes } from 'src/components/account/quotes/MyAccountListQuotes'
32
+ import { getIsRepresentative } from 'src/sdk/account/getIsRepresentative'
33
+ import PageProvider from 'src/sdk/overrides/PageProvider'
34
+ import { extractStatusFromError } from 'src/utils/utilities'
35
+
36
+ const COMPONENTS: Record<string, ComponentType<any>> = {
37
+ ...GLOBAL_COMPONENTS,
38
+ ...CUSTOM_COMPONENTS,
39
+ }
40
+
41
+ type ListQuotesPageProps = {
42
+ listQuotes: ServerListQuotesQueryQuery['listUserQuotes']
43
+ total: number
44
+ perPage: number
45
+ filters: {
46
+ page: number
47
+ status: string[]
48
+ createdAtFrom: string
49
+ createdAtTo: string
50
+ expiresAtFrom: string
51
+ expiresAtTo: string
52
+ label: string
53
+ }
54
+ } & MyAccountProps
55
+
56
+ export default function ListQuotesPage({
57
+ globalSections: globalSectionsProp,
58
+ accountName,
59
+ listQuotes,
60
+ total,
61
+ perPage,
62
+ filters,
63
+ isRepresentative,
64
+ }: ListQuotesPageProps) {
65
+ const { sections: globalSections, settings: globalSettings } =
66
+ globalSectionsProp ?? {}
67
+
68
+ return (
69
+ <PageProvider context={{ globalSettings }}>
70
+ <RenderSections globalSections={globalSections} components={COMPONENTS}>
71
+ <NextSeo noindex nofollow />
72
+
73
+ <Layout isRepresentative={isRepresentative} accountName={accountName}>
74
+ <BeforeSection />
75
+ <MyAccountListQuotes
76
+ listQuotes={listQuotes}
77
+ filters={filters}
78
+ perPage={perPage}
79
+ total={total}
80
+ />
81
+ <AfterSection />
82
+ </Layout>
83
+ </RenderSections>
84
+ </PageProvider>
85
+ )
86
+ }
87
+
88
+ const query = gql(`
89
+ query ServerListQuotesQuery ($page: Int, $perPage: Int, $status: [String], $createdAtFrom: String, $createdAtTo: String, $expiresAtFrom: String, $expiresAtTo: String, $label: String) {
90
+ listUserQuotes (page: $page, perPage: $perPage, status: $status, createdAtFrom: $createdAtFrom, createdAtTo: $createdAtTo, expiresAtFrom: $expiresAtFrom, expiresAtTo: $expiresAtTo, label: $label) {
91
+ list {
92
+ id
93
+ status
94
+ label
95
+ createdAt
96
+ expiresAt
97
+ amount
98
+ createdBy
99
+ }
100
+ paging {
101
+ total
102
+ currentPage
103
+ perPage
104
+ }
105
+ }
106
+ }
107
+ `)
108
+
109
+ const organizationMemberQuery = gql(`
110
+ query ServerQuotesOrganizationMemberQuery {
111
+ isOrganizationMember
112
+ }
113
+ `)
114
+
115
+ const getServerSidePropsBase: GetServerSideProps<
116
+ MyAccountProps,
117
+ Record<string, string>,
118
+ Locator
119
+ > = async (context) => {
120
+ const isRepresentative = getIsRepresentative({
121
+ headers: context.req.headers as Record<string, string>,
122
+ account: storeConfig.api.storeId,
123
+ })
124
+
125
+ const contentContext = {
126
+ previewData: context.previewData,
127
+ locale: context.locale,
128
+ }
129
+
130
+ const { isFaststoreMyAccountEnabled, redirect } = getMyAccountRedirect({
131
+ query: context.query,
132
+ })
133
+
134
+ if (!isFaststoreMyAccountEnabled) {
135
+ return { redirect }
136
+ }
137
+
138
+ const [
139
+ globalSectionsPromise,
140
+ globalSectionsHeaderPromise,
141
+ globalSectionsFooterPromise,
142
+ ] = getGlobalSectionsData(contentContext)
143
+
144
+ const page = Number(context.query.page as string | undefined) || 1
145
+ const perPage = 25
146
+ const status = (
147
+ Array.isArray(context.query.status)
148
+ ? context.query.status
149
+ : [context.query.status]
150
+ ).filter(Boolean) as string[]
151
+ const createdAtFrom =
152
+ (context.query.createdAtFrom as string | undefined) || ''
153
+ const createdAtTo = (context.query.createdAtTo as string | undefined) || ''
154
+ const expiresAtFrom =
155
+ (context.query.expiresAtFrom as string | undefined) || ''
156
+ const expiresAtTo = (context.query.expiresAtTo as string | undefined) || ''
157
+ const label = (context.query.label as string | undefined) || ''
158
+
159
+ const [
160
+ listQuotesResult,
161
+ accountProfileResult,
162
+ organizationMemberResult,
163
+ globalSections,
164
+ globalSectionsHeader,
165
+ globalSectionsFooter,
166
+ ] = await Promise.all([
167
+ execute<ServerListQuotesQueryQueryVariables, ServerListQuotesQueryQuery>(
168
+ {
169
+ variables: {
170
+ page,
171
+ perPage,
172
+ status,
173
+ createdAtFrom: createdAtFrom || undefined,
174
+ createdAtTo: createdAtTo || undefined,
175
+ expiresAtFrom: expiresAtFrom || undefined,
176
+ expiresAtTo: expiresAtTo || undefined,
177
+ label: label || undefined,
178
+ },
179
+ operation: query,
180
+ },
181
+ { headers: { ...context.req.headers } }
182
+ ),
183
+ execute<ServerAccountPageQueryQueryVariables, ServerAccountPageQueryQuery>(
184
+ { variables: {}, operation: ServerAccountPageQueryDocument },
185
+ { headers: { ...context.req.headers } }
186
+ ).catch(() => null),
187
+ execute<
188
+ ServerQuotesOrganizationMemberQueryQueryVariables,
189
+ ServerQuotesOrganizationMemberQueryQuery
190
+ >(
191
+ { variables: {}, operation: organizationMemberQuery },
192
+ { headers: { ...context.req.headers } }
193
+ ).catch(() => null),
194
+ globalSectionsPromise,
195
+ globalSectionsHeaderPromise,
196
+ globalSectionsFooterPromise,
197
+ ])
198
+
199
+ if (!organizationMemberResult?.data?.isOrganizationMember) {
200
+ return {
201
+ redirect: {
202
+ destination: `/pvt/account/403?from=${encodeURIComponent('/pvt/account/quotes')}`,
203
+ permanent: false,
204
+ },
205
+ }
206
+ }
207
+
208
+ if (listQuotesResult.errors) {
209
+ console.error(...listQuotesResult.errors)
210
+
211
+ const errorStatus = extractStatusFromError(listQuotesResult.errors[0])
212
+
213
+ const destination =
214
+ errorStatus === 403 || errorStatus === 401
215
+ ? `/pvt/account/403?from=${encodeURIComponent('/pvt/account/quotes')}`
216
+ : '/pvt/account/404'
217
+
218
+ return {
219
+ redirect: {
220
+ destination,
221
+ permanent: false,
222
+ },
223
+ }
224
+ }
225
+
226
+ if (!listQuotesResult.data.listUserQuotes) {
227
+ return {
228
+ redirect: {
229
+ destination: '/pvt/account/404',
230
+ permanent: false,
231
+ },
232
+ }
233
+ }
234
+
235
+ const globalSectionsResult = injectGlobalSections({
236
+ globalSections,
237
+ globalSectionsHeader,
238
+ globalSectionsFooter,
239
+ })
240
+
241
+ return {
242
+ props: {
243
+ globalSections: globalSectionsResult,
244
+ accountName: accountProfileResult?.data?.accountProfile?.name ?? '',
245
+ listQuotes: listQuotesResult.data.listUserQuotes,
246
+ total: listQuotesResult.data.listUserQuotes.paging.total,
247
+ perPage: listQuotesResult.data.listUserQuotes.paging.perPage,
248
+ filters: {
249
+ page: listQuotesResult.data.listUserQuotes.paging.currentPage ?? page,
250
+ status,
251
+ createdAtFrom,
252
+ createdAtTo,
253
+ expiresAtFrom,
254
+ expiresAtTo,
255
+ label,
256
+ },
257
+ isRepresentative,
258
+ },
259
+ }
260
+ }
261
+
262
+ export const getServerSideProps = withLocaleValidationSSR(
263
+ getServerSidePropsBase
264
+ )
@@ -0,0 +1,119 @@
1
+ import fetch from 'isomorphic-unfetch'
2
+
3
+ import storeConfig from '../../../discovery.config'
4
+
5
+ export class ContractSwitchError extends Error {
6
+ constructor(message: string) {
7
+ super(message)
8
+ this.name = 'ContractSwitchError'
9
+ }
10
+ }
11
+
12
+ export const isContractSwitchEnabled = true
13
+
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
+ }
49
+
50
+ /**
51
+ * Switches the buyer's active contract via VTEX Identity storefront credential API.
52
+ *
53
+ * POST /api/authenticator/storefront/credential/switch-properties?an={account}
54
+ * Body: { properties: { customerId: contractId } }
55
+ *
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.
59
+ */
60
+ export async function changeContractToken(
61
+ contractId: string
62
+ ): Promise<boolean> {
63
+ if (!contractId) {
64
+ throw new ContractSwitchError('Missing contractId for contract switch')
65
+ }
66
+
67
+ const an = encodeURIComponent(storeConfig.api.storeId)
68
+ const url = `/api/authenticator/storefront/credential/switch-properties?an=${an}`
69
+
70
+ let response: Response
71
+ try {
72
+ response = await fetch(url, {
73
+ method: 'POST',
74
+ credentials: 'include',
75
+ headers: {
76
+ 'Content-Type': 'application/json',
77
+ },
78
+ body: JSON.stringify({
79
+ properties: {
80
+ customerId: contractId,
81
+ },
82
+ }),
83
+ signal: AbortSignal.timeout(SWITCH_PROPERTIES_TIMEOUT_MS),
84
+ })
85
+ } catch (error) {
86
+ if (
87
+ error instanceof Error &&
88
+ (error.name === 'TimeoutError' || error.name === 'AbortError')
89
+ ) {
90
+ throw new ContractSwitchError('Contract switch timed out')
91
+ }
92
+
93
+ throw error instanceof Error
94
+ ? new ContractSwitchError(`Failed to switch contract: ${error.message}`)
95
+ : new ContractSwitchError('Failed to switch contract')
96
+ }
97
+
98
+ if (!response.ok) {
99
+ throw new ContractSwitchError(
100
+ `Failed to switch contract (${response.status})`
101
+ )
102
+ }
103
+
104
+ const payload = (await response.json()) as SwitchPropertiesResponse
105
+
106
+ if (payload.authStatus?.toLowerCase() !== 'success') {
107
+ throw new ContractSwitchError('Contract switch was not successful')
108
+ }
109
+
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
+ return true
119
+ }
@@ -0,0 +1,26 @@
1
+ import { SESSION_READY_KEY } from '../session/storageKeys'
2
+ import {
3
+ STORAGE_KEY_CACHE_BUST_LAST_VALUE,
4
+ STORAGE_KEY_PERSON_ID,
5
+ } from 'src/utils/cookieCacheBusting'
6
+
7
+ /**
8
+ * Clears client-side session persistence so a hard reload rehydrates from
9
+ * validateSession instead of stale IndexedDB / sessionStorage values.
10
+ */
11
+ export async function clearPersistedSessionState(): Promise<void> {
12
+ try {
13
+ const { del } = await import('idb-keyval')
14
+ await del('fs::session').catch(() => {})
15
+ } catch {}
16
+
17
+ if (typeof sessionStorage === 'undefined') {
18
+ return
19
+ }
20
+
21
+ try {
22
+ sessionStorage.removeItem(SESSION_READY_KEY)
23
+ sessionStorage.removeItem(STORAGE_KEY_PERSON_ID)
24
+ sessionStorage.removeItem(STORAGE_KEY_CACHE_BUST_LAST_VALUE)
25
+ } catch {}
26
+ }
@@ -27,6 +27,9 @@ export const PROFILE_ROUTE = '/pvt/account/profile'
27
27
  export const ORDERS_ROUTE = '/pvt/account/orders'
28
28
  export const USER_DETAILS_ROUTE = '/pvt/account/user-details'
29
29
  export const SECURITY_ROUTE = '/pvt/account/security'
30
+ export const QUOTES_ROUTE = '/pvt/account/quotes'
31
+
32
+ export const ROUTES_ONLY_FOR_B2B_MEMBERS = [QUOTES_ROUTE]
30
33
 
31
34
  const ROUTE_LABEL_KEYS: Record<string, keyof AccountNavigationLabels> = {
32
35
  [PROFILE_ROUTE]: 'profileLabel',
@@ -45,6 +48,10 @@ const DEFAULT_ROUTES: Route[] = [
45
48
  title: 'Orders',
46
49
  route: ORDERS_ROUTE,
47
50
  },
51
+ {
52
+ title: 'Quotes',
53
+ route: QUOTES_ROUTE,
54
+ },
48
55
  {
49
56
  title: 'User Details',
50
57
  route: USER_DETAILS_ROUTE,
@@ -25,19 +25,32 @@ async function fetchWithRetry(
25
25
  return undefined
26
26
  }
27
27
 
28
- export const refreshTokenRequest = async (): Promise<
29
- RefreshTokenResponse | undefined
30
- > => {
31
- const storeOriginURL = new URL(getStoreURL()).origin
32
- const REFRESH_TOKEN_URL = `${storeOriginURL}/api/vtexid/refreshtoken/webstore`
28
+ function getRefreshTokenUrl(): string {
29
+ if (globalThis.window !== undefined) {
30
+ return '/api/vtexid/refreshtoken/webstore'
31
+ }
32
+
33
+ return `${new URL(getStoreURL()).origin}/api/vtexid/refreshtoken/webstore`
34
+ }
35
+
36
+ function getRefreshTokenHeaders(): HeadersInit {
33
37
  const headers: HeadersInit = {
34
38
  'content-type': 'application/json',
35
- Host: `${sanitizeHost(storeOriginURL)}`,
36
39
  }
37
40
 
38
- return await fetchWithRetry(REFRESH_TOKEN_URL, {
41
+ if (globalThis.window === undefined) {
42
+ headers.Host = `${sanitizeHost(new URL(getStoreURL()).origin)}`
43
+ }
44
+
45
+ return headers
46
+ }
47
+
48
+ export const refreshTokenRequest = async (): Promise<
49
+ RefreshTokenResponse | undefined
50
+ > => {
51
+ return fetchWithRetry(getRefreshTokenUrl(), {
39
52
  credentials: 'include',
40
- headers,
53
+ headers: getRefreshTokenHeaders(),
41
54
  body: JSON.stringify({}),
42
55
  method: 'POST',
43
56
  })
@@ -0,0 +1,49 @@
1
+ import { gql } from '@generated'
2
+ import type {
3
+ AvailableContractsQueryQuery,
4
+ AvailableContractsQueryQueryVariables,
5
+ } from '@generated/graphql'
6
+
7
+ import { useQuery } from '../graphql/useQuery'
8
+ import { useSession } from '../session'
9
+
10
+ export const AvailableContractsQuery = gql(`
11
+ query AvailableContractsQuery($orgUnitId: String!) {
12
+ availableContracts(orgUnitId: $orgUnitId) {
13
+ id
14
+ corporateName
15
+ isActive
16
+ }
17
+ }
18
+ `)
19
+
20
+ export type AvailableContract =
21
+ AvailableContractsQueryQuery['availableContracts'][number]
22
+
23
+ /**
24
+ * Lists the contracts associated with the current buyer's Organization Unit,
25
+ * resolved to human-readable corporate names. The list is governed server-side:
26
+ * only contracts associated with the Org Unit are ever returned (REQ-05).
27
+ *
28
+ * The query is gated by `enabled` so it only runs on demand (e.g. when the
29
+ * switcher is opened), protecting performance budgets.
30
+ */
31
+ export const useAvailableContracts = (enabled = true) => {
32
+ const { b2b } = useSession()
33
+ const orgUnitId = b2b?.unitId ?? ''
34
+
35
+ const { data, error, isValidating } = useQuery<
36
+ AvailableContractsQueryQuery,
37
+ AvailableContractsQueryQueryVariables
38
+ >(
39
+ AvailableContractsQuery,
40
+ { orgUnitId },
41
+ { doNotRun: !enabled || !orgUnitId, dedupingInterval: 0 }
42
+ )
43
+
44
+ return {
45
+ contracts: data?.availableContracts ?? [],
46
+ loading: (enabled && !!orgUnitId && !data && !error) || isValidating,
47
+ error: error ?? null,
48
+ }
49
+ }
@@ -0,0 +1,60 @@
1
+ import { useState } from 'react'
2
+
3
+ import { clearPersistedSessionState } from './clearPersistedSessionState'
4
+ import {
5
+ ContractSwitchError,
6
+ changeContractToken,
7
+ isContractSwitchEnabled,
8
+ } from './changeContractToken'
9
+
10
+ /**
11
+ * Orchestrates a full change of commercial context (REQ-06).
12
+ *
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.
16
+ */
17
+ export const useSwitchContract = () => {
18
+ const [loading, setLoading] = useState(false)
19
+ const [error, setError] = useState<Error | null>(null)
20
+
21
+ const switchContract = async (contractId: string): Promise<boolean> => {
22
+ setLoading(true)
23
+ setError(null)
24
+
25
+ try {
26
+ const switched = await changeContractToken(contractId)
27
+ if (!switched) {
28
+ setError(
29
+ new ContractSwitchError('Contract switching is not available yet')
30
+ )
31
+ return false
32
+ }
33
+
34
+ await clearPersistedSessionState()
35
+
36
+ if (globalThis.window !== undefined) {
37
+ globalThis.window.location.reload()
38
+ }
39
+
40
+ return true
41
+ } catch (err) {
42
+ const normalized =
43
+ err instanceof Error
44
+ ? err
45
+ : new ContractSwitchError('Failed to switch contract')
46
+ setError(normalized)
47
+
48
+ return false
49
+ } finally {
50
+ setLoading(false)
51
+ }
52
+ }
53
+
54
+ return {
55
+ switchContract,
56
+ loading,
57
+ error,
58
+ enabled: isContractSwitchEnabled,
59
+ }
60
+ }
@@ -0,0 +1,96 @@
1
+ import { useEffect } from 'react'
2
+
3
+ import { gql } from '@faststore/core/api'
4
+
5
+ import storeConfig from 'discovery.config'
6
+ import { useLazyQuery } from 'src/sdk/graphql/useLazyQuery'
7
+ import { getCookie } from 'src/utils/getCookie'
8
+ import { retry } from 'src/utils/retry'
9
+
10
+ // Cookie set once the anonymous personalization session has been started,
11
+ // so we only fire `StartRecommendationSession` a single time per browser session.
12
+ const VTEX_REC_USER_START_SESSION = 'vtex-rec-user-start-session'
13
+
14
+ const startRecommendationSessionMutation = gql(`
15
+ mutation StartRecommendationSession {
16
+ startRecommendationSession
17
+ }
18
+ `)
19
+
20
+ type StartRecommendationSessionData = { startRecommendationSession: boolean }
21
+ type StartRecommendationSessionVariables = Record<string, never>
22
+
23
+ /**
24
+ * Starts the anonymous recommendation/personalization session once per browser
25
+ * session.
26
+ *
27
+ * Mounted globally from `Layout` so it runs on every page, regardless of
28
+ * whether a recommendation section is present.
29
+ *
30
+ * The product view is no longer reported through a mutation here: the PDP now
31
+ * exposes the product data as `<meta property="product:*">` tags, which the
32
+ * Activity Flow script reads to capture the product view event.
33
+ *
34
+ * All work runs client-side in effects (after hydration), so it doesn't affect
35
+ * SSR/TTFB or Lighthouse render metrics.
36
+ *
37
+ * Gated behind the `experimental.enableRecommendations` flag: stores that don't
38
+ * opt into Recommendations never start a session nor hit the Recommendations API.
39
+ */
40
+ export function useStartRecommendationSession() {
41
+ const [runStartRecommendationSession] = useLazyQuery<
42
+ StartRecommendationSessionData,
43
+ StartRecommendationSessionVariables
44
+ >(
45
+ startRecommendationSessionMutation,
46
+ {} as StartRecommendationSessionVariables,
47
+ {
48
+ doNotRun: true,
49
+ }
50
+ )
51
+
52
+ useEffect(() => {
53
+ if (!storeConfig.experimental.enableRecommendations) {
54
+ return
55
+ }
56
+
57
+ const startRecommendationSessionCookie = getCookie(
58
+ VTEX_REC_USER_START_SESSION
59
+ )
60
+
61
+ if (startRecommendationSessionCookie) {
62
+ return
63
+ }
64
+
65
+ const controller = new AbortController()
66
+
67
+ // The session endpoint may not be ready on the first try, so we retry with
68
+ // exponential backoff until it returns a defined result (or we give up).
69
+ // Transient request/GraphQL rejections are treated as retryable: swallow
70
+ // the error and return `undefined` so the backoff loop keeps going instead
71
+ // of aborting on the first failure.
72
+ void retry(
73
+ async () => {
74
+ try {
75
+ return await runStartRecommendationSession({})
76
+ } catch {
77
+ return undefined
78
+ }
79
+ },
80
+ {
81
+ attempts: 10,
82
+ delayMs: 300,
83
+ backoff: true,
84
+ maxDelayMs: 3000,
85
+ until: (result) => result !== undefined,
86
+ signal: controller.signal,
87
+ }
88
+ // Guard the discarded promise so a give-up (or any unexpected rejection)
89
+ // never surfaces as an unhandled promise rejection.
90
+ ).catch(() => {})
91
+
92
+ return () => {
93
+ controller.abort()
94
+ }
95
+ }, [runStartRecommendationSession])
96
+ }
@@ -0,0 +1,9 @@
1
+ import { getCookie } from 'src/utils/getCookie'
2
+
3
+ // Name of the cookie that holds the anonymous user id used by the
4
+ // recommendation/personalization pixel.
5
+ const USER_ID_COOKIE = 'vtex-rec-user-id'
6
+
7
+ export function getUserIdFromCookie(): string {
8
+ return getCookie(USER_ID_COOKIE) ?? ''
9
+ }