@faststore/core 4.5.0-dev.0 → 4.5.0-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.
- package/.turbo/turbo-generate.log +4 -4
- package/.turbo/turbo-test.log +135 -63
- package/@generated/cached-operations.json +2 -0
- package/@generated/gql.ts +18 -0
- package/@generated/graphql.ts +68 -0
- package/@generated/persisted-documents.json +3 -0
- package/@generated/schema.graphql +41 -0
- package/CHANGELOG.md +24 -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 +51 -26
- 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/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/pages/[slug]/p.tsx +45 -0
- package/src/sdk/account/changeContractToken.ts +119 -0
- package/src/sdk/account/clearPersistedSessionState.ts +26 -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/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/OrganizationDrawer.browser.test.tsx +197 -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/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/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 +3 -0
- package/test/utils/retry.test.ts +59 -0
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export interface RetryOptions<T> {
|
|
2
|
+
/**
|
|
3
|
+
* Maximum number of retries after the initial attempt.
|
|
4
|
+
* @default 5
|
|
5
|
+
*/
|
|
6
|
+
attempts?: number
|
|
7
|
+
/**
|
|
8
|
+
* Delay between attempts, in milliseconds.
|
|
9
|
+
* @default 500
|
|
10
|
+
*/
|
|
11
|
+
delayMs?: number
|
|
12
|
+
/**
|
|
13
|
+
* Doubles the delay after each attempt, capped at `maxDelayMs`.
|
|
14
|
+
* @default false
|
|
15
|
+
*/
|
|
16
|
+
backoff?: boolean
|
|
17
|
+
/**
|
|
18
|
+
* Upper bound for the delay when `backoff` is enabled.
|
|
19
|
+
* @default 3000
|
|
20
|
+
*/
|
|
21
|
+
maxDelayMs?: number
|
|
22
|
+
/**
|
|
23
|
+
* Stops retrying once this predicate returns `true`. Defaults to "the value
|
|
24
|
+
* is truthy".
|
|
25
|
+
*/
|
|
26
|
+
until?: (value: T) => boolean
|
|
27
|
+
/**
|
|
28
|
+
* Aborts the retry loop early (e.g. on component unmount).
|
|
29
|
+
*/
|
|
30
|
+
signal?: AbortSignal
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Repeatedly calls `fn` until `until` is satisfied or the retry budget is
|
|
34
|
+
// exhausted. Useful when a value is produced asynchronously by an external
|
|
35
|
+
// script (e.g. a cookie set by a pixel, or an endpoint that is not ready on
|
|
36
|
+
// the first call). Returns the last value produced, even when the budget runs
|
|
37
|
+
// out, so callers can decide how to handle the give-up case.
|
|
38
|
+
export async function retry<T>(
|
|
39
|
+
fn: () => T | Promise<T>,
|
|
40
|
+
{
|
|
41
|
+
attempts = 5,
|
|
42
|
+
delayMs = 500,
|
|
43
|
+
backoff = false,
|
|
44
|
+
maxDelayMs = 3000,
|
|
45
|
+
until = Boolean,
|
|
46
|
+
signal,
|
|
47
|
+
}: RetryOptions<T> = {}
|
|
48
|
+
): Promise<T> {
|
|
49
|
+
let value = await fn()
|
|
50
|
+
let delay = delayMs
|
|
51
|
+
let remaining = attempts
|
|
52
|
+
|
|
53
|
+
while (!until(value) && remaining > 0 && !signal?.aborted) {
|
|
54
|
+
await new Promise((resolve) => setTimeout(resolve, delay))
|
|
55
|
+
|
|
56
|
+
if (signal?.aborted) {
|
|
57
|
+
break
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
value = await fn()
|
|
61
|
+
remaining -= 1
|
|
62
|
+
|
|
63
|
+
if (backoff) {
|
|
64
|
+
delay = Math.min(delay * 2, maxDelayMs)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return value
|
|
69
|
+
}
|