Skip to content

Commit add85e3

Browse files
@W-19143871 Marketing Consent Footer Email Capture component with Shopper Consents hooks (#3674)
- Updated Email Subscription component (in the footer section) with Shopper Consents hooks, also with updated styling and labels. - Updated commerce-sdk-react to use Shopper Consents hooks. - Fix for email validation on marketing consent capture: TLD must be more than 1 character. - Increase package max size from 90KB to 95KB, accounting for footer component size. - Check for feature enablement via Shopper Configurations API (feature=>`EnableConsentWithMarketingCloud`)
1 parent f7847c5 commit add85e3

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

70 files changed

+4087
-255
lines changed

packages/commerce-sdk-react/CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
## v5.1.0-dev
22
- Add Node 24 support. Drop Node 16 support. [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652)
3+
- Add Shopper Consents API support [#3674](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3674)
34

45
## v5.0.0 (Feb 12, 2026)
56
- Upgrade to commerce-sdk-isomorphic v5.0.0 and introduce Payment Instrument SCAPI integration [#3552](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3552)
@@ -14,7 +15,7 @@
1415
## v4.2.0 (Nov 04, 2025)
1516

1617
- Upgrade to commerce-sdk-isomorphic v4.0.1 [#3449](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3449)
17-
- Prevent headers from being overriden in `generateCustomEndpointOptions` [#3405](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3405/)
18+
- Prevent headers from being overridden in `generateCustomEndpointOptions` [#3405](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3405/)
1819

1920
## v4.1.0 (Sep 25, 2025)
2021

packages/commerce-sdk-react/src/constant.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2023, Salesforce, Inc.
2+
* Copyright (c) 2025, Salesforce, Inc.
33
* All rights reserved.
44
* SPDX-License-Identifier: BSD-3-Clause
55
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
@@ -46,6 +46,7 @@ export const SERVER_AFFINITY_HEADER_KEY = 'sfdc_dwsid'
4646

4747
export const CLIENT_KEYS = {
4848
SHOPPER_BASKETS: 'shopperBaskets',
49+
SHOPPER_CONSENTS: 'shopperConsents',
4950
SHOPPER_CONTEXTS: 'shopperContexts',
5051
SHOPPER_CUSTOMERS: 'shopperCustomers',
5152
SHOPPER_EXPERIENCE: 'shopperExperience',
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* All rights reserved.
4+
* SPDX-License-Identifier: BSD-3-Clause
5+
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
import {ApiClients, CacheUpdateMatrix} from '../types'
8+
import {getSubscriptions} from './queryKeyHelpers'
9+
import {CLIENT_KEYS} from '../../constant'
10+
11+
const CLIENT_KEY = CLIENT_KEYS.SHOPPER_CONSENTS
12+
type Client = NonNullable<ApiClients[typeof CLIENT_KEY]>
13+
14+
export const cacheUpdateMatrix: CacheUpdateMatrix<Client> = {
15+
updateSubscription(customerId, {parameters}, response) {
16+
// When a subscription is updated, we invalidate the getSubscriptions query
17+
// to ensure the UI reflects the latest subscription state
18+
return {
19+
invalidate: [{queryKey: getSubscriptions.queryKey(parameters)}]
20+
}
21+
},
22+
updateSubscriptions(customerId, {parameters}, response) {
23+
// When multiple subscriptions are updated, we invalidate the getSubscriptions query
24+
// to ensure the UI reflects the latest subscription states
25+
return {
26+
invalidate: [{queryKey: getSubscriptions.queryKey(parameters)}]
27+
}
28+
}
29+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* All rights reserved.
4+
* SPDX-License-Identifier: BSD-3-Clause
5+
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
import {ShopperConsents} from 'commerce-sdk-isomorphic'
8+
import {getUnimplementedEndpoints} from '../../test-utils'
9+
import {cacheUpdateMatrix} from './cache'
10+
import {ShopperConsentsMutations as mutations} from './mutation'
11+
import * as queries from './query'
12+
13+
describe('Shopper Consents hooks', () => {
14+
test('all endpoints have hooks', () => {
15+
// unimplemented = SDK method exists, but no query hook or value in mutations enum
16+
const unimplemented = getUnimplementedEndpoints(ShopperConsents, queries, mutations)
17+
// If this test fails: create a new query hook, add the endpoint to the mutations enum,
18+
// or add it to the `expected` array with a comment explaining "TODO" or "never" (and why).
19+
expect(unimplemented).toEqual([])
20+
})
21+
test('all mutations have cache update logic', () => {
22+
// unimplemented = value in mutations enum, but no method in cache update matrix
23+
const unimplemented = new Set<string>(Object.values(mutations))
24+
Object.entries(cacheUpdateMatrix).forEach(([method, implementation]) => {
25+
if (implementation) unimplemented.delete(method)
26+
})
27+
// If this test fails: add cache update logic, remove the endpoint from the mutations enum,
28+
// or add it to the `expected` array to indicate that it is still a TODO.
29+
expect([...unimplemented]).toEqual([])
30+
})
31+
})
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* All rights reserved.
4+
* SPDX-License-Identifier: BSD-3-Clause
5+
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
export * from './mutation'
8+
export * from './query'
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* All rights reserved.
4+
* SPDX-License-Identifier: BSD-3-Clause
5+
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
import {act, waitFor} from '@testing-library/react'
8+
import {ShopperConsentsTypes} from 'commerce-sdk-isomorphic'
9+
import nock from 'nock'
10+
import {
11+
assertInvalidateQuery,
12+
mockMutationEndpoints,
13+
mockQueryEndpoint,
14+
renderHookWithProviders,
15+
waitAndExpectSuccess
16+
} from '../../test-utils'
17+
import {ShopperConsentsMutation, useShopperConsentsMutation} from '../ShopperConsents'
18+
import {ApiClients, Argument} from '../types'
19+
import * as queries from './query'
20+
import {CLIENT_KEYS} from '../../constant'
21+
22+
jest.mock('../../auth/index.ts', () => {
23+
const {default: mockAuth} = jest.requireActual('../../auth/index.ts')
24+
mockAuth.prototype.ready = jest.fn().mockResolvedValue({access_token: 'access_token'})
25+
return mockAuth
26+
})
27+
28+
const CLIENT_KEY = CLIENT_KEYS.SHOPPER_CONSENTS
29+
type Client = NonNullable<ApiClients[typeof CLIENT_KEY]>
30+
const consentsEndpoint = '/shopper/shopper-consents/'
31+
/** All Shopper Consents parameters. Can be used for all endpoints, as unused params are ignored. */
32+
const PARAMETERS = {
33+
siteId: 'RefArch'
34+
} as const
35+
/** Options object that can be used for all query endpoints. */
36+
const queryOptions = {parameters: PARAMETERS}
37+
38+
const baseSubscriptionResponse: ShopperConsentsTypes.ConsentSubscriptionResponse = {
39+
data: [
40+
{
41+
subscriptionId: 'test-subscription',
42+
channels: ['email' as ShopperConsentsTypes.ChannelType],
43+
contactPointValue: 'test@example.com'
44+
}
45+
]
46+
}
47+
48+
type Mutation = ShopperConsentsMutation
49+
/** Map of mutation method name to test options. */
50+
type TestMap = {[Mut in Mutation]?: Argument<Client[Mut]>}
51+
const testMap: TestMap = {
52+
updateSubscription: {
53+
parameters: PARAMETERS,
54+
body: {
55+
subscriptionId: 'test-subscription',
56+
channel: 'email',
57+
status: 'opt_in',
58+
contactPointValue: 'test@example.com'
59+
}
60+
},
61+
updateSubscriptions: {
62+
parameters: PARAMETERS,
63+
body: {
64+
subscriptions: [
65+
{
66+
subscriptionId: 'test-subscription',
67+
channel: 'email' as ShopperConsentsTypes.ChannelType,
68+
status: 'opt_in' as ShopperConsentsTypes.ConsentStatus,
69+
contactPointValue: 'test@example.com'
70+
}
71+
]
72+
}
73+
}
74+
}
75+
// Type assertion is necessary because `Object.entries` is limited
76+
type TestCase = [Mutation, NonNullable<TestMap[Mutation]>]
77+
const testCases = Object.entries(testMap) as TestCase[]
78+
79+
describe('Shopper Consents mutations', () => {
80+
beforeEach(() => nock.cleanAll())
81+
test.each(testCases)('`%s` returns data on success', async (mutationName, options) => {
82+
mockMutationEndpoints(consentsEndpoint, {})
83+
const {result} = renderHookWithProviders(() => {
84+
return useShopperConsentsMutation(mutationName)
85+
})
86+
act(() => result.current.mutate(options))
87+
await waitAndExpectSuccess(() => result.current)
88+
})
89+
})
90+
91+
describe('Cache update behavior', () => {
92+
describe('updateSubscription', () => {
93+
beforeEach(() => nock.cleanAll())
94+
95+
test('invalidates `getSubscriptions` query', async () => {
96+
mockQueryEndpoint(consentsEndpoint, baseSubscriptionResponse)
97+
mockMutationEndpoints(consentsEndpoint, {})
98+
mockQueryEndpoint(consentsEndpoint, baseSubscriptionResponse)
99+
const {result} = renderHookWithProviders(() => {
100+
return {
101+
query: queries.useSubscriptions(queryOptions),
102+
mutation: useShopperConsentsMutation('updateSubscription')
103+
}
104+
})
105+
await waitAndExpectSuccess(() => result.current.query)
106+
const oldData = result.current.query.data
107+
108+
act(() =>
109+
result.current.mutation.mutate({
110+
parameters: PARAMETERS,
111+
body: {
112+
subscriptionId: 'test-subscription',
113+
channel: 'email',
114+
status: 'opt_out',
115+
contactPointValue: 'test@example.com'
116+
}
117+
})
118+
)
119+
await waitAndExpectSuccess(() => result.current.mutation)
120+
assertInvalidateQuery(result.current.query, oldData)
121+
})
122+
123+
test('triggers refetch after cache invalidation', async () => {
124+
const updatedResponse: ShopperConsentsTypes.ConsentSubscriptionResponse = {
125+
data: [
126+
{
127+
subscriptionId: 'test-subscription',
128+
channels: ['email' as ShopperConsentsTypes.ChannelType],
129+
contactPointValue: 'test@example.com'
130+
}
131+
]
132+
}
133+
134+
mockQueryEndpoint(consentsEndpoint, baseSubscriptionResponse)
135+
mockMutationEndpoints(consentsEndpoint, {})
136+
mockQueryEndpoint(consentsEndpoint, updatedResponse) // Refetch returns updated data
137+
138+
const {result} = renderHookWithProviders(() => {
139+
return {
140+
query: queries.useSubscriptions(queryOptions),
141+
mutation: useShopperConsentsMutation('updateSubscription')
142+
}
143+
})
144+
await waitAndExpectSuccess(() => result.current.query)
145+
expect(result.current.query.data).toEqual(baseSubscriptionResponse)
146+
147+
act(() =>
148+
result.current.mutation.mutate({
149+
parameters: PARAMETERS,
150+
body: {
151+
subscriptionId: 'test-subscription',
152+
channel: 'email',
153+
status: 'opt_out',
154+
contactPointValue: 'test@example.com'
155+
}
156+
})
157+
)
158+
await waitAndExpectSuccess(() => result.current.mutation)
159+
160+
// Wait for refetch to complete
161+
await waitAndExpectSuccess(() => result.current.query)
162+
// After refetch, data should be updated
163+
expect(result.current.query.data).toEqual(updatedResponse)
164+
})
165+
})
166+
167+
describe('updateSubscriptions', () => {
168+
beforeEach(() => nock.cleanAll())
169+
170+
test('invalidates `getSubscriptions` query', async () => {
171+
mockQueryEndpoint(consentsEndpoint, baseSubscriptionResponse)
172+
mockMutationEndpoints(consentsEndpoint, {})
173+
mockQueryEndpoint(consentsEndpoint, baseSubscriptionResponse)
174+
const {result} = renderHookWithProviders(() => {
175+
return {
176+
query: queries.useSubscriptions(queryOptions),
177+
mutation: useShopperConsentsMutation('updateSubscriptions')
178+
}
179+
})
180+
await waitAndExpectSuccess(() => result.current.query)
181+
const oldData = result.current.query.data
182+
183+
act(() =>
184+
result.current.mutation.mutate({
185+
parameters: PARAMETERS,
186+
body: {
187+
subscriptions: [
188+
{
189+
subscriptionId: 'test-subscription',
190+
channel: 'email' as ShopperConsentsTypes.ChannelType,
191+
status: 'opt_out' as ShopperConsentsTypes.ConsentStatus,
192+
contactPointValue: 'test@example.com'
193+
},
194+
{
195+
subscriptionId: 'test-subscription-2',
196+
channel: 'sms' as ShopperConsentsTypes.ChannelType,
197+
status: 'opt_in' as ShopperConsentsTypes.ConsentStatus,
198+
contactPointValue: '+15551234567'
199+
}
200+
]
201+
}
202+
})
203+
)
204+
await waitAndExpectSuccess(() => result.current.mutation)
205+
assertInvalidateQuery(result.current.query, oldData)
206+
})
207+
208+
test('ensures stale cache is not served after mutation', async () => {
209+
const staleResponse = baseSubscriptionResponse
210+
const freshResponse: ShopperConsentsTypes.ConsentSubscriptionResponse = {
211+
data: [
212+
{
213+
subscriptionId: 'test-subscription',
214+
channels: ['email' as ShopperConsentsTypes.ChannelType],
215+
contactPointValue: 'test@example.com'
216+
},
217+
{
218+
subscriptionId: 'test-subscription-2',
219+
channels: ['sms' as ShopperConsentsTypes.ChannelType],
220+
contactPointValue: '+15551234567'
221+
}
222+
]
223+
}
224+
225+
mockQueryEndpoint(consentsEndpoint, staleResponse)
226+
mockMutationEndpoints(consentsEndpoint, {})
227+
mockQueryEndpoint(consentsEndpoint, freshResponse) // Refetch returns fresh data
228+
229+
const {result} = renderHookWithProviders(() => {
230+
return {
231+
query: queries.useSubscriptions(queryOptions),
232+
mutation: useShopperConsentsMutation('updateSubscriptions')
233+
}
234+
})
235+
236+
// Initial fetch - get stale data
237+
await waitAndExpectSuccess(() => result.current.query)
238+
expect(result.current.query.data?.data).toHaveLength(1)
239+
240+
// Perform mutation
241+
act(() =>
242+
result.current.mutation.mutate({
243+
parameters: PARAMETERS,
244+
body: {
245+
subscriptions: [
246+
{
247+
subscriptionId: 'test-subscription',
248+
channel: 'email' as ShopperConsentsTypes.ChannelType,
249+
status: 'opt_out' as ShopperConsentsTypes.ConsentStatus,
250+
contactPointValue: 'test@example.com'
251+
},
252+
{
253+
subscriptionId: 'test-subscription-2',
254+
channel: 'sms' as ShopperConsentsTypes.ChannelType,
255+
status: 'opt_in' as ShopperConsentsTypes.ConsentStatus,
256+
contactPointValue: '+15551234567'
257+
}
258+
]
259+
}
260+
})
261+
)
262+
await waitAndExpectSuccess(() => result.current.mutation)
263+
264+
// Wait for refetch to complete with fresh data
265+
await waitFor(() => {
266+
expect(result.current.query.data?.data).toHaveLength(2)
267+
})
268+
expect(result.current.query.data).toEqual(freshResponse)
269+
})
270+
})
271+
})

0 commit comments

Comments
 (0)