@capillarytech/creatives-library 9.0.29-alpha.1 → 9.0.29-alpha.2

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.
@@ -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 every carousel variable slot independent, even when they share a literal tag name, in both inline preview and Test&Preview slidebox', async () => {
479
479
  const templateData = {
480
480
  name: 'CarouselVarsTemplate',
481
481
  versions: {
@@ -553,13 +553,21 @@ 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
+ // Card 0 has two `{{name}}` occurrences — title's, then description's, in DOM order — and
568
+ // each is now its own independent slot despite sharing the literal name.
569
+ const card0NameAreas = nameVarAreaForCard(0);
570
+ expect(card0NameAreas.length).toBe(2);
563
571
 
564
572
  act(() => {
565
573
  const slotId = nameVarAreas.at(0).prop('id') || nameVarAreas.at(0).key();
@@ -567,23 +575,264 @@ describe('RCS carousel label resolution in non-full mode', () => {
567
575
  });
568
576
  wrapper.update();
569
577
 
570
- // Inline preview should now show resolved values across BOTH cards
578
+ // Only the title resolves description's own `{{name}}` slot is untouched (independent slot).
571
579
  const updatedInlinePreview = wrapper.find('UnifiedPreview').filterWhere((n) => n.prop('content')?.carouselData).at(0);
572
580
  const inlineCards = updatedInlinePreview.props().content.carouselData;
573
581
  expect(inlineCards[0].title).toBe('Hi madhavi');
574
- expect(inlineCards[0].bodyText).toBe('Welcome madhavi');
575
- expect(inlineCards[1].title).toBe('Offer for madhavi');
576
- expect(inlineCards[1].bodyText).toBe('Dear madhavi');
582
+ expect(inlineCards[0].bodyText).toBe('Welcome {{name}}');
583
+ expect(inlineCards[1].title).toBe('Offer for {{name}}');
584
+ expect(inlineCards[1].bodyText).toBe('Dear {{name}}');
585
+
586
+ // Filling card 0's description slot separately resolves it without disturbing the title.
587
+ act(() => {
588
+ const descSlotId = nameVarAreaForCard(0).at(1).prop('id') || nameVarAreaForCard(0).at(1).key();
589
+ nameVarAreaForCard(0).at(1).props().onChange({ target: { id: descSlotId, value: 'lakshmi' } });
590
+ });
591
+ wrapper.update();
592
+
593
+ const afterDescInlinePreview = wrapper.find('UnifiedPreview').filterWhere((n) => n.prop('content')?.carouselData).at(0);
594
+ const afterDescInlineCards = afterDescInlinePreview.props().content.carouselData;
595
+ expect(afterDescInlineCards[0].title).toBe('Hi madhavi');
596
+ expect(afterDescInlineCards[0].bodyText).toBe('Welcome lakshmi');
577
597
 
578
- // Slidebox content should also be resolved (same underlying mapping)
598
+ // Slidebox content should mirror the same independent-slot resolution.
579
599
  const slidebox = wrapper.find('TestAndPreviewSlidebox').at(0);
580
600
  expect(slidebox.exists()).toBe(true);
581
601
  const slideboxContent = slidebox.props().content;
582
602
  expect(slideboxContent?.carouselData?.length).toBeGreaterThanOrEqual(2);
583
603
  expect(slideboxContent.carouselData[0].title).toBe('Hi madhavi');
584
- 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');
604
+ expect(slideboxContent.carouselData[0].bodyText).toBe('Welcome lakshmi');
605
+ expect(slideboxContent.carouselData[1].title).toBe('Offer for {{name}}');
606
+ expect(slideboxContent.carouselData[1].bodyText).toBe('Dear {{name}}');
607
+
608
+ // Setting card 1's own `{{name}}` slots independently resolves card 1 without disturbing card 0.
609
+ // Inactive tab panes aren't mounted until switched to, so activate card 1's tab first.
610
+ act(() => {
611
+ wrapper.find('CapTab').prop('onChange')('1');
612
+ });
613
+ wrapper.update();
614
+ const card1NameAreas = nameVarAreaForCard(1);
615
+ expect(card1NameAreas.length).toBe(2);
616
+ act(() => {
617
+ const slotId = card1NameAreas.at(0).prop('id') || card1NameAreas.at(0).key();
618
+ card1NameAreas.at(0).props().onChange({ target: { id: slotId, value: 'raghav' } });
619
+ });
620
+ wrapper.update();
621
+ act(() => {
622
+ const descSlotId = nameVarAreaForCard(1).at(1).prop('id') || nameVarAreaForCard(1).at(1).key();
623
+ nameVarAreaForCard(1).at(1).props().onChange({ target: { id: descSlotId, value: 'suresh' } });
624
+ });
625
+ wrapper.update();
626
+
627
+ const finalInlinePreview = wrapper.find('UnifiedPreview').filterWhere((n) => n.prop('content')?.carouselData).at(0);
628
+ const finalInlineCards = finalInlinePreview.props().content.carouselData;
629
+ expect(finalInlineCards[0].title).toBe('Hi madhavi');
630
+ expect(finalInlineCards[0].bodyText).toBe('Welcome lakshmi');
631
+ expect(finalInlineCards[1].title).toBe('Offer for raghav');
632
+ expect(finalInlineCards[1].bodyText).toBe('Dear suresh');
633
+ });
634
+
635
+ it('picking a tag from "Add Labels" for card 2\'s description (a bare semantic token with no numeric backing) shows the value live, without touching card 2\'s title slot', async () => {
636
+ const templateData = {
637
+ name: 'CarouselTagPickTemplate',
638
+ versions: {
639
+ base: {
640
+ content: {
641
+ RCS: {
642
+ rcsContent: {
643
+ cardType: 'CAROUSEL',
644
+ cardSettings: { cardOrientation: 'VERTICAL', cardWidth: 'SMALL' },
645
+ cardContent: [
646
+ {
647
+ title: 'Hi {{name}}',
648
+ description: 'Welcome {{name}}',
649
+ mediaType: 'IMAGE',
650
+ media: { mediaUrl: 'https://cdn.example.com/c1.png', height: 'MEDIUM' },
651
+ cardVarMapped: {},
652
+ suggestions: [],
653
+ },
654
+ {
655
+ title: 'sdvsdvs {{first_name_capital}}',
656
+ description: 'dssdfv {{adv}}',
657
+ mediaType: 'IMAGE',
658
+ media: { mediaUrl: 'https://cdn.example.com/c2.png', height: 'MEDIUM' },
659
+ cardVarMapped: {},
660
+ suggestions: [],
661
+ },
662
+ ],
663
+ contentType: 'RICHCARD',
664
+ },
665
+ },
666
+ },
667
+ },
668
+ },
669
+ type: 'RCS',
670
+ };
671
+
672
+ const wrapper = mountWithIntl(
673
+ <Provider store={store}>
674
+ <Rcs
675
+ actions={{
676
+ uploadRcsAsset,
677
+ clearRcsMediaAsset,
678
+ createTemplate,
679
+ clearCreateResponse,
680
+ getTemplateDetails,
681
+ editTemplate,
682
+ clearEditResponse,
683
+ }}
684
+ globalActions={{ fetchSchemaForEntity }}
685
+ onCreateComplete={onCreateComplete}
686
+ handleClose={handleClose}
687
+ intl={{ formatMessage }}
688
+ location={{ pathname: '/rcs/edit', query: { type: false, module: 'default' }, search: '' }}
689
+ params={params}
690
+ templateData={templateData}
691
+ rcsData={{}}
692
+ isFullMode={false}
693
+ isEditFlow={false}
694
+ loadingTags={false}
695
+ metaEntities={[]}
696
+ isDltEnabled={false}
697
+ smsRegister={'DLT'}
698
+ />
699
+ </Provider>,
700
+ );
701
+
702
+ // Flush hydration useEffects
703
+ await act(async () => {
704
+ await Promise.resolve();
705
+ });
706
+ wrapper.update();
707
+
708
+ // Card 2 is inactive until switched to.
709
+ act(() => {
710
+ wrapper.find('CapTab').prop('onChange')('1');
711
+ });
712
+ wrapper.update();
713
+
714
+ const card2 = wrapper.find('CarouselCard').filterWhere((n) => n.prop('cardIndex') === 1);
715
+
716
+ const descVarArea = card2
717
+ .find('CapInputTextArea')
718
+ .filterWhere((n) => String(n.prop('id') || '').includes('{{adv}}_'));
719
+ expect(descVarArea.length).toBeGreaterThanOrEqual(1);
720
+
721
+ // Focus card 2's description var slot, exactly like a user clicking into that box.
722
+ act(() => {
723
+ const id = descVarArea.at(0).prop('id');
724
+ descVarArea.at(0).props().onFocus({ target: { id } });
725
+ });
726
+ wrapper.update();
727
+
728
+ // Card 2's description TagList (the second .tag-mock under this card) picks "adv".
729
+ const card2AfterFocus = wrapper.find('CarouselCard').filterWhere((n) => n.prop('cardIndex') === 1);
730
+ const descTagList = card2AfterFocus.find('.tag-mock').at(1);
731
+ act(() => {
732
+ descTagList.props().onTagSelect('adv');
733
+ });
734
+ wrapper.update();
735
+
736
+ // Live (no save/reload) — the description's own box should now show the picked tag as its value.
737
+ const card2Final = wrapper.find('CarouselCard').filterWhere((n) => n.prop('cardIndex') === 1);
738
+ const descVarAreaAfterPick = card2Final
739
+ .find('CapInputTextArea')
740
+ .filterWhere((n) => String(n.prop('id') || '').includes('{{adv}}_'));
741
+ expect(descVarAreaAfterPick.at(0).prop('value')).toBe('{{adv}}');
742
+
743
+ // Title's own slot must be untouched by the description's tag pick.
744
+ const titleVarAreaAfterPick = card2Final
745
+ .find('CapInputTextArea')
746
+ .filterWhere((n) => String(n.prop('id') || '').includes('{{first_name_capital}}_'));
747
+ expect(titleVarAreaAfterPick.at(0).prop('value')).toBe('');
748
+ });
749
+
750
+ it('reopening a carousel after a prior save shows card 2\'s description value (persisted under its global positional key), not empty', async () => {
751
+ // Shape of a template that was already saved once: card 2 (index 1) picked "adv" for its
752
+ // description slot in an earlier session. Card 0 has 2 var tokens (title + desc), so card 2's
753
+ // title is global position 3 and its description is global position 4 — key "4".
754
+ const templateData = {
755
+ name: 'CarouselReopenTemplate',
756
+ versions: {
757
+ base: {
758
+ content: {
759
+ RCS: {
760
+ rcsContent: {
761
+ cardType: 'CAROUSEL',
762
+ cardSettings: { cardOrientation: 'VERTICAL', cardWidth: 'SMALL' },
763
+ cardContent: [
764
+ {
765
+ title: 'Hi {{name}}',
766
+ description: 'Welcome {{name}}',
767
+ mediaType: 'IMAGE',
768
+ media: { mediaUrl: 'https://cdn.example.com/c1.png', height: 'MEDIUM' },
769
+ cardVarMapped: {},
770
+ suggestions: [],
771
+ },
772
+ {
773
+ title: 'sdvsdvs {{first_name_capital}}',
774
+ description: 'dssdfv {{adv}}',
775
+ mediaType: 'IMAGE',
776
+ media: { mediaUrl: 'https://cdn.example.com/c2.png', height: 'MEDIUM' },
777
+ cardVarMapped: { '4': '{{adv}}' },
778
+ suggestions: [],
779
+ },
780
+ ],
781
+ contentType: 'RICHCARD',
782
+ },
783
+ },
784
+ },
785
+ },
786
+ },
787
+ type: 'RCS',
788
+ };
789
+
790
+ const wrapper = mountWithIntl(
791
+ <Provider store={store}>
792
+ <Rcs
793
+ actions={{
794
+ uploadRcsAsset,
795
+ clearRcsMediaAsset,
796
+ createTemplate,
797
+ clearCreateResponse,
798
+ getTemplateDetails,
799
+ editTemplate,
800
+ clearEditResponse,
801
+ }}
802
+ globalActions={{ fetchSchemaForEntity }}
803
+ onCreateComplete={onCreateComplete}
804
+ handleClose={handleClose}
805
+ intl={{ formatMessage }}
806
+ location={{ pathname: '/rcs/edit', query: { type: false, module: 'default' }, search: '' }}
807
+ params={params}
808
+ templateData={templateData}
809
+ rcsData={{}}
810
+ isFullMode={false}
811
+ isEditFlow={false}
812
+ loadingTags={false}
813
+ metaEntities={[]}
814
+ isDltEnabled={false}
815
+ smsRegister={'DLT'}
816
+ />
817
+ </Provider>,
818
+ );
819
+
820
+ await act(async () => {
821
+ await Promise.resolve();
822
+ });
823
+ wrapper.update();
824
+
825
+ act(() => {
826
+ wrapper.find('CapTab').prop('onChange')('1');
827
+ });
828
+ wrapper.update();
829
+
830
+ const card2 = wrapper.find('CarouselCard').filterWhere((n) => n.prop('cardIndex') === 1);
831
+ const descVarArea = card2
832
+ .find('CapInputTextArea')
833
+ .filterWhere((n) => String(n.prop('id') || '').includes('{{adv}}_'));
834
+ expect(descVarArea.length).toBeGreaterThanOrEqual(1);
835
+ expect(descVarArea.at(0).prop('value')).toBe('{{adv}}');
587
836
  });
588
837
  });
589
838
 
@@ -940,74 +1189,6 @@ describe('RCS createPayload', () => {
940
1189
  expect(Object.prototype.hasOwnProperty.call(card, 'cardVarMapped')).toBe(true);
941
1190
  });
942
1191
 
943
- it('disables Done in non-full mode when a CTA button URL still holds the unresolved {{1}} placeholder', () => {
944
- const templateData = {
945
- name: 'PromoCard',
946
- type: 'RCS',
947
- versions: {
948
- base: {
949
- content: {
950
- RCS: {
951
- rcsContent: {
952
- cardType: 'STANDALONE',
953
- cardSettings: { cardOrientation: 'VERTICAL' },
954
- cardContent: [
955
- {
956
- title: 'Title',
957
- description: 'Desc',
958
- mediaType: 'VIDEO',
959
- media: {
960
- mediaUrl: 'https://cdn.example.com/video.mp4',
961
- thumbnailUrl: 'https://cdn.example.com/video-thumb.jpg',
962
- },
963
- suggestions: [
964
- { type: 'CTA', text: 'Visit here', url: 'https://example.com{{1}}' },
965
- ],
966
- },
967
- ],
968
- contentType: 'RICHCARD',
969
- },
970
- },
971
- },
972
- },
973
- },
974
- };
975
-
976
- const wrapper = mountWithIntl(
977
- <Provider store={store}>
978
- <Rcs
979
- actions={{
980
- clearCreateResponse: jest.fn(),
981
- getTemplateDetails: jest.fn(),
982
- uploadRcsAsset: jest.fn(),
983
- clearRcsMediaAsset: jest.fn(),
984
- editTemplate: jest.fn(),
985
- clearEditResponse: jest.fn(),
986
- }}
987
- globalActions={{ fetchSchemaForEntity }}
988
- onCreateComplete={onCreateComplete}
989
- handleClose={handleClose}
990
- intl={{ formatMessage }}
991
- location={{ pathname: '/rcs/create', query: { type: false, module: 'default' }, search: '' }}
992
- params={params}
993
- templateData={templateData}
994
- rcsData={{}}
995
- isFullMode={false}
996
- isEditFlow={false}
997
- loadingTags={false}
998
- metaEntities={[]}
999
- isDltEnabled={false}
1000
- smsRegister={'DLT'}
1001
- getFormData={jest.fn()}
1002
- />
1003
- </Provider>,
1004
- );
1005
-
1006
- const doneBtn = wrapper.find('CapButton.rcs-done-btn').at(0);
1007
- expect(doneBtn.exists()).toBe(true);
1008
- expect(doneBtn.prop('disabled')).toBe(true);
1009
- });
1010
-
1011
1192
  it('should sync duplicate placeholders via cardVarMapped in non-full mode (typing once updates all occurrences)', () => {
1012
1193
  const templateData = {
1013
1194
  name: 'DupVarsTemplate',
@@ -648,6 +648,86 @@ 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('substitutes plain text typed into a numeric personalization slot (not a tag pick)', () => {
673
+ const out = mapRcsCardContentForConsumerWithResolvedTags(
674
+ [
675
+ {
676
+ title: 'Hello {{1}}',
677
+ description: 'Visit us at {{2}} today',
678
+ mediaType: 'IMAGE',
679
+ cardVarMapped: { 1: 'World', 2: 'Downtown Store' },
680
+ },
681
+ ],
682
+ {},
683
+ false,
684
+ );
685
+ expect(out[0].title).toBe('Hello World');
686
+ expect(out[0].description).toBe('Visit us at Downtown Store today');
687
+ });
688
+
689
+ it('BUG REPRO: 2-card carousel — card 1 description resolves to card 0\'s value instead of its own tag pick', () => {
690
+ // Mirrors CreativesContainer/index.js ~1448-1461: rootRcsCardVarMapped is derived ONLY from
691
+ // cardContentFromSubmit[0] (the FIRST card's own cardVarMapped) and reused for every card.
692
+ // Card 0: title slot 1 -> user_id_b64, desc slot 2 -> voucher(1190738).
693
+ // Card 1: title slot 3 -> user_id_b64 (same semantic name as card 0), desc slot 4 -> first_name
694
+ // (the user's actual pick for card 1's own description slot, via "Add labels" while focused on card 1).
695
+ const card0 = {
696
+ title: 'This is a bird {{1}}',
697
+ description: 'This is Bird with colorful feathers {{2}}',
698
+ mediaType: 'IMAGE',
699
+ cardVarMapped: { 1: '{{user_id_b64}}', 2: '{{voucher(1190738)}}' },
700
+ };
701
+ const card1 = {
702
+ title: 'This is a park {{3}}',
703
+ description: 'This park has a cat and a bird {{4}}',
704
+ mediaType: 'IMAGE',
705
+ cardVarMapped: { 3: '{{user_id_b64}}', 4: '{{first_name}}' },
706
+ };
707
+ // rootRcsCardVarMapped, as CreativesContainer actually builds it: card 0's own map only.
708
+ const rootRcsCardVarMappedFromFirstCardOnly = { 1: '{{user_id_b64}}', 2: '{{voucher(1190738)}}' };
709
+
710
+ const out = mapRcsCardContentForConsumerWithResolvedTags(
711
+ [card0, card1],
712
+ rootRcsCardVarMappedFromFirstCardOnly,
713
+ true,
714
+ );
715
+
716
+ // Card 0 resolves correctly.
717
+ expect(out[0].title).toBe('This is a bird {{user_id_b64}}');
718
+ expect(out[0].description).toBe('This is Bird with colorful feathers {{voucher(1190738)}}');
719
+
720
+ // Card 1's title happens to resolve "correctly" only by coincidence (both root slot "1" and
721
+ // card 1's own slot "3" hold the same semantic value, user_id_b64).
722
+ expect(out[1].title).toBe('This is a park {{user_id_b64}}');
723
+
724
+ // Card 1's description SHOULD resolve using its own cardVarMapped slot 4 ({{first_name}}),
725
+ // not card 0's leaked slot 2 ({{voucher(1190738)}}). This is the reported bug: it currently
726
+ // resolves to card 0's value because resolveRcsCardPreviewStrings restarts slot numbering at
727
+ // 0 for every card (utils.js ~345-351) while the merged map still carries card 0's leftover
728
+ // global-numbered keys (via the root map bug in CreativesContainer).
729
+ expect(out[1].description).toBe('This park has a cat and a bird {{first_name}}');
730
+ });
651
731
  });
652
732
 
653
733
  describe('isRcsTextOnlyCardMediaType', () => {
@@ -196,6 +196,50 @@ export function coalesceCardVarMappedToTemplate(
196
196
  templateVarTokens.forEach((token, slotIndexZeroBased) => {
197
197
  const semanticVarName = getVarNameFromToken(token);
198
198
  if (!semanticVarName) return;
199
+
200
+ if (RCS_NUMERIC_VAR_NAME_REGEX.test(semanticVarName)) {
201
+ // Carousel numeric placeholder ({{3}}, {{4}}, …): the slot key IS the token digit itself —
202
+ // numbers are allocated globally across the whole carousel (getNextCarouselVarToken), not
203
+ // re-numbered per card, so the position-based `slotIndexZeroBased + 1` key below would target
204
+ // the wrong slot for every card after the first (e.g. card 2's first token is digit "3", not "1").
205
+ const numericSlotKey = semanticVarName;
206
+ const trimmedSlotValue = String(lookupSourceMap[numericSlotKey] ?? '').trim();
207
+ coalescedMap[numericSlotKey] = trimmedSlotValue;
208
+ // The slot's value may itself be a mustache-wrapped tag reference (e.g. "3" -> "{{last_name}}")
209
+ // — mirror it onto the bare semantic key too so VarSegment editors keyed by tag name also
210
+ // prepopulate (same rule as syncCardVarMappedSemanticsFromSlots).
211
+ const mustacheInnerMatch = trimmedSlotValue.match(/^\{\{([^}]+)\}\}$/);
212
+ const innerSemanticName = mustacheInnerMatch?.[1]?.trim();
213
+ if (innerSemanticName && innerSemanticName !== numericSlotKey) {
214
+ const existingInnerValue = String(coalescedMap[innerSemanticName] ?? '').trim();
215
+ if (!existingInnerValue) {
216
+ coalescedMap[innerSemanticName] = trimmedSlotValue;
217
+ }
218
+ }
219
+ return;
220
+ }
221
+
222
+ // A numeric slot may already self-reference this exact tag (e.g. "4" -> "{{last_name}}") when
223
+ // the template's own numeric placeholder has since been resolved to this tag in the text (see
224
+ // mapRcsCardContentForConsumerWithResolvedTags). That numeric key is the source of truth — don't
225
+ // fall through to the position-based legacy lookup below, which targets an unrelated (or
226
+ // nonexistent) slot and would clobber the real value with an empty string.
227
+ const mustacheSelfReference = `{{${semanticVarName}}}`;
228
+ const existingNumericKeyForSemantic = Object.keys(lookupSourceMap).find((key) => (
229
+ RCS_NUMERIC_VAR_NAME_REGEX.test(key) && lookupSourceMap[key] === mustacheSelfReference
230
+ ));
231
+ if (existingNumericKeyForSemantic) {
232
+ const trimmedSlotValue = String(lookupSourceMap[existingNumericKeyForSemantic] ?? '').trim();
233
+ coalescedMap[existingNumericKeyForSemantic] = trimmedSlotValue;
234
+ if (!seenSemanticVarNames.has(semanticVarName)) {
235
+ seenSemanticVarNames.add(semanticVarName);
236
+ coalescedMap[semanticVarName] = trimmedSlotValue;
237
+ }
238
+ return;
239
+ }
240
+
241
+ // Legacy: template embeds the semantic token directly ({{user_name}}); older payloads stored
242
+ // the resolved value under a position-based numeric key ("1", "2", …) by order of appearance.
199
243
  const numericSlotKey = String(slotIndexZeroBased + 1);
200
244
  const isRepeatOfSemanticName = seenSemanticVarNames.has(semanticVarName);
201
245
  const skipSharedSemanticLookup =
@@ -206,12 +250,6 @@ export function coalesceCardVarMappedToTemplate(
206
250
  valueFromSource = lookupSourceMap[semanticVarName];
207
251
  }
208
252
  }
209
- if (valueFromSource === undefined || valueFromSource === null) {
210
- valueFromSource = lookupSourceMap[String(slotIndexZeroBased + 1)];
211
- }
212
- if (valueFromSource === undefined || valueFromSource === null) {
213
- valueFromSource = lookupSourceMap[slotIndexZeroBased + 1];
214
- }
215
253
  const trimmedSlotValue = valueFromSource == null ? '' : String(valueFromSource).trim();
216
254
  coalescedMap[numericSlotKey] = trimmedSlotValue;
217
255
  if (!seenSemanticVarNames.has(semanticVarName)) {
@@ -287,6 +325,15 @@ export function isRcsTextOnlyCardMediaType(mediaType) {
287
325
  * Mirrors `resolveTemplateWithMap` in the Rcs editor: title vars use global slots 0..n-1, then description.
288
326
  * For text-only cards (`textOnlyCard`), ignore persisted `title` and resolve description from slot 0 — matches
289
327
  * the editor where only the message body is shown.
328
+ *
329
+ * @param {boolean} [preserveLiteralValueTokens=false] When true, a slot value that is NOT itself a
330
+ * `{{...}}`-wrapped tag (e.g. a literal preview string like "SAVE20" typed for a voucher/offer tag)
331
+ *
332
+ * @param {number} [cardSlotOffset=0] Starting global slot index for this card's title. Carousel cards
333
+ * beyond index 0 must pass the running total of every prior card's var count — numeric `{{N}}` tokens
334
+ * are numbered globally across the whole carousel, not per card (see `getNextCarouselVarToken` in
335
+ * carouselUtils.js). Without this, every card resolves as if it were card 0, colliding with an earlier
336
+ * card's numeric slot key in the merged map.
290
337
  */
291
338
  export function resolveRcsCardPreviewStrings(
292
339
  title,
@@ -321,6 +368,15 @@ export function resolveRcsCardPreviewStrings(
321
368
  omitSemantic,
322
369
  );
323
370
  if (slotValue == null || String(slotValue).trim() === '') return elem;
371
+ const trimmedSlotValue = String(slotValue).trim();
372
+ const isPlainNumericSlot = RCS_NUMERIC_VAR_NAME_REGEX.test(key);
373
+ if (
374
+ preserveLiteralValueTokens
375
+ && !isPlainNumericSlot
376
+ && !RCS_MUSTACHE_WRAPPED_REGEX.test(trimmedSlotValue)
377
+ ) {
378
+ return elem;
379
+ }
324
380
  return String(slotValue);
325
381
  }
326
382
  return elem;
@@ -373,6 +429,7 @@ export function mapRcsCardContentForConsumerWithResolvedTags(
373
429
  textOnly,
374
430
  );
375
431
  const { cardVarMapped: _drop, ...cardRest } = card;
432
+
376
433
  return {
377
434
  ...cardRest,
378
435
  title: rcsTitle,