@capillarytech/creatives-library 8.0.290-alpha.2 → 8.0.290-alpha.4

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 (27) hide show
  1. package/constants/unified.js +3 -0
  2. package/package.json +1 -1
  3. package/utils/tagValidations.js +4 -6
  4. package/utils/tests/tagValidations.test.js +161 -0
  5. package/v2Components/FormBuilder/index.js +56 -42
  6. package/v2Containers/CreativesContainer/SlideBoxContent.js +5 -1
  7. package/v2Containers/CreativesContainer/SlideBoxFooter.js +13 -7
  8. package/v2Containers/CreativesContainer/index.js +11 -1
  9. package/v2Containers/EmailWrapper/components/EmailHTMLEditor.js +15 -10
  10. package/v2Containers/EmailWrapper/components/__tests__/EmailHTMLEditor.test.js +19 -24
  11. package/v2Containers/InApp/index.js +4 -9
  12. package/v2Containers/InappAdvance/index.js +3 -6
  13. package/v2Containers/InappAdvance/tests/index.test.js +2 -0
  14. package/v2Containers/Line/Container/ImageCarousel/tests/__snapshots__/content.test.js.snap +24 -3
  15. package/v2Containers/MobilePush/Create/index.js +36 -3
  16. package/v2Containers/MobilePush/Edit/index.js +36 -3
  17. package/v2Containers/MobilePushNew/index.js +15 -4
  18. package/v2Containers/MobilepushWrapper/index.js +3 -1
  19. package/v2Containers/Rcs/tests/__snapshots__/index.test.js.snap +18 -4
  20. package/v2Containers/Sms/Create/index.js +22 -18
  21. package/v2Containers/Sms/Edit/index.js +18 -16
  22. package/v2Containers/Sms/commonMethods.js +0 -3
  23. package/v2Containers/Sms/tests/commonMethods.test.js +122 -0
  24. package/v2Containers/SmsTrai/Edit/index.js +5 -0
  25. package/v2Containers/SmsWrapper/index.js +2 -0
  26. package/v2Containers/WebPush/Create/utils/validation.test.js +59 -0
  27. package/v2Containers/Whatsapp/tests/__snapshots__/index.test.js.snap +624 -248
@@ -149,6 +149,9 @@ export const BADGES_ENROLL = 'BADGES_ENROLL';
149
149
  export const BADGES_ISSUE = 'BADGES_ISSUE';
150
150
  export const CUSTOMER_BARCODE_TAG = 'customer_barcode';
151
151
  export const COPY_OF = 'Copy of';
152
+ export const UNSUBSCRIBE_TAG = 'unsubscribe';
153
+ /** Whitespace-tolerant check for {{ unsubscribe }}-style tag in content. */
154
+ export const UNSUBSCRIBE_TAG_REGEX = new RegExp(`\\{\\{\\s*${UNSUBSCRIBE_TAG}\\s*\\}\\}`);
152
155
  export const ENTRY_TRIGGER_TAG_REGEX = /\bentryTrigger\.\w+(?:\.\w+)?(?:\(\w+\))?/g;
153
156
  export const SKIP_TAGS_REGEX_GROUPS = ["dynamic_expiry_date_after_\\d+_days.FORMAT_\\d", "unsubscribe\\(#[a-zA-Z\\d]{6}\\)", "Link_to_[a-zA-Z]", "SURVEY.*.TOKEN", "^[A-Za-z].*\\([a-zA-Z\\d]*\\)", "referral_unique_(code|url).*userid"];
154
157
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@capillarytech/creatives-library",
3
3
  "author": "meharaj",
4
- "version": "8.0.290-alpha.2",
4
+ "version": "8.0.290-alpha.4",
5
5
  "description": "Capillary creatives ui",
6
6
  "main": "./index.js",
7
7
  "module": "./index.es.js",
@@ -8,13 +8,11 @@
8
8
 
9
9
  import lodashForEach from 'lodash/forEach';
10
10
  import lodashCloneDeep from 'lodash/cloneDeep';
11
- import { ENTRY_TRIGGER_TAG_REGEX, SKIP_TAGS_REGEX_GROUPS } from '../constants/unified';
11
+ import { ENTRY_TRIGGER_TAG_REGEX, SKIP_TAGS_REGEX_GROUPS, UNSUBSCRIBE_TAG, UNSUBSCRIBE_TAG_REGEX } from '../constants/unified';
12
12
 
13
13
  const DEFAULT = 'default';
14
14
  const SUBTAGS = 'subtags';
15
15
 
16
- /** Whitespace-tolerant check for {{ unsubscribe }}-style tag in content. */
17
- const UNSUBSCRIBE_TAG_REGEX = /\{\{\s*unsubscribe\s*\}\}/;
18
16
  export const hasUnsubscribeTag = (content) =>
19
17
  typeof content === 'string' && UNSUBSCRIBE_TAG_REGEX.test(content);
20
18
 
@@ -57,7 +55,7 @@ export const validateTagsCore = ({
57
55
  value,
58
56
  },
59
57
  }) => {
60
- if (value === 'unsubscribe') {
58
+ if (value === UNSUBSCRIBE_TAG) {
61
59
  lodashForEach(supportedModules, (module) => {
62
60
  if (module.mandatory && (currentModule === module.context)) {
63
61
  if (!hasUnsubscribeTag(contentForUnsubscribeScan)) {
@@ -76,8 +74,8 @@ export const validateTagsCore = ({
76
74
  while (match !== null) {
77
75
  const tagValue = match[1].trim();
78
76
  const ifSkipped = skipTagsFn(tagValue);
79
- if (ifSkipped && tagValue.indexOf('unsubscribe') !== -1) {
80
- const missingTagIndex = response.missingTags.indexOf('unsubscribe');
77
+ if (ifSkipped && tagValue.indexOf(UNSUBSCRIBE_TAG) !== -1) {
78
+ const missingTagIndex = response.missingTags.indexOf(UNSUBSCRIBE_TAG);
81
79
  if (missingTagIndex !== -1) {
82
80
  response.missingTags.splice(missingTagIndex, 1);
83
81
  }
@@ -1,5 +1,7 @@
1
1
  import '@testing-library/jest-dom';
2
2
  import {
3
+ hasUnsubscribeTag,
4
+ validateTagsCore,
3
5
  getTagMapValue,
4
6
  getLoyaltyTagsMapValue,
5
7
  getForwardedMapValues,
@@ -7,9 +9,161 @@ import {
7
9
  validateIfTagClosed,
8
10
  validateTags,
9
11
  skipTags,
12
+ checkIfSupportedTag,
13
+ transformInjectedTags,
10
14
  } from '../tagValidations';
11
15
  import { containsBase64Images } from '../content';
12
16
 
17
+ describe('hasUnsubscribeTag', () => {
18
+ it('should return false when content is not a string', () => {
19
+ expect(hasUnsubscribeTag(null)).toBe(false);
20
+ expect(hasUnsubscribeTag(undefined)).toBe(false);
21
+ expect(hasUnsubscribeTag(123)).toBe(false);
22
+ expect(hasUnsubscribeTag({})).toBe(false);
23
+ });
24
+
25
+ it('should return false when content has no unsubscribe tag', () => {
26
+ expect(hasUnsubscribeTag('Hello world')).toBe(false);
27
+ expect(hasUnsubscribeTag('{{ name }}')).toBe(false);
28
+ });
29
+
30
+ it('should return true when content has {{ unsubscribe }}', () => {
31
+ expect(hasUnsubscribeTag('Click {{ unsubscribe }} here')).toBe(true);
32
+ expect(hasUnsubscribeTag('{{ unsubscribe }}')).toBe(true);
33
+ });
34
+ });
35
+
36
+ describe('validateTagsCore', () => {
37
+ it('should include isContentEmpty: false when includeIsContentEmpty is true', () => {
38
+ const result = validateTagsCore({
39
+ contentForBraceCheck: '{{a}}',
40
+ contentForUnsubscribeScan: '{{a}}',
41
+ tags: null,
42
+ currentModule: 'default',
43
+ isFullMode: true,
44
+ includeIsContentEmpty: true,
45
+ });
46
+ expect(result.isContentEmpty).toBe(false);
47
+ expect(result.valid).toBe(true);
48
+ });
49
+
50
+ it('should use initialMissingTags when provided', () => {
51
+ const result = validateTagsCore({
52
+ contentForBraceCheck: '{{a}}',
53
+ contentForUnsubscribeScan: '{{a}}',
54
+ tags: [{ definition: { supportedModules: [], value: 'x' } }],
55
+ currentModule: 'default',
56
+ isFullMode: false,
57
+ initialMissingTags: ['requiredTag'],
58
+ });
59
+ expect(result.missingTags).toEqual(['requiredTag']);
60
+ expect(result.valid).toBe(false);
61
+ });
62
+
63
+ it('should use custom skipTagsFn when provided', () => {
64
+ const customSkip = jest.fn(() => true);
65
+ const result = validateTagsCore({
66
+ contentForBraceCheck: '{{ unsubscribe }}',
67
+ contentForUnsubscribeScan: '{{ unsubscribe }}',
68
+ tags: [
69
+ {
70
+ definition: {
71
+ supportedModules: [{ context: 'DEFAULT', mandatory: true }],
72
+ value: 'unsubscribe',
73
+ },
74
+ },
75
+ ],
76
+ currentModule: 'DEFAULT',
77
+ isFullMode: false,
78
+ skipTagsFn: customSkip,
79
+ });
80
+ expect(customSkip).toHaveBeenCalled();
81
+ expect(result.valid).toBe(true);
82
+ });
83
+ });
84
+
85
+ describe('checkIfSupportedTag', () => {
86
+ it('should return false for empty or no injected tags', () => {
87
+ expect(checkIfSupportedTag('someTag', {})).toBe(false);
88
+ expect(checkIfSupportedTag('someTag', undefined)).toBe(false);
89
+ });
90
+
91
+ it('should return true when tag matches a top-level key', () => {
92
+ const injectedTags = { name: { name: 'Name' }, unsubscribe: { name: 'Unsubscribe' } };
93
+ expect(checkIfSupportedTag('name', injectedTags)).toBe(true);
94
+ expect(checkIfSupportedTag('unsubscribe', injectedTags)).toBe(true);
95
+ });
96
+
97
+ it('should return false when tag does not match any key', () => {
98
+ const injectedTags = { name: { name: 'Name' } };
99
+ expect(checkIfSupportedTag('other', injectedTags)).toBe(false);
100
+ });
101
+
102
+ it('should return true when tag matches a nested subtag', () => {
103
+ const injectedTags = {
104
+ customer: {
105
+ name: 'Customer',
106
+ subtags: {
107
+ first_name: { name: 'First Name' },
108
+ last_name: { name: 'Last Name' },
109
+ },
110
+ },
111
+ };
112
+ expect(checkIfSupportedTag('first_name', injectedTags)).toBe(true);
113
+ expect(checkIfSupportedTag('last_name', injectedTags)).toBe(true);
114
+ });
115
+
116
+ it('should return false when tag is in neither top-level nor subtags', () => {
117
+ const injectedTags = {
118
+ customer: {
119
+ name: 'Customer',
120
+ subtags: { first_name: { name: 'First Name' } },
121
+ },
122
+ };
123
+ expect(checkIfSupportedTag('unknown', injectedTags)).toBe(false);
124
+ });
125
+ });
126
+
127
+ describe('transformInjectedTags', () => {
128
+ it('should add tag-header and normalize subtags when key contains "subtags"', () => {
129
+ const tags = [
130
+ {
131
+ name: 'Customer',
132
+ subtags: {
133
+ first_name: { name: 'First Name' },
134
+ },
135
+ },
136
+ ];
137
+ const result = transformInjectedTags(tags);
138
+ expect(result).toBe(tags);
139
+ expect(tags[0]['tag-header']).toBe(true);
140
+ expect(tags[0].subtags).toEqual({ first_name: { name: 'First Name' } });
141
+ });
142
+
143
+ it('should recursively transform nested subtags', () => {
144
+ const tags = [
145
+ {
146
+ name: 'Parent',
147
+ subtags: {
148
+ child: {
149
+ name: 'Child',
150
+ subtags: { grandchild: { name: 'Grandchild' } },
151
+ },
152
+ },
153
+ },
154
+ ];
155
+ transformInjectedTags(tags);
156
+ expect(tags[0].subtags.child.subtags).toEqual({ grandchild: { name: 'Grandchild' } });
157
+ });
158
+
159
+ it('should return tags unchanged when no subtag keys exist', () => {
160
+ const tags = [{ name: 'Simple', desc: 'No subtags' }];
161
+ const result = transformInjectedTags(tags);
162
+ expect(result).toBe(tags);
163
+ expect(tags[0]).toEqual({ name: 'Simple', desc: 'No subtags' });
164
+ });
165
+ });
166
+
13
167
  describe("check if curly brackets are balanced", () => {
14
168
  it("test for balanced curly brackets", () => {
15
169
  let value = "hello {{optout"
@@ -18,6 +172,9 @@ describe("check if curly brackets are balanced", () => {
18
172
  value += "}}"
19
173
  let result = validateIfTagClosed(value);
20
174
  expect(result).toEqual(true);
175
+ // no braces or empty string: match returns null, l1/l2/l3 undefined -> true
176
+ expect(validateIfTagClosed("")).toEqual(true);
177
+ expect(validateIfTagClosed("plain text no braces")).toEqual(true);
21
178
  //valid cases
22
179
  expect(validateIfTagClosed("{{{Hello}}}")).toEqual(true);
23
180
  expect(validateIfTagClosed("{{{Hello}}")).toEqual(true);
@@ -1048,6 +1205,10 @@ describe('getForwardedMapValues', () => {
1048
1205
  expect(getForwardedMapValues(input)).toEqual(expected);
1049
1206
  });
1050
1207
 
1208
+ test('should return empty object when called with no argument (default param)', () => {
1209
+ expect(getForwardedMapValues()).toEqual({});
1210
+ });
1211
+
1051
1212
  test('should correctly process objects with subtags', () => {
1052
1213
  const input = {
1053
1214
  customer: {
@@ -1328,51 +1328,59 @@ class FormBuilder extends React.Component { // eslint-disable-line react/prefer-
1328
1328
  }
1329
1329
  onSubmitWrapper = (args) => {
1330
1330
  const {singleTab = null} = args || {};
1331
- if (this.isLiquidFlowSupportedByChannel()) {
1331
+ // Liquid validation (extractTags + Aira) only in library mode
1332
+ const runLiquidValidation = this.isLiquidFlowSupportedByChannel() && !this.props.isFullMode;
1333
+ if (runLiquidValidation) {
1332
1334
  // For MPUSH, we need to validate both Android and iOS content separately
1333
1335
  if (this.props.channel === MOBILE_PUSH || this.props?.schema?.channel?.toUpperCase() === MOBILE_PUSH) {
1334
1336
  this.validateFormBuilderMPush(this.state.formData, singleTab);
1335
1337
  return;
1336
1338
  }
1337
-
1338
- // For other channels (EMAIL, SMS, INAPP)
1339
- const content = getChannelData(this.props.schema.channel || this.props.channel, this.state.formData, this.props.baseLanguage);
1340
-
1341
- // Set up callbacks for error and success handling
1342
- const onError = ({ standardErrors, liquidErrors }) => {
1343
- this.setState(
1344
- (prevState) => ({
1345
- liquidErrorMessage: {
1346
- ...prevState.liquidErrorMessage,
1347
- STANDARD_ERROR_MSG: standardErrors,
1348
- LIQUID_ERROR_MSG: liquidErrors,
1349
- },
1350
- }),
1351
- () => {
1352
- this.props.showLiquidErrorInFooter(this.state.liquidErrorMessage);
1353
- this.props.stopValidation();
1354
- }
1355
- );
1356
- };
1357
-
1358
- const onSuccess = (contentToSubmit) => {
1359
- const channel = this.props.channel || this.props?.schema?.channel?.toUpperCase();
1360
- if(channel === EMAIL) {
1361
- const content = this.state.formData?.base?.[this.props.baseLanguage]?.["template-content"] || "";
1362
- this.handleLiquidTemplateSubmit(content);
1363
- } else {
1364
- this.handleLiquidTemplateSubmit(contentToSubmit);
1339
+
1340
+ // For other channels (EMAIL, SMS, INAPP): only call extractTags if there are no brace/empty errors already.
1341
+ // Run sync validation first; if it fails, block and show errors without calling the API.
1342
+ this.validateForm(null, null, true, false, () => {
1343
+ if (!this.state.isFormValid) {
1344
+ this.props.stopValidation();
1345
+ return;
1365
1346
  }
1366
- };
1367
-
1368
- // Call the common validation function
1369
- validateLiquidTemplateContent(content, {
1370
- getLiquidTags: this.props.actions.getLiquidTags,
1371
- formatMessage: this.props.intl.formatMessage,
1372
- messages,
1373
- onError,
1374
- onSuccess,
1375
- skipTags: this.skipTags.bind(this)
1347
+ const content = getChannelData(this.props.schema.channel || this.props.channel, this.state.formData, this.props.baseLanguage);
1348
+
1349
+ const onError = ({ standardErrors, liquidErrors }) => {
1350
+ this.setState(
1351
+ (prevState) => ({
1352
+ liquidErrorMessage: {
1353
+ ...prevState.liquidErrorMessage,
1354
+ STANDARD_ERROR_MSG: standardErrors,
1355
+ LIQUID_ERROR_MSG: liquidErrors,
1356
+ },
1357
+ }),
1358
+ () => {
1359
+ this.props.showLiquidErrorInFooter(this.state.liquidErrorMessage);
1360
+ this.props.stopValidation();
1361
+ this.props.onFormValidityChange(false, this.state.errorData);
1362
+ }
1363
+ );
1364
+ };
1365
+
1366
+ const onSuccess = (contentToSubmit) => {
1367
+ const channel = this.props.channel || this.props?.schema?.channel?.toUpperCase();
1368
+ if(channel === EMAIL) {
1369
+ const content = this.state.formData?.base?.[this.props.baseLanguage]?.["template-content"] || "";
1370
+ this.handleLiquidTemplateSubmit(content);
1371
+ } else {
1372
+ this.handleLiquidTemplateSubmit(contentToSubmit);
1373
+ }
1374
+ };
1375
+
1376
+ validateLiquidTemplateContent(content, {
1377
+ getLiquidTags: this.props.actions.getLiquidTags,
1378
+ formatMessage: this.props.intl.formatMessage,
1379
+ messages,
1380
+ onError,
1381
+ onSuccess,
1382
+ skipTags: this.skipTags.bind(this)
1383
+ });
1376
1384
  });
1377
1385
  } else {
1378
1386
  this.props.onSubmit(this.state.formData);
@@ -1399,7 +1407,6 @@ class FormBuilder extends React.Component { // eslint-disable-line react/prefer-
1399
1407
 
1400
1408
  // Set up callbacks for error and success handling
1401
1409
  const onLiquidError = ({ standardErrors, liquidErrors }) => {
1402
-
1403
1410
  this.setState(
1404
1411
  (prevState) => ({
1405
1412
  liquidErrorMessage: {
@@ -1411,6 +1418,8 @@ class FormBuilder extends React.Component { // eslint-disable-line react/prefer-
1411
1418
  () => {
1412
1419
  this.props.showLiquidErrorInFooter(this.state.liquidErrorMessage, this.state.currentTab);
1413
1420
  this.props.stopValidation();
1421
+ // Block save: tell parent form is invalid so Done/submit is blocked
1422
+ this.props.onFormValidityChange(false, this.state.errorData);
1414
1423
  }
1415
1424
  );
1416
1425
  };
@@ -2801,6 +2810,11 @@ class FormBuilder extends React.Component { // eslint-disable-line react/prefer-
2801
2810
  if (this.props.restrictPersonalization && hasPersonalizationTags(messageContent)) {
2802
2811
  errorMessageText = formatMessage(messages.personalizationTagsErrorMessage);
2803
2812
  }
2813
+ // Empty/required error: only show after user has triggered validation (ifError / "Done").
2814
+ // All other errors (brace, personalization, missing tags, generic): show in real time while typing.
2815
+ const isContentEmpty = !messageContent || !/\S/.test(String(messageContent).trim());
2816
+ const isEmptyError = errorType && isContentEmpty;
2817
+ const showError = errorType && (isEmptyError ? ifError : true);
2804
2818
  const prevErrorMessage = this.state.liquidErrorMessage?.STANDARD_ERROR_MSG?.[0];
2805
2819
  if (prevErrorMessage !== errorMessageText && errorMessageText && this.isLiquidFlowSupportedByChannel()) {
2806
2820
  this.setState((prevState) => ({
@@ -2823,9 +2837,9 @@ class FormBuilder extends React.Component { // eslint-disable-line react/prefer-
2823
2837
  <TextArea
2824
2838
  id={val.id}
2825
2839
  placeholder={val.placeholder ? val.placeholder : ''}
2826
- className={`${ifError ? 'error-form-builder' : ''}`}
2840
+ className={`${showError ? 'error-form-builder' : ''}`}
2827
2841
  errorMessage={
2828
- ifError && errorMessageText && (
2842
+ showError && errorMessageText && (
2829
2843
  !this.isLiquidFlowSupportedByChannel() ||
2830
2844
  [MOBILE_PUSH, INAPP].includes(this.props.schema?.channel?.toUpperCase())
2831
2845
  )
@@ -566,6 +566,7 @@ export function SlideBoxContent(props) {
566
566
  handleTestAndPreview={handleTestAndPreview}
567
567
  handleCloseTestAndPreview={handleCloseTestAndPreview}
568
568
  isTestAndPreviewMode={isTestAndPreviewMode}
569
+ onValidationFail={onValidationFail}
569
570
  />
570
571
  )}
571
572
  {isEditFTP && (
@@ -632,6 +633,7 @@ export function SlideBoxContent(props) {
632
633
  getLiquidTags={getLiquidTags}
633
634
  getDefaultTags={type}
634
635
  isFullMode={isFullMode}
636
+ onValidationFail={onValidationFail}
635
637
  forwardedTags={forwardedTags}
636
638
  selectedOfferDetails={selectedOfferDetails}
637
639
  onPreviewContentClicked={onPreviewContentClicked}
@@ -780,6 +782,7 @@ export function SlideBoxContent(props) {
780
782
  <MobliPushEdit
781
783
  getFormLibraryData={getFormData}
782
784
  setIsLoadingContent={setIsLoadingContent}
785
+ getLiquidTags={getLiquidTags}
783
786
  location={{
784
787
  pathname: `/mobilepush/edit/`,
785
788
  query,
@@ -852,6 +855,7 @@ export function SlideBoxContent(props) {
852
855
  mobilePushCreateMode={mobilePushCreateMode}
853
856
  isGetFormData={isGetFormData}
854
857
  getFormData={getFormData}
858
+ getLiquidTags={getLiquidTags}
855
859
  templateData={templateData}
856
860
  type={type}
857
861
  step={templateStep}
@@ -880,7 +884,7 @@ export function SlideBoxContent(props) {
880
884
  />
881
885
  ) : (
882
886
  <MobilePushNew
883
- key="creatives-mobilepush-wrapper"
887
+ key="creatives-mobilepush-create-new"
884
888
  date={new Date().getMilliseconds()}
885
889
  setIsLoadingContent={setIsLoadingContent}
886
890
  onMobilepushModeChange={onMobilepushModeChange}
@@ -6,7 +6,7 @@ import CapError from '@capillarytech/cap-ui-library/CapError';
6
6
  import PropTypes from 'prop-types';
7
7
  import messages from './messages';
8
8
  import ErrorInfoNote from '../../v2Components/ErrorInfoNote';
9
- import { PREVIEW } from './constants';
9
+ import { PREVIEW, EMAIL, SMS, EDIT_TEMPLATE, MOBILE_PUSH } from './constants';
10
10
  import { EMAIL_CREATE_MODES } from '../EmailWrapper/constants';
11
11
  import { hasSupportCKEditor } from '../../utils/common';
12
12
  import { getMessageForDevice, getTitleForDevice } from '../../utils/commonUtils';
@@ -15,7 +15,7 @@ function getFullModeSaveBtn(slidBoxContent, isCreatingTemplate) {
15
15
  if (isCreatingTemplate) {
16
16
  return <FormattedMessage {...messages.creativesTemplatesDone} />;
17
17
  }
18
- return slidBoxContent === "editTemplate"
18
+ return slidBoxContent === EDIT_TEMPLATE
19
19
  ? <FormattedMessage {...messages.creativesTemplatesUpdate} />
20
20
  : <FormattedMessage {...messages.creativesTemplatesSaveFullMode} />;
21
21
  }
@@ -52,9 +52,13 @@ function SlideBoxFooter(props) {
52
52
  // Calculate if buttons should be disabled
53
53
  // Only apply validation state checks for EMAIL channel in HTML Editor mode (not BEE/DragDrop)
54
54
  // For other channels, BEE editor, or when htmlEditorValidationState is not provided, don't disable based on validation
55
- const isEmailChannel = currentChannel?.toUpperCase() === 'EMAIL';
56
- const isSmsChannel = currentChannel?.toUpperCase() === 'SMS';
57
- const isEditMode = slidBoxContent === 'editTemplate';
55
+ const isEmailChannel = currentChannel?.toUpperCase() === EMAIL;
56
+ const isSmsChannel = currentChannel?.toUpperCase() === SMS;
57
+ // Use templateData.type in library/edit so footer shows when editing a mobile push template (currentChannel may not be set to template channel)
58
+ const isMobilePushChannel =
59
+ currentChannel?.toUpperCase() === MOBILE_PUSH ||
60
+ (templateData?.type && templateData.type.toUpperCase() === MOBILE_PUSH);
61
+ const isEditMode = slidBoxContent === EDIT_TEMPLATE;
58
62
 
59
63
  // Use selectedEmailCreateMode for accurate mode detection in create mode (emailCreateMode is mapped for backwards compatibility)
60
64
  // In edit mode: htmlEditorValidationState is initialized as {} but only updated by HTML Editor
@@ -130,9 +134,11 @@ function SlideBoxFooter(props) {
130
134
  const isBEEEditorModeInCreate = !isHTMLEditorMode && !isEditMode;
131
135
  const isBEEEditorMode = isBEEEditorModeInEdit || isBEEEditorModeInCreate;
132
136
  const hasBEEEditorErrors = isEmailChannel && isBEEEditorMode && (hasStandardErrors || hasLiquidErrors) && (!htmlEditorValidationState || !htmlEditorHasErrors);
133
- const hasSmsValidationErrors = isSmsChannel && hasStandardErrors;
137
+ const hasSmsValidationErrors = isSmsChannel && (hasStandardErrors || hasLiquidErrors);
138
+ // Mobile Push OLD: footer only for extractTags/Aira liquid errors, not standard tag errors
139
+ const hasMobilePushValidationErrors = isMobilePushChannel && hasLiquidErrors;
134
140
 
135
- const shouldShowErrorInfoNote = hasBEEEditorErrors || hasSmsValidationErrors || isSupportCKEditor;
141
+ const shouldShowErrorInfoNote = hasBEEEditorErrors || hasSmsValidationErrors || hasMobilePushValidationErrors || isSupportCKEditor;
136
142
 
137
143
  // Check for personalization tokens in title/message when anonymous user tries to save
138
144
  const hasPersonalizationTokens = () => {
@@ -1777,8 +1777,18 @@ export class Creatives extends React.Component {
1777
1777
  }
1778
1778
 
1779
1779
  showLiquidErrorInFooter = (errorMessagesFromFormBuilder, currentFormBuilderTab) => {
1780
+ const liquidMsgs = get(errorMessagesFromFormBuilder, constants.LIQUID_ERROR_MSG, []);
1781
+ const standardMsgs = get(errorMessagesFromFormBuilder, constants.STANDARD_ERROR_MSG, []);
1782
+ const hasLiquid = Array.isArray(liquidMsgs) ? liquidMsgs.length > 0 : !isEmpty(liquidMsgs);
1783
+ const hasStandard = Array.isArray(standardMsgs) ? standardMsgs.length > 0 : !isEmpty(standardMsgs);
1784
+ const isLiquidValidationError = hasLiquid || hasStandard;
1785
+ // Don't overwrite existing liquid error with empty only for Mobile Push OLD (FormBuilder/clear calls empty there); SMS/others clear on input change
1786
+ const isMobilePush = this.state.currentChannel?.toUpperCase() === constants.MOBILE_PUSH;
1787
+ if (!hasLiquid && !hasStandard && this.state.isLiquidValidationError && isMobilePush) {
1788
+ return;
1789
+ }
1780
1790
  this.setState({
1781
- isLiquidValidationError: !isEmpty(get(errorMessagesFromFormBuilder, constants.LIQUID_ERROR_MSG, [])) || !isEmpty(get(errorMessagesFromFormBuilder, constants.STANDARD_ERROR_MSG, [])),
1791
+ isLiquidValidationError,
1782
1792
  liquidErrorMessage: errorMessagesFromFormBuilder,
1783
1793
  activeFormBuilderTab: currentFormBuilderTab === 1 ? constants.ANDROID : (currentFormBuilderTab === 2 ? constants.IOS : null), // Update activeFormBuilderTab, default to 1 if undefined
1784
1794
  });
@@ -482,6 +482,18 @@ const EmailHTMLEditor = (props) => {
482
482
  const handleContentChange = useCallback((content) => {
483
483
  setHtmlContent(content);
484
484
 
485
+ // Clear previous liquid/API validation errors so Done button can be enabled after user fixes content
486
+ setApiValidationErrors({
487
+ liquidErrors: [],
488
+ standardErrors: [],
489
+ });
490
+ if (showLiquidErrorInFooter) {
491
+ showLiquidErrorInFooter({
492
+ STANDARD_ERROR_MSG: [],
493
+ LIQUID_ERROR_MSG: [],
494
+ });
495
+ }
496
+
485
497
  // Validate tags
486
498
  if (tags.length > 0 || !isEmpty(injectedTags)) {
487
499
  const validationResult = validateTags({
@@ -498,7 +510,7 @@ const EmailHTMLEditor = (props) => {
498
510
  setTagValidationError(null);
499
511
  }
500
512
  }
501
- }, [tags, injectedTags, location, getDefaultTags, eventContextTags]);
513
+ }, [tags, injectedTags, location, getDefaultTags, eventContextTags, showLiquidErrorInFooter]);
502
514
 
503
515
  // Store the last validation state received from HTMLEditor
504
516
  const lastValidationStateRef = useRef(null);
@@ -913,13 +925,9 @@ const EmailHTMLEditor = (props) => {
913
925
  }
914
926
  };
915
927
 
916
- // Validate first using extractTags API
917
- if (getLiquidTags) {
918
- // Note: API validation errors are already cleared at the start of handleSave
919
- // This ensures fresh validation on every save attempt
920
-
928
+ // Liquid validation (extractTags) only in library mode
929
+ if (getLiquidTags && !isFullMode) {
921
930
  const onError = ({ standardErrors, liquidErrors }) => {
922
- // Store API validation errors in state so they can be displayed in UI
923
931
  setApiValidationErrors({
924
932
  liquidErrors: liquidErrors || [],
925
933
  standardErrors: standardErrors || [],
@@ -931,15 +939,12 @@ const EmailHTMLEditor = (props) => {
931
939
  LIQUID_ERROR_MSG: liquidErrors || [],
932
940
  });
933
941
  }
934
- // Don't reset ref here - liquid validation is async and resetting causes infinite loop
935
- // The parent's isGetFormData will be reset by onValidationFail, and the next click will be detected
936
942
  if (onValidationFail) {
937
943
  onValidationFail();
938
944
  }
939
945
  };
940
946
 
941
947
  const onSuccess = () => {
942
- // Clear API validation errors on success
943
948
  setApiValidationErrors({
944
949
  liquidErrors: [],
945
950
  standardErrors: [],