@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.
- package/.turbo/turbo-generate.log +4 -4
- package/.turbo/turbo-test.log +137 -51
- package/@generated/cached-operations.json +4 -0
- package/@generated/gql.ts +30 -0
- package/@generated/graphql.ts +141 -0
- package/@generated/persisted-documents.json +5 -0
- package/@generated/schema.graphql +100 -0
- package/CHANGELOG.md +26 -0
- package/cms/faststore/components/cms_component__recommendationshelf.jsonc +86 -0
- package/cms/faststore/schema.json +89 -0
- package/discovery.config.default.js +3 -0
- package/discovery.config.js +10 -1
- package/discovery.config.local.example.js +62 -0
- package/package.json +6 -6
- package/public/icons.svg +4 -0
- package/src/Layout.tsx +6 -0
- package/src/components/account/Drawer/OrganizationDrawer/ContractSwitcher/ContractSwitcher.tsx +376 -0
- package/src/components/account/Drawer/OrganizationDrawer/ContractSwitcher/index.ts +2 -0
- package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawer.tsx +52 -23
- package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerBody.tsx +16 -6
- package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerFooter.tsx +55 -0
- package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerHeader.tsx +45 -5
- package/src/components/account/Drawer/OrganizationDrawer/index.ts +5 -0
- package/src/components/account/Drawer/OrganizationDrawer/section.module.scss +452 -54
- package/src/components/account/Layout/Layout.tsx +14 -5
- package/src/components/account/quotes/MyAccountListQuotes/MyAccountListQuotes.tsx +170 -0
- package/src/components/account/quotes/MyAccountListQuotes/MyAccountListQuotesTable/MyAccountListQuotesTable.tsx +194 -0
- package/src/components/account/quotes/MyAccountListQuotes/MyAccountListQuotesTable/MyAccountQuoteStatusBadge.tsx +15 -0
- package/src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesFilterSlider/MyAccountQuotesFilterSlider.tsx +162 -0
- package/src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesFilterSlider/section.module.scss +49 -0
- package/src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesSelectedTags/MyAccountQuotesSelectedTags.tsx +125 -0
- package/src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesStatusSelector/MyAccountQuotesStatusSelector.tsx +107 -0
- package/src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesStatusSelector/styles.module.scss +144 -0
- package/src/components/account/quotes/MyAccountListQuotes/index.ts +2 -0
- package/src/components/account/quotes/MyAccountListQuotes/quoteFilters.ts +89 -0
- package/src/components/account/quotes/MyAccountListQuotes/styles.module.scss +174 -0
- package/src/components/cms/global/Components.ts +10 -0
- package/src/components/sections/ProductGallery/ProductGallery.tsx +1 -1
- package/src/components/sections/RecommendationShelf/RecommendationShelf.tsx +223 -0
- package/src/components/sections/RecommendationShelf/RecommendationShelf.types.ts +111 -0
- package/src/components/sections/RecommendationShelf/index.ts +1 -0
- package/src/components/sections/RecommendationShelf/section.module.scss +49 -0
- package/src/components/sections/RecommendationShelf/useRecommendationUserId.ts +49 -0
- package/src/components/sections/RecommendationShelf/useRecommendations.ts +55 -0
- package/src/components/sections/RecommendationShelf/vrn.ts +55 -0
- package/src/customizations/src/myAccount/extensions/quotes/after.tsx +5 -0
- package/src/customizations/src/myAccount/extensions/quotes/before.tsx +5 -0
- package/src/pages/[slug]/p.tsx +45 -0
- package/src/pages/pvt/account/quotes/index.tsx +264 -0
- package/src/sdk/account/changeContractToken.ts +119 -0
- package/src/sdk/account/clearPersistedSessionState.ts +26 -0
- package/src/sdk/account/getMyAccountRoutes.ts +7 -0
- package/src/sdk/account/refreshToken.ts +21 -8
- package/src/sdk/account/useAvailableContracts.ts +49 -0
- package/src/sdk/account/useSwitchContract.ts +60 -0
- package/src/sdk/analytics/hooks/useStartRecommendationSession.ts +96 -0
- package/src/sdk/analytics/utils.ts +9 -0
- package/src/utils/quoteStatus.ts +27 -0
- package/src/utils/retry.ts +69 -0
- package/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx +235 -0
- package/test/components/RecommendationShelf/useRecommendationUserId.test.ts +69 -0
- package/test/components/RecommendationShelf/useRecommendations.test.ts +52 -0
- package/test/components/RecommendationShelf/vrn.test.ts +56 -0
- package/test/components/account/ContractSwitcher.browser.test.tsx +236 -0
- package/test/components/account/Layout.test.tsx +62 -0
- package/test/components/account/OrganizationDrawer.browser.test.tsx +197 -0
- package/test/components/account/OrganizationDrawerBody.test.tsx +52 -0
- package/test/components/account/OrganizationDrawerFooter.browser.test.tsx +54 -0
- package/test/components/account/OrganizationDrawerHeader.browser.test.tsx +55 -0
- package/test/components/account/doLogout.browser.test.ts +102 -0
- package/test/components/quotes/MyAccountListQuotes.test.tsx +232 -0
- package/test/components/quotes/MyAccountListQuotesTable.test.tsx +240 -0
- package/test/components/quotes/MyAccountQuoteStatusBadge.test.tsx +28 -0
- package/test/components/quotes/MyAccountQuotesFilterSlider.test.tsx +152 -0
- package/test/components/quotes/MyAccountQuotesSelectedTags.test.tsx +122 -0
- package/test/components/quotes/MyAccountQuotesStatusSelector.test.tsx +85 -0
- package/test/components/quotes/quoteFilters.test.ts +185 -0
- package/test/sdk/account/changeContractToken.browser.test.ts +157 -0
- package/test/sdk/account/clearPersistedSessionState.browser.test.ts +41 -0
- package/test/sdk/account/clearPersistedSessionState.test.ts +20 -0
- package/test/sdk/account/getMyAccountRoutes.test.ts +90 -0
- package/test/sdk/account/refreshToken.browser.test.ts +34 -0
- package/test/sdk/account/refreshToken.test.ts +87 -0
- package/test/sdk/account/useAvailableContracts.browser.test.ts +124 -0
- package/test/sdk/account/useSwitchContract.browser.test.ts +111 -0
- package/test/sdk/analytics/useStartRecommendationSession.test.ts +69 -0
- package/test/sdk/analytics/utils.test.ts +23 -0
- package/test/server/index.test.ts +5 -0
- 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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
+
}
|