@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.
- package/AppRoot.js +124 -0
- package/app-config.js +61 -0
- package/app.js +10 -175
- package/bootstrap.js +185 -0
- package/entry.js +67 -1
- package/mfe-exposed-components.js +2 -4
- package/package.json +2 -2
- package/services/api.js +9 -1
- package/styles/containers/layout/_layoutPage.scss +8 -6
- package/utils/getDataLayer.js +15 -0
- package/utils/gtmTrackers/gtmEvents/creativeDetails.js +2 -1
- package/utils/mfeDetect.js +1 -0
- package/utils/mfeFirstPaintReady.js +29 -0
- package/utils/mfeHistory.js +62 -0
- package/utils/rcsPayloadUtils.js +5 -2
- package/utils/tests/rcsPayloadUtils.test.js +54 -2
- package/v2Components/CapActionButton/constants.js +1 -0
- package/v2Components/CapActionButton/index.js +77 -9
- package/v2Components/CapActionButton/index.scss +13 -0
- package/v2Components/CapActionButton/messages.js +13 -0
- package/v2Components/CapActionButton/tests/index.test.js +32 -1
- package/v2Components/CommonTestAndPreview/index.js +44 -15
- package/v2Components/CommonTestAndPreview/tests/index.test.js +78 -0
- package/v2Components/CommonTestAndPreview/utils.js +34 -0
- package/v2Components/NavigationBar/index.js +9 -7
- package/v2Components/NavigationBar/mfeModuleHeader.config.js +16 -0
- package/v2Components/NavigationBar/tests/index.test.js +39 -19
- package/v2Components/SmsFallback/index.js +6 -0
- package/v2Containers/Cap/constants.js +1 -0
- package/v2Containers/Cap/index.js +57 -27
- package/v2Containers/CreativesContainer/index.js +11 -8
- package/v2Containers/CreativesContainer/tests/index.test.js +53 -0
- package/v2Containers/Rcs/components/CarouselCard.js +5 -0
- package/v2Containers/Rcs/components/CarouselCardButtons.js +20 -0
- package/v2Containers/Rcs/index.js +154 -79
- package/v2Containers/Rcs/rcsLibraryHydrationUtils.js +55 -4
- package/v2Containers/Rcs/tests/__snapshots__/index.test.js.snap +286 -0
- package/v2Containers/Rcs/tests/carouselUtils.test.js +13 -17
- package/v2Containers/Rcs/tests/index.test.js +141 -6
- package/v2Containers/Rcs/tests/rcsLibraryHydrationUtils.test.js +110 -0
- package/v2Containers/Rcs/tests/utils.test.js +36 -0
- package/v2Containers/Rcs/utils.js +16 -4
- package/v2Containers/SmsTrai/Edit/index.js +35 -24
- package/v2Containers/Templates/_templates.scss +1 -1
- package/v2Containers/Templates/index.js +1 -0
- package/v2Containers/Templates/tests/__snapshots__/index.test.js.snap +5 -0
- package/v2Containers/TemplatesV2/TemplatesV2.style.js +8 -3
- 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:
|
|
156
|
+
position: absolute;
|
|
158
157
|
top: 0;
|
|
158
|
+
left: 0;
|
|
159
159
|
width: 100%;
|
|
160
|
-
height:
|
|
161
|
-
background:
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/utils/rcsPayloadUtils.js
CHANGED
|
@@ -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
|
|
63
|
-
//
|
|
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
|
-
{
|
|
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';
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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={
|
|
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' : '
|
|
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 {
|
|
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
|
-
|
|
671
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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:
|
|
1086
|
-
|
|
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
|
|
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
|
-
|
|
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).
|