@capillarytech/creatives-library 8.0.318 → 8.0.319

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 (36) hide show
  1. package/constants/unified.js +1 -0
  2. package/package.json +1 -1
  3. package/services/api.js +6 -0
  4. package/services/tests/api.test.js +7 -0
  5. package/utils/common.js +6 -1
  6. package/v2Containers/CommunicationFlow/CommunicationFlow.js +291 -0
  7. package/v2Containers/CommunicationFlow/CommunicationFlow.scss +25 -0
  8. package/v2Containers/CommunicationFlow/Tests/CommunicationFlow.test.js +255 -0
  9. package/v2Containers/CommunicationFlow/constants.js +200 -0
  10. package/v2Containers/CommunicationFlow/index.js +102 -0
  11. package/v2Containers/CommunicationFlow/messages.js +346 -0
  12. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/ChannelSelectionStep.js +522 -0
  13. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/ChannelSelectionStep.scss +170 -0
  14. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/Tests/ChannelSelectionStep.test.js +796 -0
  15. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/index.js +5 -0
  16. package/v2Containers/CommunicationFlow/steps/CommunicationStrategyStep/CommunicationStrategyStep.js +95 -0
  17. package/v2Containers/CommunicationFlow/steps/CommunicationStrategyStep/Tests/CommunicationStrategyStep.test.js +133 -0
  18. package/v2Containers/CommunicationFlow/steps/CommunicationStrategyStep/index.js +5 -0
  19. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/DeliverySettingsSection.js +289 -0
  20. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/DeliverySettingsSection.scss +70 -0
  21. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/SenderDetails.js +319 -0
  22. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/SenderDetails.scss +69 -0
  23. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/DeliverySettingsSection.test.js +616 -0
  24. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/SenderDetails.test.js +577 -0
  25. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/deliverySettingsConfig.test.js +1111 -0
  26. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/deliverySettingsConfig.js +696 -0
  27. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/index.js +7 -0
  28. package/v2Containers/CommunicationFlow/steps/DynamicControlsStep/DynamicControlsStep.js +102 -0
  29. package/v2Containers/CommunicationFlow/steps/DynamicControlsStep/DynamicControlsStep.scss +36 -0
  30. package/v2Containers/CommunicationFlow/steps/DynamicControlsStep/Tests/DynamicControlsStep.test.js +91 -0
  31. package/v2Containers/CommunicationFlow/steps/DynamicControlsStep/index.js +5 -0
  32. package/v2Containers/CommunicationFlow/steps/MessageTypeStep/MessageTypeStep.js +86 -0
  33. package/v2Containers/CommunicationFlow/steps/MessageTypeStep/Tests/MessageTypeStep.test.js +100 -0
  34. package/v2Containers/CommunicationFlow/steps/MessageTypeStep/index.js +5 -0
  35. package/v2Containers/CommunicationFlow/utils/getEnabledSteps.js +30 -0
  36. package/v2Containers/CreativesContainer/constants.js +3 -0
@@ -0,0 +1,796 @@
1
+ /* eslint-disable react/prop-types */
2
+ import React from 'react';
3
+ import {
4
+ render, screen, waitFor, within,
5
+ } from '@testing-library/react';
6
+ import userEvent from '@testing-library/user-event';
7
+ import '@testing-library/jest-dom';
8
+ import { IntlProvider } from 'react-intl';
9
+ import ChannelSelectionStep from '../ChannelSelectionStep';
10
+ import { CHANNELS } from '../../../constants';
11
+
12
+ jest.mock('../../../../CreativesContainer', () => function MockCreativesContainer({
13
+ getCreativesData,
14
+ handleCloseCreatives,
15
+ creativesMode,
16
+ channel,
17
+ }) {
18
+ return (
19
+ <div data-testid="creatives-mock" data-creatives-mode={creativesMode} data-creatives-channel={channel}>
20
+ <button
21
+ type="button"
22
+ data-testid="creatives-save"
23
+ onClick={() => getCreativesData({ smsBody: 'Saved body' })}
24
+ >
25
+ Save creative
26
+ </button>
27
+ <button
28
+ type="button"
29
+ data-testid="creatives-save-null"
30
+ onClick={() => getCreativesData(null)}
31
+ >
32
+ Save null
33
+ </button>
34
+ <button type="button" data-testid="creatives-close" onClick={handleCloseCreatives}>
35
+ Close creative
36
+ </button>
37
+ </div>
38
+ );
39
+ });
40
+
41
+ jest.mock('../../DeliverySettingsStep', () => ({
42
+ DeliverySettingsSection: function MockDeliverySettings({ onDeliverySettingChange }) {
43
+ return (
44
+ <div data-testid="delivery-settings-section">
45
+ <button
46
+ type="button"
47
+ data-testid="delivery-apply"
48
+ onClick={() => onDeliverySettingChange({ channelSetting: { SMS: { domainId: 'd1' } } })}
49
+ >
50
+ Apply delivery
51
+ </button>
52
+ </div>
53
+ );
54
+ },
55
+ }));
56
+
57
+ function renderStep(ui) {
58
+ return render(
59
+ <IntlProvider locale="en" messages={{}} defaultLocale="en">
60
+ {ui}
61
+ </IntlProvider>,
62
+ );
63
+ }
64
+
65
+ describe('ChannelSelectionStep', () => {
66
+ beforeEach(() => {
67
+ jest.spyOn(console, 'log').mockImplementation(() => {});
68
+ });
69
+
70
+ afterEach(() => {
71
+ console.log.mockRestore();
72
+ });
73
+
74
+ it('empty state: shows content template heading and add-template entry point', () => {
75
+ const onChange = jest.fn();
76
+ renderStep(
77
+ <ChannelSelectionStep
78
+ value={{ contentItems: [] }}
79
+ onChange={onChange}
80
+ channels={CHANNELS}
81
+ />,
82
+ );
83
+
84
+ expect(screen.getAllByText('Content template').length).toBeGreaterThanOrEqual(1);
85
+ expect(screen.getByText('Add message content and incentive')).toBeInTheDocument();
86
+ expect(screen.getByRole('button', { name: /content template/i })).toBeInTheDocument();
87
+ expect(screen.queryByTestId('creatives-mock')).not.toBeInTheDocument();
88
+ });
89
+
90
+ it('shows validation error when error prop is set', () => {
91
+ renderStep(
92
+ <ChannelSelectionStep
93
+ value={{ contentItems: [] }}
94
+ onChange={jest.fn()}
95
+ channels={CHANNELS}
96
+ error="Channel step error"
97
+ />,
98
+ );
99
+ expect(screen.getByText('Channel step error')).toBeInTheDocument();
100
+ });
101
+
102
+ it('with content: shows preview text, sender line for SMS, and remove updates parent', async () => {
103
+ const onChange = jest.fn();
104
+ renderStep(
105
+ <ChannelSelectionStep
106
+ value={{
107
+ contentItems: [
108
+ {
109
+ contentId: 'id-1',
110
+ channel: 'SMS',
111
+ templateData: {
112
+ smsBody: 'Preview line for card',
113
+ templateConfigs: { registeredSenderIds: 'SID99' },
114
+ },
115
+ },
116
+ ],
117
+ }}
118
+ onChange={onChange}
119
+ channels={CHANNELS}
120
+ />,
121
+ );
122
+
123
+ expect(screen.getByText('Preview line for card')).toBeInTheDocument();
124
+ expect(screen.getByText(/SMS sender ID SID99/)).toBeInTheDocument();
125
+
126
+ await userEvent.click(screen.getByLabelText('Show more content options icon'));
127
+ await waitFor(() => {
128
+ expect(screen.getByRole('menu')).toBeInTheDocument();
129
+ });
130
+ await userEvent.click(within(screen.getByRole('menu')).getByText('Remove'));
131
+
132
+ expect(onChange).toHaveBeenCalledWith({ contentItems: [] });
133
+ });
134
+
135
+ it('add SMS template: opens creatives, save calls onChange with new content item', async () => {
136
+ const onChange = jest.fn();
137
+ renderStep(
138
+ <ChannelSelectionStep
139
+ value={{ contentItems: [] }}
140
+ onChange={onChange}
141
+ channels={CHANNELS}
142
+ />,
143
+ );
144
+
145
+ await userEvent.click(screen.getByRole('button', { name: /content template/i }));
146
+ await waitFor(() => {
147
+ expect(screen.getByRole('menu')).toBeInTheDocument();
148
+ });
149
+ await userEvent.click(within(screen.getByRole('menu')).getByText('SMS'));
150
+
151
+ await waitFor(() => {
152
+ expect(screen.getByTestId('creatives-mock')).toBeInTheDocument();
153
+ });
154
+
155
+ expect(screen.getByTestId('creatives-mock')).toHaveAttribute('data-creatives-mode', 'create');
156
+
157
+ expect(onChange).toHaveBeenCalledWith(
158
+ expect.objectContaining({ channel: 'SMS', channels: [] }),
159
+ );
160
+
161
+ await userEvent.click(screen.getByTestId('creatives-save'));
162
+
163
+ expect(onChange).toHaveBeenCalledWith({
164
+ contentItems: [
165
+ expect.objectContaining({
166
+ channel: 'SMS',
167
+ templateData: { smsBody: 'Saved body' },
168
+ }),
169
+ ],
170
+ });
171
+ });
172
+
173
+ it('closes creatives and clears editing state when handleCloseCreatives runs', async () => {
174
+ const onChange = jest.fn();
175
+ renderStep(
176
+ <ChannelSelectionStep
177
+ value={{ contentItems: [] }}
178
+ onChange={onChange}
179
+ channels={CHANNELS}
180
+ />,
181
+ );
182
+
183
+ await userEvent.click(screen.getByRole('button', { name: /content template/i }));
184
+ await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument());
185
+ await userEvent.click(within(screen.getByRole('menu')).getByText('SMS'));
186
+ await waitFor(() => expect(screen.getByTestId('creatives-mock')).toBeInTheDocument());
187
+
188
+ await userEvent.click(screen.getByTestId('creatives-close'));
189
+
190
+ expect(screen.queryByTestId('creatives-mock')).not.toBeInTheDocument();
191
+ });
192
+
193
+ it('edit existing content: opens creatives in edit mode and replaces item on save', async () => {
194
+ const onChange = jest.fn();
195
+ renderStep(
196
+ <ChannelSelectionStep
197
+ value={{
198
+ contentItems: [
199
+ {
200
+ contentId: 'edit-me',
201
+ channel: 'SMS',
202
+ templateData: { smsBody: 'Before' },
203
+ },
204
+ ],
205
+ }}
206
+ onChange={onChange}
207
+ channels={CHANNELS}
208
+ />,
209
+ );
210
+
211
+ await userEvent.click(screen.getByLabelText('Show more content options icon'));
212
+ await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument());
213
+ await userEvent.click(within(screen.getByRole('menu')).getByText('Edit'));
214
+
215
+ await waitFor(() => expect(screen.getByTestId('creatives-mock')).toBeInTheDocument());
216
+ expect(screen.getByTestId('creatives-mock')).toHaveAttribute('data-creatives-mode', 'edit');
217
+
218
+ await userEvent.click(screen.getByTestId('creatives-save'));
219
+
220
+ expect(onChange).toHaveBeenCalledWith({
221
+ contentItems: [
222
+ expect.objectContaining({
223
+ contentId: 'edit-me',
224
+ channel: 'SMS',
225
+ templateData: { smsBody: 'Saved body' },
226
+ }),
227
+ ],
228
+ });
229
+ });
230
+
231
+ it('preview menu logs content id', async () => {
232
+ renderStep(
233
+ <ChannelSelectionStep
234
+ value={{
235
+ contentItems: [
236
+ { contentId: 'p1', channel: 'SMS', templateData: { smsBody: 'x' } },
237
+ ],
238
+ }}
239
+ onChange={jest.fn()}
240
+ channels={CHANNELS}
241
+ />,
242
+ );
243
+
244
+ await userEvent.click(screen.getByLabelText('Show more content options icon'));
245
+ await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument());
246
+ await userEvent.click(within(screen.getByRole('menu')).getByText('Preview and Test'));
247
+
248
+ expect(console.log).toHaveBeenCalledWith('Preview content', 'p1');
249
+ });
250
+
251
+ it('delivery settings section forwards onDeliverySettingChange to onChange', async () => {
252
+ const onChange = jest.fn();
253
+ renderStep(
254
+ <ChannelSelectionStep
255
+ value={{
256
+ contentItems: [{ contentId: 'x', channel: 'SMS', templateData: {} }],
257
+ deliverySetting: {},
258
+ }}
259
+ onChange={onChange}
260
+ channels={CHANNELS}
261
+ deliverySettingsData={{}}
262
+ />,
263
+ );
264
+
265
+ await userEvent.click(screen.getByTestId('delivery-apply'));
266
+
267
+ expect(onChange).toHaveBeenCalledWith({
268
+ deliverySetting: { channelSetting: { SMS: { domainId: 'd1' } } },
269
+ });
270
+ });
271
+
272
+ it('incentive-only row (no content): selecting an incentive updates selectedOfferDetails', async () => {
273
+ const onChange = jest.fn();
274
+ renderStep(
275
+ <ChannelSelectionStep
276
+ value={{ contentItems: [] }}
277
+ onChange={onChange}
278
+ channels={CHANNELS}
279
+ incentivesData={{
280
+ types: [
281
+ { value: 'badges', label: 'Badges', isActive: true },
282
+ { value: 'coupons', label: 'Coupons', isActive: true },
283
+ ],
284
+ }}
285
+ />,
286
+ );
287
+
288
+ await userEvent.click(screen.getByRole('button', { name: /^add incentive$/i }));
289
+ await waitFor(() => {
290
+ expect(screen.getByRole('menu')).toBeInTheDocument();
291
+ });
292
+ await userEvent.click(within(screen.getByRole('menu')).getByText('Badges'));
293
+
294
+ expect(onChange).toHaveBeenCalledWith({
295
+ selectedOfferDetails: [{ type: 'badges' }],
296
+ });
297
+ });
298
+
299
+ it('card with content shows add-incentive action when incentives are available', async () => {
300
+ const onChange = jest.fn();
301
+ renderStep(
302
+ <ChannelSelectionStep
303
+ value={{
304
+ contentItems: [
305
+ { contentId: 'c1', channel: 'SMS', templateData: { smsBody: 'Body' } },
306
+ ],
307
+ }}
308
+ onChange={onChange}
309
+ channels={CHANNELS}
310
+ incentivesData={{
311
+ types: [{ value: 'badges', label: 'Badges', isActive: true }],
312
+ }}
313
+ />,
314
+ );
315
+
316
+ const addIncentiveLinks = screen.getAllByRole('button', { name: /add incentive/i });
317
+ expect(addIncentiveLinks.length).toBeGreaterThanOrEqual(1);
318
+ await userEvent.click(addIncentiveLinks[addIncentiveLinks.length - 1]);
319
+ await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument());
320
+ await userEvent.click(within(screen.getByRole('menu')).getByText('Badges'));
321
+
322
+ expect(onChange).toHaveBeenCalledWith({
323
+ selectedOfferDetails: [{ type: 'badges' }],
324
+ });
325
+ });
326
+
327
+ it('renders previews for email subject, message body, push title, and email sender footer', () => {
328
+ renderStep(
329
+ <ChannelSelectionStep
330
+ value={{
331
+ contentItems: [
332
+ { contentId: 'e1', channel: 'EMAIL', templateData: { emailSubject: 'Subject line' } },
333
+ { contentId: 'm1', channel: 'SMS', templateData: { messageBody: 'Plain message body text' } },
334
+ {
335
+ contentId: 'mp1',
336
+ channel: 'MOBILEPUSH',
337
+ templateData: { notificationTitle: 'Push title here' },
338
+ },
339
+ {
340
+ contentId: 'em2',
341
+ channel: 'EMAIL',
342
+ templateData: { emailFrom: 'from@example.com' },
343
+ },
344
+ ],
345
+ }}
346
+ onChange={jest.fn()}
347
+ channels={CHANNELS}
348
+ />,
349
+ );
350
+
351
+ expect(screen.getByText('Subject line')).toBeInTheDocument();
352
+ expect(screen.getByText(/Plain message body text/)).toBeInTheDocument();
353
+ expect(screen.getByText('Push title here')).toBeInTheDocument();
354
+ expect(screen.getByText('from@example.com')).toBeInTheDocument();
355
+ });
356
+
357
+ it('mobile push preview falls back to notification body when title is absent', () => {
358
+ renderStep(
359
+ <ChannelSelectionStep
360
+ value={{
361
+ contentItems: [
362
+ {
363
+ contentId: 'mp-body',
364
+ channel: 'MOBILEPUSH',
365
+ templateData: { notificationBody: 'Body only preview text' },
366
+ },
367
+ ],
368
+ }}
369
+ onChange={jest.fn()}
370
+ channels={CHANNELS}
371
+ />,
372
+ );
373
+ expect(screen.getByText(/Body only preview text/)).toBeInTheDocument();
374
+ });
375
+
376
+ it('renders SMS sender from header and Viber sender line', () => {
377
+ renderStep(
378
+ <ChannelSelectionStep
379
+ value={{
380
+ contentItems: [
381
+ { contentId: 's1', channel: 'SMS', templateData: { header: 'HDR' } },
382
+ {
383
+ contentId: 'v1',
384
+ channel: 'VIBER',
385
+ templateData: { templateConfigs: { viberSenderId: 'VIB-1' } },
386
+ },
387
+ ],
388
+ }}
389
+ onChange={jest.fn()}
390
+ channels={CHANNELS}
391
+ />,
392
+ );
393
+
394
+ expect(screen.getByText(/SMS sender ID HDR/)).toBeInTheDocument();
395
+ expect(screen.getByText(/Viber sender ID VIB-1/)).toBeInTheDocument();
396
+ });
397
+
398
+ it('channel dropdown returns null when no active channels in list', async () => {
399
+ const onChange = jest.fn();
400
+ renderStep(
401
+ <ChannelSelectionStep
402
+ value={{ contentItems: [] }}
403
+ onChange={onChange}
404
+ channels={[
405
+ {
406
+ value: 'SMS',
407
+ label: 'SMS',
408
+ iconType: 'sms',
409
+ isActive: false,
410
+ paneKey: 'sms',
411
+ channelProp: 'sms',
412
+ },
413
+ ]}
414
+ />,
415
+ );
416
+
417
+ await userEvent.click(screen.getByRole('button', { name: /content template/i }));
418
+ expect(screen.queryByRole('menu')).not.toBeInTheDocument();
419
+ });
420
+
421
+ it('channel dropdown returns null when all active channels are hidden', async () => {
422
+ renderStep(
423
+ <ChannelSelectionStep
424
+ value={{ contentItems: [] }}
425
+ onChange={jest.fn()}
426
+ channels={CHANNELS}
427
+ channelsToHide={CHANNELS.map((c) => c.value)}
428
+ />,
429
+ );
430
+
431
+ await userEvent.click(screen.getByRole('button', { name: /content template/i }));
432
+ expect(screen.queryByRole('menu')).not.toBeInTheDocument();
433
+ });
434
+
435
+ it('incentive dropdown is empty when only coupons and points types exist', async () => {
436
+ renderStep(
437
+ <ChannelSelectionStep
438
+ value={{ contentItems: [] }}
439
+ onChange={jest.fn()}
440
+ channels={CHANNELS}
441
+ incentivesData={{
442
+ types: [
443
+ { value: 'coupons', label: 'Coupons', isActive: true },
444
+ { value: 'points', label: 'Points', isActive: true },
445
+ ],
446
+ }}
447
+ />,
448
+ );
449
+
450
+ expect(screen.queryByRole('button', { name: /^add incentive$/i })).not.toBeInTheDocument();
451
+ });
452
+
453
+ it('treats null value as empty contentItems', () => {
454
+ renderStep(
455
+ <ChannelSelectionStep value={null} onChange={jest.fn()} channels={CHANNELS} />,
456
+ );
457
+ expect(screen.getByText('Add message content and incentive')).toBeInTheDocument();
458
+ });
459
+
460
+ it('uses CHANNELS fallback when channels prop is null', async () => {
461
+ renderStep(
462
+ <ChannelSelectionStep value={{ contentItems: [] }} onChange={jest.fn()} channels={null} />,
463
+ );
464
+ await userEvent.click(screen.getByRole('button', { name: /content template/i }));
465
+ await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument());
466
+ expect(within(screen.getByRole('menu')).getByText('SMS')).toBeInTheDocument();
467
+ });
468
+
469
+ it('notifies parent of custom channel but skips creatives when channel is missing from CHANNELS', async () => {
470
+ const onChange = jest.fn();
471
+ renderStep(
472
+ <ChannelSelectionStep
473
+ value={{ contentItems: [] }}
474
+ onChange={onChange}
475
+ channels={[
476
+ {
477
+ value: 'CUSTOM',
478
+ label: 'Custom',
479
+ iconType: 'sms',
480
+ isActive: true,
481
+ paneKey: 'custom',
482
+ channelProp: 'custom',
483
+ },
484
+ ]}
485
+ />,
486
+ );
487
+ await userEvent.click(screen.getByRole('button', { name: /content template/i }));
488
+ await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument());
489
+ await userEvent.click(screen.getByText('Custom'));
490
+ expect(onChange).toHaveBeenCalledWith({ channel: 'CUSTOM', channels: [] });
491
+ expect(screen.queryByTestId('creatives-mock')).not.toBeInTheDocument();
492
+ });
493
+
494
+ it('handleCreativesData returns early when save payload is null', async () => {
495
+ const onChange = jest.fn();
496
+ renderStep(
497
+ <ChannelSelectionStep value={{ contentItems: [] }} onChange={onChange} channels={CHANNELS} />,
498
+ );
499
+ await userEvent.click(screen.getByRole('button', { name: /content template/i }));
500
+ await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument());
501
+ await userEvent.click(within(screen.getByRole('menu')).getByText('SMS'));
502
+ await waitFor(() => expect(screen.getByTestId('creatives-mock')).toBeInTheDocument());
503
+
504
+ await userEvent.click(screen.getByTestId('creatives-save-null'));
505
+
506
+ const callsWithContentItems = onChange.mock.calls.filter(([arg]) => arg && 'contentItems' in arg);
507
+ expect(callsWithContentItems).toHaveLength(0);
508
+ expect(screen.getByTestId('creatives-mock')).toBeInTheDocument();
509
+ });
510
+
511
+ it('edit is no-op when content channel is not in CHANNELS config', async () => {
512
+ renderStep(
513
+ <ChannelSelectionStep
514
+ value={{
515
+ contentItems: [
516
+ { contentId: 'orphan', channel: 'NOT_A_REAL_CHANNEL', templateData: { smsBody: 'x' } },
517
+ ],
518
+ }}
519
+ onChange={jest.fn()}
520
+ channels={CHANNELS}
521
+ />,
522
+ );
523
+
524
+ await userEvent.click(screen.getByLabelText('Show more content options icon'));
525
+ await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument());
526
+ await userEvent.click(within(screen.getByRole('menu')).getByText('Edit'));
527
+
528
+ expect(screen.queryByTestId('creatives-mock')).not.toBeInTheDocument();
529
+ });
530
+
531
+ it('passes creativesMode preview when not editing', async () => {
532
+ renderStep(
533
+ <ChannelSelectionStep
534
+ value={{ contentItems: [] }}
535
+ onChange={jest.fn()}
536
+ channels={CHANNELS}
537
+ creativesMode="preview"
538
+ />,
539
+ );
540
+ await userEvent.click(screen.getByRole('button', { name: /content template/i }));
541
+ await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument());
542
+ await userEvent.click(within(screen.getByRole('menu')).getByText('SMS'));
543
+ await waitFor(() => expect(screen.getByTestId('creatives-mock')).toBeInTheDocument());
544
+ expect(screen.getByTestId('creatives-mock')).toHaveAttribute('data-creatives-mode', 'preview');
545
+ });
546
+
547
+ it('appends to selectedOfferDetails when an incentive is already selected', async () => {
548
+ const onChange = jest.fn();
549
+ renderStep(
550
+ <ChannelSelectionStep
551
+ value={{ contentItems: [] }}
552
+ onChange={onChange}
553
+ channels={CHANNELS}
554
+ selectedOfferDetails={[{ type: 'existing' }]}
555
+ incentivesData={{
556
+ types: [{ value: 'badges', label: 'Badges', isActive: true }],
557
+ }}
558
+ />,
559
+ );
560
+ await userEvent.click(screen.getByRole('button', { name: /^add incentive$/i }));
561
+ await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument());
562
+ await userEvent.click(within(screen.getByRole('menu')).getByText('Badges'));
563
+ expect(onChange).toHaveBeenCalledWith({
564
+ selectedOfferDetails: [{ type: 'existing' }, { type: 'badges' }],
565
+ });
566
+ });
567
+
568
+ it('uses template message field and non-string smsBody fallback in preview', () => {
569
+ renderStep(
570
+ <ChannelSelectionStep
571
+ value={{
572
+ contentItems: [
573
+ { contentId: 'line1', channel: 'LINE', templateData: { message: 'Line message preview' } },
574
+ { contentId: 'sms-num', channel: 'SMS', templateData: { smsBody: 42 } },
575
+ ],
576
+ }}
577
+ onChange={jest.fn()}
578
+ channels={CHANNELS}
579
+ />,
580
+ );
581
+ expect(screen.getByText('Line message preview')).toBeInTheDocument();
582
+ expect(screen.getByText('42')).toBeInTheDocument();
583
+ });
584
+
585
+ it('maps MPUSH channel to card type and reads push fields from android/ios payloads', () => {
586
+ renderStep(
587
+ <ChannelSelectionStep
588
+ value={{
589
+ contentItems: [
590
+ {
591
+ contentId: 'mpush1',
592
+ channel: 'MPUSH',
593
+ templateData: { notificationTitle: 'Direct MPUSH title' },
594
+ },
595
+ {
596
+ contentId: 'and',
597
+ channel: 'MOBILEPUSH',
598
+ templateData: { androidContent: { notificationTitle: 'Android title' } },
599
+ },
600
+ {
601
+ contentId: 'ios',
602
+ channel: 'MOBILEPUSH',
603
+ templateData: { iosContent: { body: 'Ios body text' } },
604
+ },
605
+ {
606
+ contentId: 'and-body',
607
+ channel: 'MOBILEPUSH',
608
+ templateData: { androidContent: { notificationBody: 'Android notif body' } },
609
+ },
610
+ ],
611
+ }}
612
+ onChange={jest.fn()}
613
+ channels={CHANNELS}
614
+ />,
615
+ );
616
+ expect(screen.getByText('Direct MPUSH title')).toBeInTheDocument();
617
+ expect(screen.getByText('Android title')).toBeInTheDocument();
618
+ expect(screen.getByText(/Ios body text/)).toBeInTheDocument();
619
+ expect(screen.getByText('Android notif body')).toBeInTheDocument();
620
+ });
621
+
622
+ it('Viber sender falls back to root viberSenderId', () => {
623
+ renderStep(
624
+ <ChannelSelectionStep
625
+ value={{
626
+ contentItems: [
627
+ {
628
+ contentId: 'v2',
629
+ channel: 'VIBER',
630
+ templateData: { viberSenderId: 'ROOT-V' },
631
+ },
632
+ ],
633
+ }}
634
+ onChange={jest.fn()}
635
+ channels={CHANNELS}
636
+ />,
637
+ );
638
+ expect(screen.getByText(/Viber sender ID ROOT-V/)).toBeInTheDocument();
639
+ });
640
+
641
+ it('SMS card without sender id omits sender row', () => {
642
+ renderStep(
643
+ <ChannelSelectionStep
644
+ value={{
645
+ contentItems: [
646
+ { contentId: 'sms-plain', channel: 'SMS', templateData: { smsBody: 'Only body' } },
647
+ ],
648
+ }}
649
+ onChange={jest.fn()}
650
+ channels={CHANNELS}
651
+ />,
652
+ );
653
+ expect(screen.getByText('Only body')).toBeInTheDocument();
654
+ expect(screen.queryByText(/SMS sender ID/)).not.toBeInTheDocument();
655
+ });
656
+
657
+ it('Viber preview uses catch when JSON.parse throws for {} fallback', () => {
658
+ const realParse = JSON.parse;
659
+ const parseSpy = jest.spyOn(JSON, 'parse').mockImplementation((text, reviver) => {
660
+ if (text === '{}') {
661
+ throw new SyntaxError('forced');
662
+ }
663
+ return realParse.call(JSON, text, reviver);
664
+ });
665
+ renderStep(
666
+ <ChannelSelectionStep
667
+ value={{
668
+ contentItems: [
669
+ {
670
+ contentId: 'vib-parse',
671
+ channel: 'VIBER',
672
+ templateData: { messageBody: '' },
673
+ },
674
+ ],
675
+ }}
676
+ onChange={jest.fn()}
677
+ channels={CHANNELS}
678
+ />,
679
+ );
680
+ expect(screen.getByText('VIBER')).toBeInTheDocument();
681
+ parseSpy.mockRestore();
682
+ });
683
+
684
+ it('Viber preview falls back to channel when parsed JSON has no text (empty messageBody)', () => {
685
+ renderStep(
686
+ <ChannelSelectionStep
687
+ value={{
688
+ contentItems: [
689
+ {
690
+ contentId: 'vib-empty',
691
+ channel: 'VIBER',
692
+ templateData: { messageBody: '' },
693
+ },
694
+ ],
695
+ }}
696
+ onChange={jest.fn()}
697
+ channels={CHANNELS}
698
+ />,
699
+ );
700
+ expect(screen.getByText('VIBER')).toBeInTheDocument();
701
+ });
702
+
703
+ it('does not open creatives when clicked channel is in channelsToDisable', async () => {
704
+ // Covers line 174-175: channelsToDisable.includes(channelValue) branch
705
+ const onChange = jest.fn();
706
+ renderStep(
707
+ <ChannelSelectionStep
708
+ value={{ contentItems: [] }}
709
+ onChange={onChange}
710
+ channels={CHANNELS}
711
+ channelsToDisable={['SMS']}
712
+ />,
713
+ );
714
+ await userEvent.click(screen.getByRole('button', { name: /content template/i }));
715
+ await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument());
716
+ // SMS is in channelsToDisable — clicking it should not call onChange or show creatives
717
+ await userEvent.click(within(screen.getByRole('menu')).getByText('SMS'));
718
+ expect(screen.queryByTestId('creatives-mock')).not.toBeInTheDocument();
719
+ expect(onChange).not.toHaveBeenCalled();
720
+ });
721
+
722
+ it('incentive dropdown returns null when all active incentive types are coupons/points only (empty after filter)', async () => {
723
+ // Covers line 277-278: availableIncentiveTypes.length === 0 (all inactive) path
724
+ renderStep(
725
+ <ChannelSelectionStep
726
+ value={{ contentItems: [] }}
727
+ onChange={jest.fn()}
728
+ channels={CHANNELS}
729
+ incentivesData={{
730
+ types: [
731
+ { value: 'rewards', label: 'Rewards', isActive: false },
732
+ { value: 'other', label: 'Other', isActive: false },
733
+ ],
734
+ }}
735
+ />,
736
+ );
737
+ // No incentive button should appear since all types are inactive
738
+ expect(screen.queryByRole('button', { name: /^add incentive$/i })).not.toBeInTheDocument();
739
+ });
740
+
741
+ it('channel in channelsToDisable still shows in dropdown but selecting is a no-op', async () => {
742
+ // Covers line 180-181: selectedChannelObj.isActive path via disabled channel click
743
+ const onChange = jest.fn();
744
+ renderStep(
745
+ <ChannelSelectionStep
746
+ value={{ contentItems: [] }}
747
+ onChange={onChange}
748
+ channels={[
749
+ {
750
+ value: 'EMAIL',
751
+ label: 'Email',
752
+ iconType: 'email',
753
+ isActive: true,
754
+ paneKey: 'email',
755
+ channelProp: 'email',
756
+ },
757
+ {
758
+ value: 'SMS',
759
+ label: 'SMS',
760
+ iconType: 'sms',
761
+ isActive: true,
762
+ paneKey: 'sms',
763
+ channelProp: 'sms',
764
+ },
765
+ ]}
766
+ channelsToDisable={['EMAIL']}
767
+ />,
768
+ );
769
+ await userEvent.click(screen.getByRole('button', { name: /content template/i }));
770
+ await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument());
771
+ // Click the disabled EMAIL menu item — handleChannelSelect should return early
772
+ await userEvent.click(within(screen.getByRole('menu')).getByText('Email'));
773
+ expect(onChange).not.toHaveBeenCalled();
774
+ expect(screen.queryByTestId('creatives-mock')).not.toBeInTheDocument();
775
+ });
776
+
777
+ it('ios notification body shows as preview when iosContent.notificationBody is present', () => {
778
+ // Covers line 125: templateData.iosContent.notificationBody fallback branch
779
+ renderStep(
780
+ <ChannelSelectionStep
781
+ value={{
782
+ contentItems: [
783
+ {
784
+ contentId: 'ios-nb',
785
+ channel: 'MOBILEPUSH',
786
+ templateData: { iosContent: { notificationBody: 'IOS notif body text' } },
787
+ },
788
+ ],
789
+ }}
790
+ onChange={jest.fn()}
791
+ channels={CHANNELS}
792
+ />,
793
+ );
794
+ expect(screen.getByText(/IOS notif body text/)).toBeInTheDocument();
795
+ });
796
+ });