@capillarytech/creatives-library 9.0.35-alpha.0 → 9.0.35

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.
@@ -554,15 +554,13 @@ describe('buildCarouselCardsForPreview', () => {
554
554
  expect(
555
555
  buildCarouselCardsForPreview(undefined, {
556
556
  isFullMode: false,
557
- rcsVarRegex,
558
- resolveTemplateWithMap: jest.fn(),
557
+ resolveCarouselTemplateWithMap: jest.fn(),
559
558
  }),
560
559
  ).toEqual([]);
561
560
  expect(
562
561
  buildCarouselCardsForPreview([], {
563
562
  isFullMode: false,
564
- rcsVarRegex,
565
- resolveTemplateWithMap: jest.fn(),
563
+ resolveCarouselTemplateWithMap: jest.fn(),
566
564
  }),
567
565
  ).toEqual([]);
568
566
  });
@@ -571,47 +569,45 @@ describe('buildCarouselCardsForPreview', () => {
571
569
  expect(
572
570
  buildCarouselCardsForPreview(null, {
573
571
  isFullMode: false,
574
- rcsVarRegex,
575
- resolveTemplateWithMap: jest.fn(),
572
+ resolveCarouselTemplateWithMap: jest.fn(),
576
573
  }),
577
574
  ).toEqual([]);
578
575
  });
579
576
 
580
- it('passes title/description through unresolved in full mode without calling resolveTemplateWithMap', () => {
581
- const resolveTemplateWithMap = jest.fn();
577
+ it('passes title/description through unresolved in full mode without calling resolveCarouselTemplateWithMap', () => {
578
+ const resolveCarouselTemplateWithMap = jest.fn();
582
579
  const out = buildCarouselCardsForPreview(
583
580
  [{ title: 'Hi {{a}}', description: 'Bye {{b}}' }],
584
- { isFullMode: true, rcsVarRegex, resolveTemplateWithMap },
581
+ { isFullMode: true, resolveCarouselTemplateWithMap },
585
582
  );
586
- expect(resolveTemplateWithMap).not.toHaveBeenCalled();
583
+ expect(resolveCarouselTemplateWithMap).not.toHaveBeenCalled();
587
584
  expect(out[0].title).toBe('Hi {{a}}');
588
585
  expect(out[0].bodyText).toBe('Bye {{b}}');
589
586
  });
590
587
 
591
- it('resolves title/description through resolveTemplateWithMap and accumulates slot offset across cards', () => {
592
- const resolveTemplateWithMap = jest.fn((str, offset) => `R(${str}|${offset})`);
588
+ it('resolves title/description through resolveCarouselTemplateWithMap, scoped per card index', () => {
589
+ const resolveCarouselTemplateWithMap = jest.fn((str, cardIndex) => `R(${str}|${cardIndex})`);
593
590
  const out = buildCarouselCardsForPreview(
594
591
  [
595
592
  { title: 'Hi {{a}}', description: 'Bye {{b}} {{c}}' },
596
593
  { title: 'Solo {{d}}', description: 'none' },
597
594
  ],
598
- { isFullMode: false, rcsVarRegex, resolveTemplateWithMap },
595
+ { isFullMode: false, resolveCarouselTemplateWithMap },
599
596
  );
600
- expect(resolveTemplateWithMap).toHaveBeenNthCalledWith(1, 'Hi {{a}}', 0);
601
- expect(resolveTemplateWithMap).toHaveBeenNthCalledWith(2, 'Bye {{b}} {{c}}', 1);
602
- expect(resolveTemplateWithMap).toHaveBeenNthCalledWith(3, 'Solo {{d}}', 3);
603
- expect(resolveTemplateWithMap).toHaveBeenNthCalledWith(4, 'none', 4);
597
+ expect(resolveCarouselTemplateWithMap).toHaveBeenNthCalledWith(1, 'Hi {{a}}', 0);
598
+ expect(resolveCarouselTemplateWithMap).toHaveBeenNthCalledWith(2, 'Bye {{b}} {{c}}', 0);
599
+ expect(resolveCarouselTemplateWithMap).toHaveBeenNthCalledWith(3, 'Solo {{d}}', 1);
600
+ expect(resolveCarouselTemplateWithMap).toHaveBeenNthCalledWith(4, 'none', 1);
604
601
  expect(out[0].title).toBe('R(Hi {{a}}|0)');
605
- expect(out[0].bodyText).toBe('R(Bye {{b}} {{c}}|1)');
606
- expect(out[1].title).toBe('R(Solo {{d}}|3)');
607
- expect(out[1].bodyText).toBe('R(none|4)');
602
+ expect(out[0].bodyText).toBe('R(Bye {{b}} {{c}}|0)');
603
+ expect(out[1].title).toBe('R(Solo {{d}}|1)');
604
+ expect(out[1].bodyText).toBe('R(none|1)');
608
605
  });
609
606
 
610
607
  it('defaults missing card fields, including a default {} for a missing array item', () => {
611
608
  const out = buildCarouselCardsForPreview([undefined], {
612
609
  isFullMode: true,
613
- rcsVarRegex,
614
- resolveTemplateWithMap: jest.fn(),
610
+ resolveCarouselTemplateWithMap: jest.fn(),
615
611
  });
616
612
  expect(out[0]).toEqual({
617
613
  mediaType: '',
@@ -635,7 +631,7 @@ describe('buildCarouselCardsForPreview', () => {
635
631
  description: '',
636
632
  },
637
633
  ],
638
- { isFullMode: true, rcsVarRegex, resolveTemplateWithMap: jest.fn() },
634
+ { isFullMode: true, resolveCarouselTemplateWithMap: jest.fn() },
639
635
  );
640
636
  expect(out[0].mediaType).toBe('image');
641
637
  expect(out[0].imageSrc).toBe('img.png');
@@ -651,7 +647,7 @@ describe('buildCarouselCardsForPreview', () => {
651
647
  videoAsset: { videoSrc: 'v.mp4', videoThumbnail: 'other-thumb.png' },
652
648
  },
653
649
  ],
654
- { isFullMode: true, rcsVarRegex, resolveTemplateWithMap: jest.fn() },
650
+ { isFullMode: true, resolveCarouselTemplateWithMap: jest.fn() },
655
651
  );
656
652
  expect(out[0].videoPreviewImg).toBe('thumb.png');
657
653
  expect(out[0].videoSrc).toBe('v.mp4');
@@ -665,7 +661,7 @@ describe('buildCarouselCardsForPreview', () => {
665
661
  videoAsset: { videoSrc: 'v.mp4', videoThumbnail: 'other-thumb.png' },
666
662
  },
667
663
  ],
668
- { isFullMode: true, rcsVarRegex, resolveTemplateWithMap: jest.fn() },
664
+ { isFullMode: true, resolveCarouselTemplateWithMap: jest.fn() },
669
665
  );
670
666
  expect(out[0].videoPreviewImg).toBe('other-thumb.png');
671
667
  });
@@ -673,7 +669,7 @@ describe('buildCarouselCardsForPreview', () => {
673
669
  it('defaults videoPreviewImg and videoSrc to empty string when nothing is set', () => {
674
670
  const out = buildCarouselCardsForPreview(
675
671
  [{ mediaType: RCS_MEDIA_TYPES.IMAGE }],
676
- { isFullMode: true, rcsVarRegex, resolveTemplateWithMap: jest.fn() },
672
+ { isFullMode: true, resolveCarouselTemplateWithMap: jest.fn() },
677
673
  );
678
674
  expect(out[0].videoPreviewImg).toBe('');
679
675
  expect(out[0].videoSrc).toBe('');
@@ -475,7 +475,7 @@ describe('Creatives rcs test/>', () => {
475
475
  });
476
476
 
477
477
  describe('RCS carousel label resolution in non-full mode', () => {
478
- it('should resolve shared variables across multiple carousel cards in both inline preview and Test&Preview slidebox', async () => {
478
+ it('keeps each carousel card\'s same-named variable independently scoped in both inline preview and Test&Preview slidebox', async () => {
479
479
  const templateData = {
480
480
  name: 'CarouselVarsTemplate',
481
481
  versions: {
@@ -553,37 +553,64 @@ describe('RCS carousel label resolution in non-full mode', () => {
553
553
  expect(inlinePreview.exists()).toBe(true);
554
554
  expect(inlinePreview.props().content?.carouselData?.length).toBeGreaterThanOrEqual(2);
555
555
 
556
- // Variable slot id may live on `id` or React `key` depending on CapInput/TextArea wiring.
557
- const nameVarAreas = wrapper.find('CapInputTextArea').filterWhere((n) => {
558
- const id = String(n.prop('id') || '');
559
- const key = String(n.key() || '');
560
- return id.includes('{{name}}_') || key.includes('{{name}}_');
561
- });
562
- expect(nameVarAreas.length).toBeGreaterThanOrEqual(1);
556
+ const nameVarAreaForCard = (cardIndex) =>
557
+ wrapper
558
+ .find('CarouselCard')
559
+ .filterWhere((n) => n.prop('cardIndex') === cardIndex)
560
+ .find('CapInputTextArea')
561
+ .filterWhere((n) => {
562
+ const id = String(n.prop('id') || '');
563
+ const key = String(n.key() || '');
564
+ return id.includes('{{name}}_') || key.includes('{{name}}_');
565
+ });
566
+
567
+ const card0NameAreas = nameVarAreaForCard(0);
568
+ expect(card0NameAreas.length).toBeGreaterThanOrEqual(1);
563
569
 
564
570
  act(() => {
565
- const slotId = nameVarAreas.at(0).prop('id') || nameVarAreas.at(0).key();
566
- nameVarAreas.at(0).props().onChange({ target: { id: slotId, value: 'madhavi' } });
571
+ const slotId = card0NameAreas.at(0).prop('id') || card0NameAreas.at(0).key();
572
+ card0NameAreas.at(0).props().onChange({ target: { id: slotId, value: 'madhavi' } });
567
573
  });
568
574
  wrapper.update();
569
575
 
570
- // Inline preview should now show resolved values across BOTH cards
576
+ // Inline preview should resolve card 0 only card 1's own `{{name}}` slot is untouched.
571
577
  const updatedInlinePreview = wrapper.find('UnifiedPreview').filterWhere((n) => n.prop('content')?.carouselData).at(0);
572
578
  const inlineCards = updatedInlinePreview.props().content.carouselData;
573
579
  expect(inlineCards[0].title).toBe('Hi madhavi');
574
580
  expect(inlineCards[0].bodyText).toBe('Welcome madhavi');
575
- expect(inlineCards[1].title).toBe('Offer for madhavi');
576
- expect(inlineCards[1].bodyText).toBe('Dear madhavi');
581
+ expect(inlineCards[1].title).toBe('Offer for {{name}}');
582
+ expect(inlineCards[1].bodyText).toBe('Dear {{name}}');
577
583
 
578
- // Slidebox content should also be resolved (same underlying mapping)
584
+ // Slidebox content should mirror the same per-card scoping (same underlying mapping)
579
585
  const slidebox = wrapper.find('TestAndPreviewSlidebox').at(0);
580
586
  expect(slidebox.exists()).toBe(true);
581
587
  const slideboxContent = slidebox.props().content;
582
588
  expect(slideboxContent?.carouselData?.length).toBeGreaterThanOrEqual(2);
583
589
  expect(slideboxContent.carouselData[0].title).toBe('Hi madhavi');
584
590
  expect(slideboxContent.carouselData[0].bodyText).toBe('Welcome madhavi');
585
- expect(slideboxContent.carouselData[1].title).toBe('Offer for madhavi');
586
- expect(slideboxContent.carouselData[1].bodyText).toBe('Dear madhavi');
591
+ expect(slideboxContent.carouselData[1].title).toBe('Offer for {{name}}');
592
+ expect(slideboxContent.carouselData[1].bodyText).toBe('Dear {{name}}');
593
+
594
+ // Setting card 1's own `{{name}}` slot independently resolves card 1 without disturbing card 0.
595
+ // Inactive tab panes aren't mounted until switched to, so activate card 1's tab first.
596
+ act(() => {
597
+ wrapper.find('CapTab').prop('onChange')('1');
598
+ });
599
+ wrapper.update();
600
+ const card1NameAreas = nameVarAreaForCard(1);
601
+ expect(card1NameAreas.length).toBeGreaterThanOrEqual(1);
602
+ act(() => {
603
+ const slotId = card1NameAreas.at(0).prop('id') || card1NameAreas.at(0).key();
604
+ card1NameAreas.at(0).props().onChange({ target: { id: slotId, value: 'raghav' } });
605
+ });
606
+ wrapper.update();
607
+
608
+ const finalInlinePreview = wrapper.find('UnifiedPreview').filterWhere((n) => n.prop('content')?.carouselData).at(0);
609
+ const finalInlineCards = finalInlinePreview.props().content.carouselData;
610
+ expect(finalInlineCards[0].title).toBe('Hi madhavi');
611
+ expect(finalInlineCards[0].bodyText).toBe('Welcome madhavi');
612
+ expect(finalInlineCards[1].title).toBe('Offer for raghav');
613
+ expect(finalInlineCards[1].bodyText).toBe('Dear raghav');
587
614
  });
588
615
  });
589
616
 
@@ -1161,7 +1188,12 @@ describe('RCS createPayload', () => {
1161
1188
  cardContent: [
1162
1189
  {
1163
1190
  title: 'Hello {{user_name}} and {{user_name}}',
1164
- description: 'Hi {{user_name}}',
1191
+ // Distinct from the title's var name: VarSegmentMessageEditor ids are
1192
+ // `${token}_${localSegmentIndex}`, not scoped per-field, so reusing
1193
+ // {{user_name}} here would collide with the title's own id and get
1194
+ // picked up by the `{{user_name}}_` filter below once the tag-select
1195
+ // renames the title's first occurrence.
1196
+ description: 'Hi {{other_var}}',
1165
1197
  mediaType: 'IMAGE',
1166
1198
  media: { mediaUrl: '', thumbnailUrl: '' },
1167
1199
  cardVarMapped: {},
@@ -1227,12 +1259,18 @@ describe('RCS createPayload', () => {
1227
1259
  });
1228
1260
  wrapper.update();
1229
1261
 
1230
- const updatedTitleVarAreas = wrapper.find('CapInputTextArea').filterWhere((n) => {
1262
+ // The focused occurrence's own token is replaced with the picked tag, while the untouched
1263
+ // duplicate keeps its original {{user_name}} token but syncs to the same resolved value.
1264
+ const renamedVarArea = wrapper.find('CapInputTextArea').filterWhere((n) => {
1265
+ const id = n.prop('id') || '';
1266
+ return id.includes('{{first_name}}_');
1267
+ });
1268
+ const duplicateVarArea = wrapper.find('CapInputTextArea').filterWhere((n) => {
1231
1269
  const id = n.prop('id') || '';
1232
1270
  return id.includes('{{user_name}}_');
1233
1271
  });
1234
- expect(updatedTitleVarAreas.at(0).prop('value')).toBe('{{first_name}}');
1235
- expect(updatedTitleVarAreas.at(1).prop('value')).toBe('{{first_name}}');
1272
+ expect(renamedVarArea.at(0).prop('value')).toBe('{{first_name}}');
1273
+ expect(duplicateVarArea.at(0).prop('value')).toBe('{{first_name}}');
1236
1274
  });
1237
1275
 
1238
1276
  it('should keep two tags + freetext inside the variable textarea in non-full mode edit (not merged into static text)', () => {
@@ -364,6 +364,14 @@ describe('rcsLibraryHydrationUtils', () => {
364
364
  expect(out).toEqual({ 1: 'x', user_name: 'y' });
365
365
  });
366
366
 
367
+ it('keeps function-style tag keys with parentheses (voucher/offer/badge tags)', () => {
368
+ const input = {
369
+ 'voucher(1190738)': 'SAVE20',
370
+ 'badges_enroll_no_expiry(123)': 'Gold',
371
+ };
372
+ expect(pickRcsCardVarMappedEntries(input)).toEqual(input);
373
+ });
374
+
367
375
  it('returns {} for an empty object', () => {
368
376
  expect(pickRcsCardVarMappedEntries({})).toEqual({});
369
377
  });
@@ -439,6 +447,7 @@ describe('rcsLibraryHydrationUtils', () => {
439
447
  template: 'Hello',
440
448
  templateName: 'T1',
441
449
  registeredSenderIds: ['S1'],
450
+ templateVariableMapping: { '{#a#}_0': { data: 'x', count: 1 } },
442
451
  },
443
452
  },
444
453
  });
@@ -497,6 +506,7 @@ describe('rcsLibraryHydrationUtils', () => {
497
506
  expect(out.smsFallBackContent.templateConfigs).toEqual({
498
507
  templateName: 'ConfigName',
499
508
  registeredSenderIds: ['H1'],
509
+ templateVariableMapping: { a: { data: '1', count: 1 } },
500
510
  });
501
511
  expect(out.smsFallBackContent.rcsSmsFallbackVarMapped).toEqual({ a: '1' });
502
512
  });
@@ -537,6 +547,25 @@ describe('rcsLibraryHydrationUtils', () => {
537
547
  });
538
548
  });
539
549
 
550
+ it('non-full mode: DLT campaign with registeredSenderIds carries the var map into templateConfigs.templateVariableMapping (so it survives normalizeRcsMessageContentForApi stripping rcsSmsFallbackVarMapped)', () => {
551
+ enableTraiDlt();
552
+ const out = buildSmsFallBackContentForPayload({
553
+ smsFallbackData: {
554
+ message: 'Hi {#var#} your OTP is {#var#}.Capillary Tech.',
555
+ templateName: 'TEST_FALLBACK',
556
+ registeredSenderIds: ['CPLRYT'],
557
+ rcsSmsFallbackVarMapped: { '{#var#}_1': 'test1', '{#var#}_3': 'test2' },
558
+ },
559
+ templateData: {},
560
+ isFullMode: false,
561
+ smsRegister: 'DLT',
562
+ });
563
+ expect(out.smsFallBackContent.templateConfigs.templateVariableMapping).toEqual({
564
+ '{#var#}_1': { data: 'test1', count: 1 },
565
+ '{#var#}_3': { data: 'test2', count: 2 },
566
+ });
567
+ });
568
+
540
569
  it('non-full mode: DLT campaign but no derivable templateConfigs omits templateConfigs', () => {
541
570
  enableTraiDlt();
542
571
  const out = buildSmsFallBackContentForPayload({
@@ -744,6 +773,48 @@ describe('rcsLibraryHydrationUtils', () => {
744
773
  ).toBe('Direct Template');
745
774
  });
746
775
 
776
+ it('prefers templateConfigs.template (raw, {#var#} intact) over smsContent/message for DLT', () => {
777
+ const out = resolveSmsFallbackHydrationFromDetails({
778
+ smsFallBackContent: {
779
+ smsContent: 'hey user this is a test',
780
+ message: 'hey user this is a test',
781
+ templateConfigs: {
782
+ templateName: 'RCSDLT2',
783
+ template: 'hey{#var#}test',
784
+ templateVariableMapping: {
785
+ '{#var#}_1': { data: ' user this is a ', count: 1 },
786
+ },
787
+ },
788
+ },
789
+ });
790
+ expect(out.nextSmsState.content).toBe('hey{#var#}test');
791
+ expect(out.nextSmsState.templateContent).toBe('hey{#var#}test');
792
+ expect(out.nextSmsState.rcsSmsFallbackVarMapped).toEqual({ '{#var#}_1': ' user this is a ' });
793
+ });
794
+
795
+ it('merges templateVariableMapping-derived slots with an explicit rcsSmsFallbackVarMapped (explicit wins)', () => {
796
+ const out = resolveSmsFallbackHydrationFromDetails({
797
+ smsFallBackContent: {
798
+ smsContent: 'hey user this is a test',
799
+ rcsSmsFallbackVarMapped: { '{#var#}_1': 'explicit override' },
800
+ templateConfigs: {
801
+ template: 'hey{#var#}test',
802
+ templateVariableMapping: {
803
+ '{#var#}_1': { data: ' user this is a ', count: 1 },
804
+ },
805
+ },
806
+ },
807
+ });
808
+ expect(out.nextSmsState.rcsSmsFallbackVarMapped).toEqual({ '{#var#}_1': 'explicit override' });
809
+ });
810
+
811
+ it('falls back to smsContent when templateConfigs.template is absent (non-DLT)', () => {
812
+ const out = resolveSmsFallbackHydrationFromDetails({
813
+ smsFallBackContent: { smsContent: 'Hi {{first_name}}, your OTP is 1234' },
814
+ });
815
+ expect(out.nextSmsState.content).toBe('Hi {{first_name}}, your OTP is 1234');
816
+ });
817
+
747
818
  it('joins an updated-sms-editor array into the fallback message', () => {
748
819
  const out = resolveSmsFallbackHydrationFromDetails({
749
820
  smsFallBackContent: {
@@ -648,6 +648,69 @@ describe('RCS utils', () => {
648
648
  const out = mapRcsCardContentForConsumerWithResolvedTags([null, 'string', 42], {}, false);
649
649
  expect(out).toEqual([null, 'string', 42]);
650
650
  });
651
+
652
+ it('preserves a voucher/offer tag value (parenthesized key) in the emitted cardVarMapped, and keeps the raw tag in the description', () => {
653
+ const out = mapRcsCardContentForConsumerWithResolvedTags(
654
+ [
655
+ {
656
+ title: '',
657
+ description: 'This is Bird with colorful feathers {{voucher(1190738)}}',
658
+ mediaType: 'NONE',
659
+ cardVarMapped: { 'voucher(1190738)': 'SAVE20' },
660
+ },
661
+ ],
662
+ {},
663
+ false,
664
+ );
665
+ expect(out[0].cardVarMapped).toEqual({ 'voucher(1190738)': 'SAVE20' });
666
+ // The typed value is a literal preview string, not another mustache tag — the raw
667
+ // {{voucher(...)}} token must survive so the editor can re-render an editable slot
668
+ // on reopen, and so the backend can resolve a real per-recipient code at send time.
669
+ expect(out[0].description).toBe('This is Bird with colorful feathers {{voucher(1190738)}}');
670
+ });
671
+
672
+ it('BUG REPRO: 2-card carousel — card 1 description resolves to card 0\'s value instead of its own tag pick', () => {
673
+ // Mirrors CreativesContainer/index.js ~1448-1461: rootRcsCardVarMapped is derived ONLY from
674
+ // cardContentFromSubmit[0] (the FIRST card's own cardVarMapped) and reused for every card.
675
+ // Card 0: title slot 1 -> user_id_b64, desc slot 2 -> voucher(1190738).
676
+ // Card 1: title slot 3 -> user_id_b64 (same semantic name as card 0), desc slot 4 -> first_name
677
+ // (the user's actual pick for card 1's own description slot, via "Add labels" while focused on card 1).
678
+ const card0 = {
679
+ title: 'This is a bird {{1}}',
680
+ description: 'This is Bird with colorful feathers {{2}}',
681
+ mediaType: 'IMAGE',
682
+ cardVarMapped: { 1: '{{user_id_b64}}', 2: '{{voucher(1190738)}}' },
683
+ };
684
+ const card1 = {
685
+ title: 'This is a park {{3}}',
686
+ description: 'This park has a cat and a bird {{4}}',
687
+ mediaType: 'IMAGE',
688
+ cardVarMapped: { 3: '{{user_id_b64}}', 4: '{{first_name}}' },
689
+ };
690
+ // rootRcsCardVarMapped, as CreativesContainer actually builds it: card 0's own map only.
691
+ const rootRcsCardVarMappedFromFirstCardOnly = { 1: '{{user_id_b64}}', 2: '{{voucher(1190738)}}' };
692
+
693
+ const out = mapRcsCardContentForConsumerWithResolvedTags(
694
+ [card0, card1],
695
+ rootRcsCardVarMappedFromFirstCardOnly,
696
+ true,
697
+ );
698
+
699
+ // Card 0 resolves correctly.
700
+ expect(out[0].title).toBe('This is a bird {{user_id_b64}}');
701
+ expect(out[0].description).toBe('This is Bird with colorful feathers {{voucher(1190738)}}');
702
+
703
+ // Card 1's title happens to resolve "correctly" only by coincidence (both root slot "1" and
704
+ // card 1's own slot "3" hold the same semantic value, user_id_b64).
705
+ expect(out[1].title).toBe('This is a park {{user_id_b64}}');
706
+
707
+ // Card 1's description SHOULD resolve using its own cardVarMapped slot 4 ({{first_name}}),
708
+ // not card 0's leaked slot 2 ({{voucher(1190738)}}). This is the reported bug: it currently
709
+ // resolves to card 0's value because resolveRcsCardPreviewStrings restarts slot numbering at
710
+ // 0 for every card (utils.js ~345-351) while the merged map still carries card 0's leftover
711
+ // global-numbered keys (via the root map bug in CreativesContainer).
712
+ expect(out[1].description).toBe('This park has a cat and a bird {{first_name}}');
713
+ });
651
714
  });
652
715
 
653
716
  describe('isRcsTextOnlyCardMediaType', () => {
@@ -701,6 +764,42 @@ describe('RCS utils', () => {
701
764
  expect(rcsTitle).toBe('');
702
765
  expect(rcsDesc).toBe('Visit Store for {{loyalty_points}} discount');
703
766
  });
767
+
768
+ it('carousel card 1+: cardSlotOffset param resolves against its own global slots, not card 0\'s', () => {
769
+ // Full, correctly-populated carousel-wide map: slot 1 = card 0 title (user_id_b64), slot 2 =
770
+ // card 0 desc (voucher(1190738)), slot 3 = card 1 title (user_id_b64), slot 4 = card 1 desc
771
+ // (first_name).
772
+ const fullCarouselWideMap = {
773
+ 1: '{{user_id_b64}}',
774
+ 2: '{{voucher(1190738)}}',
775
+ 3: '{{user_id_b64}}',
776
+ 4: '{{first_name}}',
777
+ };
778
+ // Card 1's own title/description, called with cardSlotOffset=2 (card 0 contributed 2 slots) —
779
+ // the same way mapRcsCardContentForConsumerWithResolvedTags now calls it per card.
780
+ const { rcsTitle, rcsDesc } = resolveRcsCardPreviewStrings(
781
+ 'This is a park {{3}}',
782
+ 'This park has a cat and a bird {{4}}',
783
+ fullCarouselWideMap,
784
+ true,
785
+ false,
786
+ false,
787
+ 2,
788
+ );
789
+ expect(rcsTitle).toBe('This is a park {{user_id_b64}}');
790
+ expect(rcsDesc).toBe('This park has a cat and a bird {{first_name}}');
791
+ });
792
+
793
+ it('omitting cardSlotOffset defaults to 0 (single-card / standalone callers unaffected)', () => {
794
+ const { rcsTitle, rcsDesc } = resolveRcsCardPreviewStrings(
795
+ 'Hi {{1}}',
796
+ 'Pts {{2}}',
797
+ { 1: '{{first_name}}', 2: '{{loyalty_points}}' },
798
+ true,
799
+ );
800
+ expect(rcsTitle).toBe('Hi {{first_name}}');
801
+ expect(rcsDesc).toBe('Pts {{loyalty_points}}');
802
+ });
704
803
  });
705
804
 
706
805
  describe('sanitizeCardVarMappedValue', () => {
@@ -6,6 +6,7 @@ import {
6
6
  RCS_NUMERIC_VAR_NAME_REGEX,
7
7
  REGEX_SPECIAL_CHARS_ESCAPE_PATTERN,
8
8
  RCS_STRIP_MUSTACHE_DELIMITERS_REGEX,
9
+ RCS_MUSTACHE_WRAPPED_REGEX,
9
10
  } from './constants';
10
11
  import './index.scss';
11
12
  // import { formatMessage } from '../../../utils/intl';
@@ -287,6 +288,20 @@ export function isRcsTextOnlyCardMediaType(mediaType) {
287
288
  * Mirrors `resolveTemplateWithMap` in the Rcs editor: title vars use global slots 0..n-1, then description.
288
289
  * For text-only cards (`textOnlyCard`), ignore persisted `title` and resolve description from slot 0 — matches
289
290
  * the editor where only the message body is shown.
291
+ *
292
+ * @param {boolean} [preserveLiteralValueTokens=false] When true, a slot value that is NOT itself a
293
+ * `{{...}}`-wrapped tag (e.g. a literal preview string like "SAVE20" typed for a voucher/offer tag)
294
+ * is left as the raw token instead of being baked into the returned string. Personalization-style
295
+ * values (themselves another mustache tag, e.g. `{{loyalty_points}}`) still resolve either way — those
296
+ * are internal-alias renames, not literal content. Pass true for payloads that get persisted/replayed
297
+ * (see `mapRcsCardContentForConsumerWithResolvedTags`) so a voucher's saved token survives for the
298
+ * editor to re-render an editable slot from, and for the backend to resolve per-recipient at send time.
299
+ *
300
+ * @param {number} [cardSlotOffset=0] Starting global slot index for this card's title. Carousel cards
301
+ * beyond index 0 must pass the running total of every prior card's var count — numeric `{{N}}` tokens
302
+ * are numbered globally across the whole carousel, not per card (see `getNextCarouselVarToken` in
303
+ * carouselUtils.js). Without this, every card resolves as if it were card 0, colliding with an earlier
304
+ * card's numeric slot key in the merged map.
290
305
  */
291
306
  export function resolveRcsCardPreviewStrings(
292
307
  title,
@@ -294,6 +309,8 @@ export function resolveRcsCardPreviewStrings(
294
309
  cardVarMapped,
295
310
  isLibraryMode = false,
296
311
  textOnlyCard = false,
312
+ preserveLiteralValueTokens = false,
313
+ cardSlotOffset = 0,
297
314
  ) {
298
315
  const splitTemplateVarStringRcs = (str) => splitTemplateVarString(str, rcsVarRegex);
299
316
  const getVarNameFromToken = (token = '') =>
@@ -321,6 +338,10 @@ export function resolveRcsCardPreviewStrings(
321
338
  omitSemantic,
322
339
  );
323
340
  if (slotValue == null || String(slotValue).trim() === '') return elem;
341
+ const trimmedSlotValue = String(slotValue).trim();
342
+ if (preserveLiteralValueTokens && !RCS_MUSTACHE_WRAPPED_REGEX.test(trimmedSlotValue)) {
343
+ return elem;
344
+ }
324
345
  return String(slotValue);
325
346
  }
326
347
  return elem;
@@ -333,8 +354,8 @@ export function resolveRcsCardPreviewStrings(
333
354
  ? 0
334
355
  : (effectiveTitle.match(rcsVarRegex) || []).length;
335
356
  return {
336
- rcsTitle: textOnlyCard ? '' : resolveTemplateWithMap(effectiveTitle, 0),
337
- rcsDesc: resolveTemplateWithMap(String(description || ''), titleVarCount),
357
+ rcsTitle: textOnlyCard ? '' : resolveTemplateWithMap(effectiveTitle, cardSlotOffset),
358
+ rcsDesc: resolveTemplateWithMap(String(description || ''), cardSlotOffset + titleVarCount),
338
359
  };
339
360
  }
340
361
 
@@ -355,6 +376,7 @@ export function mapRcsCardContentForConsumerWithResolvedTags(
355
376
  : {};
356
377
  const list = Array.isArray(cardContentArray) ? cardContentArray : [];
357
378
  const isLibraryMode = isFullMode !== true;
379
+ let carouselSlotOffset = 0;
358
380
  return list.map((card) => {
359
381
  if (!card || typeof card !== 'object') return card;
360
382
  const nested =
@@ -365,13 +387,21 @@ export function mapRcsCardContentForConsumerWithResolvedTags(
365
387
  const nestedClean = pickRcsCardVarMappedEntries(nested);
366
388
  const merged = { ...rootClean, ...nestedClean };
367
389
  const textOnly = isRcsTextOnlyCardMediaType(card.mediaType);
390
+ const cardTitle = card.title ?? '';
391
+ const cardDescription = card.description ?? '';
368
392
  const { rcsTitle, rcsDesc } = resolveRcsCardPreviewStrings(
369
- card.title ?? '',
370
- card.description ?? '',
393
+ cardTitle,
394
+ cardDescription,
371
395
  merged,
372
396
  isLibraryMode,
373
397
  textOnly,
398
+ true,
399
+ carouselSlotOffset,
374
400
  );
401
+ const effectiveTitleForCount = textOnly ? '' : String(cardTitle);
402
+ const titleVarCount = (effectiveTitleForCount.match(rcsVarRegex) || []).length;
403
+ const descVarCount = (String(cardDescription).match(rcsVarRegex) || []).length;
404
+ carouselSlotOffset += titleVarCount + descVarCount;
375
405
  const { cardVarMapped: _drop, ...cardRest } = card;
376
406
  return {
377
407
  ...cardRest,