@capillarytech/creatives-library 8.0.226 → 8.0.227

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@capillarytech/creatives-library",
3
3
  "author": "meharaj",
4
- "version": "8.0.226",
4
+ "version": "8.0.227",
5
5
  "description": "Capillary creatives ui",
6
6
  "main": "./index.js",
7
7
  "module": "./index.es.js",
@@ -19,7 +19,6 @@ const SUBTAGS = 'subtags';
19
19
  * @param {Object} tagObject - The tagLookupMap.
20
20
  */
21
21
  export const checkSupport = (response = {}, tagObject = {}, eventContextTags = [], isLiquidFlow = false, forwardedTags = {}) => {
22
-
23
22
  const supportedList = [];
24
23
  // Verifies the presence of the tag in the 'Add Labels' section.
25
24
  // Incase of journey event context the tags won't be available in the tagObject(tagLookupMap).
@@ -40,7 +39,7 @@ export const checkSupport = (response = {}, tagObject = {}, eventContextTags = [
40
39
  let updatedChildName = childName;
41
40
  let updatedWithoutDotChildName = childName;
42
41
  if (childName?.includes(".")) {
43
- updatedChildName = "." + childName?.split(".")?.[1];
42
+ updatedChildName = `.${childName?.split(".")?.[1]}`;
44
43
  updatedWithoutDotChildName = childName?.split(".")?.[1];
45
44
  }
46
45
  if (tagObject?.[parentTag]) {
@@ -71,7 +70,6 @@ export const checkSupport = (response = {}, tagObject = {}, eventContextTags = [
71
70
  if (item?.children?.length) {
72
71
  processChildren(item?.name, item?.children);
73
72
  }
74
-
75
73
  }
76
74
 
77
75
 
@@ -80,19 +78,19 @@ export const checkSupport = (response = {}, tagObject = {}, eventContextTags = [
80
78
 
81
79
  const handleForwardedTags = (forwardedTags) => {
82
80
  const result = [];
83
- Object.keys(forwardedTags).forEach(key => {
81
+ Object.keys(forwardedTags).forEach((key) => {
84
82
  result.push(key); // Add the main key to the result array
85
-
83
+
86
84
  // Check if there are subtags for the current key
87
85
  if (forwardedTags[key].subtags) {
88
86
  // If subtags exist, add all subtag keys to the result array
89
- Object.keys(forwardedTags[key].subtags).forEach(subkey => {
90
- result.push(subkey);
87
+ Object.keys(forwardedTags[key].subtags).forEach((subkey) => {
88
+ result.push(subkey);
91
89
  });
92
90
  }
93
91
  });
94
92
  return result;
95
- }
93
+ };
96
94
 
97
95
  /**
98
96
  * Extracts the names from the given data.
@@ -155,7 +153,7 @@ export const validateTags = ({
155
153
  unsupportedTags: [],
156
154
  isBraceError: false,
157
155
  };
158
- if(tags && tags.length) {
156
+ if (tags && tags.length) {
159
157
  lodashForEach(tags, ({
160
158
  definition: {
161
159
  supportedModules,
@@ -216,7 +214,7 @@ export const validateTags = ({
216
214
  // validations (eg button) are handled on valid property coming from the response.
217
215
  response.isBraceError ? response.valid = false : response.valid = true;
218
216
  return response;
219
- }
217
+ };
220
218
 
221
219
  /**
222
220
  * Checks if the given tag is supported based on the injected tags.
@@ -233,14 +231,14 @@ export const checkIfSupportedTag = (checkingTag, injectedTags) => {
233
231
  result = true;
234
232
  }
235
233
  });
236
-
234
+
237
235
  return result;
238
236
  };
239
237
 
240
238
  const indexOfEnd = (targetString, string) => {
241
- let io = targetString.indexOf(string);
239
+ const io = targetString.indexOf(string);
242
240
  return io == -1 ? -1 : io + string.length;
243
- }
241
+ };
244
242
 
245
243
  export const skipTags = (tag) => {
246
244
  // If the tag contains the word "entryTrigger.", then it's an event context tag and should not be skipped.
@@ -268,35 +266,100 @@ export const transformInjectedTags = (tags) => {
268
266
  if (subKey !== '') {
269
267
  temp['tag-header'] = true;
270
268
  if (subKey !== SUBTAGS) {
271
- temp.subtags =lodashCloneDeep(temp[subKey]);
269
+ temp.subtags = lodashCloneDeep(temp[subKey]);
272
270
  delete temp[subKey];
273
271
  }
274
272
  temp.subtags = transformInjectedTags(temp.subtags);
275
273
  }
276
274
  });
277
275
  return tags;
278
- }
276
+ };
279
277
 
280
278
  //checks if the opening curly brackets have corresponding closing brackets
281
279
  export const validateIfTagClosed = (value) => {
282
280
  if (value.includes("{{{{") || value.includes("}}}}")) {
283
281
  return false;
284
282
  }
285
- let regex1 = /{{.*?}}/g;
286
- let regex2 = /{{/g;
287
- let regex3 = /}}/g;
283
+ const regex1 = /{{.*?}}/g;
284
+ const regex2 = /{{/g;
285
+ const regex3 = /}}/g;
288
286
 
289
- let l1 = value.match(regex1)?.length;
290
- let l2 = value.match(regex2)?.length;
291
- let l3 = value.match(regex3)?.length;
287
+ const l1 = value.match(regex1)?.length;
288
+ const l2 = value.match(regex2)?.length;
289
+ const l3 = value.match(regex3)?.length;
292
290
 
293
291
  return (l1 == l2 && l2 == l3 && l1 == l3);
294
-
292
+ };
293
+
294
+ /**
295
+ * Validates tag format: ensures tags are in format {{tag_name}} and checks for invalid patterns
296
+ * Validates against:
297
+ * - Single braces like {tag} (must be {{tag}})
298
+ * - Invalid patterns like {{first or first}}, {{first and first}}
299
+ * - Empty tag names
300
+ * - Unclosed single braces within tag names
301
+ * @param {string} textContent - The text content to validate
302
+ * @returns {boolean} - True if all tags have valid format, false otherwise
303
+ */
304
+ export const validateTagFormat = (textContent) => {
305
+ // Find all potential tag patterns {{tag_name}}
306
+ const tagPattern = /{{[^}]*}}/g;
307
+ const matches = textContent.match(tagPattern) || [];
308
+
309
+ // Remove all valid {{tag}} patterns from content to check for invalid braces
310
+ let contentWithoutValidTags = textContent;
311
+ matches.forEach((match) => {
312
+ contentWithoutValidTags = contentWithoutValidTags.replace(match, '');
313
+ });
314
+
315
+ // Check if there are any remaining braces (single braces or unclosed braces)
316
+ // These would be invalid patterns like {tag}, {first, first}, etc.
317
+ if (contentWithoutValidTags.includes('{') || contentWithoutValidTags.includes('}')) {
318
+ return false;
319
+ }
320
+
321
+ // Check each tag for valid format
322
+ const allTagsValid = matches.every((match) => {
323
+ // Valid tag format: {{tag_name}} - must start with {{ and end with }}
324
+ if (!match.startsWith('{{') || !match.endsWith('}}')) {
325
+ return false;
326
+ }
327
+
328
+ // Extract tag name (content between {{ and }})
329
+ const tagName = match.slice(2, -2).trim();
330
+
331
+ // Tag name should not be empty
332
+ if (!tagName) {
333
+ return false;
334
+ }
335
+
336
+ // Check for invalid patterns in tag name
337
+ // Invalid patterns: "first or first", "first and first", etc.
338
+ const invalidPatterns = [
339
+ /\s+or\s+/i, // " or " as separate word (e.g., "first or first")
340
+ /\s+and\s+/i, // " and " as separate word
341
+ ];
342
+
343
+ const hasInvalidPattern = invalidPatterns.some((pattern) => pattern.test(tagName));
344
+ if (hasInvalidPattern) {
345
+ return false;
346
+ }
347
+
348
+ // Check for unclosed single braces in tag name (e.g., {{first{name}})
349
+ const singleOpenBraces = (tagName.match(/{/g) || []).length;
350
+ const singleCloseBraces = (tagName.match(/}/g) || []).length;
351
+ if (singleOpenBraces !== singleCloseBraces) {
352
+ return false;
353
+ }
354
+
355
+ return true;
356
+ });
357
+
358
+ return allTagsValid;
295
359
  };
296
360
 
297
361
  //replaces encoded string with their respective characters
298
362
  export const preprocessHtml = (content) => {
299
-
300
363
  const replacements = {
301
364
  "'": "'",
302
365
  """: "'",
@@ -304,7 +367,7 @@ export const preprocessHtml = (content) => {
304
367
  "&": "&",
305
368
  "&lt;": "<",
306
369
  "&gt;": ">",
307
- "\n": "", // Handling newlines by replacing them with an empty string
370
+ "\n": "", // Handling newlines by replacing them with an empty string
308
371
  };
309
372
 
310
373
 
@@ -318,28 +381,22 @@ export const preprocessHtml = (content) => {
318
381
  });
319
382
 
320
383
  // Step 2: Perform the standard replacements on the entire content
321
- return contentWithStyleFixes?.replace(/&#39;|&quot;|&amp;|&lt;|&gt;|"|\n/g, match => replacements[match]);
384
+ return contentWithStyleFixes?.replace(/&#39;|&quot;|&amp;|&lt;|&gt;|"|\n/g, (match) => replacements[match]);
322
385
  };
323
386
 
324
387
  //this is used to get the subtags from custom or extended tags
325
- export const getTagMapValue = (object = {}) => {
326
- return Object.values(
327
- object
328
- ).reduce((acc, current) => {
329
- return { ...acc, ...current?.subtags ?? {} };
330
- }, {});
331
- };
332
-
333
- export const getLoyaltyTagsMapValue = (object = {}) => {
334
- return Object.entries(object).reduce((acc, [key, current]) => {
335
- if (current?.subtags && Object.keys(current.subtags).length > 0) {
336
- // If subtags exist → merge them
337
- return { ...acc, ...(current.subtags ?? {}) };
338
- }
339
- // If no subtags → keep the tag itself
340
- return { ...acc, [key]: current };
341
- }, {});
342
- };
388
+ export const getTagMapValue = (object = {}) => Object.values(
389
+ object
390
+ ).reduce((acc, current) => ({ ...acc, ...current?.subtags ?? {} }), {});
391
+
392
+ export const getLoyaltyTagsMapValue = (object = {}) => Object.entries(object).reduce((acc, [key, current]) => {
393
+ if (current?.subtags && Object.keys(current.subtags).length > 0) {
394
+ // If subtags exist → merge them
395
+ return { ...acc, ...(current.subtags ?? {}) };
396
+ }
397
+ // If no subtags keep the tag itself
398
+ return { ...acc, [key]: current };
399
+ }, {});
343
400
 
344
401
 
345
402
  /**
@@ -348,27 +405,25 @@ export const getLoyaltyTagsMapValue = (object = {}) => {
348
405
  * @param {Object} object - The input object containing top-level keys with optional subtags.
349
406
  * @returns {Object} - A flat map containing all top-level keys and their subtags.
350
407
  */
351
- export const getForwardedMapValues = (object = {}) => {
352
- return Object?.entries(object)?.reduce((acc, [key, current]) => {
353
- // Check if current has 'subtags' and it's an object
354
- if (current && current?.subtags && typeof current?.subtags === 'object') {
355
- // Add the top-level key with its 'name' and 'desc'
356
- acc[key] = {
357
- name: current?.name,
358
- desc: current?.desc,
359
- };
360
-
361
- // Merge the subtags into the accumulator
362
- acc = { ...acc, ...current?.subtags };
363
- } else if (current && typeof current === 'object') {
364
- // If no 'subtags', add the top-level key with its 'name' and 'desc'
365
- acc[key] = {
366
- name: current?.name,
367
- desc: current?.desc,
368
- };
369
- }
370
-
371
- // If the current entry is not an object or lacks 'name'/'desc', skip it
372
- return acc;
373
- }, {});
374
- };
408
+ export const getForwardedMapValues = (object = {}) => Object?.entries(object)?.reduce((acc, [key, current]) => {
409
+ // Check if current has 'subtags' and it's an object
410
+ if (current && current?.subtags && typeof current?.subtags === 'object') {
411
+ // Add the top-level key with its 'name' and 'desc'
412
+ acc[key] = {
413
+ name: current?.name,
414
+ desc: current?.desc,
415
+ };
416
+
417
+ // Merge the subtags into the accumulator
418
+ acc = { ...acc, ...current?.subtags };
419
+ } else if (current && typeof current === 'object') {
420
+ // If no 'subtags', add the top-level key with its 'name' and 'desc'
421
+ acc[key] = {
422
+ name: current?.name,
423
+ desc: current?.desc,
424
+ };
425
+ }
426
+
427
+ // If the current entry is not an object or lacks 'name'/'desc', skip it
428
+ return acc;
429
+ }, {});
@@ -10,35 +10,36 @@ import {
10
10
  validateTags,
11
11
  skipTags,
12
12
  isInsideLiquidBlock,
13
+ validateTagFormat,
13
14
  } from '../tagValidations';
14
15
  import { containsBase64Images } from '../content';
15
16
  import { eventContextTags } from '../../v2Containers/TagList/tests/mockdata';
16
17
 
17
18
  describe("check if curly brackets are balanced", () => {
18
- it("test for balanced curly brackets", () => {
19
- let value = "hello {{optout"
20
- let res = validateIfTagClosed(value);
21
- expect(res).toEqual(false);
22
- value += "}}"
23
- let result = validateIfTagClosed(value);
24
- expect(result).toEqual(true);
25
- //valid cases
26
- expect(validateIfTagClosed("{{{Hello}}}")).toEqual(true);
27
- expect(validateIfTagClosed("{{{Hello}}")).toEqual(true);
28
- expect(validateIfTagClosed("{{Hello}}}")).toEqual(true);
29
-
30
- //invalid cases
31
- expect(validateIfTagClosed("{{Hello}")).toEqual(false);
32
- expect(validateIfTagClosed("{Hello}}")).toEqual(false);
33
- expect(validateIfTagClosed("{{{Hello}")).toEqual(false);
34
- expect(validateIfTagClosed("{Hello}}}")).toEqual(false);
35
- expect(validateIfTagClosed("{{{{Hello}}}}")).toEqual(false);
36
- expect(validateIfTagClosed("{{{{{Hello}}}}}")).toEqual(false);
37
- expect(validateIfTagClosed("{{{{Hello}}")).toEqual(false);
38
- expect(validateIfTagClosed("{{{{Hello}}World}}")).toEqual(false);
39
- expect(validateIfTagClosed("{{Hello{{World}}")).toEqual(false);
40
- })
41
- })
19
+ it("test for balanced curly brackets", () => {
20
+ let value = "hello {{optout";
21
+ const res = validateIfTagClosed(value);
22
+ expect(res).toEqual(false);
23
+ value += "}}";
24
+ const result = validateIfTagClosed(value);
25
+ expect(result).toEqual(true);
26
+ //valid cases
27
+ expect(validateIfTagClosed("{{{Hello}}}")).toEqual(true);
28
+ expect(validateIfTagClosed("{{{Hello}}")).toEqual(true);
29
+ expect(validateIfTagClosed("{{Hello}}}")).toEqual(true);
30
+
31
+ //invalid cases
32
+ expect(validateIfTagClosed("{{Hello}")).toEqual(false);
33
+ expect(validateIfTagClosed("{Hello}}")).toEqual(false);
34
+ expect(validateIfTagClosed("{{{Hello}")).toEqual(false);
35
+ expect(validateIfTagClosed("{Hello}}}")).toEqual(false);
36
+ expect(validateIfTagClosed("{{{{Hello}}}}")).toEqual(false);
37
+ expect(validateIfTagClosed("{{{{{Hello}}}}}")).toEqual(false);
38
+ expect(validateIfTagClosed("{{{{Hello}}")).toEqual(false);
39
+ expect(validateIfTagClosed("{{{{Hello}}World}}")).toEqual(false);
40
+ expect(validateIfTagClosed("{{Hello{{World}}")).toEqual(false);
41
+ });
42
+ });
42
43
 
43
44
  describe("validateTags", () => {
44
45
  const tagsParam = [
@@ -85,12 +86,12 @@ describe("validateTags", () => {
85
86
 
86
87
  it("should return invalid response when a mandatory tag is missing", () => {
87
88
  const content = "Hello {{tag1}}, {{tag2}}, {{tag3}}";
88
- const updatedTagsParam = [...tagsParam, {
89
+ const updatedTagsParam = [...tagsParam, {
89
90
  definition: {
90
91
  supportedModules: [{ context: "DEFAULT", mandatory: true }],
91
92
  value: "tag4",
92
93
  },
93
- },];
94
+ }];
94
95
  const injectedTagsParams = [];
95
96
  const location = { query: { module: "DEFAULT" } };
96
97
  const tagModule = null;
@@ -351,9 +352,9 @@ describe("checkSupport", () => {
351
352
  data: [{ name: "tag1" }, { name: "tag2" }, { name: "tag3" }],
352
353
  };
353
354
  const tagObject = {
354
- tag1: { definition: { subtags: ["tag1.1", "tag1.2"] } },
355
- tag2: { definition: { subtags: ["tag2.1", "tag2.2"] } },
356
- tag3: { definition: { subtags: ["tag3.1", "tag3.2"] } },
355
+ "tag1": { definition: { subtags: ["tag1.1", "tag1.2"] } },
356
+ "tag2": { definition: { subtags: ["tag2.1", "tag2.2"] } },
357
+ "tag3": { definition: { subtags: ["tag3.1", "tag3.2"] } },
357
358
  "tag1.1": {},
358
359
  "tag2.1": {},
359
360
  "tag3.1": {},
@@ -386,9 +387,9 @@ describe("checkSupport", () => {
386
387
  ],
387
388
  };
388
389
  const tagObject = {
389
- tag1: { definition: { subtags: [".1", "tag1.2"] } },
390
- tag2: { definition: { subtags: ["tag2.1", "tag2.2"] } },
391
- tag3: { definition: { subtags: ["tag3.1", "tag3.2"] } },
390
+ "tag1": { definition: { subtags: [".1", "tag1.2"] } },
391
+ "tag2": { definition: { subtags: ["tag2.1", "tag2.2"] } },
392
+ "tag3": { definition: { subtags: ["tag3.1", "tag3.2"] } },
392
393
  "tag1.1": {},
393
394
  "tag2.1": {},
394
395
  "tag3.1": {},
@@ -405,9 +406,9 @@ describe("checkSupport", () => {
405
406
  ],
406
407
  };
407
408
  const tagObject = {
408
- tag1: { definition: { subtags: [".1", "tag1.2"] } },
409
- tag2: { definition: { subtags: ["tag2.1", "tag2.2"] } },
410
- tag3: { definition: { subtags: ["tag3.1", "tag3.2"] } },
409
+ "tag1": { definition: { subtags: [".1", "tag1.2"] } },
410
+ "tag2": { definition: { subtags: ["tag2.1", "tag2.2"] } },
411
+ "tag3": { definition: { subtags: ["tag3.1", "tag3.2"] } },
411
412
  "tag1.1": {},
412
413
  "tag2.1": {},
413
414
  "tag3.1": {},
@@ -433,7 +434,7 @@ describe("checkSupport", () => {
433
434
  const eventContextTags = [{ tagName: "leaderboard", subTags: ["userId"]}];
434
435
  const isLiquidFlow = true;
435
436
  const result = checkSupport(response, tagObject, eventContextTags, isLiquidFlow);
436
- expect(result).toEqual( [ 'leaderboard', 'person.userId' ]);
437
+ expect(result).toEqual( ['leaderboard', 'person.userId']);
437
438
  });
438
439
 
439
440
  it("should not add childName to supportedList which does not have dot in eventContextTags", () => {
@@ -443,7 +444,7 @@ describe("checkSupport", () => {
443
444
  const eventContextTags = [{ tagName: "entryTrigger.lifetimePoints", children: [{name: "userId"}] }];
444
445
  const isLiquidFlow = true;
445
446
  const result = checkSupport(response, tagObject, eventContextTags, isLiquidFlow);
446
- expect(result).toEqual( [ "entryTrigger.lifetimePoints" ]);
447
+ expect(result).toEqual( ["entryTrigger.lifetimePoints"]);
447
448
  });
448
449
 
449
450
  it("should add only parent tag to supportedList if isLiquidFlow false", () => {
@@ -452,7 +453,7 @@ describe("checkSupport", () => {
452
453
  const eventContextTags = [{ tagName: "leaderboard", subTags: ["userId"]}];
453
454
  const isLiquidFlow = false;
454
455
  const result = checkSupport(response, tagObject, eventContextTags, isLiquidFlow);
455
- expect(result).toEqual( [ 'leaderboard' ]);
456
+ expect(result).toEqual( ['leaderboard']);
456
457
  });
457
458
 
458
459
  it("test for checking loyalty tags in that are coming in forwardedTags", () => {
@@ -472,7 +473,7 @@ describe("checkSupport", () => {
472
473
  },
473
474
  };
474
475
  const result = checkSupport(response, tagObject, [], isLiquidFlow, forwardedTags);
475
- expect(result).toEqual( [ 'leaderboard', 'person.userId' ]);
476
+ expect(result).toEqual( ['leaderboard', 'person.userId']);
476
477
  });
477
478
  });
478
479
 
@@ -529,8 +530,6 @@ describe('preprocessHtml', () => {
529
530
  const expectedOutput = "<style {line-height:0;font-size:75%} >sup{line-height:0;font-size:75% }</style> ";
530
531
  expect(preprocessHtml(input)).toEqual(expectedOutput);
531
532
  });
532
-
533
-
534
533
  });
535
534
 
536
535
 
@@ -556,7 +555,7 @@ describe("getTagMapValue", () => {
556
555
  },
557
556
  };
558
557
  const result = getTagMapValue(object);
559
- expect(result).toEqual({ name: "233", tagName: "234", });
558
+ expect(result).toEqual({ name: "233", tagName: "234" });
560
559
  });
561
560
  it("should return the tag map value when an object is provided but subtag is not present in one of the obj", () => {
562
561
  const object = {
@@ -888,9 +887,9 @@ describe("getLoyaltyTagsMapValue", () => {
888
887
  },
889
888
  };
890
889
  const inputCopy = JSON.parse(JSON.stringify(object));
891
-
890
+
892
891
  getLoyaltyTagsMapValue(object);
893
-
892
+
894
893
  expect(object).toEqual(inputCopy);
895
894
  });
896
895
 
@@ -1393,7 +1392,7 @@ describe('containsBase64Images', () => {
1393
1392
 
1394
1393
  beforeEach(() => {
1395
1394
  mockCapNotification = {
1396
- error: jest.fn()
1395
+ error: jest.fn(),
1397
1396
  };
1398
1397
  mockCallback = jest.fn();
1399
1398
  });
@@ -1420,7 +1419,7 @@ describe('containsBase64Images', () => {
1420
1419
  it('should return true for content with base64 images and trigger notification', () => {
1421
1420
  const content = '<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==" alt="test" />';
1422
1421
  expect(containsBase64Images({content, CapNotification: mockCapNotification, callback: mockCallback})).toBe(true);
1423
-
1422
+
1424
1423
  expect(mockCapNotification.error).toHaveBeenCalledWith({
1425
1424
  key: 'base64Error',
1426
1425
  message: 'Base64 images are not allowed. Please upload your image to a gallery and use it, or add the image URL instead.',
@@ -1452,12 +1451,12 @@ describe('containsBase64Images', () => {
1452
1451
  const jpegContent = '<img src="data:image/jpeg;base64,/9j/4AAQ..." />';
1453
1452
  const gifContent = '<img src="data:image/gif;base64,R0lGODlh..." />';
1454
1453
  const webpContent = '<img src="data:image/webp;base64,UklGRi..." />';
1455
-
1454
+
1456
1455
  expect(containsBase64Images({content: pngContent, CapNotification: mockCapNotification})).toBe(true);
1457
1456
  expect(containsBase64Images({content: jpegContent, CapNotification: mockCapNotification})).toBe(true);
1458
1457
  expect(containsBase64Images({content: gifContent, CapNotification: mockCapNotification})).toBe(true);
1459
1458
  expect(containsBase64Images({content: webpContent, CapNotification: mockCapNotification})).toBe(true);
1460
-
1459
+
1461
1460
  expect(mockCapNotification.error).toHaveBeenCalledTimes(4);
1462
1461
  });
1463
1462
 
@@ -1489,11 +1488,209 @@ describe('containsBase64Images', () => {
1489
1488
  it('should execute callback only when base64 images are found', () => {
1490
1489
  const contentWithBase64 = '<img src="data:image/png;base64,iVBORw0..." alt="test" />';
1491
1490
  const contentWithoutBase64 = '<img src="https://example.com/image.jpg" alt="test" />';
1492
-
1491
+
1493
1492
  containsBase64Images({content: contentWithBase64, callback: mockCallback});
1494
1493
  expect(mockCallback).toHaveBeenCalledTimes(1);
1495
-
1494
+
1496
1495
  containsBase64Images({content: contentWithoutBase64, callback: mockCallback});
1497
1496
  expect(mockCallback).toHaveBeenCalledTimes(1); // Should still be 1, not called again
1498
1497
  });
1499
1498
  });
1499
+
1500
+ describe('validateTagFormat', () => {
1501
+ describe('valid tag formats', () => {
1502
+ it('should return true for empty string', () => {
1503
+ expect(validateTagFormat('')).toBe(true);
1504
+ });
1505
+
1506
+ it('should return true for text without tags', () => {
1507
+ expect(validateTagFormat('Hello world')).toBe(true);
1508
+ expect(validateTagFormat('No tags here at all')).toBe(true);
1509
+ });
1510
+
1511
+ it('should return true for a single valid tag', () => {
1512
+ expect(validateTagFormat('{{tag_name}}')).toBe(true);
1513
+ expect(validateTagFormat('{{first_name}}')).toBe(true);
1514
+ expect(validateTagFormat('{{customer.name}}')).toBe(true);
1515
+ });
1516
+
1517
+ it('should return true for multiple valid tags', () => {
1518
+ expect(validateTagFormat('Hello {{first_name}}, your order {{order_id}} is ready')).toBe(true);
1519
+ expect(validateTagFormat('{{tag1}} and {{tag2}} and {{tag3}}')).toBe(true);
1520
+ });
1521
+
1522
+ it('should return true for tags with various characters', () => {
1523
+ expect(validateTagFormat('{{tag_name_123}}')).toBe(true);
1524
+ expect(validateTagFormat('{{customer.first_name}}')).toBe(true);
1525
+ expect(validateTagFormat('{{tag-name}}')).toBe(true);
1526
+ expect(validateTagFormat('{{tag.name}}')).toBe(true);
1527
+ });
1528
+
1529
+ it('should return true for tags with whitespace in tag name (trimmed)', () => {
1530
+ expect(validateTagFormat('{{ tag_name }}')).toBe(true);
1531
+ expect(validateTagFormat('{{ tag_name }}')).toBe(true);
1532
+ });
1533
+
1534
+ it('should return true for tags with special characters in tag name', () => {
1535
+ expect(validateTagFormat('{{tag@name}}')).toBe(true);
1536
+ expect(validateTagFormat('{{tag#name}}')).toBe(true);
1537
+ expect(validateTagFormat('{{tag$name}}')).toBe(true);
1538
+ });
1539
+ });
1540
+
1541
+ describe('invalid tag formats - single braces', () => {
1542
+ it('should return false for single opening brace', () => {
1543
+ expect(validateTagFormat('{tag}')).toBe(false);
1544
+ expect(validateTagFormat('Hello {name}')).toBe(false);
1545
+ });
1546
+
1547
+ it('should return false for single closing brace', () => {
1548
+ expect(validateTagFormat('tag}')).toBe(false);
1549
+ expect(validateTagFormat('Hello name}')).toBe(false);
1550
+ });
1551
+
1552
+ it('should return false for unclosed single brace', () => {
1553
+ expect(validateTagFormat('{tag')).toBe(false);
1554
+ expect(validateTagFormat('tag}')).toBe(false);
1555
+ });
1556
+
1557
+ it('should return false for mixed single and double braces', () => {
1558
+ expect(validateTagFormat('{{valid}} and {invalid}')).toBe(false);
1559
+ expect(validateTagFormat('{invalid} and {{valid}}')).toBe(false);
1560
+ });
1561
+ });
1562
+
1563
+ describe('invalid tag formats - unclosed double braces', () => {
1564
+ it('should return false for unclosed opening double braces', () => {
1565
+ expect(validateTagFormat('{{tag')).toBe(false);
1566
+ expect(validateTagFormat('{{tag}')).toBe(false);
1567
+ });
1568
+
1569
+ it('should return false for unclosed closing double braces', () => {
1570
+ expect(validateTagFormat('tag}}')).toBe(false);
1571
+ expect(validateTagFormat('{tag}}')).toBe(false);
1572
+ });
1573
+
1574
+ it('should return false for mismatched braces', () => {
1575
+ expect(validateTagFormat('{{tag}')).toBe(false);
1576
+ expect(validateTagFormat('{tag}}')).toBe(false);
1577
+ });
1578
+ });
1579
+
1580
+ describe('invalid tag formats - empty tag names', () => {
1581
+ it('should return false for empty tag name', () => {
1582
+ expect(validateTagFormat('{{}}')).toBe(false);
1583
+ });
1584
+
1585
+ it('should return false for tag with only whitespace', () => {
1586
+ expect(validateTagFormat('{{ }}')).toBe(false);
1587
+ });
1588
+ });
1589
+
1590
+ describe('invalid tag formats - invalid patterns', () => {
1591
+ it('should return false for tag with "or" pattern', () => {
1592
+ expect(validateTagFormat('{{first or first}}')).toBe(false);
1593
+ expect(validateTagFormat('{{tag1 or tag2}}')).toBe(false);
1594
+ expect(validateTagFormat('{{name OR name}}')).toBe(false);
1595
+ expect(validateTagFormat('{{first Or first}}')).toBe(false);
1596
+ });
1597
+
1598
+ it('should return false for tag with "and" pattern', () => {
1599
+ expect(validateTagFormat('{{first and first}}')).toBe(false);
1600
+ expect(validateTagFormat('{{tag1 and tag2}}')).toBe(false);
1601
+ expect(validateTagFormat('{{name AND name}}')).toBe(false);
1602
+ expect(validateTagFormat('{{first And first}}')).toBe(false);
1603
+ });
1604
+
1605
+ it('should return false for tag with "or" pattern in mixed case', () => {
1606
+ expect(validateTagFormat('{{first Or first}}')).toBe(false);
1607
+ expect(validateTagFormat('{{first OR first}}')).toBe(false);
1608
+ expect(validateTagFormat('{{first oR first}}')).toBe(false);
1609
+ });
1610
+
1611
+ it('should return false for tag with "and" pattern in mixed case', () => {
1612
+ expect(validateTagFormat('{{first And first}}')).toBe(false);
1613
+ expect(validateTagFormat('{{first AND first}}')).toBe(false);
1614
+ expect(validateTagFormat('{{first aNd first}}')).toBe(false);
1615
+ });
1616
+
1617
+ it('should allow "or" and "and" as part of other words', () => {
1618
+ expect(validateTagFormat('{{order_id}}')).toBe(true);
1619
+ expect(validateTagFormat('{{customer_name}}')).toBe(true);
1620
+ expect(validateTagFormat('{{landing_page}}')).toBe(true);
1621
+ });
1622
+ });
1623
+
1624
+ describe('invalid tag formats - unclosed single braces in tag name', () => {
1625
+ it('should return false for unclosed single opening brace in tag name', () => {
1626
+ expect(validateTagFormat('{{first{name}}')).toBe(false);
1627
+ expect(validateTagFormat('{{tag{value}}')).toBe(false);
1628
+ });
1629
+
1630
+ it('should return false for unclosed single closing brace in tag name', () => {
1631
+ expect(validateTagFormat('{{first}name}}')).toBe(false);
1632
+ expect(validateTagFormat('{{tag}value}}')).toBe(false);
1633
+ });
1634
+
1635
+ it('should return false for mismatched single braces in tag name', () => {
1636
+ expect(validateTagFormat('{{first{name}}')).toBe(false);
1637
+ expect(validateTagFormat('{{first}name}}')).toBe(false);
1638
+ });
1639
+
1640
+ it('should return false for balanced single braces in tag name (regex cannot match)', () => {
1641
+ // The regex pattern /{{[^}]*}}/g stops at the first }, so {{first{name}}}
1642
+ // would leave a remaining } brace, making it invalid
1643
+ expect(validateTagFormat('{{first{name}}}')).toBe(false);
1644
+ expect(validateTagFormat('{{tag{value}}}')).toBe(false);
1645
+ });
1646
+ });
1647
+
1648
+ describe('invalid tag formats - remaining braces after removing valid tags', () => {
1649
+ it('should return false when there are remaining single braces after valid tags', () => {
1650
+ expect(validateTagFormat('{{valid}} {invalid}')).toBe(false);
1651
+ expect(validateTagFormat('{invalid} {{valid}}')).toBe(false);
1652
+ });
1653
+
1654
+ it('should return false when there are remaining unclosed braces', () => {
1655
+ expect(validateTagFormat('{{valid}} {unclosed')).toBe(false);
1656
+ expect(validateTagFormat('{{valid}} unclosed}')).toBe(false);
1657
+ });
1658
+
1659
+ it('should return false for complex mixed valid and invalid patterns', () => {
1660
+ expect(validateTagFormat('{{tag1}} and {tag2} and {{tag3}}')).toBe(false);
1661
+ });
1662
+ });
1663
+
1664
+ describe('edge cases', () => {
1665
+ it('should return false for tags that do not start with {{', () => {
1666
+ expect(validateTagFormat('{tag}}')).toBe(false);
1667
+ });
1668
+
1669
+ it('should return false for tags that do not end with }}', () => {
1670
+ expect(validateTagFormat('{{tag}')).toBe(false);
1671
+ });
1672
+
1673
+ it('should handle multiple invalid tags', () => {
1674
+ expect(validateTagFormat('{{first or first}} and {{second and second}}')).toBe(false);
1675
+ });
1676
+
1677
+ it('should return false if any tag is invalid in a string with multiple tags', () => {
1678
+ expect(validateTagFormat('{{valid1}} {{valid2}} {{invalid or invalid}}')).toBe(false);
1679
+ expect(validateTagFormat('{{invalid or invalid}} {{valid1}} {{valid2}}')).toBe(false);
1680
+ });
1681
+
1682
+ it('should return true for all valid tags even with many tags', () => {
1683
+ expect(validateTagFormat('{{tag1}} {{tag2}} {{tag3}} {{tag4}} {{tag5}}')).toBe(true);
1684
+ });
1685
+
1686
+ it('should handle tags with nested structure', () => {
1687
+ expect(validateTagFormat('{{customer.first_name}}')).toBe(true);
1688
+ expect(validateTagFormat('{{order.items.count}}')).toBe(true);
1689
+ });
1690
+
1691
+ it('should handle tags with numbers and underscores', () => {
1692
+ expect(validateTagFormat('{{tag_123}}')).toBe(true);
1693
+ expect(validateTagFormat('{{tag1_2_3}}')).toBe(true);
1694
+ });
1695
+ });
1696
+ });
@@ -4,10 +4,11 @@ export const UPLOAD = 'upload';
4
4
  export const USE_EDITOR = 'useEditor';
5
5
  export const COPY_PRIMARY_LANGUAGE = 'copyPrimaryLanguage';
6
6
  export const GLOBAL_CONVERT_OPTIONS = {
7
- selectors: [
8
- ...[1, 2, 3, 4, 5, 6].map(level => ({
9
- selector: `h${level}`,
10
- options: { uppercase: false }
11
- }))
12
- ]
13
- };
7
+ wordwrap: null,
8
+ selectors: [
9
+ ...[1, 2, 3, 4, 5, 6].map((level) => ({
10
+ selector: `h${level}`,
11
+ options: { uppercase: false },
12
+ })),
13
+ ],
14
+ };
@@ -54,7 +54,7 @@ import { containsBase64Images } from '../../utils/content';
54
54
  import { SMS, MOBILE_PUSH, LINE, ENABLE_AI_SUGGESTIONS,AI_CONTENT_BOT_DISABLED, EMAIL, LIQUID_SUPPORTED_CHANNELS, INAPP } from '../../v2Containers/CreativesContainer/constants';
55
55
  import globalMessages from '../../v2Containers/Cap/messages';
56
56
  import { convert } from 'html-to-text';
57
- import { OUTBOUND, ADD_LANGUAGE, UPLOAD, USE_EDITOR, COPY_PRIMARY_LANGUAGE } from './constants';
57
+ import { OUTBOUND, ADD_LANGUAGE, UPLOAD, USE_EDITOR, COPY_PRIMARY_LANGUAGE, GLOBAL_CONVERT_OPTIONS } from './constants';
58
58
  import { GET_TRANSLATION_MAPPED } from '../../constants/unified';
59
59
  import moment from 'moment';
60
60
  import { CUSTOMER_BARCODE_TAG , COPY_OF, ENTRY_TRIGGER_TAG_REGEX, SKIP_TAGS_REGEX_GROUPS} from '../../constants/unified';
@@ -1516,7 +1516,7 @@ class FormBuilder extends React.Component { // eslint-disable-line react/prefer-
1516
1516
  response.unsupportedTags = [];
1517
1517
  response.isBraceError = false;
1518
1518
  response.isContentEmpty = false;
1519
- const contentForValidation = isEmail ? convert(content) : content ;
1519
+ const contentForValidation = isEmail ? convert(content, GLOBAL_CONVERT_OPTIONS) : content ;
1520
1520
  if(tags && tags.length) {
1521
1521
  _.forEach(tags, (tag) => {
1522
1522
  _.forEach(tag.definition.supportedModules, (module) => {
@@ -9,6 +9,19 @@ import CapTreeSelect from '@capillarytech/cap-ui-library/CapTreeSelect';
9
9
  import isEmpty from 'lodash/isEmpty';
10
10
  import messages from './messages';
11
11
 
12
+ const isSendTestButtonDisabled = ({
13
+ selectedTestEntities,
14
+ formData,
15
+ isSendingTestMessage,
16
+ isContentValid,
17
+ }) => {
18
+ const hasSelectedEntities = !isEmpty(selectedTestEntities);
19
+ const hasTemplateSubject = !isEmpty(formData['template-subject']) || !isEmpty(formData[0]?.['template-subject']);
20
+ const isReadyToSend = hasSelectedEntities && hasTemplateSubject && isContentValid && !isSendingTestMessage;
21
+
22
+ return !isReadyToSend;
23
+ };
24
+
12
25
  const SendTestMessage = ({
13
26
  isFetchingTestCustomers,
14
27
  isFetchingTestGroups,
@@ -19,6 +32,7 @@ const SendTestMessage = ({
19
32
  formData,
20
33
  isSendingTestMessage,
21
34
  formatMessage,
35
+ isContentValid = true,
22
36
  }) => (
23
37
  <CapStepsAccordian
24
38
  showNumberSteps={false}
@@ -43,7 +57,16 @@ const SendTestMessage = ({
43
57
  multiple
44
58
  placeholder={formatMessage(messages.testCustomersPlaceholder)}
45
59
  />
46
- <CapButton onClick={handleSendTestMessage} disabled={isEmpty(selectedTestEntities) || (isEmpty(formData['template-subject']) && isEmpty(formData[0]?.['template-subject'])) || isSendingTestMessage}>
60
+ <CapButton
61
+ onClick={handleSendTestMessage}
62
+ disabled={isSendTestButtonDisabled({
63
+ selectedTestEntities,
64
+ formData,
65
+ isSendingTestMessage,
66
+ isContentValid,
67
+ })}
68
+ title={!isContentValid ? formatMessage(messages.contentInvalid) : ''}
69
+ >
47
70
  <FormattedMessage {...messages.sendTestButton} />
48
71
  </CapButton>
49
72
  </CapRow>),
@@ -63,6 +86,7 @@ SendTestMessage.propTypes = {
63
86
  formData: PropTypes.object.isRequired,
64
87
  isSendingTestMessage: PropTypes.bool.isRequired,
65
88
  formatMessage: PropTypes.func.isRequired,
89
+ isContentValid: PropTypes.bool,
66
90
  };
67
91
 
68
92
  export default SendTestMessage;
@@ -52,6 +52,7 @@ import {
52
52
  INITIAL_PAYLOAD, EMAIL, TEST, DESKTOP, ACTIVE, MOBILE,
53
53
  } from './constants';
54
54
  import { GLOBAL_CONVERT_OPTIONS } from '../FormBuilder/constants';
55
+ import { validateIfTagClosed, validateTagFormat } from '../../utils/tagValidations';
55
56
 
56
57
  const TestAndPreviewSlidebox = (props) => {
57
58
  const {
@@ -103,10 +104,58 @@ const TestAndPreviewSlidebox = (props) => {
103
104
  const [selectedTestEntities, setSelectedTestEntities] = useState([]);
104
105
  const [beeContent, setBeeContent] = useState(''); // Track BEE editor content separately
105
106
  const previousBeeContentRef = useRef(''); // Track previous BEE content to prevent unnecessary updates
107
+ const [isContentValid, setIsContentValid] = useState(true); // Track if content tags are valid
106
108
 
107
109
  const isUpdatePreviewDisabled = useMemo(() => (
108
- requiredTags.some((tag) => !customValues[tag.fullPath])
109
- ), [requiredTags, customValues]);
110
+ requiredTags.some((tag) => !customValues[tag.fullPath]) || !isContentValid
111
+ ), [requiredTags, customValues, isContentValid]);
112
+
113
+ /**
114
+ * Validates tags in content: checks for proper tag format and balanced braces
115
+ * Uses validateIfTagClosed and validateTagFormat from utils/tagValidations
116
+ * @param {string} content - The HTML content to validate
117
+ * @returns {boolean} - True if content is valid, false otherwise
118
+ */
119
+ const validateContentTags = (content) => {
120
+ if (!content) return true;
121
+
122
+ try {
123
+ // Convert HTML to text (same as what's used for tag extraction)
124
+ // This ensures we validate the same content that will be used for tag extraction
125
+ const textContent = convert(content, GLOBAL_CONVERT_OPTIONS);
126
+
127
+ // Check if there are any braces in the content
128
+ const hasBraces = textContent.includes('{') || textContent.includes('}');
129
+
130
+ // If no braces exist, content is valid (no tag validation needed)
131
+ if (!hasBraces) {
132
+ return true;
133
+ }
134
+
135
+ // First check if tags are properly closed using the utility function from tagValidations
136
+ // This validates that all opening braces have corresponding closing braces
137
+ if (!validateIfTagClosed(textContent)) {
138
+ return false;
139
+ }
140
+
141
+ // Now validate tag format: tags must be in format {{tag_name}}
142
+ return validateTagFormat(textContent);
143
+ } catch (error) {
144
+ // If conversion fails, fall back to validating the original content
145
+ console.warn('Error converting content for validation:', error);
146
+ const hasBraces = content.includes('{') || content.includes('}');
147
+ if (!hasBraces) {
148
+ return true;
149
+ }
150
+
151
+ // Apply same validation to original content
152
+ if (!validateIfTagClosed(content)) {
153
+ return false;
154
+ }
155
+
156
+ return validateTagFormat(content);
157
+ }
158
+ };
110
159
 
111
160
  // Function to resolve tags in text with custom values
112
161
  const resolveTagsInText = (text, tagValues) => {
@@ -153,6 +202,10 @@ const TestAndPreviewSlidebox = (props) => {
153
202
  if (existingContent && existingContent.trim() !== '') {
154
203
  // We already have content, update local state only if it's different
155
204
  if (existingContent !== previousBeeContentRef.current) {
205
+ // Validate content tags for BEE editor
206
+ const isValid = validateContentTags(existingContent);
207
+ setIsContentValid(isValid);
208
+
156
209
  previousBeeContentRef.current = existingContent;
157
210
  setBeeContent(existingContent);
158
211
  setPreviewDataHtml({
@@ -186,6 +239,10 @@ const TestAndPreviewSlidebox = (props) => {
186
239
  }
187
240
 
188
241
  if (htmlFile) {
242
+ // Validate content tags
243
+ const isValid = validateContentTags(htmlFile);
244
+ setIsContentValid(isValid);
245
+
189
246
  // Update our states
190
247
  previousBeeContentRef.current = htmlFile;
191
248
  setBeeContent(htmlFile);
@@ -194,9 +251,16 @@ const TestAndPreviewSlidebox = (props) => {
194
251
  resolvedTitle: formData['template-subject'] || ''
195
252
  });
196
253
 
197
- // Always extract tags when content changes
198
- const payloadContent = convert(htmlFile, GLOBAL_CONVERT_OPTIONS);
199
- actions.extractTagsRequested(formData['template-subject'] || '', payloadContent);
254
+ // Only extract tags if content is valid
255
+ if (isValid) {
256
+ const payloadContent = convert(htmlFile, GLOBAL_CONVERT_OPTIONS);
257
+ actions.extractTagsRequested(formData['template-subject'] || '', payloadContent);
258
+ } else {
259
+ // Show error notification for invalid content
260
+ CapNotification.error({
261
+ message: formatMessage(messages.contentInvalid),
262
+ });
263
+ }
200
264
  }
201
265
 
202
266
  // Restore original handler
@@ -211,23 +275,45 @@ const TestAndPreviewSlidebox = (props) => {
211
275
  const templateContent = currentTabData?.[activeTab]?.['template-content'];
212
276
 
213
277
  if (templateContent) {
278
+ // Validate content tags
279
+ const isValid = validateContentTags(templateContent);
280
+ setIsContentValid(isValid);
281
+
214
282
  // Update preview with initial content
215
283
  setPreviewDataHtml({
216
284
  resolvedBody: templateContent,
217
285
  resolvedTitle: formData['template-subject'] || ''
218
286
  });
219
287
 
220
- // Always extract tags when showing
221
- const payloadContent = convert(templateContent, GLOBAL_CONVERT_OPTIONS);
222
- actions.extractTagsRequested(formData['template-subject'] || '', payloadContent);
288
+ // Only extract tags if content is valid
289
+ if (isValid) {
290
+ const payloadContent = convert(templateContent, GLOBAL_CONVERT_OPTIONS);
291
+ actions.extractTagsRequested(formData['template-subject'] || '', payloadContent);
292
+ } else {
293
+ // Show error notification for invalid content
294
+ CapNotification.error({
295
+ message: formatMessage(messages.contentInvalid),
296
+ });
297
+ }
223
298
  } else {
224
299
  // Fallback to content prop if no template content
225
- const payloadContent = convert(
226
- getCurrentContent,
227
- GLOBAL_CONVERT_OPTIONS
228
- );
229
- // Always extract tags when showing
230
- actions.extractTagsRequested(formData['template-subject'] || '', payloadContent);
300
+ const contentToValidate = getCurrentContent;
301
+ const isValid = validateContentTags(contentToValidate);
302
+ setIsContentValid(isValid);
303
+
304
+ // Only extract tags if content is valid
305
+ if (isValid) {
306
+ const payloadContent = convert(
307
+ contentToValidate,
308
+ GLOBAL_CONVERT_OPTIONS
309
+ );
310
+ actions.extractTagsRequested(formData['template-subject'] || '', payloadContent);
311
+ } else {
312
+ // Show error notification for invalid content
313
+ CapNotification.error({
314
+ message: formatMessage(messages.contentInvalid),
315
+ });
316
+ }
231
317
  }
232
318
  }
233
319
 
@@ -243,17 +329,28 @@ const TestAndPreviewSlidebox = (props) => {
243
329
  const isDragDrop = currentTabData?.[activeTab]?.is_drag_drop;
244
330
  const templateContent = currentTabData?.[activeTab]?.['template-content'];
245
331
 
246
- if (templateContent && templateContent.trim() !== '') {
247
- // Common function to handle content update
332
+ if (templateContent && templateContent.trim() !== '' && show) {
333
+ // Common function to handle content update with validation
248
334
  const handleContentUpdate = (content) => {
335
+ // Validate content tags for each update
336
+ const isValid = validateContentTags(content);
337
+ setIsContentValid(isValid);
338
+
249
339
  setPreviewDataHtml({
250
340
  resolvedBody: content,
251
341
  resolvedTitle: formData['template-subject'] || ''
252
342
  });
253
343
 
254
- // Extract tags from content
255
- const payloadContent = convert(content, GLOBAL_CONVERT_OPTIONS);
256
- actions.extractTagsRequested(formData['template-subject'] || '', payloadContent);
344
+ // Only extract tags if content is valid
345
+ if (isValid) {
346
+ const payloadContent = convert(content, GLOBAL_CONVERT_OPTIONS);
347
+ actions.extractTagsRequested(formData['template-subject'] || '', payloadContent);
348
+ } else {
349
+ // Show error notification for invalid content
350
+ CapNotification.error({
351
+ message: formatMessage(messages.contentInvalid),
352
+ });
353
+ }
257
354
  };
258
355
 
259
356
  if (isDragDrop) {
@@ -287,6 +384,7 @@ const TestAndPreviewSlidebox = (props) => {
287
384
  setTagsExtracted(false);
288
385
  setPreviewDevice('desktop');
289
386
  setSelectedTestEntities([]);
387
+ setIsContentValid(true);
290
388
  actions.clearPrefilledValues();
291
389
  }
292
390
  }, [show]);
@@ -530,6 +628,22 @@ const TestAndPreviewSlidebox = (props) => {
530
628
 
531
629
  // Handle update preview
532
630
  const handleUpdatePreview = async () => {
631
+ // Re-validate content to get latest state (in case liquid errors were fixed)
632
+ const currentTabData = formData[currentTab - 1];
633
+ const activeTab = currentTabData?.activeTab;
634
+ const templateContent = currentTabData?.[activeTab]?.['template-content'];
635
+ const contentToValidate = templateContent || getCurrentContent;
636
+ const isValid = validateContentTags(contentToValidate);
637
+ setIsContentValid(isValid);
638
+
639
+ // Check if content is valid before updating preview
640
+ if (!isValid) {
641
+ CapNotification.error({
642
+ message: formatMessage(messages.contentInvalid),
643
+ });
644
+ return;
645
+ }
646
+
533
647
  try {
534
648
  // Include unsubscribe tag if content contains it
535
649
  const resolvedTags = { ...customValues };
@@ -559,9 +673,20 @@ const TestAndPreviewSlidebox = (props) => {
559
673
  const currentTabData = formData[currentTab - 1];
560
674
  const activeTab = currentTabData?.activeTab;
561
675
  const templateContent = currentTabData?.[activeTab]?.['template-content'];
676
+ const content = templateContent || getCurrentContent;
677
+
678
+ // Validate content tags before extracting
679
+ const isValid = validateContentTags(content);
680
+ setIsContentValid(isValid);
681
+
682
+ if (!isValid) {
683
+ CapNotification.error({
684
+ message: formatMessage(messages.contentInvalid),
685
+ });
686
+ return;
687
+ }
562
688
 
563
689
  // Check for personalization tags (excluding unsubscribe)
564
- const content = templateContent || getCurrentContent;
565
690
  const tags = content.match(/{{[^}]+}}/g) || [];
566
691
  const hasPersonalizationTags = tags.some(tag => !tag.includes('unsubscribe'));
567
692
 
@@ -590,6 +715,22 @@ const TestAndPreviewSlidebox = (props) => {
590
715
  };
591
716
 
592
717
  const handleSendTestMessage = () => {
718
+ // Re-validate content to get latest state (in case liquid errors were fixed)
719
+ const currentTabData = formData[currentTab - 1];
720
+ const activeTab = currentTabData?.activeTab;
721
+ const templateContent = currentTabData?.[activeTab]?.['template-content'];
722
+ const contentToValidate = templateContent || getCurrentContent;
723
+ const isValid = validateContentTags(contentToValidate);
724
+ setIsContentValid(isValid);
725
+
726
+ // Check if content is valid before sending test message
727
+ if (!isValid) {
728
+ CapNotification.error({
729
+ message: formatMessage(messages.contentInvalid),
730
+ });
731
+ return;
732
+ }
733
+
593
734
  const allUserIds = [];
594
735
  selectedTestEntities.forEach((entityId) => {
595
736
  const group = testGroups.find((g) => g.groupId === entityId);
@@ -685,6 +826,7 @@ const TestAndPreviewSlidebox = (props) => {
685
826
  formData={formData}
686
827
  isSendingTestMessage={isSendingTestMessage}
687
828
  formatMessage={formatMessage}
829
+ isContentValid={isContentValid}
688
830
  />
689
831
  );
690
832
 
@@ -144,4 +144,12 @@ export default defineMessages({
144
144
  id: `${scope}.invalidJSON`,
145
145
  defaultMessage: 'Invalid JSON input',
146
146
  },
147
+ contentInvalid: {
148
+ id: `${scope}.contentInvalid`,
149
+ defaultMessage: 'Content is invalid. Please fix the tags in your content before testing or previewing.',
150
+ },
151
+ previewUpdateError: {
152
+ id: `${scope}.previewUpdateError`,
153
+ defaultMessage: 'Failed to update preview',
154
+ },
147
155
  });