@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.
@@ -78,8 +78,13 @@ export const rcsVarTestRegex = /^\{\{[^}]+\}\}$/;
78
78
  export const RCS_NUMERIC_VAR_TOKEN_REGEX = /\{\{(\d+)\}\}/g;
79
79
  /** `cardVarMapped` slot keys that are numeric only (legacy ordering). */
80
80
  export const RCS_NUMERIC_VAR_NAME_REGEX = /^\d+$/;
81
- /** Semantic Liquid-style keys on RCS `cardVarMapped` (same class as `{{…}}` inner names in the editor). */
82
- export const RCS_CARD_VAR_MAPPED_SEMANTIC_KEY_REGEX = /^[\w.]+$/;
81
+ /**
82
+ * Semantic Liquid-style keys on RCS `cardVarMapped` (same class as `{{…}}` inner names in the
83
+ * editor). Carousel slots are now keyed by global position (see `computeCarouselVarSlotKeys`), a
84
+ * plain number, so this only needs to match bare semantic names — the optional `cN::` prefix is
85
+ * kept solely so `pickRcsCardVarMappedEntries` doesn't drop legacy scoped keys from older payloads.
86
+ */
87
+ export const RCS_CARD_VAR_MAPPED_SEMANTIC_KEY_REGEX = /^(?:c\d+::)?[\w.()]+$/;
83
88
  /** Escape all special RegExp characters in an arbitrary string before using it in `new RegExp(...)`. */
84
89
  export const REGEX_SPECIAL_CHARS_ESCAPE_PATTERN = /[-/\\^$*+?.()|[\]{}]/g;
85
90
  /** Entire string is wrapped in `{{…}}` mustache delimiters (allows any content including `}`). */
@@ -175,6 +175,7 @@ import {
175
175
  getCarouselDescriptionCharacterCount,
176
176
  buildCarouselCardsForPreview as buildCarouselCardsForPreviewUtil,
177
177
  buildCarouselCardContentForPayload,
178
+ computeCarouselVarSlotKeys,
178
179
  } from './carouselUtils';
179
180
  import CarouselDimensionSelection from './components/CarouselDimensionSelection';
180
181
  import CarouselCard from './components/CarouselCard';
@@ -270,7 +271,12 @@ export const Rcs = (props) => {
270
271
  const [carouselErrors, setCarouselErrors] = useState([]); // [{ title: string|false, description: string|false }]
271
272
  const [activeCarouselIndex, setActiveCarouselIndex] = useState('0');
272
273
  const [carouselResetNonce, setCarouselResetNonce] = useState(0);
273
- const [carouselFocusedVarId, setCarouselFocusedVarId] = useState('');
274
+ // Title and description each track their own last-focused var slot (mirrors titleTextAreaId/
275
+ // descTextAreaId for the single-card flow) — a single shared id would let a click on the
276
+ // "Add Labels" picker next to one field silently apply to whichever field was focused last,
277
+ // if the user hadn't just clicked into that field's own var slot.
278
+ const [carouselTitleFocusedVarId, setCarouselTitleFocusedVarId] = useState('');
279
+ const [carouselDescFocusedVarId, setCarouselDescFocusedVarId] = useState('');
274
280
  const [imageError, setImageError] = useState(null);
275
281
  const [templateTitleError, setTemplateTitleError] = useState(false);
276
282
  const [cardVarMapped, setCardVarMapped] = useState({});
@@ -596,7 +602,8 @@ export const Rcs = (props) => {
596
602
  }}
597
603
  onTitleAddVar={() => appendVarToCarouselField(index, 'title')}
598
604
  onDescriptionAddVar={() => appendVarToCarouselField(index, 'description')}
599
- onTagSelect={onCarouselTagSelect}
605
+ onTitleTagSelect={onCarouselTitleTagSelect}
606
+ onDescTagSelect={onCarouselDescTagSelect}
600
607
  onContextChange={handleOnTagsContextChange}
601
608
  renderEditMessage={renderCarouselEditMessage}
602
609
  onFieldChange={(fieldName, value) => handleCarouselValueChange(index, [{ fieldName, value }])}
@@ -655,7 +662,8 @@ export const Rcs = (props) => {
655
662
  onChange={(key) => {
656
663
  setActiveCarouselIndex(`${key}`);
657
664
  // reset focused var when switching cards (as per requirement)
658
- setCarouselFocusedVarId('');
665
+ setCarouselTitleFocusedVarId('');
666
+ setCarouselDescFocusedVarId('');
659
667
  }}
660
668
  panes={getCarouselTabPanes()}
661
669
  />
@@ -775,7 +783,7 @@ export const Rcs = (props) => {
775
783
  ['title', 'description'].forEach((field) => {
776
784
  const templateStr = card?.[field] || '';
777
785
  if (!templateStr) return;
778
- const resolved = resolveTemplateWithMap(templateStr);
786
+ const resolved = resolveCarouselTemplateWithMap(templateStr, idx, field === 'description');
779
787
  if (!resolved) {
780
788
  updateCarouselErrors(idx, { [field]: false });
781
789
  return;
@@ -867,6 +875,30 @@ export const Rcs = (props) => {
867
875
  }).join('');
868
876
  };
869
877
 
878
+ /**
879
+ * Carousel resolve: each variable slot's key comes from `computeCarouselVarSlotKeys` (title,
880
+ * then description, per card; same name within a card shares one key; different cards never
881
+ * collide). A slot's key never depends on which tag currently fills it.
882
+ */
883
+ const resolveCarouselTemplateWithMap = (str = '', cardIndex, isDescField = false) => {
884
+ if (!str) return '';
885
+ const arr = splitTemplateVarStringRcs(str);
886
+ const fieldKeys = (isDescField
887
+ ? computeCarouselVarSlotKeys(carouselData, rcsVarRegex)[cardIndex]?.description
888
+ : computeCarouselVarSlotKeys(carouselData, rcsVarRegex)[cardIndex]?.title) ?? [];
889
+ let varOrdinal = 0;
890
+ return arr.map((elem) => {
891
+ if (rcsVarTestRegex.test(elem)) {
892
+ const slotKey = fieldKeys[varOrdinal];
893
+ varOrdinal += 1;
894
+ const slotValue = cardVarMapped?.[slotKey];
895
+ if (isNil(slotValue) || String(slotValue)?.trim?.() === '') return elem;
896
+ return String(slotValue);
897
+ }
898
+ return elem;
899
+ }).join('');
900
+ };
901
+
870
902
  const buildCarouselCardsForPreview = (cards = []) =>
871
903
  buildCarouselCardsForPreviewUtil(cards, { isFullMode, rcsVarRegex, resolveTemplateWithMap });
872
904
 
@@ -1214,6 +1246,19 @@ export const Rcs = (props) => {
1214
1246
  setSelectedCarousel(`${firstHeight}_${cardWidth}`);
1215
1247
  setActiveCarouselIndex('0');
1216
1248
 
1249
+ if (cards.length > 1) {
1250
+ // Each card's own `cardVarMapped` is already keyed by global var position (see
1251
+ // computeCarouselVarSlotKeys) — a slot's key is assigned once, from where its token
1252
+ // sits in the template text, and never depends on which tag fills it. So merging every
1253
+ // card's own map directly is correct and collision-free: no per-card coalescing,
1254
+ // normalizing, or re-scoping needed (unlike the old name-based scoped-key scheme).
1255
+ setCardVarMapped((previousVarMap) => cards.reduce((merged, c = {}) => {
1256
+ const rawCardMap = c?.cardVarMapped;
1257
+ if (rawCardMap == null || typeof rawCardMap !== 'object') return merged;
1258
+ return { ...merged, ...rawCardMap };
1259
+ }, { ...(previousVarMap || {}) }));
1260
+ }
1261
+
1217
1262
  const hydratedCards = cards.map((c = {}, idx) => {
1218
1263
  const mediaType = c.mediaType;
1219
1264
  const media = c.media || {};
@@ -1529,23 +1574,51 @@ export const Rcs = (props) => {
1529
1574
 
1530
1575
  const onDescTagSelect = (tagName) => onTagSelect(tagName, descTextAreaId, RCS_TAG_AREA_FIELD_DESC);
1531
1576
 
1532
- const onCarouselTagSelect = (data) => {
1533
- if (!carouselFocusedVarId) return;
1534
- const sep = carouselFocusedVarId.lastIndexOf('_');
1535
- if (sep === -1) return;
1536
- const token = carouselFocusedVarId.slice(0, sep);
1537
- const variableName = getVarNameFromToken(token);
1538
- if (!variableName) return;
1539
- setCardVarMapped((prev) => {
1540
- const base = (prev?.[variableName] ?? '').toString();
1541
- const nextVal = `${base}{{${data}}}`;
1542
- return {
1543
- ...(prev || {}),
1544
- [variableName]: nextVal,
1545
- };
1546
- });
1577
+ /** Field-local var ordinal (0-based, vars only) for a `{{tok}}_segIdx` composite id — scoped to
1578
+ * a single field's own template string (title and description are tracked independently). */
1579
+ const getCarouselFieldLocalVarOrdinal = (compositeId, fieldTemplateStr) => {
1580
+ const segments = splitTemplateVarStringRcs(fieldTemplateStr ?? '');
1581
+ let varOrdinal = 0;
1582
+ for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex += 1) {
1583
+ if (rcsVarTestRegex.test(segments[segmentIndex])) {
1584
+ if (`${segments[segmentIndex]}_${segmentIndex}` === compositeId) return varOrdinal;
1585
+ varOrdinal += 1;
1586
+ }
1587
+ }
1588
+ return null;
1589
+ };
1590
+
1591
+ // The card's placeholder (numeric {{N}} or a bare semantic token like {{first_name}}) is locked
1592
+ // here — picking a tag only supplies/replaces the *value* behind that slot in cardVarMapped, it
1593
+ // never rewrites the card's title/description text. `focusedVarId` is passed in by the caller
1594
+ // (title vs description each track their own last-focused slot, like titleTextAreaId/
1595
+ // descTextAreaId) so a click on one field's "Add Labels" can never apply to the other field's
1596
+ // last-focused slot. The slot's key is its global position across the whole carousel — assigned
1597
+ // once from where its token sits in the template text, never from whatever tag currently fills
1598
+ // it — so swapping tags never changes the key and never collides with another card's slot.
1599
+ const onCarouselTagSelect = (data, focusedVarId, isDescField) => {
1600
+ if (!focusedVarId) return;
1601
+ const activeIdx = parseInt(activeCarouselIndex, 10);
1602
+ const cardIndex = isNaN(activeIdx) ? 0 : activeIdx;
1603
+ const fieldTemplateStr = (isDescField
1604
+ ? carouselData?.[cardIndex]?.description
1605
+ : carouselData?.[cardIndex]?.title) || '';
1606
+ const localVarOrdinal = getCarouselFieldLocalVarOrdinal(focusedVarId, fieldTemplateStr);
1607
+ if (localVarOrdinal == null) return;
1608
+ const slotKeys = computeCarouselVarSlotKeys(carouselData, rcsVarRegex)[cardIndex];
1609
+ const slotKey = (isDescField ? slotKeys?.description : slotKeys?.title)?.[localVarOrdinal];
1610
+ if (!slotKey) return;
1611
+
1612
+ setCardVarMapped((prev) => ({
1613
+ ...(prev || {}),
1614
+ [slotKey]: `{{${data}}}`,
1615
+ }));
1547
1616
  };
1548
1617
 
1618
+ const onCarouselTitleTagSelect = (data) => onCarouselTagSelect(data, carouselTitleFocusedVarId, false);
1619
+
1620
+ const onCarouselDescTagSelect = (data) => onCarouselTagSelect(data, carouselDescFocusedVarId, true);
1621
+
1549
1622
  //removing optout tag for rcs
1550
1623
  const getRcsTags = () => {
1551
1624
  const tempTags = cloneDeep(tags);
@@ -1631,7 +1704,7 @@ export const Rcs = (props) => {
1631
1704
  key={`${elem}_${index}`}
1632
1705
  placeholder={`enter the value for ${elem}`}
1633
1706
  autosize={{ minRows: 1, maxRows: 3 }}
1634
- onChange={e => textAreaValueChange(e, type)}
1707
+ onChange={e => textAreaValueChange(e)}
1635
1708
  value={textAreaValue(index, type)}
1636
1709
  onFocus={(e) => setTextAreaId(e, type)}
1637
1710
  />
@@ -1844,26 +1917,39 @@ const onTitleAddVar = () => {
1844
1917
  return "";
1845
1918
  };
1846
1919
 
1847
- // Carousel: render variable-value editor for a given template string (title/description).
1848
- // This matches rich-card/text edit behavior: static pieces are read-only, variable tokens are editable.
1849
- const renderCarouselEditMessage = (templateStr) => {
1920
+ // Carousel: render variable-value editor for a given template string (title/description) belonging
1921
+ // to `cardIndex`. This matches rich-card/text edit behavior: static pieces are read-only, variable
1922
+ // tokens are editable. Each slot's key comes from `computeCarouselVarSlotKeys` — stable regardless
1923
+ // of which tag fills it, and independent even from another occurrence of the same tag name.
1924
+ const renderCarouselEditMessage = (templateStr, cardIndex, fieldType) => {
1850
1925
  const renderArray = [];
1851
1926
  const templateArr = splitTemplateVarString(templateStr);
1927
+ const isDescField = fieldType === RCS_TAG_AREA_FIELD_DESC;
1928
+ const slotKeysForCard = computeCarouselVarSlotKeys(carouselData, rcsVarRegex)[cardIndex];
1929
+ const fieldKeys = (isDescField ? slotKeysForCard?.description : slotKeysForCard?.title) ?? [];
1930
+ let varOrdinal = 0;
1852
1931
  if (templateArr?.length) {
1853
1932
  templateArr.forEach((elem, index) => {
1854
1933
  if (rcsVarTestRegex.test(elem)) {
1855
1934
  const varName = getVarNameFromToken(elem);
1935
+ const mapKey = fieldKeys[varOrdinal];
1936
+ varOrdinal += 1;
1937
+ const computedValue = varName ? ((cardVarMapped?.[mapKey] ?? '').toString()) : '';
1856
1938
  renderArray.push(
1857
1939
  <div key={`${elem}_${index}`} className="var-segment-message-editor__var-slot">
1858
1940
  <TextArea
1859
1941
  id={`${elem}_${index}`}
1860
1942
  placeholder={`enter the value for ${elem}`}
1861
1943
  autosize={{ minRows: 1, maxRows: 3 }}
1862
- onChange={(e) => textAreaValueChange(e, TITLE_TEXT)}
1863
- value={varName ? ((cardVarMapped?.[varName] ?? '').toString()) : ''}
1944
+ onChange={(e) => carouselTextAreaValueChange(e, cardIndex, fieldType)}
1945
+ value={computedValue}
1864
1946
  onFocus={(e) => {
1865
1947
  const id = e?.target?.id || e?.currentTarget?.id || '';
1866
- setCarouselFocusedVarId(id);
1948
+ if (fieldType === RCS_TAG_AREA_FIELD_DESC) {
1949
+ setCarouselDescFocusedVarId(id);
1950
+ } else {
1951
+ setCarouselTitleFocusedVarId(id);
1952
+ }
1867
1953
  }}
1868
1954
  />
1869
1955
  </div>
@@ -1884,7 +1970,7 @@ const onTitleAddVar = () => {
1884
1970
  return <CapRow className="rcs-edit-template-message-input">{renderArray}</CapRow>;
1885
1971
  };
1886
1972
 
1887
- const textAreaValueChange = (e, type) => {
1973
+ const textAreaValueChange = (e) => {
1888
1974
  const value = e?.target?.value ?? '';
1889
1975
  const id = e?.target?.id || e?.currentTarget?.id || '';
1890
1976
  if (!id) return;
@@ -1895,13 +1981,38 @@ const onTitleAddVar = () => {
1895
1981
  const variableName = getVarNameFromToken(token);
1896
1982
 
1897
1983
  if (variableName) {
1984
+ const nextVal = isInvalidValue ? "" : value;
1898
1985
  setCardVarMapped((prev) => ({
1899
1986
  ...prev,
1900
- [variableName]: isInvalidValue ? "" : value,
1987
+ [variableName]: nextVal,
1901
1988
  }));
1902
1989
  }
1903
1990
  };
1904
1991
 
1992
+ // Carousel: typing directly into a slot's value box (as opposed to picking a tag) — same global
1993
+ // positional key as onCarouselTagSelect/renderCarouselEditMessage, so both ways of filling a
1994
+ // slot land on the identical key.
1995
+ const carouselTextAreaValueChange = (e, cardIndex, fieldType) => {
1996
+ const value = e?.target?.value ?? '';
1997
+ const id = e?.target?.id || e?.currentTarget?.id || '';
1998
+ if (!id) return;
1999
+ const isDescField = fieldType === RCS_TAG_AREA_FIELD_DESC;
2000
+ const fieldTemplateStr = (isDescField
2001
+ ? carouselData?.[cardIndex]?.description
2002
+ : carouselData?.[cardIndex]?.title) || '';
2003
+ const localVarOrdinal = getCarouselFieldLocalVarOrdinal(id, fieldTemplateStr);
2004
+ if (localVarOrdinal == null) return;
2005
+ const slotKeys = computeCarouselVarSlotKeys(carouselData, rcsVarRegex)[cardIndex];
2006
+ const mapKey = (isDescField ? slotKeys?.description : slotKeys?.title)?.[localVarOrdinal];
2007
+ if (!mapKey) return;
2008
+ const isInvalidValue = value?.trim() === "";
2009
+ const nextVal = isInvalidValue ? "" : value;
2010
+ setCardVarMapped((prev) => ({
2011
+ ...prev,
2012
+ [mapKey]: nextVal,
2013
+ }));
2014
+ };
2015
+
1905
2016
  const setTextAreaId = (e, type) => {
1906
2017
  // VarSegmentMessageEditor calls onFocus(id) with a plain string; DOM events
1907
2018
  // have an `.target.id` shape. Support both.
@@ -2749,11 +2860,8 @@ const onTitleAddVar = () => {
2749
2860
  // user mapped via cardVarMapped. Run this for ALL modes (create, edit, consumer) so that:
2750
2861
  // - buildRcsTestMessagePayload sends real Capillary tag names to the test API, and
2751
2862
  // - prepareTagExtractionPayload can extract tag metadata from the card content.
2752
- // Track cumulative slotOffset across cards: each card's title/description vars occupy
2753
- // sequential global slot indices ({{1}},{{2}} in card 0; {{3}},{{4}} in card 1, …).
2754
- // Without the offset, every card restarts at slotKey="1" and resolves against card 0's
2755
- // mappings, causing tags from card 1+ to be missing or wrong.
2756
- let carouselSlotOffset = 0;
2863
+ // Each variable slot's key comes from computeCarouselVarSlotKeys, so two cards' variables
2864
+ // never share a key even when they pick the same tag name for their own slot.
2757
2865
  rcsForTest = {
2758
2866
  ...rcsForTest,
2759
2867
  rcsContent: {
@@ -2761,11 +2869,8 @@ const onTitleAddVar = () => {
2761
2869
  cardContent: cardContent.map((card) => {
2762
2870
  const rawTitle = card.title || '';
2763
2871
  const rawDesc = card.description || '';
2764
- const titleVarCount = (rawTitle.match(rcsVarRegex) || []).length;
2765
- const descVarCount = (rawDesc.match(rcsVarRegex) || []).length;
2766
- const resolvedTitle = resolveTemplateWithMap(rawTitle, carouselSlotOffset);
2767
- const resolvedDesc = resolveTemplateWithMap(rawDesc, carouselSlotOffset + titleVarCount);
2768
- carouselSlotOffset += titleVarCount + descVarCount;
2872
+ const resolvedTitle = resolveCarouselTemplateWithMap(rawTitle, cardIndex, false);
2873
+ const resolvedDesc = resolveCarouselTemplateWithMap(rawDesc, cardIndex, true);
2769
2874
  return { ...card, title: resolvedTitle, description: resolvedDesc };
2770
2875
  }),
2771
2876
  },
@@ -2955,12 +3060,16 @@ const onTitleAddVar = () => {
2955
3060
  ['title', 'description'].some((field) => !(card?.[field] || '').trim())
2956
3061
  );
2957
3062
  if (hasEmptyField) return true;
2958
- const unfilledVar = (carouselData || []).some((card) =>
3063
+ const slotKeysByCard = computeCarouselVarSlotKeys(carouselData, rcsVarRegex);
3064
+ const unfilledVar = (carouselData || []).some((card, cardIndex) =>
2959
3065
  ['title', 'description'].some((field) => {
3066
+ const isDescField = field === 'description';
2960
3067
  const tokens = splitTemplateVarStringRcs(card?.[field] || '').filter((token) => rcsVarTestRegex.test(token));
2961
- return tokens.some((token) => {
2962
- const name = token.replace(RCS_STRIP_MUSTACHE_DELIMITERS_REGEX, '');
2963
- const slotValue = cardVarMapped?.[name];
3068
+ if (tokens.length === 0) return false;
3069
+ const fieldKeys = (isDescField ? slotKeysByCard[cardIndex]?.description : slotKeysByCard[cardIndex]?.title) ?? [];
3070
+ return tokens.some((token, localVarOrdinal) => {
3071
+ const slotKey = fieldKeys[localVarOrdinal];
3072
+ const slotValue = cardVarMapped?.[slotKey];
2964
3073
  return slotValue == null || String(slotValue).trim() === '';
2965
3074
  });
2966
3075
  })
@@ -3053,20 +3162,6 @@ const onTitleAddVar = () => {
3053
3162
  return true;
3054
3163
  }
3055
3164
  }
3056
- // Mirror WhatsApp's isEditDoneDisabled: block Done while a CTA button's dynamic URL still
3057
- // holds the unresolved {{1}} placeholder (no real personalization tag assigned yet).
3058
- const hasUnresolvedDynamicCtaUrl = isCarouselType
3059
- ? (carouselData || []).some((card) =>
3060
- (card?.suggestions || []).some((suggestion) =>
3061
- suggestion?.type === CTA && (suggestion?.url || '').includes('{{1}}')
3062
- )
3063
- )
3064
- : suggestions.some((suggestion) =>
3065
- suggestion?.type === CTA && (suggestion?.url || '').includes('{{1}}')
3066
- );
3067
- if (hasUnresolvedDynamicCtaUrl) {
3068
- return true;
3069
- }
3070
3165
  if (templateTitleError || templateDescError) {
3071
3166
  return true;
3072
3167
  }
@@ -396,6 +396,7 @@ export function syncCardVarMappedSemanticsFromSlots(
396
396
  templateDesc,
397
397
  rcsVarRegex,
398
398
  ) {
399
+ console.log('syncCardVarMappedSemanticsFromSlots', cardVarMappedInput, templateTitle, templateDesc);
399
400
  const cardVarMappedSynced =
400
401
  cardVarMappedInput != null && typeof cardVarMappedInput === 'object'
401
402
  ? { ...cardVarMappedInput }
@@ -408,11 +409,48 @@ export function syncCardVarMappedSemanticsFromSlots(
408
409
  templateVarTokens.forEach((token, slotIndexZeroBased) => {
409
410
  const semanticVarName = getVarNameFromToken(token);
410
411
  if (!semanticVarName) return;
411
- const numericSlotKey = String(slotIndexZeroBased + 1);
412
- const semanticValueTrimmed = String(cardVarMappedSynced[semanticVarName] ?? '').trim();
413
- const numericSlotValueTrimmed = String(cardVarMappedSynced[numericSlotKey] ?? '').trim();
414
- if (!semanticValueTrimmed && numericSlotValueTrimmed) {
415
- cardVarMappedSynced[semanticVarName] = cardVarMappedSynced[numericSlotKey];
412
+
413
+ if (!RCS_NUMERIC_VAR_NAME_REGEX.test(semanticVarName)) {
414
+ // A numeric slot may already self-reference this exact tag (e.g. "4" -> "{{last_name}}") when
415
+ // the template's own numeric placeholder has since been resolved to this tag in the text —
416
+ // that numeric key is the source of truth, not this token's position.
417
+ const mustacheSelfReference = `{{${semanticVarName}}}`;
418
+ const existingNumericKeyForSemantic = Object.keys(cardVarMappedSynced).find((key) => (
419
+ RCS_NUMERIC_VAR_NAME_REGEX.test(key) && cardVarMappedSynced[key] === mustacheSelfReference
420
+ ));
421
+ if (existingNumericKeyForSemantic) {
422
+ const semanticValueTrimmed = String(cardVarMappedSynced[semanticVarName] ?? '').trim();
423
+ const numericValueTrimmed = String(cardVarMappedSynced[existingNumericKeyForSemantic] ?? '').trim();
424
+ if (!semanticValueTrimmed && numericValueTrimmed) {
425
+ cardVarMappedSynced[semanticVarName] = cardVarMappedSynced[existingNumericKeyForSemantic];
426
+ }
427
+ return;
428
+ }
429
+
430
+ // Legacy: template embeds the semantic token directly ({{user_name}}), but older payloads
431
+ // stored the resolved value under a position-based numeric key ("1", "2", …).
432
+ const numericSlotKey = String(slotIndexZeroBased + 1);
433
+ const semanticValueTrimmed = String(cardVarMappedSynced[semanticVarName] ?? '').trim();
434
+ const numericSlotValueTrimmed = String(cardVarMappedSynced[numericSlotKey] ?? '').trim();
435
+ if (!semanticValueTrimmed && numericSlotValueTrimmed) {
436
+ cardVarMappedSynced[semanticVarName] = cardVarMappedSynced[numericSlotKey];
437
+ }
438
+ return;
439
+ }
440
+
441
+ // Carousel numeric placeholder ({{3}}, {{4}}, …): the slot key IS the token digit itself —
442
+ // numbers are allocated globally across the whole carousel (getNextCarouselVarToken), not
443
+ // re-numbered per card, so a position-based key here would target the wrong slot. Its value
444
+ // may be a mustache-wrapped tag reference (e.g. "3" -> "{{last_name}}") — mirror that onto the
445
+ // bare semantic key too so VarSegment editors keyed by tag name also prepopulate.
446
+ const slotValueTrimmed = String(cardVarMappedSynced[semanticVarName] ?? '').trim();
447
+ if (!slotValueTrimmed) return;
448
+ const mustacheInnerMatch = slotValueTrimmed.match(/^\{\{([^}]+)\}\}$/);
449
+ const innerSemanticName = mustacheInnerMatch?.[1]?.trim();
450
+ if (!innerSemanticName || innerSemanticName === semanticVarName) return;
451
+ const existingSemanticValue = String(cardVarMappedSynced[innerSemanticName] ?? '').trim();
452
+ if (!existingSemanticValue) {
453
+ cardVarMappedSynced[innerSemanticName] = cardVarMappedSynced[semanticVarName];
416
454
  }
417
455
  });
418
456
  return cardVarMappedSynced;
@@ -220,7 +220,8 @@ const baseProps = {
220
220
  onDescriptionChange: jest.fn(),
221
221
  onTitleAddVar: jest.fn(),
222
222
  onDescriptionAddVar: jest.fn(),
223
- onTagSelect: jest.fn(),
223
+ onTitleTagSelect: jest.fn(),
224
+ onDescTagSelect: jest.fn(),
224
225
  onContextChange: jest.fn(),
225
226
  renderEditMessage: (val) => <div data-testid="render-edit-message">{val}</div>,
226
227
  onFieldChange: jest.fn(),
@@ -588,8 +588,8 @@ describe('buildCarouselCardsForPreview', () => {
588
588
  expect(out[0].bodyText).toBe('Bye {{b}}');
589
589
  });
590
590
 
591
- it('resolves title/description through resolveTemplateWithMap and accumulates slot offset across cards', () => {
592
- const resolveTemplateWithMap = jest.fn((str, offset) => `R(${str}|${offset})`);
591
+ it('resolves title/description through resolveCarouselTemplateWithMap, scoped per card index and field', () => {
592
+ const resolveCarouselTemplateWithMap = jest.fn((str, cardIndex, isDescField) => `R(${str}|${cardIndex}|${isDescField})`);
593
593
  const out = buildCarouselCardsForPreview(
594
594
  [
595
595
  { title: 'Hi {{a}}', description: 'Bye {{b}} {{c}}' },
@@ -597,14 +597,14 @@ describe('buildCarouselCardsForPreview', () => {
597
597
  ],
598
598
  { isFullMode: false, rcsVarRegex, resolveTemplateWithMap },
599
599
  );
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);
604
- 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)');
600
+ expect(resolveCarouselTemplateWithMap).toHaveBeenNthCalledWith(1, 'Hi {{a}}', 0, false);
601
+ expect(resolveCarouselTemplateWithMap).toHaveBeenNthCalledWith(2, 'Bye {{b}} {{c}}', 0, true);
602
+ expect(resolveCarouselTemplateWithMap).toHaveBeenNthCalledWith(3, 'Solo {{d}}', 1, false);
603
+ expect(resolveCarouselTemplateWithMap).toHaveBeenNthCalledWith(4, 'none', 1, true);
604
+ expect(out[0].title).toBe('R(Hi {{a}}|0|false)');
605
+ expect(out[0].bodyText).toBe('R(Bye {{b}} {{c}}|0|true)');
606
+ expect(out[1].title).toBe('R(Solo {{d}}|1|false)');
607
+ expect(out[1].bodyText).toBe('R(none|1|true)');
608
608
  });
609
609
 
610
610
  it('defaults missing card fields, including a default {} for a missing array item', () => {
@@ -857,17 +857,17 @@ describe('buildCarouselCardContentForPayload', () => {
857
857
  expect(out[0].cardVarMapped).toBeUndefined();
858
858
  });
859
859
 
860
- it('builds cardVarMapped from title and description tokens, sanitizing values', () => {
860
+ it('builds cardVarMapped from title and description tokens, keyed by global position, sanitizing values', () => {
861
861
  const out = buildCarouselCardContentForPayload(
862
862
  [{ title: 'Hi {{name}}', description: 'Pts {{points}}', mediaType: RCS_MEDIA_TYPES.NONE }],
863
863
  {
864
864
  isSlotMappingMode: true,
865
- cardVarMapped: { name: 'Bob', points: '{{2}}' },
865
+ cardVarMapped: { 1: 'Bob', 2: '{{2}}' },
866
866
  selectedCarouselHeight: 'SHORT',
867
867
  rcsVarRegex,
868
868
  },
869
869
  );
870
- expect(out[0].cardVarMapped).toEqual({ name: 'Bob', points: '' });
870
+ expect(out[0].cardVarMapped).toEqual({ 1: 'Bob', 2: '' });
871
871
  });
872
872
 
873
873
  it('defaults an unmapped token value to empty string via sanitizeCardVarMappedValue', () => {
@@ -875,7 +875,7 @@ describe('buildCarouselCardContentForPayload', () => {
875
875
  [{ title: 'Hi {{missing}}', description: 'D', mediaType: RCS_MEDIA_TYPES.NONE }],
876
876
  { isSlotMappingMode: true, cardVarMapped: {}, selectedCarouselHeight: 'SHORT', rcsVarRegex },
877
877
  );
878
- expect(out[0].cardVarMapped).toEqual({ missing: '' });
878
+ expect(out[0].cardVarMapped).toEqual({ 1: '' });
879
879
  });
880
880
 
881
881
  it('handles a missing cardVarMapped object entirely', () => {
@@ -883,7 +883,7 @@ describe('buildCarouselCardContentForPayload', () => {
883
883
  [{ title: 'Hi {{name}}', description: 'D', mediaType: RCS_MEDIA_TYPES.NONE }],
884
884
  { isSlotMappingMode: true, cardVarMapped: undefined, selectedCarouselHeight: 'SHORT', rcsVarRegex },
885
885
  );
886
- expect(out[0].cardVarMapped).toEqual({ name: '' });
886
+ expect(out[0].cardVarMapped).toEqual({ 1: '' });
887
887
  });
888
888
 
889
889
  it('skips a matched token that strips down to an empty variable name', () => {