From 61addf890adb1f0ad87fa81f7dacd9cf08d02fc7 Mon Sep 17 00:00:00 2001 From: Apoorva Jain Date: Sun, 4 Jan 2026 18:38:55 -0800 Subject: [PATCH] Fix: Resolve email management issues - VIDEO type, draft clearing, offline alerts, and preview images - Add VIDEO variable type support with video icon in EmailTemplateEditor - Fix Clear Draft to properly reset form state in IntegratedEmailSender - Fix offline alert banner to auto-dismiss when connection restored - Fix preview images to display properly with constrained sizing in CSS - Add automatic HTML wrapping for image/video variables in preview utils --- .../email-sender/IntegratedEmailSender.jsx | 2259 +++++++++++++++++ .../IntegratedEmailSender.module.css | 1192 +++++++++ .../EmailManagement/email-sender/utils.js | 80 + .../templates/EmailTemplateEditor.jsx | 1171 +++++++++ 4 files changed, 4702 insertions(+) create mode 100644 src/components/EmailManagement/email-sender/IntegratedEmailSender.jsx create mode 100644 src/components/EmailManagement/email-sender/IntegratedEmailSender.module.css create mode 100644 src/components/EmailManagement/email-sender/utils.js create mode 100644 src/components/EmailManagement/template-management/templates/EmailTemplateEditor.jsx diff --git a/src/components/EmailManagement/email-sender/IntegratedEmailSender.jsx b/src/components/EmailManagement/email-sender/IntegratedEmailSender.jsx new file mode 100644 index 0000000000..c0762c376b --- /dev/null +++ b/src/components/EmailManagement/email-sender/IntegratedEmailSender.jsx @@ -0,0 +1,2259 @@ +import React, { + useState, + useEffect, + useCallback, + useMemo, + useRef, + Suspense, + lazy, + useReducer, +} from 'react'; +// import WeeklyUpdateComposer from './WeeklyUpdateComposer'; +import { connect, useSelector } from 'react-redux'; +import { useHistory, useLocation } from 'react-router-dom'; +import { toast } from 'react-toastify'; +import PropTypes from 'prop-types'; +import axios from 'axios'; +import { ENDPOINTS } from '~/utils/URL'; +import { + Form, + FormGroup, + Label, + Input, + Button, + Alert, + Modal, + ModalHeader, + ModalBody, + ModalFooter, + Badge, + Spinner, + Table, + Progress, + Card, + CardBody, +} from 'reactstrap'; +import { + FaPaperPlane, + FaTimes, + FaEye, + FaExclamationTriangle, + FaRedo, + FaCheckCircle, + FaInfoCircle, + FaSpinner, +} from 'react-icons/fa'; +import { getEmailSenderConfig } from '../shared'; +import { validateTemplateVariables, validateVariable, Validators } from './validation'; +import { + parseRecipients as parseRecipientsUtil, + validateEmail as validateEmailUtil, + buildRenderedEmailFromTemplate, +} from './utils'; +import { + fetchEmailTemplates, + clearEmailTemplateError, + previewEmailTemplate, +} from '../../../actions/emailTemplateActions'; +import './IntegratedEmailSender.module.css'; +import '../EmailManagementShared.css'; +import { saveDraft, loadDraft, clearDraft, hasDraft, getDraftAge } from './formPersistence'; +import { + EMAIL_MODES, + EMAIL_DISTRIBUTION, + YOUTUBE_THUMBNAIL_QUALITIES, +} from './constants/emailConstants'; + +// Lazy load heavy components +const LazyEditor = lazy(() => + import('@tinymce/tinymce-react').then(module => ({ default: module.Editor })), +); + +// Memoized VariableRow component for better performance +const VariableRow = React.memo( + ({ + variable, + value, + extractedValue, + error, + onVariableChange, + onImageSourceChange, + onImageLoadStatusChange, + }) => { + const [imgError, setImgError] = React.useState(false); + const [qualityIndex, setQualityIndex] = React.useState(0); + const qualities = React.useMemo(() => YOUTUBE_THUMBNAIL_QUALITIES, []); + + React.useEffect(() => { + setImgError(false); + setQualityIndex(0); + }, [value, extractedValue]); + + const youtubeId = React.useMemo(() => Validators.extractYouTubeId(value || ''), [value]); + const computedSrc = React.useMemo(() => { + if (youtubeId) { + const q = qualities[Math.min(qualityIndex, qualities.length - 1)]; + return `https://img.youtube.com/vi/${youtubeId}/${q}.jpg`; + } + return extractedValue || value || ''; + }, [youtubeId, qualities, qualityIndex, extractedValue, value]); + + const handleImageError = useCallback(() => { + if (youtubeId && qualityIndex < qualities.length - 1) { + setQualityIndex(idx => idx + 1); + } else { + setImgError(true); + if (onImageLoadStatusChange) { + onImageLoadStatusChange(variable.name, false); + } + } + }, [youtubeId, qualityIndex, qualities.length]); + + const handleImageLoad = useCallback(() => { + setImgError(false); + if (onImageLoadStatusChange) { + onImageLoadStatusChange(variable.name, true); + } + }, [onImageLoadStatusChange, variable?.name]); + + return ( + + +
+ {variable.name} + {variable.required && *} +
+ + + + {variable.type} + + + + {variable.type === 'textarea' ? ( + onVariableChange(variable.name, e.target.value)} + placeholder={`Enter ${variable.name.toLowerCase()}`} + invalid={!!error} + className="variable-input variable-textarea" + /> + ) : variable.type === 'image' ? ( +
+ onImageSourceChange(variable.name, e.target.value)} + placeholder="Image URL or YouTube link" + invalid={!!error} + className="variable-input" + /> + + Supports: Direct image URLs (.jpg, .png, .gif, .webp, .svg), YouTube links + {extractedValue && (Auto-extracted)} + + {(value || extractedValue) && ( +
+ Preview: +
+ {!imgError ? ( + Preview + ) : ( +
+
+
Invalid Image
+
+ )} + {extractedValue && ( +
+
+ Extracted from: +
+
{value}
+
+ )} +
+
+ )} +
+ ) : ( + onVariableChange(variable.name, e.target.value)} + placeholder={ + variable.type === 'url' + ? 'https://example.com' + : variable.type === 'number' + ? 'Enter number' + : `Enter ${variable.name.toLowerCase()}` + } + invalid={!!error} + className="variable-input" + /> + )} + {error &&
{error}
} + + + ); + }, +); + +VariableRow.displayName = 'VariableRow'; +// Initial state for email sender +const initialEmailState = { + selectedTemplate: null, + customContent: '', + customSubject: '', + recipients: '', + variableValues: {}, + emailDistribution: EMAIL_DISTRIBUTION.SPECIFIC, + showPreviewModal: false, + validationErrors: {}, + recipientList: [], + isSending: false, + apiError: null, + retryCount: 0, + isRetrying: false, + loadingProgress: 0, + isEditorLoaded: false, + editorError: null, + showRetryOptions: false, + lastSuccessfulLoad: null, + fullTemplateContent: null, + previewLoading: false, + previewError: null, + backendPreviewData: null, + componentError: null, + // NEW PROPERTIES FOR DRAFT PERSISTENCE + showDraftNotification: false, + draftAge: null, + isOnline: typeof navigator !== 'undefined' ? navigator.onLine : true, + showOfflineWarning: false, +}; + +// Reducer function +const emailReducer = (state, action) => { + switch (action.type) { + case 'SET_SELECTED_TEMPLATE': + return { ...state, selectedTemplate: action.payload }; + + case 'SET_CUSTOM_CONTENT': + return { ...state, customContent: action.payload }; + + case 'SET_CUSTOM_SUBJECT': + return { ...state, customSubject: action.payload }; + + case 'SET_RECIPIENTS': + return { ...state, recipients: action.payload }; + + case 'SET_VARIABLE_VALUES': + return { ...state, variableValues: action.payload }; + + case 'UPDATE_VARIABLE_VALUE': + return { + ...state, + variableValues: { + ...state.variableValues, + [action.payload.name]: action.payload.value, + }, + }; + + case 'SET_EMAIL_DISTRIBUTION': + return { ...state, emailDistribution: action.payload }; + + case 'SET_SHOW_PREVIEW_MODAL': + return { ...state, showPreviewModal: action.payload }; + + case 'SET_VALIDATION_ERRORS': + return { ...state, validationErrors: action.payload }; + + case 'UPDATE_VALIDATION_ERROR': + return { + ...state, + validationErrors: { + ...state.validationErrors, + [action.payload.field]: action.payload.error, + }, + }; + + case 'SET_RECIPIENT_LIST': + return { ...state, recipientList: action.payload }; + + case 'SET_IS_SENDING': + return { ...state, isSending: action.payload }; + + case 'SET_API_ERROR': + return { ...state, apiError: action.payload }; + + case 'SET_RETRY_COUNT': + return { ...state, retryCount: action.payload }; + + case 'INCREMENT_RETRY_COUNT': + return { ...state, retryCount: state.retryCount + 1 }; + + case 'SET_IS_RETRYING': + return { ...state, isRetrying: action.payload }; + + case 'SET_LOADING_PROGRESS': + return { ...state, loadingProgress: action.payload }; + + case 'SET_EDITOR_LOADED': + return { ...state, isEditorLoaded: action.payload }; + + case 'SET_EDITOR_ERROR': + return { ...state, editorError: action.payload }; + + case 'SET_SHOW_RETRY_OPTIONS': + return { ...state, showRetryOptions: action.payload }; + + case 'SET_LAST_SUCCESSFUL_LOAD': + return { ...state, lastSuccessfulLoad: action.payload }; + + case 'SET_FULL_TEMPLATE_CONTENT': + return { ...state, fullTemplateContent: action.payload }; + + case 'SET_PREVIEW_LOADING': + return { ...state, previewLoading: action.payload }; + + case 'SET_PREVIEW_ERROR': + return { ...state, previewError: action.payload }; + + case 'SET_BACKEND_PREVIEW_DATA': + return { ...state, backendPreviewData: action.payload }; + + case 'SET_COMPONENT_ERROR': + return { ...state, componentError: action.payload }; + + case 'RESET_FORM': + return { + ...initialEmailState, + // Keep some state that shouldn't reset + apiError: state.apiError, + lastSuccessfulLoad: state.lastSuccessfulLoad, + }; + + case 'RESET_ERRORS': + return { + ...state, + apiError: null, + validationErrors: {}, + editorError: null, + previewError: null, + componentError: null, + }; + + case 'SET_SHOW_DRAFT_NOTIFICATION': + return { ...state, showDraftNotification: action.payload }; + + case 'SET_DRAFT_AGE': + return { ...state, draftAge: action.payload }; + + case 'SET_IS_ONLINE': + return { ...state, isOnline: action.payload }; + + case 'SET_SHOW_OFFLINE_WARNING': + return { ...state, showOfflineWarning: action.payload }; + + case 'RESTORE_DRAFT': + return { + ...state, + ...action.payload, + showDraftNotification: false, + }; + + default: + return state; + } +}; +const IntegratedEmailSender = ({ + templates, + loading, + error, + fetchEmailTemplates, + clearEmailTemplateError, + previewEmailTemplate, + onClose, + initialContent = '', + initialSubject = '', + preSelectedTemplate = null, + initialRecipients = '', +}) => { + const history = useHistory(); + const location = useLocation(); + const darkMode = useSelector(state => state.theme.darkMode); + const currentUser = useSelector(state => state.auth?.user); + + // Get current mode from URL query params, default to 'template' + const getCurrentModeFromURL = useCallback(() => { + const urlParams = new URLSearchParams(location.search); + const mode = urlParams.get('mode'); + if (mode === EMAIL_MODES.CUSTOM) return EMAIL_MODES.CUSTOM; + if (mode === EMAIL_MODES.WEEKLY_UPDATE) return EMAIL_MODES.WEEKLY_UPDATE; + return EMAIL_MODES.TEMPLATES; // Default to template mode + }, [location.search]); + + const [emailMode, setEmailMode] = useState(() => { + const urlParams = new URLSearchParams(location.search); + const mode = urlParams.get('mode'); + if (mode === EMAIL_MODES.CUSTOM) return EMAIL_MODES.CUSTOM; + if (mode === EMAIL_MODES.WEEKLY_UPDATE) return EMAIL_MODES.WEEKLY_UPDATE; + return EMAIL_MODES.TEMPLATES; // Default to template mode + }); + + // Use reducer for email sender state + const [state, dispatch] = useReducer(emailReducer, initialEmailState); + + // Destructure state + const { + selectedTemplate, + customContent, + customSubject, + recipients, + variableValues, + emailDistribution, + showPreviewModal, + validationErrors, + recipientList, + isSending, + apiError, + retryCount, + isRetrying, + loadingProgress, + isEditorLoaded, + editorError, + showRetryOptions, + lastSuccessfulLoad, + fullTemplateContent, + previewLoading, + previewError, + backendPreviewData, + componentError, + showDraftNotification, + draftAge, + isOnline, + showOfflineWarning, + } = state; + + // Refs for performance optimization + const abortControllerRef = useRef(null); + const timeoutRefs = useRef([]); + const progressIntervalRef = useRef(null); + const editorLoadTimeoutRef = useRef(null); + + // Enhanced loading component with progress bar + const EnhancedLoader = useMemo(() => { + const LoaderComponent = ({ message = 'Loading...', progress = 0, showProgress = true }) => ( +
+
+ + {message} +
+ {showProgress && } + Please wait while we load your content... +
+ ); + LoaderComponent.displayName = 'EnhancedLoader'; + return LoaderComponent; + }, []); + + // Fallback component for failed operations + const FallbackComponent = useMemo(() => { + const FallbackComponentInner = ({ + title, + message, + onRetry, + onDismiss, + retryCount = 0, + showRetryOptions = false, + }) => ( + + + +
{title}
+

{message}

+ {retryCount > 0 && ( + Retry attempt {retryCount} + )} +
+ + {showRetryOptions && ( + + )} +
+
+
+ ); + FallbackComponentInner.displayName = 'FallbackComponent'; + return FallbackComponentInner; + }, []); + + // Simple template loading indicator + const TemplateSelectLoader = useMemo(() => { + const TemplateLoaderComponent = () => ( +
+ + Loading templates... +
+ ); + TemplateLoaderComponent.displayName = 'TemplateSelectLoader'; + return TemplateLoaderComponent; + }, []); + + // Memoized URL update function + const updateModeURL = useCallback( + mode => { + const urlParams = new URLSearchParams(location.search); + + // Clear template management parameters when switching email sender modes + urlParams.delete('view'); + urlParams.delete('templateId'); + + if (mode === EMAIL_MODES.TEMPLATES) { + urlParams.delete('mode'); // Remove mode param for template (default) + } else { + urlParams.set('mode', mode); // Set mode param ('custom' or 'weekly') + } + + const newSearch = urlParams.toString(); + const newURL = `${location.pathname}${newSearch ? `?${newSearch}` : ''}`; + history.replace(newURL); + }, + [location.search, location.pathname, history], + ); + + // Handle mode change with cleanup + const handleModeChange = useCallback( + mode => { + // Cancel any pending requests + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + // Clear all timeouts + timeoutRefs.current.forEach(timeout => clearTimeout(timeout)); + timeoutRefs.current = []; + + // Reset all states when changing modes + dispatch({ type: 'SET_SELECTED_TEMPLATE', payload: null }); + dispatch({ type: 'SET_FULL_TEMPLATE_CONTENT', payload: null }); + dispatch({ type: 'SET_CUSTOM_CONTENT', payload: '' }); + dispatch({ type: 'SET_CUSTOM_SUBJECT', payload: '' }); + dispatch({ type: 'SET_RECIPIENTS', payload: '' }); + dispatch({ type: 'SET_VARIABLE_VALUES', payload: {} }); + dispatch({ type: 'SET_EMAIL_DISTRIBUTION', payload: EMAIL_DISTRIBUTION.SPECIFIC }); + dispatch({ type: 'SET_SHOW_PREVIEW_MODAL', payload: false }); + dispatch({ type: 'SET_VALIDATION_ERRORS', payload: {} }); + dispatch({ type: 'SET_RECIPIENT_LIST', payload: [] }); + dispatch({ type: 'SET_API_ERROR', payload: null }); + dispatch({ type: 'SET_RETRY_COUNT', payload: 0 }); + dispatch({ type: 'SET_IS_RETRYING', payload: false }); + + setEmailMode(mode); + updateModeURL(mode); + }, + [updateModeURL], + ); + + // Complete state reset function with cleanup + const resetAllStates = useCallback(() => { + // Cancel any pending requests + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + // Clear all timeouts + timeoutRefs.current.forEach(timeout => clearTimeout(timeout)); + timeoutRefs.current = []; + + dispatch({ type: 'SET_SELECTED_TEMPLATE', payload: null }); + dispatch({ type: 'SET_FULL_TEMPLATE_CONTENT', payload: null }); + dispatch({ type: 'SET_CUSTOM_CONTENT', payload: '' }); + dispatch({ type: 'SET_CUSTOM_SUBJECT', payload: '' }); + dispatch({ type: 'SET_RECIPIENTS', payload: '' }); + dispatch({ type: 'SET_VARIABLE_VALUES', payload: {} }); + dispatch({ type: 'SET_EMAIL_DISTRIBUTION', payload: EMAIL_DISTRIBUTION.SPECIFIC }); + dispatch({ type: 'SET_SHOW_PREVIEW_MODAL', payload: false }); + dispatch({ type: 'SET_VALIDATION_ERRORS', payload: {} }); + dispatch({ type: 'SET_RECIPIENT_LIST', payload: [] }); + dispatch({ type: 'SET_API_ERROR', payload: null }); + dispatch({ type: 'SET_RETRY_COUNT', payload: 0 }); + dispatch({ type: 'SET_IS_RETRYING', payload: false }); + }, []); + + // Update useTemplate when URL changes (e.g., browser back/forward) + useEffect(() => { + const newMode = getCurrentModeFromURL(); + if (newMode !== emailMode) { + // Always reset all states when switching modes + resetAllStates(); + setEmailMode(newMode); + } + }, [location.search, emailMode, resetAllStates, getCurrentModeFromURL]); + + // Enhanced template fetching with progress tracking + useEffect(() => { + // Create abort controller for this request + abortControllerRef.current = new AbortController(); + + const fetchTemplatesWithProgress = async () => { + try { + dispatch({ type: 'SET_LOADING_PROGRESS', payload: 0 }); + dispatch({ type: 'SET_API_ERROR', payload: null }); + dispatch({ type: 'SET_SHOW_RETRY_OPTIONS', payload: false }); + + // Simulate progress for better UX + const progressInterval = setInterval(() => { + dispatch({ + type: 'SET_LOADING_PROGRESS', + payload: prev => { + if (prev >= 90) return prev; + return prev + Math.random() * 15; + }, + }); + }, 200); + + progressIntervalRef.current = progressInterval; + + await fetchEmailTemplates({ + search: '', + // no pagination for dropdown; fetch all + sortBy: 'updated_at', + sortOrder: 'desc', + includeEmailContent: false, + }); + + dispatch({ type: 'SET_LOADING_PROGRESS', payload: 100 }); + dispatch({ type: 'SET_LAST_SUCCESSFUL_LOAD', payload: new Date() }); + clearInterval(progressInterval); + + // Reset progress after success + setTimeout(() => dispatch({ type: 'SET_LOADING_PROGRESS', payload: 0 }), 1000); + } catch (error) { + if (error.name !== 'AbortError') { + dispatch({ + type: 'SET_API_ERROR', + payload: 'Failed to load templates. Please try again.', + }); + dispatch({ type: 'SET_SHOW_RETRY_OPTIONS', payload: true }); + clearInterval(progressIntervalRef.current); + } + } + }; + + fetchTemplatesWithProgress(); + + // Cleanup function + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + if (progressIntervalRef.current) { + clearInterval(progressIntervalRef.current); + } + }; + }, [fetchEmailTemplates]); + + // Handle preSelectedTemplate + useEffect(() => { + if (preSelectedTemplate && templates && templates.length > 0) { + dispatch({ type: 'SET_SELECTED_TEMPLATE', payload: preSelectedTemplate }); + // Initialize variable values directly to avoid dependency issues + const initialValues = {}; + if ( + preSelectedTemplate && + preSelectedTemplate.variables && + Array.isArray(preSelectedTemplate.variables) + ) { + preSelectedTemplate.variables.forEach(variable => { + if (variable && variable.name) { + initialValues[variable.name] = ''; + } + }); + } + dispatch({ type: 'SET_VARIABLE_VALUES', payload: initialValues }); + } + }, [preSelectedTemplate, templates]); + + // Cleanup effect to reset states when component unmounts + useEffect(() => { + return () => { + // Cancel any pending requests + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + // Clear all timeouts + timeoutRefs.current.forEach(timeout => clearTimeout(timeout)); + timeoutRefs.current = []; + + // Clear everything when exiting email sender + resetAllStates(); + }; + }, [resetAllStates]); + + // Check for existing draft on component mount + useEffect(() => { + if (hasDraft()) { + const age = getDraftAge(); + dispatch({ type: 'SET_DRAFT_AGE', payload: age }); + dispatch({ type: 'SET_SHOW_DRAFT_NOTIFICATION', payload: true }); + } + }, []); + + // Network status monitoring + useEffect(() => { + let offlineToastId = null; // Track the offline toast + + const handleOnline = () => { + // Dismiss the offline toast if it exists + if (offlineToastId !== null) { + toast.dismiss(offlineToastId); + offlineToastId = null; + } + + // Force state updates with explicit boolean values + dispatch({ type: 'SET_IS_ONLINE', payload: true }); + dispatch({ type: 'SET_SHOW_OFFLINE_WARNING', payload: false }); + + // Show success toast + toast.success('Connection restored!', { autoClose: 2000 }); + }; + + const handleOffline = () => { + // Force state updates with explicit boolean values + dispatch({ type: 'SET_IS_ONLINE', payload: false }); + dispatch({ type: 'SET_SHOW_OFFLINE_WARNING', payload: true }); + + // Store the toast ID so we can dismiss it later + offlineToastId = toast.warning('You are offline. Your work will be saved locally.', { + autoClose: false, + closeButton: true, + }); + }; + + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + + // Clean up: dismiss offline toast if component unmounts + if (offlineToastId !== null) { + toast.dismiss(offlineToastId); + } + }; + }, []); + + // Auto-save form data to localStorage + useEffect(() => { + // Don't save if form is completely empty + if ( + !selectedTemplate && + !customContent.trim() && + !customSubject.trim() && + !recipients.trim() && + Object.keys(variableValues).length === 0 + ) { + return; + } + + // Debounce save operation + const timeoutId = setTimeout(() => { + const formState = { + selectedTemplate, + customContent, + customSubject, + recipients, + variableValues, + emailDistribution, + emailMode, + }; + + saveDraft(formState); + }, 1000); // Save after 1 second of inactivity + + return () => clearTimeout(timeoutId); + }, [ + selectedTemplate, + customContent, + customSubject, + recipients, + variableValues, + emailDistribution, + emailMode, + ]); + + // Enhanced retry mechanism with exponential backoff + const handleRetry = useCallback(async () => { + if (isRetrying) return; // Prevent multiple simultaneous retries + + dispatch({ type: 'SET_IS_RETRYING', payload: true }); + dispatch({ type: 'INCREMENT_RETRY_COUNT' }); + dispatch({ type: 'SET_API_ERROR', payload: null }); + dispatch({ type: 'SET_LOADING_PROGRESS', payload: 0 }); + dispatch({ type: 'SET_SHOW_RETRY_OPTIONS', payload: false }); + + toast.info(`Retrying to load templates... (Attempt ${retryCount + 1})`, { + autoClose: 1000, + }); + + try { + // Cancel previous request if any + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + // Create new abort controller + abortControllerRef.current = new AbortController(); + + // Progress tracking for retry + const progressInterval = setInterval(() => { + dispatch({ + type: 'SET_LOADING_PROGRESS', + payload: prev => { + if (prev >= 90) return prev; + return prev + Math.random() * 10; + }, + }); + }, 150); + + progressIntervalRef.current = progressInterval; + + await fetchEmailTemplates({ + search: '', + // no pagination for dropdown; fetch all + sortBy: 'created_at', + sortOrder: 'desc', + includeVariables: true, + }); + + clearInterval(progressInterval); + dispatch({ type: 'SET_LOADING_PROGRESS', payload: 100 }); + dispatch({ type: 'SET_LAST_SUCCESSFUL_LOAD', payload: new Date() }); + clearEmailTemplateError(); + dispatch({ type: 'SET_SHOW_RETRY_OPTIONS', payload: false }); + + toast.success('Templates loaded successfully!', { + icon: , + autoClose: 1000, + }); + + // Reset retry count on success + dispatch({ type: 'SET_RETRY_COUNT', payload: 0 }); + } catch (err) { + if (err.name !== 'AbortError') { + const errorMessage = `Retry failed: ${err.message || 'Unknown error'}`; + + dispatch({ type: 'SET_API_ERROR', payload: errorMessage }); + dispatch({ type: 'SET_SHOW_RETRY_OPTIONS', payload: true }); + + toast.error(errorMessage, { + autoClose: 5000, + }); + } + } finally { + dispatch({ type: 'SET_IS_RETRYING', payload: false }); + if (progressIntervalRef.current) { + clearInterval(progressIntervalRef.current); + } + } + }, [fetchEmailTemplates, clearEmailTemplateError, isRetrying, retryCount]); + + const clearError = useCallback(() => { + // Cancel any pending requests + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + dispatch({ type: 'SET_API_ERROR', payload: null }); + dispatch({ type: 'SET_RETRY_COUNT', payload: 0 }); + dispatch({ type: 'SET_IS_RETRYING', payload: false }); + clearEmailTemplateError(); + }, [clearEmailTemplateError]); + + // Show toast notifications for errors from Redux + useEffect(() => { + if (error) { + toast.error(`Error: ${error}`, { + autoClose: 5000, + hideProgressBar: false, + closeOnClick: true, + pauseOnHover: true, + }); + } + }, [error]); + + const initializeVariableValues = useCallback(template => { + const initialValues = {}; + if (template && template.variables && Array.isArray(template.variables)) { + template.variables.forEach(variable => { + if (variable && variable.name) { + initialValues[variable.name] = ''; + } + }); + } + dispatch({ type: 'SET_VARIABLE_VALUES', payload: initialValues }); + }, []); + + // Function to fetch full template content + const fetchFullTemplateContent = useCallback(async templateId => { + try { + const response = await axios.get(ENDPOINTS.EMAIL_TEMPLATE_BY_ID(templateId)); + + if (response.data.success && response.data.template) { + dispatch({ type: 'SET_FULL_TEMPLATE_CONTENT', payload: response.data.template }); + return response.data.template; + } else { + throw new Error(response.data.message || 'Template not found'); + } + } catch (error) { + dispatch({ type: 'SET_FULL_TEMPLATE_CONTENT', payload: null }); + throw error; + } + }, []); + + const handleTemplateSelect = useCallback( + async template => { + // Clear all previous state first + dispatch({ type: 'SET_VARIABLE_VALUES', payload: {} }); + dispatch({ type: 'SET_VALIDATION_ERRORS', payload: {} }); + dispatch({ type: 'SET_SHOW_PREVIEW_MODAL', payload: false }); + dispatch({ type: 'SET_FULL_TEMPLATE_CONTENT', payload: null }); + + if (!template) { + dispatch({ type: 'SET_SELECTED_TEMPLATE', payload: null }); + return; + } + + // Check if template has full data + const hasFullData = + Array.isArray(template.variables) && (template.html_content || template.subject); + + if (!hasFullData) { + // Fallback: fetch full template data if only basic data was loaded + try { + const fullTemplate = await fetchFullTemplateContent(template._id); + if (fullTemplate) { + dispatch({ type: 'SET_SELECTED_TEMPLATE', payload: fullTemplate }); + initializeVariableValues(fullTemplate); + dispatch({ type: 'SET_FULL_TEMPLATE_CONTENT', payload: fullTemplate }); + } + } catch (error) { + // Continue with the template we have + dispatch({ type: 'SET_SELECTED_TEMPLATE', payload: template }); + initializeVariableValues(template); + } + } else { + // Template already has full data + dispatch({ type: 'SET_SELECTED_TEMPLATE', payload: template }); + initializeVariableValues(template); + dispatch({ type: 'SET_FULL_TEMPLATE_CONTENT', payload: template }); + } + }, + [initializeVariableValues, fetchFullTemplateContent], + ); + + const handleVariableChange = useCallback( + (variableName, value) => { + const nextValues = { ...variableValues, [variableName]: value }; + dispatch({ type: 'SET_VARIABLE_VALUES', payload: nextValues }); + + // Live validate only the changed variable when a template is selected + if (selectedTemplate) { + const variableMeta = selectedTemplate.variables?.find(v => v?.name === variableName); + if (variableMeta) { + const errorMsg = validateVariable(variableMeta, nextValues); + dispatch({ + type: 'UPDATE_VALIDATION_ERROR', + payload: { field: variableName, error: errorMsg }, + }); + } + } + }, + [selectedTemplate, variableValues], + ); + + const parseRecipients = useCallback(recipientText => parseRecipientsUtil(recipientText), []); + + // Memoized function to extract image from various sources + const extractImageFromSource = useCallback(Validators.extractImageFromSource, []); + + // Memoized handle image source change with automatic extraction + const handleImageSourceChange = useCallback( + (variableName, source) => { + const extractedImage = extractImageFromSource(source); + + const nextValues = { + ...variableValues, + [variableName]: source, + [`${variableName}_extracted`]: extractedImage || '', + }; + dispatch({ type: 'SET_VARIABLE_VALUES', payload: nextValues }); + + if (selectedTemplate) { + const variableMeta = selectedTemplate.variables?.find(v => v?.name === variableName); + if (variableMeta) { + const errorMsg = validateVariable(variableMeta, nextValues); + dispatch({ + type: 'UPDATE_VALIDATION_ERROR', + payload: { field: variableName, error: errorMsg }, + }); + } + } + }, + [extractImageFromSource, selectedTemplate, variableValues], + ); + + const validateEmail = useCallback(email => validateEmailUtil(email), []); + + const useTemplate = emailMode === EMAIL_MODES.TEMPLATES; + + const validateForm = useCallback(() => { + const errors = {}; + + if (useTemplate && !selectedTemplate) { + errors.template = 'Please select a template'; + } + + if (!useTemplate && !customContent.trim()) { + errors.customContent = 'Please enter email content'; + } + + if (!useTemplate && !customSubject.trim()) { + errors.customSubject = 'Please enter email subject'; + } + + if (emailDistribution === EMAIL_DISTRIBUTION.SPECIFIC) { + if (!recipients.trim()) { + errors.recipients = 'Please enter at least one recipient'; + } else { + const recipientEmails = parseRecipients(recipients); + if (recipientEmails.length === 0) { + errors.recipients = 'Please enter at least one valid email address'; + } else { + const invalidEmails = recipientEmails.filter(email => !validateEmail(email)); + if (invalidEmails.length > 0) { + errors.recipients = `Invalid email addresses: ${invalidEmails.join(', ')}`; + } + dispatch({ type: 'SET_RECIPIENT_LIST', payload: recipientEmails }); + } + } + } else { + dispatch({ type: 'SET_RECIPIENT_LIST', payload: [] }); + } + + // Validate template variables + if (useTemplate && selectedTemplate) { + const varErrors = validateTemplateVariables(selectedTemplate, variableValues); + Object.assign(errors, varErrors); + } + + dispatch({ type: 'SET_VALIDATION_ERRORS', payload: errors }); + return Object.keys(errors).length === 0; + }, [ + useTemplate, + selectedTemplate, + customContent, + customSubject, + emailDistribution, + recipients, + parseRecipients, + validateEmail, + variableValues, + ]); + + // Separate validation for preview (doesn't require recipients) + const validateForPreview = useCallback(() => { + const errors = {}; + + if (useTemplate && !selectedTemplate) { + errors.template = 'Please select a template'; + } + + if (!useTemplate && !customContent.trim()) { + errors.customContent = 'Please enter email content'; + } + + if (!useTemplate && !customSubject.trim()) { + errors.customSubject = 'Please enter email subject'; + } + + // Validate variables as in submit (recipients are skipped for preview) + if (useTemplate && selectedTemplate) { + const varErrors = validateTemplateVariables(selectedTemplate, variableValues); + Object.assign(errors, varErrors); + } + + dispatch({ type: 'SET_VALIDATION_ERRORS', payload: errors }); + return Object.keys(errors).length === 0; + }, [useTemplate, selectedTemplate, customContent, customSubject, variableValues]); + + // Get preview content + const getPreviewContent = useCallback(() => { + // For custom emails, use client-side content + if (!useTemplate || !selectedTemplate) { + return { subject: customSubject, content: customContent }; + } + + // If backend preview data is available, use it + if (backendPreviewData) { + return { + subject: backendPreviewData.subject || customSubject, + content: backendPreviewData.htmlContent || backendPreviewData.html_content || customContent, + }; + } + + // Fallback to client-side rendering + const templateData = fullTemplateContent || selectedTemplate; + return buildRenderedEmailFromTemplate(templateData, variableValues); + }, [ + useTemplate, + selectedTemplate, + fullTemplateContent, + variableValues, + customSubject, + customContent, + backendPreviewData, + ]); + + // Handle preview with backend API for templates + const handlePreview = useCallback(async () => { + if (!validateForPreview()) { + toast.warning('Please fix validation errors before previewing', { + autoClose: 1000, + }); + return; + } + + // For custom emails, just show preview modal + if (!useTemplate || !selectedTemplate) { + dispatch({ type: 'SET_SHOW_PREVIEW_MODAL', payload: true }); + return; + } + + // For templates, try to use backend API + if (selectedTemplate._id) { + dispatch({ type: 'SET_PREVIEW_LOADING', payload: true }); + dispatch({ type: 'SET_PREVIEW_ERROR', payload: null }); + dispatch({ type: 'SET_BACKEND_PREVIEW_DATA', payload: null }); + + try { + const preview = await previewEmailTemplate(selectedTemplate._id, variableValues); + dispatch({ type: 'SET_BACKEND_PREVIEW_DATA', payload: preview }); + dispatch({ type: 'SET_SHOW_PREVIEW_MODAL', payload: true }); + } catch (error) { + dispatch({ + type: 'SET_PREVIEW_ERROR', + payload: error.message || 'Failed to preview template', + }); + toast.warning('Preview failed, using basic preview', { + position: 'top-right', + autoClose: 3000, + }); + dispatch({ type: 'SET_BACKEND_PREVIEW_DATA', payload: null }); + dispatch({ type: 'SET_SHOW_PREVIEW_MODAL', payload: true }); + } finally { + dispatch({ type: 'SET_PREVIEW_LOADING', payload: false }); + } + } else { + dispatch({ type: 'SET_SHOW_PREVIEW_MODAL', payload: true }); + } + }, [useTemplate, selectedTemplate, variableValues, validateForPreview, previewEmailTemplate]); + + // Handle send email - opens preview modal + const handleSendEmail = useCallback(() => { + if (!validateForm()) { + return; + } + handlePreview(); + }, [validateForm, handlePreview]); + + // Send email from preview modal + const handleSendFromPreview = useCallback(async () => { + dispatch({ type: 'SET_IS_SENDING', payload: true }); + + try { + if (useTemplate && selectedTemplate) { + const templateData = fullTemplateContent || selectedTemplate; + + // Replace image variables with extracted images if available + const processedVariableValues = { ...variableValues }; + if (templateData.variables && Array.isArray(templateData.variables)) { + templateData.variables.forEach(variable => { + if ( + variable.type === 'image' && + processedVariableValues[`${variable.name}_extracted`] + ) { + processedVariableValues[variable.name] = + processedVariableValues[`${variable.name}_extracted`]; + } + }); + } + + const rendered = getPreviewContent(); + const payload = + emailDistribution === EMAIL_DISTRIBUTION.BROADCAST + ? { + subject: rendered.subject, + html: rendered.content, + } + : { + to: recipientList, + subject: rendered.subject, + html: rendered.content, + }; + + if (!currentUser || !currentUser.userid) { + throw new Error('User authentication required to send emails'); + } + + const requestor = { + requestorId: currentUser.userid, + email: currentUser.email, + role: currentUser.role, + }; + + const payloadWithRequestor = { + ...payload, + requestor, + }; + + if (emailDistribution === EMAIL_DISTRIBUTION.BROADCAST) { + await axios.post(ENDPOINTS.BROADCAST_EMAILS, payloadWithRequestor); + } else { + await axios.post(ENDPOINTS.POST_EMAILS, payloadWithRequestor); + } + + const recipientCount = + emailDistribution === EMAIL_DISTRIBUTION.BROADCAST + ? 'all subscribers' + : `${recipientList.length} recipient(s)`; + toast.info(`Email created successfully for ${recipientCount}. Processing started.`, { + autoClose: 3000, + }); + + dispatch({ type: 'SET_SHOW_PREVIEW_MODAL', payload: false }); + dispatch({ type: 'SET_BACKEND_PREVIEW_DATA', payload: null }); + dispatch({ type: 'SET_PREVIEW_ERROR', payload: null }); + clearDraft(); // Clear saved draft after successful send + resetAllStates(); + } else { + // Send custom email + if (!currentUser || !currentUser.userid) { + throw new Error('User authentication required to send emails'); + } + + const requestor = { + requestorId: currentUser.userid, + email: currentUser.email, + role: currentUser.role, + }; + + if (emailDistribution === EMAIL_DISTRIBUTION.BROADCAST) { + await axios.post(ENDPOINTS.BROADCAST_EMAILS, { + subject: customSubject, + html: customContent, + requestor, + }); + } else { + await axios.post(ENDPOINTS.POST_EMAILS, { + to: recipientList, + subject: customSubject, + html: customContent, + requestor, + }); + } + + const recipientCount = + emailDistribution === EMAIL_DISTRIBUTION.BROADCAST + ? 'all subscribers' + : `${recipientList.length} recipient(s)`; + toast.info(`Email created successfully for ${recipientCount}. Processing started.`, { + autoClose: 3000, + }); + + dispatch({ type: 'SET_SHOW_PREVIEW_MODAL', payload: false }); + dispatch({ type: 'SET_BACKEND_PREVIEW_DATA', payload: null }); + dispatch({ type: 'SET_PREVIEW_ERROR', payload: null }); + clearDraft(); // Clear saved draft after successful send + resetAllStates(); + } + } catch (error) { + const errorMessage = error.response?.data?.message || error.message || 'Unknown error'; + toast.error(`Failed to send email: ${errorMessage}`); + dispatch({ + type: 'UPDATE_VALIDATION_ERROR', + payload: { field: 'general', error: errorMessage }, + autoClose: 3000, + }); + } finally { + dispatch({ type: 'SET_IS_SENDING', payload: false }); + } + }, [ + useTemplate, + selectedTemplate, + variableValues, + fullTemplateContent, + getPreviewContent, + emailDistribution, + recipientList, + customSubject, + customContent, + resetAllStates, + currentUser, + ]); + const handleRestoreDraft = useCallback(async () => { + const draft = loadDraft(); + + if (!draft) { + toast.error('No draft found to restore'); + dispatch({ type: 'SET_SHOW_DRAFT_NOTIFICATION', payload: false }); + return; + } + + try { + // Restore email mode first + if (draft.emailMode && draft.emailMode !== emailMode) { + setEmailMode(draft.emailMode); + updateModeURL(draft.emailMode); + } + + // Restore template if in template mode + if (draft.emailMode === EMAIL_MODES.TEMPLATES && draft.selectedTemplateId && templates) { + const template = templates.find(t => t._id === draft.selectedTemplateId); + if (template) { + await handleTemplateSelect(template); + } + } + + // Restore other form fields + dispatch({ + type: 'RESTORE_DRAFT', + payload: { + customContent: draft.customContent || '', + customSubject: draft.customSubject || '', + recipients: draft.recipients || '', + variableValues: draft.variableValues || {}, + emailDistribution: draft.emailDistribution || EMAIL_DISTRIBUTION.SPECIFIC, + }, + }); + + toast.success('Draft restored successfully!', { + autoClose: 1000, + icon: , + }); + + dispatch({ type: 'SET_SHOW_DRAFT_NOTIFICATION', payload: false }); + } catch (error) { + toast.error('Failed to restore draft'); + console.error('Draft restoration error:', error); + } + }, [emailMode, templates, handleTemplateSelect, setEmailMode, updateModeURL]); + + const handleDismissDraft = useCallback(() => { + clearDraft(); + dispatch({ type: 'SET_SHOW_DRAFT_NOTIFICATION', payload: false }); + toast.info('Draft dismissed', { autoClose: 2000 }); + }, []); + + const handleClearDraft = useCallback(() => { + if (window.confirm('Are you sure you want to clear the saved draft? This cannot be undone.')) { + clearDraft(); // Clear from localStorage + + // Reset all form states to clear the UI + dispatch({ type: 'SET_SELECTED_TEMPLATE', payload: null }); + dispatch({ type: 'SET_CUSTOM_CONTENT', payload: '' }); + dispatch({ type: 'SET_CUSTOM_SUBJECT', payload: '' }); + dispatch({ type: 'SET_RECIPIENTS', payload: '' }); + dispatch({ type: 'SET_VARIABLE_VALUES', payload: {} }); + dispatch({ type: 'SET_EMAIL_DISTRIBUTION', payload: EMAIL_DISTRIBUTION.SPECIFIC }); + dispatch({ type: 'SET_VALIDATION_ERRORS', payload: {} }); + dispatch({ type: 'SET_RECIPIENT_LIST', payload: [] }); + + toast.info('Draft cleared', { autoClose: 2000 }); + } + }, []); + + // Memoized TinyMCE configuration + const TINY_MCE_INIT_OPTIONS = useMemo(() => getEmailSenderConfig(darkMode), [darkMode]); + + // Clear backend preview data when variables or template change + useEffect(() => { + dispatch({ type: 'SET_BACKEND_PREVIEW_DATA', payload: null }); + dispatch({ type: 'SET_PREVIEW_ERROR', payload: null }); + }, [variableValues, selectedTemplate?._id]); + + // Memoized preview content + const previewContent = useMemo(() => getPreviewContent(), [getPreviewContent]); + + // Error boundary effect + useEffect(() => { + const handleError = error => { + dispatch({ type: 'SET_COMPONENT_ERROR', payload: error.message }); + }; + + window.addEventListener('error', handleError); + return () => window.removeEventListener('error', handleError); + }, []); + + // Reset component error when switching modes + useEffect(() => { + if (componentError) { + dispatch({ type: 'SET_COMPONENT_ERROR', payload: null }); + } + }, [useTemplate, componentError]); + + // Show error boundary if component error occurs + if (componentError) { + return ( +
+
+

Send Email

+
+ + +
+ Component Error +
+ {componentError} +
+ +
+
+
+
+ ); + } + + return ( +
+ {/* Page Title */} +
+

Send Email

+
+ {/* Draft Notification - Restore saved work */} + {showDraftNotification && ( + +
+ +
+ Draft Available +
+ + You have unsaved work from {draftAge} minute{draftAge !== 1 ? 's' : ''} ago. + +
+
+
+ + +
+
+ )} + + {/* Offline Warning */} + {showOfflineWarning && ( + + +
+ You are offline +
+ + Your work is being saved locally. You can continue editing and send when connection is + restored. + +
+
+ )} + +
+
+ {/* Email Mode Selector */} +
+ + + {/* */} +
+
+ + {onClose && ( + + )} + {hasDraft() && ( + + )} +
+
+
+ + {/* General Validation Error Alert */} + {validationErrors.general && ( + + dispatch({ + type: 'UPDATE_VALIDATION_ERROR', + payload: { field: 'general', error: null }, + }) + } + className="d-flex align-items-center" + > + +
+ Error +
+ {validationErrors.general} +
+
+ )} + + {/* Show Form only for Template and Custom modes */} + {emailMode !== EMAIL_MODES.WEEKLY_UPDATE && ( +
+ {/* Template Selection (if using template) */} + {useTemplate && ( + + + {loading ? ( + + ) : error || apiError || (templates && templates.length === 0) ? ( +
+ + + + + {/* Simple Error Message */} +
+ + + {error || apiError + ? 'Error loading templates. Please try again.' + : 'No templates available. Please create a template first.'} + +
+ + {/* Simple Retry Option */} + {(error || apiError) && ( +
+ +
+ )} +
+ ) : ( +
+ { + const template = templates.find(t => t._id === e.target.value); + handleTemplateSelect(template); + }} + invalid={!!validationErrors.template} + className="template-select" + > + + {templates && templates.length > 0 ? ( + templates.map(template => ( + + )) + ) : ( + + )} + + + {templates && templates.length > 0 && ( +
+ + {templates.length} template{templates.length !== 1 ? 's' : ''} available + {lastSuccessfulLoad && ( + + • Last updated: {lastSuccessfulLoad.toLocaleTimeString()} + + )} + {isRetrying && • Retrying...} + +
+ )} +
+ )} + + {validationErrors.template && ( +
{validationErrors.template}
+ )} +
+ )} + + {/* Custom Email Fields */} + {!useTemplate && ( + <> + + + dispatch({ type: 'SET_CUSTOM_SUBJECT', payload: e.target.value })} + invalid={!!validationErrors.customSubject} + placeholder="Enter email subject" + /> + {validationErrors.customSubject && ( +
{validationErrors.customSubject}
+ )} +
+ + + +
+ {!isEditorLoaded && !editorError && ( +
+ + Loading editor... +
+ )} + + {editorError && ( + { + dispatch({ type: 'SET_EDITOR_ERROR', payload: null }); + dispatch({ type: 'SET_EDITOR_LOADED', payload: false }); + dispatch({ type: 'SET_LOADING_PROGRESS', payload: 0 }); + }} + onDismiss={() => dispatch({ type: 'SET_EDITOR_ERROR', payload: null })} + retryCount={retryCount} + showRetryOptions={true} + /> + )} + + + + Loading editor... +
+ } + > + {!editorError && ( + + dispatch({ type: 'SET_CUSTOM_CONTENT', payload: content }) + } + init={{ + ...TINY_MCE_INIT_OPTIONS, + setup: editor => { + editor.on('init', () => { + dispatch({ type: 'SET_EDITOR_LOADED', payload: true }); + dispatch({ type: 'SET_LOADING_PROGRESS', payload: 100 }); + }); + + editor.on('error', e => { + dispatch({ + type: 'SET_EDITOR_ERROR', + payload: 'Editor encountered an error', + }); + dispatch({ type: 'SET_EDITOR_LOADED', payload: false }); + }); + + editorLoadTimeoutRef.current = setTimeout(() => { + if (!isEditorLoaded) { + dispatch({ + type: 'SET_EDITOR_ERROR', + payload: 'Editor is taking too long to load', + }); + dispatch({ type: 'SET_EDITOR_LOADED', payload: false }); + } + }, 10000); + }, + }} + onLoadContent={() => { + clearTimeout(editorLoadTimeoutRef.current); + dispatch({ type: 'SET_EDITOR_LOADED', payload: true }); + }} + /> + )} + + + {/* Fallback textarea if editor fails */} + {editorError && ( +
+ + + dispatch({ type: 'SET_CUSTOM_CONTENT', payload: e.target.value }) + } + placeholder="Enter your email content here..." + className="mt-2" + /> +
+ )} +
+ + {validationErrors.customContent && ( +
{validationErrors.customContent}
+ )} + + + )} + + {/* Variable Values */} + {useTemplate && selectedTemplate && ( +
+
+
+ Template Variables + + {selectedTemplate.variables ? selectedTemplate.variables.length : 0} variable + {selectedTemplate.variables && selectedTemplate.variables.length !== 1 + ? 's' + : ''} + +
+
+ + {selectedTemplate.variables && selectedTemplate.variables.length > 0 ? ( +
+ + + + + + + + + + {selectedTemplate.variables.map(variable => ( + { + dispatch({ + type: 'UPDATE_VALIDATION_ERROR', + payload: { + field: name, + error: ok + ? null + : `${name} is required (valid image URL or YouTube link)`, + }, + }); + }} + /> + ))} + +
VariableTypeValue
+
+ ) : ( +
+
+ + No variables defined for this template. +
+ + This template doesn't require any variable inputs. + +
+ )} +
+ )} + + {/* Email Distribution Selection */} + + +
+ + +
+ + {/* Expanded content for specific recipients */} + {emailDistribution === EMAIL_DISTRIBUTION.SPECIFIC && ( + + + { + const val = e.target.value; + dispatch({ type: 'SET_RECIPIENTS', payload: val }); + + if (emailDistribution === EMAIL_DISTRIBUTION.SPECIFIC) { + const emails = parseRecipients(val); + let errorMsg = null; + if (!val.trim()) { + errorMsg = 'Please enter at least one recipient'; + } else if (emails.length === 0) { + errorMsg = 'Please enter at least one valid email address'; + } else { + const invalid = emails.filter(email => !validateEmail(email)); + if (invalid.length > 0) { + errorMsg = `Invalid email addresses: ${invalid.join(', ')}`; + } + } + dispatch({ + type: 'UPDATE_VALIDATION_ERROR', + payload: { field: 'recipients', error: errorMsg }, + }); + dispatch({ type: 'SET_RECIPIENT_LIST', payload: errorMsg ? [] : emails }); + } + }} + invalid={!!validationErrors.recipients} + placeholder="Enter email addresses separated by commas Example: john@example.com, jane@example.com, team@company.com" + /> + {validationErrors.recipients && ( +
{validationErrors.recipients}
+ )} +
+ )} +
+ + )} + + {/* Weekly Update Mode */} + {/* {emailMode === EMAIL_MODES.WEEKLY_UPDATE && } */} + + {/* Preview & Send Modal */} + { + if (!isSending) { + dispatch({ type: 'SET_SHOW_PREVIEW_MODAL', payload: false }); + dispatch({ type: 'SET_BACKEND_PREVIEW_DATA', payload: null }); + dispatch({ type: 'SET_PREVIEW_ERROR', payload: null }); + } + }} + size="lg" + centered + backdrop={isSending ? 'static' : true} + keyboard={!isSending} + > + { + if (!isSending) { + dispatch({ type: 'SET_SHOW_PREVIEW_MODAL', payload: false }); + dispatch({ type: 'SET_BACKEND_PREVIEW_DATA', payload: null }); + dispatch({ type: 'SET_PREVIEW_ERROR', payload: null }); + } + }} + > + {previewLoading ? ( + <> + + Loading Preview... + + ) : isSending ? ( + <> + + Sending Email... + + ) : ( + 'Preview & Send Email' + )} + + + {previewLoading ? ( +
+ +
Loading email preview...
+
+ ) : ( + <> + {previewError && ( + + + {previewError} + {useTemplate && + selectedTemplate && + !selectedTemplate._id && + ' (Using client-side preview)'} + + )} + +
+ Subject: {previewContent.subject || 'No subject'} +
+ +
+ Distribution:{' '} + {emailDistribution === EMAIL_DISTRIBUTION.BROADCAST ? ( + <> + + Broadcast + + All subscribed users + + ) : ( + <> + + Specific + + {parseRecipients(recipients).length} recipient(s) + {parseRecipients(recipients).length > 0 && + parseRecipients(recipients).length <= 5 && ( +
+ Recipients: + + {parseRecipients(recipients).join(', ')} + +
+ )} + + )} +
+ + {useTemplate && selectedTemplate && backendPreviewData && ( +
+ + + Server-rendered preview + + + This preview matches exactly what will be sent + +
+ )} + + {previewContent.content ? ( +
+ Content Preview: +
+
+ ) : ( +
+ Content Preview: +
+ + No content available for preview. + {useTemplate + ? ' Please fill in all template variables.' + : ' Please add some content to your email.'} +
+
+ )} + + + + Please review your email carefully. Once sent, this action cannot + be undone. The email will be processed immediately. + + + )} + + + + + + +
+ ); +}; + +// PropTypes +IntegratedEmailSender.propTypes = { + templates: PropTypes.arrayOf( + PropTypes.shape({ + _id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + subject: PropTypes.string.isRequired, + html_content: PropTypes.string.isRequired, + variables: PropTypes.arrayOf( + PropTypes.shape({ + name: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, + required: PropTypes.bool, + }), + ), + is_active: PropTypes.bool, + }), + ), + loading: PropTypes.bool, + error: PropTypes.string, + fetchEmailTemplates: PropTypes.func.isRequired, + clearEmailTemplateError: PropTypes.func.isRequired, + previewEmailTemplate: PropTypes.func, + onClose: PropTypes.func, + initialContent: PropTypes.string, + initialSubject: PropTypes.string, + preSelectedTemplate: PropTypes.object, + initialRecipients: PropTypes.string, +}; + +IntegratedEmailSender.defaultProps = { + templates: [], + loading: false, + error: null, + onClose: null, + initialContent: '', + initialSubject: '', + preSelectedTemplate: null, + initialRecipients: '', +}; + +const mapStateToProps = state => ({ + templates: state.emailTemplates.templates, + loading: state.emailTemplates.loading, + error: state.emailTemplates.error, +}); + +const mapDispatchToProps = { + fetchEmailTemplates, + clearEmailTemplateError, + previewEmailTemplate, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(IntegratedEmailSender); diff --git a/src/components/EmailManagement/email-sender/IntegratedEmailSender.module.css b/src/components/EmailManagement/email-sender/IntegratedEmailSender.module.css new file mode 100644 index 0000000000..b9d6f47f4d --- /dev/null +++ b/src/components/EmailManagement/email-sender/IntegratedEmailSender.module.css @@ -0,0 +1,1192 @@ +/* Enhanced Email Sender Styles */ + +/* Main Email Sender Container - Add padding */ +.email-sender { + padding: 0 2rem; + max-width: 1400px; + margin: 0 auto; +} + +/* Page Title - Better styling */ +.email-sender .page-title-container { + margin-bottom: 1.5rem; + padding-left: 0.5rem; +} + +.email-sender .page-title { + font-size: 1.75rem; + font-weight: 600; + color: #343a40; + margin: 0; +} + +/* Email Controls Container */ +.email-controls-container { + width: 100%; + margin-bottom: 2rem; + padding: 0 0.5rem; +} + +.email-controls-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +/* Mode Buttons (Templates/Custom) */ +.mode-buttons { + display: flex; + gap: 0.5rem; + flex-shrink: 0; +} + +.mode-buttons .btn { + border-radius: 6px; + border: 1px solid #dee2e6; + font-weight: 500; + font-size: 0.85rem; + transition: all 0.2s ease; + position: relative; + overflow: hidden; + min-width: 90px; + padding: 0.4rem 0.7rem; +} + +.mode-buttons .btn:hover { + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); +} + +.mode-buttons .btn:active { + transform: translateY(0); +} + +.mode-buttons .btn-primary { + background: #007bff; + border-color: #007bff; + color: white; +} + +.mode-buttons .btn-outline-primary { + background: white; + color: #007bff; + border-color: #007bff; +} + +.mode-buttons .btn-outline-primary:hover { + background: #007bff; + color: white; +} + +.mode-buttons .btn-success { + background: #28a745; + border-color: #28a745; + color: white; +} + +.mode-buttons .btn-outline-success { + background: white; + color: #28a745; + border-color: #28a745; +} + +.mode-buttons .btn-outline-success:hover { + background: #28a745; + color: white; +} + +/* Email Distribution Radio Buttons - Compact */ +.distribution-options { + display: flex; + gap: 1.5rem; + margin-top: 0.5rem; +} + +.distribution-option { + display: flex; + align-items: center; + padding: 0.5rem 1rem; + border: 1px solid #dee2e6; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + background: white; + font-size: 0.9rem; + position: relative; +} + +.distribution-option:hover { + border-color: #007bff; + background: #f8f9fa; +} + +.distribution-option.selected { + border-color: #007bff; + background: #e3f2fd; + color: #007bff; +} + +.distribution-option input[type="radio"] { + margin-right: 0.5rem; +} + +.option-icon { + font-size: 1.1rem; + margin-right: 0.5rem; +} + +/* Action Buttons */ +.action-buttons { + display: flex; + gap: 0.5rem; + flex-shrink: 0; +} + +.action-buttons .btn { + border-radius: 6px; + font-weight: 500; + font-size: 0.85rem; + transition: all 0.2s ease; + min-width: 100px; + padding: 0.5rem 1rem; +} + +.action-buttons .btn:hover { + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); +} + +.action-buttons .btn:active { + transform: translateY(0); +} + +.sending-email-btn { + opacity: 0.7; + cursor: not-allowed; +} + +/* Email Sending Progress */ +.email-sending-progress { + margin-top: 1rem; + padding: 1rem; + background: #f8f9fa; + border-radius: 8px; + border: 1px solid #dee2e6; +} + +.progress-container { + width: 100%; +} + +.progress-header { + display: flex; + align-items: center; + margin-bottom: 0.5rem; + color: #495057; + font-weight: 500; +} + +.progress-bar-container { + position: relative; + height: 8px; + background: #e9ecef; + border-radius: 4px; + overflow: hidden; + margin-bottom: 0.5rem; +} + +.progress-bar-sending { + height: 100%; + background: #007bff; + border-radius: 4px; + transition: width 0.3s ease; +} + +.progress-bar-fill { + height: 100%; + background: #007bff; + border-radius: 4px; + animation: progress-pulse 2s ease-in-out infinite; +} + +@keyframes progress-pulse { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.7; + } +} + +.progress-text { + font-size: 0.85rem; + color: #6c757d; +} + +/* Template Selection */ +.template-selection-container { + margin-bottom: 1rem; +} + +.template-select { + width: 100%; + border-radius: 6px; + border: 1px solid #ced4da; + padding: 0.5rem 0.75rem; + font-size: 0.9rem; + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +} + +.template-select:focus { + border-color: #007bff; + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); +} + +.template-select-disabled { + background-color: #e9ecef; + opacity: 0.6; + cursor: not-allowed; +} + +.template-info { + font-size: 0.8rem; + color: #6c757d; + margin-top: 0.5rem; +} + +.template-error-message { + display: flex; + align-items: center; + gap: 0.5rem; + color: #dc3545; + font-size: 0.85rem; + margin-top: 0.5rem; +} + +.error-icon { + color: #dc3545; +} + +.error-text { + flex: 1; +} + +.template-retry-simple { + margin-top: 0.5rem; +} + +.retry-btn-simple { + background: #dc3545; + border: 1px solid #dc3545; + color: white; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.8rem; + transition: all 0.2s ease; +} + +.retry-btn-simple:hover { + background: #c82333; + border-color: #bd2130; + color: white; +} + +.retry-btn-simple:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Template Variables */ +.template-variables { + margin-top: 1.5rem; + padding: 1.25rem; + background: #f8f9fa; + border-radius: 8px; + border: 1px solid #e9ecef; +} + +.variables-header { + margin-bottom: 1.25rem; + padding-bottom: 0.75rem; + border-bottom: 2px solid #dee2e6; +} + +.variables-header h6 { + font-size: 1.1rem; + font-weight: 600; + color: #343a40; + margin: 0; +} + +/* Table Improvements */ +.template-variables .table { + margin-bottom: 0; + background: white; + border-radius: 6px; + overflow: hidden; +} + +.template-variables .table thead th { + background: #e9ecef; + color: #495057; + font-weight: 600; + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 0.85rem; + border-bottom: 2px solid #dee2e6; +} + +.template-variables .table tbody tr { + transition: background-color 0.15s ease; +} + +.template-variables .table tbody tr:hover { + background-color: #f8f9fa; +} + +.template-variables .table tbody td { + padding: 1rem 0.85rem; + vertical-align: middle; + border-color: #e9ecef; +} + +/* Empty Variables State */ +.empty-variables-state { + background: #f8f9fa; + border-radius: 6px; + border: 1px dashed #dee2e6; + margin-top: 0.5rem; +} + +.variable-label { + font-weight: 600; + color: #343a40; + margin-bottom: 0; + display: flex; + align-items: center; + gap: 0.25rem; + font-size: 0.95rem; +} + +.variable-input { + width: 100%; + border-radius: 6px; + border: 1.5px solid #ced4da; + padding: 0.65rem 0.85rem; + font-size: 0.9rem; + transition: all 0.2s ease; + background: white; +} + +.variable-input:focus { + border-color: #007bff; + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.15); + outline: none; +} + +.variable-input:hover:not(:focus) { + border-color: #adb5bd; +} + +.variable-textarea { + min-height: 100px; + resize: vertical; + font-family: inherit; +} + +/* Editor Container */ +.editor-container { + position: relative; + border: none; + border-radius: 6px; + overflow: hidden; +} + +.editor-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; + background: #f8f9fa; + color: #6c757d; +} + +.editor-suspense { + min-height: 200px; +} + +.editor-fallback { + padding: 1rem; + background: #fff3cd; + border: 1px solid #ffeaa7; + border-radius: 6px; + color: #856404; +} + +/* Shimmer Loading */ +.template-shimmer-container { + margin-bottom: 1rem; +} + +.shimmer-select-wrapper { + margin-bottom: 0.5rem; +} + +.shimmer-select { + width: 100%; + height: 38px; + background: #e9ecef; + border-radius: 6px; +} + +.shimmer-info { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.shimmer-line { + height: 16px; + background: #e9ecef; + border-radius: 4px; +} + +.shimmer-short { + width: 60%; +} + +.shimmer-animated { + background: linear-gradient(90deg, #e9ecef 25%, #f8f9fa 50%, #e9ecef 75%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; +} + +@keyframes shimmer { + 0% { + background-position: -200% 0; + } + + 100% { + background-position: 200% 0; + } +} + +/* Skeleton Loader */ +.skeleton-loader { + background: #e9ecef; + border-radius: 4px; + display: inline-block; + position: relative; + overflow: hidden; +} + +.skeleton-animated { + background: linear-gradient(90deg, #e9ecef 25%, #f8f9fa 50%, #e9ecef 75%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; +} + +.enhanced-loader { + text-align: center; + padding: 2rem; +} + +.fallback-card { + border: 1px solid #dee2e6; + border-radius: 8px; + background: white; +} + + +/* Empty content message */ +.empty-content-message { + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; + text-align: center; + padding: 2rem; +} + +.empty-content-message p { + margin: 0; + font-size: 1rem; + color: #6c757d; +} + +.empty-content-message i { + color: #17a2b8; +} + +/* Responsive Design */ +@media (max-width: 992px) and (min-width: 769px) { + .email-sender { + padding: 0 1.5rem; + } + + .email-controls-row { + gap: 0.75rem; + } + + .mode-buttons .btn, + .action-buttons .btn { + font-size: 0.8rem; + padding: 0.35rem 0.6rem; + min-width: 80px; + } +} + +@media (max-width: 768px) { + .email-sender { + padding: 0 1rem; + } + + .email-controls-row { + flex-direction: column; + align-items: stretch; + gap: 1rem; + } + + .mode-buttons, + .action-buttons { + justify-content: center; + } + + .distribution-options { + flex-direction: column; + gap: 0.75rem; + padding-left: 0; + } + + .distribution-option { + justify-content: center; + } +} + +/* Action Buttons Dark Mode */ +.dark-mode .action-buttons .btn-outline-secondary { + background: #2d3748; + color: #e2e8f0; + border-color: #4a5568; +} + +.dark-mode .action-buttons .btn-outline-secondary:hover { + background: #4a5568; + color: #e2e8f0; + border-color: #718096; +} + +.dark-mode .action-buttons .btn-primary { + background: #3182ce; + border-color: #3182ce; + color: white; +} + +.dark-mode .action-buttons .btn-primary:hover { + background: #2c5aa0; + border-color: #2c5aa0; + color: white; +} + +.dark-mode .action-buttons .btn-primary:disabled { + background: #4a5568; + border-color: #4a5568; + color: #a0aec0; + opacity: 0.7; +} + +/* Dark Mode Support */ +.dark-mode .email-controls-container { + color: #e2e8f0; +} + +.dark-mode .email-sender .page-title { + color: #e2e8f0; +} + +/* Variable Type Badges */ +.template-variables .badge { + padding: 0.35rem 0.65rem; + font-size: 0.75rem; + font-weight: 600; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.dark-mode .mode-buttons .btn-outline-primary { + background: #2d3748; + color: #63b3ed; + border-color: #4a5568; +} + +.dark-mode .mode-buttons .btn-outline-primary:hover { + background: #3182ce; + color: white; +} + +.dark-mode .mode-buttons .btn-outline-success { + background: #2d3748; + color: #68d391; + border-color: #4a5568; +} + +.dark-mode .mode-buttons .btn-outline-success:hover { + background: #38a169; + color: white; +} + +.dark-mode .distribution-option { + background: #2d3748; + border-color: #4a5568; + color: #e2e8f0; +} + +.dark-mode .distribution-option:hover { + border-color: #63b3ed; + background: #4a5568; +} + +.dark-mode .distribution-option.selected { + border-color: #63b3ed; + background: #2b6cb0; + color: #e2e8f0; +} + +.dark-mode .template-select { + background: #2d3748; + border-color: #4a5568; + color: #e2e8f0; +} + +.dark-mode .template-select:focus { + border-color: #63b3ed; + box-shadow: 0 0 0 0.2rem rgba(99, 179, 237, 0.25); +} + +.dark-mode .template-variables { + background: #1a202c; + border-color: #2d3748; +} + +.dark-mode .variables-header { + border-bottom-color: #4a5568; +} + +.dark-mode .variables-header h6 { + color: #e2e8f0; +} + +.dark-mode .template-variables .table { + background: #2d3748; +} + +.dark-mode .template-variables .table thead th { + background: #4a5568; + color: #e2e8f0; + border-color: #718096; +} + +.dark-mode .template-variables .table tbody tr:hover { + background-color: #374151; +} + +.dark-mode .template-variables .table tbody td { + background: #2d3748; + color: #e2e8f0; + border-color: #4a5568; +} + +.dark-mode .template-variables .variable-input { + background: #374151; + border-color: #4a5568; + color: #e2e8f0; +} + +.dark-mode .template-variables .variable-input:focus { + border-color: #63b3ed; + box-shadow: 0 0 0 0.2rem rgba(99, 179, 237, 0.15); +} + +.dark-mode .template-variables .variable-input:hover:not(:focus) { + border-color: #718096; +} + +.dark-mode .template-variables .form-control { + background: #4a5568; + border-color: #718096; + color: #e2e8f0; +} + +.dark-mode .template-variables .form-select { + background: #4a5568; + border-color: #718096; + color: #e2e8f0; +} + +.dark-mode .template-variables .variable-label { + color: #e2e8f0; +} + +.dark-mode .template-variables .badge { + background: #4a5568; + color: #e2e8f0; +} + +.dark-mode .empty-variables-state { + background: #4a5568; + border-color: #718096; + color: #e2e8f0; +} + +.dark-mode .invalid-feedback { + color: #f8d7da !important; +} + +/* Simple Modal Styles - Component Scoped */ +.email-sender .modal-content { + border: none; + border-radius: 12px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); +} + +.email-sender .modal-header { + background: #007bff; + color: white; + border-bottom: none; + border-radius: 12px 12px 0 0; + padding: 1.25rem 1.5rem; + font-weight: 600; +} + +.email-sender .modal-header .btn-close { + filter: brightness(0) invert(1); +} + +.email-sender .modal-body { + padding: 1.5rem; + background: #ffffff; +} + +.email-sender .modal-footer { + background: #f8f9fa; + border-top: 1px solid #dee2e6; + border-radius: 0 0 12px 12px; + padding: 1rem 1.5rem; +} + +/* Preview & Send Modal - Uses standard Bootstrap modal styling (matching template info modal) */ + +/* Legacy Preview Modal Styles (for backward compatibility) */ +.email-sender .preview-info { + background: #f8f9fa; + border-radius: 8px; + padding: 1rem; + margin-bottom: 1rem; +} + +.email-sender .preview-field { + margin-bottom: 0.75rem; + color: #495057; +} + +.email-sender .preview-field:last-child { + margin-bottom: 0; +} + +.email-sender .preview-field strong { + color: #495057; + font-weight: 600; +} + +.email-sender .preview-content { + background: #ffffff; + border: 1px solid #dee2e6; + border-radius: 8px; + padding: 1rem; + min-height: 200px; + color: #495057; +} + +/* Dark Mode - Simple and Bulletproof */ +.email-sender.dark-mode .modal-content { + background: #2d3748 !important; + border: 1px solid #4a5568 !important; + color: #e2e8f0 !important; +} + +.email-sender.dark-mode .modal-header { + background: #3182ce !important; + color: #e2e8f0 !important; + border-bottom: 1px solid #4a5568 !important; +} + +.email-sender.dark-mode .modal-body { + background: #2d3748 !important; + color: #e2e8f0 !important; +} + +.email-sender.dark-mode .modal-footer { + background: #4a5568 !important; + border-top: 1px solid #718096 !important; +} + +.email-sender.dark-mode .preview-info { + background: #4a5568 !important; + border: 1px solid #718096 !important; +} + +.email-sender.dark-mode .preview-field { + color: #e2e8f0 !important; +} + +.email-sender.dark-mode .preview-field strong { + color: #e2e8f0 !important; +} + +.email-sender.dark-mode .preview-content { + background: #1a202c !important; + border-color: #4a5568 !important; + color: #e2e8f0 !important; +} + +.email-sender.dark-mode .confirm-info { + background: #4a5568 !important; + border: 1px solid #718096 !important; +} + +.email-sender.dark-mode .confirm-field { + color: #e2e8f0 !important; +} + +.email-sender.dark-mode .confirm-field strong { + color: #e2e8f0 !important; +} + +.email-sender.dark-mode .confirm-warning { + background: #744210 !important; + border-color: #d69e2e !important; +} + +.email-sender.dark-mode .confirm-warning small { + color: #f6e05e !important; +} + +/* Preview & Send Modal - Dark mode handled by Bootstrap's dark mode classes */ + +/* Dark Mode Buttons */ +.email-sender.dark-mode .btn-secondary { + background: #4a5568 !important; + border-color: #718096 !important; + color: #e2e8f0 !important; +} + +.email-sender.dark-mode .btn-secondary:hover { + background: #718096 !important; + border-color: #a0aec0 !important; + color: #e2e8f0 !important; +} + +.email-sender.dark-mode .btn-primary { + background: #3182ce !important; + border-color: #3182ce !important; + color: white !important; +} + +.email-sender.dark-mode .btn-primary:hover { + background: #2c5aa0 !important; + border-color: #2c5aa0 !important; + color: white !important; +} + +/* ======================================== + IMPROVED FORM STYLING + ======================================== */ + +/* Main Form Container - Add consistent padding */ +.email-sender form { + padding: 0 0.5rem; +} + +/* Form Groups - Better spacing */ +.email-sender .form-group { + margin-bottom: 2rem; + padding: 0; +} + +/* Form Labels - Better typography and spacing */ +.email-sender .form-label, +.email-sender label { + font-weight: 600; + color: #343a40; + font-size: 1rem; + margin-bottom: 0.75rem; + display: block; + padding-left: 0.25rem; +} + +/* Select Template Dropdown - Enhanced */ +.email-sender .template-select { + font-size: 0.95rem; + padding: 0.75rem 2.5rem 0.75rem 1rem; + border: 1.5px solid #ced4da; + transition: all 0.2s ease; + color: #495057; + font-weight: 500; + width: 100%; + height: auto; + min-height: 45px; + line-height: 1.5; + appearance: none; + background-color: white; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23333' d='M6 9L1 4h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 1rem center; + background-size: 12px; + white-space: nowrap; + overflow: visible; + text-overflow: ellipsis; +} + +.email-sender .template-select option { + color: #212529; + font-weight: 400; + padding: 0.5rem; +} + +.email-sender .template-select:hover:not(:disabled) { + border-color: #adb5bd; +} + +/* Template Selection Container - Add padding */ +.email-sender .template-selection-container { + padding: 0; +} + +/* Email Distribution Section - Enhanced with padding */ +.email-sender .distribution-options { + padding-left: 0.25rem; +} + +.email-sender .distribution-option { + padding: 0.85rem 1.5rem; + border: 2px solid #dee2e6; + border-radius: 8px; + font-weight: 500; +} + +.email-sender .distribution-option:hover { + border-color: #007bff; + background: #f8f9fa; + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08); +} + +.email-sender .distribution-option.selected { + border-color: #007bff; + background: #e7f3ff; + box-shadow: 0 2px 8px rgba(0, 123, 255, 0.15); +} + +/* Recipients Textarea - Enhanced with more padding */ +.email-sender textarea.form-control { + border: 1.5px solid #ced4da; + border-radius: 6px; + padding: 0.75rem; + padding-bottom: 3rem; + font-size: 0.9rem; + transition: all 0.2s ease; + line-height: 1.6; + min-height: 120px; +} + +.email-sender textarea.form-control:hover:not(:focus) { + border-color: #adb5bd; +} + +.email-sender textarea.form-control:focus { + border-color: #007bff; + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.15); +} + +.email-sender textarea.form-control::placeholder { + color: #6c757d; + font-size: 0.875rem; +} + +/* Form Section Titles - Better styling */ +.email-sender h6, +.email-sender .section-title { + font-size: 1.05rem; + font-weight: 600; + color: #343a40; + margin-bottom: 0.75rem; +} + +/* Info Text - Better styling */ +.email-sender .template-info, +.email-sender small.text-muted { + font-size: 0.8rem; + color: #6c757d; + display: block; + margin-top: 0.5rem; +} + +/* Dark Mode for Form Improvements */ +.email-sender.dark-mode .form-label, +.email-sender.dark-mode label { + color: #e2e8f0; +} + +.email-sender.dark-mode .template-select { + background: #2d3748; + border-color: #4a5568; + color: #e2e8f0; +} + +.email-sender.dark-mode .template-select option { + background: #2d3748; + color: #e2e8f0; +} + +.email-sender.dark-mode .template-select:hover:not(:disabled) { + border-color: #718096; +} + +.email-sender.dark-mode textarea.form-control { + background: #2d3748; + border-color: #4a5568; + color: #e2e8f0; +} + +.email-sender.dark-mode textarea.form-control:hover:not(:focus) { + border-color: #718096; +} + +.email-sender.dark-mode textarea.form-control::placeholder { + color: #a0aec0; +} + +.email-sender.dark-mode .distribution-option { + border-color: #4a5568; +} + +.email-sender.dark-mode .distribution-option.selected { + background: #2b6cb0; + border-color: #63b3ed; +} + + +/* Draft notification animation */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.draft-notification { + animation: fadeIn 0.3s ease-in; +} + +/* Alert button gaps */ +.alert .gap-2 { + gap: 0.5rem !important; +} + +.alert .d-flex.gap-2 > * { + margin-left: 0 !important; +} + +/* Offline warning styling */ +.alert-warning { + border-left: 4px solid #ffc107; +} + +.alert-info { + border-left: 4px solid #17a2b8; +} + +/* Draft notification responsive */ +@media (max-width: 768px) { + .draft-notification { + flex-direction: column !important; + align-items: flex-start !important; + } + + .draft-notification .d-flex.gap-2 { + margin-top: 0.75rem; + width: 100%; + } + + .draft-notification .d-flex.gap-2 button { + flex: 1; + } +} + +/* Clear Draft button styling */ +.action-buttons .btn-outline-danger { + border-color: #dc3545; + color: #dc3545; +} + +.action-buttons .btn-outline-danger:hover { + background-color: #dc3545; + color: #ffffff; +} + +/* Dark mode adjustments for alerts */ +[data-theme="dark"] .alert-info, +.dark-mode .alert-info { + background-color: rgba(23, 162, 184, 0.2); + border-color: #17a2b8; + color: #ffffff; +} + +[data-theme="dark"] .alert-warning, +.dark-mode .alert-warning { + background-color: rgba(255, 193, 7, 0.2); + border-color: #ffc107; + color: #ffffff; +} + +[data-theme="dark"] .alert-info small, +[data-theme="dark"] .alert-warning small, +.dark-mode .alert-info small, +.dark-mode .alert-warning small { + color: rgba(255, 255, 255, 0.8); +} + +/* Email preview content - constrain images for better readability */ +.email-preview-content img { + max-width: 100% !important; + height: auto !important; + display: block; + margin: 10px 0; + object-fit: contain; +} + +/* Ensure text content after images is visible */ +.email-preview-content { + word-wrap: break-word; + overflow-wrap: break-word; +} + +/* Better spacing for preview content */ +.email-preview-content p { + margin-bottom: 0.5rem; +} + +.email-preview-content > *:last-child { + margin-bottom: 0; +} \ No newline at end of file diff --git a/src/components/EmailManagement/email-sender/utils.js b/src/components/EmailManagement/email-sender/utils.js new file mode 100644 index 0000000000..b01e79ddfe --- /dev/null +++ b/src/components/EmailManagement/email-sender/utils.js @@ -0,0 +1,80 @@ +import { Validators } from './validation'; + +export function parseRecipients(recipientText) { + if (!recipientText || typeof recipientText !== 'string') return []; + return recipientText + .split(/[,;\n]/) + .map(email => email.trim()) + .filter(email => email.length > 0 && email.includes('@')); +} + +export function validateEmail(email) { + if (!email || typeof email !== 'string') return false; + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email.trim()); +} + +export function buildRenderedEmailFromTemplate(templateData, variableValues) { + if (!templateData) return { subject: '', content: '' }; + + let content = templateData.html_content || templateData.content || ''; + let subject = templateData.subject || ''; + + if (Array.isArray(templateData.variables)) { + templateData.variables.forEach(variable => { + if (!variable || !variable.name) return; + // Keep {{variableName}} as is if no value provided, don't replace with [variableName] + if (!variableValues?.[variable.name]) return; + + let value = variableValues[variable.name]; + + // Handle image variables + if (variable.type === 'image') { + const extracted = variableValues?.[`${variable.name}_extracted`]; + if (extracted) value = extracted; + else if (value) { + const candidate = Validators.extractImageFromSource(value); + if (candidate) value = candidate; + } + + // Wrap image URL in tag for preview + if (value) { + value = `${variable.name}`; + } + } + + // Handle video variables + if (variable.type === 'video') { + if (value) { + // Check if it's a YouTube URL + const youtubeId = Validators.extractYouTubeId(value); + if (youtubeId) { + // Create YouTube embed or link + value = ``; + } else { + // For direct video URLs, create a link + value = `
+ + ▶ Watch Video + +
+ ${value} +
`; + } + } + } + + const regex = new RegExp(`{{${variable.name}}}`, 'g'); + content = content.replace(regex, value); + subject = subject.replace(regex, value); + }); + } + + return { subject, content }; +} diff --git a/src/components/EmailManagement/template-management/templates/EmailTemplateEditor.jsx b/src/components/EmailManagement/template-management/templates/EmailTemplateEditor.jsx new file mode 100644 index 0000000000..a2c06f27d5 --- /dev/null +++ b/src/components/EmailManagement/template-management/templates/EmailTemplateEditor.jsx @@ -0,0 +1,1171 @@ +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { connect, useSelector } from 'react-redux'; +import PropTypes from 'prop-types'; +import { toast } from 'react-toastify'; +import DOMPurify from 'dompurify'; +import { + Form, + FormGroup, + Label, + Input, + Button, + Alert, + Modal, + ModalHeader, + ModalBody, + ModalFooter, + ListGroup, + ListGroupItem, + Badge, + Spinner, +} from 'reactstrap'; +import { + FaSave, + FaTimes, + FaPlus, + FaTrash, + FaEye, + FaCode, + FaExclamationTriangle, + FaPencilAlt, + FaSpinner, + FaFileAlt, + FaImage, + FaHashtag, + FaEnvelope, + FaLink, + FaCalendar, + FaAlignLeft, + FaVideo, +} from 'react-icons/fa'; +import { Editor } from '@tinymce/tinymce-react'; +import { getTemplateEditorConfig } from '../../shared'; +import { + createEmailTemplate, + updateEmailTemplate, + fetchEmailTemplate, + clearEmailTemplateError, + clearCurrentTemplate, + previewEmailTemplate, + validateEmailTemplate, +} from '../../../../actions/emailTemplateActions'; +import './EmailTemplateEditor.css'; +import '../../EmailManagementShared.css'; + +const EmailTemplateEditor = ({ + template, + loading, + error, + createEmailTemplate, + updateEmailTemplate, + fetchEmailTemplate, + clearEmailTemplateError, + clearCurrentTemplate, + previewEmailTemplate, + validateEmailTemplate, + onClose, + onSave, + templateId = null, // For editing existing templates +}) => { + const darkMode = useSelector(state => state.theme.darkMode); + const [formData, setFormData] = useState({ + name: '', + subject: '', + html_content: '', + variables: [], + }); + const [validationErrors, setValidationErrors] = useState({}); + const [saving, setSaving] = useState(false); + const [initialLoading, setInitialLoading] = useState(false); + const [apiError, setApiError] = useState(null); + const [showPreviewModal, setShowPreviewModal] = useState(false); + const [showVariableModal, setShowVariableModal] = useState(false); + const [extractedVariables, setExtractedVariables] = useState([]); + const [variableError, setVariableError] = useState(''); + const [newVariable, setNewVariable] = useState({ + name: '', + type: 'text', + }); + const [showTypeSelectionModal, setShowTypeSelectionModal] = useState(false); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [editingVariableIndex, setEditingVariableIndex] = useState(null); + const [retryAttempts, setRetryAttempts] = useState(0); + const [isRetrying, setIsRetrying] = useState(false); + const [previewData, setPreviewData] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + const [previewError, setPreviewError] = useState(null); + // Helper function to get icon for variable type + const getVariableTypeIcon = useCallback(type => { + const iconMap = { + text: , + textarea: , + image: , + number: , + email: , + url: , + date: , + video: , + }; + return iconMap[type] || ; + }, []); + + // Effect to load template data when in edit mode + useEffect(() => { + if (templateId) { + // Always fetch template data for the given templateId + clearCurrentTemplate(); + setInitialLoading(true); + setApiError(null); + // Don't clear form data immediately - let it be populated when template loads + fetchEmailTemplate(templateId).catch(err => { + // eslint-disable-next-line no-console + console.error('Failed to fetch template:', err); + setApiError('Failed to load template. Please try again.'); + setInitialLoading(false); + toast.error('Failed to load template. Please try again.'); + }); + } else { + clearCurrentTemplate(); // Clear any previous template data when creating a new one + setFormData({ + name: '', + subject: '', + html_content: '', + variables: [], + }); + setInitialLoading(false); + setApiError(null); + } + }, [templateId, fetchEmailTemplate, clearCurrentTemplate]); + + // Effect to populate form data when template is fetched + useEffect(() => { + if (template && templateId) { + try { + setFormData({ + name: template.name || '', + subject: template.subject || '', + html_content: template.html_content || '', + variables: Array.isArray(template.variables) ? template.variables : [], + }); + setInitialLoading(false); + setApiError(null); + } catch (err) { + // eslint-disable-next-line no-console + console.error('Error processing template data:', err); + setApiError('Error processing template data. Please try again.'); + setInitialLoading(false); + toast.error('Error processing template data. Please try again.'); + } + } + }, [template, templateId]); + + // Track unsaved changes + useEffect(() => { + if (templateId && template) { + // Compare current form data with original template data + const hasChanges = + formData.name !== (template.name || '') || + formData.subject !== (template.subject || '') || + formData.html_content !== (template.html_content || '') || + JSON.stringify(formData.variables || []) !== JSON.stringify(template.variables || []); + + setHasUnsavedChanges(hasChanges); + } else { + // For new templates, check if any field has content + const hasContent = + (formData.name && formData.name.trim() !== '') || + (formData.subject && formData.subject.trim() !== '') || + (formData.html_content && formData.html_content.trim() !== '') || + (formData.variables && formData.variables.length > 0); + + setHasUnsavedChanges(hasContent); + } + }, [formData, template, templateId]); + + // Handle error state - clear initial loading if there's an error + useEffect(() => { + if (error && initialLoading) { + setInitialLoading(false); + } + }, [error, initialLoading]); + + // Warn user before leaving with unsaved changes + useEffect(() => { + const handleBeforeUnload = e => { + if (hasUnsavedChanges) { + e.preventDefault(); + e.returnValue = 'You have unsaved changes. Are you sure you want to leave?'; + return e.returnValue; + } + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + return () => window.removeEventListener('beforeunload', handleBeforeUnload); + }, [hasUnsavedChanges]); + + const handleInputChange = useCallback((field, value) => { + setFormData(prev => ({ ...prev, [field]: value })); + setValidationErrors(prev => ({ ...prev, [field]: '' })); // Clear error on input change + }, []); + + const handleVariableChange = useCallback((index, field, value) => { + setFormData(prev => { + const newVariables = [...prev.variables]; + newVariables[index] = { ...newVariables[index], [field]: value }; + return { ...prev, variables: newVariables }; + }); + setValidationErrors(prev => ({ ...prev, [`variable_${index}_${field}`]: '' })); + }, []); + + // Extract variables from HTML content and subject + const extractVariables = useCallback(() => { + const htmlContent = formData.html_content || ''; + const subject = formData.subject || ''; + const allContent = `${htmlContent} ${subject}`; + const regex = /{{(\w+)}}/g; + const matches = [...allContent.matchAll(regex)]; + const uniqueVariables = [...new Set(matches.map(match => match[1]))]; + return uniqueVariables.map(name => ({ name, type: 'text' })); + }, [formData.html_content, formData.subject]); + + // Auto-populate variables from HTML content and subject + const handleAutoPopulateVariables = () => { + try { + const extractedVars = extractVariables(); + + if (!extractedVars || extractedVars.length === 0) { + toast.alert( + 'No variables found in the content or subject. Make sure to use {{variableName}} format.', + ); + return; + } + + // Filter out variables that already exist + const existingVariableNames = (formData.variables || []).map(v => v.name); + const newVariables = extractedVars.filter(v => !existingVariableNames.includes(v.name)); + + if (newVariables.length === 0) { + toast.alert('All variables from the content and subject are already defined.'); + return; + } + + // Set extracted variables and show type selection modal + setExtractedVariables(newVariables); + setShowTypeSelectionModal(true); + } catch (err) { + // eslint-disable-next-line no-console + console.error('Error extracting variables:', err); + toast.error('Error extracting variables. Please check your content format and try again.'); + } + }; + + // Handle type selection for extracted variables + const handleTypeSelection = (variableIndex, type) => { + setExtractedVariables(prev => + prev.map((variable, index) => (index === variableIndex ? { ...variable, type } : variable)), + ); + }; + + // Confirm and add variables with selected types + const handleConfirmTypeSelection = () => { + // Create variables with proper structure (name as label, required as false) + const variablesToAdd = extractedVariables.map(variable => ({ + name: variable.name, + type: variable.type, + })); + + setFormData(prev => ({ + ...prev, + variables: [...prev.variables, ...variablesToAdd], + })); + + setShowTypeSelectionModal(false); + setExtractedVariables([]); + + toast.alert( + `Added ${extractedVariables.length} new variable(s): ${extractedVariables + .map(v => v.name) + .join(', ')}`, + ); + }; + + // Cancel type selection + const handleCancelTypeSelection = () => { + setShowTypeSelectionModal(false); + setExtractedVariables([]); + }; + + const validateForm = useCallback(() => { + const errors = {}; + + // Template name validation + if (!formData.name.trim()) { + errors.name = 'Template name is required'; + } else if (formData.name.trim().length < 3) { + errors.name = 'Template name must be at least 3 characters long'; + } else if (formData.name.trim().length > 100) { + errors.name = 'Template name must be less than 100 characters'; + } + + // Subject validation + if (!formData.subject.trim()) { + errors.subject = 'Subject is required'; + } else if (formData.subject.trim().length > 200) { + errors.subject = 'Subject must be less than 200 characters'; + } + + // HTML content validation + if (!formData.html_content.trim()) { + errors.html_content = 'HTML content is required'; + } else if (formData.html_content.trim().length < 10) { + errors.html_content = 'HTML content must be at least 10 characters long'; + } + + // Validate variables + const variableNames = new Set(); + formData.variables.forEach((variable, index) => { + if (!variable.name.trim()) { + errors[`variable_${index}_name`] = 'Variable name is required'; + } else if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(variable.name)) { + errors[`variable_${index}_name`] = + 'Variable name must start with a letter and contain only letters, numbers, and underscores'; + } else if (variableNames.has(variable.name)) { + errors[`variable_${index}_name`] = 'Variable name must be unique'; + } else { + variableNames.add(variable.name); + } + }); + + // Check if all variables used in content and subject are defined + const allContent = `${formData.html_content || ''} ${formData.subject || ''}`; + const usedVariables = [ + ...new Set([...allContent.matchAll(/{{(\w+)}}/g)].map(match => match[1])), + ]; + const definedVariableNames = (formData.variables || []).map(v => v.name); + const undefinedVariables = usedVariables.filter(v => !definedVariableNames.includes(v)); + const unusedVariables = definedVariableNames.filter(v => !usedVariables.includes(v)); + + if (undefinedVariables.length > 0) { + errors.undefined_variables = `The following variables are used but not defined: ${undefinedVariables.join( + ', ', + )}. Please define them or remove them from the content.`; + } + + if (unusedVariables.length > 0) { + errors.unused_variables = `The following variables are defined but not used: ${unusedVariables.join( + ', ', + )}. Consider removing them or using them in your content.`; + } + + setValidationErrors(errors); + return Object.keys(errors).length === 0; + }, [formData]); + + const handleSave = useCallback(async () => { + if (!validateForm()) { + return; + } + + // Validate template structure with backend if template is saved + if (templateId) { + try { + const validation = await validateEmailTemplate(templateId); + if (!validation.isValid && validation.errors && validation.errors.length > 0) { + toast.warning(`Template validation warnings: ${validation.errors.join(', ')}`, { + position: 'top-right', + autoClose: 5000, + }); + // Continue with save despite warnings (user can decide) + } + } catch (error) { + // Validation error is not blocking, but log it + // eslint-disable-next-line no-console + console.warn('Template validation error:', error); + } + } + + setSaving(true); + try { + if (templateId) { + await updateEmailTemplate(templateId, formData); + toast.success('Template updated successfully!', { + position: 'top-right', + autoClose: 3000, + hideProgressBar: false, + closeOnClick: true, + pauseOnHover: true, + draggable: true, + }); + } else { + await createEmailTemplate(formData); + toast.success('Template created successfully!', { + position: 'top-right', + autoClose: 3000, + hideProgressBar: false, + closeOnClick: true, + pauseOnHover: true, + draggable: true, + }); + } + setHasUnsavedChanges(false); // Clear unsaved changes indicator + if (onSave) { + onSave(formData); // Pass saved data back to parent if needed + } + } catch (err) { + toast.error('Failed to save template. Please try again.', { + position: 'top-right', + autoClose: 5000, + hideProgressBar: false, + closeOnClick: true, + pauseOnHover: true, + draggable: true, + }); + } finally { + setSaving(false); + } + }, [ + validateForm, + templateId, + formData, + validateEmailTemplate, + updateEmailTemplate, + createEmailTemplate, + onSave, + ]); + + const handleOpenVariableModal = useCallback(() => { + setNewVariable({ name: '', type: 'text' }); + setVariableError(''); + setShowVariableModal(true); + }, []); + + const handleEditVariable = useCallback( + index => { + const variable = formData.variables[index]; + setNewVariable({ ...variable }); + setEditingVariableIndex(index); + setVariableError(''); + setShowVariableModal(true); + }, + [formData.variables], + ); + + const handleAddVariable = useCallback(() => { + if (!newVariable.name.trim()) { + setVariableError('Variable name is required'); + return; + } + + // Validate variable name format + if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(newVariable.name)) { + setVariableError( + 'Variable name must start with a letter and contain only letters, numbers, and underscores', + ); + return; + } + + // Check if variable name already exists (excluding current variable when editing) + const existingVariable = formData.variables.find( + (v, index) => v.name === newVariable.name && index !== editingVariableIndex, + ); + if (existingVariable) { + setVariableError('A variable with this name already exists'); + return; + } + + // Create variable with name as label and required as false + const variableToAdd = { + name: newVariable.name, + type: newVariable.type, + }; + + if (editingVariableIndex !== null) { + // Update existing variable + setFormData(prev => ({ + ...prev, + variables: prev.variables.map((v, index) => + index === editingVariableIndex ? { ...variableToAdd } : v, + ), + })); + } else { + // Add new variable + setFormData(prev => ({ + ...prev, + variables: [...prev.variables, { ...variableToAdd }], + })); + } + + setShowVariableModal(false); + setNewVariable({ name: '', type: 'text' }); + setEditingVariableIndex(null); + setVariableError(''); + }, [formData.variables, newVariable, editingVariableIndex]); + + const handleDeleteVariable = useCallback(index => { + setFormData(prev => ({ + ...prev, + variables: prev.variables.filter((_, i) => i !== index), + })); + }, []); + + const resetAllStates = useCallback(() => { + setFormData({ + name: '', + subject: '', + html_content: '', + variables: [], + }); + setValidationErrors({}); + setSaving(false); + setShowPreviewModal(false); + setShowVariableModal(false); + setExtractedVariables([]); + setVariableError(''); + setNewVariable({ name: '', type: 'text' }); + setShowTypeSelectionModal(false); + }, []); + + const clearReduxState = useCallback(() => { + clearEmailTemplateError(); + clearCurrentTemplate(); + }, [clearEmailTemplateError, clearCurrentTemplate]); + + // Manual retry function for template loading + const handleManualRetry = useCallback(async () => { + if (isRetrying || !templateId) return; + + setIsRetrying(true); + setRetryAttempts(prev => prev + 1); + setApiError(null); + + try { + await fetchEmailTemplate(templateId); + setRetryAttempts(0); // Reset on success + toast.success('Template loaded successfully'); + } catch (err) { + setApiError('Failed to load template. Please try again.'); + toast.error(`Retry failed: ${err.message || 'Unknown error'}`); + } finally { + setIsRetrying(false); + } + }, [fetchEmailTemplate, templateId, isRetrying]); + + // Client-side preview fallback (for unsaved templates) + const getClientSidePreview = useMemo(() => { + if (!formData.html_content) return ''; + + let content = formData.html_content; + if (formData.variables && Array.isArray(formData.variables)) { + formData.variables.forEach(variable => { + if (variable && variable.name) { + const placeholder = `[${variable.name}]`; + const regex = new RegExp(`{{${variable.name}}}`, 'g'); + content = content.replace(regex, placeholder); + } + }); + } + return content; + }, [formData.html_content, formData.variables]); + + // Handle preview with backend API if template is saved, otherwise use client-side + const handlePreview = useCallback(async () => { + // If template is not saved yet, use client-side preview + if (!templateId) { + setPreviewData({ + subject: formData.subject || '', + htmlContent: getClientSidePreview, + }); + setShowPreviewModal(true); + return; + } + + // For saved templates, use backend API with placeholder values + setPreviewLoading(true); + setPreviewError(null); + try { + // Build variable values object with placeholder values for preview + const variableValues = {}; + if (formData.variables && Array.isArray(formData.variables)) { + formData.variables.forEach(variable => { + if (variable && variable.name) { + // Use placeholder values based on variable type + if (variable.type === 'image') { + variableValues[variable.name] = 'https://example.com/placeholder-image.jpg'; + } else if (variable.type === 'video') { + variableValues[variable.name] = 'https://example.com/placeholder-video.mp4'; + } else if (variable.type === 'number') { + variableValues[variable.name] = '123'; + } else if (variable.type === 'email') { + variableValues[variable.name] = 'example@email.com'; + } else { + variableValues[variable.name] = `[${variable.name}]`; + } + } + }); + } + + const preview = await previewEmailTemplate(templateId, variableValues); + setPreviewData(preview); + setShowPreviewModal(true); + } catch (error) { + // If preview fails, show error but still allow viewing client-side preview + setPreviewError(error.message || 'Failed to preview template'); + toast.warning('Preview failed, showing basic preview', { + position: 'top-right', + autoClose: 3000, + }); + // Fallback to client-side preview + setPreviewData({ + subject: formData.subject || '', + htmlContent: getClientSidePreview, + }); + setShowPreviewModal(true); + } finally { + setPreviewLoading(false); + } + }, [templateId, formData, previewEmailTemplate, getClientSidePreview]); + + const tinyMCEConfig = useMemo(() => getTemplateEditorConfig(darkMode, formData), [ + darkMode, + formData, + ]); + + if (initialLoading && templateId) { + return ( +
+
+ +
Loading template...
+
+
+ ); + } + + // Show error state if template loading failed + if (apiError && templateId) { + return ( +
+
+
+
Error Loading Template
+

{apiError}

+ {retryAttempts > 0 && ( + Retry attempt: {retryAttempts} + )} + +
+
+
+ ); + } + + return ( +
+ {/* Compact Header */} +
+
+ {/* Template Name field */} +
+ + handleInputChange('name', e.target.value)} + invalid={!!validationErrors.name} + placeholder="Template Name *" + aria-describedby={validationErrors.name ? 'template-name-error' : undefined} + /> + {validationErrors.name && ( +
+ {validationErrors.name} +
+ )} +
+
+ + {/* Unsaved changes indicator and Action buttons */} +
+ {/* Unsaved changes indicator */} + {hasUnsavedChanges && ( +
+ + + Unsaved changes + +
+ )} + + {/* Action buttons */} +
+
+ + + +
+
+
+
+
+ + {/* Error Alert */} + {error && ( + + +
+ Error saving template +
+ {error} +
+
+ )} + + {/* Undefined Variables - keep this error window at the top */} + {validationErrors.undefined_variables && ( + +
+ +
+ Undefined Variables: +
{validationErrors.undefined_variables}
+
+
+
+ )} + + {/* Unused Variables - keep this warning window at the top */} + {validationErrors.unused_variables && ( + +
+ +
+ Unused Variables: +
{validationErrors.unused_variables}
+
+
+
+ )} + + {/* Main Content */} +
+ {/* Basic Information Section */} +
+ + + handleInputChange('subject', e.target.value)} + invalid={!!validationErrors.subject} + placeholder="Enter email subject" + aria-describedby={validationErrors.subject ? 'template-subject-error' : undefined} + /> + {validationErrors.subject && ( +
+ {validationErrors.subject} +
+ )} +
+
+ + {/* Variables Section - Compact & Responsive */} +
+
+
+
+ + Template Variables + {formData.variables.length > 0 && ( + + {formData.variables.length} + + )} +
+
+ +
+
+
+ + {formData.variables.length === 0 ? ( +
+
+

+ Use the "Auto Extract" button above to automatically + extract variables from your HTML content. +

+
+
+ ) : ( +
+ {formData.variables.map((variable, index) => ( +
+ {`{{${variable.name}}}`} + + {getVariableTypeIcon(variable.type)} + {variable.type} + + + +
+ ))} +
+ )} +
+ + {/* HTML Content Section */} +
+ + + handleInputChange('html_content', content)} + init={tinyMCEConfig} + /> + {validationErrors.html_content && ( +
{validationErrors.html_content}
+ )} +
+
+
+ + {/* Preview Modal - FIXED XSS VULNERABILITY */} + setShowPreviewModal(false)} size="lg" centered> + setShowPreviewModal(false)}>Email Preview + + {previewError && ( + + + {previewError} + {!templateId && ' (Using client-side preview for unsaved template)'} + + )} + {previewData ? ( +
+
+ Subject:{' '} + {previewData.subject || formData.subject || '(No subject)'} +
+
+ Variables: {formData.variables.length} + {formData.variables.length > 0 && ( +
+ {formData.variables.map((variable, index) => ( + + {variable.name} ({variable.type}) + + ))} +
+ )} +
+
+ Content Preview: +
+
+
+ ) : ( +
+ + Loading preview... +
+ )} + + + + + + + {/* Add Variable Modal */} + { + setShowVariableModal(false); + setEditingVariableIndex(null); + setNewVariable({ name: '', type: 'text' }); + setVariableError(''); + }} + centered + > + { + setShowVariableModal(false); + setEditingVariableIndex(null); + setNewVariable({ name: '', type: 'text' }); + setVariableError(''); + }} + > + {editingVariableIndex !== null ? 'Edit Variable' : 'Add New Variable'} + + + {variableError && {variableError}} + + + setNewVariable(prev => ({ ...prev, name: e.target.value }))} + placeholder="Enter variable name (e.g., firstName)" + /> + + + + setNewVariable(prev => ({ ...prev, type: e.target.value }))} + > + + + + + + + + + + + + + + + + + + {/* Type Selection Modal for Auto-extracted Variables */} + + Select Variable Types + +

Please select a type for each new variable:

+
+ + {extractedVariables.map((variable, index) => ( + + {variable.name} + handleTypeSelection(index, e.target.value)} + style={{ width: '150px' }} + > + + + + + + + + + + + ))} + +
+
+ + + + +
+
+ ); +}; + +const mapStateToProps = state => ({ + template: state.emailTemplates.currentTemplate, + loading: state.emailTemplates.loading, + error: state.emailTemplates.error, +}); + +const mapDispatchToProps = { + createEmailTemplate, + updateEmailTemplate, + fetchEmailTemplate, + clearEmailTemplateError, + clearCurrentTemplate, + previewEmailTemplate, + validateEmailTemplate, +}; + +// PropTypes for type checking +EmailTemplateEditor.propTypes = { + template: PropTypes.object, + loading: PropTypes.bool, + error: PropTypes.string, + createEmailTemplate: PropTypes.func.isRequired, + updateEmailTemplate: PropTypes.func.isRequired, + fetchEmailTemplate: PropTypes.func.isRequired, + clearEmailTemplateError: PropTypes.func.isRequired, + clearCurrentTemplate: PropTypes.func.isRequired, + previewEmailTemplate: PropTypes.func, + validateEmailTemplate: PropTypes.func, + onClose: PropTypes.func, + onSave: PropTypes.func, + templateId: PropTypes.string, +}; + +// Default props +EmailTemplateEditor.defaultProps = { + template: null, + loading: false, + error: null, + onClose: null, + onSave: null, + templateId: null, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(EmailTemplateEditor);