@salesforce/retail-react-app 7.0.0-preview.0 → 7.0.0

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 (38) hide show
  1. package/CHANGELOG.md +9 -8
  2. package/app/components/dynamic-image/index.jsx +91 -16
  3. package/app/components/dynamic-image/index.test.js +214 -30
  4. package/app/components/image/index.jsx +5 -13
  5. package/app/components/image/index.test.js +6 -3
  6. package/app/components/island/README.md +15 -10
  7. package/app/components/island/index.jsx +12 -5
  8. package/app/components/island/index.test.js +35 -0
  9. package/app/components/passwordless-login/index.jsx +4 -5
  10. package/app/components/passwordless-login/index.test.js +2 -4
  11. package/app/components/product-tile/index.jsx +1 -1
  12. package/app/components/product-view-modal/bundle.jsx +12 -2
  13. package/app/components/social-login/index.jsx +1 -0
  14. package/app/components/standard-login/index.jsx +4 -1
  15. package/app/constants.js +3 -0
  16. package/app/hooks/use-auth-modal.js +68 -67
  17. package/app/hooks/use-auth-modal.test.js +93 -23
  18. package/app/hooks/use-datacloud.js +169 -192
  19. package/app/hooks/use-datacloud.test.js +273 -17
  20. package/app/pages/cart/index.jsx +2 -1
  21. package/app/pages/cart/partials/cart-secondary-button-group.jsx +8 -10
  22. package/app/pages/cart/partials/cart-secondary-button-group.test.js +2 -3
  23. package/app/pages/checkout/partials/contact-info.jsx +9 -8
  24. package/app/pages/checkout/partials/contact-info.test.js +41 -4
  25. package/app/pages/checkout/partials/login-state.jsx +3 -3
  26. package/app/pages/home/index.test.js +2 -1
  27. package/app/pages/login/index.jsx +37 -37
  28. package/app/pages/login/index.test.js +42 -0
  29. package/app/pages/product-detail/index.jsx +64 -73
  30. package/app/pages/product-list/index.jsx +19 -9
  31. package/app/pages/product-list/index.test.js +153 -19
  32. package/app/utils/image.js +29 -0
  33. package/app/utils/image.test.js +141 -1
  34. package/app/utils/responsive-image.js +197 -115
  35. package/app/utils/responsive-image.test.js +483 -133
  36. package/config/default.js +2 -2
  37. package/config/mocks/default.js +2 -2
  38. package/package.json +7 -7
@@ -50,7 +50,8 @@ const mockRegisteredCustomer = {
50
50
 
51
51
  const mockAuthHelperFunctions = {
52
52
  [AuthHelpers.AuthorizePasswordless]: {mutateAsync: jest.fn()},
53
- [AuthHelpers.Register]: {mutateAsync: jest.fn()}
53
+ [AuthHelpers.Register]: {mutateAsync: jest.fn()},
54
+ [AuthHelpers.LoginRegisteredUserB2C]: {mutateAsync: jest.fn()}
54
55
  }
55
56
 
56
57
  jest.mock('@salesforce/commerce-sdk-react', () => {
@@ -119,6 +120,7 @@ beforeEach(() => {
119
120
  afterEach(() => {
120
121
  localStorage.clear()
121
122
  jest.resetModules()
123
+ jest.restoreAllMocks()
122
124
  })
123
125
 
124
126
  test('Renders login modal by default', async () => {
@@ -171,11 +173,41 @@ test('Renders check email modal on email mode', async () => {
171
173
  mockUseForm.mockRestore()
172
174
  })
173
175
 
176
+ test('allows regular login via Enter key in password mode', async () => {
177
+ const {user} = renderWithProviders(<MockedComponent isPasswordlessEnabled={true} />)
178
+ const validEmail = 'test@salesforce.com'
179
+ const validPassword = 'Password123!'
180
+
181
+ // open the modal
182
+ const trigger = screen.getByText(/open modal/i)
183
+ await user.click(trigger)
184
+
185
+ await waitFor(() => {
186
+ expect(screen.getByText(/continue securely/i)).toBeInTheDocument()
187
+ })
188
+
189
+ // enter email and switch to password mode
190
+ await user.type(screen.getByLabelText('Email'), validEmail)
191
+ await user.click(screen.getByText(/password/i))
192
+
193
+ // enter password
194
+ await user.type(screen.getByLabelText('Password'), validPassword)
195
+
196
+ // simulate Enter key press in password field
197
+ await user.keyboard('{Enter}')
198
+
199
+ // should trigger regular login
200
+ expect(
201
+ mockAuthHelperFunctions[AuthHelpers.LoginRegisteredUserB2C].mutateAsync
202
+ ).toHaveBeenCalledWith({
203
+ username: validEmail,
204
+ password: validPassword
205
+ })
206
+ })
207
+
174
208
  describe('Passwordless enabled', () => {
175
209
  test('Renders passwordless login when enabled', async () => {
176
- const user = userEvent.setup()
177
-
178
- renderWithProviders(<MockedComponent isPasswordlessEnabled={true} />)
210
+ const {user} = renderWithProviders(<MockedComponent isPasswordlessEnabled={true} />)
179
211
 
180
212
  // open the modal
181
213
  const trigger = screen.getByText(/open modal/i)
@@ -187,6 +219,10 @@ describe('Passwordless enabled', () => {
187
219
  })
188
220
 
189
221
  test('Allows passwordless login', async () => {
222
+ jest.spyOn(window, 'location', 'get').mockReturnValue({
223
+ pathname: '/',
224
+ origin: 'https://example.com'
225
+ })
190
226
  const {user} = renderWithProviders(<MockedComponent isPasswordlessEnabled={true} />)
191
227
  const validEmail = 'test@salesforce.com'
192
228
 
@@ -203,8 +239,6 @@ describe('Passwordless enabled', () => {
203
239
 
204
240
  // initiate passwordless login
205
241
  const passwordlessLoginButton = screen.getByText(/continue securely/i)
206
- // Click the button twice as the isPasswordlessLoginClicked state doesn't change after the first click
207
- await user.click(passwordlessLoginButton)
208
242
  await user.click(passwordlessLoginButton)
209
243
  expect(
210
244
  mockAuthHelperFunctions[AuthHelpers.AuthorizePasswordless].mutateAsync
@@ -214,20 +248,64 @@ describe('Passwordless enabled', () => {
214
248
  })
215
249
 
216
250
  // check that check email modal is open
251
+ await waitFor(
252
+ () => {
253
+ const withinForm = within(screen.getByTestId('sf-form-resend-passwordless-email'))
254
+ expect(withinForm.getByText(/Check Your Email/i)).toBeInTheDocument()
255
+ expect(withinForm.getByText(validEmail)).toBeInTheDocument()
256
+ },
257
+ {timeout: 5000}
258
+ )
259
+
260
+ // resend the email
261
+ await user.click(screen.getByText(/Resend Link/i))
262
+ expect(
263
+ mockAuthHelperFunctions[AuthHelpers.AuthorizePasswordless].mutateAsync
264
+ ).toHaveBeenCalledWith({
265
+ userid: validEmail,
266
+ callbackURI: 'https://webhook.site/27761b71-50c1-4097-a600-21a3b89a546c?redirectUrl=/'
267
+ })
268
+ })
269
+
270
+ test('allows passwordless login via Enter key', async () => {
271
+ jest.spyOn(window, 'location', 'get').mockReturnValue({
272
+ pathname: '/',
273
+ origin: 'https://example.com'
274
+ })
275
+ const {user} = renderWithProviders(<MockedComponent isPasswordlessEnabled={true} />)
276
+ const validEmail = 'test@salesforce.com'
277
+
278
+ // open the modal
279
+ const trigger = screen.getByText(/open modal/i)
280
+ await user.click(trigger)
281
+
217
282
  await waitFor(() => {
218
- const withinForm = within(screen.getByTestId('sf-form-resend-passwordless-email'))
219
- expect(withinForm.getByText(/Check Your Email/i)).toBeInTheDocument()
220
- expect(withinForm.getByText(validEmail)).toBeInTheDocument()
283
+ expect(screen.getByText(/continue securely/i)).toBeInTheDocument()
221
284
  })
222
285
 
223
- // resend the email
224
- user.click(screen.getByText(/Resend Link/i))
286
+ // enter a valid email address
287
+ await user.type(screen.getByLabelText('Email'), validEmail)
288
+
289
+ // simulate Enter key press in email field
290
+ await user.keyboard('{Enter}')
291
+
292
+ // should trigger passwordless login
225
293
  expect(
226
294
  mockAuthHelperFunctions[AuthHelpers.AuthorizePasswordless].mutateAsync
227
295
  ).toHaveBeenCalledWith({
228
296
  userid: validEmail,
229
297
  callbackURI: 'https://webhook.site/27761b71-50c1-4097-a600-21a3b89a546c?redirectUrl=/'
230
298
  })
299
+
300
+ // check that check email modal is open
301
+ await waitFor(
302
+ () => {
303
+ const withinForm = within(screen.getByTestId('sf-form-resend-passwordless-email'))
304
+ expect(withinForm.getByText(/Check Your Email/i)).toBeInTheDocument()
305
+ expect(withinForm.getByText(validEmail)).toBeInTheDocument()
306
+ },
307
+ {timeout: 5000}
308
+ )
231
309
  })
232
310
  })
233
311
 
@@ -277,10 +355,8 @@ test.skip('Renders error when given incorrect log in credentials', async () => {
277
355
  })
278
356
 
279
357
  test('Allows customer to create an account', async () => {
280
- const user = userEvent.setup()
281
-
282
358
  // render our test component
283
- renderWithProviders(<MockedComponent />, {
359
+ const {user} = renderWithProviders(<MockedComponent />, {
284
360
  wrapperProps: {
285
361
  bypassAuth: true
286
362
  }
@@ -360,10 +436,8 @@ test('Allows customer to create an account', async () => {
360
436
  // TODO: investigate why this test is failing when running with other tests
361
437
  // eslint-disable-next-line jest/no-disabled-tests
362
438
  test.skip('Allows customer to sign in to their account', async () => {
363
- const user = userEvent.setup()
364
-
365
439
  // render our test component
366
- renderWithProviders(<MockedComponent />, {
440
+ const {user} = renderWithProviders(<MockedComponent />, {
367
441
  wrapperProps: {
368
442
  bypassAuth: false
369
443
  }
@@ -418,10 +492,8 @@ describe('Reset password', function () {
418
492
  // TODO: Fix flaky/broken test
419
493
  // eslint-disable-next-line jest/no-disabled-tests
420
494
  test.skip('Allows customer to generate password token', async () => {
421
- const user = userEvent.setup()
422
-
423
495
  // render our test component
424
- renderWithProviders(<MockedComponent initialView="password" />, {
496
+ const {user} = renderWithProviders(<MockedComponent initialView="password" />, {
425
497
  wrapperProps: {
426
498
  bypassAuth: false
427
499
  }
@@ -451,10 +523,8 @@ describe('Reset password', function () {
451
523
  // TODO: Fix flaky/broken test
452
524
  // eslint-disable-next-line jest/no-disabled-tests
453
525
  test.skip('Allows customer to open generate password token modal from everywhere', async () => {
454
- const user = userEvent.setup()
455
-
456
526
  // render our test component
457
- renderWithProviders(<MockedComponent initialView="password" />)
527
+ const {user} = renderWithProviders(<MockedComponent initialView="password" />)
458
528
 
459
529
  // open the modal
460
530
  const trigger = screen.getByText(/open modal/i)
@@ -16,12 +16,6 @@ import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-cur
16
16
  export class DataCloudApi {
17
17
  constructor({siteId, appSourceId, tenantId, dnt}) {
18
18
  this.siteId = siteId
19
-
20
- // Return early if Data Cloud API configuration is not available
21
- if (!appSourceId || !tenantId) {
22
- console.error('DataCloud API Configuration is missing.')
23
- return
24
- }
25
19
  this.sdk = initDataCloudSdk(tenantId, appSourceId)
26
20
  this.dnt = dnt
27
21
  }
@@ -38,13 +32,20 @@ export class DataCloudApi {
38
32
  guestId: args.guestId,
39
33
  siteId: this.siteId,
40
34
  sessionId: args.sessionId,
41
- deviceId: args.deviceId,
35
+ deviceId: args.customerId || args.guestId,
42
36
  dateTime: new Date().toISOString(),
43
37
  ...(args.customerId && {customerId: args.customerId}), // Can remove the conditionality after the hook -> Promise is changed in future PWA release
44
38
  ...(args.customerNo && {customerNo: args.customerNo})
45
39
  }
46
40
  }
47
41
 
42
+ /**
43
+ * Constructs a user details object for use in identity events.
44
+ * Includes information such as guest/registered status, first/last name, and other profile data.
45
+ *
46
+ * @param {object} args - The arguments containing user profile details (isGuest, firstName, lastName, etc.).
47
+ * @returns {object} - The user details object for identity events.
48
+ */
48
49
  _constructUserDetails(args) {
49
50
  return {
50
51
  isAnonymous: args.isGuest,
@@ -106,50 +107,138 @@ export class DataCloudApi {
106
107
  }
107
108
  }
108
109
 
110
+ /**
111
+ * Concatenates multiple event objects into a single object by merging their properties.
112
+ * Later objects in the argument list will override properties from earlier ones if there are conflicts.
113
+ *
114
+ * @param {...object} events - One or more event objects to be merged.
115
+ * @returns {object} - The merged event object containing all properties from the input objects.
116
+ */
109
117
  _concatenateEvents = (...events) => ({...events.reduce((acc, obj) => ({...acc, ...obj}), {})})
110
118
 
111
119
  /**
112
- * Sends a `page-view` event to Data Cloud.
120
+ * Constructs the party identification event object for a user.
121
+ * This includes identifiers and metadata for the user, such as guest or registered customer IDs.
113
122
  *
114
- * This method records an `userEnagement` event type to track which page the shopper has viewed.
115
- *
116
- * @param {string} path - The URL path of the page that was viewed
117
- * @param {object} args - Additional metadata for the event
123
+ * @param {object} args - The arguments containing user identification details (isGuest, guestId, customerId).
124
+ * @returns {object} - The party identification event object with required fields for the schema.
118
125
  */
119
- async sendViewPage(path, args) {
126
+ _constructPartyIdentification(args) {
127
+ const partyIdentifier = args.isGuest ? args.guestId : args.customerId
128
+ const partyIdType = args.isGuest ? 'CC_USID' : 'CC_REGISTERED_CUSTOMER_ID'
129
+
130
+ return {
131
+ party: partyIdentifier,
132
+ userId: partyIdentifier,
133
+ IDName: partyIdType,
134
+ IDType: partyIdType,
135
+ partyIdentificationId: partyIdentifier,
136
+ internalOrganizationId: this.siteId,
137
+ creationEventId: crypto.randomUUID()
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Creates standard events (identity, party identification, contact point email)
143
+ * that are common across all send methods
144
+ */
145
+ _createStandardEvents(args, additionalIdentityProps = {}) {
120
146
  const baseEvent = this._constructBaseEvent(args)
121
147
  const userDetails = this._constructUserDetails(args)
122
148
 
123
- // If DNT, we do not send the identity Profile event
124
149
  const identityProfile = this.dnt
125
150
  ? {}
126
151
  : this._concatenateEvents(
127
152
  baseEvent,
128
153
  this._generateEventDetails('identity', 'Profile'),
129
154
  userDetails,
130
- {
131
- sourceUrl: path
132
- }
155
+ additionalIdentityProps
156
+ )
157
+
158
+ const partyIdentification = this.dnt
159
+ ? {}
160
+ : this._concatenateEvents(
161
+ baseEvent,
162
+ this._generateEventDetails('partyIdentification', 'Profile'),
163
+ this._constructPartyIdentification(args)
133
164
  )
134
165
 
135
- const userEngagement = this._concatenateEvents(
136
- baseEvent,
137
- this._generateEventDetails('userEngagement', 'Engagement'),
138
- {
139
- interactionName: 'page-view',
140
- sourceUrl: path
141
- }
142
- )
166
+ const contactPointEmail = args.email
167
+ ? this._concatenateEvents(
168
+ baseEvent,
169
+ this._generateEventDetails('contactPointEmail', 'Profile', args.email)
170
+ )
171
+ : null
143
172
 
144
- const interaction = {
145
- events: [...(!this.dnt ? [identityProfile] : []), userEngagement]
173
+ return {baseEvent, identityProfile, partyIdentification, contactPointEmail}
174
+ }
175
+
176
+ _handleApiError(err) {
177
+ if (err?.response?.status === 400) {
178
+ logger.warn(
179
+ '[DataCloudApi] 400 Bad Request: Check your Data Cloud configuration (appSourceId, tenantId) and event payload.',
180
+ {
181
+ namespace: 'use-datacloud._handleApiError',
182
+ additionalProperties: {error: err?.response}
183
+ }
184
+ )
185
+ } else {
186
+ logger.error('[DataCloudApi] Error sending Data Cloud event', {
187
+ namespace: 'use-datacloud._handleApiError',
188
+ additionalProperties: {error: err?.response}
189
+ })
146
190
  }
191
+ }
192
+
193
+ /**
194
+ * Constructs the interaction object and sends it to Data Cloud
195
+ */
196
+ _sendInteraction(standardEvents, specificEvents) {
197
+ const {identityProfile, partyIdentification, contactPointEmail} = standardEvents
147
198
 
148
- try {
149
- this.sdk.webEventsAppSourceIdPost(interaction)
150
- } catch (err) {
151
- logger.error('Error sending DataCloud event', err)
199
+ const interaction = {
200
+ events: [
201
+ ...(!this.dnt ? [identityProfile, partyIdentification] : []),
202
+ ...(contactPointEmail ? [contactPointEmail] : []),
203
+ ...specificEvents
204
+ ]
152
205
  }
206
+
207
+ return this.sdk
208
+ .webEventsAppSourceIdPost(interaction)
209
+ .catch((err) => this._handleApiError(err))
210
+ }
211
+
212
+ /**
213
+ * Maps search results to DataCloud product format
214
+ */
215
+ _mapSearchResultsToProducts(searchResults) {
216
+ return searchResults?.hits?.map((product) => this._constructDatacloudProduct(product)) || []
217
+ }
218
+
219
+ /**
220
+ * Sends a `page-view` event to Data Cloud.
221
+ *
222
+ * This method records an `userEnagement` event type to track which page the shopper has viewed.
223
+ *
224
+ * @param {string} path - The URL path of the page that was viewed
225
+ * @param {object} args - Additional metadata for the event
226
+ */
227
+ async sendViewPage(path, args) {
228
+ const standardEvents = this._createStandardEvents(args, {sourceUrl: path})
229
+
230
+ const specificEvents = [
231
+ this._concatenateEvents(
232
+ standardEvents.baseEvent,
233
+ this._generateEventDetails('userEngagement', 'Engagement'),
234
+ {
235
+ interactionName: 'page-view',
236
+ sourceUrl: path
237
+ }
238
+ )
239
+ ]
240
+
241
+ return this._sendInteraction(standardEvents, specificEvents)
153
242
  }
154
243
 
155
244
  /**
@@ -162,50 +251,22 @@ export class DataCloudApi {
162
251
  * @param {object} args - Additional metadata for the event
163
252
  */
164
253
  async sendViewProduct(product, args) {
165
- const baseEvent = this._constructBaseEvent(args)
166
- const baseProduct = this._constructDatacloudProduct(product)
167
- const userDetails = this._constructUserDetails(args)
168
-
169
- const identityProfile = this.dnt
170
- ? {}
171
- : this._concatenateEvents(
172
- baseEvent,
173
- this._generateEventDetails('identity', 'Profile'),
174
- userDetails
175
- )
254
+ const standardEvents = this._createStandardEvents(args)
176
255
 
177
- let contactPointEmail = null
178
- if (args.email) {
179
- contactPointEmail = this._concatenateEvents(
180
- baseEvent,
181
- this._generateEventDetails('contactPointEmail', 'Profile', args.email)
256
+ const specificEvents = [
257
+ this._concatenateEvents(
258
+ standardEvents.baseEvent,
259
+ this._generateEventDetails('catalog', 'Engagement'),
260
+ this._constructDatacloudProduct(product),
261
+ {
262
+ type: 'Product',
263
+ webStoreId: 'pwa',
264
+ interactionName: 'catalog-object-view-start'
265
+ }
182
266
  )
183
- }
184
-
185
- const catalog = this._concatenateEvents(
186
- baseEvent,
187
- this._generateEventDetails('catalog', 'Engagement'),
188
- baseProduct,
189
- {
190
- type: 'Product',
191
- webStoreId: 'pwa',
192
- interactionName: 'catalog-object-view-start'
193
- }
194
- )
195
-
196
- const interaction = {
197
- events: [
198
- ...(!this.dnt ? [identityProfile] : []),
199
- ...(contactPointEmail ? [contactPointEmail] : []),
200
- catalog
201
- ]
202
- }
267
+ ]
203
268
 
204
- try {
205
- this.sdk.webEventsAppSourceIdPost(interaction)
206
- } catch (err) {
207
- logger.error('Error sending DataCloud event', err)
208
- }
269
+ return this._sendInteraction(standardEvents, specificEvents)
209
270
  }
210
271
 
211
272
  /**
@@ -222,16 +283,11 @@ export class DataCloudApi {
222
283
  * @param {object} args - Additional metadata for the event
223
284
  */
224
285
  async sendViewCategory(searchParams, category, searchResults, args) {
225
- const baseEvent = this._constructBaseEvent(args)
226
- const userDetails = this._constructUserDetails(args)
286
+ const standardEvents = this._createStandardEvents(args)
227
287
 
228
- const products = searchResults?.hits?.map((product) =>
229
- this._constructDatacloudProduct(product)
230
- )
231
-
232
- const catalogObjects = products.map((product) => {
288
+ const specificEvents = this._mapSearchResultsToProducts(searchResults).map((product) => {
233
289
  return this._concatenateEvents(
234
- baseEvent,
290
+ standardEvents.baseEvent,
235
291
  this._generateEventDetails('catalog', 'Engagement'),
236
292
  this._constructBaseSearchResult(searchParams),
237
293
  {
@@ -244,35 +300,7 @@ export class DataCloudApi {
244
300
  )
245
301
  })
246
302
 
247
- const identityProfile = this.dnt
248
- ? null
249
- : this._concatenateEvents(
250
- baseEvent,
251
- this._generateEventDetails('identity', 'Profile'),
252
- userDetails
253
- )
254
-
255
- let contactPointEmail = null
256
- if (args.email) {
257
- contactPointEmail = this._concatenateEvents(
258
- baseEvent,
259
- this._generateEventDetails('contactPointEmail', 'Profile', args.email)
260
- )
261
- }
262
-
263
- const interaction = {
264
- events: [
265
- ...(!this.dnt ? [identityProfile] : []),
266
- ...(contactPointEmail ? [contactPointEmail] : []),
267
- ...catalogObjects
268
- ]
269
- }
270
-
271
- try {
272
- this.sdk.webEventsAppSourceIdPost(interaction)
273
- } catch (err) {
274
- logger.error('Error sending DataCloud event', err)
275
- }
303
+ return this._sendInteraction(standardEvents, specificEvents)
276
304
  }
277
305
 
278
306
  /**
@@ -289,16 +317,11 @@ export class DataCloudApi {
289
317
  * @param {object} args - Additional metadata for the event
290
318
  */
291
319
  async sendViewSearchResults(searchParams, searchResults, args) {
292
- const baseEvent = this._constructBaseEvent(args)
293
- const userDetails = this._constructUserDetails(args)
294
-
295
- const products = searchResults?.hits?.map((product) =>
296
- this._constructDatacloudProduct(product)
297
- )
320
+ const standardEvents = this._createStandardEvents(args)
298
321
 
299
- const catalogObjects = products.map((product) => {
322
+ const specificEvents = this._mapSearchResultsToProducts(searchResults).map((product) => {
300
323
  return this._concatenateEvents(
301
- baseEvent,
324
+ standardEvents.baseEvent,
302
325
  this._generateEventDetails('catalog', 'Engagement'),
303
326
  this._constructBaseSearchResult(searchParams),
304
327
  {
@@ -311,35 +334,7 @@ export class DataCloudApi {
311
334
  )
312
335
  })
313
336
 
314
- const identityProfile = this.dnt
315
- ? {}
316
- : this._concatenateEvents(
317
- baseEvent,
318
- this._generateEventDetails('identity', 'Profile'),
319
- userDetails
320
- )
321
-
322
- let contactPointEmail = null
323
- if (args.email) {
324
- contactPointEmail = this._concatenateEvents(
325
- baseEvent,
326
- this._generateEventDetails('contactPointEmail', 'Profile', args.email)
327
- )
328
- }
329
-
330
- const interaction = {
331
- events: [
332
- ...(!this.dnt ? [identityProfile] : []),
333
- ...(contactPointEmail ? [contactPointEmail] : []),
334
- ...catalogObjects
335
- ]
336
- }
337
-
338
- try {
339
- this.sdk.webEventsAppSourceIdPost(interaction)
340
- } catch (err) {
341
- logger.error('Error sending DataCloud event', err)
342
- }
337
+ return this._sendInteraction(standardEvents, specificEvents)
343
338
  }
344
339
 
345
340
  /**
@@ -354,12 +349,11 @@ export class DataCloudApi {
354
349
  * @param {object} args - Additional metadata for the event
355
350
  */
356
351
  async sendViewRecommendations(recommenderDetails, products, args) {
357
- const baseEvent = this._constructBaseEvent(args)
358
- const userDetails = this._constructUserDetails(args)
352
+ const standardEvents = this._createStandardEvents(args)
359
353
 
360
- const catalogObjects = products.map((product) => {
354
+ const specificEvents = products.map((product) => {
361
355
  return this._concatenateEvents(
362
- baseEvent,
356
+ standardEvents.baseEvent,
363
357
  this._generateEventDetails('catalog', 'Engagement'),
364
358
  {
365
359
  id: product.id,
@@ -372,35 +366,7 @@ export class DataCloudApi {
372
366
  )
373
367
  })
374
368
 
375
- const identityProfile = this.dnt
376
- ? {}
377
- : this._concatenateEvents(
378
- baseEvent,
379
- this._generateEventDetails('identity', 'Profile'),
380
- userDetails
381
- )
382
-
383
- let contactPointEmail = null
384
- if (args.email) {
385
- contactPointEmail = this._concatenateEvents(
386
- baseEvent,
387
- this._generateEventDetails('contactPointEmail', 'Profile', args.email)
388
- )
389
- }
390
-
391
- const interaction = {
392
- events: [
393
- ...(!this.dnt ? [identityProfile] : []),
394
- ...(contactPointEmail ? [contactPointEmail] : []),
395
- ...catalogObjects
396
- ]
397
- }
398
-
399
- try {
400
- this.sdk.webEventsAppSourceIdPost(interaction)
401
- } catch (err) {
402
- logger.error('Error sending DataCloud event', err)
403
- }
369
+ return this._sendInteraction(standardEvents, specificEvents)
404
370
  }
405
371
  }
406
372
 
@@ -428,7 +394,7 @@ const useDataCloud = () => {
428
394
  customerId: effectiveDnt ? '__DNT__' : customer?.customerId,
429
395
  customerNo: effectiveDnt ? '__DNT__' : customer?.customerNo,
430
396
  guestId: effectiveDnt ? '__DNT__' : usid,
431
- deviceId: effectiveDnt ? '__DNT__' : usid,
397
+ deviceId: effectiveDnt ? '__DNT__' : customer?.customerId || usid,
432
398
  sessionId: effectiveDnt ? '__DNT__' : sessionId,
433
399
  firstName: customer?.firstName,
434
400
  lastName: customer?.lastName,
@@ -436,23 +402,34 @@ const useDataCloud = () => {
436
402
  }
437
403
  }
438
404
 
439
- // Grab Data Cloud configuration values and intialize the sdk
405
+ // Grab Data Cloud configuration values. Only initialize the SDK if config is present.
440
406
  const {
441
- app: {dataCloudAPI: config}
407
+ app: {dataCloudAPI: config = {}}
442
408
  } = getConfig()
443
409
 
444
410
  const {appSourceId, tenantId} = config
445
411
 
446
- const dataCloud = useMemo(
447
- () =>
448
- new DataCloudApi({
449
- siteId: site.id,
450
- appSourceId: appSourceId,
451
- tenantId: tenantId,
452
- dnt: effectiveDnt
453
- }),
454
- [site]
455
- )
412
+ const dataCloud = useMemo(() => {
413
+ if (!appSourceId || !tenantId) return null
414
+ return new DataCloudApi({
415
+ siteId: site.id,
416
+ appSourceId,
417
+ tenantId,
418
+ dnt: effectiveDnt
419
+ })
420
+ }, [site, appSourceId, tenantId, effectiveDnt])
421
+
422
+ // If Data Cloud config is missing, return no-op async functions for all event methods (SDK will not be initialized)
423
+ if (!appSourceId || !tenantId) {
424
+ const noop = async () => {}
425
+ return {
426
+ sendViewPage: noop,
427
+ sendViewProduct: noop,
428
+ sendViewCategory: noop,
429
+ sendViewSearchResults: noop,
430
+ sendViewRecommendations: noop
431
+ }
432
+ }
456
433
 
457
434
  return {
458
435
  async sendViewPage(...args) {