@capillarytech/creatives-library 9.0.36-alpha.0 → 9.0.37-alpha.0

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 (50) hide show
  1. package/package.json +1 -1
  2. package/translations/en.json +2 -2
  3. package/utils/permissions.js +59 -0
  4. package/utils/tests/permissions.test.js +116 -0
  5. package/v2Components/AccessForbidden/_accessForbidden.scss +14 -0
  6. package/v2Components/AccessForbidden/index.js +5 -3
  7. package/v2Components/AccessForbidden/messages.js +3 -2
  8. package/v2Components/CommonTestAndPreview/UnifiedPreview/ViberPreviewContent.js +15 -108
  9. package/v2Components/CommonTestAndPreview/UnifiedPreview/_unifiedPreview.scss +1 -139
  10. package/v2Components/CommonTestAndPreview/index.js +26 -240
  11. package/v2Components/CommonTestAndPreview/tests/UnifiedPreview/ViberPreviewContent.test.js +0 -364
  12. package/v2Components/NoPermissionWrapper/_noPermissionWrapper.scss +12 -0
  13. package/v2Components/NoPermissionWrapper/index.js +26 -0
  14. package/v2Containers/App/constants.js +5 -0
  15. package/v2Containers/Assets/Gallery/_gallery.scss +6 -0
  16. package/v2Containers/Assets/Gallery/index.js +34 -10
  17. package/v2Containers/Cap/index.js +5 -2
  18. package/v2Containers/Cap/messages.js +4 -0
  19. package/v2Containers/Cap/tests/__snapshots__/index.test.js.snap +2 -2
  20. package/v2Containers/Line/Container/ImageCarousel/tests/__snapshots__/content.test.js.snap +6 -6
  21. package/v2Containers/Line/Container/ImageCarousel/tests/__snapshots__/index.test.js.snap +4 -4
  22. package/v2Containers/Line/Container/Wrapper/tests/__snapshots__/index.test.js.snap +32 -32
  23. package/v2Containers/Line/Container/tests/__snapshots__/index.test.js.snap +36 -36
  24. package/v2Containers/Rcs/carouselUtils.js +73 -46
  25. package/v2Containers/Rcs/components/CarouselCard.js +9 -6
  26. package/v2Containers/Rcs/constants.js +7 -2
  27. package/v2Containers/Rcs/index.js +126 -72
  28. package/v2Containers/Rcs/rcsLibraryHydrationUtils.js +43 -5
  29. package/v2Containers/Rcs/tests/CarouselCard.test.js +2 -1
  30. package/v2Containers/Rcs/tests/__snapshots__/index.test.js.snap +118 -118
  31. package/v2Containers/Rcs/tests/carouselUtils.test.js +15 -15
  32. package/v2Containers/Rcs/tests/index.test.js +232 -10
  33. package/v2Containers/Rcs/tests/utils.test.js +17 -0
  34. package/v2Containers/Rcs/utils.js +51 -12
  35. package/v2Containers/SmsTrai/Create/tests/__snapshots__/index.test.js.snap +8 -8
  36. package/v2Containers/SmsTrai/Edit/tests/__snapshots__/index.test.js.snap +18 -18
  37. package/v2Containers/Templates/ChannelTypeIllustration.js +10 -1
  38. package/v2Containers/Templates/_templates.scss +6 -114
  39. package/v2Containers/Templates/index.js +151 -190
  40. package/v2Containers/Templates/tests/ChannelTypeIllustration.test.js +7 -0
  41. package/v2Containers/Templates/tests/__snapshots__/index.test.js.snap +173 -120
  42. package/v2Containers/TemplatesV2/index.js +16 -5
  43. package/v2Containers/Viber/constants.js +0 -21
  44. package/v2Containers/Viber/index.js +49 -719
  45. package/v2Containers/Viber/index.scss +0 -175
  46. package/v2Containers/Viber/messages.js +0 -121
  47. package/v2Containers/Viber/tests/index.test.js +0 -80
  48. package/v2Containers/Whatsapp/tests/__snapshots__/index.test.js.snap +268 -268
  49. package/v2Components/CommonTestAndPreview/UnifiedPreview/ViberCarouselPreviewCards.js +0 -132
  50. package/v2Components/CommonTestAndPreview/UnifiedPreview/_viberCarouselPreviewCards.scss +0 -131
@@ -15,6 +15,7 @@ import CapDivider from '@capillarytech/cap-ui-library/CapDivider';
15
15
  import TagList from '../../TagList';
16
16
  import globalMessages from '../../Cap/messages';
17
17
  import messages from '../messages';
18
+ import { RCS_TAG_AREA_FIELD_TITLE, RCS_TAG_AREA_FIELD_DESC } from '../constants';
18
19
  import CarouselCardMedia from './CarouselCardMedia';
19
20
  import CarouselCardButtons from './CarouselCardButtons';
20
21
  import CarouselCharacterCount from './CarouselCharacterCount';
@@ -50,7 +51,8 @@ const CarouselCard = ({
50
51
  onDescriptionChange,
51
52
  onTitleAddVar,
52
53
  onDescriptionAddVar,
53
- onTagSelect,
54
+ onTitleTagSelect,
55
+ onDescTagSelect,
54
56
  onContextChange,
55
57
  renderEditMessage,
56
58
  onFieldChange,
@@ -137,7 +139,7 @@ const CarouselCard = ({
137
139
  (isEditFlow || !isFullMode) ? (
138
140
  <TagList
139
141
  label={formatMessage(globalMessages.addLabels)}
140
- onTagSelect={onTagSelect}
142
+ onTagSelect={onTitleTagSelect}
141
143
  location={location}
142
144
  tags={tagsForPicker}
143
145
  onContextChange={onContextChange}
@@ -159,7 +161,7 @@ const CarouselCard = ({
159
161
  />
160
162
  <CapRow className="rcs_text_area_wrapper">
161
163
  {(isEditFlow || !isFullMode) ? (
162
- renderEditMessage(card.title || '', cardIndex)
164
+ renderEditMessage(card.title || '', cardIndex, RCS_TAG_AREA_FIELD_TITLE)
163
165
  ) : (
164
166
  <CapInput
165
167
  value={card.title || ''}
@@ -189,7 +191,7 @@ const CarouselCard = ({
189
191
  (isEditFlow || !isFullMode) ? (
190
192
  <TagList
191
193
  label={formatMessage(globalMessages.addLabels)}
192
- onTagSelect={onTagSelect}
194
+ onTagSelect={onDescTagSelect}
193
195
  location={location}
194
196
  tags={tagsForPicker}
195
197
  onContextChange={onContextChange}
@@ -211,7 +213,7 @@ const CarouselCard = ({
211
213
  />
212
214
  <CapRow className="rcs_text_area_wrapper">
213
215
  {(isEditFlow || !isFullMode) ? (
214
- renderEditMessage(card.description || '', cardIndex)
216
+ renderEditMessage(card.description || '', cardIndex, RCS_TAG_AREA_FIELD_DESC)
215
217
  ) : (
216
218
  <TextArea
217
219
  autosize={{ minRows: 3, maxRows: 5 }}
@@ -286,7 +288,8 @@ CarouselCard.propTypes = {
286
288
  onDescriptionChange: PropTypes.func.isRequired,
287
289
  onTitleAddVar: PropTypes.func.isRequired,
288
290
  onDescriptionAddVar: PropTypes.func.isRequired,
289
- onTagSelect: PropTypes.func.isRequired,
291
+ onTitleTagSelect: PropTypes.func.isRequired,
292
+ onDescTagSelect: PropTypes.func.isRequired,
290
293
  onContextChange: PropTypes.func,
291
294
  renderEditMessage: PropTypes.func.isRequired,
292
295
  onFieldChange: PropTypes.func.isRequired,
@@ -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,7 +175,7 @@ import {
175
175
  getCarouselDescriptionCharacterCount,
176
176
  buildCarouselCardsForPreview as buildCarouselCardsForPreviewUtil,
177
177
  buildCarouselCardContentForPayload,
178
- getCarouselVarMapKey,
178
+ computeCarouselVarSlotKeys,
179
179
  } from './carouselUtils';
180
180
  import CarouselDimensionSelection from './components/CarouselDimensionSelection';
181
181
  import CarouselCard from './components/CarouselCard';
@@ -271,7 +271,12 @@ export const Rcs = (props) => {
271
271
  const [carouselErrors, setCarouselErrors] = useState([]); // [{ title: string|false, description: string|false }]
272
272
  const [activeCarouselIndex, setActiveCarouselIndex] = useState('0');
273
273
  const [carouselResetNonce, setCarouselResetNonce] = useState(0);
274
- 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('');
275
280
  const [imageError, setImageError] = useState(null);
276
281
  const [templateTitleError, setTemplateTitleError] = useState(false);
277
282
  const [cardVarMapped, setCardVarMapped] = useState({});
@@ -597,7 +602,8 @@ export const Rcs = (props) => {
597
602
  }}
598
603
  onTitleAddVar={() => appendVarToCarouselField(index, 'title')}
599
604
  onDescriptionAddVar={() => appendVarToCarouselField(index, 'description')}
600
- onTagSelect={onCarouselTagSelect}
605
+ onTitleTagSelect={onCarouselTitleTagSelect}
606
+ onDescTagSelect={onCarouselDescTagSelect}
601
607
  onContextChange={handleOnTagsContextChange}
602
608
  renderEditMessage={renderCarouselEditMessage}
603
609
  onFieldChange={(fieldName, value) => handleCarouselValueChange(index, [{ fieldName, value }])}
@@ -656,7 +662,8 @@ export const Rcs = (props) => {
656
662
  onChange={(key) => {
657
663
  setActiveCarouselIndex(`${key}`);
658
664
  // reset focused var when switching cards (as per requirement)
659
- setCarouselFocusedVarId('');
665
+ setCarouselTitleFocusedVarId('');
666
+ setCarouselDescFocusedVarId('');
660
667
  }}
661
668
  panes={getCarouselTabPanes()}
662
669
  />
@@ -776,7 +783,7 @@ export const Rcs = (props) => {
776
783
  ['title', 'description'].forEach((field) => {
777
784
  const templateStr = card?.[field] || '';
778
785
  if (!templateStr) return;
779
- const resolved = resolveCarouselTemplateWithMap(templateStr, idx);
786
+ const resolved = resolveCarouselTemplateWithMap(templateStr, idx, field === 'description');
780
787
  if (!resolved) {
781
788
  updateCarouselErrors(idx, { [field]: false });
782
789
  return;
@@ -869,17 +876,22 @@ export const Rcs = (props) => {
869
876
  };
870
877
 
871
878
  /**
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.
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.
875
882
  */
876
- const resolveCarouselTemplateWithMap = (str = '', cardIndex) => {
883
+ const resolveCarouselTemplateWithMap = (str = '', cardIndex, isDescField = false) => {
877
884
  if (!str) return '';
878
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;
879
890
  return arr.map((elem) => {
880
891
  if (rcsVarTestRegex.test(elem)) {
881
- const varName = getVarNameFromToken(elem);
882
- const slotValue = cardVarMapped?.[getCarouselVarMapKey(cardIndex, varName)];
892
+ const slotKey = fieldKeys[varOrdinal];
893
+ varOrdinal += 1;
894
+ const slotValue = cardVarMapped?.[slotKey];
883
895
  if (isNil(slotValue) || String(slotValue)?.trim?.() === '') return elem;
884
896
  return String(slotValue);
885
897
  }
@@ -1237,35 +1249,15 @@ export const Rcs = (props) => {
1237
1249
  setActiveCarouselIndex('0');
1238
1250
 
1239
1251
  if (cards.length > 1) {
1240
-
1241
- setCardVarMapped((previousVarMap) => cards.reduce((merged, c = {}, idx) => {
1252
+ // Each card's own `cardVarMapped` is already keyed by global var position (see
1253
+ // computeCarouselVarSlotKeys) a slot's key is assigned once, from where its token
1254
+ // sits in the template text, and never depends on which tag fills it. So merging every
1255
+ // card's own map directly is correct and collision-free: no per-card coalescing,
1256
+ // normalizing, or re-scoping needed (unlike the old name-based scoped-key scheme).
1257
+ setCardVarMapped((previousVarMap) => cards.reduce((merged, c = {}) => {
1242
1258
  const rawCardMap = c?.cardVarMapped;
1243
1259
  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 };
1260
+ return { ...merged, ...rawCardMap };
1269
1261
  }, { ...(previousVarMap || {}) }));
1270
1262
  }
1271
1263
 
@@ -1606,25 +1598,51 @@ export const Rcs = (props) => {
1606
1598
 
1607
1599
  const onDescTagSelect = (tagName) => onTagSelect(tagName, descTextAreaId, RCS_TAG_AREA_FIELD_DESC);
1608
1600
 
1609
- const onCarouselTagSelect = (data) => {
1610
- if (!carouselFocusedVarId) return;
1611
- const sep = carouselFocusedVarId.lastIndexOf('_');
1612
- if (sep === -1) return;
1613
- const token = carouselFocusedVarId.slice(0, sep);
1614
- const variableName = getVarNameFromToken(token);
1615
- if (!variableName) return;
1601
+ /** Field-local var ordinal (0-based, vars only) for a `{{tok}}_segIdx` composite id — scoped to
1602
+ * a single field's own template string (title and description are tracked independently). */
1603
+ const getCarouselFieldLocalVarOrdinal = (compositeId, fieldTemplateStr) => {
1604
+ const segments = splitTemplateVarStringRcs(fieldTemplateStr ?? '');
1605
+ let varOrdinal = 0;
1606
+ for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex += 1) {
1607
+ if (rcsVarTestRegex.test(segments[segmentIndex])) {
1608
+ if (`${segments[segmentIndex]}_${segmentIndex}` === compositeId) return varOrdinal;
1609
+ varOrdinal += 1;
1610
+ }
1611
+ }
1612
+ return null;
1613
+ };
1614
+
1615
+ // The card's placeholder (numeric {{N}} or a bare semantic token like {{first_name}}) is locked
1616
+ // here — picking a tag only supplies/replaces the *value* behind that slot in cardVarMapped, it
1617
+ // never rewrites the card's title/description text. `focusedVarId` is passed in by the caller
1618
+ // (title vs description each track their own last-focused slot, like titleTextAreaId/
1619
+ // descTextAreaId) so a click on one field's "Add Labels" can never apply to the other field's
1620
+ // last-focused slot. The slot's key is its global position across the whole carousel — assigned
1621
+ // once from where its token sits in the template text, never from whatever tag currently fills
1622
+ // it — so swapping tags never changes the key and never collides with another card's slot.
1623
+ const onCarouselTagSelect = (data, focusedVarId, isDescField) => {
1624
+ if (!focusedVarId) return;
1616
1625
  const activeIdx = parseInt(activeCarouselIndex, 10);
1617
- const mapKey = getCarouselVarMapKey(isNaN(activeIdx) ? 0 : activeIdx, variableName);
1618
- setCardVarMapped((prev) => {
1619
- const base = (prev?.[mapKey] ?? '').toString();
1620
- const nextVal = `${base}{{${data}}}`;
1621
- return {
1622
- ...(prev || {}),
1623
- [mapKey]: nextVal,
1624
- };
1625
- });
1626
+ const cardIndex = isNaN(activeIdx) ? 0 : activeIdx;
1627
+ const fieldTemplateStr = (isDescField
1628
+ ? carouselData?.[cardIndex]?.description
1629
+ : carouselData?.[cardIndex]?.title) || '';
1630
+ const localVarOrdinal = getCarouselFieldLocalVarOrdinal(focusedVarId, fieldTemplateStr);
1631
+ if (localVarOrdinal == null) return;
1632
+ const slotKeys = computeCarouselVarSlotKeys(carouselData, rcsVarRegex)[cardIndex];
1633
+ const slotKey = (isDescField ? slotKeys?.description : slotKeys?.title)?.[localVarOrdinal];
1634
+ if (!slotKey) return;
1635
+
1636
+ setCardVarMapped((prev) => ({
1637
+ ...(prev || {}),
1638
+ [slotKey]: `{{${data}}}`,
1639
+ }));
1626
1640
  };
1627
1641
 
1642
+ const onCarouselTitleTagSelect = (data) => onCarouselTagSelect(data, carouselTitleFocusedVarId, false);
1643
+
1644
+ const onCarouselDescTagSelect = (data) => onCarouselTagSelect(data, carouselDescFocusedVarId, true);
1645
+
1628
1646
  //removing optout tag for rcs
1629
1647
  const getRcsTags = () => {
1630
1648
  const tempTags = cloneDeep(tags);
@@ -1710,7 +1728,7 @@ export const Rcs = (props) => {
1710
1728
  key={`${elem}_${index}`}
1711
1729
  placeholder={`enter the value for ${elem}`}
1712
1730
  autosize={{ minRows: 1, maxRows: 3 }}
1713
- onChange={e => textAreaValueChange(e, type)}
1731
+ onChange={e => textAreaValueChange(e)}
1714
1732
  value={textAreaValue(index, type)}
1715
1733
  onFocus={(e) => setTextAreaId(e, type)}
1716
1734
  />
@@ -1930,16 +1948,21 @@ const onTitleAddVar = () => {
1930
1948
 
1931
1949
  // Carousel: render variable-value editor for a given template string (title/description) belonging
1932
1950
  // 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) => {
1951
+ // tokens are editable. Each slot's key comes from `computeCarouselVarSlotKeys` stable regardless
1952
+ // of which tag fills it, and independent even from another occurrence of the same tag name.
1953
+ const renderCarouselEditMessage = (templateStr, cardIndex, fieldType) => {
1936
1954
  const renderArray = [];
1937
1955
  const templateArr = splitTemplateVarString(templateStr);
1956
+ const isDescField = fieldType === RCS_TAG_AREA_FIELD_DESC;
1957
+ const slotKeysForCard = computeCarouselVarSlotKeys(carouselData, rcsVarRegex)[cardIndex];
1958
+ const fieldKeys = (isDescField ? slotKeysForCard?.description : slotKeysForCard?.title) ?? [];
1959
+ let varOrdinal = 0;
1938
1960
  if (templateArr?.length) {
1939
1961
  templateArr.forEach((elem, index) => {
1940
1962
  if (rcsVarTestRegex.test(elem)) {
1941
1963
  const varName = getVarNameFromToken(elem);
1942
- const mapKey = getCarouselVarMapKey(cardIndex, varName);
1964
+ const mapKey = fieldKeys[varOrdinal];
1965
+ varOrdinal += 1;
1943
1966
  const computedValue = varName ? ((cardVarMapped?.[mapKey] ?? '').toString()) : '';
1944
1967
  renderArray.push(
1945
1968
  <div
@@ -1950,11 +1973,15 @@ const onTitleAddVar = () => {
1950
1973
  id={`${elem}_${index}`}
1951
1974
  placeholder={`enter the value for ${elem}`}
1952
1975
  autosize={{ minRows: 1, maxRows: 3 }}
1953
- onChange={(e) => textAreaValueChange(e, cardIndex)}
1976
+ onChange={(e) => carouselTextAreaValueChange(e, cardIndex, fieldType)}
1954
1977
  value={computedValue}
1955
1978
  onFocus={(e) => {
1956
1979
  const id = e?.target?.id || e?.currentTarget?.id || '';
1957
- setCarouselFocusedVarId(id);
1980
+ if (fieldType === RCS_TAG_AREA_FIELD_DESC) {
1981
+ setCarouselDescFocusedVarId(id);
1982
+ } else {
1983
+ setCarouselTitleFocusedVarId(id);
1984
+ }
1958
1985
  }}
1959
1986
  />
1960
1987
  </div>
@@ -1975,7 +2002,7 @@ const onTitleAddVar = () => {
1975
2002
  return <CapRow className="rcs-edit-template-message-input">{renderArray}</CapRow>;
1976
2003
  };
1977
2004
 
1978
- const textAreaValueChange = (e, cardIndex) => {
2005
+ const textAreaValueChange = (e) => {
1979
2006
  const value = e?.target?.value ?? '';
1980
2007
  const id = e?.target?.id || e?.currentTarget?.id || '';
1981
2008
  if (!id) return;
@@ -1986,14 +2013,38 @@ const onTitleAddVar = () => {
1986
2013
  const variableName = getVarNameFromToken(token);
1987
2014
 
1988
2015
  if (variableName) {
1989
- const mapKey = getCarouselVarMapKey(cardIndex, variableName);
2016
+ const nextVal = isInvalidValue ? "" : value;
1990
2017
  setCardVarMapped((prev) => ({
1991
2018
  ...prev,
1992
- [mapKey]: isInvalidValue ? "" : value,
2019
+ [variableName]: nextVal,
1993
2020
  }));
1994
2021
  }
1995
2022
  };
1996
2023
 
2024
+ // Carousel: typing directly into a slot's value box (as opposed to picking a tag) — same global
2025
+ // positional key as onCarouselTagSelect/renderCarouselEditMessage, so both ways of filling a
2026
+ // slot land on the identical key.
2027
+ const carouselTextAreaValueChange = (e, cardIndex, fieldType) => {
2028
+ const value = e?.target?.value ?? '';
2029
+ const id = e?.target?.id || e?.currentTarget?.id || '';
2030
+ if (!id) return;
2031
+ const isDescField = fieldType === RCS_TAG_AREA_FIELD_DESC;
2032
+ const fieldTemplateStr = (isDescField
2033
+ ? carouselData?.[cardIndex]?.description
2034
+ : carouselData?.[cardIndex]?.title) || '';
2035
+ const localVarOrdinal = getCarouselFieldLocalVarOrdinal(id, fieldTemplateStr);
2036
+ if (localVarOrdinal == null) return;
2037
+ const slotKeys = computeCarouselVarSlotKeys(carouselData, rcsVarRegex)[cardIndex];
2038
+ const mapKey = (isDescField ? slotKeys?.description : slotKeys?.title)?.[localVarOrdinal];
2039
+ if (!mapKey) return;
2040
+ const isInvalidValue = value?.trim() === "";
2041
+ const nextVal = isInvalidValue ? "" : value;
2042
+ setCardVarMapped((prev) => ({
2043
+ ...prev,
2044
+ [mapKey]: nextVal,
2045
+ }));
2046
+ };
2047
+
1997
2048
  const setTextAreaId = (e, type) => {
1998
2049
  // VarSegmentMessageEditor calls onFocus(id) with a plain string; DOM events
1999
2050
  // have an `.target.id` shape. Support both.
@@ -2844,9 +2895,8 @@ const onTitleAddVar = () => {
2844
2895
  // the user mapped via cardVarMapped. Run this for ALL modes (create, edit, consumer) so that:
2845
2896
  // - buildRcsTestMessagePayload sends real Capillary tag names to the test API, and
2846
2897
  // - prepareTagExtractionPayload can extract tag metadata from the card content.
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).
2898
+ // Each variable slot's key comes from computeCarouselVarSlotKeys, so two cards' variables
2899
+ // never share a key even when they pick the same tag name for their own slot.
2850
2900
  rcsForTest = {
2851
2901
  ...rcsForTest,
2852
2902
  rcsContent: {
@@ -2854,8 +2904,8 @@ const onTitleAddVar = () => {
2854
2904
  cardContent: cardContent.map((card, cardIndex) => {
2855
2905
  const rawTitle = card.title || '';
2856
2906
  const rawDesc = card.description || '';
2857
- const resolvedTitle = resolveCarouselTemplateWithMap(rawTitle, cardIndex);
2858
- const resolvedDesc = resolveCarouselTemplateWithMap(rawDesc, cardIndex);
2907
+ const resolvedTitle = resolveCarouselTemplateWithMap(rawTitle, cardIndex, false);
2908
+ const resolvedDesc = resolveCarouselTemplateWithMap(rawDesc, cardIndex, true);
2859
2909
  return { ...card, title: resolvedTitle, description: resolvedDesc };
2860
2910
  }),
2861
2911
  },
@@ -3066,12 +3116,16 @@ const onTitleAddVar = () => {
3066
3116
  ['title', 'description'].some((field) => !(card?.[field] || '').trim())
3067
3117
  );
3068
3118
  if (hasEmptyField) return true;
3119
+ const slotKeysByCard = computeCarouselVarSlotKeys(carouselData, rcsVarRegex);
3069
3120
  const unfilledVar = (carouselData || []).some((card, cardIndex) =>
3070
3121
  ['title', 'description'].some((field) => {
3122
+ const isDescField = field === 'description';
3071
3123
  const tokens = splitTemplateVarStringRcs(card?.[field] || '').filter((token) => rcsVarTestRegex.test(token));
3072
- return tokens.some((token) => {
3073
- const name = token.replace(RCS_STRIP_MUSTACHE_DELIMITERS_REGEX, '');
3074
- const slotValue = cardVarMapped?.[getCarouselVarMapKey(cardIndex, name)];
3124
+ if (tokens.length === 0) return false;
3125
+ const fieldKeys = (isDescField ? slotKeysByCard[cardIndex]?.description : slotKeysByCard[cardIndex]?.title) ?? [];
3126
+ return tokens.some((token, localVarOrdinal) => {
3127
+ const slotKey = fieldKeys[localVarOrdinal];
3128
+ const slotValue = cardVarMapped?.[slotKey];
3075
3129
  return slotValue == null || String(slotValue).trim() === '';
3076
3130
  });
3077
3131
  })
@@ -447,6 +447,7 @@ export function syncCardVarMappedSemanticsFromSlots(
447
447
  templateDesc,
448
448
  rcsVarRegex,
449
449
  ) {
450
+ console.log('syncCardVarMappedSemanticsFromSlots', cardVarMappedInput, templateTitle, templateDesc);
450
451
  const cardVarMappedSynced =
451
452
  cardVarMappedInput != null && typeof cardVarMappedInput === 'object'
452
453
  ? { ...cardVarMappedInput }
@@ -459,11 +460,48 @@ export function syncCardVarMappedSemanticsFromSlots(
459
460
  templateVarTokens.forEach((token, slotIndexZeroBased) => {
460
461
  const semanticVarName = getVarNameFromToken(token);
461
462
  if (!semanticVarName) return;
462
- const numericSlotKey = String(slotIndexZeroBased + 1);
463
- const semanticValueTrimmed = String(cardVarMappedSynced[semanticVarName] ?? '').trim();
464
- const numericSlotValueTrimmed = String(cardVarMappedSynced[numericSlotKey] ?? '').trim();
465
- if (!semanticValueTrimmed && numericSlotValueTrimmed) {
466
- cardVarMappedSynced[semanticVarName] = cardVarMappedSynced[numericSlotKey];
463
+
464
+ if (!RCS_NUMERIC_VAR_NAME_REGEX.test(semanticVarName)) {
465
+ // A numeric slot may already self-reference this exact tag (e.g. "4" -> "{{last_name}}") when
466
+ // the template's own numeric placeholder has since been resolved to this tag in the text —
467
+ // that numeric key is the source of truth, not this token's position.
468
+ const mustacheSelfReference = `{{${semanticVarName}}}`;
469
+ const existingNumericKeyForSemantic = Object.keys(cardVarMappedSynced).find((key) => (
470
+ RCS_NUMERIC_VAR_NAME_REGEX.test(key) && cardVarMappedSynced[key] === mustacheSelfReference
471
+ ));
472
+ if (existingNumericKeyForSemantic) {
473
+ const semanticValueTrimmed = String(cardVarMappedSynced[semanticVarName] ?? '').trim();
474
+ const numericValueTrimmed = String(cardVarMappedSynced[existingNumericKeyForSemantic] ?? '').trim();
475
+ if (!semanticValueTrimmed && numericValueTrimmed) {
476
+ cardVarMappedSynced[semanticVarName] = cardVarMappedSynced[existingNumericKeyForSemantic];
477
+ }
478
+ return;
479
+ }
480
+
481
+ // Legacy: template embeds the semantic token directly ({{user_name}}), but older payloads
482
+ // stored the resolved value under a position-based numeric key ("1", "2", …).
483
+ const numericSlotKey = String(slotIndexZeroBased + 1);
484
+ const semanticValueTrimmed = String(cardVarMappedSynced[semanticVarName] ?? '').trim();
485
+ const numericSlotValueTrimmed = String(cardVarMappedSynced[numericSlotKey] ?? '').trim();
486
+ if (!semanticValueTrimmed && numericSlotValueTrimmed) {
487
+ cardVarMappedSynced[semanticVarName] = cardVarMappedSynced[numericSlotKey];
488
+ }
489
+ return;
490
+ }
491
+
492
+ // Carousel numeric placeholder ({{3}}, {{4}}, …): the slot key IS the token digit itself —
493
+ // numbers are allocated globally across the whole carousel (getNextCarouselVarToken), not
494
+ // re-numbered per card, so a position-based key here would target the wrong slot. Its value
495
+ // may be a mustache-wrapped tag reference (e.g. "3" -> "{{last_name}}") — mirror that onto the
496
+ // bare semantic key too so VarSegment editors keyed by tag name also prepopulate.
497
+ const slotValueTrimmed = String(cardVarMappedSynced[semanticVarName] ?? '').trim();
498
+ if (!slotValueTrimmed) return;
499
+ const mustacheInnerMatch = slotValueTrimmed.match(/^\{\{([^}]+)\}\}$/);
500
+ const innerSemanticName = mustacheInnerMatch?.[1]?.trim();
501
+ if (!innerSemanticName || innerSemanticName === semanticVarName) return;
502
+ const existingSemanticValue = String(cardVarMappedSynced[innerSemanticName] ?? '').trim();
503
+ if (!existingSemanticValue) {
504
+ cardVarMappedSynced[innerSemanticName] = cardVarMappedSynced[semanticVarName];
467
505
  }
468
506
  });
469
507
  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(),