@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.
Files changed (61) hide show
  1. package/.turbo/turbo-generate.log +4 -4
  2. package/.turbo/turbo-test.log +135 -63
  3. package/@generated/cached-operations.json +2 -0
  4. package/@generated/gql.ts +18 -0
  5. package/@generated/graphql.ts +68 -0
  6. package/@generated/persisted-documents.json +3 -0
  7. package/@generated/schema.graphql +41 -0
  8. package/CHANGELOG.md +24 -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 +51 -26
  20. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerFooter.tsx +55 -0
  21. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerHeader.tsx +45 -5
  22. package/src/components/account/Drawer/OrganizationDrawer/index.ts +5 -0
  23. package/src/components/account/Drawer/OrganizationDrawer/section.module.scss +452 -54
  24. package/src/components/cms/global/Components.ts +10 -0
  25. package/src/components/sections/ProductGallery/ProductGallery.tsx +1 -1
  26. package/src/components/sections/RecommendationShelf/RecommendationShelf.tsx +223 -0
  27. package/src/components/sections/RecommendationShelf/RecommendationShelf.types.ts +111 -0
  28. package/src/components/sections/RecommendationShelf/index.ts +1 -0
  29. package/src/components/sections/RecommendationShelf/section.module.scss +49 -0
  30. package/src/components/sections/RecommendationShelf/useRecommendationUserId.ts +49 -0
  31. package/src/components/sections/RecommendationShelf/useRecommendations.ts +55 -0
  32. package/src/components/sections/RecommendationShelf/vrn.ts +55 -0
  33. package/src/pages/[slug]/p.tsx +45 -0
  34. package/src/sdk/account/changeContractToken.ts +119 -0
  35. package/src/sdk/account/clearPersistedSessionState.ts +26 -0
  36. package/src/sdk/account/refreshToken.ts +21 -8
  37. package/src/sdk/account/useAvailableContracts.ts +49 -0
  38. package/src/sdk/account/useSwitchContract.ts +60 -0
  39. package/src/sdk/analytics/hooks/useStartRecommendationSession.ts +96 -0
  40. package/src/sdk/analytics/utils.ts +9 -0
  41. package/src/utils/retry.ts +69 -0
  42. package/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx +235 -0
  43. package/test/components/RecommendationShelf/useRecommendationUserId.test.ts +69 -0
  44. package/test/components/RecommendationShelf/useRecommendations.test.ts +52 -0
  45. package/test/components/RecommendationShelf/vrn.test.ts +56 -0
  46. package/test/components/account/ContractSwitcher.browser.test.tsx +236 -0
  47. package/test/components/account/OrganizationDrawer.browser.test.tsx +197 -0
  48. package/test/components/account/OrganizationDrawerFooter.browser.test.tsx +54 -0
  49. package/test/components/account/OrganizationDrawerHeader.browser.test.tsx +55 -0
  50. package/test/components/account/doLogout.browser.test.ts +102 -0
  51. package/test/sdk/account/changeContractToken.browser.test.ts +157 -0
  52. package/test/sdk/account/clearPersistedSessionState.browser.test.ts +41 -0
  53. package/test/sdk/account/clearPersistedSessionState.test.ts +20 -0
  54. package/test/sdk/account/refreshToken.browser.test.ts +34 -0
  55. package/test/sdk/account/refreshToken.test.ts +87 -0
  56. package/test/sdk/account/useAvailableContracts.browser.test.ts +124 -0
  57. package/test/sdk/account/useSwitchContract.browser.test.ts +111 -0
  58. package/test/sdk/analytics/useStartRecommendationSession.test.ts +69 -0
  59. package/test/sdk/analytics/utils.test.ts +23 -0
  60. package/test/server/index.test.ts +3 -0
  61. 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
- 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
+ }
@@ -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
+ }