@capillarytech/creatives-library 8.0.213 → 8.0.214-beta.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.
Files changed (37) hide show
  1. package/HOW_BEE_EDITOR_WORKS.md +375 -0
  2. package/constants/unified.js +1 -0
  3. package/package.json +1 -1
  4. package/services/api.js +5 -0
  5. package/utils/common.js +6 -1
  6. package/v2Components/CapTagList/index.js +2 -1
  7. package/v2Components/CapTagListWithInput/index.js +5 -1
  8. package/v2Components/CapTagListWithInput/messages.js +1 -1
  9. package/v2Components/ErrorInfoNote/style.scss +1 -1
  10. package/v2Components/HtmlEditor/HTMLEditor.js +86 -14
  11. package/v2Components/HtmlEditor/_htmlEditor.scss +4 -4
  12. package/v2Components/HtmlEditor/_index.lazy.scss +1 -1
  13. package/v2Components/HtmlEditor/components/CodeEditorPane/_codeEditorPane.scss +107 -96
  14. package/v2Components/HtmlEditor/components/CodeEditorPane/index.js +68 -92
  15. package/v2Components/HtmlEditor/components/SplitContainer/_splitContainer.scss +1 -1
  16. package/v2Components/HtmlEditor/hooks/useEditorContent.js +5 -2
  17. package/v2Containers/CreativesContainer/SlideBoxContent.js +85 -35
  18. package/v2Containers/CreativesContainer/SlideBoxFooter.js +9 -3
  19. package/v2Containers/CreativesContainer/index.js +107 -35
  20. package/v2Containers/CreativesContainer/messages.js +4 -0
  21. package/v2Containers/Email/actions.js +7 -0
  22. package/v2Containers/Email/constants.js +5 -1
  23. package/v2Containers/Email/index.js +13 -0
  24. package/v2Containers/Email/messages.js +32 -0
  25. package/v2Containers/Email/reducer.js +12 -1
  26. package/v2Containers/Email/sagas.js +17 -0
  27. package/v2Containers/EmailWrapper/components/EmailHTMLEditor.js +1005 -0
  28. package/v2Containers/EmailWrapper/components/EmailWrapperView.js +193 -7
  29. package/v2Containers/EmailWrapper/constants.js +2 -0
  30. package/v2Containers/EmailWrapper/hooks/useEmailWrapper.js +470 -71
  31. package/v2Containers/EmailWrapper/index.js +102 -23
  32. package/v2Containers/EmailWrapper/messages.js +61 -1
  33. package/v2Containers/EmailWrapper/tests/EmailHTMLEditor.test.js +177 -0
  34. package/v2Containers/EmailWrapper/tests/EmailHTMLEditorValidation.test.js +90 -0
  35. package/v2Containers/EmailWrapper/tests/useEmailWrapper.test.js +49 -49
  36. package/v2Containers/TagList/index.js +2 -0
  37. package/v2Containers/Templates/index.js +5 -0
@@ -0,0 +1,1005 @@
1
+ import React, {
2
+ useState, useEffect, useCallback, useRef, useImperativeHandle, forwardRef,
3
+ } from 'react';
4
+ import PropTypes from 'prop-types';
5
+ import { injectIntl, FormattedMessage } from 'react-intl';
6
+ import isEmpty from 'lodash/isEmpty';
7
+ import get from 'lodash/get';
8
+ import _ from 'lodash';
9
+ import { CAP_SPACE_16 } from '@capillarytech/cap-ui-library/styled/variables';
10
+ import CapSpin from '@capillarytech/cap-ui-library/CapSpin';
11
+ import CapRow from '@capillarytech/cap-ui-library/CapRow';
12
+ import CapColumn from '@capillarytech/cap-ui-library/CapColumn';
13
+ import CapNotification from '@capillarytech/cap-ui-library/CapNotification';
14
+ import HTMLEditor from '../../../v2Components/HtmlEditor';
15
+ import CapTagListWithInput from '../../../v2Components/CapTagListWithInput';
16
+ import formBuilderMessages from '../../../v2Components/FormBuilder/messages';
17
+ import { validateLiquidTemplateContent } from '../../../utils/commonUtils';
18
+ import { hasLiquidSupportFeature } from '../../../utils/common';
19
+ import history from '../../../utils/history';
20
+ import messages from '../messages';
21
+ import emailMessages from '../../Email/messages';
22
+ import { validateTags } from '../../../utils/tagValidations';
23
+ import {
24
+ TAG, EMBEDDED, DEFAULT, FULL, LIBRARY,
25
+ } from '../../Whatsapp/constants';
26
+ import { EMAIL } from '../../CreativesContainer/constants';
27
+
28
+ /**
29
+ * EmailHTMLEditor Component
30
+ *
31
+ * IMPORTANT: This component is ONLY used when supportCKEditor flag is FALSE (new flow).
32
+ * When supportCKEditor is TRUE, the existing Email component with FormBuilder is used (legacy flow).
33
+ *
34
+ * A completely self-contained component for Email HTML Editor that handles:
35
+ * - Tag loading and management
36
+ * - Tag validation
37
+ * - Content editing with HTMLEditor
38
+ * - Template data extraction for edit mode
39
+ * - Save logic (full mode & library mode) with liquid validation
40
+ * - API calls via Email actions/sagas
41
+ *
42
+ * This component is independent and reusable, similar to Whatsapp and InApp channels.
43
+ */
44
+ const EmailHTMLEditor = forwardRef((props, ref) => {
45
+ const {
46
+ intl,
47
+ location,
48
+ params,
49
+ getDefaultTags,
50
+ supportedTags,
51
+ metaEntities,
52
+ injectedTags,
53
+ globalActions,
54
+ loadingTags,
55
+ eventContextTags,
56
+ forwardedTags,
57
+ selectedOfferDetails,
58
+ currentOrgDetails,
59
+ isReadOnly = false,
60
+ fetchingLiquidTags = false,
61
+ createTemplateInProgress = false,
62
+ fetchingCmsData = false,
63
+ // Email Redux state
64
+ Email,
65
+ // Email actions for API calls
66
+ emailActions,
67
+ // Full mode props
68
+ isFullMode,
69
+ templateName,
70
+ showTemplateName,
71
+ isGetFormData,
72
+ getFormdata,
73
+ // Library mode props
74
+ templateData: templateDataProp,
75
+ // Uploaded content from zip file
76
+ EmailLayout,
77
+ // Liquid validation
78
+ getLiquidTags,
79
+ showLiquidErrorInFooter,
80
+ onValidationFail,
81
+ // Preview/Test
82
+ // Parent loading control
83
+ setIsLoadingContent,
84
+ } = props;
85
+
86
+ const { formatMessage } = intl;
87
+
88
+ // State for content and subject
89
+ const [htmlContent, setHtmlContent] = useState('');
90
+ const [loadedHtmlContent, setLoadedHtmlContent] = useState(''); // Stable content for HTMLEditor initialization
91
+ const [subject, setSubject] = useState('');
92
+ const [subjectError, setSubjectError] = useState('');
93
+ // State for template name (extracted from template data in Edit mode)
94
+ const [extractedTemplateName, setExtractedTemplateName] = useState('');
95
+
96
+ // State for tags and validation
97
+ const [tags, setTags] = useState([]);
98
+ const [tagValidationError, setTagValidationError] = useState(null);
99
+ const [isLoading, setIsLoading] = useState(true);
100
+
101
+ // Refs for tracking initialization and previous values
102
+ const contentInitializedRef = useRef(false);
103
+ const subjectInitializedRef = useRef(false);
104
+ const lastTemplateIdRef = useRef(null);
105
+ const fetchingTemplateIdRef = useRef(null); // Track which template we're currently fetching
106
+ const prevIsGetFormDataRef = useRef(false);
107
+
108
+ // Expose method to get formData for TestAndPreviewSlidebox
109
+ useImperativeHandle(ref, () => ({
110
+ getFormDataForPreview: () => {
111
+ const baseLanguage = get(currentOrgDetails, 'basic_details.base_language', 'en');
112
+ return {
113
+ "0": {
114
+ [baseLanguage]: {
115
+ 'template-content': htmlContent || '',
116
+ },
117
+ activeTab: baseLanguage,
118
+ selectedLanguages: [baseLanguage],
119
+ base: true,
120
+ },
121
+ 'template-subject': subject || '',
122
+ };
123
+ },
124
+ getContentForPreview: () => htmlContent || '',
125
+ }));
126
+
127
+ // Check if liquid support is enabled
128
+ const isLiquidEnabled = hasLiquidSupportFeature();
129
+
130
+ // Detect edit mode
131
+ const hasParamsId = params?.id || location?.query?.id || location?.params?.id || location?.pathname?.includes('/edit/');
132
+ const currentTemplateId = params?.id || location?.query?.id || location?.params?.id
133
+ || location?.pathname?.match(/\/edit\/([^/]+)/)?.[1];
134
+ const isEditMode = !!currentTemplateId || !!hasParamsId;
135
+
136
+ // Load tags on component mount
137
+ useEffect(() => {
138
+ const { type, module } = location?.query || {};
139
+ const isEmbedded = type === EMBEDDED;
140
+ const query = {
141
+ layout: EMAIL,
142
+ type: TAG,
143
+ context: isEmbedded ? module : DEFAULT,
144
+ embedded: isEmbedded ? type : FULL,
145
+ };
146
+ if (getDefaultTags) {
147
+ query.context = getDefaultTags;
148
+ }
149
+ if (globalActions && globalActions.fetchSchemaForEntity) {
150
+ globalActions.fetchSchemaForEntity(query);
151
+ }
152
+ }, []);
153
+
154
+ // Update tags when metaEntities change
155
+ useEffect(() => {
156
+ let tagList = get(metaEntities, 'tags.standard', []);
157
+ const { type, module } = location?.query || {};
158
+ if (type === EMBEDDED && module === LIBRARY && !getDefaultTags) {
159
+ tagList = supportedTags || [];
160
+ }
161
+ setTags(tagList);
162
+ }, [metaEntities, supportedTags, location, getDefaultTags]);
163
+
164
+ // Initialize content from EmailLayout (uploaded zip) in create mode
165
+ useEffect(() => {
166
+ // Only check EmailLayout in create mode (not edit mode)
167
+ if (isEditMode) {
168
+ return;
169
+ }
170
+
171
+ // Check if EmailLayout has content from zip upload
172
+ if (EmailLayout && !contentInitializedRef.current) {
173
+ // EmailLayout can be a string (HTML content) or an object with html property
174
+ const uploadedContent = typeof EmailLayout === 'string'
175
+ ? EmailLayout
176
+ : (EmailLayout.html || EmailLayout.content || '');
177
+
178
+ if (uploadedContent) {
179
+ setHtmlContent(uploadedContent);
180
+ contentInitializedRef.current = true;
181
+ setIsLoading(false);
182
+ if (setIsLoadingContent) {
183
+ setIsLoadingContent(false);
184
+ }
185
+ } else {
186
+ // No uploaded content, stop loading
187
+ setIsLoading(false);
188
+ if (setIsLoadingContent) {
189
+ setIsLoadingContent(false);
190
+ }
191
+ }
192
+ } else if (!EmailLayout && !contentInitializedRef.current) {
193
+ // No EmailLayout, stop loading
194
+ setIsLoading(false);
195
+ if (setIsLoadingContent) {
196
+ setIsLoadingContent(false);
197
+ }
198
+ }
199
+ }, [EmailLayout, isEditMode, setIsLoadingContent]);
200
+
201
+ // Edit mode: Extract template data and load content/subject
202
+ useEffect(() => {
203
+ if (!isEditMode) {
204
+ // Create mode: stop loading immediately
205
+ setIsLoading(false);
206
+ return;
207
+ }
208
+
209
+ const templateDataFromRedux = Email?.templateDetails || Email?.BEETemplate;
210
+ const isTemplateLoading = Email?.getTemplateDetailsInProgress || Email?.fetchingCmsData;
211
+
212
+ // Check if template ID changed (switching templates)
213
+ const templateIdChanged = currentTemplateId
214
+ && lastTemplateIdRef.current
215
+ && currentTemplateId !== lastTemplateIdRef.current;
216
+
217
+ // Reset refs when switching templates
218
+ if (templateIdChanged) {
219
+ contentInitializedRef.current = false;
220
+ subjectInitializedRef.current = false;
221
+ lastTemplateIdRef.current = currentTemplateId;
222
+ setHtmlContent('');
223
+ setSubject('');
224
+ setExtractedTemplateName('');
225
+ }
226
+
227
+ // Set last template ID on first load
228
+ if (currentTemplateId && !lastTemplateIdRef.current) {
229
+ lastTemplateIdRef.current = currentTemplateId;
230
+ }
231
+
232
+ // Check if templateDataProp has complete data
233
+ const hasCompleteTemplateData = templateDataProp && (
234
+ templateDataProp.emailBody
235
+ || templateDataProp.html_content
236
+ || templateDataProp.base?.html_content
237
+ || templateDataProp.base?.emailBody
238
+ || templateDataProp.versions?.base
239
+ || (templateDataProp.base && (templateDataProp.base.subject || templateDataProp.base.emailSubject))
240
+ );
241
+
242
+ // Extract from templateDataProp if available
243
+ if (hasCompleteTemplateData && !contentInitializedRef.current) {
244
+ let extractedContent = '';
245
+ let extractedSubject = '';
246
+ let extractedName = '';
247
+
248
+ if (templateDataProp.base) {
249
+ extractedContent = templateDataProp.base.html_content || templateDataProp.base.emailBody || '';
250
+ extractedSubject = templateDataProp.base.subject || templateDataProp.base.emailSubject || '';
251
+ } else {
252
+ extractedContent = templateDataProp.emailBody || templateDataProp.html_content || '';
253
+ extractedSubject = templateDataProp.emailSubject || templateDataProp.subject || '';
254
+ }
255
+
256
+ // Extract template name from templateDataProp
257
+ extractedName = templateDataProp.name || get(templateDataProp, 'versions.base.name') || '';
258
+
259
+ setHtmlContent(extractedContent);
260
+ setSubject(extractedSubject);
261
+ setExtractedTemplateName(extractedName);
262
+ contentInitializedRef.current = true;
263
+ subjectInitializedRef.current = true;
264
+ setIsLoading(false);
265
+ if (setIsLoadingContent) {
266
+ setIsLoadingContent(false);
267
+ }
268
+ return;
269
+ }
270
+
271
+ if (currentTemplateId && emailActions?.getTemplateDetails && !isTemplateLoading) {
272
+ const templateIdChanged = lastTemplateIdRef.current !== currentTemplateId;
273
+ const needsContent = !contentInitializedRef.current;
274
+ const alreadyFetching = fetchingTemplateIdRef.current === currentTemplateId;
275
+ const shouldFetch = (templateIdChanged || needsContent) && !alreadyFetching;
276
+
277
+ // Fetch fresh data when template ID changes OR when we don't have content loaded yet
278
+ // BUT only if we're not already fetching this template
279
+ if (shouldFetch) {
280
+ fetchingTemplateIdRef.current = currentTemplateId; // Mark as fetching
281
+ emailActions.getTemplateDetails(currentTemplateId, 'email');
282
+ lastTemplateIdRef.current = currentTemplateId;
283
+ }
284
+ }
285
+
286
+ // **IMPORTANT: Clear stale Redux data in create mode**
287
+ // When not in edit mode (create mode), clear any existing template data from Redux
288
+ // This prevents previous template data from persisting when switching from Edit to Create
289
+ if (!currentTemplateId && !isEditMode && (templateDataFromRedux?._id || templateDataFromRedux?.name)) {
290
+ // Clear stale template data - component will handle via resetTemplateData or clearAllValues
291
+ if (emailActions?.clearAllValues) {
292
+ emailActions.clearAllValues();
293
+ }
294
+ // Reset component state to ensure clean slate for create mode
295
+ setExtractedTemplateName('');
296
+ contentInitializedRef.current = false;
297
+ subjectInitializedRef.current = false;
298
+ lastTemplateIdRef.current = null;
299
+ }
300
+
301
+ // Stop loading if template is loading
302
+ if (isTemplateLoading) {
303
+ return;
304
+ }
305
+
306
+ // Extract from Redux data
307
+ const hasTemplateDataFromRedux = templateDataFromRedux
308
+ && (templateDataFromRedux._id || templateDataFromRedux.name || templateDataFromRedux.versions);
309
+
310
+ if (hasTemplateDataFromRedux && currentTemplateId) {
311
+ const reduxTemplateId = templateDataFromRedux?._id;
312
+
313
+ if (reduxTemplateId === currentTemplateId) {
314
+ const baseData = get(templateDataFromRedux, 'versions.base') || get(templateDataFromRedux, 'base') || {};
315
+ const activeTab = baseData.activeTab
316
+ || get(currentOrgDetails, 'basic_details.base_language', 'en');
317
+ const languageData = baseData[activeTab] || {};
318
+
319
+ const extractedContent = languageData['template-content']
320
+ || languageData.html_content
321
+ || baseData.html_content
322
+ || baseData['template-content']
323
+ || get(templateDataFromRedux, 'html_content')
324
+ || get(templateDataFromRedux, 'template-content')
325
+ || '';
326
+
327
+ const extractedSubject = baseData.subject
328
+ || get(templateDataFromRedux, 'subject')
329
+ || get(templateDataFromRedux, 'versions.base.subject')
330
+ || '';
331
+
332
+ // Extract template name from Redux data
333
+ const extractedName = templateDataFromRedux.name || '';
334
+
335
+ // Smart update logic:
336
+ // 1. Always update loadedHtmlContent if Redux data is different (keep sync with backend)
337
+ // 2. Update htmlContent ONLY if it matches the OLD loadedHtmlContent (user hasn't edited)
338
+ // OR if it's the first initialization
339
+ if (extractedContent !== loadedHtmlContent) {
340
+ setLoadedHtmlContent(extractedContent);
341
+
342
+ if (!contentInitializedRef.current || htmlContent === loadedHtmlContent) {
343
+ setHtmlContent(extractedContent);
344
+ setSubject(extractedSubject);
345
+ setExtractedTemplateName(extractedName);
346
+ }
347
+ } else if (!contentInitializedRef.current) {
348
+ // First load, even if content matches (e.g. empty), ensure we set state
349
+ setHtmlContent(extractedContent);
350
+ setLoadedHtmlContent(extractedContent);
351
+ setSubject(extractedSubject);
352
+ setExtractedTemplateName(extractedName);
353
+ }
354
+
355
+ contentInitializedRef.current = true;
356
+ subjectInitializedRef.current = true;
357
+
358
+ // Only clear fetching ref if we are not loading anymore
359
+ // This prevents race conditions where we load stale data while real fetch is pending
360
+ if (!isTemplateLoading) {
361
+ fetchingTemplateIdRef.current = null;
362
+ }
363
+ }
364
+
365
+ setIsLoading(false);
366
+ if (setIsLoadingContent) {
367
+ setIsLoadingContent(false);
368
+ }
369
+ return;
370
+ }
371
+
372
+ // Fallback: stop loading anyway
373
+ if (!isTemplateLoading && !hasTemplateDataFromRedux && !hasCompleteTemplateData) {
374
+ setHtmlContent('');
375
+ setLoadedHtmlContent(''); // Set stable loaded content
376
+ setSubject('');
377
+ setExtractedTemplateName('');
378
+ setIsLoading(false);
379
+ if (setIsLoadingContent) {
380
+ setIsLoadingContent(false);
381
+ }
382
+ }
383
+ }, [
384
+ Email?.templateDetails,
385
+ Email?.BEETemplate,
386
+ Email?.getTemplateDetailsInProgress,
387
+ Email?.fetchingCmsData,
388
+ templateDataProp,
389
+ currentTemplateId,
390
+ isEditMode,
391
+ emailActions,
392
+ currentOrgDetails,
393
+ ]);
394
+
395
+ // Handle loading state
396
+ useEffect(() => {
397
+ const isAnyApiInProgress = loadingTags || fetchingLiquidTags || createTemplateInProgress || fetchingCmsData;
398
+
399
+ // Stop loading when no API is in progress and tags are loaded
400
+ if (!isAnyApiInProgress && tags.length >= 0) {
401
+ setIsLoading(false);
402
+ } else if (isAnyApiInProgress) {
403
+ setIsLoading(true);
404
+ }
405
+ }, [loadingTags, tags, fetchingLiquidTags, createTemplateInProgress, fetchingCmsData]);
406
+
407
+ // Handle content change from HTMLEditor
408
+ const handleContentChange = useCallback((content) => {
409
+ setHtmlContent(content);
410
+
411
+ // Validate tags
412
+ if (tags.length > 0 || !isEmpty(injectedTags)) {
413
+ const validationResult = validateTags({
414
+ content,
415
+ tagsParam: tags,
416
+ injectedTagsParams: injectedTags,
417
+ location,
418
+ tagModule: getDefaultTags,
419
+ eventContextTags,
420
+ });
421
+
422
+ if (!validationResult.valid) {
423
+ setTagValidationError(validationResult);
424
+ } else {
425
+ setTagValidationError(null);
426
+ }
427
+ }
428
+ }, [tags, injectedTags, location, getDefaultTags, eventContextTags]);
429
+
430
+ // Handle tag insertion into Subject field
431
+ const handleSubjectTagSelect = useCallback((data) => {
432
+ if (data) {
433
+ const tagToInsert = `{{${data}}}`;
434
+ const input = document.getElementById('template-subject') || document.querySelector('#template-subject input');
435
+ let subjectValue = subject || '';
436
+ try {
437
+ if (input && (typeof input.selectionStart === 'number')) {
438
+ const startPos = input.selectionStart;
439
+ const endPos = input.selectionEnd;
440
+ subjectValue = `${subjectValue.substring(0, startPos)}${tagToInsert}${subjectValue.substring(endPos)}`;
441
+ setSubject(subjectValue);
442
+ try {
443
+ input.focus();
444
+ const newPos = startPos + tagToInsert.length;
445
+ input.selectionStart = newPos;
446
+ input.selectionEnd = newPos;
447
+ } catch (e) {
448
+ // Ignore focus errors
449
+ }
450
+ } else {
451
+ subjectValue = `${subjectValue}${tagToInsert}`;
452
+ setSubject(subjectValue);
453
+ if (input) {
454
+ try {
455
+ input.value = subjectValue;
456
+ } catch (e) {
457
+ // Ignore value setting errors
458
+ }
459
+ }
460
+ }
461
+ } catch (e) {
462
+ // Fallback: safe append
463
+ subjectValue = `${subjectValue}${tagToInsert}`;
464
+ setSubject(subjectValue);
465
+ }
466
+ }
467
+ }, [subject]);
468
+
469
+ // Handle subject change
470
+ const handleSubjectChange = useCallback((e) => {
471
+ const newSubject = e.target.value;
472
+ setSubject(newSubject);
473
+ if (newSubject && subjectError) {
474
+ setSubjectError('');
475
+ }
476
+ }, [subjectError]);
477
+
478
+ // Handle Save/Update with liquid validation
479
+ const handleSave = useCallback(() => {
480
+ // 1. Validate Subject - BLOCKING (matches CK/BEE behavior)
481
+ if (!subject || !subject.trim()) {
482
+ const errorMessage = formatMessage(emailMessages["Email Subject cannot be empty."]);
483
+ setSubjectError(errorMessage);
484
+ // Reset parent state so next click is detected as a change
485
+ if (onValidationFail) {
486
+ onValidationFail();
487
+ }
488
+ // IMPORTANT: Return here to block save - matches CK/BEE editor behavior
489
+ return;
490
+ } else {
491
+ // Clear error if subject is valid
492
+ if (subjectError) {
493
+ setSubjectError('');
494
+ }
495
+ }
496
+
497
+ // 2. Validate Content Tags
498
+ // For NON-liquid orgs: BLOCKING validation (matches CK/BEE behavior)
499
+ // For liquid orgs: Non-blocking (extractTags API will validate)
500
+ if (tags.length > 0 || !isEmpty(injectedTags)) {
501
+ const validationResult = validateTags({
502
+ content: htmlContent,
503
+ tagsParam: tags,
504
+ injectedTagsParams: injectedTags,
505
+ location,
506
+ tagModule: getDefaultTags,
507
+ eventContextTags,
508
+ });
509
+
510
+ const hasUnsupportedTags = validationResult?.unsupportedTags?.length > 0;
511
+ if (!validationResult?.valid || hasUnsupportedTags) {
512
+ setTagValidationError(validationResult);
513
+
514
+ // IMPORTANT: For non-liquid orgs, block save (like CK/BEE editor)
515
+ // For liquid orgs, continue (extractTags API will validate)
516
+ if (!isLiquidEnabled) {
517
+ // Show notification popup like CK/BEE editor
518
+ const baseLanguage = get(currentOrgDetails, 'basic_details.base_language', 'en');
519
+
520
+ const contentNotValidMsg = intl.formatMessage(formBuilderMessages.contentNotValidLanguage);
521
+ let errorMessage = `${contentNotValidMsg} ${baseLanguage}`;
522
+
523
+ if (hasUnsupportedTags) {
524
+ const unsupportedTagsMsg = intl.formatMessage(formBuilderMessages.unsupportedTags);
525
+ errorMessage += `\n${unsupportedTagsMsg} ${validationResult?.unsupportedTags?.join(', ')}`;
526
+ }
527
+ if (validationResult?.missingTags?.length > 0) {
528
+ const missingTagsMsg = intl.formatMessage(formBuilderMessages.missingTags);
529
+ errorMessage += `\n${missingTagsMsg} ${validationResult?.missingTags?.join(', ')}`;
530
+ }
531
+
532
+ const type = 'error';
533
+ CapNotification[type]({
534
+ message: `${type.toUpperCase()} ! ! ! `,
535
+ description: errorMessage,
536
+ duration: 5,
537
+ });
538
+
539
+ // Reset parent state so next click is detected as a change
540
+ if (onValidationFail) {
541
+ onValidationFail();
542
+ }
543
+ // Block save for non-liquid orgs
544
+ return;
545
+ }
546
+ // For liquid orgs, just show warning and continue
547
+ } else {
548
+ // Clear tag errors if valid
549
+ if (tagValidationError) {
550
+ setTagValidationError(null);
551
+ }
552
+ }
553
+ }
554
+
555
+
556
+ const baseLanguage = get(currentOrgDetails, 'basic_details.base_language', 'en');
557
+
558
+ // Actual save function - called after liquid validation (if enabled) or directly
559
+ const performSave = () => {
560
+ if (isFullMode) {
561
+ // Full mode: Call email actions directly
562
+ const templateDataFromRedux = Email?.templateDetails || Email?.BEETemplate;
563
+ // IMPORTANT: Only use currentTemplateId from URL/params - don't fallback to Redux data
564
+ // which might be stale from previous template in create mode
565
+ const templateId = currentTemplateId;
566
+ const isEditModeForSave = !!templateId;
567
+
568
+ const langId = get(currentOrgDetails, `basic_details.languages.${baseLanguage}.lang_id`, '');
569
+ const language = get(currentOrgDetails, `basic_details.languages.${baseLanguage}.language`, baseLanguage);
570
+
571
+ // Generate or reuse tabKey
572
+ let tabKey = _.uniqueId();
573
+ if (isEditMode && templateDataFromRedux) {
574
+ const existingTabKey = get(templateDataFromRedux, 'versions.base.tabKey')
575
+ || get(templateDataFromRedux, 'base.tabKey')
576
+ if (existingTabKey) {
577
+ tabKey = existingTabKey;
578
+ }
579
+ }
580
+
581
+ const languageData = {
582
+ 'template-content': htmlContent || '',
583
+ "is_drag_drop": false,
584
+ "drag_drop_id": '',
585
+ "lang_id": langId,
586
+ "iso_code": baseLanguage,
587
+ "language": language,
588
+ "tabKey": tabKey,
589
+ };
590
+
591
+ const baseStructure = {
592
+ [baseLanguage]: languageData,
593
+ activeTab: baseLanguage,
594
+ selectedLanguages: [baseLanguage],
595
+ base: true,
596
+ tabKey,
597
+ subject: subject || '',
598
+ };
599
+
600
+ const historyEntry = {
601
+ [baseLanguage]: { ...languageData },
602
+ activeTab: baseLanguage,
603
+ selectedLanguages: [baseLanguage],
604
+ base: true,
605
+ tabKey,
606
+ subject: subject || '',
607
+ };
608
+
609
+ const finalTemplateName = isEditModeForSave ? (extractedTemplateName || '') : (templateName || '');
610
+
611
+ const obj = {
612
+ type: EMAIL,
613
+ // In Edit mode, use extractedTemplateName; in Create mode, use templateName prop
614
+ name: finalTemplateName,
615
+ versions: {
616
+ base: baseStructure,
617
+ history: [historyEntry],
618
+ },
619
+ };
620
+
621
+ if (isEditModeForSave && templateId) {
622
+ obj._id = templateId;
623
+ }
624
+
625
+ if (emailActions?.transformEmailTemplate && emailActions?.createTemplate) {
626
+ emailActions.transformEmailTemplate(obj, (newEmail) => {
627
+ emailActions.createTemplate(newEmail, (createResponse) => {
628
+ if (createResponse && createResponse.templateId) {
629
+ const successMessage = formatMessage(emailMessages.emailCreateSuccess);
630
+ CapNotification.success({ message: successMessage, key: 'create-template-success' });
631
+
632
+ const module = location?.query?.module || 'default';
633
+ const type = location?.query?.type;
634
+ const isLanguageSupport = location?.query?.isLanguageSupport || false;
635
+ const isBEESupport = (location?.query?.isBEESupport !== "false") || false;
636
+
637
+ if (getFormdata) {
638
+ const { versions, ...rest } = createResponse.templateId;
639
+ getFormdata({ value: versions, ...rest, validity: true });
640
+ } else {
641
+ const queryParams = type === 'embedded'
642
+ ? {
643
+ type: 'embedded', module, isLanguageSupport, isBEESupport,
644
+ }
645
+ : { module, isLanguageSupport, isBEESupport };
646
+
647
+ const searchParams = new URLSearchParams();
648
+ Object.keys(queryParams).forEach((key) => {
649
+ if (queryParams[key] !== undefined && queryParams[key] !== null) {
650
+ searchParams.append(key, queryParams[key]);
651
+ }
652
+ });
653
+
654
+ history.push({
655
+ pathname: '/email',
656
+ search: searchParams.toString() ? `?${searchParams.toString()}` : '',
657
+ });
658
+ }
659
+ }
660
+ });
661
+ });
662
+ }
663
+ } else {
664
+ // Library mode: Use getFormdata flow
665
+ const tmpData = {
666
+ html_content: htmlContent || '',
667
+ is_drag_drop: false,
668
+ lang_id: get(currentOrgDetails, `basic_details.languages.${baseLanguage}.lang_id`, ''),
669
+ iso_code: baseLanguage,
670
+ language: get(currentOrgDetails, `basic_details.languages.${baseLanguage}.language`, baseLanguage),
671
+ };
672
+
673
+ const libraryPayload = {
674
+ base: tmpData,
675
+ secondary_templates: [{ template_data: tmpData }],
676
+ };
677
+ libraryPayload.base.subject = subject || '';
678
+
679
+ if (location?.query?.module === 'library' && isGetFormData && getFormdata) {
680
+ const response = {
681
+ action: "getFormData",
682
+ postAction: 'next',
683
+ id: get(Email, 'templateDetails._id', ''),
684
+ value: libraryPayload,
685
+ validity: true,
686
+ type: EMAIL,
687
+ };
688
+ getFormdata(response);
689
+ } else if (getFormdata) {
690
+ getFormdata({
691
+ value: libraryPayload,
692
+ validity: true,
693
+ type: EMAIL,
694
+ });
695
+ }
696
+ }
697
+ };
698
+
699
+ // If liquid enabled, validate first using extractTags API
700
+ if (isLiquidEnabled && getLiquidTags) {
701
+ const onError = ({ standardErrors, liquidErrors }) => {
702
+ if (showLiquidErrorInFooter) {
703
+ showLiquidErrorInFooter({
704
+ STANDARD_ERROR_MSG: standardErrors || [],
705
+ LIQUID_ERROR_MSG: liquidErrors || [],
706
+ });
707
+ }
708
+ // Don't reset ref here - liquid validation is async and resetting causes infinite loop
709
+ // The parent's isGetFormData will be reset by onValidationFail, and the next click will be detected
710
+ if (onValidationFail) {
711
+ onValidationFail();
712
+ }
713
+ };
714
+
715
+ const onSuccess = () => {
716
+ performSave();
717
+ };
718
+
719
+ validateLiquidTemplateContent(htmlContent || '', {
720
+ getLiquidTags: getLiquidTags
721
+ ? (inputContent, callback) => getLiquidTags(inputContent, callback)
722
+ : (inputContent, callback) => globalActions?.getLiquidTags?.(inputContent, callback),
723
+ formatMessage: intl.formatMessage,
724
+ messages: formBuilderMessages,
725
+ onError,
726
+ onSuccess,
727
+ tagLookupMap: metaEntities?.tagLookupMap,
728
+ eventContextTags,
729
+ isLiquidFlow: true,
730
+ forwardedTags: forwardedTags || {},
731
+ });
732
+ } else {
733
+ performSave();
734
+ }
735
+ }, [
736
+ subject,
737
+ htmlContent,
738
+ tags,
739
+ injectedTags,
740
+ location,
741
+ getDefaultTags,
742
+ eventContextTags,
743
+ formatMessage,
744
+ subjectError,
745
+ isFullMode,
746
+ currentOrgDetails,
747
+ Email,
748
+ currentTemplateId,
749
+ templateDataProp,
750
+ templateName,
751
+ emailActions,
752
+ getFormdata,
753
+ isGetFormData,
754
+ isLiquidEnabled,
755
+ getLiquidTags,
756
+ showLiquidErrorInFooter,
757
+ metaEntities,
758
+ forwardedTags,
759
+ globalActions,
760
+ intl,
761
+ extractedTemplateName,
762
+ ]);
763
+
764
+ // Trigger save when isGetFormData becomes true (Create/Done button clicked)
765
+ useEffect(() => {
766
+ const isGetFormDataChanged = isGetFormData && !prevIsGetFormDataRef.current;
767
+ const wasReset = !isGetFormData && prevIsGetFormDataRef.current;
768
+
769
+ if (isGetFormDataChanged) {
770
+ handleSave();
771
+ }
772
+
773
+ // Reset ref when parent resets isGetFormData (e.g., after validation failure)
774
+ if (wasReset) {
775
+ prevIsGetFormDataRef.current = false;
776
+ return;
777
+ }
778
+
779
+ prevIsGetFormDataRef.current = isGetFormData;
780
+ }, [isGetFormData, handleSave]);
781
+
782
+ // Handle tag context change
783
+ const handleOnTagsContextChange = useCallback((data) => {
784
+ const { type, module } = location?.query || {};
785
+ const isEmbedded = type === EMBEDDED;
786
+ const query = {
787
+ layout: EMAIL,
788
+ type: TAG,
789
+ context: data || (isEmbedded ? module : DEFAULT),
790
+ embedded: isEmbedded ? type : FULL,
791
+ };
792
+ if (globalActions && globalActions.fetchSchemaForEntity) {
793
+ globalActions.fetchSchemaForEntity(query);
794
+ }
795
+ }, [location, globalActions]);
796
+
797
+ const spinTip = fetchingLiquidTags ? <FormattedMessage {...formBuilderMessages.liquidSpinText} /> : '';
798
+
799
+ // Handle template name change
800
+ // Call showTemplateName when templateName is available (for CreativesContainer header)
801
+ // This matches the behavior of Email component which calls showTemplateName in onFormDataChange
802
+ // In Edit mode, use extractedTemplateName from template data; in Create mode, use templateName prop
803
+ const { onFormDataChange: onFormDataChangeProp } = props;
804
+
805
+ // Create onFormDataChange callback that updates extractedTemplateName in Edit mode
806
+ // This matches the Email component's onFormDataChange which updates its state
807
+ const handleFormDataChange = useCallback((updatedFormData) => {
808
+ const newTemplateName = updatedFormData?.['template-name'] || '';
809
+
810
+ // In Edit mode, update extractedTemplateName state (similar to Email component updating its formData state)
811
+ if (isEditMode && newTemplateName !== extractedTemplateName) {
812
+ setExtractedTemplateName(newTemplateName);
813
+ }
814
+
815
+ // Call the parent's onFormDataChange if provided
816
+ if (onFormDataChangeProp) {
817
+ onFormDataChangeProp(updatedFormData);
818
+ }
819
+
820
+ // Call showTemplateName again with updated formData (same pattern as Email component)
821
+ if (showTemplateName && isFullMode) {
822
+ showTemplateName({ formData: updatedFormData, onFormDataChange: handleFormDataChange });
823
+ }
824
+ }, [isEditMode, extractedTemplateName, onFormDataChangeProp, showTemplateName, isFullMode]);
825
+
826
+ useEffect(() => {
827
+ if (showTemplateName && isFullMode) {
828
+ // In Edit mode, use extractedTemplateName; in Create mode, use templateName prop
829
+ const nameToUse = isEditMode ? extractedTemplateName : templateName;
830
+ const formData = {
831
+ 'template-name': nameToUse || '',
832
+ };
833
+ // Pass handleFormDataChange callback so CreativesContainer can update template name
834
+ // This is the same pattern used in Email component
835
+ showTemplateName({ formData, onFormDataChange: handleFormDataChange });
836
+ }
837
+ }, [showTemplateName, isFullMode, templateName, extractedTemplateName, isEditMode, handleFormDataChange]);
838
+
839
+ return (
840
+ <CapSpin spinning={isLoading} tip={spinTip}>
841
+ <CapRow>
842
+ <CapColumn span={24}>
843
+ {/* Subject Field */}
844
+ <CapColumn span={24} style={{ marginBottom: CAP_SPACE_16 }}>
845
+ <CapTagListWithInput
846
+ inputId="template-subject"
847
+ inputValue={subject}
848
+ inputOnChange={handleSubjectChange}
849
+ inputPlaceholder={formatMessage(messages.enterEmailSubject)}
850
+ inputRequired
851
+ inputErrorMessage={subjectError}
852
+ headingText={formatMessage(messages.subject)}
853
+ headingType="h4"
854
+ headingStyle={{ marginRight: '85%' }}
855
+ onTagSelect={handleSubjectTagSelect}
856
+ onContextChange={handleOnTagsContextChange}
857
+ location={location}
858
+ tags={tags}
859
+ injectedTags={injectedTags || {}}
860
+ selectedOfferDetails={selectedOfferDetails}
861
+ eventContextTags={eventContextTags}
862
+ showHeading
863
+ showTagList
864
+ showInput
865
+ containerStyle={{
866
+ display: 'flex',
867
+ flexDirection: 'column',
868
+ }}
869
+ popoverPlacement="bottomRight"
870
+ />
871
+ </CapColumn>
872
+
873
+ {/* Tag Validation Error Display */}
874
+ {tagValidationError && !tagValidationError.valid && (
875
+ <CapColumn span={24} style={{ marginBottom: CAP_SPACE_16 }}>
876
+ <CapNotification
877
+ type="error"
878
+ message={formatMessage({
879
+ id: 'creatives.containersV2.Email.tagValidationError',
880
+ defaultMessage: 'Tag validation error',
881
+ })}
882
+ description={(
883
+ <CapRow>
884
+ {tagValidationError?.missingTags?.length > 0 && (
885
+ <CapColumn span={24}>
886
+ Missing tags:
887
+ {' '}
888
+ {tagValidationError.missingTags.join(', ')}
889
+ </CapColumn>
890
+ )}
891
+ {tagValidationError?.unsupportedTags?.length > 0 && (
892
+ <CapColumn span={24}>
893
+ Unsupported tags:
894
+ {' '}
895
+ {tagValidationError.unsupportedTags.join(', ')}
896
+ </CapColumn>
897
+ )}
898
+ {tagValidationError?.isBraceError && (
899
+ <CapColumn span={24}>
900
+ Unbalanced brackets detected
901
+ </CapColumn>
902
+ )}
903
+ </CapRow>
904
+ )}
905
+ />
906
+ </CapColumn>
907
+ )}
908
+
909
+ <HTMLEditor
910
+ variant="email"
911
+ initialContent={loadedHtmlContent || ''}
912
+ onContentChange={handleContentChange}
913
+ onSave={handleSave}
914
+ readOnly={isReadOnly}
915
+ showFullscreenButton
916
+ autoSave={false}
917
+ tags={tags}
918
+ injectedTags={injectedTags}
919
+ location={location}
920
+ eventContextTags={eventContextTags}
921
+ selectedOfferDetails={selectedOfferDetails}
922
+ channel={EMAIL}
923
+ userLocale={intl.locale || 'en'}
924
+ moduleFilterEnabled={location?.query?.type !== EMBEDDED}
925
+ onTagContextChange={handleOnTagsContextChange}
926
+ />
927
+ </CapColumn>
928
+ </CapRow>
929
+ </CapSpin>
930
+ );
931
+ });
932
+
933
+ EmailHTMLEditor.propTypes = {
934
+ intl: PropTypes.object.isRequired,
935
+ location: PropTypes.object,
936
+ params: PropTypes.object,
937
+ getDefaultTags: PropTypes.string,
938
+ supportedTags: PropTypes.array,
939
+ metaEntities: PropTypes.object,
940
+ injectedTags: PropTypes.object,
941
+ globalActions: PropTypes.object,
942
+ loadingTags: PropTypes.bool,
943
+ eventContextTags: PropTypes.array,
944
+ forwardedTags: PropTypes.object,
945
+ selectedOfferDetails: PropTypes.array,
946
+ currentOrgDetails: PropTypes.object,
947
+ isReadOnly: PropTypes.bool,
948
+ fetchingLiquidTags: PropTypes.bool,
949
+ createTemplateInProgress: PropTypes.bool,
950
+ fetchingCmsData: PropTypes.bool,
951
+ // Email Redux state
952
+ Email: PropTypes.object,
953
+ // Email actions
954
+ emailActions: PropTypes.object,
955
+ // Full mode props
956
+ isFullMode: PropTypes.bool,
957
+ templateName: PropTypes.string,
958
+ showTemplateName: PropTypes.func,
959
+ onFormDataChange: PropTypes.func,
960
+ isGetFormData: PropTypes.bool,
961
+ getFormdata: PropTypes.func,
962
+ // Library mode props
963
+ templateData: PropTypes.object,
964
+ // Uploaded content from zip file
965
+ EmailLayout: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), // eslint-disable-line react/require-default-props
966
+ // Liquid validation
967
+ getLiquidTags: PropTypes.func,
968
+ showLiquidErrorInFooter: PropTypes.func,
969
+ // Preview/Test
970
+ // Parent loading control
971
+ setIsLoadingContent: PropTypes.func,
972
+ };
973
+
974
+ EmailHTMLEditor.defaultProps = {
975
+ location: {},
976
+ params: {},
977
+ getDefaultTags: null,
978
+ supportedTags: [],
979
+ showTemplateName: null,
980
+ metaEntities: {},
981
+ injectedTags: {},
982
+ globalActions: {},
983
+ loadingTags: false,
984
+ eventContextTags: [],
985
+ forwardedTags: {},
986
+ selectedOfferDetails: [],
987
+ currentOrgDetails: {},
988
+ isReadOnly: false,
989
+ fetchingLiquidTags: false,
990
+ createTemplateInProgress: false,
991
+ fetchingCmsData: false,
992
+ Email: {},
993
+ emailActions: {},
994
+ isFullMode: false,
995
+ templateName: '',
996
+ onFormDataChange: null,
997
+ isGetFormData: false,
998
+ getFormdata: null,
999
+ templateData: null,
1000
+ getLiquidTags: null,
1001
+ showLiquidErrorInFooter: null,
1002
+ setIsLoadingContent: null,
1003
+ };
1004
+
1005
+ export default injectIntl(EmailHTMLEditor);