@capillarytech/creatives-library 8.0.329 → 8.0.330-alpha.1

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 (89) hide show
  1. package/constants/unified.js +4 -0
  2. package/package.json +1 -1
  3. package/utils/commonUtils.js +19 -1
  4. package/utils/templateVarUtils.js +35 -6
  5. package/utils/tests/tagValidations.test.js +20 -0
  6. package/utils/tests/templateVarUtils.test.js +44 -0
  7. package/v2Components/CapActionButton/constants.js +7 -0
  8. package/v2Components/CapActionButton/index.js +167 -109
  9. package/v2Components/CapActionButton/index.scss +157 -6
  10. package/v2Components/CapActionButton/messages.js +19 -3
  11. package/v2Components/CapActionButton/tests/index.test.js +41 -17
  12. package/v2Components/CapTagList/index.js +28 -23
  13. package/v2Components/CapTagList/style.scss +29 -0
  14. package/v2Components/CapTagListWithInput/__tests__/CapTagListWithInput.test.js +63 -0
  15. package/v2Components/CapTagListWithInput/index.js +4 -0
  16. package/v2Components/CapWhatsappCTA/index.js +2 -0
  17. package/v2Components/CommonTestAndPreview/ExistingCustomerModal.js +1 -0
  18. package/v2Components/CommonTestAndPreview/UnifiedPreview/RcsPreviewContent.js +160 -15
  19. package/v2Components/CommonTestAndPreview/UnifiedPreview/RcsPreviewContent.js.rej +18 -0
  20. package/v2Components/CommonTestAndPreview/UnifiedPreview/_unifiedPreview.scss +323 -77
  21. package/v2Components/CommonTestAndPreview/index.js +49 -57
  22. package/v2Components/CommonTestAndPreview/messages.js +8 -0
  23. package/v2Components/CommonTestAndPreview/reducer.js +3 -1
  24. package/v2Components/CommonTestAndPreview/sagas.js +2 -1
  25. package/v2Components/CommonTestAndPreview/tests/PreviewSection.test.js +8 -1
  26. package/v2Components/CommonTestAndPreview/tests/UnifiedPreview/RcsPreviewContent.test.js +281 -283
  27. package/v2Components/FormBuilder/index.js +1 -0
  28. package/v2Components/HtmlEditor/HTMLEditor.js +6 -1
  29. package/v2Components/HtmlEditor/__tests__/HTMLEditor.apiErrors.test.js +1 -0
  30. package/v2Components/HtmlEditor/__tests__/HTMLEditor.test.js +927 -2
  31. package/v2Components/HtmlEditor/components/CodeEditorPane/index.js +3 -0
  32. package/v2Components/SmsFallback/smsFallbackUtils.js +14 -3
  33. package/v2Components/SmsFallback/tests/smsFallbackUtils.test.js +16 -0
  34. package/v2Components/TemplatePreview/_templatePreview.scss +33 -23
  35. package/v2Components/TemplatePreview/constants.js +2 -0
  36. package/v2Components/TemplatePreview/index.js +143 -28
  37. package/v2Components/TemplatePreview/tests/index.test.js +142 -0
  38. package/v2Components/TestAndPreviewSlidebox/index.js +5 -0
  39. package/v2Components/mockdata.js +1 -0
  40. package/v2Containers/BeeEditor/index.js +19 -1
  41. package/v2Containers/CreativesContainer/SlideBoxContent.js +28 -1
  42. package/v2Containers/CreativesContainer/index.js +9 -3
  43. package/v2Containers/CreativesContainer/tests/__snapshots__/index.test.js.snap +5 -0
  44. package/v2Containers/Email/index.js +78 -39
  45. package/v2Containers/Email/reducer.js +2 -2
  46. package/v2Containers/Email/sagas.js +3 -1
  47. package/v2Containers/Email/tests/__snapshots__/reducer.test.js.snap +2 -2
  48. package/v2Containers/Email/tests/sagas.test.js +230 -0
  49. package/v2Containers/EmailWrapper/components/EmailHTMLEditor.js +6 -1
  50. package/v2Containers/EmailWrapper/components/EmailWrapperView.js +3 -0
  51. package/v2Containers/EmailWrapper/components/__tests__/EmailHTMLEditor.test.js +20 -2
  52. package/v2Containers/EmailWrapper/components/__tests__/EmailWrapperView.test.js +16 -1
  53. package/v2Containers/EmailWrapper/hooks/useEmailWrapper.js +3 -1
  54. package/v2Containers/EmailWrapper/index.js +4 -0
  55. package/v2Containers/EmailWrapper/tests/useEmailWrapper.edgeCases.test.js +1 -0
  56. package/v2Containers/EmailWrapper/tests/useEmailWrapper.test.js +9 -0
  57. package/v2Containers/InAppWrapper/hooks/__tests__/useInAppWrapper.test.js +1 -0
  58. package/v2Containers/MobilePush/Create/index.js +2 -0
  59. package/v2Containers/MobilePush/Edit/index.js +2 -0
  60. package/v2Containers/MobilepushWrapper/index.js +3 -1
  61. package/v2Containers/Rcs/constants.js +85 -7
  62. package/v2Containers/Rcs/index.js +1592 -156
  63. package/v2Containers/Rcs/index.js.rej +1336 -0
  64. package/v2Containers/Rcs/index.scss +191 -0
  65. package/v2Containers/Rcs/index.scss.rej +74 -0
  66. package/v2Containers/Rcs/messages.js +28 -2
  67. package/v2Containers/Rcs/rcsLibraryHydrationUtils.js +20 -0
  68. package/v2Containers/Rcs/tests/__snapshots__/index.test.js.snap +69178 -117691
  69. package/v2Containers/Rcs/tests/__snapshots__/utils.test.js.snap.rej +128 -0
  70. package/v2Containers/Rcs/tests/index.test.js +132 -94
  71. package/v2Containers/Rcs/tests/rcsLibraryHydrationUtils.test.js +67 -0
  72. package/v2Containers/Rcs/tests/utils.test.js +276 -38
  73. package/v2Containers/Rcs/utils.js +130 -7
  74. package/v2Containers/Sms/Edit/index.js +2 -0
  75. package/v2Containers/SmsTrai/Edit/index.js +27 -0
  76. package/v2Containers/SmsTrai/Edit/messages.js +5 -0
  77. package/v2Containers/SmsTrai/Edit/tests/__snapshots__/index.test.js.snap +6 -6
  78. package/v2Containers/SmsWrapper/index.js +2 -0
  79. package/v2Containers/TagList/index.js +73 -20
  80. package/v2Containers/TagList/messages.js +4 -0
  81. package/v2Containers/TagList/tests/TagList.test.js +124 -20
  82. package/v2Containers/TagList/tests/mockdata.js +17 -0
  83. package/v2Containers/Templates/_templates.scss +99 -0
  84. package/v2Containers/Templates/index.js +29 -14
  85. package/v2Containers/Viber/index.js +3 -0
  86. package/v2Containers/WebPush/Create/hooks/useTagManagement.js +0 -2
  87. package/v2Containers/WebPush/Create/index.js +10 -2
  88. package/v2Containers/Whatsapp/index.js +5 -0
  89. package/v2Containers/Zalo/index.js +2 -0
@@ -7,6 +7,7 @@ import {
7
7
  getTemplateStatusType,
8
8
  normalizeCardVarMapped,
9
9
  coalesceCardVarMappedToTemplate,
10
+ getRcsSemanticVarNamesSpanningTitleAndDesc,
10
11
  resolveCardVarMappedSlotValue,
11
12
  isRcsTextOnlyCardMediaType,
12
13
  mapRcsCardContentForConsumerWithResolvedTags,
@@ -40,38 +41,26 @@ describe('RCS utils - renderRcsSuggestionsPreview', () => {
40
41
  },
41
42
  };
42
43
 
43
- it('renders divider and QUICK_REPLY suggestion with small-link icon', () => {
44
+ const renderWithSuggestion = (buttonType, text) => {
44
45
  const template = JSON.parse(JSON.stringify(templateBase));
45
46
  template.versions.base.content[RCS].rcsContent.cardContent[0].suggestions = [
46
- { type: RCS_BUTTON_TYPES.QUICK_REPLY, text: 'Reply' },
47
+ { type: buttonType, text },
47
48
  ];
48
49
  render(getRCSContent(template));
49
- expect(document.querySelectorAll('.whatsapp-divider').length).toBeGreaterThan(0);
50
- const labels = document.querySelectorAll('.rcs-cta-preview');
51
- expect(labels.length).toBe(1);
52
- expect(labels[0].textContent).toContain('Reply');
53
- });
54
-
55
- it('renders CTA suggestion with launch icon', () => {
56
- const template = JSON.parse(JSON.stringify(templateBase));
57
- template.versions.base.content[RCS].rcsContent.cardContent[0].suggestions = [
58
- { type: RCS_BUTTON_TYPES.CTA, text: 'Visit' },
59
- ];
60
- render(getRCSContent(template));
61
- const labels = document.querySelectorAll('.rcs-cta-preview');
62
- expect(labels.length).toBe(1);
63
- expect(labels[0].textContent).toContain('Visit');
64
- });
50
+ };
65
51
 
66
- it('renders PHONE_NUMBER suggestion with call icon', () => {
67
- const template = JSON.parse(JSON.stringify(templateBase));
68
- template.versions.base.content[RCS].rcsContent.cardContent[0].suggestions = [
69
- { type: RCS_BUTTON_TYPES.PHONE_NUMBER, text: 'Call' },
70
- ];
71
- render(getRCSContent(template));
52
+ it.each([
53
+ [RCS_BUTTON_TYPES.QUICK_REPLY, 'Reply', true],
54
+ [RCS_BUTTON_TYPES.CTA, 'Visit', false],
55
+ [RCS_BUTTON_TYPES.PHONE_NUMBER, 'Call', false],
56
+ ])('renders %s suggestion with expected label', (buttonType, text, expectDivider) => {
57
+ renderWithSuggestion(buttonType, text);
58
+ if (expectDivider) {
59
+ expect(document.querySelectorAll('.whatsapp-divider').length).toBeGreaterThan(0);
60
+ }
72
61
  const labels = document.querySelectorAll('.rcs-cta-preview');
73
62
  expect(labels.length).toBe(1);
74
- expect(labels[0].textContent).toContain('Call');
63
+ expect(labels[0].textContent).toContain(text);
75
64
  });
76
65
 
77
66
  it('renders only divider for unknown suggestion type', () => {
@@ -96,16 +85,17 @@ describe('RCS utils - renderRcsSuggestionsPreview', () => {
96
85
 
97
86
  describe('RCS utils', () => {
98
87
  describe('getRCSContent', () => {
88
+ /** Single place for snapshot serialization — keeps test bodies focused on template setup. */
89
+ const expectGetRCSContentSnapshot = (template) => {
90
+ expect(renderer.create(getRCSContent(template)).toJSON()).toMatchSnapshot();
91
+ };
92
+
99
93
  it('renders RCS content with image and CTA suggestion', () => {
100
- const template = mockData.editData1.templateDetails;
101
- const tree = renderer.create(getRCSContent(template)).toJSON();
102
- expect(tree).toMatchSnapshot();
94
+ expectGetRCSContentSnapshot(mockData.editData1.templateDetails);
103
95
  });
104
96
 
105
97
  it('renders RCS content with no media', () => {
106
- const template = mockData.editData3.templateDetails;
107
- const tree = renderer.create(getRCSContent(template)).toJSON();
108
- expect(tree).toMatchSnapshot();
98
+ expectGetRCSContentSnapshot(mockData.editData3.templateDetails);
109
99
  });
110
100
 
111
101
  it('renders RCS content with multiple suggestion types', () => {
@@ -115,19 +105,212 @@ describe('RCS utils', () => {
115
105
  { type: RCS_BUTTON_TYPES.CTA, text: 'URL Action' },
116
106
  { type: RCS_BUTTON_TYPES.PHONE_NUMBER, text: 'Call Now' },
117
107
  ];
118
- const tree = renderer.create(getRCSContent(template)).toJSON();
119
- expect(tree).toMatchSnapshot();
108
+ expectGetRCSContentSnapshot(template);
120
109
  });
121
110
 
122
111
  it('renders RCS content with missing cardContent', () => {
123
- const template = { versions: { base: { content: { RCS: { rcsContent: {} } } } } };
124
- const tree = renderer.create(getRCSContent(template)).toJSON();
125
- expect(tree).toMatchSnapshot();
112
+ expectGetRCSContentSnapshot({ versions: { base: { content: { RCS: { rcsContent: {} } } } } });
126
113
  });
127
114
 
128
115
  it('renders RCS content with empty template', () => {
129
- const tree = renderer.create(getRCSContent({})).toJSON();
130
- expect(tree).toMatchSnapshot();
116
+ expectGetRCSContentSnapshot({});
117
+ });
118
+
119
+ it('renders RCS carousel listing preview when cardType is carousel', () => {
120
+ const template = JSON.parse(JSON.stringify(mockData.editData1.templateDetails));
121
+ const rcsContent = template.versions.base.content.RCS.rcsContent;
122
+ rcsContent.cardType = 'CAROUSEL';
123
+ rcsContent.cardContent = [
124
+ {
125
+ title: 'Card1',
126
+ description: 'Desc1',
127
+ media: { mediaUrl: 'https://example.com/1.png' },
128
+ suggestions: [],
129
+ },
130
+ {
131
+ title: 'Card2',
132
+ description: 'Desc2',
133
+ media: { mediaUrl: 'https://example.com/2.png' },
134
+ suggestions: [],
135
+ },
136
+ {
137
+ title: 'Card3',
138
+ description: 'Desc3',
139
+ media: { mediaUrl: 'https://example.com/3.png' },
140
+ suggestions: [],
141
+ },
142
+ {
143
+ title: 'Card4',
144
+ description: 'Desc4',
145
+ media: { mediaUrl: 'https://example.com/4.png' },
146
+ suggestions: [],
147
+ },
148
+ ];
149
+
150
+ render(getRCSContent(template));
151
+ // "Peek" shows max 3
152
+ expect(document.querySelectorAll('.whatsapp-carousel-container').length).toBe(3);
153
+ expect(document.querySelectorAll('.scroll-container').length).toBeGreaterThan(0);
154
+ });
155
+
156
+ const carouselTemplate = (cardContent) => ({
157
+ versions: {
158
+ base: {
159
+ content: {
160
+ [RCS]: {
161
+ rcsContent: {
162
+ cardType: 'carousel',
163
+ cardContent,
164
+ },
165
+ },
166
+ },
167
+ },
168
+ },
169
+ });
170
+
171
+ const carouselSecondCardFiller = {
172
+ title: 'B',
173
+ description: 'D',
174
+ media: { mediaUrl: 'https://example.com/b.png' },
175
+ suggestions: [],
176
+ };
177
+
178
+ it('carousel: video card with thumbnailUrl uses thumbnail (not placeholder)', () => {
179
+ const template = carouselTemplate([
180
+ {
181
+ title: 'Vid',
182
+ description: 'With thumb',
183
+ media: {
184
+ mediaType: 'VIDEO',
185
+ mediaUrl: 'https://example.com/video.mp4',
186
+ thumbnailUrl: 'https://example.com/poster.jpg',
187
+ },
188
+ suggestions: [],
189
+ },
190
+ carouselSecondCardFiller,
191
+ ]);
192
+ render(getRCSContent(template));
193
+ expect(document.querySelector('.rcs-video-preview-placeholder')).toBeFalsy();
194
+ const firstImg = document.querySelector('.whatsapp-carousel-card .whatsapp-image');
195
+ expect(firstImg).toBeTruthy();
196
+ expect(firstImg.getAttribute('src')).toBe('https://example.com/poster.jpg');
197
+ });
198
+
199
+ it('carousel: shows video preview placeholder when VIDEO has mediaUrl but no thumbnail (no img src to mp4)', () => {
200
+ const template = carouselTemplate([
201
+ {
202
+ title: 'Clip',
203
+ description: 'No thumb',
204
+ media: {
205
+ mediaType: 'VIDEO',
206
+ mediaUrl: 'https://example.com/video.mp4',
207
+ },
208
+ suggestions: [],
209
+ },
210
+ carouselSecondCardFiller,
211
+ ]);
212
+ render(getRCSContent(template));
213
+ expect(document.querySelector('.rcs-video-preview-placeholder')).toBeTruthy();
214
+ expect(document.querySelector('.rcs-video-preview-label')?.textContent).toContain('Video preview');
215
+ });
216
+
217
+ it('carousel: uses whatsapp-message-without-media when card has no image/video URL', () => {
218
+ const template = carouselTemplate([
219
+ {
220
+ title: 'Text only',
221
+ description: 'No media',
222
+ media: {},
223
+ suggestions: [],
224
+ },
225
+ {
226
+ title: 'Second',
227
+ description: '',
228
+ suggestions: [],
229
+ },
230
+ ]);
231
+ render(getRCSContent(template));
232
+ const withoutMedia = document.querySelectorAll('.whatsapp-message-without-media');
233
+ expect(withoutMedia.length).toBeGreaterThan(0);
234
+ });
235
+
236
+ it('carousel: body includes description with newline when description is set', () => {
237
+ const template = carouselTemplate([
238
+ {
239
+ title: 'Head',
240
+ description: 'Tail line',
241
+ media: { mediaUrl: 'https://example.com/x.png' },
242
+ suggestions: [],
243
+ },
244
+ {
245
+ title: 'Y',
246
+ description: 'Z',
247
+ media: { mediaUrl: 'https://example.com/y.png' },
248
+ suggestions: [],
249
+ },
250
+ ]);
251
+ render(getRCSContent(template));
252
+ const bodies = document.querySelectorAll('.whatsapp-carousel-body');
253
+ expect(bodies[0].textContent).toContain('Head');
254
+ expect(bodies[0].textContent).toContain('Tail line');
255
+ });
256
+
257
+ it('carousel: body handles missing title and empty description', () => {
258
+ const template = carouselTemplate([
259
+ {
260
+ description: '',
261
+ media: { mediaUrl: 'https://example.com/x.png' },
262
+ suggestions: [],
263
+ },
264
+ {
265
+ title: 'Only title',
266
+ media: { mediaUrl: 'https://example.com/y.png' },
267
+ suggestions: [],
268
+ },
269
+ ]);
270
+ render(getRCSContent(template));
271
+ const bodies = document.querySelectorAll('.whatsapp-carousel-body');
272
+ expect(bodies[0].textContent.trim()).toBe('');
273
+ expect(bodies[1].textContent).toContain('Only title');
274
+ });
275
+
276
+ it('carousel: renders per-card suggestions via renderRcsSuggestionsPreview', () => {
277
+ const template = carouselTemplate([
278
+ {
279
+ title: 'C1',
280
+ description: 'D1',
281
+ media: { mediaUrl: 'https://example.com/1.png' },
282
+ suggestions: [{ type: RCS_BUTTON_TYPES.CTA, text: 'Go' }],
283
+ },
284
+ {
285
+ title: 'C2',
286
+ description: 'D2',
287
+ media: { mediaUrl: 'https://example.com/2.png' },
288
+ suggestions: [],
289
+ },
290
+ ]);
291
+ render(getRCSContent(template));
292
+ expect(document.querySelectorAll('.rcs-cta-preview').length).toBeGreaterThanOrEqual(1);
293
+ expect(Array.from(document.querySelectorAll('.rcs-cta-preview')).some((el) => el.textContent.includes('Go'))).toBe(true);
294
+ });
295
+
296
+ it('carousel: does not render suggestion block when suggestions missing or not an array', () => {
297
+ const template = carouselTemplate([
298
+ {
299
+ title: 'A',
300
+ description: 'B',
301
+ media: { mediaUrl: 'https://example.com/a.png' },
302
+ },
303
+ {
304
+ title: 'C',
305
+ description: 'D',
306
+ media: { mediaUrl: 'https://example.com/c.png' },
307
+ suggestions: null,
308
+ },
309
+ ]);
310
+ render(getRCSContent(template));
311
+ const firstCard = document.querySelectorAll('.whatsapp-carousel-card')[0];
312
+ const dividersInFirst = firstCard.querySelectorAll('.whatsapp-divider');
313
+ expect(dividersInFirst.length).toBe(0);
131
314
  });
132
315
  });
133
316
 
@@ -215,7 +398,39 @@ describe('RCS utils', () => {
215
398
  });
216
399
  });
217
400
 
401
+ describe('getRcsSemanticVarNamesSpanningTitleAndDesc', () => {
402
+ it('returns names that appear in both title and description', () => {
403
+ const set = getRcsSemanticVarNamesSpanningTitleAndDesc(
404
+ 'Hello {{adv}}',
405
+ 'Body {{adv}} x {{other}}',
406
+ rcsVarRegex,
407
+ );
408
+ expect(Array.from(set).sort()).toEqual(['adv']);
409
+ });
410
+
411
+ it('is empty when the same name is only repeated in the title', () => {
412
+ const set = getRcsSemanticVarNamesSpanningTitleAndDesc(
413
+ '{{user_name}} and {{user_name}}',
414
+ 'Hi',
415
+ rcsVarRegex,
416
+ );
417
+ expect(set.size).toBe(0);
418
+ });
419
+ });
420
+
218
421
  describe('coalesceCardVarMappedToTemplate', () => {
422
+ it('does not copy a shared semantic value into the second slot when the tag spans title and description', () => {
423
+ const out = coalesceCardVarMappedToTemplate(
424
+ { adv: 'shared' },
425
+ 'T {{adv}}',
426
+ 'D {{adv}}',
427
+ rcsVarRegex,
428
+ );
429
+ expect(out[1]).toBe('shared');
430
+ expect(out[2]).toBe('');
431
+ expect(out.adv).toBe('shared');
432
+ });
433
+
219
434
  it('maps slot 1 to first token name from title', () => {
220
435
  expect(
221
436
  coalesceCardVarMappedToTemplate(
@@ -332,6 +547,24 @@ describe('RCS utils', () => {
332
547
  ),
333
548
  ).toBe('{{loyalty_points}}');
334
549
  });
550
+
551
+ it('omitSemanticFallback: duplicate title+desc tag does not read shared semantic for either slot', () => {
552
+ expect(
553
+ resolveCardVarMappedSlotValue({ adv: '{{adv}}', 1: '', 2: '' }, 'adv', 0, false, true),
554
+ ).toBe('');
555
+ expect(
556
+ resolveCardVarMappedSlotValue({ adv: '{{adv}}', 1: 'A', 2: 'B' }, 'adv', 0, false, true),
557
+ ).toBe('A');
558
+ expect(
559
+ resolveCardVarMappedSlotValue({ adv: '{{adv}}', 1: 'A', 2: 'B' }, 'adv', 1, false, true),
560
+ ).toBe('B');
561
+ });
562
+
563
+ it('omitSemanticFallback: cleared semantic does not force empty when numeric slot has a value', () => {
564
+ expect(
565
+ resolveCardVarMappedSlotValue({ adv: '', 1: 'first', 2: 'second' }, 'adv', 1, false, true),
566
+ ).toBe('second');
567
+ });
335
568
  });
336
569
 
337
570
  describe('mapRcsCardContentForConsumerWithResolvedTags', () => {
@@ -374,6 +607,11 @@ describe('RCS utils', () => {
374
607
  expect(out[0].title).toBe('Hi {{customer_name}}');
375
608
  expect(out[0].description).toBe('Pts {{loyalty_points}}');
376
609
  });
610
+
611
+ it('passes through null or non-object items in the card array unchanged', () => {
612
+ const out = mapRcsCardContentForConsumerWithResolvedTags([null, 'string', 42], {}, false);
613
+ expect(out).toEqual([null, 'string', 42]);
614
+ });
377
615
  });
378
616
 
379
617
  describe('isRcsTextOnlyCardMediaType', () => {
@@ -22,6 +22,7 @@ import {
22
22
  COMBINED_SMS_TEMPLATE_VAR_REGEX,
23
23
  isAnyTemplateVarToken,
24
24
  } from '../../utils/templateVarUtils';
25
+ import { pickRcsCardVarMappedEntries } from './rcsLibraryHydrationUtils';
25
26
 
26
27
  export const getRcsStatusType = (status) => {
27
28
  switch (status) {
@@ -49,6 +50,13 @@ export const getTemplateStatusType = (templateStatus) => {
49
50
  }
50
51
  };
51
52
 
53
+ /** Localized label for a carousel video thumbnail size (width × height in px). */
54
+ export const formatRcsCarouselVideoThumbnailLabel = (formatMessage, dimensionEntry) => {
55
+ if (!dimensionEntry) return '';
56
+ const { width, height } = dimensionEntry;
57
+ return formatMessage(messages.rcsCarouselVideoThumbnailLabel, { width, height });
58
+ };
59
+
52
60
  /**
53
61
  * Global RegExp matching `{{numericVarName}}` in RCS template strings.
54
62
  * `numericVarName` is escaped for regex metacharacters.
@@ -130,6 +138,27 @@ export function normalizeCardVarMapped(rawCardVarMapped, orderedTagNames) {
130
138
  return normalizedMap;
131
139
  }
132
140
 
141
+ /**
142
+ * Semantic names that appear in both title and description (e.g. `{{adv}}` in header and body).
143
+ * Those slots must not share one semantic `cardVarMapped` key — otherwise VarSegment inputs mirror.
144
+ */
145
+ export function getRcsSemanticVarNamesSpanningTitleAndDesc(
146
+ templateTitle,
147
+ templateDesc,
148
+ rcsVarRegex,
149
+ ) {
150
+ const getVarNameFromToken = (token = '') => token.replace(RCS_STRIP_MUSTACHE_DELIMITERS_REGEX, '');
151
+ const titleTokens = templateTitle?.match(rcsVarRegex) ?? [];
152
+ const descTokens = templateDesc?.match(rcsVarRegex) ?? [];
153
+ const titleNames = new Set(titleTokens.map(getVarNameFromToken).filter(Boolean));
154
+ const descNames = new Set(descTokens.map(getVarNameFromToken).filter(Boolean));
155
+ const spanning = new Set();
156
+ titleNames.forEach((n) => {
157
+ if (descNames.has(n)) spanning.add(n);
158
+ });
159
+ return spanning;
160
+ }
161
+
133
162
  /**
134
163
  * Rebuild `cardVarMapped` so keys match the current title/description tokens (title tokens first,
135
164
  * then description), in order. Values are taken from the matching key, else from legacy slot
@@ -140,9 +169,13 @@ export function coalesceCardVarMappedToTemplate(
140
169
  templateTitle,
141
170
  templateDesc,
142
171
  rcsVarRegex,
172
+ rcsVarRegexParam,
143
173
  ) {
144
174
  const getVarNameFromToken = (token = '') => token.replace(RCS_STRIP_MUSTACHE_DELIMITERS_REGEX, '');
145
175
  const templateVarTokens = [
176
+ ...(templateTitle?.match(rcsVarRegexParam) ?? []),
177
+ rcsVarRegexParam,
178
+ ...(templateDesc?.match(rcsVarRegexParam) ?? []),
146
179
  ...(templateTitle?.match(rcsVarRegex) ?? []),
147
180
  ...(templateDesc?.match(rcsVarRegex) ?? []),
148
181
  ];
@@ -151,15 +184,25 @@ export function coalesceCardVarMappedToTemplate(
151
184
  if (!templateVarTokens.length) {
152
185
  return { ...lookupSourceMap };
153
186
  }
187
+ const semanticNamesSpanningTitleAndDesc = getRcsSemanticVarNamesSpanningTitleAndDesc(
188
+ templateTitle,
189
+ templateDesc,
190
+ rcsVarRegex,
191
+ );
154
192
  const coalescedMap = { ...lookupSourceMap };
155
193
  const seenSemanticVarNames = new Set();
156
194
  templateVarTokens.forEach((token, slotIndexZeroBased) => {
157
195
  const semanticVarName = getVarNameFromToken(token);
158
196
  if (!semanticVarName) return;
159
197
  const numericSlotKey = String(slotIndexZeroBased + 1);
198
+ const isRepeatOfSemanticName = seenSemanticVarNames.has(semanticVarName);
199
+ const skipSharedSemanticLookup =
200
+ isRepeatOfSemanticName && semanticNamesSpanningTitleAndDesc.has(semanticVarName);
160
201
  let valueFromSource = lookupSourceMap[numericSlotKey];
161
202
  if (valueFromSource === undefined || valueFromSource === null) {
162
- valueFromSource = lookupSourceMap[semanticVarName];
203
+ if (!skipSharedSemanticLookup) {
204
+ valueFromSource = lookupSourceMap[semanticVarName];
205
+ }
163
206
  }
164
207
  if (valueFromSource === undefined || valueFromSource === null) {
165
208
  valueFromSource = lookupSourceMap[String(slotIndexZeroBased + 1)];
@@ -189,12 +232,17 @@ export function coalesceCardVarMappedToTemplate(
189
232
  * When a numeric slot is present but only whitespace / empty (common after hydration), do not
190
233
  * treat it as authoritative — fall through to the semantic key so preview and payload match the
191
234
  * tag the user selected (e.g. `1: ''` but `promotion_points: '{{newTag}}'`).
235
+ *
236
+ * @param {boolean} [omitSemanticFallback=false] When true, do not read `varName` on the map (and do
237
+ * not apply the early `semanticEmpty` short-circuit). Use when the same semantic name appears in
238
+ * both title and description so each global slot stays independent.
192
239
  */
193
240
  export function resolveCardVarMappedSlotValue(
194
241
  cardVarMapped,
195
242
  varName,
196
243
  globalSlotIndexZeroBased,
197
244
  isLibraryMode = false,
245
+ omitSemanticFallback = false,
198
246
  ) {
199
247
  const varMap = cardVarMapped ?? {};
200
248
  const slotKey = String(globalSlotIndexZeroBased + 1);
@@ -205,11 +253,12 @@ export function resolveCardVarMappedSlotValue(
205
253
  Object.prototype.hasOwnProperty.call(varMap, slotKey)
206
254
  && String(varMap[slotKey] ?? '').trim() !== '';
207
255
 
208
- if (semanticEmpty && !(isLibraryMode && slotNonEmpty)) {
256
+ if (semanticEmpty && !(isLibraryMode && slotNonEmpty) && !omitSemanticFallback) {
209
257
  return '';
210
258
  }
211
259
  let numericSlotValue = '';
212
260
  if (Object.prototype.hasOwnProperty.call(varMap, slotKey)) {
261
+ /** Text-only RCS card: editor shows a single "Text message" field (description); title row is hidden. */
213
262
  numericSlotValue = String(varMap[slotKey] ?? '');
214
263
  } else if (Object.prototype.hasOwnProperty.call(varMap, globalSlotIndexZeroBased + 1)) {
215
264
  numericSlotValue = String(varMap[globalSlotIndexZeroBased + 1] ?? '');
@@ -217,7 +266,7 @@ export function resolveCardVarMappedSlotValue(
217
266
  if (numericSlotValue.trim() !== '') {
218
267
  return numericSlotValue;
219
268
  }
220
- if (Object.prototype.hasOwnProperty.call(varMap, varName)) {
269
+ if (!omitSemanticFallback && Object.prototype.hasOwnProperty.call(varMap, varName)) {
221
270
  return String(varMap[varName] ?? '');
222
271
  }
223
272
  return '';
@@ -247,6 +296,9 @@ export function resolveRcsCardPreviewStrings(
247
296
  const splitTemplateVarStringRcs = (str) => splitTemplateVarString(str, rcsVarRegex);
248
297
  const getVarNameFromToken = (token = '') =>
249
298
  token.replace(RCS_STRIP_MUSTACHE_DELIMITERS_REGEX, '');
299
+ const semanticNamesSpanningTitleAndDesc = textOnlyCard
300
+ ? new Set()
301
+ : getRcsSemanticVarNamesSpanningTitleAndDesc(title, description, rcsVarRegex);
250
302
 
251
303
  const resolveTemplateWithMap = (str = '', slotOffset = 0) => {
252
304
  if (!str) return '';
@@ -258,11 +310,13 @@ export function resolveRcsCardPreviewStrings(
258
310
  const key = getVarNameFromToken(elem);
259
311
  const globalSlot = slotOffset + varOrdinal;
260
312
  varOrdinal += 1;
313
+ const omitSemantic = semanticNamesSpanningTitleAndDesc.has(key);
261
314
  const v = resolveCardVarMappedSlotValue(
262
315
  cardVarMapped,
263
316
  key,
264
317
  globalSlot,
265
318
  isLibraryMode,
319
+ omitSemantic,
266
320
  );
267
321
  if (v == null || String(v).trim() === '') return elem;
268
322
  return String(v);
@@ -285,7 +339,8 @@ export function resolveRcsCardPreviewStrings(
285
339
  /**
286
340
  * Campaign consumer payload: replace each card's `title` / `description` with VarSegment-resolved
287
341
  * tag strings (same rules as {@link resolveRcsCardPreviewStrings}). Root `rcsCardVarMapped` merges
288
- * with per-card `cardVarMapped`; `cardVarMapped` on each card is left unchanged for round-trip.
342
+ * with per-card `cardVarMapped` for resolution; emitted `cardVarMapped` omits SMS-fallback slot keys
343
+ * (root/nested merged with {@link pickRcsCardVarMappedEntries} per side).
289
344
  */
290
345
  export function mapRcsCardContentForConsumerWithResolvedTags(
291
346
  cardContentArray,
@@ -304,7 +359,9 @@ export function mapRcsCardContentForConsumerWithResolvedTags(
304
359
  card.cardVarMapped != null && typeof card.cardVarMapped === 'object'
305
360
  ? card.cardVarMapped
306
361
  : {};
307
- const merged = { ...rootRecord, ...nested };
362
+ const rootClean = pickRcsCardVarMappedEntries(rootRecord);
363
+ const nestedClean = pickRcsCardVarMappedEntries(nested);
364
+ const merged = { ...rootClean, ...nestedClean };
308
365
  const textOnly = isRcsTextOnlyCardMediaType(card.mediaType);
309
366
  const { rcsTitle, rcsDesc } = resolveRcsCardPreviewStrings(
310
367
  card.title ?? '',
@@ -313,10 +370,12 @@ export function mapRcsCardContentForConsumerWithResolvedTags(
313
370
  isLibraryMode,
314
371
  textOnly,
315
372
  );
373
+ const { cardVarMapped: _drop, ...cardRest } = card;
316
374
  return {
317
- ...card,
375
+ ...cardRest,
318
376
  title: rcsTitle,
319
377
  description: rcsDesc,
378
+ ...(Object.keys(nestedClean).length > 0 ? { cardVarMapped: nestedClean } : {}),
320
379
  };
321
380
  });
322
381
  }
@@ -416,6 +475,7 @@ export const getRCSContent = (template) => {
416
475
  content: {
417
476
  [RCS]: {
418
477
  rcsContent: {
478
+ cardType = '',
419
479
  cardContent = [{}],
420
480
  cardSettings = {},
421
481
  } = {},
@@ -431,8 +491,72 @@ export const getRCSContent = (template) => {
431
491
  mediaType,
432
492
  suggestions = [],
433
493
  } = cardContent[0];
494
+ const isCarousel =
495
+ (cardType || '').toString().toLowerCase() === 'carousel' ||
496
+ (cardContent || []).length > 1;
434
497
  const isTextOnlyCard = isRcsTextOnlyCardMediaType(mediaType);
435
498
  const mediaPreview = media?.thumbnailUrl ? media.thumbnailUrl : media.mediaUrl;
499
+
500
+ const renderCarouselListingPreview = () => {
501
+ const cards = Array.isArray(cardContent) ? cardContent : [];
502
+ if (!cards.length) return null;
503
+ const cardsToShow = cards.slice(0, 3); // enough to show a "peek" of next card
504
+ return (
505
+ <div className="scroll-container">
506
+ {cardsToShow.map((c, idx) => {
507
+ const m = c?.media || {};
508
+ const isVideo = (m?.mediaType || '').toString().toUpperCase() === RCS_MEDIA_TYPES.VIDEO;
509
+ const thumbUrl = m?.thumbnailUrl;
510
+ const mediaUrl = m?.mediaUrl;
511
+ // Avoid rendering an <img src="...mp4"> when a video doesn't have a thumbnail.
512
+ const url = thumbUrl ? thumbUrl : (isVideo ? '' : mediaUrl);
513
+ return (
514
+ <div
515
+ key={`rcs-carousel-listing-${idx}-${url || ''}`}
516
+ className="whatsapp-carousel-container"
517
+ role="group"
518
+ >
519
+ <div className="whatsapp-carousel-card">
520
+ {url && (
521
+ <CapImage
522
+ src={url}
523
+ className="whatsapp-image"
524
+ />
525
+ )}
526
+ {!url && isVideo && (
527
+ <div
528
+ className="whatsapp-image video-preview rcs-video-preview-placeholder"
529
+ >
530
+ <CapLabel type="label9" className="rcs-video-preview-label">
531
+ Video preview
532
+ </CapLabel>
533
+ </div>
534
+ )}
535
+ <span
536
+ className={`${url ? 'whatsapp-message-with-media' : 'whatsapp-message-without-media'}`}
537
+ >
538
+ <CapLabel type="label9" className="whatsapp-carousel-body">
539
+ {c?.title || ''}
540
+ {c?.description ? `\n${c?.description}` : ''}
541
+ </CapLabel>
542
+ </span>
543
+ {Array.isArray(c?.suggestions) && c.suggestions.length > 0 && (
544
+ <>
545
+ {renderRcsSuggestionsPreview(c.suggestions)}
546
+ </>
547
+ )}
548
+ </div>
549
+ </div>
550
+ );
551
+ })}
552
+ </div>
553
+ );
554
+ };
555
+
556
+ if (isCarousel) {
557
+ return renderCarouselListingPreview();
558
+ }
559
+
436
560
  return (
437
561
  <div className="cap-rcs-creatives">
438
562
  {mediaPreview && (
@@ -457,4 +581,3 @@ export const getRCSContent = (template) => {
457
581
  </div>
458
582
  );
459
583
  };
460
-
@@ -1091,6 +1091,7 @@ export class Edit extends React.Component { // eslint-disable-line react/prefer-
1091
1091
  onPreviewContentClicked={this.props.onPreviewContentClicked}
1092
1092
  onTestContentClicked={this.props.onTestContentClicked}
1093
1093
  eventContextTags={this.props?.eventContextTags}
1094
+ waitEventContextTags={this.props?.waitEventContextTags}
1094
1095
  messageDetails={this.props?.messageDetails}
1095
1096
  />
1096
1097
  </CapColumn>
@@ -1131,6 +1132,7 @@ Edit.propTypes = {
1131
1132
  injectedTags: PropTypes.object,
1132
1133
  selectedOfferDetails: PropTypes.array,
1133
1134
  eventContextTags: PropTypes.array,
1135
+ waitEventContextTags: PropTypes.object,
1134
1136
  messageDetails: PropTypes.object,
1135
1137
  showTestAndPreviewSlidebox: PropTypes.bool,
1136
1138
  handleTestAndPreview: PropTypes.func,