@capillarytech/creatives-library 9.0.50-alpha.1 → 9.0.50

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 (25) hide show
  1. package/package.json +1 -1
  2. package/v2Components/CommonTestAndPreview/UnifiedPreview/ViberPreviewContent.js +15 -106
  3. package/v2Components/CommonTestAndPreview/UnifiedPreview/_unifiedPreview.scss +1 -139
  4. package/v2Components/CommonTestAndPreview/constants.js +0 -2
  5. package/v2Components/CommonTestAndPreview/index.js +26 -240
  6. package/v2Components/CommonTestAndPreview/tests/UnifiedPreview/ViberPreviewContent.test.js +0 -364
  7. package/v2Components/CommonTestAndPreview/tests/utils.test.js +0 -49
  8. package/v2Components/CommonTestAndPreview/utils.js +0 -20
  9. package/v2Containers/CreativesContainer/index.js +4 -9
  10. package/v2Containers/CreativesContainer/tests/index.test.js +4 -3
  11. package/v2Containers/Rcs/carouselUtils.js +43 -13
  12. package/v2Containers/Rcs/index.js +32 -8
  13. package/v2Containers/Rcs/tests/carouselUtils.test.js +23 -4
  14. package/v2Containers/Rcs/tests/index.test.js +265 -3
  15. package/v2Containers/Rcs/tests/utils.test.js +59 -74
  16. package/v2Containers/Rcs/utils.js +9 -34
  17. package/v2Containers/Templates/_templates.scss +1 -179
  18. package/v2Containers/Templates/index.js +10 -97
  19. package/v2Containers/Viber/constants.js +0 -21
  20. package/v2Containers/Viber/index.js +43 -711
  21. package/v2Containers/Viber/index.scss +0 -175
  22. package/v2Containers/Viber/messages.js +0 -121
  23. package/v2Containers/Viber/tests/index.test.js +0 -80
  24. package/v2Components/CommonTestAndPreview/UnifiedPreview/ViberCarouselPreviewCards.js +0 -127
  25. package/v2Components/CommonTestAndPreview/UnifiedPreview/_viberCarouselPreviewCards.scss +0 -133
@@ -853,7 +853,7 @@ describe('buildCarouselCardContentForPayload', () => {
853
853
  expect(out[0].cardVarMapped).toBeUndefined();
854
854
  });
855
855
 
856
- it('builds cardVarMapped from title and description tokens, sanitizing values', () => {
856
+ it('renumbers non-numeric title/description tokens to sequential {{N}} slots and keys cardVarMapped by number', () => {
857
857
  const out = buildCarouselCardContentForPayload(
858
858
  [{ title: 'Hi {{name}}', description: 'Pts {{points}}', mediaType: RCS_MEDIA_TYPES.NONE }],
859
859
  {
@@ -863,7 +863,9 @@ describe('buildCarouselCardContentForPayload', () => {
863
863
  rcsVarRegex,
864
864
  },
865
865
  );
866
- expect(out[0].cardVarMapped).toEqual({ name: 'Bob', points: '' });
866
+ expect(out[0].title).toBe('Hi {{1}}');
867
+ expect(out[0].description).toBe('Pts {{2}}');
868
+ expect(out[0].cardVarMapped).toEqual({ 1: 'Bob', 2: '' });
867
869
  });
868
870
 
869
871
  it('defaults an unmapped token value to empty string via sanitizeCardVarMappedValue', () => {
@@ -871,7 +873,8 @@ describe('buildCarouselCardContentForPayload', () => {
871
873
  [{ title: 'Hi {{missing}}', description: 'D', mediaType: RCS_MEDIA_TYPES.NONE }],
872
874
  { isSlotMappingMode: true, cardVarMapped: {}, selectedCarouselHeight: 'SHORT', rcsVarRegex },
873
875
  );
874
- expect(out[0].cardVarMapped).toEqual({ missing: '' });
876
+ expect(out[0].title).toBe('Hi {{1}}');
877
+ expect(out[0].cardVarMapped).toEqual({ 1: '' });
875
878
  });
876
879
 
877
880
  it('handles a missing cardVarMapped object entirely', () => {
@@ -879,7 +882,23 @@ describe('buildCarouselCardContentForPayload', () => {
879
882
  [{ title: 'Hi {{name}}', description: 'D', mediaType: RCS_MEDIA_TYPES.NONE }],
880
883
  { isSlotMappingMode: true, cardVarMapped: undefined, selectedCarouselHeight: 'SHORT', rcsVarRegex },
881
884
  );
882
- expect(out[0].cardVarMapped).toEqual({ name: '' });
885
+ expect(out[0].title).toBe('Hi {{1}}');
886
+ expect(out[0].cardVarMapped).toEqual({ 1: '' });
887
+ });
888
+
889
+ it('leaves an already-numeric slot untouched and only renumbers the legacy/named slot alongside it', () => {
890
+ const out = buildCarouselCardContentForPayload(
891
+ [{ title: 'Hi {{1}}', description: 'Pts {{first_name}} exp {{3}}', mediaType: RCS_MEDIA_TYPES.NONE }],
892
+ {
893
+ isSlotMappingMode: true,
894
+ cardVarMapped: { 1: 'test', first_name: '{{first_name}}', 3: 'test' },
895
+ selectedCarouselHeight: 'SHORT',
896
+ rcsVarRegex,
897
+ },
898
+ );
899
+ expect(out[0].title).toBe('Hi {{1}}');
900
+ expect(out[0].description).toBe('Pts {{2}} exp {{3}}');
901
+ expect(out[0].cardVarMapped).toEqual({ 1: 'test', 2: '{{first_name}}', 3: 'test' });
883
902
  });
884
903
 
885
904
  it('skips a matched token that strips down to an empty variable name', () => {
@@ -1259,11 +1259,14 @@ describe('RCS createPayload', () => {
1259
1259
  });
1260
1260
  wrapper.update();
1261
1261
 
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.
1262
+ // The persisted API contract requires title/description to only ever hold {{1}}, {{2}}, …
1263
+ // — never the picked tag's name — so the focused occurrence's own token is normalized to its
1264
+ // numeric slot instead of being replaced with {{first_name}}; the tag itself lives in
1265
+ // cardVarMapped. The untouched duplicate keeps its original {{user_name}} token but syncs to
1266
+ // the same resolved value.
1264
1267
  const renamedVarArea = wrapper.find('CapInputTextArea').filterWhere((n) => {
1265
1268
  const id = n.prop('id') || '';
1266
- return id.includes('{{first_name}}_');
1269
+ return id.includes('{{1}}_');
1267
1270
  });
1268
1271
  const duplicateVarArea = wrapper.find('CapInputTextArea').filterWhere((n) => {
1269
1272
  const id = n.prop('id') || '';
@@ -1273,6 +1276,265 @@ describe('RCS createPayload', () => {
1273
1276
  expect(duplicateVarArea.at(0).prop('value')).toBe('{{first_name}}');
1274
1277
  });
1275
1278
 
1279
+ it('normalizes a hydrated legacy/semantic token to its numeric slot in the payload even without touching TagList', () => {
1280
+ const getFormData = jest.fn();
1281
+ const templateData = {
1282
+ name: 'RenewalReminder',
1283
+ versions: {
1284
+ base: {
1285
+ content: {
1286
+ RCS: {
1287
+ rcsContent: {
1288
+ cardType: 'STANDALONE',
1289
+ cardSettings: { cardOrientation: 'VERTICAL', cardWidth: 'SMALL' },
1290
+ cardContent: [
1291
+ {
1292
+ title: '',
1293
+ description:
1294
+ 'Hello {{1}}, your {{first_name}} renews on {{3}}. Renew via our website to avoid disruption. Thank you, Capillary Technologies.',
1295
+ mediaType: 'NONE',
1296
+ // Slots 1 and 3 were typed directly; slot 2's token was hydrated as a legacy
1297
+ // semantic name that was never touched via TagList (self-referential value).
1298
+ cardVarMapped: { 1: 'test', first_name: '{{first_name}}', 3: 'test' },
1299
+ suggestions: [
1300
+ { type: 'QUICK_REPLY', text: 'stop' },
1301
+ ],
1302
+ },
1303
+ ],
1304
+ contentType: 'RICHCARD',
1305
+ },
1306
+ },
1307
+ },
1308
+ },
1309
+ },
1310
+ type: 'RCS',
1311
+ };
1312
+
1313
+ const wrapper = mountWithIntl(
1314
+ <Provider store={store}>
1315
+ <Rcs
1316
+ actions={{
1317
+ clearCreateResponse: jest.fn(),
1318
+ getTemplateDetails: jest.fn(),
1319
+ uploadRcsAsset: jest.fn(),
1320
+ clearRcsMediaAsset: jest.fn(),
1321
+ editTemplate: jest.fn(),
1322
+ clearEditResponse: jest.fn(),
1323
+ }}
1324
+ globalActions={{ fetchSchemaForEntity }}
1325
+ onCreateComplete={onCreateComplete}
1326
+ handleClose={handleClose}
1327
+ intl={{ formatMessage }}
1328
+ location={{ pathname: '/rcs/create', query: { type: false, module: 'default' }, search: '' }}
1329
+ params={params}
1330
+ templateData={templateData}
1331
+ rcsData={{}}
1332
+ isFullMode={false}
1333
+ isEditFlow={false}
1334
+ loadingTags={false}
1335
+ metaEntities={[]}
1336
+ isDltEnabled={false}
1337
+ smsRegister={'DLT'}
1338
+ getFormData={getFormData}
1339
+ />
1340
+ </Provider>,
1341
+ );
1342
+
1343
+ // No TagList/VarSegment interaction at all — click Done straight away.
1344
+ const doneBtn = wrapper.find('CapButton.rcs-done-btn').at(0);
1345
+ expect(doneBtn.exists()).toBe(true);
1346
+ act(() => {
1347
+ doneBtn.prop('onClick')();
1348
+ });
1349
+ expect(getFormData).toHaveBeenCalled();
1350
+ const payloadArg = getFormData.mock.calls[0][0].value;
1351
+ const card = payloadArg.versions.base.content.RCS.rcsContent.cardContent[0];
1352
+
1353
+ expect(card.description).toBe(
1354
+ 'Hello {{1}}, your {{2}} renews on {{3}}. Renew via our website to avoid disruption. Thank you, Capillary Technologies.',
1355
+ );
1356
+ expect(card.cardVarMapped).toEqual({ 1: 'test', 2: '{{first_name}}', 3: 'test' });
1357
+ });
1358
+
1359
+ it('standalone full-mode EDIT of an already-approved template: correct sequential numeric slots pass through byte-identical (no regression)', async () => {
1360
+ const getFormData = jest.fn();
1361
+ const templateData = {
1362
+ name: 'ApprovedPromoCard',
1363
+ versions: {
1364
+ base: {
1365
+ content: {
1366
+ RCS: {
1367
+ rcsContent: {
1368
+ cardType: 'STANDALONE',
1369
+ cardSettings: { cardOrientation: 'VERTICAL', cardWidth: 'SMALL' },
1370
+ cardContent: [
1371
+ {
1372
+ title: 'Hi {{1}}',
1373
+ description: 'Your order {{2}} ships on {{3}}.',
1374
+ mediaType: 'IMAGE',
1375
+ media: { mediaUrl: 'https://cdn.example.com/card.png' },
1376
+ cardVarMapped: { 1: '{{first_name}}', 2: 'ORD-42', 3: '{{ship_date}}' },
1377
+ suggestions: [{ type: 'QUICK_REPLY', text: 'stop' }],
1378
+ },
1379
+ ],
1380
+ contentType: 'RICHCARD',
1381
+ },
1382
+ },
1383
+ },
1384
+ },
1385
+ },
1386
+ type: 'RCS',
1387
+ };
1388
+
1389
+ const rcsProps = (rcsData) => ({
1390
+ actions: {
1391
+ clearCreateResponse: jest.fn(),
1392
+ getTemplateDetails: jest.fn(),
1393
+ uploadRcsAsset: jest.fn(),
1394
+ clearRcsMediaAsset: jest.fn(),
1395
+ editTemplate: jest.fn(),
1396
+ clearEditResponse: jest.fn(),
1397
+ },
1398
+ globalActions: { fetchSchemaForEntity },
1399
+ onCreateComplete,
1400
+ handleClose,
1401
+ intl: { formatMessage },
1402
+ location: { pathname: '/rcs/edit', query: { type: false, module: 'default' }, search: '' },
1403
+ params,
1404
+ rcsData,
1405
+ isFullMode: true,
1406
+ isEditFlow: false,
1407
+ loadingTags: false,
1408
+ metaEntities: [],
1409
+ isDltEnabled: false,
1410
+ smsRegister: 'DLT',
1411
+ getFormData,
1412
+ });
1413
+
1414
+ const wrapper = mountWithIntl(
1415
+ <Provider store={store}>
1416
+ <Rcs {...rcsProps({})} />
1417
+ </Provider>,
1418
+ );
1419
+
1420
+ // Mirrors real usage: the component mounts before rcsData.templateDetails arrives (fetched via
1421
+ // getTemplateDetails), then the prop updates once the template loads — matching how
1422
+ // rcsHydrationDetails / setEditFlow(true) actually fire in the app. Updating rcsData in the
1423
+ // same synchronous pass as mount would run the hydration effect and the templateType-reset
1424
+ // effect together against stale pre-hydration closures, clobbering the hydrated title/desc.
1425
+ await act(async () => {
1426
+ await Promise.resolve();
1427
+ });
1428
+ wrapper.update();
1429
+ await act(async () => {
1430
+ wrapper.setProps({
1431
+ children: <Rcs {...rcsProps({ templateDetails: templateData })} />,
1432
+ });
1433
+ await Promise.resolve();
1434
+ });
1435
+ wrapper.update();
1436
+
1437
+ const doneBtn = wrapper.find('CapButton.rcs-done-btn').at(0);
1438
+ expect(doneBtn.exists()).toBe(true);
1439
+ act(() => {
1440
+ doneBtn.prop('onClick')();
1441
+ });
1442
+ expect(getFormData).toHaveBeenCalled();
1443
+ const card = getFormData.mock.calls[0][0].value.versions.base.content.RCS.rcsContent.cardContent[0];
1444
+
1445
+ expect(card.title).toBe('Hi {{1}}');
1446
+ expect(card.description).toBe('Your order {{2}} ships on {{3}}.');
1447
+ expect(card.cardVarMapped).toEqual({ 1: '{{first_name}}', 2: 'ORD-42', 3: '{{ship_date}}' });
1448
+ });
1449
+
1450
+ it('standalone full-mode EDIT: a legacy/semantic token still self-heals to its numeric slot (same fix applies outside library mode)', async () => {
1451
+ const getFormData = jest.fn();
1452
+ const templateData = {
1453
+ name: 'LegacyPromoCard',
1454
+ versions: {
1455
+ base: {
1456
+ content: {
1457
+ RCS: {
1458
+ rcsContent: {
1459
+ cardType: 'STANDALONE',
1460
+ cardSettings: { cardOrientation: 'VERTICAL', cardWidth: 'SMALL' },
1461
+ cardContent: [
1462
+ {
1463
+ title: '',
1464
+ // Legacy key "first_name" holds a DIFFERENT tag ({{loyalty_points}}) as its value —
1465
+ // deliberately not self-referential: normalizeCardVarMapped (full-mode hydration
1466
+ // only) clears a legacy key whose value is the exact same {{tagName}} as the key
1467
+ // itself, treating that as "never actually filled." That heuristic is pre-existing
1468
+ // and orthogonal to this fix, so avoid colliding with it here.
1469
+ description: 'Hello {{1}}, your {{first_name}} renews on {{3}}.',
1470
+ mediaType: 'NONE',
1471
+ cardVarMapped: { 1: 'test', first_name: '{{loyalty_points}}', 3: 'test' },
1472
+ suggestions: [{ type: 'QUICK_REPLY', text: 'stop' }],
1473
+ },
1474
+ ],
1475
+ contentType: 'RICHCARD',
1476
+ },
1477
+ },
1478
+ },
1479
+ },
1480
+ },
1481
+ type: 'RCS',
1482
+ };
1483
+
1484
+ const rcsProps = (rcsData) => ({
1485
+ actions: {
1486
+ clearCreateResponse: jest.fn(),
1487
+ getTemplateDetails: jest.fn(),
1488
+ uploadRcsAsset: jest.fn(),
1489
+ clearRcsMediaAsset: jest.fn(),
1490
+ editTemplate: jest.fn(),
1491
+ clearEditResponse: jest.fn(),
1492
+ },
1493
+ globalActions: { fetchSchemaForEntity },
1494
+ onCreateComplete,
1495
+ handleClose,
1496
+ intl: { formatMessage },
1497
+ location: { pathname: '/rcs/edit', query: { type: false, module: 'default' }, search: '' },
1498
+ params,
1499
+ rcsData,
1500
+ isFullMode: true,
1501
+ isEditFlow: false,
1502
+ loadingTags: false,
1503
+ metaEntities: [],
1504
+ isDltEnabled: false,
1505
+ smsRegister: 'DLT',
1506
+ getFormData,
1507
+ });
1508
+
1509
+ const wrapper = mountWithIntl(
1510
+ <Provider store={store}>
1511
+ <Rcs {...rcsProps({})} />
1512
+ </Provider>,
1513
+ );
1514
+
1515
+ // See the previous test for why hydration must arrive via a prop update, not the initial mount.
1516
+ await act(async () => {
1517
+ await Promise.resolve();
1518
+ });
1519
+ wrapper.update();
1520
+ await act(async () => {
1521
+ wrapper.setProps({
1522
+ children: <Rcs {...rcsProps({ templateDetails: templateData })} />,
1523
+ });
1524
+ await Promise.resolve();
1525
+ });
1526
+ wrapper.update();
1527
+
1528
+ const doneBtn = wrapper.find('CapButton.rcs-done-btn').at(0);
1529
+ act(() => {
1530
+ doneBtn.prop('onClick')();
1531
+ });
1532
+ const card = getFormData.mock.calls[0][0].value.versions.base.content.RCS.rcsContent.cardContent[0];
1533
+
1534
+ expect(card.description).toBe('Hello {{1}}, your {{2}} renews on {{3}}.');
1535
+ expect(card.cardVarMapped).toEqual({ 1: 'test', 2: '{{loyalty_points}}', 3: 'test' });
1536
+ });
1537
+
1276
1538
  it('should keep two tags + freetext inside the variable textarea in non-full mode edit (not merged into static text)', () => {
1277
1539
  const templateData = {
1278
1540
  name: 'TwoTagsAndFreeText',
@@ -604,77 +604,75 @@ describe('RCS utils', () => {
604
604
  });
605
605
 
606
606
  describe('mapRcsCardContentForConsumerWithResolvedTags', () => {
607
- it('sets title and description to resolved tag strings on each card', () => {
608
- const out = mapRcsCardContentForConsumerWithResolvedTags(
609
- [
610
- {
611
- title: '',
612
- description: 'Visit {{gt}} discount',
613
- mediaType: 'NONE',
614
- cardVarMapped: { gt: '{{loyalty_points}}', 1: '{{loyalty_points}}' },
615
- },
616
- ],
617
- {},
618
- false,
619
- );
607
+ it('leaves numeric-slot title/description untouched — consumers get the same {{1}}, {{2}}, … contract as the editor', () => {
608
+ const out = mapRcsCardContentForConsumerWithResolvedTags([
609
+ {
610
+ title: '',
611
+ description: 'Visit {{gt}} discount',
612
+ mediaType: 'IMAGE',
613
+ cardVarMapped: { gt: '{{loyalty_points}}', 1: '{{loyalty_points}}' },
614
+ },
615
+ ]);
616
+ expect(out[0].title).toBe('');
617
+ // No resolution happens here — the tag reference lives only in cardVarMapped, never baked
618
+ // into the text (that used to replace {{gt}} with {{loyalty_points}}).
619
+ expect(out[0].description).toBe('Visit {{gt}} discount');
620
+ expect(out[0].cardVarMapped).toEqual({ gt: '{{loyalty_points}}', 1: '{{loyalty_points}}' });
621
+ });
622
+
623
+ it('forces title to empty for text-only cards (mediaType NONE/TEXT), matching the editor\'s own display', () => {
624
+ const out = mapRcsCardContentForConsumerWithResolvedTags([
625
+ {
626
+ title: 'Stale title',
627
+ description: 'Hi {{1}}',
628
+ mediaType: 'NONE',
629
+ cardVarMapped: { 1: 'test' },
630
+ },
631
+ ]);
620
632
  expect(out[0].title).toBe('');
621
- expect(out[0].description).toBe('Visit {{loyalty_points}} discount');
633
+ expect(out[0].description).toBe('Hi {{1}}');
622
634
  });
623
635
 
624
- it('merges root rcsCardVarMapped with nested cardVarMapped', () => {
636
+ it('ignores any extra legacy args (root map / isFullMode) — only this card\'s own cardVarMapped is used', () => {
625
637
  const out = mapRcsCardContentForConsumerWithResolvedTags(
626
638
  [
627
639
  {
628
- title: 'Hi {{first_name}}',
629
- description: 'Pts {{promotion_points}}',
640
+ title: 'Hi {{1}}',
641
+ description: 'Pts {{2}}',
630
642
  mediaType: 'IMAGE',
631
- cardVarMapped: {
632
- promotion_points: '{{loyalty_points}}',
633
- 2: '{{loyalty_points}}',
634
- },
643
+ cardVarMapped: { 1: '{{first_name}}', 2: '{{loyalty_points}}' },
635
644
  },
636
645
  ],
637
- {
638
- first_name: '{{customer_name}}',
639
- 1: '{{customer_name}}',
640
- },
646
+ { 1: '{{customer_name}}' },
641
647
  false,
642
648
  );
643
- expect(out[0].title).toBe('Hi {{customer_name}}');
644
- expect(out[0].description).toBe('Pts {{loyalty_points}}');
649
+ expect(out[0].title).toBe('Hi {{1}}');
650
+ expect(out[0].description).toBe('Pts {{2}}');
651
+ expect(out[0].cardVarMapped).toEqual({ 1: '{{first_name}}', 2: '{{loyalty_points}}' });
645
652
  });
646
653
 
647
654
  it('passes through null or non-object items in the card array unchanged', () => {
648
- const out = mapRcsCardContentForConsumerWithResolvedTags([null, 'string', 42], {}, false);
655
+ const out = mapRcsCardContentForConsumerWithResolvedTags([null, 'string', 42]);
649
656
  expect(out).toEqual([null, 'string', 42]);
650
657
  });
651
658
 
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).
659
+ it('preserves a voucher/offer tag value (parenthesized key) in the emitted cardVarMapped, and keeps the raw {{N}} slot in the description', () => {
660
+ const out = mapRcsCardContentForConsumerWithResolvedTags([
661
+ {
662
+ title: '',
663
+ description: 'This is Bird with colorful feathers {{1}}',
664
+ mediaType: 'NONE',
665
+ cardVarMapped: { 'voucher(1190738)': 'SAVE20', 1: 'SAVE20' },
666
+ },
667
+ ]);
668
+ expect(out[0].cardVarMapped).toEqual({ 'voucher(1190738)': 'SAVE20', 1: 'SAVE20' });
669
+ expect(out[0].description).toBe('This is Bird with colorful feathers {{1}}');
670
+ });
671
+
672
+ it('carousel: each card keeps its own numeric slots and its own cardVarMapped — no cross-card leak', () => {
673
+ // Previously, CreativesContainer derived a "root" map from card 0 only and merged it into
674
+ // every card's resolution, which could leak card 0's values into card 1's text. Since text is
675
+ // no longer resolved at all, that leak class is gone: each card's output is self-contained.
678
676
  const card0 = {
679
677
  title: 'This is a bird {{1}}',
680
678
  description: 'This is Bird with colorful feathers {{2}}',
@@ -687,29 +685,16 @@ describe('RCS utils', () => {
687
685
  mediaType: 'IMAGE',
688
686
  cardVarMapped: { 3: '{{user_id_b64}}', 4: '{{first_name}}' },
689
687
  };
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
688
 
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)}}');
689
+ const out = mapRcsCardContentForConsumerWithResolvedTags([card0, card1]);
702
690
 
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}}');
691
+ expect(out[0].title).toBe('This is a bird {{1}}');
692
+ expect(out[0].description).toBe('This is Bird with colorful feathers {{2}}');
693
+ expect(out[0].cardVarMapped).toEqual({ 1: '{{user_id_b64}}', 2: '{{voucher(1190738)}}' });
706
694
 
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}}');
695
+ expect(out[1].title).toBe('This is a park {{3}}');
696
+ expect(out[1].description).toBe('This park has a cat and a bird {{4}}');
697
+ expect(out[1].cardVarMapped).toEqual({ 3: '{{user_id_b64}}', 4: '{{first_name}}' });
713
698
  });
714
699
  });
715
700
 
@@ -360,53 +360,28 @@ export function resolveRcsCardPreviewStrings(
360
360
  }
361
361
 
362
362
  /**
363
- * Campaign consumer payload: replace each card's `title` / `description` with VarSegment-resolved
364
- * tag strings (same rules as {@link resolveRcsCardPreviewStrings}). Root `rcsCardVarMapped` merges
365
- * with per-card `cardVarMapped` for resolution; emitted `cardVarMapped` omits SMS-fallback slot keys
366
- * (root/nested merged with {@link pickRcsCardVarMappedEntries} per side).
363
+ * Campaign consumer payload: pass each card's `title` / `description` through unchanged — per the
364
+ * API contract (see `createPayload`), they only ever contain sequential numeric slot placeholders
365
+ * (`{{1}}`, `{{2}}`, …), never a tag name or literal value baked into the text. Personalization tag
366
+ * references and literal values alike live solely in the emitted `cardVarMapped`, so the backend
367
+ * resolves every slot from that map. `cardVarMapped` is filtered to this card's own numeric/semantic
368
+ * entries via `pickRcsCardVarMappedEntries` (drops stray/SMS-fallback keys). Text-only cards
369
+ * (`mediaType` NONE/TEXT) never surface a title to consumers, matching the editor's own display.
367
370
  */
368
- export function mapRcsCardContentForConsumerWithResolvedTags(
369
- cardContentArray,
370
- rootRcsCardVarMapped,
371
- isFullMode,
372
- ) {
373
- const rootRecord =
374
- rootRcsCardVarMapped != null && typeof rootRcsCardVarMapped === 'object'
375
- ? rootRcsCardVarMapped
376
- : {};
371
+ export function mapRcsCardContentForConsumerWithResolvedTags(cardContentArray) {
377
372
  const list = Array.isArray(cardContentArray) ? cardContentArray : [];
378
- const isLibraryMode = isFullMode !== true;
379
- let carouselSlotOffset = 0;
380
373
  return list.map((card) => {
381
374
  if (!card || typeof card !== 'object') return card;
382
375
  const nested =
383
376
  card.cardVarMapped != null && typeof card.cardVarMapped === 'object'
384
377
  ? card.cardVarMapped
385
378
  : {};
386
- const rootClean = pickRcsCardVarMappedEntries(rootRecord);
387
379
  const nestedClean = pickRcsCardVarMappedEntries(nested);
388
- const merged = { ...rootClean, ...nestedClean };
389
380
  const textOnly = isRcsTextOnlyCardMediaType(card.mediaType);
390
- const cardTitle = card.title ?? '';
391
- const cardDescription = card.description ?? '';
392
- const { rcsTitle, rcsDesc } = resolveRcsCardPreviewStrings(
393
- cardTitle,
394
- cardDescription,
395
- merged,
396
- isLibraryMode,
397
- textOnly,
398
- true,
399
- carouselSlotOffset,
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;
405
381
  const { cardVarMapped: _drop, ...cardRest } = card;
406
382
  return {
407
383
  ...cardRest,
408
- title: rcsTitle,
409
- description: rcsDesc,
384
+ ...(textOnly ? { title: '' } : {}),
410
385
  ...(Object.keys(nestedClean).length > 0 ? { cardVarMapped: nestedClean } : {}),
411
386
  };
412
387
  });