@capillarytech/creatives-library 9.0.36 → 9.0.37-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.
Files changed (48) hide show
  1. package/AppRoot.js +124 -0
  2. package/app-config.js +61 -0
  3. package/app.js +10 -175
  4. package/bootstrap.js +185 -0
  5. package/entry.js +67 -1
  6. package/mfe-exposed-components.js +2 -4
  7. package/package.json +2 -2
  8. package/services/api.js +9 -1
  9. package/styles/containers/layout/_layoutPage.scss +8 -6
  10. package/utils/getDataLayer.js +15 -0
  11. package/utils/gtmTrackers/gtmEvents/creativeDetails.js +2 -1
  12. package/utils/mfeDetect.js +1 -0
  13. package/utils/mfeFirstPaintReady.js +29 -0
  14. package/utils/mfeHistory.js +62 -0
  15. package/utils/rcsPayloadUtils.js +5 -2
  16. package/utils/tests/rcsPayloadUtils.test.js +54 -2
  17. package/v2Components/CapActionButton/constants.js +1 -0
  18. package/v2Components/CapActionButton/index.js +77 -9
  19. package/v2Components/CapActionButton/index.scss +13 -0
  20. package/v2Components/CapActionButton/messages.js +13 -0
  21. package/v2Components/CapActionButton/tests/index.test.js +32 -1
  22. package/v2Components/CommonTestAndPreview/index.js +44 -15
  23. package/v2Components/CommonTestAndPreview/tests/index.test.js +78 -0
  24. package/v2Components/CommonTestAndPreview/utils.js +34 -0
  25. package/v2Components/NavigationBar/index.js +9 -7
  26. package/v2Components/NavigationBar/mfeModuleHeader.config.js +16 -0
  27. package/v2Components/NavigationBar/tests/index.test.js +39 -19
  28. package/v2Components/SmsFallback/index.js +6 -0
  29. package/v2Containers/Cap/constants.js +1 -0
  30. package/v2Containers/Cap/index.js +57 -27
  31. package/v2Containers/CreativesContainer/index.js +11 -8
  32. package/v2Containers/CreativesContainer/tests/index.test.js +53 -0
  33. package/v2Containers/Rcs/components/CarouselCard.js +5 -0
  34. package/v2Containers/Rcs/components/CarouselCardButtons.js +20 -0
  35. package/v2Containers/Rcs/index.js +154 -79
  36. package/v2Containers/Rcs/rcsLibraryHydrationUtils.js +55 -4
  37. package/v2Containers/Rcs/tests/__snapshots__/index.test.js.snap +286 -0
  38. package/v2Containers/Rcs/tests/carouselUtils.test.js +13 -17
  39. package/v2Containers/Rcs/tests/index.test.js +141 -6
  40. package/v2Containers/Rcs/tests/rcsLibraryHydrationUtils.test.js +110 -0
  41. package/v2Containers/Rcs/tests/utils.test.js +36 -0
  42. package/v2Containers/Rcs/utils.js +16 -4
  43. package/v2Containers/SmsTrai/Edit/index.js +35 -24
  44. package/v2Containers/Templates/_templates.scss +1 -1
  45. package/v2Containers/Templates/index.js +1 -0
  46. package/v2Containers/Templates/tests/__snapshots__/index.test.js.snap +5 -0
  47. package/v2Containers/TemplatesV2/TemplatesV2.style.js +8 -3
  48. package/v2Containers/TemplatesV2/index.js +22 -9
@@ -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,
@@ -900,7 +900,7 @@ export const Rcs = (props) => {
900
900
  };
901
901
 
902
902
  const buildCarouselCardsForPreview = (cards = []) =>
903
- buildCarouselCardsForPreviewUtil(cards, { isFullMode, rcsVarRegex, resolveTemplateWithMap });
903
+ buildCarouselCardsForPreviewUtil(cards, { isFullMode, resolveCarouselTemplateWithMap });
904
904
 
905
905
  /**
906
906
  * Content for TestAndPreviewSlidebox — apply cardVarMapped whenever the slot editor is shown
@@ -1098,9 +1098,9 @@ export const Rcs = (props) => {
1098
1098
  if (mediaType) {
1099
1099
  setTemplateMediaType(mediaType);
1100
1100
  }
1101
- const tempOrientation = cardSettings.cardOrientation;
1102
- const tempAlignment = cardSettings.mediaAlignment;
1103
- const tempHeight = mediaData.height;
1101
+ const tempOrientation = cardSettings?.cardOrientation;
1102
+ const tempAlignment = cardSettings?.mediaAlignment;
1103
+ const tempHeight = mediaData?.height;
1104
1104
 
1105
1105
  // Map to actual RCS_IMAGE_DIMENSIONS keys
1106
1106
  let tempDimension;
@@ -1228,19 +1228,21 @@ export const Rcs = (props) => {
1228
1228
 
1229
1229
  setTemplateType(contentType.carousel);
1230
1230
  setTemplateMediaType(RCS_MEDIA_TYPES.NONE);
1231
- // Library/campaign payloads can store cardSettings/cardContent at the flat top-level
1232
- // `rcsContent` instead of nested under versions.base.content.RCS (same fallback already
1233
- // used for the rich_card media lookup below, line ~1793). Without it, cardWidth/height
1234
- // silently fall back to SMALL/MEDIUM, mismatching the dimensions the thumbnail was
1235
- // actually uploaded at and causing object-fit:cover to crop the preview image.
1231
+
1236
1232
  const cardSettings = rcsContent?.cardSettings
1237
1233
  || get(details, 'rcsContent.cardSettings', {});
1238
1234
  const cardWidth = cardSettings?.cardWidth || SMALL;
1239
1235
  setSelectedCarouselWidth(cardWidth);
1240
1236
 
1241
- const cards = Array.isArray(rcsContent?.cardContent)
1242
- ? rcsContent.cardContent
1243
- : get(details, 'rcsContent.cardContent', []);
1237
+ const nestedCards = Array.isArray(rcsContent?.cardContent) ? rcsContent.cardContent : [];
1238
+ const topCardsRaw = get(details, 'rcsContent.cardContent', []);
1239
+ const topCards = Array.isArray(topCardsRaw) ? topCardsRaw : [];
1240
+ const cardCount = Math.max(nestedCards.length, topCards.length);
1241
+ const mergedCards = Array.from({ length: cardCount }, (_, idx) => ({
1242
+ ...(topCards[idx] || {}),
1243
+ ...(nestedCards[idx] || {}),
1244
+ }));
1245
+ const cards = cardCount > 0 ? mergedCards : topCards;
1244
1246
  const firstHeight = cards?.[0]?.media?.height || MEDIUM;
1245
1247
  setSelectedCarouselHeight(firstHeight);
1246
1248
  setSelectedCarousel(`${firstHeight}_${cardWidth}`);
@@ -1464,10 +1466,29 @@ export const Rcs = (props) => {
1464
1466
  globalActions.fetchSchemaForEntity(query);
1465
1467
  };
1466
1468
 
1467
- const replaceNumericPlaceholderWithTagInTemplate = (templateStr, numericVarName, tagName) => {
1468
- if (!templateStr || !numericVarName || !tagName) return templateStr;
1469
- const placeholderRegex = buildRcsNumericMustachePlaceholderRegex(numericVarName);
1470
- return templateStr.replace(placeholderRegex, `{{${tagName}}}`);
1469
+ /**
1470
+ * Replaces only the var token at `slotOrdinalZeroBased` (the field-local ordinal among
1471
+ * `{{...}}` tokens, left to right) with `{{tagName}}`. Unlike a name-based regex replace,
1472
+ * this targets a single slot even when the same tag name appears in other slots of the field.
1473
+ */
1474
+ const replaceTagAtFieldSlotOrdinal = (templateStr, slotOrdinalZeroBased, tagName) => {
1475
+ if (!templateStr || slotOrdinalZeroBased === null || slotOrdinalZeroBased === undefined || !tagName) {
1476
+ return templateStr;
1477
+ }
1478
+ let varOrdinal = 0;
1479
+ let replaced = false;
1480
+ const updatedSegments = splitTemplateVarStringRcs(templateStr).map((segment) => {
1481
+ if (rcsVarTestRegex.test(segment)) {
1482
+ const currentOrdinal = varOrdinal;
1483
+ varOrdinal += 1;
1484
+ if (currentOrdinal === slotOrdinalZeroBased) {
1485
+ replaced = true;
1486
+ return `{{${tagName}}}`;
1487
+ }
1488
+ }
1489
+ return segment;
1490
+ });
1491
+ return replaced ? updatedSegments.join('') : templateStr;
1471
1492
  };
1472
1493
 
1473
1494
  const onTagSelect = (selectedTagNameFromPicker, varSegmentCompositeDomId, tagAreaField) => {
@@ -1497,16 +1518,15 @@ export const Rcs = (props) => {
1497
1518
  setCardVarMapped((previousCardVarMapped) => {
1498
1519
  const updatedCardVarMapped = { ...(previousCardVarMapped || {}) };
1499
1520
  if (isNumericPlaceholderSlot) {
1521
+ // Numeric slot ({{1}}, {{2}}, …) is the persisted API contract — keep the numeric key
1522
+ // itself (do not fork off a semantic-name key); the template text stays {{N}} below.
1500
1523
  const existingValueBeforeAppend = (
1501
1524
  previousCardVarMapped?.[semanticOrNumericVarName] ?? ''
1502
1525
  ).toString();
1503
1526
  const mappedValueAfterAppendingTag = `${existingValueBeforeAppend}{{${selectedTagNameFromPicker}}}`;
1504
- delete updatedCardVarMapped[semanticOrNumericVarName];
1505
- updatedCardVarMapped[selectedTagNameFromPicker] = mappedValueAfterAppendingTag;
1527
+ updatedCardVarMapped[semanticOrNumericVarName] = mappedValueAfterAppendingTag;
1506
1528
  } else {
1507
- // Same semantic token (e.g. {{adv}}) in title and body must not share one map key for
1508
- // "existing value" — that appends the new tag onto the other field. Match handleRcsVarChange:
1509
- // read/write the global numeric slot only and drop the shared semantic key.
1529
+
1510
1530
  const existingValueBeforeAppend = cardVarMappedNumericSlotKey
1511
1531
  ? String(previousCardVarMapped?.[cardVarMappedNumericSlotKey] ?? '')
1512
1532
  : String(previousCardVarMapped?.[semanticOrNumericVarName] ?? '');
@@ -1535,36 +1555,40 @@ export const Rcs = (props) => {
1535
1555
  return updatedCardVarMapped;
1536
1556
  });
1537
1557
 
1538
- if (
1539
- isNumericPlaceholderSlot
1540
- && (tagAreaField === RCS_TAG_AREA_FIELD_TITLE || tagAreaField === RCS_TAG_AREA_FIELD_DESC)
1541
- ) {
1558
+ // Numeric slot ({{1}}, {{2}}, …): the token is already the persisted API contract — leave
1559
+ // templateTitle/templateDesc untouched. The chip label re-resolves via titleVarSegmentValueMapById
1560
+ // / descriptionVarSegmentValueMapById, both keyed off cardVarMapped (updated above).
1561
+ if (isNumericPlaceholderSlot) return;
1562
+
1563
+ if (tagAreaField === RCS_TAG_AREA_FIELD_TITLE || tagAreaField === RCS_TAG_AREA_FIELD_DESC) {
1564
+ // Legacy slot that already holds a named tag (e.g. {{first_name}}) is replaced by
1565
+ // field-local position, since a name-based replace would also overwrite other slots
1566
+ // sharing that same tag name.
1567
+ const fieldOffsetForSlot = tagAreaField === RCS_TAG_AREA_FIELD_TITLE
1568
+ ? 0
1569
+ : (templateTitle?.match(rcsVarRegex) ?? []).length;
1570
+ const localSlotOrdinal = (globalVarSlotIndexZeroBased !== null && globalVarSlotIndexZeroBased !== undefined)
1571
+ ? globalVarSlotIndexZeroBased - fieldOffsetForSlot
1572
+ : null;
1573
+
1542
1574
  if (tagAreaField === RCS_TAG_AREA_FIELD_TITLE) {
1543
1575
  setTemplateTitle((previousTitle) => {
1544
- const titleAfterReplacingNumericPlaceholder = replaceNumericPlaceholderWithTagInTemplate(
1545
- previousTitle || '',
1546
- semanticOrNumericVarName,
1547
- selectedTagNameFromPicker,
1548
- );
1549
- if (titleAfterReplacingNumericPlaceholder === previousTitle) return previousTitle;
1550
- setTemplateTitleError(variableErrorHandling(titleAfterReplacingNumericPlaceholder));
1576
+ const titleAfterReplacingTag = replaceTagAtFieldSlotOrdinal(previousTitle || '', localSlotOrdinal, selectedTagNameFromPicker);
1577
+ if (titleAfterReplacingTag === previousTitle) return previousTitle;
1578
+ setTemplateTitleError(computeTemplateTitleError(titleAfterReplacingTag));
1551
1579
  // 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
1552
1580
  setRcsVarSegmentEditorRemountKey((remountKey) => remountKey + 1);
1553
- return titleAfterReplacingNumericPlaceholder;
1581
+ return titleAfterReplacingTag;
1554
1582
  });
1555
1583
  } else {
1556
1584
  setTemplateDesc((previousDescription) => {
1557
- const descriptionAfterReplacingNumericPlaceholder = replaceNumericPlaceholderWithTagInTemplate(
1558
- previousDescription || '',
1559
- semanticOrNumericVarName,
1560
- selectedTagNameFromPicker,
1561
- );
1562
- if (descriptionAfterReplacingNumericPlaceholder === previousDescription) {
1585
+ const descriptionAfterReplacingTag = replaceTagAtFieldSlotOrdinal(previousDescription || '', localSlotOrdinal, selectedTagNameFromPicker);
1586
+ if (descriptionAfterReplacingTag === previousDescription) {
1563
1587
  return previousDescription;
1564
1588
  }
1565
- setTemplateDescError(variableErrorHandling(descriptionAfterReplacingNumericPlaceholder));
1589
+ setTemplateDescError(computeTemplateDescError(descriptionAfterReplacingTag));
1566
1590
  setRcsVarSegmentEditorRemountKey((remountKey) => remountKey + 1);
1567
- return descriptionAfterReplacingNumericPlaceholder;
1591
+ return descriptionAfterReplacingTag;
1568
1592
  });
1569
1593
  }
1570
1594
  }
@@ -1726,31 +1750,13 @@ export const Rcs = (props) => {
1726
1750
  return renderArray;
1727
1751
  };
1728
1752
  const onTemplateTitleChange = ({ target: { value } }) => {
1729
- let errorMessage = false;
1730
- if (templateType === contentType.rich_card && !value.trim()) {
1731
- errorMessage = formatMessage(messages.emptyTemplateTitleErrorMessage);
1732
- } else if (value.length > TEMPLATE_TITLE_MAX_LENGTH) {
1733
- errorMessage = formatMessage(messages.templateHeaderLengthError);
1734
- } else {
1735
- errorMessage = variableErrorHandling(value);
1736
- }
1737
1753
  setTemplateTitle(value);
1738
- setTemplateTitleError(errorMessage);
1754
+ setTemplateTitleError(computeTemplateTitleError(value));
1739
1755
  };
1740
1756
 
1741
1757
  const onTemplateDescChange = ({ target: { value } }) => {
1742
- let errorMessage = false;
1743
- if(templateType === contentType.text_message && value?.length > (isHostInfoBip ? RCS_TEXT_MESSAGE_MAX_LENGTH_INFOBIP : RCS_TEXT_MESSAGE_MAX_LENGTH)){
1744
- errorMessage = formatMessage(messages.templateMessageLengthError);
1745
- } else if(templateType === contentType.rich_card && value?.length > RCS_RICH_CARD_MAX_LENGTH){
1746
- errorMessage = formatMessage(messages.templateMessageLengthError);
1747
- } else {
1748
- errorMessage = false;
1749
- }
1750
- const varError = variableErrorHandling(value);
1751
- const error = errorMessage || varError;
1752
1758
  setTemplateDesc(value);
1753
- setTemplateDescError(error);
1759
+ setTemplateDescError(computeTemplateDescError(value));
1754
1760
  };
1755
1761
 
1756
1762
 
@@ -1833,6 +1839,29 @@ export const Rcs = (props) => {
1833
1839
  return false;
1834
1840
  };
1835
1841
 
1842
+ // Shared with onTemplateTitleChange so tag-picker inserts enforce the same length limit as manual typing.
1843
+ const computeTemplateTitleError = (value) => {
1844
+ if (templateType === contentType.rich_card && !value.trim()) {
1845
+ return formatMessage(messages.emptyTemplateTitleErrorMessage);
1846
+ }
1847
+ if (value.length > TEMPLATE_TITLE_MAX_LENGTH) {
1848
+ return formatMessage(messages.templateHeaderLengthError);
1849
+ }
1850
+ return variableErrorHandling(value);
1851
+ };
1852
+
1853
+ // Shared with onTemplateDescChange so tag-picker inserts enforce the same length limit as manual typing.
1854
+ const computeTemplateDescError = (value) => {
1855
+ let errorMessage = false;
1856
+ if (templateType === contentType.text_message && value?.length > (isHostInfoBip ? RCS_TEXT_MESSAGE_MAX_LENGTH_INFOBIP : RCS_TEXT_MESSAGE_MAX_LENGTH)) {
1857
+ errorMessage = formatMessage(messages.templateMessageLengthError);
1858
+ } else if (templateType === contentType.rich_card && value?.length > RCS_RICH_CARD_MAX_LENGTH) {
1859
+ errorMessage = formatMessage(messages.templateMessageLengthError);
1860
+ }
1861
+ const varError = variableErrorHandling(value);
1862
+ return errorMessage || varError;
1863
+ };
1864
+
1836
1865
  const onMessageAddVar = () => {
1837
1866
  onAddVar(templateDesc);
1838
1867
  };
@@ -1936,7 +1965,10 @@ const onTitleAddVar = () => {
1936
1965
  varOrdinal += 1;
1937
1966
  const computedValue = varName ? ((cardVarMapped?.[mapKey] ?? '').toString()) : '';
1938
1967
  renderArray.push(
1939
- <div key={`${elem}_${index}`} className="var-segment-message-editor__var-slot">
1968
+ <div
1969
+ key={`${elem}_${index}_${rcsVarSegmentEditorRemountKey}`}
1970
+ className="var-segment-message-editor__var-slot"
1971
+ >
1940
1972
  <TextArea
1941
1973
  id={`${elem}_${index}`}
1942
1974
  placeholder={`enter the value for ${elem}`}
@@ -2050,6 +2082,11 @@ const onTitleAddVar = () => {
2050
2082
  isFullMode={isFullMode}
2051
2083
  maxButtons={MAX_BUTTONS}
2052
2084
  host={hostName}
2085
+ location={location}
2086
+ tags={getRcsTags()}
2087
+ injectedTags={injectedTags || {}}
2088
+ selectedOfferDetails={selectedOfferDetails}
2089
+ onContextChange={handleOnTagsContextChange}
2053
2090
  />
2054
2091
  </>
2055
2092
  );
@@ -2671,8 +2708,6 @@ const onTitleAddVar = () => {
2671
2708
  const carouselVidDims =
2672
2709
  RCS_CAROUSEL_VIDEO_THUMBNAIL_DIMENSIONS[carouselDimKey]
2673
2710
  || RCS_CAROUSEL_VIDEO_THUMBNAIL_DIMENSIONS.MEDIUM_MEDIUM;
2674
- // Debug log for embedded/library mode preview payload (carousel)
2675
- // eslint-disable-next-line no-console
2676
2711
  return (
2677
2712
  <UnifiedPreview
2678
2713
  channel={RCS}
@@ -2856,8 +2891,8 @@ const onTitleAddVar = () => {
2856
2891
  const cardContent = rcsForTest.rcsContent?.cardContent;
2857
2892
  if (Array.isArray(cardContent) && cardContent[0]) {
2858
2893
  if (isCarouselType) {
2859
- // Carousel: resolve {{N}} slot tokens to the actual tag expressions / static values the
2860
- // user mapped via cardVarMapped. Run this for ALL modes (create, edit, consumer) so that:
2894
+ // Carousel: resolve {{N}}/named slot tokens to the actual tag expressions / static values
2895
+ // the user mapped via cardVarMapped. Run this for ALL modes (create, edit, consumer) so that:
2861
2896
  // - buildRcsTestMessagePayload sends real Capillary tag names to the test API, and
2862
2897
  // - prepareTagExtractionPayload can extract tag metadata from the card content.
2863
2898
  // Each variable slot's key comes from computeCarouselVarSlotKeys, so two cards' variables
@@ -2866,7 +2901,7 @@ const onTitleAddVar = () => {
2866
2901
  ...rcsForTest,
2867
2902
  rcsContent: {
2868
2903
  ...rcsForTest.rcsContent,
2869
- cardContent: cardContent.map((card) => {
2904
+ cardContent: cardContent.map((card, cardIndex) => {
2870
2905
  const rawTitle = card.title || '';
2871
2906
  const rawDesc = card.description || '';
2872
2907
  const resolvedTitle = resolveCarouselTemplateWithMap(rawTitle, cardIndex, false);
@@ -2877,19 +2912,33 @@ const onTitleAddVar = () => {
2877
2912
  };
2878
2913
  } else if (isSlotMappingModeForPreview) {
2879
2914
  // Standalone card: coalesce cardVarMapped with the template's slot names so the preview
2880
- // API receives a correctly-keyed var map for non-carousel templates.
2915
+ // API receives a correctly-keyed var map for non-carousel templates. Also resolve the
2916
+ // {{N}} slot tokens in title/description themselves (mirrors the carousel branch above) —
2917
+ // otherwise prepareTagExtractionPayload/getRcsPrimaryTagExtractionText send the raw "{{1}}"
2918
+ // token as template text, which the extract-tags API doesn't recognize, so the
2919
+ // synthetic-tag fallback names the row "1" instead of the real tag.
2881
2920
  const fullCardVarMapped = coalesceCardVarMappedToTemplate(
2882
2921
  pickRcsCardVarMappedEntries(cardVarMapped),
2883
2922
  templateTitle,
2884
2923
  templateDesc,
2885
2924
  rcsVarRegex,
2886
2925
  );
2926
+ const rawStandaloneTitle = cardContent[0].title || '';
2927
+ const rawStandaloneDesc = cardContent[0].description || '';
2928
+ const standaloneTitleVarCount = (rawStandaloneTitle.match(rcsVarRegex) || []).length;
2929
+ const resolvedStandaloneTitle = resolveTemplateWithMap(rawStandaloneTitle, 0);
2930
+ const resolvedStandaloneDesc = resolveTemplateWithMap(rawStandaloneDesc, standaloneTitleVarCount);
2887
2931
  rcsForTest = {
2888
2932
  ...rcsForTest,
2889
2933
  rcsContent: {
2890
2934
  ...rcsForTest.rcsContent,
2891
2935
  cardContent: [
2892
- { ...cardContent[0], cardVarMapped: fullCardVarMapped },
2936
+ {
2937
+ ...cardContent[0],
2938
+ title: resolvedStandaloneTitle,
2939
+ description: resolvedStandaloneDesc,
2940
+ cardVarMapped: fullCardVarMapped,
2941
+ },
2893
2942
  ...cardContent.slice(1),
2894
2943
  ],
2895
2944
  },
@@ -2905,16 +2954,23 @@ const onTitleAddVar = () => {
2905
2954
  },
2906
2955
  },
2907
2956
  };
2908
- // Use the smsFallbackTemplateConfigs already built by createPayload() / buildSmsFallBackContentForPayload —
2909
- // it merges the API baseline (templateId, registeredSenderIds, templateName, etc.) with local
2910
- // smsFallbackData state. Reading raw smsFallbackData here would miss the API-sourced smsTemplateId
2911
- // in campaign mode. This also keeps templateName (and all other fields) in sync with the approval
2912
- // payload automatically.
2913
- const smsFallbackTcFromPayload = rcs?.rcsContent?.smsFallBackContent?.templateConfigs;
2914
- if (smsFallbackTcFromPayload && typeof smsFallbackTcFromPayload === 'object') {
2957
+
2958
+ // buildSmsFallBackContentForPayload nests registeredSenderIds under smsFallBackContent.templateConfigs
2959
+ // only for full mode, or for campaign mode when isDltCampaign (smsRegister === 'DLT') is true — but it
2960
+ // always sets the flat smsFallBackContent.registeredSenderIds whenever the fallback template has them.
2961
+ // Extract via both so Test & Preview's DLT sender-id filter still sees real ids (and actually filters)
2962
+ // even when isDltCampaign was false at payload-build time.
2963
+ const smsFallbackRecordForTest = rcs?.rcsContent?.smsFallBackContent;
2964
+ const smsFallbackTcFromPayload = smsFallbackRecordForTest?.templateConfigs;
2965
+ const registeredSenderIdsForTest = extractRegisteredSenderIdsFromSmsFallbackRecord(smsFallbackRecordForTest);
2966
+ const hasRegisteredSenderIdsForTest =
2967
+ Array.isArray(registeredSenderIdsForTest) && registeredSenderIdsForTest.length > 0;
2968
+ if ((smsFallbackTcFromPayload && typeof smsFallbackTcFromPayload === 'object') || hasRegisteredSenderIdsForTest) {
2915
2969
  out.templateConfigs = {
2916
- ...smsFallbackTcFromPayload,
2917
- traiDltEnabled: isTraiDLTEnable(isFullMode, smsRegister),
2970
+ ...(smsFallbackTcFromPayload && typeof smsFallbackTcFromPayload === 'object' ? smsFallbackTcFromPayload : {}),
2971
+ ...(hasRegisteredSenderIdsForTest && { registeredSenderIds: registeredSenderIdsForTest }),
2972
+ // Real registered sender IDs mean filtering should happen regardless of the smsRegister heuristic.
2973
+ traiDltEnabled: isTraiDLTEnable(isFullMode, smsRegister) || hasRegisteredSenderIdsForTest,
2918
2974
  };
2919
2975
  }
2920
2976
  return out;
@@ -3077,6 +3133,19 @@ const onTitleAddVar = () => {
3077
3133
  return unfilledVar;
3078
3134
  };
3079
3135
 
3136
+ // Mirror WhatsApp's isEditDoneDisabled: block Done while a CTA button's dynamic URL still
3137
+ // holds the unresolved {{1}} placeholder (no real personalization tag assigned yet).
3138
+ const hasUnresolvedDynamicCtaUrl = () =>
3139
+ isCarouselType
3140
+ ? (carouselData || []).some((card) =>
3141
+ (card?.suggestions || []).some((suggestion) =>
3142
+ suggestion?.type === CTA && (suggestion?.url || '').includes('{{1}}')
3143
+ )
3144
+ )
3145
+ : suggestions.some((suggestion) =>
3146
+ suggestion?.type === CTA && (suggestion?.url || '').includes('{{1}}')
3147
+ );
3148
+
3080
3149
  const isDisableDone = () => {
3081
3150
  if(isEditFlow){
3082
3151
  return false;
@@ -3118,6 +3187,9 @@ const onTitleAddVar = () => {
3118
3187
  return true;
3119
3188
  }
3120
3189
  }
3190
+ if (hasUnresolvedDynamicCtaUrl()) {
3191
+ return true;
3192
+ }
3121
3193
  if (templateDescError || templateTitleError) {
3122
3194
  return true;
3123
3195
  }
@@ -3162,6 +3234,9 @@ const onTitleAddVar = () => {
3162
3234
  return true;
3163
3235
  }
3164
3236
  }
3237
+ if (hasUnresolvedDynamicCtaUrl()) {
3238
+ return true;
3239
+ }
3165
3240
  if (templateTitleError || templateDescError) {
3166
3241
  return true;
3167
3242
  }
@@ -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,18 +316,48 @@ 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;
350
+ const smsFallbackTemplateName =
351
+ smsFallbackContent.templateName
352
+ || smsFallbackContent.smsTemplateName
353
+ || templateConfigsRecord.templateName
354
+ || templateConfigsRecord.name
355
+ || '';
305
356
  const hasFallbackPayload =
306
357
  smsFallbackContent
307
358
  && Object.keys(smsFallbackContent).length > 0
308
359
  && (
309
- !!smsFallbackContent.smsTemplateName
360
+ !!smsFallbackTemplateName
310
361
  || !!fallbackMessage
311
362
  || hasVarMapped
312
363
  );
@@ -325,7 +376,7 @@ export function resolveSmsFallbackHydrationFromDetails(details) {
325
376
  : (typeof base['unicode-validity'] === 'boolean' ? base['unicode-validity'] : true);
326
377
  const registeredSenderIds = extractRegisteredSenderIdsFromSmsFallbackRecord(smsFallbackContent);
327
378
  const nextSmsState = {
328
- templateName: smsFallbackContent.smsTemplateName || '',
379
+ templateName: smsFallbackTemplateName,
329
380
  content: fallbackMessage,
330
381
  templateContent: fallbackMessage,
331
382
  unicodeValidity: unicodeFromApi,