@capillarytech/creatives-library 9.0.34 → 9.0.35-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.
@@ -71,6 +71,7 @@ import {
71
71
  mergeSmsFallbackForLibrary,
72
72
  buildSmsFallBackContentForPayload,
73
73
  pickRcsCardVarMappedEntries,
74
+ extractRegisteredSenderIdsFromSmsFallbackRecord,
74
75
  } from './rcsLibraryHydrationUtils';
75
76
  import {
76
77
  RCS,
@@ -152,7 +153,6 @@ import { isTagIncluded } from '../../utils/commonUtils';
152
153
  import injectReducer from '../../utils/injectReducer';
153
154
  import v2RcsReducer from './reducer';
154
155
  import {
155
- buildRcsNumericMustachePlaceholderRegex,
156
156
  getTemplateStatusType,
157
157
  normalizeCardVarMapped,
158
158
  coalesceCardVarMappedToTemplate,
@@ -175,6 +175,7 @@ import {
175
175
  getCarouselDescriptionCharacterCount,
176
176
  buildCarouselCardsForPreview as buildCarouselCardsForPreviewUtil,
177
177
  buildCarouselCardContentForPayload,
178
+ getCarouselVarMapKey,
178
179
  } from './carouselUtils';
179
180
  import CarouselDimensionSelection from './components/CarouselDimensionSelection';
180
181
  import CarouselCard from './components/CarouselCard';
@@ -775,7 +776,7 @@ export const Rcs = (props) => {
775
776
  ['title', 'description'].forEach((field) => {
776
777
  const templateStr = card?.[field] || '';
777
778
  if (!templateStr) return;
778
- const resolved = resolveTemplateWithMap(templateStr);
779
+ const resolved = resolveCarouselTemplateWithMap(templateStr, idx);
779
780
  if (!resolved) {
780
781
  updateCarouselErrors(idx, { [field]: false });
781
782
  return;
@@ -867,8 +868,27 @@ export const Rcs = (props) => {
867
868
  }).join('');
868
869
  };
869
870
 
871
+ /**
872
+ * Carousel resolve: each card's variables live under their own `getCarouselVarMapKey(cardIndex, name)`
873
+ * key in `cardVarMapped` (see that helper's doc) — no global slot-offset arithmetic needed since
874
+ * cards can no longer collide with each other's entries.
875
+ */
876
+ const resolveCarouselTemplateWithMap = (str = '', cardIndex) => {
877
+ if (!str) return '';
878
+ const arr = splitTemplateVarStringRcs(str);
879
+ return arr.map((elem) => {
880
+ if (rcsVarTestRegex.test(elem)) {
881
+ const varName = getVarNameFromToken(elem);
882
+ const slotValue = cardVarMapped?.[getCarouselVarMapKey(cardIndex, varName)];
883
+ if (isNil(slotValue) || String(slotValue)?.trim?.() === '') return elem;
884
+ return String(slotValue);
885
+ }
886
+ return elem;
887
+ }).join('');
888
+ };
889
+
870
890
  const buildCarouselCardsForPreview = (cards = []) =>
871
- buildCarouselCardsForPreviewUtil(cards, { isFullMode, rcsVarRegex, resolveTemplateWithMap });
891
+ buildCarouselCardsForPreviewUtil(cards, { isFullMode, resolveCarouselTemplateWithMap });
872
892
 
873
893
  /**
874
894
  * Content for TestAndPreviewSlidebox — apply cardVarMapped whenever the slot editor is shown
@@ -1196,28 +1216,56 @@ export const Rcs = (props) => {
1196
1216
 
1197
1217
  setTemplateType(contentType.carousel);
1198
1218
  setTemplateMediaType(RCS_MEDIA_TYPES.NONE);
1199
- // Library/campaign payloads can store cardSettings/cardContent at the flat top-level
1200
- // `rcsContent` instead of nested under versions.base.content.RCS (same fallback already
1201
- // used for the rich_card media lookup below, line ~1793). Without it, cardWidth/height
1202
- // silently fall back to SMALL/MEDIUM, mismatching the dimensions the thumbnail was
1203
- // actually uploaded at and causing object-fit:cover to crop the preview image.
1219
+
1204
1220
  const cardSettings = rcsContent?.cardSettings
1205
1221
  || get(details, 'rcsContent.cardSettings', {});
1206
1222
  const cardWidth = cardSettings?.cardWidth || SMALL;
1207
1223
  setSelectedCarouselWidth(cardWidth);
1208
1224
 
1209
- const cards = Array.isArray(rcsContent?.cardContent)
1210
- ? rcsContent.cardContent
1211
- : get(details, 'rcsContent.cardContent', []);
1225
+ const nestedCards = Array.isArray(rcsContent?.cardContent) ? rcsContent.cardContent : [];
1226
+ const topCardsRaw = get(details, 'rcsContent.cardContent', []);
1227
+ const topCards = Array.isArray(topCardsRaw) ? topCardsRaw : [];
1228
+ const cardCount = Math.max(nestedCards.length, topCards.length);
1229
+ const mergedCards = Array.from({ length: cardCount }, (_, idx) => ({
1230
+ ...(topCards[idx] || {}),
1231
+ ...(nestedCards[idx] || {}),
1232
+ }));
1233
+ const cards = cardCount > 0 ? mergedCards : topCards;
1212
1234
  const firstHeight = cards?.[0]?.media?.height || MEDIUM;
1213
1235
  setSelectedCarouselHeight(firstHeight);
1214
1236
  setSelectedCarousel(`${firstHeight}_${cardWidth}`);
1215
1237
  setActiveCarouselIndex('0');
1216
1238
 
1217
1239
  if (cards.length > 1) {
1218
- setCardVarMapped((previousVarMap) => cards.reduce((merged, c) => {
1219
- const cardMap = c?.cardVarMapped;
1220
- return cardMap != null && typeof cardMap === 'object' ? { ...merged, ...cardMap } : merged;
1240
+
1241
+ setCardVarMapped((previousVarMap) => cards.reduce((merged, c = {}, idx) => {
1242
+ const rawCardMap = c?.cardVarMapped;
1243
+ if (rawCardMap == null || typeof rawCardMap !== 'object') return merged;
1244
+ if (idx === 0) return { ...merged, ...rawCardMap };
1245
+ const cardTitle = c?.title != null ? String(c.title) : '';
1246
+ const cardDesc = c?.description != null ? String(c.description) : '';
1247
+ const cardTokens = [
1248
+ ...(cardTitle ? cardTitle.match(rcsVarRegex) ?? [] : []),
1249
+ ...(cardDesc ? cardDesc.match(rcsVarRegex) ?? [] : []),
1250
+ ];
1251
+ const cardOrderedTagNames = cardTokens.map((token) => getVarNameFromToken(token)).filter(Boolean);
1252
+ const cardMapBeforeCoalesce = isFullMode
1253
+ ? normalizeCardVarMapped(rawCardMap, cardOrderedTagNames)
1254
+ : { ...rawCardMap };
1255
+ const cardMapAfterCoalesce = coalesceCardVarMappedToTemplate(
1256
+ cardMapBeforeCoalesce,
1257
+ cardTitle,
1258
+ cardDesc,
1259
+ rcsVarRegex,
1260
+ );
1261
+ const cardMapAfterSlotSync = !isFullMode
1262
+ ? syncCardVarMappedSemanticsFromSlots(cardMapAfterCoalesce, cardTitle, cardDesc, rcsVarRegex)
1263
+ : cardMapAfterCoalesce;
1264
+ const namespacedCardMap = {};
1265
+ Object.entries(cardMapAfterSlotSync).forEach(([key, value]) => {
1266
+ namespacedCardMap[getCarouselVarMapKey(idx, key)] = value;
1267
+ });
1268
+ return { ...merged, ...namespacedCardMap };
1221
1269
  }, { ...(previousVarMap || {}) }));
1222
1270
  }
1223
1271
 
@@ -1426,10 +1474,29 @@ export const Rcs = (props) => {
1426
1474
  globalActions.fetchSchemaForEntity(query);
1427
1475
  };
1428
1476
 
1429
- const replaceNumericPlaceholderWithTagInTemplate = (templateStr, numericVarName, tagName) => {
1430
- if (!templateStr || !numericVarName || !tagName) return templateStr;
1431
- const placeholderRegex = buildRcsNumericMustachePlaceholderRegex(numericVarName);
1432
- return templateStr.replace(placeholderRegex, `{{${tagName}}}`);
1477
+ /**
1478
+ * Replaces only the var token at `slotOrdinalZeroBased` (the field-local ordinal among
1479
+ * `{{...}}` tokens, left to right) with `{{tagName}}`. Unlike a name-based regex replace,
1480
+ * this targets a single slot even when the same tag name appears in other slots of the field.
1481
+ */
1482
+ const replaceTagAtFieldSlotOrdinal = (templateStr, slotOrdinalZeroBased, tagName) => {
1483
+ if (!templateStr || slotOrdinalZeroBased === null || slotOrdinalZeroBased === undefined || !tagName) {
1484
+ return templateStr;
1485
+ }
1486
+ let varOrdinal = 0;
1487
+ let replaced = false;
1488
+ const updatedSegments = splitTemplateVarStringRcs(templateStr).map((segment) => {
1489
+ if (rcsVarTestRegex.test(segment)) {
1490
+ const currentOrdinal = varOrdinal;
1491
+ varOrdinal += 1;
1492
+ if (currentOrdinal === slotOrdinalZeroBased) {
1493
+ replaced = true;
1494
+ return `{{${tagName}}}`;
1495
+ }
1496
+ }
1497
+ return segment;
1498
+ });
1499
+ return replaced ? updatedSegments.join('') : templateStr;
1433
1500
  };
1434
1501
 
1435
1502
  const onTagSelect = (selectedTagNameFromPicker, varSegmentCompositeDomId, tagAreaField) => {
@@ -1459,16 +1526,15 @@ export const Rcs = (props) => {
1459
1526
  setCardVarMapped((previousCardVarMapped) => {
1460
1527
  const updatedCardVarMapped = { ...(previousCardVarMapped || {}) };
1461
1528
  if (isNumericPlaceholderSlot) {
1529
+ // Numeric slot ({{1}}, {{2}}, …) is the persisted API contract — keep the numeric key
1530
+ // itself (do not fork off a semantic-name key); the template text stays {{N}} below.
1462
1531
  const existingValueBeforeAppend = (
1463
1532
  previousCardVarMapped?.[semanticOrNumericVarName] ?? ''
1464
1533
  ).toString();
1465
1534
  const mappedValueAfterAppendingTag = `${existingValueBeforeAppend}{{${selectedTagNameFromPicker}}}`;
1466
- delete updatedCardVarMapped[semanticOrNumericVarName];
1467
- updatedCardVarMapped[selectedTagNameFromPicker] = mappedValueAfterAppendingTag;
1535
+ updatedCardVarMapped[semanticOrNumericVarName] = mappedValueAfterAppendingTag;
1468
1536
  } else {
1469
- // Same semantic token (e.g. {{adv}}) in title and body must not share one map key for
1470
- // "existing value" — that appends the new tag onto the other field. Match handleRcsVarChange:
1471
- // read/write the global numeric slot only and drop the shared semantic key.
1537
+
1472
1538
  const existingValueBeforeAppend = cardVarMappedNumericSlotKey
1473
1539
  ? String(previousCardVarMapped?.[cardVarMappedNumericSlotKey] ?? '')
1474
1540
  : String(previousCardVarMapped?.[semanticOrNumericVarName] ?? '');
@@ -1497,36 +1563,40 @@ export const Rcs = (props) => {
1497
1563
  return updatedCardVarMapped;
1498
1564
  });
1499
1565
 
1500
- if (
1501
- isNumericPlaceholderSlot
1502
- && (tagAreaField === RCS_TAG_AREA_FIELD_TITLE || tagAreaField === RCS_TAG_AREA_FIELD_DESC)
1503
- ) {
1566
+ // Numeric slot ({{1}}, {{2}}, …): the token is already the persisted API contract — leave
1567
+ // templateTitle/templateDesc untouched. The chip label re-resolves via titleVarSegmentValueMapById
1568
+ // / descriptionVarSegmentValueMapById, both keyed off cardVarMapped (updated above).
1569
+ if (isNumericPlaceholderSlot) return;
1570
+
1571
+ if (tagAreaField === RCS_TAG_AREA_FIELD_TITLE || tagAreaField === RCS_TAG_AREA_FIELD_DESC) {
1572
+ // Legacy slot that already holds a named tag (e.g. {{first_name}}) is replaced by
1573
+ // field-local position, since a name-based replace would also overwrite other slots
1574
+ // sharing that same tag name.
1575
+ const fieldOffsetForSlot = tagAreaField === RCS_TAG_AREA_FIELD_TITLE
1576
+ ? 0
1577
+ : (templateTitle?.match(rcsVarRegex) ?? []).length;
1578
+ const localSlotOrdinal = (globalVarSlotIndexZeroBased !== null && globalVarSlotIndexZeroBased !== undefined)
1579
+ ? globalVarSlotIndexZeroBased - fieldOffsetForSlot
1580
+ : null;
1581
+
1504
1582
  if (tagAreaField === RCS_TAG_AREA_FIELD_TITLE) {
1505
1583
  setTemplateTitle((previousTitle) => {
1506
- const titleAfterReplacingNumericPlaceholder = replaceNumericPlaceholderWithTagInTemplate(
1507
- previousTitle || '',
1508
- semanticOrNumericVarName,
1509
- selectedTagNameFromPicker,
1510
- );
1511
- if (titleAfterReplacingNumericPlaceholder === previousTitle) return previousTitle;
1512
- setTemplateTitleError(variableErrorHandling(titleAfterReplacingNumericPlaceholder));
1584
+ const titleAfterReplacingTag = replaceTagAtFieldSlotOrdinal(previousTitle || '', localSlotOrdinal, selectedTagNameFromPicker);
1585
+ if (titleAfterReplacingTag === previousTitle) return previousTitle;
1586
+ setTemplateTitleError(computeTemplateTitleError(titleAfterReplacingTag));
1513
1587
  // Remount segment editor: tag insert replaces {{n}} with e.g. {{tag.FORMAT_1}} — slot ids change; avoids stale UI vs manual typing in full-mode TextArea
1514
1588
  setRcsVarSegmentEditorRemountKey((remountKey) => remountKey + 1);
1515
- return titleAfterReplacingNumericPlaceholder;
1589
+ return titleAfterReplacingTag;
1516
1590
  });
1517
1591
  } else {
1518
1592
  setTemplateDesc((previousDescription) => {
1519
- const descriptionAfterReplacingNumericPlaceholder = replaceNumericPlaceholderWithTagInTemplate(
1520
- previousDescription || '',
1521
- semanticOrNumericVarName,
1522
- selectedTagNameFromPicker,
1523
- );
1524
- if (descriptionAfterReplacingNumericPlaceholder === previousDescription) {
1593
+ const descriptionAfterReplacingTag = replaceTagAtFieldSlotOrdinal(previousDescription || '', localSlotOrdinal, selectedTagNameFromPicker);
1594
+ if (descriptionAfterReplacingTag === previousDescription) {
1525
1595
  return previousDescription;
1526
1596
  }
1527
- setTemplateDescError(variableErrorHandling(descriptionAfterReplacingNumericPlaceholder));
1597
+ setTemplateDescError(computeTemplateDescError(descriptionAfterReplacingTag));
1528
1598
  setRcsVarSegmentEditorRemountKey((remountKey) => remountKey + 1);
1529
- return descriptionAfterReplacingNumericPlaceholder;
1599
+ return descriptionAfterReplacingTag;
1530
1600
  });
1531
1601
  }
1532
1602
  }
@@ -1543,12 +1613,14 @@ export const Rcs = (props) => {
1543
1613
  const token = carouselFocusedVarId.slice(0, sep);
1544
1614
  const variableName = getVarNameFromToken(token);
1545
1615
  if (!variableName) return;
1616
+ const activeIdx = parseInt(activeCarouselIndex, 10);
1617
+ const mapKey = getCarouselVarMapKey(isNaN(activeIdx) ? 0 : activeIdx, variableName);
1546
1618
  setCardVarMapped((prev) => {
1547
- const base = (prev?.[variableName] ?? '').toString();
1619
+ const base = (prev?.[mapKey] ?? '').toString();
1548
1620
  const nextVal = `${base}{{${data}}}`;
1549
1621
  return {
1550
1622
  ...(prev || {}),
1551
- [variableName]: nextVal,
1623
+ [mapKey]: nextVal,
1552
1624
  };
1553
1625
  });
1554
1626
  };
@@ -1660,31 +1732,13 @@ export const Rcs = (props) => {
1660
1732
  return renderArray;
1661
1733
  };
1662
1734
  const onTemplateTitleChange = ({ target: { value } }) => {
1663
- let errorMessage = false;
1664
- if (templateType === contentType.rich_card && !value.trim()) {
1665
- errorMessage = formatMessage(messages.emptyTemplateTitleErrorMessage);
1666
- } else if (value.length > TEMPLATE_TITLE_MAX_LENGTH) {
1667
- errorMessage = formatMessage(messages.templateHeaderLengthError);
1668
- } else {
1669
- errorMessage = variableErrorHandling(value);
1670
- }
1671
1735
  setTemplateTitle(value);
1672
- setTemplateTitleError(errorMessage);
1736
+ setTemplateTitleError(computeTemplateTitleError(value));
1673
1737
  };
1674
1738
 
1675
1739
  const onTemplateDescChange = ({ target: { value } }) => {
1676
- let errorMessage = false;
1677
- if(templateType === contentType.text_message && value?.length > (isHostInfoBip ? RCS_TEXT_MESSAGE_MAX_LENGTH_INFOBIP : RCS_TEXT_MESSAGE_MAX_LENGTH)){
1678
- errorMessage = formatMessage(messages.templateMessageLengthError);
1679
- } else if(templateType === contentType.rich_card && value?.length > RCS_RICH_CARD_MAX_LENGTH){
1680
- errorMessage = formatMessage(messages.templateMessageLengthError);
1681
- } else {
1682
- errorMessage = false;
1683
- }
1684
- const varError = variableErrorHandling(value);
1685
- const error = errorMessage || varError;
1686
1740
  setTemplateDesc(value);
1687
- setTemplateDescError(error);
1741
+ setTemplateDescError(computeTemplateDescError(value));
1688
1742
  };
1689
1743
 
1690
1744
 
@@ -1767,6 +1821,29 @@ export const Rcs = (props) => {
1767
1821
  return false;
1768
1822
  };
1769
1823
 
1824
+ // Shared with onTemplateTitleChange so tag-picker inserts enforce the same length limit as manual typing.
1825
+ const computeTemplateTitleError = (value) => {
1826
+ if (templateType === contentType.rich_card && !value.trim()) {
1827
+ return formatMessage(messages.emptyTemplateTitleErrorMessage);
1828
+ }
1829
+ if (value.length > TEMPLATE_TITLE_MAX_LENGTH) {
1830
+ return formatMessage(messages.templateHeaderLengthError);
1831
+ }
1832
+ return variableErrorHandling(value);
1833
+ };
1834
+
1835
+ // Shared with onTemplateDescChange so tag-picker inserts enforce the same length limit as manual typing.
1836
+ const computeTemplateDescError = (value) => {
1837
+ let errorMessage = false;
1838
+ if (templateType === contentType.text_message && value?.length > (isHostInfoBip ? RCS_TEXT_MESSAGE_MAX_LENGTH_INFOBIP : RCS_TEXT_MESSAGE_MAX_LENGTH)) {
1839
+ errorMessage = formatMessage(messages.templateMessageLengthError);
1840
+ } else if (templateType === contentType.rich_card && value?.length > RCS_RICH_CARD_MAX_LENGTH) {
1841
+ errorMessage = formatMessage(messages.templateMessageLengthError);
1842
+ }
1843
+ const varError = variableErrorHandling(value);
1844
+ return errorMessage || varError;
1845
+ };
1846
+
1770
1847
  const onMessageAddVar = () => {
1771
1848
  onAddVar(templateDesc);
1772
1849
  };
@@ -1851,23 +1928,30 @@ const onTitleAddVar = () => {
1851
1928
  return "";
1852
1929
  };
1853
1930
 
1854
- // Carousel: render variable-value editor for a given template string (title/description).
1855
- // This matches rich-card/text edit behavior: static pieces are read-only, variable tokens are editable.
1856
- const renderCarouselEditMessage = (templateStr) => {
1931
+ // Carousel: render variable-value editor for a given template string (title/description) belonging
1932
+ // to `cardIndex`. This matches rich-card/text edit behavior: static pieces are read-only, variable
1933
+ // tokens are editable. Each card's values live under their own `getCarouselVarMapKey(cardIndex, name)`
1934
+ // key in `cardVarMapped` so two cards using the same variable/tag name never collide.
1935
+ const renderCarouselEditMessage = (templateStr, cardIndex) => {
1857
1936
  const renderArray = [];
1858
1937
  const templateArr = splitTemplateVarString(templateStr);
1859
1938
  if (templateArr?.length) {
1860
1939
  templateArr.forEach((elem, index) => {
1861
1940
  if (rcsVarTestRegex.test(elem)) {
1862
1941
  const varName = getVarNameFromToken(elem);
1942
+ const mapKey = getCarouselVarMapKey(cardIndex, varName);
1943
+ const computedValue = varName ? ((cardVarMapped?.[mapKey] ?? '').toString()) : '';
1863
1944
  renderArray.push(
1864
- <div key={`${elem}_${index}`} className="var-segment-message-editor__var-slot">
1945
+ <div
1946
+ key={`${elem}_${index}_${rcsVarSegmentEditorRemountKey}`}
1947
+ className="var-segment-message-editor__var-slot"
1948
+ >
1865
1949
  <TextArea
1866
1950
  id={`${elem}_${index}`}
1867
1951
  placeholder={`enter the value for ${elem}`}
1868
1952
  autosize={{ minRows: 1, maxRows: 3 }}
1869
- onChange={(e) => textAreaValueChange(e, TITLE_TEXT)}
1870
- value={varName ? ((cardVarMapped?.[varName] ?? '').toString()) : ''}
1953
+ onChange={(e) => textAreaValueChange(e, cardIndex)}
1954
+ value={computedValue}
1871
1955
  onFocus={(e) => {
1872
1956
  const id = e?.target?.id || e?.currentTarget?.id || '';
1873
1957
  setCarouselFocusedVarId(id);
@@ -1891,7 +1975,7 @@ const onTitleAddVar = () => {
1891
1975
  return <CapRow className="rcs-edit-template-message-input">{renderArray}</CapRow>;
1892
1976
  };
1893
1977
 
1894
- const textAreaValueChange = (e, type) => {
1978
+ const textAreaValueChange = (e, cardIndex) => {
1895
1979
  const value = e?.target?.value ?? '';
1896
1980
  const id = e?.target?.id || e?.currentTarget?.id || '';
1897
1981
  if (!id) return;
@@ -1902,9 +1986,10 @@ const onTitleAddVar = () => {
1902
1986
  const variableName = getVarNameFromToken(token);
1903
1987
 
1904
1988
  if (variableName) {
1989
+ const mapKey = getCarouselVarMapKey(cardIndex, variableName);
1905
1990
  setCardVarMapped((prev) => ({
1906
1991
  ...prev,
1907
- [variableName]: isInvalidValue ? "" : value,
1992
+ [mapKey]: isInvalidValue ? "" : value,
1908
1993
  }));
1909
1994
  }
1910
1995
  };
@@ -2572,8 +2657,6 @@ const onTitleAddVar = () => {
2572
2657
  const carouselVidDims =
2573
2658
  RCS_CAROUSEL_VIDEO_THUMBNAIL_DIMENSIONS[carouselDimKey]
2574
2659
  || RCS_CAROUSEL_VIDEO_THUMBNAIL_DIMENSIONS.MEDIUM_MEDIUM;
2575
- // Debug log for embedded/library mode preview payload (carousel)
2576
- // eslint-disable-next-line no-console
2577
2660
  return (
2578
2661
  <UnifiedPreview
2579
2662
  channel={RCS}
@@ -2757,46 +2840,55 @@ const onTitleAddVar = () => {
2757
2840
  const cardContent = rcsForTest.rcsContent?.cardContent;
2758
2841
  if (Array.isArray(cardContent) && cardContent[0]) {
2759
2842
  if (isCarouselType) {
2760
- // Carousel: resolve {{N}} slot tokens to the actual tag expressions / static values the
2761
- // user mapped via cardVarMapped. Run this for ALL modes (create, edit, consumer) so that:
2843
+ // Carousel: resolve {{N}}/named slot tokens to the actual tag expressions / static values
2844
+ // the user mapped via cardVarMapped. Run this for ALL modes (create, edit, consumer) so that:
2762
2845
  // - buildRcsTestMessagePayload sends real Capillary tag names to the test API, and
2763
2846
  // - prepareTagExtractionPayload can extract tag metadata from the card content.
2764
- // Track cumulative slotOffset across cards: each card's title/description vars occupy
2765
- // sequential global slot indices ({{1}},{{2}} in card 0; {{3}},{{4}} in card 1, …).
2766
- // Without the offset, every card restarts at slotKey="1" and resolves against card 0's
2767
- // mappings, causing tags from card 1+ to be missing or wrong.
2768
- let carouselSlotOffset = 0;
2847
+ // Each card's variables live under their own getCarouselVarMapKey(cardIndex, name) slot, so
2848
+ // no cross-card slot-offset bookkeeping is needed (or safe two cards' variables never share
2849
+ // a key, so there is nothing to accidentally collide).
2769
2850
  rcsForTest = {
2770
2851
  ...rcsForTest,
2771
2852
  rcsContent: {
2772
2853
  ...rcsForTest.rcsContent,
2773
- cardContent: cardContent.map((card) => {
2854
+ cardContent: cardContent.map((card, cardIndex) => {
2774
2855
  const rawTitle = card.title || '';
2775
2856
  const rawDesc = card.description || '';
2776
- const titleVarCount = (rawTitle.match(rcsVarRegex) || []).length;
2777
- const descVarCount = (rawDesc.match(rcsVarRegex) || []).length;
2778
- const resolvedTitle = resolveTemplateWithMap(rawTitle, carouselSlotOffset);
2779
- const resolvedDesc = resolveTemplateWithMap(rawDesc, carouselSlotOffset + titleVarCount);
2780
- carouselSlotOffset += titleVarCount + descVarCount;
2857
+ const resolvedTitle = resolveCarouselTemplateWithMap(rawTitle, cardIndex);
2858
+ const resolvedDesc = resolveCarouselTemplateWithMap(rawDesc, cardIndex);
2781
2859
  return { ...card, title: resolvedTitle, description: resolvedDesc };
2782
2860
  }),
2783
2861
  },
2784
2862
  };
2785
2863
  } else if (isSlotMappingModeForPreview) {
2786
2864
  // Standalone card: coalesce cardVarMapped with the template's slot names so the preview
2787
- // API receives a correctly-keyed var map for non-carousel templates.
2865
+ // API receives a correctly-keyed var map for non-carousel templates. Also resolve the
2866
+ // {{N}} slot tokens in title/description themselves (mirrors the carousel branch above) —
2867
+ // otherwise prepareTagExtractionPayload/getRcsPrimaryTagExtractionText send the raw "{{1}}"
2868
+ // token as template text, which the extract-tags API doesn't recognize, so the
2869
+ // synthetic-tag fallback names the row "1" instead of the real tag.
2788
2870
  const fullCardVarMapped = coalesceCardVarMappedToTemplate(
2789
2871
  pickRcsCardVarMappedEntries(cardVarMapped),
2790
2872
  templateTitle,
2791
2873
  templateDesc,
2792
2874
  rcsVarRegex,
2793
2875
  );
2876
+ const rawStandaloneTitle = cardContent[0].title || '';
2877
+ const rawStandaloneDesc = cardContent[0].description || '';
2878
+ const standaloneTitleVarCount = (rawStandaloneTitle.match(rcsVarRegex) || []).length;
2879
+ const resolvedStandaloneTitle = resolveTemplateWithMap(rawStandaloneTitle, 0);
2880
+ const resolvedStandaloneDesc = resolveTemplateWithMap(rawStandaloneDesc, standaloneTitleVarCount);
2794
2881
  rcsForTest = {
2795
2882
  ...rcsForTest,
2796
2883
  rcsContent: {
2797
2884
  ...rcsForTest.rcsContent,
2798
2885
  cardContent: [
2799
- { ...cardContent[0], cardVarMapped: fullCardVarMapped },
2886
+ {
2887
+ ...cardContent[0],
2888
+ title: resolvedStandaloneTitle,
2889
+ description: resolvedStandaloneDesc,
2890
+ cardVarMapped: fullCardVarMapped,
2891
+ },
2800
2892
  ...cardContent.slice(1),
2801
2893
  ],
2802
2894
  },
@@ -2812,16 +2904,23 @@ const onTitleAddVar = () => {
2812
2904
  },
2813
2905
  },
2814
2906
  };
2815
- // Use the smsFallbackTemplateConfigs already built by createPayload() / buildSmsFallBackContentForPayload —
2816
- // it merges the API baseline (templateId, registeredSenderIds, templateName, etc.) with local
2817
- // smsFallbackData state. Reading raw smsFallbackData here would miss the API-sourced smsTemplateId
2818
- // in campaign mode. This also keeps templateName (and all other fields) in sync with the approval
2819
- // payload automatically.
2820
- const smsFallbackTcFromPayload = rcs?.rcsContent?.smsFallBackContent?.templateConfigs;
2821
- if (smsFallbackTcFromPayload && typeof smsFallbackTcFromPayload === 'object') {
2907
+
2908
+ // buildSmsFallBackContentForPayload nests registeredSenderIds under smsFallBackContent.templateConfigs
2909
+ // only for full mode, or for campaign mode when isDltCampaign (smsRegister === 'DLT') is true — but it
2910
+ // always sets the flat smsFallBackContent.registeredSenderIds whenever the fallback template has them.
2911
+ // Extract via both so Test & Preview's DLT sender-id filter still sees real ids (and actually filters)
2912
+ // even when isDltCampaign was false at payload-build time.
2913
+ const smsFallbackRecordForTest = rcs?.rcsContent?.smsFallBackContent;
2914
+ const smsFallbackTcFromPayload = smsFallbackRecordForTest?.templateConfigs;
2915
+ const registeredSenderIdsForTest = extractRegisteredSenderIdsFromSmsFallbackRecord(smsFallbackRecordForTest);
2916
+ const hasRegisteredSenderIdsForTest =
2917
+ Array.isArray(registeredSenderIdsForTest) && registeredSenderIdsForTest.length > 0;
2918
+ if ((smsFallbackTcFromPayload && typeof smsFallbackTcFromPayload === 'object') || hasRegisteredSenderIdsForTest) {
2822
2919
  out.templateConfigs = {
2823
- ...smsFallbackTcFromPayload,
2824
- traiDltEnabled: isTraiDLTEnable(isFullMode, smsRegister),
2920
+ ...(smsFallbackTcFromPayload && typeof smsFallbackTcFromPayload === 'object' ? smsFallbackTcFromPayload : {}),
2921
+ ...(hasRegisteredSenderIdsForTest && { registeredSenderIds: registeredSenderIdsForTest }),
2922
+ // Real registered sender IDs mean filtering should happen regardless of the smsRegister heuristic.
2923
+ traiDltEnabled: isTraiDLTEnable(isFullMode, smsRegister) || hasRegisteredSenderIdsForTest,
2825
2924
  };
2826
2925
  }
2827
2926
  return out;
@@ -2967,12 +3066,12 @@ const onTitleAddVar = () => {
2967
3066
  ['title', 'description'].some((field) => !(card?.[field] || '').trim())
2968
3067
  );
2969
3068
  if (hasEmptyField) return true;
2970
- const unfilledVar = (carouselData || []).some((card) =>
3069
+ const unfilledVar = (carouselData || []).some((card, cardIndex) =>
2971
3070
  ['title', 'description'].some((field) => {
2972
3071
  const tokens = splitTemplateVarStringRcs(card?.[field] || '').filter((token) => rcsVarTestRegex.test(token));
2973
3072
  return tokens.some((token) => {
2974
3073
  const name = token.replace(RCS_STRIP_MUSTACHE_DELIMITERS_REGEX, '');
2975
- const slotValue = cardVarMapped?.[name];
3074
+ const slotValue = cardVarMapped?.[getCarouselVarMapKey(cardIndex, name)];
2976
3075
  return slotValue == null || String(slotValue).trim() === '';
2977
3076
  });
2978
3077
  })
@@ -217,6 +217,24 @@ export function buildSmsFallBackContentForPayload({
217
217
  ? tcSibling.header
218
218
  : null;
219
219
  const hasRegisteredSenderIds = Array.isArray(registeredSenderIdsForPayload);
220
+ // `rcsSmsFallbackVarMapped` (root of smsFallBackContent) is a custom field that
221
+ // normalizeRcsMessageContentForApi strips before the real API call (see comment below) — it
222
+ // only survives for same-session local re-hydration. templateConfigs.templateVariableMapping is
223
+ // the field the backend actually persists (same shape TRAI/DLT registration uses), so mirror the
224
+ // var map there too — otherwise slot values saved through campaigns/library mode are lost on reopen.
225
+ const varMapForTemplateConfigs =
226
+ smsFallbackForPayload?.[RCS_SMS_FALLBACK_VAR_MAPPED_PROP]
227
+ || mergedFallback[RCS_SMS_FALLBACK_VAR_MAPPED_PROP];
228
+ const templateVariableMappingForPayload =
229
+ varMapForTemplateConfigs && typeof varMapForTemplateConfigs === 'object'
230
+ ? Object.entries(varMapForTemplateConfigs).reduce((acc, [slotKey, slotValue], index) => {
231
+ if (slotValue == null || slotValue === '') return acc;
232
+ acc[slotKey] = { data: slotValue, count: index + 1 };
233
+ return acc;
234
+ }, {})
235
+ : null;
236
+ const hasTemplateVariableMapping =
237
+ templateVariableMappingForPayload && Object.keys(templateVariableMappingForPayload).length > 0;
220
238
  const smsFallbackTemplateConfigs =
221
239
  smsFallbackTemplateId || hasRegisteredSenderIds
222
240
  ? {
@@ -228,6 +246,9 @@ export function buildSmsFallBackContentForPayload({
228
246
  ...(hasRegisteredSenderIds && {
229
247
  registeredSenderIds: registeredSenderIdsForPayload,
230
248
  }),
249
+ ...(hasTemplateVariableMapping && {
250
+ templateVariableMapping: templateVariableMappingForPayload,
251
+ }),
231
252
  }
232
253
  : null;
233
254
  const isDltCampaign = !isFullMode && isTraiDLTEnable(isFullMode, smsRegister);
@@ -295,17 +316,37 @@ export function resolveSmsFallbackHydrationFromDetails(details) {
295
316
  const fromNested = Array.isArray(updatedEditor)
296
317
  ? updatedEditor.join('')
297
318
  : (typeof updatedEditor === 'string' ? updatedEditor : (smsEditor || ''));
298
- const fallbackMessage = smsFallbackContent.smsContent
319
+ const templateConfigsRecord =
320
+ smsFallbackContent.templateConfigs && typeof smsFallbackContent.templateConfigs === 'object'
321
+ ? smsFallbackContent.templateConfigs
322
+ : {};
323
+ // For DLT/TRAI fallbacks, smsContent/smsTemplateContent/message are the send-ready text —
324
+ // {#var#} slots are already baked into a literal value there (required to match the
325
+ // TRAI-approved template). Only templateConfigs.template keeps {#var#} intact, so prefer it
326
+ // here — otherwise reopening the editor can never re-render that slot as its own box again.
327
+ const rawDltTemplate =
328
+ typeof templateConfigsRecord.template === 'string' && templateConfigsRecord.template.trim() !== ''
329
+ ? templateConfigsRecord.template
330
+ : '';
331
+ const fallbackMessage = rawDltTemplate
332
+ || smsFallbackContent.smsContent
299
333
  || smsFallbackContent.smsTemplateContent
300
334
  || smsFallbackContent.message
301
335
  || fromNested
302
336
  || '';
303
- const varMappedFromPayload = smsFallbackContent[RCS_SMS_FALLBACK_VAR_MAPPED_PROP] || {};
337
+ const varMapFromTemplateVariableMapping =
338
+ templateConfigsRecord.templateVariableMapping && typeof templateConfigsRecord.templateVariableMapping === 'object'
339
+ ? Object.entries(templateConfigsRecord.templateVariableMapping).reduce((acc, [slotKey, slotEntry]) => {
340
+ const slotValue = slotEntry && typeof slotEntry === 'object' ? slotEntry.data : slotEntry;
341
+ if (slotValue != null) acc[slotKey] = slotValue;
342
+ return acc;
343
+ }, {})
344
+ : null;
345
+ const varMappedFromPayload = {
346
+ ...(varMapFromTemplateVariableMapping || {}),
347
+ ...(smsFallbackContent[RCS_SMS_FALLBACK_VAR_MAPPED_PROP] || {}),
348
+ };
304
349
  const hasVarMapped = Object.keys(varMappedFromPayload).length > 0;
305
- const templateConfigsRecord =
306
- smsFallbackContent.templateConfigs && typeof smsFallbackContent.templateConfigs === 'object'
307
- ? smsFallbackContent.templateConfigs
308
- : {};
309
350
  const smsFallbackTemplateName =
310
351
  smsFallbackContent.templateName
311
352
  || smsFallbackContent.smsTemplateName