@faststore/core 4.6.0-dev.1 → 4.6.0-dev.3
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 +104 -98
- package/@generated/cached-operations.json +1 -1
- package/@generated/gql.ts +9 -9
- package/@generated/graphql.ts +18 -12
- package/@generated/persisted-documents.json +2 -2
- package/@generated/schema.graphql +6 -0
- package/CHANGELOG.md +13 -0
- package/package.json +6 -6
- package/src/components/sections/RecommendationShelf/README.md +4 -1
- package/src/components/sections/RecommendationShelf/RecommendationShelf.tsx +11 -92
- package/src/components/sections/RecommendationShelf/RecommendationShelf.types.ts +3 -18
- package/src/sdk/cart/index.ts +15 -1
- package/src/sdk/cart/waitForSessionValidated.ts +43 -0
- package/src/sdk/recommendations/index.ts +17 -0
- package/src/sdk/recommendations/types.ts +17 -0
- package/src/sdk/recommendations/useRecommendationShelf.ts +141 -0
- package/src/{components/sections/RecommendationShelf → sdk/recommendations}/vrn.ts +1 -1
- package/src/sdk/session/index.ts +14 -0
- package/src/sdk/session/syncSalesChannelFromOrderForm.ts +68 -0
- package/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx +6 -12
- package/test/sdk/cart/waitForSessionValidated.test.ts +96 -0
- package/test/sdk/recommendations/useRecommendationShelf.browser.test.tsx +199 -0
- package/test/{components/RecommendationShelf → sdk/recommendations}/useRecommendationUserId.test.ts +1 -1
- package/test/{components/RecommendationShelf → sdk/recommendations}/useRecommendations.test.ts +1 -1
- package/test/{components/RecommendationShelf → sdk/recommendations}/vrn.test.ts +1 -4
- package/test/sdk/session/syncSalesChannelFromOrderForm.test.ts +125 -0
- /package/src/{components/sections/RecommendationShelf → sdk/recommendations}/useRecommendationUserId.ts +0 -0
- /package/src/{components/sections/RecommendationShelf → sdk/recommendations}/useRecommendations.ts +0 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { usePDP } from '@faststore/core'
|
|
2
|
+
import { useMemo } from 'react'
|
|
3
|
+
|
|
4
|
+
import { useCart } from 'src/sdk/cart'
|
|
5
|
+
|
|
6
|
+
import type { ItemContext } from './types'
|
|
7
|
+
import {
|
|
8
|
+
type RecommendationInput,
|
|
9
|
+
type RecommendationResponse,
|
|
10
|
+
useRecommendations,
|
|
11
|
+
} from './useRecommendations'
|
|
12
|
+
import { useRecommendationUserId } from './useRecommendationUserId'
|
|
13
|
+
import { getTypeFromVrn } from './vrn'
|
|
14
|
+
|
|
15
|
+
export function getRecommendationArguments(
|
|
16
|
+
campaignVrn: string,
|
|
17
|
+
context: { userId?: string | null; contextProducts: string[] }
|
|
18
|
+
): RecommendationInput | null {
|
|
19
|
+
const { userId, contextProducts } = context
|
|
20
|
+
const type = getTypeFromVrn(campaignVrn)
|
|
21
|
+
|
|
22
|
+
// `type` is null for malformed/unknown VRNs; bail out so we never fetch (and
|
|
23
|
+
// never throw) on an invalid campaign coming from the CMS.
|
|
24
|
+
if (!type || !userId) return null
|
|
25
|
+
|
|
26
|
+
switch (type) {
|
|
27
|
+
case 'NEXT_INTERACTION':
|
|
28
|
+
case 'VISUAL_SIMILARITY':
|
|
29
|
+
case 'CROSS_SELL':
|
|
30
|
+
case 'SIMILAR_ITEMS':
|
|
31
|
+
// These campaigns need product context. Without any (e.g. `CART` context
|
|
32
|
+
// on an empty cart, or `PDP` context outside a product page), skip the
|
|
33
|
+
// fetch instead of requesting recommendations we can't anchor.
|
|
34
|
+
if (contextProducts.length === 0) {
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
userId,
|
|
39
|
+
campaignVrn,
|
|
40
|
+
products: contextProducts,
|
|
41
|
+
}
|
|
42
|
+
default:
|
|
43
|
+
return {
|
|
44
|
+
userId,
|
|
45
|
+
campaignVrn,
|
|
46
|
+
products: [],
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type UseRecommendationShelfArgs = {
|
|
52
|
+
readonly campaignVrn: string
|
|
53
|
+
/**
|
|
54
|
+
* Opt-in for VTEX Recommendations. When `false` (default) the campaign is
|
|
55
|
+
* never requested. The anonymous user id is still resolved, but it goes
|
|
56
|
+
* unused and never leaves the browser.
|
|
57
|
+
*/
|
|
58
|
+
readonly enableRecommendations?: boolean
|
|
59
|
+
/**
|
|
60
|
+
* Where to read the products used as context for the recommendation request.
|
|
61
|
+
* @default 'PDP'
|
|
62
|
+
*/
|
|
63
|
+
readonly itemsContext?: ItemContext
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type UseRecommendationShelfResult = {
|
|
67
|
+
readonly items: RecommendationResponse['products']
|
|
68
|
+
readonly isLoading: boolean
|
|
69
|
+
readonly error: unknown
|
|
70
|
+
readonly campaign?: RecommendationResponse['campaign']
|
|
71
|
+
readonly correlationId?: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const EMPTY_ITEMS: RecommendationResponse['products'] = []
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Headless data layer behind every recommendation shelf: resolves the campaign
|
|
78
|
+
* type, the anonymous user id and the product context, then fetches the
|
|
79
|
+
* campaign. It renders nothing — presentation lives in the shelf components
|
|
80
|
+
* that consume it, so a new surface can reuse the campaign rules instead of
|
|
81
|
+
* reimplementing them.
|
|
82
|
+
*/
|
|
83
|
+
export function useRecommendationShelf({
|
|
84
|
+
campaignVrn,
|
|
85
|
+
enableRecommendations = false,
|
|
86
|
+
itemsContext = 'PDP',
|
|
87
|
+
}: UseRecommendationShelfArgs): UseRecommendationShelfResult {
|
|
88
|
+
const userId = useRecommendationUserId(campaignVrn)
|
|
89
|
+
|
|
90
|
+
const { data: productDetailPage } = usePDP()
|
|
91
|
+
const { items: cartItems } = useCart()
|
|
92
|
+
|
|
93
|
+
// Resolve the products used as context for the request from the configured
|
|
94
|
+
// source: the current PDP product, or the (deduplicated) cart items.
|
|
95
|
+
const contextProducts = useMemo(() => {
|
|
96
|
+
if (itemsContext === 'CART') {
|
|
97
|
+
return Array.from(
|
|
98
|
+
new Set(
|
|
99
|
+
cartItems
|
|
100
|
+
.map((item) => item.itemOffered.isVariantOf.productGroupID)
|
|
101
|
+
.filter(Boolean)
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const pdpProduct = productDetailPage?.product?.isVariantOf?.productGroupID
|
|
107
|
+
|
|
108
|
+
return pdpProduct ? [pdpProduct] : []
|
|
109
|
+
}, [itemsContext, cartItems, productDetailPage])
|
|
110
|
+
|
|
111
|
+
// Gate the whole feature behind the CMS opt-in: when Recommendations is
|
|
112
|
+
// disabled the shelf never requests recommendations (nor renders).
|
|
113
|
+
const recommendationArgs = enableRecommendations
|
|
114
|
+
? getRecommendationArguments(campaignVrn, {
|
|
115
|
+
userId,
|
|
116
|
+
contextProducts,
|
|
117
|
+
})
|
|
118
|
+
: null
|
|
119
|
+
|
|
120
|
+
const { data, isLoading, error } = useRecommendations(recommendationArgs)
|
|
121
|
+
|
|
122
|
+
if (error) {
|
|
123
|
+
// Don't log `recommendationArgs`: it carries the userId. Log only
|
|
124
|
+
// non-identifying context.
|
|
125
|
+
console.error(
|
|
126
|
+
'Error fetching recommendations',
|
|
127
|
+
(error as { cause?: unknown })?.cause,
|
|
128
|
+
(error as { message?: string })?.message,
|
|
129
|
+
'for campaign',
|
|
130
|
+
campaignVrn
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
items: error ? EMPTY_ITEMS : (data?.products ?? EMPTY_ITEMS),
|
|
136
|
+
isLoading,
|
|
137
|
+
error,
|
|
138
|
+
campaign: data?.campaign,
|
|
139
|
+
correlationId: data?.correlationId,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { RecommendationType } from './
|
|
1
|
+
import type { RecommendationType } from './types'
|
|
2
2
|
|
|
3
3
|
// Single source of truth for the campaign taxonomy: maps each Intelligent Search
|
|
4
4
|
// campaign VRN type to its `RecommendationType`. The supported VRN types, the
|
package/src/sdk/session/index.ts
CHANGED
|
@@ -194,6 +194,9 @@ export const validateSession = async (session: Session) => {
|
|
|
194
194
|
const [validationStore, onValidate, hasValidatedStore] =
|
|
195
195
|
createValidationStore(validateSession)
|
|
196
196
|
|
|
197
|
+
/** True after at least one validateSession cycle finished (success or failure). */
|
|
198
|
+
export const hasValidatedSessionStore = hasValidatedStore
|
|
199
|
+
|
|
197
200
|
const urlAwareInitialSession = getInitialSession()
|
|
198
201
|
const defaultStore = createSessionStore(
|
|
199
202
|
urlAwareInitialSession,
|
|
@@ -214,6 +217,17 @@ export const sessionStore = {
|
|
|
214
217
|
// Trigger cart revalidation when session changes
|
|
215
218
|
cartStore.set(cartStore.read())
|
|
216
219
|
},
|
|
220
|
+
/**
|
|
221
|
+
* Updates session without revalidating the cart. Used when aligning
|
|
222
|
+
* `channel.salesChannel` to an orderForm SC adopted by `validateCart`.
|
|
223
|
+
* Still runs `validateSession` via the store's optimistic validator; that
|
|
224
|
+
* path keeps an explicit (non-default) client SC.
|
|
225
|
+
*/
|
|
226
|
+
setSilent: (val: Session) => {
|
|
227
|
+
if (deepEqual(val, defaultStore.read()) === true) return
|
|
228
|
+
|
|
229
|
+
defaultStore.set(val)
|
|
230
|
+
},
|
|
217
231
|
}
|
|
218
232
|
|
|
219
233
|
export async function logoutAndClearSession(session: Session) {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Session } from '@faststore/sdk'
|
|
2
|
+
|
|
3
|
+
function parseChannelRecord(
|
|
4
|
+
raw: string | null | undefined
|
|
5
|
+
): Record<string, unknown> {
|
|
6
|
+
try {
|
|
7
|
+
const parsed: unknown = JSON.parse(raw || '{}')
|
|
8
|
+
|
|
9
|
+
if (
|
|
10
|
+
parsed !== null &&
|
|
11
|
+
typeof parsed === 'object' &&
|
|
12
|
+
!Array.isArray(parsed)
|
|
13
|
+
) {
|
|
14
|
+
return parsed as Record<string, unknown>
|
|
15
|
+
}
|
|
16
|
+
} catch {
|
|
17
|
+
// Malformed channel — reset below.
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return {}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readSalesChannel(channel: Record<string, unknown>): string {
|
|
24
|
+
const value = channel.salesChannel
|
|
25
|
+
|
|
26
|
+
if (typeof value === 'string' || typeof value === 'number') {
|
|
27
|
+
return String(value)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return ''
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Aligns `fs::session.channel` with a sales channel adopted from the orderForm
|
|
35
|
+
* (e.g. after Quick Order) without revalidating the cart.
|
|
36
|
+
*
|
|
37
|
+
* Uses `sessionStore.setSilent` so we do not trigger `cartStore.set` mid-flight
|
|
38
|
+
* (that race previously wiped SC-exclusive items). Marks the channel as
|
|
39
|
+
* explicit (`hasOnlyDefaultSalesChannel: false`) so `validateSession` keeps it.
|
|
40
|
+
*/
|
|
41
|
+
export function syncSalesChannelFromOrderForm(
|
|
42
|
+
salesChannel: string | null | undefined,
|
|
43
|
+
readSession: () => Session,
|
|
44
|
+
setSilent: (session: Session) => void
|
|
45
|
+
): boolean {
|
|
46
|
+
if (salesChannel == null || salesChannel === '') {
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const session = readSession()
|
|
51
|
+
const channel = parseChannelRecord(session.channel)
|
|
52
|
+
const nextSalesChannel = String(salesChannel)
|
|
53
|
+
|
|
54
|
+
if (readSalesChannel(channel) === nextSalesChannel) {
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
setSilent({
|
|
59
|
+
...session,
|
|
60
|
+
channel: JSON.stringify({
|
|
61
|
+
...channel,
|
|
62
|
+
salesChannel: nextSalesChannel,
|
|
63
|
+
hasOnlyDefaultSalesChannel: false,
|
|
64
|
+
}),
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
return true
|
|
68
|
+
}
|
|
@@ -29,23 +29,17 @@ vi.mock('src/components/skeletons/ProductShelfSkeleton', () => ({
|
|
|
29
29
|
}))
|
|
30
30
|
|
|
31
31
|
const useRecommendations = vi.hoisted(() => vi.fn())
|
|
32
|
-
vi.mock(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
useRecommendations,
|
|
36
|
-
})
|
|
37
|
-
)
|
|
32
|
+
vi.mock('src/sdk/recommendations/useRecommendations', () => ({
|
|
33
|
+
useRecommendations,
|
|
34
|
+
}))
|
|
38
35
|
|
|
39
36
|
const useScreenResize = vi.hoisted(() => vi.fn())
|
|
40
37
|
vi.mock('src/sdk/ui/useScreenResize', () => ({ default: useScreenResize }))
|
|
41
38
|
|
|
42
39
|
const useRecommendationUserId = vi.hoisted(() => vi.fn())
|
|
43
|
-
vi.mock(
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
useRecommendationUserId,
|
|
47
|
-
})
|
|
48
|
-
)
|
|
40
|
+
vi.mock('src/sdk/recommendations/useRecommendationUserId', () => ({
|
|
41
|
+
useRecommendationUserId,
|
|
42
|
+
}))
|
|
49
43
|
|
|
50
44
|
import { RecommendationShelf } from 'src/components/sections/RecommendationShelf/RecommendationShelf'
|
|
51
45
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { createBaseStore } from '@faststore/sdk'
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import {
|
|
4
|
+
SESSION_VALIDATION_WAIT_MS,
|
|
5
|
+
waitForSessionValidated,
|
|
6
|
+
} from '../../../src/sdk/cart/waitForSessionValidated'
|
|
7
|
+
|
|
8
|
+
describe('waitForSessionValidated', () => {
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
vi.useRealTimers()
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('resolves immediately when session was already validated', async () => {
|
|
14
|
+
const store = createBaseStore(true)
|
|
15
|
+
|
|
16
|
+
await expect(waitForSessionValidated(store)).resolves.toBeUndefined()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('resolves when session becomes validated after subscribe', async () => {
|
|
20
|
+
const store = createBaseStore(false)
|
|
21
|
+
|
|
22
|
+
const pending = waitForSessionValidated(store, 5_000)
|
|
23
|
+
store.set(true)
|
|
24
|
+
|
|
25
|
+
await expect(pending).resolves.toBeUndefined()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('ignores subscribe callbacks while validated is still false', async () => {
|
|
29
|
+
vi.useFakeTimers()
|
|
30
|
+
const listeners: Array<(value: boolean) => void> = []
|
|
31
|
+
const store = {
|
|
32
|
+
read: vi.fn(() => false),
|
|
33
|
+
readInitial: vi.fn(() => false),
|
|
34
|
+
subscribe: vi.fn((listener: (value: boolean) => void) => {
|
|
35
|
+
listeners.push(listener)
|
|
36
|
+
return () => {
|
|
37
|
+
const index = listeners.indexOf(listener)
|
|
38
|
+
if (index >= 0) {
|
|
39
|
+
listeners.splice(index, 1)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}),
|
|
43
|
+
set: vi.fn(),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const pending = waitForSessionValidated(store, SESSION_VALIDATION_WAIT_MS)
|
|
47
|
+
|
|
48
|
+
// Emit false — should not resolve yet
|
|
49
|
+
for (const listener of listeners) {
|
|
50
|
+
listener(false)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
await vi.advanceTimersByTimeAsync(SESSION_VALIDATION_WAIT_MS)
|
|
54
|
+
await expect(pending).resolves.toBeUndefined()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('resolves on timeout when session never validates', async () => {
|
|
58
|
+
vi.useFakeTimers()
|
|
59
|
+
const store = createBaseStore(false)
|
|
60
|
+
|
|
61
|
+
const pending = waitForSessionValidated(store, SESSION_VALIDATION_WAIT_MS)
|
|
62
|
+
|
|
63
|
+
await vi.advanceTimersByTimeAsync(SESSION_VALIDATION_WAIT_MS)
|
|
64
|
+
|
|
65
|
+
await expect(pending).resolves.toBeUndefined()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('resolves the race when validation finishes between read and subscribe', async () => {
|
|
69
|
+
let current = false
|
|
70
|
+
const listeners: Array<(value: boolean) => void> = []
|
|
71
|
+
|
|
72
|
+
const store = {
|
|
73
|
+
read: vi.fn(() => {
|
|
74
|
+
// First read: not validated. Second read (post-subscribe): validated.
|
|
75
|
+
const value = current
|
|
76
|
+
current = true
|
|
77
|
+
return value
|
|
78
|
+
}),
|
|
79
|
+
readInitial: vi.fn(() => false),
|
|
80
|
+
subscribe: vi.fn((listener: (value: boolean) => void) => {
|
|
81
|
+
listeners.push(listener)
|
|
82
|
+
return () => {
|
|
83
|
+
const index = listeners.indexOf(listener)
|
|
84
|
+
if (index >= 0) {
|
|
85
|
+
listeners.splice(index, 1)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}),
|
|
89
|
+
set: vi.fn(),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
await expect(waitForSessionValidated(store, 5_000)).resolves.toBeUndefined()
|
|
93
|
+
expect(store.subscribe).toHaveBeenCalledTimes(1)
|
|
94
|
+
expect(store.read).toHaveBeenCalledTimes(2)
|
|
95
|
+
})
|
|
96
|
+
})
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { renderHook } from '@testing-library/react'
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
const usePDP = vi.hoisted(() => vi.fn())
|
|
5
|
+
vi.mock('@faststore/core', () => ({ usePDP }))
|
|
6
|
+
|
|
7
|
+
const useCart = vi.hoisted(() => vi.fn())
|
|
8
|
+
vi.mock('src/sdk/cart', () => ({ useCart }))
|
|
9
|
+
|
|
10
|
+
const useRecommendations = vi.hoisted(() => vi.fn())
|
|
11
|
+
vi.mock('src/sdk/recommendations/useRecommendations', () => ({
|
|
12
|
+
useRecommendations,
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
const useRecommendationUserId = vi.hoisted(() => vi.fn())
|
|
16
|
+
vi.mock('src/sdk/recommendations/useRecommendationUserId', () => ({
|
|
17
|
+
useRecommendationUserId,
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
import { useRecommendationShelf } from 'src/sdk/recommendations/useRecommendationShelf'
|
|
21
|
+
|
|
22
|
+
const TOP_ITEMS_VRN = 'vrn:recommendations:acc:rec-top-items-v2:campaign-1'
|
|
23
|
+
const CROSS_SELL_VRN = 'vrn:recommendations:acc:rec-cross-v2:campaign-1'
|
|
24
|
+
|
|
25
|
+
const cartItem = (productGroupID: string) => ({
|
|
26
|
+
itemOffered: { isVariantOf: { productGroupID } },
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const campaignData = {
|
|
30
|
+
products: [
|
|
31
|
+
{ id: 'p-1', isVariantOf: { productGroupID: 'p-1' } },
|
|
32
|
+
{ id: 'p-2', isVariantOf: { productGroupID: 'p-2' } },
|
|
33
|
+
],
|
|
34
|
+
correlationId: 'corr-1',
|
|
35
|
+
campaign: { id: 'camp-1', title: 'Recommended for you', type: 'TOP_ITEMS' },
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const lastArgs = () => useRecommendations.mock.calls.at(-1)?.[0]
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
usePDP.mockReturnValue({ data: undefined })
|
|
42
|
+
useCart.mockReturnValue({ items: [] })
|
|
43
|
+
useRecommendationUserId.mockReturnValue('user-1')
|
|
44
|
+
useRecommendations.mockReturnValue({
|
|
45
|
+
data: campaignData,
|
|
46
|
+
isLoading: false,
|
|
47
|
+
error: null,
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
vi.clearAllMocks()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('useRecommendationShelf', () => {
|
|
56
|
+
it('exposes the campaign payload when enabled', () => {
|
|
57
|
+
const { result } = renderHook(() =>
|
|
58
|
+
useRecommendationShelf({
|
|
59
|
+
campaignVrn: TOP_ITEMS_VRN,
|
|
60
|
+
enableRecommendations: true,
|
|
61
|
+
})
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
expect(result.current.items).toHaveLength(2)
|
|
65
|
+
expect(result.current.campaign?.title).toBe('Recommended for you')
|
|
66
|
+
expect(result.current.correlationId).toBe('corr-1')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('never fetches while recommendations are disabled', () => {
|
|
70
|
+
renderHook(() => useRecommendationShelf({ campaignVrn: TOP_ITEMS_VRN }))
|
|
71
|
+
|
|
72
|
+
expect(lastArgs()).toBeNull()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('never fetches before a userId resolves', () => {
|
|
76
|
+
useRecommendationUserId.mockReturnValue(null)
|
|
77
|
+
|
|
78
|
+
renderHook(() =>
|
|
79
|
+
useRecommendationShelf({
|
|
80
|
+
campaignVrn: TOP_ITEMS_VRN,
|
|
81
|
+
enableRecommendations: true,
|
|
82
|
+
})
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
expect(lastArgs()).toBeNull()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('never fetches on a malformed campaign vrn', () => {
|
|
89
|
+
renderHook(() =>
|
|
90
|
+
useRecommendationShelf({
|
|
91
|
+
campaignVrn: 'not-a-vrn',
|
|
92
|
+
enableRecommendations: true,
|
|
93
|
+
})
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
expect(lastArgs()).toBeNull()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('anchors cart-context campaigns on the deduplicated cart products', () => {
|
|
100
|
+
useCart.mockReturnValue({
|
|
101
|
+
items: [cartItem('pg-1'), cartItem('pg-2'), cartItem('pg-1')],
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
renderHook(() =>
|
|
105
|
+
useRecommendationShelf({
|
|
106
|
+
campaignVrn: CROSS_SELL_VRN,
|
|
107
|
+
enableRecommendations: true,
|
|
108
|
+
itemsContext: 'CART',
|
|
109
|
+
})
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
expect(lastArgs()).toEqual({
|
|
113
|
+
userId: 'user-1',
|
|
114
|
+
campaignVrn: CROSS_SELL_VRN,
|
|
115
|
+
products: ['pg-1', 'pg-2'],
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('skips cart-context campaigns while the cart is empty', () => {
|
|
120
|
+
renderHook(() =>
|
|
121
|
+
useRecommendationShelf({
|
|
122
|
+
campaignVrn: CROSS_SELL_VRN,
|
|
123
|
+
enableRecommendations: true,
|
|
124
|
+
itemsContext: 'CART',
|
|
125
|
+
})
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
expect(lastArgs()).toBeNull()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('still fetches context-agnostic campaigns on an empty cart', () => {
|
|
132
|
+
renderHook(() =>
|
|
133
|
+
useRecommendationShelf({
|
|
134
|
+
campaignVrn: TOP_ITEMS_VRN,
|
|
135
|
+
enableRecommendations: true,
|
|
136
|
+
itemsContext: 'CART',
|
|
137
|
+
})
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
expect(lastArgs()).toEqual({
|
|
141
|
+
userId: 'user-1',
|
|
142
|
+
campaignVrn: TOP_ITEMS_VRN,
|
|
143
|
+
products: [],
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('follows the live cart context when not frozen', () => {
|
|
148
|
+
useCart.mockReturnValue({ items: [cartItem('pg-1')] })
|
|
149
|
+
|
|
150
|
+
const { rerender } = renderHook(() =>
|
|
151
|
+
useRecommendationShelf({
|
|
152
|
+
campaignVrn: CROSS_SELL_VRN,
|
|
153
|
+
enableRecommendations: true,
|
|
154
|
+
itemsContext: 'CART',
|
|
155
|
+
})
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
useCart.mockReturnValue({ items: [cartItem('pg-1'), cartItem('pg-2')] })
|
|
159
|
+
rerender()
|
|
160
|
+
|
|
161
|
+
expect(lastArgs()?.products).toEqual(['pg-1', 'pg-2'])
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('anchors PDP-context campaigns on the current product', () => {
|
|
165
|
+
usePDP.mockReturnValue({
|
|
166
|
+
data: { product: { isVariantOf: { productGroupID: 'pg-pdp' } } },
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
renderHook(() =>
|
|
170
|
+
useRecommendationShelf({
|
|
171
|
+
campaignVrn: CROSS_SELL_VRN,
|
|
172
|
+
enableRecommendations: true,
|
|
173
|
+
})
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
expect(lastArgs()?.products).toEqual(['pg-pdp'])
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('returns no items and logs without the userId on error', () => {
|
|
180
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
181
|
+
useRecommendations.mockReturnValue({
|
|
182
|
+
data: undefined,
|
|
183
|
+
isLoading: false,
|
|
184
|
+
error: new Error('boom'),
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
const { result } = renderHook(() =>
|
|
188
|
+
useRecommendationShelf({
|
|
189
|
+
campaignVrn: TOP_ITEMS_VRN,
|
|
190
|
+
enableRecommendations: true,
|
|
191
|
+
})
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
expect(result.current.items).toEqual([])
|
|
195
|
+
expect(result.current.error).toBeInstanceOf(Error)
|
|
196
|
+
expect(consoleError).toHaveBeenCalled()
|
|
197
|
+
expect(JSON.stringify(consoleError.mock.calls)).not.toContain('user-1')
|
|
198
|
+
})
|
|
199
|
+
})
|
package/test/{components/RecommendationShelf → sdk/recommendations}/useRecommendationUserId.test.ts
RENAMED
|
@@ -14,7 +14,7 @@ vi.mock('src/utils/retry', () => ({
|
|
|
14
14
|
retry: (fn: () => unknown) => Promise.resolve().then(fn),
|
|
15
15
|
}))
|
|
16
16
|
|
|
17
|
-
import { useRecommendationUserId } from 'src/
|
|
17
|
+
import { useRecommendationUserId } from 'src/sdk/recommendations/useRecommendationUserId'
|
|
18
18
|
|
|
19
19
|
const CAMPAIGN_VRN = 'vrn:recommendations:acc:rec-top-items-v2:campaign-1'
|
|
20
20
|
|
package/test/{components/RecommendationShelf → sdk/recommendations}/useRecommendations.test.ts
RENAMED
|
@@ -7,7 +7,7 @@ vi.mock('src/sdk/graphql/useQuery', () => ({
|
|
|
7
7
|
useQuery: useQueryMock,
|
|
8
8
|
}))
|
|
9
9
|
|
|
10
|
-
import { useRecommendations } from 'src/
|
|
10
|
+
import { useRecommendations } from 'src/sdk/recommendations/useRecommendations'
|
|
11
11
|
|
|
12
12
|
afterEach(() => {
|
|
13
13
|
vi.clearAllMocks()
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
getTypeFromVrn,
|
|
5
|
-
isValidVrn,
|
|
6
|
-
} from 'src/components/sections/RecommendationShelf/vrn'
|
|
3
|
+
import { getTypeFromVrn, isValidVrn } from 'src/sdk/recommendations/vrn'
|
|
7
4
|
|
|
8
5
|
const vrn = (type: string) => `vrn:recommendations:account:${type}:campaign-1`
|
|
9
6
|
|