@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
@@ -79,7 +79,6 @@
79
79
  flex: 1;
80
80
  height: calc(100vh - 50px);
81
81
  overflow: auto;
82
- padding: 8px 16px 0;
83
82
  color: #333333;
84
83
  }
85
84
 
@@ -154,16 +153,19 @@
154
153
  }
155
154
 
156
155
  .cap-loader-box {
157
- position: fixed;
156
+ position: absolute;
158
157
  top: 0;
158
+ left: 0;
159
159
  width: 100%;
160
- height: 100%;
161
- background: rgb(255,255,255);
162
- text-align: center;
160
+ min-height: calc(100vh - 48px);
161
+ background: #ffffff;
162
+ display: flex;
163
+ align-items: center;
164
+ justify-content: center;
163
165
 
164
166
  .loader-image {
165
167
  width: 80px;
166
- margin-top: 20%;
168
+ height: auto;
167
169
  }
168
170
  }
169
171
 
@@ -0,0 +1,15 @@
1
+ import { isMFEMode } from './mfeDetect';
2
+ import appConfig from '../app-config';
3
+
4
+ const getDataLayerName = () => {
5
+ if (isMFEMode()) {
6
+ return `dataLayer_${appConfig.appName.replace(/-/g, '_')}`;
7
+ }
8
+ return 'dataLayer';
9
+ };
10
+
11
+ export const getDataLayer = () => {
12
+ const name = getDataLayerName();
13
+ window[name] = window[name] || [];
14
+ return window[name];
15
+ };
@@ -1,4 +1,5 @@
1
1
  import { CREATIVES } from "../../../v2Containers/App/constants";
2
+ import { getDataLayer } from '../../getDataLayer';
2
3
 
3
4
  const creativeDetails = ({
4
5
  name,
@@ -23,7 +24,7 @@ const creativeDetails = ({
23
24
  videoAdded,
24
25
  stickersAdded
25
26
  };
26
- window.dataLayer.push({
27
+ getDataLayer().push({
27
28
  creativeDetails: parsedObj,
28
29
  event: 'creativeDetails',
29
30
  });
@@ -0,0 +1 @@
1
+ export const isMFEMode = () => Boolean(window.MFE_HOST);
@@ -0,0 +1,29 @@
1
+ import { MFEEventBus } from '@capillarytech/cap-ui-utils';
2
+ import appConfig from '../app-config';
3
+ import { isMFEMode } from './mfeDetect';
4
+
5
+ // Routes whose containers emit the data-settled 'lcp' themselves —
6
+ // the first-paint emit must NOT fire for these.
7
+ // TemplatesV2 renders at relative '/v2' and '/v2/loyalty' (routes.js); in MFE mode
8
+ // the shared history is rebased on publicPath, so the real browser pathname carries
9
+ // the '/creatives/ui' prefix. Match the prefixed paths — this util reads
10
+ // window.location.pathname, not the rebased router location. (v1 standalone is
11
+ // non-MFE and irrelevant here.)
12
+ const DATA_LANDING_MATCHERS = ['/creatives/ui/v2', '/creatives/ui/v2/loyalty'];
13
+
14
+ export const isDataLandingPath = (pathname) => {
15
+ const p = (pathname || '').replace(/\/+$/, '') || '/';
16
+ return DATA_LANDING_MATCHERS.some((m) => (m instanceof RegExp ? m.test(p) : m === p));
17
+ };
18
+
19
+ export const emitFirstPaintReadyIfNotDataLanding = () => {
20
+ if (!isMFEMode() || isDataLandingPath(window.location.pathname)) return;
21
+ requestAnimationFrame(() => {
22
+ MFEEventBus.emit('mfe:segment', {
23
+ action: 'stop',
24
+ appId: appConfig.appName,
25
+ type: 'lcp',
26
+ timestamp: performance.now(),
27
+ });
28
+ });
29
+ };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Re-base the host's shared root history for this MFE remote.
3
+ *
4
+ * The MFE host owns ONE root history instance and hands it to every remote, so host + all
5
+ * remotes stay perfectly in sync: a push on the shared instance notifies every mounted
6
+ * router directly (no popstate hack, no drift on fast remote switching).
7
+ *
8
+ * This remote's routes / <Link>s / history.push calls are written RELATIVE to its serving
9
+ * prefix. This adapter presents a basename'd VIEW of the shared history — stripping the
10
+ * prefix when the router reads the location, and prepending it when the router navigates —
11
+ * while delegating all actual navigation to the shared instance. Net effect: relative
12
+ * routing keeps working unchanged, and navigation still flows through the single shared
13
+ * history. Standalone (no host history) is unaffected; the remote keeps creating its own.
14
+ */
15
+ const addBase = (base, to) => {
16
+ if (typeof to === 'string') {
17
+ if (!to.startsWith('/')) return to; // already relative to current location
18
+ return to === '/' ? base : `${base}${to}`;
19
+ }
20
+ if (to && typeof to === 'object') {
21
+ return { ...to, pathname: addBase(base, to.pathname || '/') };
22
+ }
23
+ return to;
24
+ };
25
+
26
+ const stripBase = (base, pathname) => {
27
+ if (!pathname) return pathname;
28
+ if (pathname === base) return '/';
29
+ if (pathname.startsWith(`${base}/`)) return pathname.slice(base.length);
30
+ return pathname;
31
+ };
32
+
33
+ export default function rebaseHistory(history, rawBase) {
34
+ const base = String(rawBase || '').replace(/\/+$/, ''); // normalise: no trailing slash
35
+ if (!base) return history;
36
+ const view = loc => ({ ...loc, pathname: stripBase(base, loc.pathname) });
37
+ return {
38
+ get length() {
39
+ return history.length;
40
+ },
41
+ get action() {
42
+ return history.action;
43
+ },
44
+ get location() {
45
+ return view(history.location);
46
+ },
47
+ push: (to, state) => history.push(addBase(base, to), state),
48
+ replace: (to, state) => history.replace(addBase(base, to), state),
49
+ go: n => history.go(n),
50
+ goBack: () => history.goBack(),
51
+ goForward: () => history.goForward(),
52
+ block: (...args) => history.block(...args),
53
+ listen: listener =>
54
+ history.listen((loc, action) => listener(view(loc), action)),
55
+ createHref: to =>
56
+ history.createHref(
57
+ typeof to === 'string'
58
+ ? addBase(base, to)
59
+ : { ...to, pathname: addBase(base, (to && to.pathname) || '/') },
60
+ ),
61
+ };
62
+ }
@@ -59,9 +59,12 @@ export const normalizeRcsMessageContentForApi = messageContentItem => {
59
59
  rcsCardPayloadOnly.cardContent.forEach(card => {
60
60
  if (Array.isArray(card?.suggestions)) {
61
61
  card.suggestions.forEach(suggestion => {
62
- // isSaved is UI-only editor state: campaigns' send-for-approval API rejects it, and it
63
- // isn't part of the persisted payload shape.
62
+ // isSaved and urlType are UI-only editor state: campaigns' send-for-approval API
63
+ // rejects them, and they aren't part of the persisted payload shape. The dynamic tag
64
+ // (if any) already lives inline in `url` (e.g. "https://x.com{{first_name}}") — no
65
+ // separate dynamicUrlPayload field is sent for RCS.
64
66
  delete suggestion?.isSaved;
67
+ delete suggestion?.urlType;
65
68
  });
66
69
  }
67
70
  });
@@ -209,7 +209,7 @@ describe('normalizeRcsMessageContentForApi', () => {
209
209
  expect(item.rcsContent.cardContent).toEqual([{ title: 'Card' }]);
210
210
  });
211
211
 
212
- it('strips isSaved from each suggestion in cardContent', () => {
212
+ it('strips isSaved and urlType from each suggestion in cardContent', () => {
213
213
  const item = {
214
214
  rcsContent: {
215
215
  cardContent: [
@@ -217,7 +217,9 @@ describe('normalizeRcsMessageContentForApi', () => {
217
217
  title: 'Card 1',
218
218
  suggestions: [
219
219
  { type: 'QUICK_REPLY', text: 'Yes', isSaved: true },
220
- { type: 'CTA', text: 'Go', url: 'https://example.com', isSaved: false },
220
+ {
221
+ type: 'CTA', text: 'Go', url: 'https://example.com', urlType: 'STATIC', isSaved: false,
222
+ },
221
223
  ],
222
224
  },
223
225
  ],
@@ -230,6 +232,56 @@ describe('normalizeRcsMessageContentForApi', () => {
230
232
  ]);
231
233
  });
232
234
 
235
+ it('keeps type "CTA" and the inline tag in url, but drops urlType, for a dynamic-URL suggestion', () => {
236
+ const item = {
237
+ rcsContent: {
238
+ cardContent: [
239
+ {
240
+ title: 'Card 1',
241
+ suggestions: [
242
+ {
243
+ type: 'CTA',
244
+ text: 'Visit here',
245
+ url: 'https://example.com/{{first_name}}',
246
+ urlType: 'DYNAMIC',
247
+ isSaved: true,
248
+ },
249
+ ],
250
+ },
251
+ ],
252
+ },
253
+ };
254
+ normalizeRcsMessageContentForApi(item);
255
+ expect(item.rcsContent.cardContent[0].suggestions).toEqual([
256
+ {
257
+ type: 'CTA',
258
+ text: 'Visit here',
259
+ url: 'https://example.com/{{first_name}}',
260
+ },
261
+ ]);
262
+ });
263
+
264
+ it('leaves phone number and quick reply suggestions untouched by the urlType stripping', () => {
265
+ const item = {
266
+ rcsContent: {
267
+ cardContent: [
268
+ {
269
+ title: 'Card 1',
270
+ suggestions: [
271
+ {
272
+ type: 'PHONE_NUMBER', text: 'Call', phoneNumber: '918987678', isSaved: true,
273
+ },
274
+ ],
275
+ },
276
+ ],
277
+ },
278
+ };
279
+ normalizeRcsMessageContentForApi(item);
280
+ expect(item.rcsContent.cardContent[0].suggestions).toEqual([
281
+ { type: 'PHONE_NUMBER', text: 'Call', phoneNumber: '918987678' },
282
+ ]);
283
+ });
284
+
233
285
  it('handles cardContent with no suggestions gracefully', () => {
234
286
  const item = {
235
287
  rcsContent: {
@@ -43,6 +43,7 @@ export const RCS_CTA_URL_TYPE = {
43
43
  export const BTN_MAX_LENGTH = 20;
44
44
  export const PHONE_NUMBER_MAX_LENGTH = 15;
45
45
  export const URL_MAX_LENGTH = 2000;
46
+ export const DYNAMIC_URL_SUFFIX = '{{1}}';
46
47
 
47
48
  export const CTA_TYPE_RADIO_OPTIONS = [
48
49
  { label: <FormattedMessage {...messages.ctaPhoneNo} />, value: RCS_BUTTON_TYPES.PHONE_NUMBER },
@@ -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).