@capillarytech/creatives-library 8.0.199 → 8.0.200-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -44,7 +44,6 @@ export const REGISTRATION_CUSTOM_FIELD = 'Registration custom fields';
44
44
  export const GIFT_CARDS = 'GIFT_CARDS';
45
45
  export const PROMO_ENGINE = 'PROMO_ENGINE';
46
46
  export const LIQUID_SUPPORT = 'ENABLE_LIQUID_SUPPORT';
47
- export const ENABLE_NEW_MPUSH = 'ENABLE_NEW_MPUSH';
48
47
  export const CUSTOM_TAG = 'CustomTagMessage';
49
48
  export const CUSTOMER_EXTENDED_FIELD = 'Customer extended fields';
50
49
  export const EXTENDED_TAG = 'ExtendedTagMessage';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@capillarytech/creatives-library",
3
3
  "author": "meharaj",
4
- "version": "8.0.199",
4
+ "version": "8.0.200-alpha.0",
5
5
  "description": "Capillary creatives ui",
6
6
  "main": "./index.js",
7
7
  "module": "./index.es.js",
package/utils/common.js CHANGED
@@ -21,8 +21,7 @@ import {
21
21
  EMAIL_UNSUBSCRIBE_TAG_MANDATORY,
22
22
  BADGES_ISSUE,
23
23
  ENABLE_WECHAT,
24
- LIQUID_SUPPORT,
25
- ENABLE_NEW_MPUSH
24
+ LIQUID_SUPPORT
26
25
  } from '../constants/unified';
27
26
  import { apiMessageFormatHandler } from './commonUtils';
28
27
 
@@ -126,11 +125,6 @@ export const isEmailUnsubscribeTagMandatory = Auth.hasFeatureAccess.bind(
126
125
  EMAIL_UNSUBSCRIBE_TAG_MANDATORY,
127
126
  );
128
127
 
129
- export const hasNewMobilePushFeatureEnabled = Auth.hasFeatureAccess.bind(
130
- null,
131
- ENABLE_NEW_MPUSH,
132
- );
133
-
134
128
  //filtering tags based on scope
135
129
  export const filterTags = (tagsToFilter, tagsList) => tagsList?.filter(
136
130
  (tag) => !tagsToFilter?.includes(tag?.definition?.value)
@@ -62,12 +62,33 @@ const createMobilePushPayload = ({
62
62
  throw new Error(intl.formatMessage(messages.templateNameEmptyError));
63
63
  }
64
64
 
65
- // Validate content
66
- if (isAndroidSupported && (!androidContent?.title || !androidContent?.message)) {
67
- throw new Error(intl.formatMessage(messages.contentValidationError, { platform: 'Android' }));
68
- }
69
- if (isIosSupported && (!iosContent?.title || !iosContent?.message)) {
70
- throw new Error(intl.formatMessage(messages.contentValidationError, { platform: 'iOS' }));
65
+ // Validate content - allow single platform if explicitly allowed
66
+ const allowSinglePlatform = options?.allowSinglePlatform || false;
67
+
68
+ if (!allowSinglePlatform) {
69
+ // Normal validation: require all supported platforms to have content
70
+ if (isAndroidSupported && (!androidContent?.title || !androidContent?.message)) {
71
+ throw new Error(intl.formatMessage(messages.contentValidationError, { platform: 'Android' }));
72
+ }
73
+ if (isIosSupported && (!iosContent?.title || !iosContent?.message)) {
74
+ throw new Error(intl.formatMessage(messages.contentValidationError, { platform: 'iOS' }));
75
+ }
76
+ } else {
77
+ // Single platform mode: require at least one platform to have content
78
+ const hasAndroidContent = isAndroidSupported && androidContent?.title?.trim() && androidContent?.message?.trim();
79
+ const hasIosContent = isIosSupported && iosContent?.title?.trim() && iosContent?.message?.trim();
80
+
81
+ if (!hasAndroidContent && !hasIosContent) {
82
+ throw new Error(intl.formatMessage(messages.singlePlatformContentMissing));
83
+ }
84
+
85
+ // Validate individual platforms that are supported but have incomplete content
86
+ if (isAndroidSupported && !hasAndroidContent && androidContent) {
87
+ throw new Error(intl.formatMessage(messages.contentValidationError, { platform: 'Android' }));
88
+ }
89
+ if (isIosSupported && !hasIosContent && iosContent) {
90
+ throw new Error(intl.formatMessage(messages.contentValidationError, { platform: 'iOS' }));
91
+ }
71
92
  }
72
93
 
73
94
  // Ensure imageSrc has the required properties
@@ -261,6 +261,59 @@ describe('createMobilePushPayload', () => {
261
261
  accountData: unsupportedAccountData,
262
262
  })).not.toThrow();
263
263
  });
264
+
265
+ // Single Platform Mode Tests
266
+ it('should allow single platform when allowSinglePlatform is true - Android only', () => {
267
+ expect(() => callWithIntl({
268
+ templateName: 'Test',
269
+ androidContent: { title: 'Title', message: 'Message' },
270
+ iosContent: null,
271
+ accountData: mockAccountData,
272
+ options: { allowSinglePlatform: true },
273
+ })).not.toThrow();
274
+ });
275
+
276
+ it('should allow single platform when allowSinglePlatform is true - iOS only', () => {
277
+ expect(() => callWithIntl({
278
+ templateName: 'Test',
279
+ androidContent: null,
280
+ iosContent: { title: 'Title', message: 'Message' },
281
+ accountData: mockAccountData,
282
+ options: { allowSinglePlatform: true },
283
+ })).not.toThrow();
284
+ });
285
+
286
+ it('should throw error when no platforms have content even with allowSinglePlatform', () => {
287
+ expect(() => callWithIntl({
288
+ templateName: 'Test',
289
+ androidContent: null,
290
+ iosContent: null,
291
+ accountData: mockAccountData,
292
+ options: { allowSinglePlatform: true },
293
+ })).toThrow(intl.formatMessage(messages.singlePlatformContentMissing));
294
+ });
295
+
296
+ it('should validate individual platforms in single platform mode - Android incomplete', () => {
297
+ // Test case where iOS has valid content but Android has incomplete content
298
+ expect(() => callWithIntl({
299
+ templateName: 'Test',
300
+ androidContent: { title: 'Valid Title' }, // Missing message property
301
+ iosContent: { title: 'Valid Title', message: 'Valid Message' }, // Complete content
302
+ accountData: mockAccountData,
303
+ options: { allowSinglePlatform: true },
304
+ })).toThrow(intl.formatMessage(messages.contentValidationError, { platform: 'Android' }));
305
+ });
306
+
307
+ it('should validate individual platforms in single platform mode - iOS incomplete', () => {
308
+ // Test case where Android has valid content but iOS has incomplete content
309
+ expect(() => callWithIntl({
310
+ templateName: 'Test',
311
+ androidContent: { title: 'Valid Title', message: 'Valid Message' }, // Complete content
312
+ iosContent: { title: 'Valid Title' }, // Missing message property
313
+ accountData: mockAccountData,
314
+ options: { allowSinglePlatform: true },
315
+ })).toThrow(intl.formatMessage(messages.contentValidationError, { platform: 'iOS' }));
316
+ });
264
317
  });
265
318
 
266
319
  // Account Data Validation Tests
@@ -139,8 +139,196 @@ class FormBuilder extends React.Component { // eslint-disable-line react/prefer-
139
139
  // Check if the liquid flow feature is supported and the channel is in the supported list.
140
140
  this.liquidFlow = this.isLiquidFlowSupported.bind(this);
141
141
  this.onSubmitWrapper = this.onSubmitWrapper.bind(this);
142
+
143
+ // Performance optimization: Debounced functions for high-frequency updates
144
+ this.debouncedUpdateFormData = _.debounce((data, val, event, skipStateUpdate) => {
145
+ this.performFormDataUpdate(data, val, event, skipStateUpdate);
146
+ }, 300);
147
+ this.debouncedValidation = _.debounce(this.validateForm.bind(this), 500);
148
+
149
+ // Memoized validation cache
150
+ this.validationCache = new Map();
151
+
152
+ }
153
+
154
+ // Helper function to generate unique tab ID
155
+ generateUniqueTabId(formData, tabIndex) {
156
+ let id = _.uniqueId();
157
+ let validId = false;
158
+
159
+ while (!validId) {
160
+ validId = true;
161
+ for (let idx = 0; idx < formData[tabIndex].selectedLanguages.length; idx += 1) {
162
+ if (!formData[tabIndex]) {
163
+ continue;
164
+ }
165
+ if (id === formData[tabIndex][formData[tabIndex].selectedLanguages[idx]].tabKey) {
166
+ validId = false;
167
+ break;
168
+ }
169
+ }
170
+ if (!validId) {
171
+ id = _.uniqueId();
172
+ }
173
+ }
174
+
175
+ return id;
176
+ }
177
+
178
+ // Performance optimized form data update function
179
+ performFormDataUpdate(data, val, event, skipStateUpdate = false) {
180
+
181
+ // Use optimized state update instead of deep cloning
182
+ const formData = this.optimizedFormDataUpdate(data, val, event);
183
+
184
+ const tabIndex = this.state.currentTab - 1;
185
+ let currentTab = this.state.currentTab;
186
+
187
+ if (this.state.usingTabContainer && !val.standalone) {
188
+ const data1 = data;
189
+ if (event === "addLanguage") {
190
+ const addLanguageType = this.props.addLanguageType;
191
+ if (addLanguageType === '') {
192
+ return;
193
+ }
194
+ const currentLang = formData[tabIndex].activeTab;
195
+ let baseTab;
196
+
197
+ switch (addLanguageType) {
198
+ case "upload":
199
+ baseTab = _.cloneDeep(formData[tabIndex][currentLang]);
200
+
201
+ baseTab.iso_code = data.iso_code;
202
+ baseTab.lang_id = data.lang_id;
203
+ baseTab.language = data.language;
204
+ baseTab.tabKey = this.generateUniqueTabId(formData, tabIndex);
205
+
206
+ formData[tabIndex][data.iso_code] = baseTab;
207
+ formData[tabIndex].activeTab = data.iso_code;
208
+ formData[tabIndex].tabKey = baseTab.tabKey;
209
+ break;
210
+ case "copyPrimaryLanguage":
211
+ case "useEditor":
212
+ baseTab = _.cloneDeep(formData[tabIndex][this.props.baseLanguage]);
213
+
214
+ baseTab.iso_code = data.iso_code;
215
+ baseTab.lang_id = data.lang_id;
216
+ baseTab.language = data.language;
217
+ baseTab.tabKey = this.generateUniqueTabId(formData, tabIndex);
218
+
219
+ formData[tabIndex].selectedLanguages.push(data.iso_code);
220
+ formData[tabIndex][data.iso_code] = baseTab;
221
+ formData[tabIndex].activeTab = data.iso_code;
222
+ formData[tabIndex].tabKey = baseTab.tabKey;
223
+ break;
224
+ case '':
225
+ return;
226
+ default:
227
+ break;
228
+ }
229
+ const that = this;
230
+ setTimeout(() => {
231
+ that.setState({tabKey: baseTab.tabKey});
232
+ }, 0);
233
+ }
234
+
235
+ if (!this.props.isNewVersionFlow) {
236
+ formData[tabIndex][val.id] = data1;
237
+ } else if (this.props.isNewVersionFlow && event !== "addLanguage" && event !== "onContentChange") {
238
+ formData[tabIndex][this.props.baseLanguage][val.id] = data1;
239
+ }
240
+
241
+ if (formData[tabIndex].base) {
242
+ if (!this.props.isNewVersionFlow) {
243
+ formData.base[val.id] = data1;
244
+ } else {
245
+ formData.base[data1.iso_code] = formData[tabIndex][data1.iso_code];
246
+ formData.base.tabKey = formData[tabIndex].tabKey;
247
+ formData.base.activeTab = formData[tabIndex].activeTab;
248
+ formData.base.selectedLanguages = formData[tabIndex].selectedLanguages;
249
+ }
250
+ }
251
+ } else {
252
+ formData[val.id] = data;
253
+ }
254
+
255
+ if (this.props.isNewVersionFlow) {
256
+ if (event === 'onSelect' && data === 'New Version') {
257
+ this.callChildEvent(data, val, 'addVersion', event);
258
+ } else if (event === 'onSelect' && data !== 'New Version') {
259
+ currentTab = _.findIndex(this.state.formData['template-version-options'], { key: data}) + 1;
260
+ this.setState({currentTab, tabKey: formData[`${currentTab - 1}`].tabKey}, () => {
261
+ val.injectedEvents[event].call(this, this.state.formData['template-version-options'][currentTab - 1].key, formData, val);
262
+ });
263
+ }
264
+
265
+ if (event === 'onContentChange') {
266
+ // Content change handling
267
+ }
268
+ }
142
269
 
270
+ // Only update state if not already updated (for immediate UI feedback)
271
+ if (!skipStateUpdate) {
272
+ this.setState({formData}, () => {
273
+ if (this.props.startValidation) {
274
+ this.debouncedValidation();
275
+ }
276
+ });
277
+ } else {
278
+ // Just trigger validation if state was already updated
279
+ if (this.props.startValidation) {
280
+ this.debouncedValidation();
281
+ }
282
+ }
283
+
284
+ if (event && val.injectedEvents[event]) {
285
+ if (event === "onRowClick") {
286
+ val.injectedEvents[event].call(this, data);
287
+ } else if (this.props.isNewVersionFlow && event !== 'onSelect') {
288
+ if (event === 'addLanguage') {
289
+ val.injectedEvents[event].call(this, data, formData, val);
290
+ this.setState({currentEventVal: {}, currentEvent: {}, currentEventData: {}});
291
+ } else {
292
+ val.injectedEvents[event].call(this, true, formData, val);
293
+ }
294
+ } else if (!this.props.isNewVersionFlow) {
295
+ val.injectedEvents[event].call(this, true, formData, val);
296
+ }
297
+ } else if (val.injectedEvents && val.injectedEvents.onChange) {
298
+ val.injectedEvents.onChange.call(this, true, formData, val);
299
+ }
300
+
301
+ if (!((event === 'onSelect' && data === 'New Version') || event === 'onContentChange')) {
302
+ this.props.onChange(formData, this.state.tabCount, currentTab, val);
303
+ }
304
+ }
305
+
306
+ // Optimized form data update - only clone what's necessary
307
+ optimizedFormDataUpdate(data, val, event) {
308
+ const currentFormData = this.state.formData;
309
+
310
+ // For simple field updates, use spread operator instead of deep clone
311
+ if (!this.state.usingTabContainer || val.standalone) {
312
+ return {
313
+ ...currentFormData,
314
+ [val.id]: data
315
+ };
316
+ }
317
+
318
+ // For tab container updates, only clone the affected tab
319
+ const tabIndex = this.state.currentTab - 1;
320
+ const updatedFormData = { ...currentFormData };
321
+
322
+ if (updatedFormData[tabIndex]) {
323
+ updatedFormData[tabIndex] = {
324
+ ...updatedFormData[tabIndex],
325
+ [val.id]: data
326
+ };
327
+ }
328
+
329
+ return updatedFormData;
143
330
  }
331
+
144
332
  isLiquidFlowSupported = () => {
145
333
  return Boolean(LIQUID_SUPPORTED_CHANNELS.includes(this.props?.schema?.channel?.toUpperCase()) && hasLiquidSupportFeature());
146
334
  }
@@ -2027,6 +2215,64 @@ class FormBuilder extends React.Component { // eslint-disable-line react/prefer-
2027
2215
  }
2028
2216
 
2029
2217
  updateFormData(data, val, event) {
2218
+
2219
+ // Check if this is a high-frequency input field that should be optimized
2220
+ const isHighFrequencyField = val && (
2221
+ val.id === 'template-name' ||
2222
+ val.id === 'template-subject' ||
2223
+ val.type === 'input' ||
2224
+ val.type === 'textarea'
2225
+ );
2226
+
2227
+ if (isHighFrequencyField && !event) {
2228
+ // For high-frequency fields: immediate UI update + debounced expensive operations
2229
+ this.updateFormDataOptimized(data, val, event);
2230
+ return;
2231
+ }
2232
+
2233
+ // For non-high-frequency fields or special events, use immediate update
2234
+ this.performFormDataUpdate(data, val, event);
2235
+ }
2236
+
2237
+ // Optimized update for high-frequency fields
2238
+ updateFormDataOptimized(data, val, event) {
2239
+ // 1. Immediate UI update - update the field value instantly
2240
+ this.updateFieldValueImmediately(data, val);
2241
+
2242
+ // 2. Debounce expensive operations (validation, parent updates) - skip state update since we already did it
2243
+ this.debouncedUpdateFormData(data, val, event, true);
2244
+ }
2245
+
2246
+ // Update field value immediately for UI feedback
2247
+ updateFieldValueImmediately(data, val) {
2248
+ const currentFormData = this.state.formData;
2249
+ let updatedFormData;
2250
+
2251
+ if (!this.state.usingTabContainer || val.standalone) {
2252
+ // Simple field update
2253
+ updatedFormData = {
2254
+ ...currentFormData,
2255
+ [val.id]: data
2256
+ };
2257
+ } else {
2258
+ // Tab container update
2259
+ const tabIndex = this.state.currentTab - 1;
2260
+ updatedFormData = { ...currentFormData };
2261
+
2262
+ if (updatedFormData[tabIndex]) {
2263
+ updatedFormData[tabIndex] = {
2264
+ ...updatedFormData[tabIndex],
2265
+ [val.id]: data
2266
+ };
2267
+ }
2268
+ }
2269
+
2270
+ // Update state immediately for UI feedback
2271
+ this.setState({ formData: updatedFormData });
2272
+ }
2273
+
2274
+ // Legacy updateFormData function - kept for backward compatibility
2275
+ updateFormDataLegacy(data, val, event) {
2030
2276
  const formData = _.cloneDeep(this.state.formData);
2031
2277
 
2032
2278
  const tabIndex = this.state.currentTab - 1;
@@ -2186,6 +2432,57 @@ class FormBuilder extends React.Component { // eslint-disable-line react/prefer-
2186
2432
  hasClass(element, className) {
2187
2433
  return (` ${element.className} `).indexOf(` ${className} `) > -1;
2188
2434
  }
2435
+
2436
+ // Handle field blur for validation
2437
+ handleFieldBlur = (e, val) => {
2438
+ // Trigger validation on blur for high-frequency fields
2439
+ if (val && (val.id === 'template-name' || val.id === 'template-subject' || val.type === 'input' || val.type === 'textarea')) {
2440
+ this.debouncedValidation();
2441
+ }
2442
+ }
2443
+
2444
+ // Memoized validation for specific fields
2445
+ getMemoizedValidation = (fieldId, fieldValue) => {
2446
+ const cacheKey = `${fieldId}_${fieldValue}`;
2447
+
2448
+ if (this.validationCache.has(cacheKey)) {
2449
+ return this.validationCache.get(cacheKey);
2450
+ }
2451
+
2452
+ // Perform validation logic here
2453
+ const isValid = this.performFieldValidation(fieldId, fieldValue);
2454
+ this.validationCache.set(cacheKey, isValid);
2455
+
2456
+ // Clear cache if it gets too large
2457
+ if (this.validationCache.size > 100) {
2458
+ this.validationCache.clear();
2459
+ }
2460
+
2461
+ return isValid;
2462
+ }
2463
+
2464
+ // Perform validation for a specific field
2465
+ performFieldValidation = (fieldId, fieldValue) => {
2466
+ // Basic validation logic for template-name and template-subject
2467
+ if (fieldId === 'template-name' || fieldId === 'template-subject') {
2468
+ return fieldValue && fieldValue.trim().length > 0;
2469
+ }
2470
+ return true;
2471
+ }
2472
+
2473
+ // Cleanup debounced functions on component unmount
2474
+ componentWillUnmount() {
2475
+ if (this.debouncedUpdateFormData) {
2476
+ this.debouncedUpdateFormData.cancel();
2477
+ }
2478
+ if (this.debouncedValidation) {
2479
+ this.debouncedValidation.cancel();
2480
+ }
2481
+ // Clear validation cache
2482
+ if (this.validationCache) {
2483
+ this.validationCache.clear();
2484
+ }
2485
+ }
2189
2486
  allowAddSecondaryCta = (val) => {
2190
2487
  if (val.fieldsCount > 0) {
2191
2488
  const errorData = _.cloneDeep(this.state.errorData);
@@ -2601,6 +2898,7 @@ class FormBuilder extends React.Component { // eslint-disable-line react/prefer-
2601
2898
  label={val.label}
2602
2899
  autosize={val.autosize ? val.autosizeParams : false}
2603
2900
  onChange={(e) => this.updateFormData(e.target.value, val)}
2901
+ onBlur={(e) => this.handleFieldBlur(e, val)}
2604
2902
  style={val.style ? val.style : {}}
2605
2903
  defaultValue={messageContent || ''}
2606
2904
  value={messageContent || ""}
@@ -2682,6 +2980,7 @@ class FormBuilder extends React.Component { // eslint-disable-line react/prefer-
2682
2980
  style={val.style ? val.style : {}}
2683
2981
  placeholder={val.placeholder}
2684
2982
  onChange={(e) => this.updateFormData(e.target.value, val)}
2983
+ onBlur={(e) => this.handleFieldBlur(e, val)}
2685
2984
  defaultValue={isVersionEnable ? this.state.formData[`${this.state.currentTab - 1}`][val.id] : this.state.formData[val.id]}
2686
2985
  value={value || ""}
2687
2986
  disabled={val.disabled}
@@ -3166,6 +3465,7 @@ class FormBuilder extends React.Component { // eslint-disable-line react/prefer-
3166
3465
  style={val.style ? val.style : {}}
3167
3466
  placeholder={val.placeholder}
3168
3467
  onChange={(e) => this.updateFormData(e.target.value, val)}
3468
+ onBlur={(e) => this.handleFieldBlur(e, val)}
3169
3469
  value={value || ""}
3170
3470
  defaultValue={isVersionEnable ? this.state.formData[`${this.state.currentTab - 1}`][val.id] : this.state.formData[val.id]}
3171
3471
  disabled={val.disabled}
@@ -349,4 +349,4 @@ const withConnect = connect(mapStateToProps, mapDispatchToProps);
349
349
  const withSaga = injectSaga({ key: 'beeEditor', saga: v2BeeEditionSagas });
350
350
  const withReducer = injectReducer({ key: 'beeEditor', reducer: v2BeeEditionReducer });
351
351
 
352
- export default compose(withReducer, withSaga, withConnect)(injectIntl(BeeEditor));
352
+ export default compose(withReducer, withSaga, withConnect)(injectIntl(React.memo(BeeEditor)));
@@ -668,9 +668,7 @@ export function SlideBoxContent(props) {
668
668
  />
669
669
  )}
670
670
  {isEditMPush && (
671
- (isFullMode && !commonUtil.hasNewMobilePushFeatureEnabled()) ||
672
- (!isFullMode && isLoyaltyModule) ||
673
- (!isFullMode && !isLoyaltyModule && !commonUtil.hasNewMobilePushFeatureEnabled()) ? (
671
+ (!isFullMode && isLoyaltyModule) ? (
674
672
  <MobliPushEdit
675
673
  getFormLibraryData={getFormData}
676
674
  setIsLoadingContent={setIsLoadingContent}
@@ -726,9 +724,7 @@ export function SlideBoxContent(props) {
726
724
  )
727
725
  )}
728
726
  {isCreateMPush && (
729
- (isFullMode && !commonUtil.hasNewMobilePushFeatureEnabled()) ||
730
- (!isFullMode && isLoyaltyModule) ||
731
- (!isFullMode && !isLoyaltyModule && !commonUtil.hasNewMobilePushFeatureEnabled()) ? (
727
+ (!isFullMode && isLoyaltyModule) ? (
732
728
  <MobilepushWrapper
733
729
  key="creatives-mobilepush-wrapper"
734
730
  date={new Date().getMilliseconds()}
@@ -11,7 +11,7 @@ import CapNotification from '@capillarytech/cap-ui-library/CapNotification';
11
11
  import { injectIntl, FormattedMessage } from 'react-intl';
12
12
  import classnames from 'classnames';
13
13
  import {
14
- isEmpty, get, forEach, cloneDeep,
14
+ isEmpty, get, forEach, cloneDeep, debounce,
15
15
  } from 'lodash';
16
16
  import { connect } from 'react-redux';
17
17
  import { createStructuredSelector } from 'reselect';
@@ -105,6 +105,8 @@ export class Creatives extends React.Component {
105
105
  // NEW: Test and Preview feature state
106
106
  showTestAndPreviewSlidebox: false,
107
107
  isTestAndPreviewMode: false, // Add flag to track Test & Preview mode
108
+ // Performance optimization: Local template name for immediate UI feedback
109
+ localTemplateName: '',
108
110
  };
109
111
  this.liquidFlow = Boolean(commonUtil.hasLiquidSupportFeature());
110
112
  this.creativesTemplateSteps = {
@@ -112,6 +114,9 @@ export class Creatives extends React.Component {
112
114
  2: 'templateSelection', // only for email in current flows wil be used for mpush, line and wechat as well.
113
115
  3: 'createTemplateContent',
114
116
  };
117
+
118
+ // Performance optimization: Debounced template name update
119
+ this.debouncedTemplateNameUpdate = debounce(this.performTemplateNameUpdate.bind(this), 300);
115
120
  }
116
121
 
117
122
  componentWillUnmount() {
@@ -119,6 +124,11 @@ export class Creatives extends React.Component {
119
124
  this.props.templateActions.resetTemplateStoreData();
120
125
  }
121
126
  this.props.globalActions.clearMetaEntities();
127
+
128
+ // Cleanup debounced function
129
+ if (this.debouncedTemplateNameUpdate) {
130
+ this.debouncedTemplateNameUpdate.cancel();
131
+ }
122
132
  }
123
133
 
124
134
  componentDidMount() {
@@ -136,6 +146,29 @@ export class Creatives extends React.Component {
136
146
 
137
147
  onEnterTemplateName = () => {
138
148
  this.setState({ templateNameExists: true });
149
+ }
150
+
151
+ // Performance optimized template name update
152
+ performTemplateNameUpdate = (value, formData, onFormDataChange) => {
153
+ const isEmptyTemplateName = !value.trim();
154
+ const newFormData = { ...formData, 'template-name': value, 'isTemplateNameEdited': true };
155
+
156
+ this.setState({ isTemplateNameEmpty: isEmptyTemplateName });
157
+ onFormDataChange(newFormData);
158
+ }
159
+
160
+ // Update template name immediately for UI feedback
161
+ updateTemplateNameImmediately = (value, formData, onFormDataChange) => {
162
+ const isEmptyTemplateName = !value.trim();
163
+
164
+ // 1. IMMEDIATE: Update local state for instant UI feedback
165
+ this.setState({
166
+ isTemplateNameEmpty: isEmptyTemplateName,
167
+ localTemplateName: value
168
+ });
169
+
170
+ // 2. DEBOUNCED: Only debounce the expensive onFormDataChange call
171
+ this.debouncedTemplateNameUpdate(value, formData, onFormDataChange);
139
172
  };
140
173
 
141
174
  onRemoveTemplateName = () => {
@@ -1395,21 +1428,30 @@ export class Creatives extends React.Component {
1395
1428
  } />
1396
1429
  )
1397
1430
 
1398
- templateNameComponentInput = ({ formData, onFormDataChange, name }) => (
1399
- <CapInput
1400
- value={name}
1401
- suffix={<span />}
1402
- onBlur={() => { this.setState({ isEditName: false }, () => { this.showTemplateName({ formData, onFormDataChange }); }); }}
1403
- onChange={(ev) => {
1404
- const { value } = ev.currentTarget;
1405
- const isEmptyTemplateName = !value.trim();
1406
-
1407
- const newFormData = { ...formData, 'template-name': value, 'isTemplateNameEdited': true };
1408
- this.setState({ isTemplateNameEmpty: isEmptyTemplateName });
1409
- onFormDataChange(newFormData);
1410
- }}
1411
- />
1412
- )
1431
+ templateNameComponentInput = ({ formData, onFormDataChange, name }) => {
1432
+ // Use local state for immediate UI feedback, fallback to prop value
1433
+ const displayValue = this.state.localTemplateName !== '' ? this.state.localTemplateName : name;
1434
+
1435
+ return (
1436
+ <CapInput
1437
+ value={displayValue}
1438
+ suffix={<span />}
1439
+ onBlur={() => {
1440
+ this.setState({
1441
+ isEditName: false,
1442
+ localTemplateName: '' // Clear local state on blur
1443
+ }, () => {
1444
+ this.showTemplateName({ formData, onFormDataChange });
1445
+ });
1446
+ }}
1447
+ onChange={(ev) => {
1448
+ const { value } = ev.currentTarget;
1449
+ // Use optimized update for better performance
1450
+ this.updateTemplateNameImmediately(value, formData, onFormDataChange);
1451
+ }}
1452
+ />
1453
+ );
1454
+ }
1413
1455
 
1414
1456
  showTemplateName = ({ formData, onFormDataChange }) => { //gets called from email/index after template data is fetched
1415
1457
  const { slidBoxContent, currentChannel, isEditName } = this.state;
@@ -1423,7 +1465,10 @@ export class Creatives extends React.Component {
1423
1465
  if (name && !isEditName) {
1424
1466
  this.setState({ showTemplateNameComponentEdit: false });
1425
1467
  } else if (isEditName) {
1426
- this.setState({ showTemplateNameComponentEdit: true });
1468
+ this.setState({
1469
+ showTemplateNameComponentEdit: true,
1470
+ localTemplateName: name || '' // Initialize local state with current value
1471
+ });
1427
1472
  }
1428
1473
  }
1429
1474
  }
@@ -700,4 +700,139 @@ describe('Test SlideBoxContent container', () => {
700
700
  expect(renderedComponent).toMatchSnapshot();
701
701
  });
702
702
  });
703
+
704
+ // Mobile Push Loyalty Module Tests
705
+ describe('Mobile Push with Loyalty Module', () => {
706
+ it('Should render MobliPushEdit for loyalty module in library mode (edit)', () => {
707
+ renderFunction('MOBILE_PUSH', 'editTemplate', { _id: 'test-id' }, {
708
+ isFullMode: false,
709
+ isLoyaltyModule: true
710
+ });
711
+ expect(renderedComponent).toMatchSnapshot();
712
+ });
713
+
714
+ it('Should render MobilePushNew for loyalty module in full mode (edit)', () => {
715
+ renderFunction('MOBILE_PUSH', 'editTemplate', { _id: 'test-id' }, {
716
+ isFullMode: true,
717
+ isLoyaltyModule: true
718
+ });
719
+ expect(renderedComponent).toMatchSnapshot();
720
+ });
721
+
722
+ it('Should render MobilepushWrapper for loyalty module in library mode (create)', () => {
723
+ renderFunction('MOBILE_PUSH', 'createTemplate', { mode: 'create' }, {
724
+ isFullMode: false,
725
+ isLoyaltyModule: true
726
+ });
727
+ expect(renderedComponent).toMatchSnapshot();
728
+ });
729
+
730
+ it('Should render MobilePushNew for loyalty module in full mode (create)', () => {
731
+ renderFunction('MOBILE_PUSH', 'createTemplate', { mode: 'create' }, {
732
+ isFullMode: true,
733
+ isLoyaltyModule: true
734
+ });
735
+ expect(renderedComponent).toMatchSnapshot();
736
+ });
737
+
738
+ it('Should render MobilePushNew for non-loyalty module in library mode (edit)', () => {
739
+ renderFunction('MOBILE_PUSH', 'editTemplate', { _id: 'test-id' }, {
740
+ isFullMode: false,
741
+ isLoyaltyModule: false
742
+ });
743
+ expect(renderedComponent).toMatchSnapshot();
744
+ });
745
+
746
+ it('Should render MobilePushNew for non-loyalty module in library mode (create)', () => {
747
+ renderFunction('MOBILE_PUSH', 'createTemplate', { mode: 'create' }, {
748
+ isFullMode: false,
749
+ isLoyaltyModule: false
750
+ });
751
+ expect(renderedComponent).toMatchSnapshot();
752
+ });
753
+ });
754
+
755
+ // Additional edge case tests to cover uncovered lines
756
+ describe('Edge Cases for Uncovered Lines', () => {
757
+ it('Should handle getLineType with LINE channel and empty templateData', () => {
758
+ // This should cover lines 72-74 in getLineType function
759
+ // LINE channel, not full mode, templateData exists but has no _id, no messageBody, and not isDefault
760
+ const emptyLineTemplateData = {
761
+ type: 'LINE',
762
+ isDefault: false,
763
+ // No _id and no versions.base.content.messages[0]
764
+ };
765
+ renderFunction('LINE', 'editTemplate', emptyLineTemplateData, {
766
+ isFullMode: false
767
+ });
768
+ expect(renderedComponent).toMatchSnapshot();
769
+ });
770
+
771
+ it('Should handle SMS channel with DLT content processing', () => {
772
+ // This should cover line 251 in SMS content processing
773
+ // Need isDltEnabled = true and updatedSmsEditor as array
774
+ // Mock hasTraiDltFeature to return true
775
+ const commonUtils = require('../../../utils/common');
776
+ const originalHasTraiDltFeature = commonUtils.hasTraiDltFeature;
777
+ commonUtils.hasTraiDltFeature = jest.fn(() => true);
778
+
779
+ const smsTemplateData = {
780
+ type: 'SMS',
781
+ versions: {
782
+ base: {
783
+ 'updated-sms-editor': ['Line 1', 'Line 2', 'Line 3'], // Array to trigger join()
784
+ 'sms-editor': 'Fallback SMS content'
785
+ }
786
+ }
787
+ };
788
+ renderFunction('SMS', 'preview', smsTemplateData, {
789
+ smsRegister: 'DLT', // Enable DLT for library mode
790
+ isFullMode: false
791
+ });
792
+
793
+ // Restore original function
794
+ commonUtils.hasTraiDltFeature = originalHasTraiDltFeature;
795
+ expect(renderedComponent).toMatchSnapshot();
796
+ });
797
+
798
+ it('Should handle LINE channel with video content', () => {
799
+ // This should cover lines 314-315 for video detection
800
+ // Need a LINE message with video property but no text, previewImageUrl, selectedSticker, baseUrl, template, or contents
801
+ const lineTemplateDataWithVideo = {
802
+ type: 'LINE',
803
+ versions: {
804
+ base: {
805
+ content: {
806
+ messages: [{
807
+ video: {
808
+ originalContentUrl: 'https://example.com/video.mp4',
809
+ externalLink: {
810
+ linkUri: 'https://example.com/link'
811
+ }
812
+ }
813
+ // No text, previewImageUrl, selectedSticker, baseUrl, template, or contents
814
+ }]
815
+ }
816
+ }
817
+ }
818
+ };
819
+ renderFunction('LINE', 'preview', lineTemplateDataWithVideo);
820
+ expect(renderedComponent).toMatchSnapshot();
821
+ });
822
+
823
+ it('Should handle default case in getChannelPreviewContent', () => {
824
+ // This should cover line 395 - default case return
825
+ const unknownChannelData = {
826
+ versions: {
827
+ base: {
828
+ content: {
829
+ customField: 'custom content'
830
+ }
831
+ }
832
+ }
833
+ };
834
+ renderFunction('UNKNOWN_CHANNEL', 'preview', unknownChannelData);
835
+ expect(renderedComponent).toMatchSnapshot();
836
+ });
837
+ });
703
838
  });
@@ -1,5 +1,138 @@
1
1
  // Jest Snapshot v1, https://goo.gl/fbAQLP
2
2
 
3
+ exports[`Test SlideBoxContent container Edge Cases for Uncovered Lines Should handle LINE channel with video content 1`] = `
4
+ <SlideBoxContent__CreativesWrapper>
5
+ <InjectIntl(TemplatePreview)
6
+ channel="line"
7
+ charCounterEnabled={false}
8
+ content={
9
+ Array [
10
+ Object {
11
+ "actionUrl": "https://example.com/link",
12
+ "baseUrl": undefined,
13
+ "imageCarousel": Array [
14
+ Object {
15
+ "actionLabel": undefined,
16
+ "imageUrl": undefined,
17
+ },
18
+ ],
19
+ "messageContent": undefined,
20
+ "previewImageUrl": undefined,
21
+ "selectedSticker": Object {
22
+ "animatedStickerUrl": undefined,
23
+ "packageId": undefined,
24
+ "stickerId": undefined,
25
+ "stickerUrl": undefined,
26
+ },
27
+ "type": "image_carousel",
28
+ "video": "https://example.com/video.mp4",
29
+ },
30
+ ]
31
+ }
32
+ showCount={true}
33
+ templateData={
34
+ Object {
35
+ "type": "LINE",
36
+ "versions": Object {
37
+ "base": Object {
38
+ "content": Object {
39
+ "messages": Array [
40
+ Object {
41
+ "video": Object {
42
+ "externalLink": Object {
43
+ "linkUri": "https://example.com/link",
44
+ },
45
+ "originalContentUrl": "https://example.com/video.mp4",
46
+ },
47
+ },
48
+ ],
49
+ },
50
+ },
51
+ },
52
+ }
53
+ }
54
+ viberBrandName=""
55
+ />
56
+ <Connect(Connect(UserIsAuthenticated(InjectIntl(CreativesCommon))))
57
+ location={
58
+ Object {
59
+ "pathname": "/sms/edit",
60
+ "query": Object {
61
+ "isEditFromCampaigns": undefined,
62
+ "module": "library",
63
+ "type": "embedded",
64
+ },
65
+ "search": "",
66
+ }
67
+ }
68
+ onCreateComplete={[MockFunction]}
69
+ params={
70
+ Object {
71
+ "id": undefined,
72
+ "mode": undefined,
73
+ }
74
+ }
75
+ route={
76
+ Object {
77
+ "name": "edit_text",
78
+ }
79
+ }
80
+ templateData={
81
+ Object {
82
+ "type": "LINE",
83
+ "versions": Object {
84
+ "base": Object {
85
+ "content": Object {
86
+ "messages": Array [
87
+ Object {
88
+ "video": Object {
89
+ "externalLink": Object {
90
+ "linkUri": "https://example.com/link",
91
+ },
92
+ "originalContentUrl": "https://example.com/video.mp4",
93
+ },
94
+ },
95
+ ],
96
+ },
97
+ },
98
+ },
99
+ }
100
+ }
101
+ />
102
+ </SlideBoxContent__CreativesWrapper>
103
+ `;
104
+
105
+ exports[`Test SlideBoxContent container Edge Cases for Uncovered Lines Should handle SMS channel with DLT content processing 1`] = `
106
+ <SlideBoxContent__CreativesWrapper>
107
+ <InjectIntl(TemplatePreview)
108
+ channel="sms"
109
+ charCounterEnabled={true}
110
+ content="Fallback SMS content"
111
+ showCount={true}
112
+ templateData={
113
+ Object {
114
+ "type": "SMS",
115
+ "versions": Object {
116
+ "base": Object {
117
+ "sms-editor": "Fallback SMS content",
118
+ "updated-sms-editor": Array [
119
+ "Line 1",
120
+ "Line 2",
121
+ "Line 3",
122
+ ],
123
+ },
124
+ },
125
+ }
126
+ }
127
+ viberBrandName=""
128
+ />
129
+ </SlideBoxContent__CreativesWrapper>
130
+ `;
131
+
132
+ exports[`Test SlideBoxContent container Edge Cases for Uncovered Lines Should handle default case in getChannelPreviewContent 1`] = `<SlideBoxContent__CreativesWrapper />`;
133
+
134
+ exports[`Test SlideBoxContent container Edge Cases for Uncovered Lines Should handle getLineType with LINE channel and empty templateData 1`] = `<SlideBoxContent__CreativesWrapper />`;
135
+
3
136
  exports[`Test SlideBoxContent container Email component isTestAndPreviewMode IIFE Should handle isTestAndPreviewMode IIFE in Email edit mode with ID 1`] = `
4
137
  <SlideBoxContent__CreativesWrapper>
5
138
  <ForwardRef
@@ -202,6 +335,18 @@ exports[`Test SlideBoxContent container Email component isTestAndPreviewMode IIF
202
335
  </SlideBoxContent__CreativesWrapper>
203
336
  `;
204
337
 
338
+ exports[`Test SlideBoxContent container Mobile Push with Loyalty Module Should render MobilePushNew for loyalty module in full mode (create) 1`] = `<SlideBoxContent__CreativesWrapper />`;
339
+
340
+ exports[`Test SlideBoxContent container Mobile Push with Loyalty Module Should render MobilePushNew for loyalty module in full mode (edit) 1`] = `<SlideBoxContent__CreativesWrapper />`;
341
+
342
+ exports[`Test SlideBoxContent container Mobile Push with Loyalty Module Should render MobilePushNew for non-loyalty module in library mode (create) 1`] = `<SlideBoxContent__CreativesWrapper />`;
343
+
344
+ exports[`Test SlideBoxContent container Mobile Push with Loyalty Module Should render MobilePushNew for non-loyalty module in library mode (edit) 1`] = `<SlideBoxContent__CreativesWrapper />`;
345
+
346
+ exports[`Test SlideBoxContent container Mobile Push with Loyalty Module Should render MobilepushWrapper for loyalty module in library mode (create) 1`] = `<SlideBoxContent__CreativesWrapper />`;
347
+
348
+ exports[`Test SlideBoxContent container Mobile Push with Loyalty Module Should render MobliPushEdit for loyalty module in library mode (edit) 1`] = `<SlideBoxContent__CreativesWrapper />`;
349
+
205
350
  exports[`Test SlideBoxContent container Should handle isTestAndPreviewMode IIFE implementation correctly 1`] = `
206
351
  <SlideBoxContent__CreativesWrapper>
207
352
  <ForwardRef
@@ -2516,6 +2516,9 @@ export class Email extends React.Component { // eslint-disable-line react/prefer
2516
2516
  });
2517
2517
  obj.versions.history.push(newdata);
2518
2518
  }
2519
+ if (index === "template-subject") {
2520
+ obj.versions.history[0].subject = newdata;
2521
+ }
2519
2522
  });
2520
2523
  //const data = formData[`${this.state.currentTab - 1}`];
2521
2524
  obj.name = formData['template-name'];
@@ -21,6 +21,7 @@ import { intlShape } from "react-intl";
21
21
  import "./index.scss";
22
22
  import { GA } from "@capillarytech/cap-ui-utils";
23
23
  import CapNotification from "@capillarytech/cap-ui-library/CapNotification";
24
+ import { CapModal } from "@capillarytech/cap-react-ui-library";
24
25
  import { DAEMON } from "@capillarytech/vulcan-react-sdk/utils/sagaInjectorTypes";
25
26
  import globalMessages from "../Cap/messages";
26
27
  import * as actions from "./actions";
@@ -529,6 +530,9 @@ const MobilePushNew = ({
529
530
  STANDARD_ERROR_MSG: {},
530
531
  LIQUID_ERROR_MSG: {},
531
532
  });
533
+ // Modal state for single platform confirmation
534
+ const [showModal, setShowModal] = useState(false);
535
+ const [modalContent, setModalContent] = useState({});
532
536
  const [androidContent, setAndroidContent] = useState(INITIAL_CONTENT);
533
537
 
534
538
  const [iosContent, setIosContent] = useState(INITIAL_CONTENT);
@@ -2130,39 +2134,93 @@ const MobilePushNew = ({
2130
2134
  return panes;
2131
2135
  }, [isAndroidSupported, isIosSupported, renderContentFields, formatMessage]);
2132
2136
 
2133
- // Save button disabled logic: only check enabled platforms
2137
+ // Save button disabled logic: require at least one platform to have data
2138
+ const hasAndroidData = isAndroidSupported && androidContent?.title?.trim() && androidContent?.message?.trim();
2139
+ const hasIosData = isIosSupported && iosContent?.title?.trim() && iosContent?.message?.trim();
2140
+ const hasAnyPlatformData = hasAndroidData || hasIosData;
2141
+
2142
+ // Validation checks for save button
2143
+ const carouselErrors = Object.values(carouselLinkErrors).some((error) => error !== null && error !== "");
2144
+ const carouselValid = isCarouselDataValid();
2145
+
2134
2146
  const isSaveDisabled = (
2135
- (isAndroidSupported && (!androidContent?.title?.trim() || !androidContent?.message?.trim()))
2136
- || (isIosSupported && (!iosContent?.title?.trim() || !iosContent?.message?.trim()))
2137
- || templateNameError
2138
- || Object.values(carouselLinkErrors).some((error) => error !== null && error !== "")
2139
- || !isCarouselDataValid()
2147
+ !hasAnyPlatformData // At least one supported platform must have data
2148
+ || (isFullMode && (!templateName || !templateName.trim())) // Template name required in full mode
2149
+ || carouselErrors
2150
+ || !carouselValid
2140
2151
  );
2141
2152
 
2142
- // Validation in handleSave: only show errors for enabled platforms
2143
- const handleSave = useCallback(() => {
2144
- if (isAndroidSupported && (!androidContent?.title?.trim() || !androidContent?.message?.trim())) {
2145
- CapNotification.error({
2146
- message: formatMessage(messages.androidValidationError),
2147
- });
2148
- if (onValidationFail) onValidationFail();
2149
- return;
2153
+ // Modal handler functions
2154
+ const setModalContentHandler = useCallback((type) => {
2155
+ if (type === 'ios') {
2156
+ const content = {
2157
+ title: formatMessage(messages.alertMessage),
2158
+ body: formatMessage(messages.iosTemplateNotConfigured),
2159
+ type: 'confirm',
2160
+ id: 'ios',
2161
+ };
2162
+ setModalContent(content);
2163
+ setShowModal(true);
2164
+ } else if (type === 'android') {
2165
+ const content = {
2166
+ title: formatMessage(messages.alertMessage),
2167
+ body: formatMessage(messages.androidTemplateNotConfigured),
2168
+ type: 'confirm',
2169
+ id: 'android',
2170
+ };
2171
+ setModalContent(content);
2172
+ setShowModal(true);
2150
2173
  }
2151
- if (isIosSupported && (!iosContent?.title?.trim() || !iosContent?.message?.trim())) {
2152
- CapNotification.error({
2153
- message: formatMessage(messages.iosValidationError),
2154
- });
2155
- if (onValidationFail) onValidationFail();
2156
- return;
2174
+ }, [formatMessage]);
2175
+
2176
+ const handleCancelModal = useCallback(() => {
2177
+ setShowModal(false);
2178
+ setModalContent({});
2179
+ }, []);
2180
+
2181
+ // Internal save function with optional modal validation skip
2182
+ const handleSaveInternal = useCallback((skipModalValidation = false) => {
2183
+ // Check for single platform data and show modal confirmation (unless skipped)
2184
+ if (!skipModalValidation) {
2185
+ const androidHasData = androidContent?.title?.trim() && androidContent?.message?.trim();
2186
+ const iosHasData = iosContent?.title?.trim() && iosContent?.message?.trim();
2187
+
2188
+ // If both platforms are supported but only one has data, show confirmation modal
2189
+ if (isAndroidSupported && isIosSupported) {
2190
+ if (androidHasData && !iosHasData) {
2191
+ setModalContentHandler('ios'); // Show iOS not configured modal
2192
+ return;
2193
+ }
2194
+ if (iosHasData && !androidHasData) {
2195
+ setModalContentHandler('android'); // Show Android not configured modal
2196
+ return;
2197
+ }
2198
+ }
2157
2199
  }
2158
- if (templateNameError) {
2159
- CapNotification.error({
2160
- message: formatMessage(messages.emptyTemplateErrorMessage),
2161
- });
2162
- return;
2200
+
2201
+ // Only validate platforms that have data or are required
2202
+ // If user confirmed via modal, we allow single platform data
2203
+ if (!skipModalValidation) {
2204
+ // In normal flow, require data for all supported platforms
2205
+ if (isAndroidSupported && (!androidContent?.title?.trim() || !androidContent?.message?.trim())) {
2206
+ CapNotification.error({
2207
+ message: formatMessage(messages.androidValidationError),
2208
+ });
2209
+ if (onValidationFail) onValidationFail();
2210
+ return;
2211
+ }
2212
+ if (isIosSupported && (!iosContent?.title?.trim() || !iosContent?.message?.trim())) {
2213
+ CapNotification.error({
2214
+ message: formatMessage(messages.iosValidationError),
2215
+ });
2216
+ if (onValidationFail) onValidationFail();
2217
+ return;
2218
+ }
2163
2219
  }
2164
- // Only require templateName in full mode
2165
- // Use ref to get the latest template name value for validation
2220
+ // Note: In modal-confirmed flow (skipModalValidation = true), we skip platform validation
2221
+ // since user has already confirmed they want to save with incomplete platform data
2222
+
2223
+ // Template name validation - only required in full mode
2166
2224
  const currentTemplateName = templateNameRef.current || templateName;
2167
2225
  if (isFullMode && !currentTemplateName?.trim()) {
2168
2226
  CapNotification.error({
@@ -2327,6 +2385,8 @@ const MobilePushNew = ({
2327
2385
  }
2328
2386
 
2329
2387
  // Create payload with enabled platform content and intl
2388
+ // In modal-confirmed flow, we may have intentionally missing platform data
2389
+ const allowSinglePlatform = skipModalValidation;
2330
2390
  const payload = createMobilePushPayloadWithIntl({
2331
2391
  templateName: finalTemplateName,
2332
2392
  androidContent: isAndroidSupported ? processedAndroidContent : undefined,
@@ -2337,6 +2397,7 @@ const MobilePushNew = ({
2337
2397
  sameContent,
2338
2398
  options: {
2339
2399
  mode: params?.mode,
2400
+ allowSinglePlatform,
2340
2401
  },
2341
2402
  intl,
2342
2403
  });
@@ -2516,6 +2577,68 @@ const MobilePushNew = ({
2516
2577
  isCarouselDataValid,
2517
2578
  isFullMode,
2518
2579
  templateId,
2580
+ activeTab,
2581
+ setModalContentHandler,
2582
+ ]);
2583
+
2584
+ // Public save function (with modal validation)
2585
+ const handleSave = useCallback(() => {
2586
+ handleSaveInternal(false);
2587
+ }, [handleSaveInternal]);
2588
+
2589
+ // Save function that skips single platform modal validation
2590
+ const handleSaveWithoutModal = useCallback(() => {
2591
+ handleSaveInternal(true);
2592
+ }, [handleSaveInternal]);
2593
+
2594
+ const handleConfirmModal = useCallback(() => {
2595
+ setShowModal(false);
2596
+ setModalContent({});
2597
+ // Proceed with save after modal confirmation - skip single platform validation
2598
+ handleSaveWithoutModal();
2599
+ }, [handleSaveWithoutModal]);
2600
+
2601
+ const liquidMiddleWare = useCallback(() => {
2602
+ const onError = ({ standardErrors, liquidErrors }) => {
2603
+ setErrorMessage((prev) => ({
2604
+ STANDARD_ERROR_MSG: { ...prev.STANDARD_ERROR_MSG, ...standardErrors },
2605
+ LIQUID_ERROR_MSG: { ...prev.LIQUID_ERROR_MSG, ...liquidErrors },
2606
+ }));
2607
+ };
2608
+ const onSuccess = () => handleSave();
2609
+
2610
+ validateMobilePushContent([androidContent, iosContent], {
2611
+ currentTab: activeTab === ANDROID ? 1 : 2,
2612
+ onError,
2613
+ onSuccess,
2614
+ getLiquidTags: globalActionsProps.getLiquidTags,
2615
+ formatMessage,
2616
+ messages: formBuilderMessages,
2617
+ tagLookupMap: metaEntities?.tagLookupMap || {},
2618
+ eventContextTags: metaEntities?.eventContextTags || [],
2619
+ isLiquidFlow: hasLiquidSupportFeature(),
2620
+ forwardedTags: {},
2621
+ skipTags: (tag) => {
2622
+ const skipRegexes = [
2623
+ /dynamic_expiry_date_after_\d+_days\.FORMAT_\d/,
2624
+ /unsubscribe\(#[a-zA-Z\d]{6}\)/,
2625
+ /Link_to_[a-zA-z]/,
2626
+ /SURVEY.*\.TOKEN/,
2627
+ /^[A-Za-z].*\([a-zA-Z\d]*\)/,
2628
+ ];
2629
+ return skipRegexes.some((regex) => regex.test(tag));
2630
+ },
2631
+ singleTab: getSingleTab(accountData),
2632
+ });
2633
+ }, [
2634
+ androidContent,
2635
+ iosContent,
2636
+ activeTab,
2637
+ globalActionsProps,
2638
+ formatMessage,
2639
+ metaEntities,
2640
+ accountData,
2641
+ handleSave,
2519
2642
  ]);
2520
2643
 
2521
2644
  // Helper to sync content between platforms
@@ -2547,7 +2670,9 @@ const MobilePushNew = ({
2547
2670
  setTemplateName(value);
2548
2671
  // Update ref to always have the latest value
2549
2672
  templateNameRef.current = value;
2550
- const isInvalid = !value || value.trim() === "";
2673
+ // Only set error if user has interacted and field is empty
2674
+ // In full mode, template name is required only at save time, not during typing
2675
+ const isInvalid = isFullMode && value.trim() === "";
2551
2676
  setTemplateNameError(isInvalid);
2552
2677
  if (value && onEnterTemplateName) {
2553
2678
  onEnterTemplateName();
@@ -2555,7 +2680,7 @@ const MobilePushNew = ({
2555
2680
  onRemoveTemplateName();
2556
2681
  }
2557
2682
  },
2558
- [onEnterTemplateName, onRemoveTemplateName]
2683
+ [onEnterTemplateName, onRemoveTemplateName, isFullMode]
2559
2684
  );
2560
2685
 
2561
2686
  // --- Only show template name input in full mode (not library/consumer mode) ---
@@ -2584,47 +2709,6 @@ const MobilePushNew = ({
2584
2709
  [isFullMode, formatMessage, templateName, onTemplateNameChange, templateNameError, params?.id]
2585
2710
  );
2586
2711
 
2587
- const liquidMiddleWare = useCallback(() => {
2588
- const onError = ({ standardErrors, liquidErrors }) => {
2589
- setErrorMessage((prev) => ({
2590
- STANDARD_ERROR_MSG: { ...prev.STANDARD_ERROR_MSG, ...standardErrors },
2591
- LIQUID_ERROR_MSG: { ...prev.LIQUID_ERROR_MSG, ...liquidErrors },
2592
- }));
2593
- };
2594
- const onSuccess = () => handleSave();
2595
-
2596
- validateMobilePushContent([androidContent, iosContent], {
2597
- currentTab: activeTab === ANDROID ? 1 : 2,
2598
- onError,
2599
- onSuccess,
2600
- getLiquidTags: globalActionsProps.getLiquidTags,
2601
- formatMessage,
2602
- messages: formBuilderMessages,
2603
- tagLookupMap: metaEntities?.tagLookupMap || {},
2604
- eventContextTags: metaEntities?.eventContextTags || [],
2605
- isLiquidFlow: hasLiquidSupportFeature(),
2606
- forwardedTags: {},
2607
- skipTags: (tag) => {
2608
- const skipRegexes = [
2609
- /dynamic_expiry_date_after_\d+_days\.FORMAT_\d/,
2610
- /unsubscribe\(#[a-zA-Z\d]{6}\)/,
2611
- /Link_to_[a-zA-z]/,
2612
- /SURVEY.*\.TOKEN/,
2613
- /^[A-Za-z].*\([a-zA-Z\d]*\)/,
2614
- ];
2615
- return skipRegexes.some((regex) => regex.test(tag));
2616
- },
2617
- singleTab: getSingleTab(accountData),
2618
- });
2619
- }, [
2620
- androidContent,
2621
- iosContent,
2622
- activeTab,
2623
- globalActionsProps,
2624
- formatMessage,
2625
- metaEntities,
2626
- accountData,
2627
- ]);
2628
2712
 
2629
2713
  const isLiquidFlow = hasLiquidSupportFeature();
2630
2714
 
@@ -2945,6 +3029,24 @@ const MobilePushNew = ({
2945
3029
  />
2946
3030
  </CapColumn>
2947
3031
  </CapRow>
3032
+
3033
+ {/* Modal for single platform confirmation */}
3034
+ <CapModal
3035
+ visible={showModal}
3036
+ title={modalContent.title}
3037
+ onOk={handleConfirmModal}
3038
+ onCancel={handleCancelModal}
3039
+ footer={[
3040
+ <CapButton key="cancel" onClick={handleCancelModal}>
3041
+ No
3042
+ </CapButton>,
3043
+ <CapButton key="confirm" type="primary" onClick={handleConfirmModal}>
3044
+ Yes
3045
+ </CapButton>,
3046
+ ]}
3047
+ >
3048
+ {modalContent.body}
3049
+ </CapModal>
2948
3050
  </CapSpin>
2949
3051
  );
2950
3052
  };
@@ -252,6 +252,10 @@ export default defineMessages({
252
252
  id: `${scope}.contentValidationError`,
253
253
  defaultMessage: '{platform} content must have title and message',
254
254
  },
255
+ singlePlatformContentMissing: {
256
+ id: `${scope}.singlePlatformContentMissing`,
257
+ defaultMessage: 'At least one platform must have title and message',
258
+ },
255
259
  // File validation error messages for useUpload.js
256
260
  fileSizeError: {
257
261
  id: `${scope}.fileSizeError`,
@@ -269,4 +273,17 @@ export default defineMessages({
269
273
  id: `${scope}.gifFileTypeError`,
270
274
  defaultMessage: 'Only GIF files are allowed',
271
275
  },
276
+ // Modal confirmation messages for single platform data
277
+ alertMessage: {
278
+ id: `${scope}.alertMessage`,
279
+ defaultMessage: 'Alert',
280
+ },
281
+ androidTemplateNotConfigured: {
282
+ id: `${scope}.androidTemplateNotConfigured`,
283
+ defaultMessage: 'Android template is not configured. Continue save?',
284
+ },
285
+ iosTemplateNotConfigured: {
286
+ id: `${scope}.iosTemplateNotConfigured`,
287
+ defaultMessage: 'IOS template is not configured, Save without IOS template',
288
+ },
272
289
  });
@@ -1357,19 +1357,16 @@ export class Templates extends React.Component { // eslint-disable-line react/pr
1357
1357
  break;
1358
1358
  }
1359
1359
  case MOBILE_PUSH:
1360
- if (!commonUtil.hasNewMobilePushFeatureEnabled()) {
1361
- templateData.content = template;
1362
- } else {
1363
- const mpushData = get(template, 'versions.base', template);
1364
- const androidData = get(mpushData, 'ANDROID') || get(mpushData, 'androidContent');
1365
- const iosData = get(mpushData, 'IOS') || get(mpushData, 'iosContent');
1366
- let mpushListingData = androidData;
1367
- if (isEmpty(androidData) || !androidData?.title) {
1368
- mpushListingData = iosData;
1369
- };
1370
- const { title, message, expandableDetails: { style = '', image, carouselData = [], ctas = [], media = [] } = {} } = mpushListingData || {};
1371
- const {url = ''} = media?.[0] || {};
1372
- templateData.content = (
1360
+ const mpushData = get(template, 'versions.base', template);
1361
+ const androidData = get(mpushData, 'ANDROID') || get(mpushData, 'androidContent');
1362
+ const iosData = get(mpushData, 'IOS') || get(mpushData, 'iosContent');
1363
+ let mpushListingData = androidData;
1364
+ if (isEmpty(androidData) || !androidData?.title) {
1365
+ mpushListingData = iosData;
1366
+ };
1367
+ const { title, message, expandableDetails: { style = '', image, carouselData = [], ctas = [], media = [] } = {} } = mpushListingData || {};
1368
+ const {url = ''} = media?.[0] || {};
1369
+ templateData.content = (
1373
1370
  <div className='mobilepush-container'>
1374
1371
  <div className="app-header">
1375
1372
  <div className="app-header-left">
@@ -1441,8 +1438,7 @@ export class Templates extends React.Component { // eslint-disable-line react/pr
1441
1438
  )}
1442
1439
  </div>
1443
1440
  );
1444
- templateData.isNewMobilePush = commonUtil.hasNewMobilePushFeatureEnabled();
1445
- }
1441
+ templateData.isNewMobilePush = true;
1446
1442
  break;
1447
1443
  case INAPP:
1448
1444
  templateData.content = template;