@capillarytech/creatives-library 9.0.36 → 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 (70) 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/translations/en.json +2 -2
  11. package/utils/getDataLayer.js +15 -0
  12. package/utils/gtmTrackers/gtmEvents/creativeDetails.js +2 -1
  13. package/utils/mfeDetect.js +1 -0
  14. package/utils/mfeFirstPaintReady.js +29 -0
  15. package/utils/mfeHistory.js +62 -0
  16. package/utils/permissions.js +59 -0
  17. package/utils/rcsPayloadUtils.js +5 -2
  18. package/utils/tests/permissions.test.js +116 -0
  19. package/utils/tests/rcsPayloadUtils.test.js +54 -2
  20. package/v2Components/AccessForbidden/_accessForbidden.scss +14 -0
  21. package/v2Components/AccessForbidden/index.js +5 -3
  22. package/v2Components/AccessForbidden/messages.js +3 -2
  23. package/v2Components/CapActionButton/constants.js +1 -0
  24. package/v2Components/CapActionButton/index.js +77 -9
  25. package/v2Components/CapActionButton/index.scss +13 -0
  26. package/v2Components/CapActionButton/messages.js +13 -0
  27. package/v2Components/CapActionButton/tests/index.test.js +32 -1
  28. package/v2Components/CommonTestAndPreview/index.js +44 -15
  29. package/v2Components/CommonTestAndPreview/tests/index.test.js +78 -0
  30. package/v2Components/CommonTestAndPreview/utils.js +34 -0
  31. package/v2Components/NavigationBar/index.js +9 -7
  32. package/v2Components/NavigationBar/mfeModuleHeader.config.js +16 -0
  33. package/v2Components/NavigationBar/tests/index.test.js +39 -19
  34. package/v2Components/NoPermissionWrapper/_noPermissionWrapper.scss +12 -0
  35. package/v2Components/NoPermissionWrapper/index.js +26 -0
  36. package/v2Components/SmsFallback/index.js +6 -0
  37. package/v2Containers/App/constants.js +5 -0
  38. package/v2Containers/Assets/Gallery/_gallery.scss +6 -0
  39. package/v2Containers/Assets/Gallery/index.js +34 -10
  40. package/v2Containers/Cap/constants.js +1 -0
  41. package/v2Containers/Cap/index.js +62 -29
  42. package/v2Containers/Cap/messages.js +4 -0
  43. package/v2Containers/Cap/tests/__snapshots__/index.test.js.snap +2 -2
  44. package/v2Containers/CreativesContainer/index.js +11 -8
  45. package/v2Containers/CreativesContainer/tests/index.test.js +53 -0
  46. package/v2Containers/Line/Container/ImageCarousel/tests/__snapshots__/content.test.js.snap +6 -6
  47. package/v2Containers/Line/Container/ImageCarousel/tests/__snapshots__/index.test.js.snap +4 -4
  48. package/v2Containers/Line/Container/Wrapper/tests/__snapshots__/index.test.js.snap +32 -32
  49. package/v2Containers/Line/Container/tests/__snapshots__/index.test.js.snap +36 -36
  50. package/v2Containers/Rcs/components/CarouselCard.js +5 -0
  51. package/v2Containers/Rcs/components/CarouselCardButtons.js +20 -0
  52. package/v2Containers/Rcs/index.js +154 -79
  53. package/v2Containers/Rcs/rcsLibraryHydrationUtils.js +55 -4
  54. package/v2Containers/Rcs/tests/__snapshots__/index.test.js.snap +404 -118
  55. package/v2Containers/Rcs/tests/carouselUtils.test.js +13 -17
  56. package/v2Containers/Rcs/tests/index.test.js +141 -6
  57. package/v2Containers/Rcs/tests/rcsLibraryHydrationUtils.test.js +110 -0
  58. package/v2Containers/Rcs/tests/utils.test.js +36 -0
  59. package/v2Containers/Rcs/utils.js +16 -4
  60. package/v2Containers/SmsTrai/Create/tests/__snapshots__/index.test.js.snap +8 -8
  61. package/v2Containers/SmsTrai/Edit/index.js +35 -24
  62. package/v2Containers/SmsTrai/Edit/tests/__snapshots__/index.test.js.snap +18 -18
  63. package/v2Containers/Templates/ChannelTypeIllustration.js +10 -1
  64. package/v2Containers/Templates/_templates.scss +7 -1
  65. package/v2Containers/Templates/index.js +141 -99
  66. package/v2Containers/Templates/tests/ChannelTypeIllustration.test.js +7 -0
  67. package/v2Containers/Templates/tests/__snapshots__/index.test.js.snap +173 -115
  68. package/v2Containers/TemplatesV2/TemplatesV2.style.js +8 -3
  69. package/v2Containers/TemplatesV2/index.js +38 -14
  70. package/v2Containers/Whatsapp/tests/__snapshots__/index.test.js.snap +268 -268
@@ -17,7 +17,8 @@ import CapButton from '@capillarytech/cap-ui-library/CapButton';
17
17
  import CapTooltip from '@capillarytech/cap-ui-library/CapTooltip';
18
18
  import CapTooltipWithInfo from '@capillarytech/cap-ui-library/CapTooltipWithInfo';
19
19
  import { CAP_SPACE_04 } from '@capillarytech/cap-ui-library/styled/variables';
20
- import globalMessages from '../../v2Containers/Cap/messages';import CapTagListWithInput from '../CapTagListWithInput';
20
+ import globalMessages from '../../v2Containers/Cap/messages';
21
+ import TagList from '../../v2Containers/TagList';
21
22
 
22
23
  import { isUrl, isValidText } from '../../v2Containers/Line/Container/Wrapper/utils';
23
24
  import messages from './messages';
@@ -30,6 +31,7 @@ import {
30
31
  RCS_CTA_URL_TYPE,
31
32
  CTA_TYPE_RADIO_OPTIONS,
32
33
  CTA_URL_TYPE_SELECT_OPTIONS,
34
+ DYNAMIC_URL_SUFFIX,
33
35
  } from './constants';
34
36
  import './index.scss';
35
37
  import { INITIAL_SUGGESTIONS, RCS_BUTTON_TYPES, HOST_ICS} from '../../v2Containers/Rcs/constants';
@@ -56,6 +58,7 @@ export const CapActionButton = (props) => {
56
58
  minSavedSuggestions = 0,
57
59
  hideDeleteSuggestionIndexes = [],
58
60
  } = props;
61
+ const { formatMessage } = intl;
59
62
  const isHostIcs = host === HOST_ICS;
60
63
  const [urlError, setUrlError] = useState(false);
61
64
  const [buttonError, setButtonError] = useState(false);
@@ -122,7 +125,8 @@ export const CapActionButton = (props) => {
122
125
  return formatMessage(messages.ctaWebsiteUrlErrorMessage);
123
126
  }
124
127
  if (urlSubtype === RCS_CTA_URL_TYPE.DYNAMIC) {
125
- return false;
128
+ const baseUrl = trimmedUrl.replace(invalidVarRegex, '');
129
+ return isUrl(baseUrl) ? false : formatMessage(messages.ctaWebsiteUrlErrorMessage);
126
130
  }
127
131
  if (!isUrl(trimmedUrl)) {
128
132
  return formatMessage(messages.ctaWebsiteUrlErrorMessage);
@@ -134,9 +138,12 @@ export const CapActionButton = (props) => {
134
138
  };
135
139
 
136
140
  const onUrlChange = ({ target }) => {
137
- const { value, id } = target;
141
+ const { id } = target;
138
142
  const row = suggestions[id] || {};
139
143
  const subtype = row.urlType || RCS_CTA_URL_TYPE.STATIC;
144
+ const value = subtype === RCS_CTA_URL_TYPE.DYNAMIC
145
+ ? target.value.replace(invalidVarRegex, '')
146
+ : target.value;
140
147
  setUrlError(validateCtaUrlValue(value, subtype));
141
148
  updateHandler(HANDLERS.URL, value, id);
142
149
  };
@@ -149,6 +156,14 @@ export const CapActionButton = (props) => {
149
156
  updateButtonChange(cloned, index);
150
157
  };
151
158
 
159
+ const onTagSelect = (data, index, url) => {
160
+ updateHandler(HANDLERS.URL, (url || '').replace(DYNAMIC_URL_SUFFIX, `{{${data}}}`), index);
161
+ };
162
+
163
+ const revertTagSelect = (index, url) => {
164
+ updateHandler(HANDLERS.URL, (url || '').replace(invalidVarRegex, DYNAMIC_URL_SUFFIX), index);
165
+ };
166
+
152
167
  const onPhoneNoChange = (value, index) => {
153
168
  updateHandler(HANDLERS.PHONE_NUMBER, value, index);
154
169
  };
@@ -161,12 +176,25 @@ export const CapActionButton = (props) => {
161
176
  return true;
162
177
  } if (type === RCS_BUTTON_TYPES.CTA) {
163
178
  const subtype = urlType || RCS_CTA_URL_TYPE.STATIC;
164
- return !!validateCtaUrlValue(url, subtype);
179
+ if (validateCtaUrlValue(url, subtype)) {
180
+ return true;
181
+ }
182
+ // A dynamic URL still holding the unresolved {{1}} placeholder has no real
183
+ // personalization tag assigned yet — block save until one is picked via "Add URL label".
184
+ return subtype === RCS_CTA_URL_TYPE.DYNAMIC && (url || '').includes(DYNAMIC_URL_SUFFIX);
165
185
  }
166
186
  return false;
167
187
  };
168
188
 
169
189
  const saveCta = (index) => {
190
+ const cta = suggestions[index];
191
+ if (
192
+ cta?.type === RCS_BUTTON_TYPES.CTA
193
+ && cta?.urlType === RCS_CTA_URL_TYPE.DYNAMIC
194
+ && !(cta?.url || '').includes(DYNAMIC_URL_SUFFIX)
195
+ ) {
196
+ cta.url = `${cta.url || ''}${DYNAMIC_URL_SUFFIX}`;
197
+ }
170
198
  updateHandler(HANDLERS.IS_SAVED, true, index);
171
199
  };
172
200
 
@@ -180,8 +208,6 @@ export const CapActionButton = (props) => {
180
208
  updateButtonChange(newSuggestion, suggestions?.length);
181
209
  };
182
210
 
183
- const { formatMessage } = intl;
184
-
185
211
  const hideDeleteForSuggestionIndex = (idx) =>
186
212
  Array.isArray(hideDeleteSuggestionIndexes) && hideDeleteSuggestionIndexes.includes(idx);
187
213
 
@@ -204,6 +230,7 @@ export const CapActionButton = (props) => {
204
230
  const urlSubtype = type === RCS_BUTTON_TYPES.CTA
205
231
  ? (cta.urlType || RCS_CTA_URL_TYPE.STATIC)
206
232
  : RCS_CTA_URL_TYPE.STATIC;
233
+ const isDynamicUrlSubtype = urlSubtype === RCS_CTA_URL_TYPE.DYNAMIC;
207
234
  const phoneNumber = type !== RCS_BUTTON_TYPES.PHONE_NUMBER ? null : cta.phoneNumber;
208
235
  if (isFullMode && !isEditFlow && !isSaved) {
209
236
  renderArray.push(
@@ -315,13 +342,32 @@ export const CapActionButton = (props) => {
315
342
  </CapColumn>
316
343
  <CapColumn span={18} className="rcs-cta-url-value-col">
317
344
  <CapHeading type="h4" className="cta-label">
318
- {formatMessage(messages.ctaUrlField)}
345
+ {formatMessage(messages.ctaWebsiteUrl)}
346
+ {isDynamicUrlSubtype && (
347
+ <CapTooltipWithInfo
348
+ infoIconProps={{
349
+ style: { marginLeft: CAP_SPACE_04 },
350
+ }}
351
+ autoAdjustOverflow
352
+ placement="right"
353
+ title={formatMessage(messages.ctaDynamicUrlTooltip, { one: DYNAMIC_URL_SUFFIX })}
354
+ />
355
+ )}
319
356
  </CapHeading>
320
357
  <CapInput
321
358
  id={index}
359
+ addonAfter={
360
+ isDynamicUrlSubtype
361
+ && !(url || '').includes(DYNAMIC_URL_SUFFIX)
362
+ && DYNAMIC_URL_SUFFIX
363
+ }
322
364
  className="rcs-cta-url"
323
365
  onChange={onUrlChange}
324
- placeholder={formatMessage(messages.ctaEnterUrlPlaceholder)}
366
+ placeholder={
367
+ isDynamicUrlSubtype
368
+ ? formatMessage(messages.ctaDynamicPlaceholder)
369
+ : formatMessage(messages.ctaEnterUrlPlaceholder)
370
+ }
325
371
  value={url || ''}
326
372
  size="large"
327
373
  maxLength={URL_MAX_LENGTH}
@@ -403,7 +449,7 @@ export const CapActionButton = (props) => {
403
449
  span={1}
404
450
  className={`${ctaIsPhone ? 'whatsapp-saved-cta-phone-icon' : ''}`}
405
451
  >
406
- <CapIcon size="s" type={ctaIsPhone ? 'call' : (ctaIsReply ? 'small-link' : 'launch')} />
452
+ <CapIcon size="s" type={ctaIsPhone ? 'call' : (ctaIsReply ? 'small-link' : 'open-in-new')} />
407
453
  </CapColumn>
408
454
  <CapColumn span={6}>
409
455
  <CapLabel
@@ -430,6 +476,28 @@ export const CapActionButton = (props) => {
430
476
  </CapTooltip>
431
477
  </>
432
478
  )}
479
+ {isDynamicUrlSubtype && (url || '').includes(DYNAMIC_URL_SUFFIX) && (
480
+ <TagList
481
+ className="rcs-cta-taglist"
482
+ label={formatMessage(messages.ctaTagListLabel)}
483
+ onTagSelect={(data) => onTagSelect(data, index, url)}
484
+ location={location}
485
+ tags={tags}
486
+ injectedTags={injectedTags}
487
+ selectedOfferDetails={selectedOfferDetails}
488
+ onContextChange={onContextChange}
489
+ />
490
+ )}
491
+ {isDynamicUrlSubtype && !(url || '').includes(DYNAMIC_URL_SUFFIX) && (
492
+ <CapTooltip title={formatMessage(messages.ctaTagListRevert)} placement="top">
493
+ <CapIcon
494
+ size="s"
495
+ type="return"
496
+ className="rcs-cta-tag-revert"
497
+ onClick={() => revertTagSelect(index, url)}
498
+ />
499
+ </CapTooltip>
500
+ )}
433
501
  {(isFullMode && !isEditFlow) && (
434
502
  <div className="rcs-saved-cta-action-icons">
435
503
  <CapIcon
@@ -84,6 +84,19 @@
84
84
  .rcs-saved-cta-delete-icon {
85
85
  cursor: pointer;
86
86
  }
87
+
88
+ // Dynamic-URL tag controls: keep them from stretching the flex row's
89
+ // height/width — same treatment as the edit/delete icons above.
90
+ .rcs-cta-tag-revert {
91
+ display: flex;
92
+ align-items: center;
93
+ flex-shrink: 0;
94
+ cursor: pointer;
95
+ }
96
+
97
+ .rcs-cta-taglist {
98
+ flex-shrink: 0;
99
+ }
87
100
  }
88
101
 
89
102
  // Button text / URL: count via CapInput `suffix` (antd affix, right-aligned inside field).
@@ -199,4 +199,17 @@ export default defineMessages({
199
199
  id: `${prefix}.addLabels`,
200
200
  defaultMessage: 'Add Labels',
201
201
  },
202
+ ctaDynamicUrlTooltip: {
203
+ id: `${prefix}.ctaDynamicUrlTooltip`,
204
+ defaultMessage:
205
+ 'Only one variable can be added to a URL. No need to add {one} to the end of the URL',
206
+ },
207
+ ctaTagListLabel: {
208
+ id: `${prefix}.ctaTagListLabel`,
209
+ defaultMessage: 'Add URL label',
210
+ },
211
+ ctaTagListRevert: {
212
+ id: `${prefix}.ctaTagListRevert`,
213
+ defaultMessage: 'Reset website URL label to default value',
214
+ },
202
215
  });
@@ -4,7 +4,9 @@ import '@testing-library/jest-dom';
4
4
  import { render, screen, fireEvent } from '../../../utils/test-utils';
5
5
  import { waitFor } from '@testing-library/react';
6
6
  import { CapActionButton } from '../index';
7
- import { BTN_MAX_LENGTH, PHONE_NUMBER_MAX_LENGTH, URL_MAX_LENGTH } from '../constants';
7
+ import {
8
+ BTN_MAX_LENGTH, PHONE_NUMBER_MAX_LENGTH, URL_MAX_LENGTH, RCS_CTA_URL_TYPE, DYNAMIC_URL_SUFFIX,
9
+ } from '../constants';
8
10
  import { RCS_BUTTON_TYPES, HOST_ICS } from '../../../v2Containers/Rcs/constants';
9
11
 
10
12
  const updateHandler = jest.fn();
@@ -27,6 +29,7 @@ const initializeComponent = (
27
29
  text: d.displayText ?? d.text ?? '',
28
30
  phoneNumber: d.phoneNumber ?? '',
29
31
  url: d.url ?? '',
32
+ urlType: d.urlType,
30
33
  postback: d.postback ?? '',
31
34
  isSaved: d.isSaved ?? false,
32
35
  }));
@@ -551,6 +554,34 @@ describe('CapActionButton', () => {
551
554
  expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
552
555
  });
553
556
 
557
+ it('should disable save for a dynamic-URL CTA still holding the unresolved {{1}} placeholder', () => {
558
+ const initial = {
559
+ index: 0,
560
+ type: RCS_BUTTON_TYPES.CTA,
561
+ text: 'Visit here',
562
+ url: `https://example.com${DYNAMIC_URL_SUFFIX}`,
563
+ urlType: RCS_CTA_URL_TYPE.DYNAMIC,
564
+ postback: 'Visit here',
565
+ isSaved: false,
566
+ };
567
+ initializeComponent([initial]);
568
+ expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
569
+ });
570
+
571
+ it('should enable save for a dynamic-URL CTA once the placeholder has been replaced with a real tag', () => {
572
+ const initial = {
573
+ index: 0,
574
+ type: RCS_BUTTON_TYPES.CTA,
575
+ text: 'Visit here',
576
+ url: 'https://example.com{{first_name}}',
577
+ urlType: RCS_CTA_URL_TYPE.DYNAMIC,
578
+ postback: 'Visit here',
579
+ isSaved: false,
580
+ };
581
+ initializeComponent([initial]);
582
+ expect(screen.getByRole('button', { name: /save/i })).not.toBeDisabled();
583
+ });
584
+
554
585
  it('should render edit and delete icons in edit mode (renderedContent)', () => {
555
586
  const button = {
556
587
  index: 1,
@@ -37,6 +37,8 @@ import {
37
37
  buildSyntheticSmsMustacheTags,
38
38
  normalizeRcsTestCardMedia,
39
39
  mapRcsSuggestionForTestMeta,
40
+ getRcsPrimaryTagExtractionText,
41
+ mergeSyntheticMustacheTags,
40
42
  } from './utils';
41
43
  import AddTestCustomerButton from './AddTestCustomer';
42
44
  import ExistingCustomerModal from './ExistingCustomerModal';
@@ -574,7 +576,7 @@ const CommonTestAndPreview = (props) => {
574
576
  }
575
577
  const hasFallbackSmsBody = !!(smsFallbackContent?.templateContent || smsFallbackContent?.content);
576
578
  if (channel === CHANNELS.RCS && hasFallbackSmsBody) {
577
- const rcsPrimaryTags = extractedTags ?? [];
579
+ const rcsPrimaryTags = mergeSyntheticMustacheTags(extractedTags, getRcsPrimaryTagExtractionText(formData));
578
580
  const fallbackSmsTextForTags = smsFallbackTextForTagExtraction ?? '';
579
581
  const fallbackSmsTagRows = smsTemplateHasMustacheTags(fallbackSmsTextForTags)
580
582
  ? (smsFallbackExtractedTags?.length > 0
@@ -585,11 +587,15 @@ const CommonTestAndPreview = (props) => {
585
587
  if (mergedRcsAndFallbackTags.length > 0) return mergedRcsAndFallbackTags;
586
588
  return buildSyntheticSmsMustacheTags(fallbackSmsTextForTags);
587
589
  }
590
+ if (channel === CHANNELS.RCS) {
591
+ return mergeSyntheticMustacheTags(extractedTags, getRcsPrimaryTagExtractionText(formData));
592
+ }
588
593
  return extractedTags ?? [];
589
594
  }, [
590
595
  channel,
591
596
  extractedTags,
592
597
  getCurrentContent,
598
+ formData,
593
599
  smsFallbackContent,
594
600
  smsFallbackExtractedTags,
595
601
  smsFallbackTextForTagExtraction,
@@ -665,10 +671,17 @@ const CommonTestAndPreview = (props) => {
665
671
  let resolvedText = text;
666
672
 
667
673
  // Replace each tag with its custom value
668
- Object.keys(tagValues).forEach((tagPath) => {
674
+ Object.keys(tagValues || {}).forEach((tagPath) => {
669
675
  const tagName = tagPath.split('.').pop(); // Get the actual tag name from the path
670
- const tagRegex = new RegExp(`{{${tagName}}}`, 'g');
671
- resolvedText = resolvedText.replace(tagRegex, tagValues[tagPath] || `{{${tagName}}}`);
676
+ // Escape regex metacharacters in tagName (arbitrary JSON key) so it can't break the pattern.
677
+ const escapedTagName = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
678
+ const tagRegex = new RegExp(`{{${escapedTagName}}}`, 'g');
679
+ const rawValue = tagValues[tagPath];
680
+ // Preserve legitimate falsy values (0, false); only an unset/blank slot keeps the placeholder.
681
+ const hasValue = rawValue !== null && rawValue !== undefined && rawValue !== '';
682
+ const replacement = hasValue ? String(rawValue) : `{{${tagName}}}`;
683
+ // Replacer function (not a string) so `$&`/`$1`-style sequences in the value are inserted literally.
684
+ resolvedText = resolvedText.replace(tagRegex, () => replacement);
672
685
  });
673
686
 
674
687
  return resolvedText;
@@ -1056,7 +1069,7 @@ const CommonTestAndPreview = (props) => {
1056
1069
  const buildRcsTestMessagePayload = (
1057
1070
  creativeFormData,
1058
1071
  _unusedEditorContentString,
1059
- _customValuesObj,
1072
+ customValuesObj,
1060
1073
  deliverySettingsOverride,
1061
1074
  basePayload,
1062
1075
  _rcsTestMetaExtras = {},
@@ -1071,7 +1084,9 @@ const CommonTestAndPreview = (props) => {
1071
1084
  } else if (rcsContentFromForm?.cardContent) {
1072
1085
  rcsCardPayloadList = [rcsContentFromForm.cardContent];
1073
1086
  }
1074
- // Raw title/description with template tags; SMS fallback uses tagged template fields (pickFirst…).
1087
+ // Title/description/suggestion text carry {{tag}} placeholders; resolve them against the
1088
+ // user's test values here, same as every other channel (see resolveTagsInText usage below
1089
+ // for SMS/EMAIL/WHATSAPP) — otherwise the test send goes out with literal tags unresolved.
1075
1090
  const cardContentForTestMetaApi = rcsCardPayloadList.map((singleRcsCardPayload) => {
1076
1091
  const normalizedCardMediaForTestApi = singleRcsCardPayload?.media
1077
1092
  ? normalizeRcsTestCardMedia(singleRcsCardPayload.media)
@@ -1079,11 +1094,25 @@ const CommonTestAndPreview = (props) => {
1079
1094
  const suggestionsFromCard = Array.isArray(singleRcsCardPayload?.suggestions)
1080
1095
  ? singleRcsCardPayload.suggestions
1081
1096
  : [];
1082
- const suggestionsFormattedForTestMeta = suggestionsFromCard.map((suggestionItem, index) =>
1083
- mapRcsSuggestionForTestMeta(suggestionItem, index));
1097
+ const suggestionsFormattedForTestMeta = suggestionsFromCard.map((suggestionItem, index) => mapRcsSuggestionForTestMeta(
1098
+ {
1099
+ ...suggestionItem,
1100
+ text: resolveTagsInText(
1101
+ suggestionItem?.text != null ? String(suggestionItem.text) : '',
1102
+ customValuesObj,
1103
+ ),
1104
+ },
1105
+ index,
1106
+ ));
1084
1107
  return {
1085
- title: singleRcsCardPayload?.title ?? '',
1086
- description: singleRcsCardPayload?.description ?? '',
1108
+ title: resolveTagsInText(
1109
+ singleRcsCardPayload?.title != null ? String(singleRcsCardPayload.title) : '',
1110
+ customValuesObj,
1111
+ ),
1112
+ description: resolveTagsInText(
1113
+ singleRcsCardPayload?.description != null ? String(singleRcsCardPayload.description) : '',
1114
+ customValuesObj,
1115
+ ),
1087
1116
  mediaType: singleRcsCardPayload?.mediaType ?? MEDIA_TYPE_TEXT,
1088
1117
  ...(normalizedCardMediaForTestApi && { media: normalizedCardMediaForTestApi }),
1089
1118
  ...(suggestionsFormattedForTestMeta.length > 0 && {
@@ -3161,7 +3190,8 @@ const CommonTestAndPreview = (props) => {
3161
3190
  * Apply tag extraction when content comes from RCS + SMS fallback (no API call).
3162
3191
  */
3163
3192
  const applyRcsSmsFallbackTagExtraction = () => {
3164
- const rcsPrimaryCategorized = categorizeTags(extractedTags ?? []);
3193
+ const rcsPrimaryTagTree = mergeSyntheticMustacheTags(extractedTags, getRcsPrimaryTagExtractionText(formData));
3194
+ const rcsPrimaryCategorized = categorizeTags(rcsPrimaryTagTree);
3165
3195
  const fallbackSmsResolvedForTags = smsFallbackTextForTagExtraction ?? '';
3166
3196
  let fallbackSmsTagTree = smsFallbackExtractedTags?.length > 0
3167
3197
  ? smsFallbackExtractedTags
@@ -3187,8 +3217,7 @@ const CommonTestAndPreview = (props) => {
3187
3217
  */
3188
3218
  useEffect(() => {
3189
3219
  if (!show) return;
3190
- const hasFallbackSmsTemplate = !!(smsFallbackContent?.templateContent || smsFallbackContent?.content);
3191
- if (channel === CHANNELS.RCS && hasFallbackSmsTemplate) {
3220
+ if (channel === CHANNELS.RCS) {
3192
3221
  applyRcsSmsFallbackTagExtraction();
3193
3222
  return;
3194
3223
  }
@@ -3204,8 +3233,8 @@ const CommonTestAndPreview = (props) => {
3204
3233
  setOptionalTags(optional);
3205
3234
  setTagsExtracted(hasPersonalizationTags);
3206
3235
  setCustomValues((prev) => mergeCustomValuesWithTagKeys(prev, { required, optional }, { required: [], optional: [] }));
3207
- // eslint-disable-next-line react-hooks/exhaustive-deps -- applyRcsSmsFallbackTagExtraction closes over latest extractedTags/smsFallbackExtractedTags
3208
- }, [show, extractedTags, channel, smsFallbackContent, smsFallbackExtractedTags, getCurrentContent, smsFallbackTextForTagExtraction]);
3236
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- applyRcsSmsFallbackTagExtraction closes over latest extractedTags/smsFallbackExtractedTags/formData
3237
+ }, [show, extractedTags, channel, formData, smsFallbackContent, smsFallbackExtractedTags, getCurrentContent, smsFallbackTextForTagExtraction]);
3209
3238
 
3210
3239
  /**
3211
3240
  * Get content to run tag extraction on (channel-specific).
@@ -3561,6 +3561,84 @@ describe('CommonTestAndPreview', () => {
3561
3561
  });
3562
3562
  });
3563
3563
 
3564
+ describe('RCS primary card content synthetic tag fallback', () => {
3565
+ const rcsCardFormData = (description) => ({
3566
+ rcsTitle: '',
3567
+ rcsDesc: description,
3568
+ versions: {
3569
+ base: {
3570
+ content: {
3571
+ RCS: {
3572
+ rcsContent: {
3573
+ cardContent: [{ title: '', description }],
3574
+ },
3575
+ },
3576
+ },
3577
+ },
3578
+ },
3579
+ });
3580
+
3581
+ it('should surface a {{tag}} from RCS card description as a fillable field when the extract-tags API returns nothing (no SMS fallback)', async () => {
3582
+ render(
3583
+ <TestWrapper>
3584
+ <CommonTestAndPreview
3585
+ {...defaultProps}
3586
+ channel={CHANNELS.RCS}
3587
+ formData={rcsCardFormData('Visit store for offer {{user_id_b64}}')}
3588
+ extractedTags={[]}
3589
+ />
3590
+ </TestWrapper>
3591
+ );
3592
+
3593
+ await waitFor(() => expect(lastLeftPanelContentProps).not.toBeNull());
3594
+
3595
+ const tags = lastLeftPanelContentProps.extractedTags;
3596
+ expect(tags.some((tag) => tag.name === 'user_id_b64')).toBe(true);
3597
+ });
3598
+
3599
+ it('should surface an RCS card tag alongside a merged fallback SMS tag when both are present', async () => {
3600
+ render(
3601
+ <TestWrapper>
3602
+ <CommonTestAndPreview
3603
+ {...defaultProps}
3604
+ channel={CHANNELS.RCS}
3605
+ formData={rcsCardFormData('Visit store for offer {{user_id_b64}}')}
3606
+ extractedTags={[]}
3607
+ smsFallbackContent={{
3608
+ templateContent: 'Fallback {{name}}',
3609
+ content: 'Fallback {{name}}',
3610
+ }}
3611
+ />
3612
+ </TestWrapper>
3613
+ );
3614
+
3615
+ await waitFor(() => expect(lastLeftPanelContentProps).not.toBeNull());
3616
+
3617
+ const tags = lastLeftPanelContentProps.extractedTags;
3618
+ expect(tags.some((tag) => tag.name === 'user_id_b64')).toBe(true);
3619
+ expect(tags.some((tag) => tag.name === 'name')).toBe(true);
3620
+ });
3621
+
3622
+ it('should not duplicate a tag the extract-tags API already returned for RCS card content', async () => {
3623
+ const apiTags = [{ name: 'user_id_b64', metaData: { userDriven: false }, children: [] }];
3624
+ render(
3625
+ <TestWrapper>
3626
+ <CommonTestAndPreview
3627
+ {...defaultProps}
3628
+ channel={CHANNELS.RCS}
3629
+ formData={rcsCardFormData('Visit store for offer {{user_id_b64}}')}
3630
+ extractedTags={apiTags}
3631
+ />
3632
+ </TestWrapper>
3633
+ );
3634
+
3635
+ await waitFor(() => expect(lastLeftPanelContentProps).not.toBeNull());
3636
+
3637
+ const tags = lastLeftPanelContentProps.extractedTags;
3638
+ expect(tags.filter((tag) => tag.name === 'user_id_b64')).toHaveLength(1);
3639
+ });
3640
+ });
3641
+
3564
3642
  describe('handleDiscardCustomValues — preview reset', () => {
3565
3643
  it('should call updatePreviewRequested when handleDiscardCustomValues is invoked', async () => {
3566
3644
  render(
@@ -27,6 +27,40 @@ export const buildSyntheticSmsMustacheTags = (body = '') => {
27
27
  }));
28
28
  };
29
29
 
30
+ /**
31
+ * Raw RCS primary-card text (title + description across all cards) to scan for `{{…}}` tags.
32
+ * Mirrors the carousel/standalone branching in prepareTagExtractionPayload's RCS case so both
33
+ * the extract-tags API payload and the client-side synthetic tag fallback agree on card text.
34
+ */
35
+ export const getRcsPrimaryTagExtractionText = (formDataObj) => {
36
+ const rcsCardContentArr =
37
+ formDataObj?.versions?.base?.content?.RCS?.rcsContent?.cardContent
38
+ ?? formDataObj?.content?.RCS?.rcsContent?.cardContent;
39
+ if (Array.isArray(rcsCardContentArr) && rcsCardContentArr.length > 0) {
40
+ const carouselTagText = rcsCardContentArr
41
+ .map((card) => `${card.title || ''} ${card.description || ''}`)
42
+ .join(' ')
43
+ .trim();
44
+ if (carouselTagText) return carouselTagText;
45
+ }
46
+ return `${formDataObj?.rcsTitle || ''} ${formDataObj?.rcsDesc || ''}`.trim();
47
+ };
48
+
49
+ /**
50
+ * Combine tags the extract-tags API returned with tags synthesized client-side from a raw
51
+ * `{{…}}` scan of `rawText`, so a tag the backend's catalog doesn't recognize (e.g. a reserved
52
+ * token like `user_id_b64`) still surfaces as a fillable field instead of silently disappearing.
53
+ * Dedupes by name so API-recognized tags (which carry richer metaData) are never overridden.
54
+ */
55
+ export const mergeSyntheticMustacheTags = (apiTags = [], rawText = '') => {
56
+ if (!smsTemplateHasMustacheTags(rawText)) return apiTags ?? [];
57
+ const existingNames = new Set((apiTags ?? []).map((tag) => tag?.name));
58
+ const synthetic = buildSyntheticSmsMustacheTags(rawText).filter(
59
+ (tag) => !existingNames.has(tag.name),
60
+ );
61
+ return [...(apiTags ?? []), ...synthetic];
62
+ };
63
+
30
64
  /** RCS createMessageMeta: normalize media shape (mediaUrl, thumbnailUrl, height string). */
31
65
  export const normalizeRcsTestCardMedia = (media) => {
32
66
  if (!media || typeof media !== 'object') return undefined;
@@ -12,8 +12,10 @@ import { loadItem } from 'services/localStorageApi';
12
12
  import { intlShape, injectIntl } from 'react-intl';
13
13
  import { withRouter } from 'react-router-dom';
14
14
  import { get } from 'lodash';
15
- import CapNavigation from '@capillarytech/cap-ui-library/CapNavigation';
15
+ import CapNavigation from '@capillarytech/cap-ui-library/CapNavigationSPA';
16
16
  import { CAP_WHITE } from '@capillarytech/cap-ui-library/styled/variables';
17
+ import { MFEModuleHeader } from '@capillarytech/cap-ui-utils';
18
+ import { MFE_MODULE_HEADER } from './mfeModuleHeader.config';
17
19
  import injectSaga from '../../utils/injectSaga';
18
20
  import sagas from './saga';
19
21
  import messages from './messages';
@@ -30,12 +32,11 @@ import { CapLeftNavigatioOpenCss, CapLeftNavigationCss } from './style';
30
32
  import { EMBEDDED } from './constants';
31
33
 
32
34
  const CapWrapper = styled.div`
33
- position: absolute;
34
35
  padding: 0;
35
36
  background-color: ${CAP_WHITE};
36
37
  width: 100%;
37
38
  display: flex;
38
- margin-top: ${(props) => props.isEmbedded ? '0px' : '64px'};
39
+
39
40
 
40
41
  .sidebar-container {
41
42
  margin-right: 10px;
@@ -46,10 +47,8 @@ const CapWrapper = styled.div`
46
47
  `;
47
48
 
48
49
  const ComponentWrapper = styled.div`
49
- max-width: 1140px;
50
- margin: 0 auto;
51
50
  width: 100%;
52
- padding: 24px 0;
51
+ padding: 0;
53
52
  `;
54
53
 
55
54
  export class NavigationBar extends React.Component {
@@ -58,6 +57,7 @@ export class NavigationBar extends React.Component {
58
57
  this.state = this.initializeSelectedProduct();
59
58
  }
60
59
 
60
+
61
61
  initializeSelectedProduct = () => {
62
62
  const { location, intl: { formatMessage } } = this.props;
63
63
  const { pathname } = location;
@@ -179,6 +179,7 @@ export class NavigationBar extends React.Component {
179
179
  const topbarIcons = this.getTopbarIcons(showDocumentationBot);
180
180
  const headerOverideCss = leftNavbarExpandedProp ? CapLeftNavigatioOpenCss : CapLeftNavigationCss;
181
181
  return (
182
+ <>
182
183
  <CapNavigation
183
184
  className="creatives-main-container"
184
185
  showContent
@@ -212,7 +213,8 @@ export class NavigationBar extends React.Component {
212
213
  </CapWrapper>
213
214
  </div>
214
215
  </CapNavigation>
215
-
216
+ <MFEModuleHeader history={this.props.history} {...MFE_MODULE_HEADER} />
217
+ </>
216
218
  );
217
219
  }
218
220
  }
@@ -0,0 +1,16 @@
1
+ // Static, module-specific config for the shared MFE module-header wrapper
2
+ // (MFEModuleHeader). Runtime values (history) are supplied in NavigationBar.
3
+ export const MFE_MODULE_HEADER = {
4
+ header: {
5
+ name: 'app.commonUtils.capUiLibrary.navigationSPA.moduleHeader.creatives.default.name',
6
+ description: 'app.commonUtils.capUiLibrary.navigationSPA.moduleHeader.creatives.default.description',
7
+ showBorder: false,
8
+ showSettings: false,
9
+ },
10
+ landingRoutes: [
11
+ '/creatives/ui/v2/',
12
+ '/creatives/ui/v2',
13
+ '/v2/',
14
+ '/v2'
15
+ ],
16
+ };