/** * Content Plan Plugin - Context for Global State Management */ import { createContext, useContext, useState, useEffect, useCallback, useRef, } from '@wordpress/element'; import type React from 'react'; import { __ } from '@wordpress/i18n'; import apiFetch from '@wordpress/api-fetch'; import { ContentPlanContextState, ContentPlan, ContentPlanItem, Post, Settings, FieldConfigs, Strings, Data, User, UsageData, PresetResponse, ApiResponse, PostCreationFormData, ContentPlanGenerationFormData, Task, Log, } from '../types'; // Create the context const ContentPlanContext = createContext( undefined ); // Custom hook to use the context export const useContentPlan = (): ContentPlanContextState => { const context = useContext(ContentPlanContext); if (!context) { throw new Error( 'useContentPlan must be used within a ContentPlanProvider' ); } return context; }; // Context Provider Component interface ContentPlanProviderProps { children: React.ReactNode; } export const ContentPlanProvider: React.FC = ({ children, }) => { const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [contentPlan, setContentPlan] = useState({ contentPlanItems: [], }); const [posts, setPosts] = useState([]); const [settings, setSettings] = useState({}); const [fieldConfigs, setFieldConfigs] = useState({}); const [strings, setStrings] = useState({}); const [data, setData] = useState({}); const [user, setUser] = useState({ name: '', role: '' }); const [usage, setUsage] = useState(null); const [usageError, setUsageError] = useState(null); const [backendConfigured, setBackendConfigured] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarType, setSidebarType] = useState(null); // 'post-creation', 'settings', etc. const [generationModalOpen, setGenerationModalOpen] = useState(false); const [tasks, setTasks] = useState([]); const [tasksLoading, setTasksLoading] = useState(false); const [tasksError, setTasksError] = useState(null); const [logs, setLogs] = useState([]); const [logsLoading, setLogsLoading] = useState(false); const [logsError, setLogsError] = useState(null); const [currentTaskId, setCurrentTaskId] = useState(null); const [shouldPollTasks, setShouldPollTasks] = useState(false); const [taskFilterId, setTaskFilterId] = useState(null); // Task ID to filter content plan items by const [isNewWebsite, setIsNewWebsite] = useState(false); const [opted, setOpted] = useState(undefined); const [optinUrl, setOptinUrl] = useState(undefined); const [timezone, setTimezone] = useState<{ string: string; offset: number; } | null>(null); // Track if preset has been loaded (prevents tasks from being fetched before preset) const presetLoadedRef = useRef(false); // Store previous tasks to detect status changes const previousTasksRef = useRef([]); // Track when failed tasks without results were first seen (for timeout) const failedTasksStartTimeRef = useRef>(new Map()); // Track polling interval to prevent multiple intervals const pollingIntervalRef = useRef(null); // Track if fetchTasks is currently in progress to prevent concurrent calls const fetchTasksInProgressRef = useRef(false); // Track which completed task IDs we've already refreshed usage for const refreshedUsageForTasksRef = useRef>(new Set()); // Actions for content plan data const updateContentPlan = (newData: Partial): void => { setContentPlan(prevData => ({ ...prevData, ...newData })); }; const addContentPlanItem = (item: ContentPlanItem): void => { setContentPlan(prevData => ({ ...prevData, contentPlanItems: [...prevData.contentPlanItems, item], })); }; const updateContentPlanItemLocal = ( itemId: number, updatedItem: Partial ): void => { setContentPlan(prevData => ({ ...prevData, contentPlanItems: prevData.contentPlanItems.map(item => item.id === itemId ? { ...item, ...updatedItem } : item ), })); }; const deleteContentPlanItemLocal = (itemId: number): void => { setContentPlan(prevData => ({ ...prevData, contentPlanItems: prevData.contentPlanItems.filter( item => item.id !== itemId ), })); }; const updateSettings = (newSettings: Partial): void => { setSettings(prevSettings => { const updated = { ...prevSettings, ...newSettings }; return updated; }); }; const updateFieldConfigs = (newConfigs: Partial): void => { setFieldConfigs(prevConfigs => { const updated = { ...prevConfigs }; Object.keys(newConfigs).forEach(key => { if (newConfigs[key]) { updated[key] = newConfigs[key]!; } }); return updated; }); }; const updateStrings = (newStrings: Partial): void => { setStrings(prevStrings => { const updated = { ...prevStrings }; Object.keys(newStrings).forEach(key => { if (newStrings[key]) { updated[key] = newStrings[key]!; } }); return updated; }); }; const clearError = (): void => { setError(null); }; const setLoadingState = (isLoading: boolean): void => { setLoading(isLoading); }; const setSavingState = (isSaving: boolean): void => { setSaving(isSaving); }; const setErrorState = (errorMessage: string | null): void => { setError(errorMessage); }; // Sidebar control functions const openSidebar = (type: string): void => { setSidebarType(type); setSidebarOpen(true); }; const closeSidebar = (): void => { setSidebarOpen(false); setSidebarType(null); }; // Generation modal control functions const openGenerationModal = (): void => { setGenerationModalOpen(true); }; const closeGenerationModal = (): void => { setGenerationModalOpen(false); }; const setCurrentTaskIdState = (taskId: string | null): void => { setCurrentTaskId(taskId); }; // Task filter functions const setTaskFilter = (taskId: number | null): void => { setTaskFilterId(taskId); }; const clearTaskFilter = (): void => { setTaskFilterId(null); }; // Helper function to get strings with fallback const getString = ( category: string, key: string, fallback: string = '' ): string => { return strings[category]?.[key] || fallback; }; // Helper function to get the latest date from content plan items const getLatestContentPlanDate = (): string | null => { if ( !contentPlan.contentPlanItems || contentPlan.contentPlanItems.length === 0 ) { return null; } const dates = contentPlan.contentPlanItems .map(item => item.scheduled_on) .filter(date => date !== null && date !== undefined) as string[]; if (dates.length === 0) { return null; } // Compare dates as strings (YYYY-MM-DD format sorts correctly) // This avoids timezone conversion issues const latestDate = dates.reduce((latest, current) => { return current > latest ? current : latest; }); // Return just the date part (YYYY-MM-DD) without time const dateOnly = latestDate.split(' ')[0]; return dateOnly; }; // Fetch preset data (settings, field configs, strings) const fetchPresetData = useCallback(async (): Promise => { setLoading(true); setError(null); try { // Get locale from localized script data const locale = (window as any).cpepai_data?.locale || ''; const presetPath = locale ? `/cpepai/v1/preset?locale=${encodeURIComponent(locale)}` : '/cpepai/v1/preset'; const preset: PresetResponse = await apiFetch({ path: presetPath, method: 'GET', }); if (preset.settings) { // Merge with existing settings to preserve optimistic updates // Preserve onboarding_completed if it was set optimistically setSettings(prevSettings => { const merged = { ...prevSettings, ...preset.settings, }; // Preserve onboarding_completed from previous settings if it was true // (optimistic update should not be overwritten by stale server data) if ( prevSettings.onboarding_completed === true && preset.settings && (!preset.settings.onboarding_completed || preset.settings.onboarding_completed !== true) ) { merged.onboarding_completed = true; } return merged; }); } if (preset.field_configs) { setFieldConfigs(preset.field_configs); } if (preset.strings) { setStrings(preset.strings); } if (preset.backend_configured !== undefined) { setBackendConfigured(preset.backend_configured); } if (preset.usage) { setUsage(preset.usage); setUsageError(null); // Clear error if usage is successfully fetched } else { // Set usage error if backend is configured but fetch failed // Otherwise, clear error (backend not configured is not an error) if (preset.backend_configured && preset.usage_error) { setUsageError(preset.usage_error); } else { setUsageError(null); } } if (preset.is_new_website !== undefined) { setIsNewWebsite(preset.is_new_website); } if (preset.opted !== undefined) { setOpted(preset.opted); } if (preset.optin_url !== undefined) { setOptinUrl(preset.optin_url); } if (preset.timezone) { setTimezone(preset.timezone); } // Mark preset as loaded presetLoadedRef.current = true; } catch (err: any) { setError(err.message); // Only set usage error if the entire preset fetch failed // (which would prevent usage from being fetched) setUsageError(err.message); console.error('Error fetching preset data:', err); // Still mark as loaded even on error to allow app to continue presetLoadedRef.current = true; } finally { setLoading(false); } }, []); // Refresh usage data only (lightweight function for frequent updates) const refreshUsage = useCallback(async (): Promise => { try { // Get locale from localized script data const locale = (window as any).cpepai_data?.locale || ''; // Add cache-busting parameter and locale to ensure fresh data const cacheBuster = `_=${Date.now()}`; const localeParam = locale ? `&locale=${encodeURIComponent(locale)}` : ''; const preset: PresetResponse = await apiFetch({ path: `/cpepai/v1/preset?${cacheBuster}${localeParam}`, method: 'GET', }); if (preset.backend_configured !== undefined) { setBackendConfigured(preset.backend_configured); } if (preset.usage) { setUsage(preset.usage); setUsageError(null); // Clear error if usage is successfully fetched } else { // Set usage error if backend is configured but fetch failed // Otherwise, clear error (backend not configured is not an error) if (preset.backend_configured && preset.usage_error) { setUsageError(preset.usage_error); } else { setUsageError(null); } } if (preset.opted !== undefined) { setOpted(preset.opted); } if (preset.optin_url !== undefined) { setOptinUrl(preset.optin_url); } } catch (err: any) { // Set usage error to indicate fetch failed setUsageError(err.message || 'Failed to fetch usage data'); console.error('Error refreshing usage data:', err); } }, []); // Save settings const saveSettings = useCallback( async (settingsData: Settings): Promise => { setSaving(true); setError(null); try { const result = await apiFetch({ path: '/cpepai/v1/settings', method: 'POST', data: { settings: settingsData }, }); if ((result as any).settings) { // Merge with existing settings to preserve optimistic updates // Preserve onboarding_completed if it was set optimistically setSettings(prevSettings => { const merged = { ...prevSettings, ...(result as any).settings, }; // Preserve onboarding_completed from previous settings if it was true // (optimistic update should not be overwritten by stale server data) if ( prevSettings.onboarding_completed === true && (!(result as any).settings.onboarding_completed || (result as any).settings .onboarding_completed !== true) ) { merged.onboarding_completed = true; } return merged; }); } return result; } catch (err: any) { setError(err.message); console.error('Error saving settings:', err); throw err; } finally { setSaving(false); } }, [] ); // Refresh all data const refreshData = useCallback((): void => { fetchPresetData(); }, [fetchPresetData]); // API Functions for Content Plan Items const fetchContentPlanItems = useCallback( async (planId: number = 1): Promise => { setLoading(true); setError(null); try { const items: ContentPlanItem[] = await apiFetch({ path: `/cpepai/v1/items`, method: 'GET', }); // Ensure all items have a status field and post field const itemsWithStatus = (items || []).map(item => ({ ...item, status: item.status || 'planned', post: item.post || null, })); setContentPlan(prevData => ({ ...prevData, contentPlanItems: itemsWithStatus, })); } catch (err: any) { console.error('Error fetching content plan items:', err); setError(err.message); } finally { setLoading(false); } }, [] ); // Recalculate scheduled dates for future items const recalculateSchedule = useCallback(async (): Promise => { setSaving(true); setError(null); try { const result = await apiFetch({ path: '/cpepai/v1/items/recalculate-schedule', method: 'POST', }); // Refresh content plan items to show updated dates await fetchContentPlanItems(1); } catch (err: any) { setError(err.message); console.error('Error recalculating schedule:', err); throw err; } finally { setSaving(false); } }, [fetchContentPlanItems]); const createContentPlanItem = useCallback( async ( itemData: Partial, planId: number = 1 ): Promise => { setSaving(true); setError(null); // Create a temporary ID for optimistic update const tempId = -Date.now(); // Negative to avoid conflicts with real IDs // Create optimistic item const optimisticItem: ContentPlanItem = { id: tempId, title: itemData.title || '', structure: itemData.structure || '[]', keywords: itemData.keywords || '', status: itemData.status || 'planned', scheduled_on: itemData.scheduled_on || null, post: itemData.post || null, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }; // Store previous state for rollback const previousItems = contentPlan.contentPlanItems; // Add optimistic item to state setContentPlan(prev => ({ ...prev, contentPlanItems: [...prev.contentPlanItems, optimisticItem], })); try { const result: ApiResponse = await apiFetch({ path: `/cpepai/v1/items`, method: 'POST', data: itemData, }); if (!result.success) { throw new Error( result.message || 'Failed to create content plan item' ); } // Update the temporary item with the real one from the server if (result.data) { setContentPlan(prev => ({ ...prev, contentPlanItems: prev.contentPlanItems.map(item => item.id === tempId ? (result.data as ContentPlanItem) : item ), })); } } catch (err: any) { // Rollback on error setContentPlan(prev => ({ ...prev, contentPlanItems: previousItems, })); setError(err.message); console.error('Error creating content plan item:', err); throw err; } finally { setSaving(false); } }, [contentPlan.contentPlanItems] ); const updateContentPlanItemAPI = useCallback( async ( id: number, itemData: Partial, planId: number = 1 ): Promise => { setSaving(true); setError(null); // Store the previous state for rollback if needed const previousItems = contentPlan.contentPlanItems; // Find existing item const existingItem = previousItems.find(item => item.id === id); if (!existingItem) { throw new Error('Item not found'); } // Optimistically update the local state const updatedItem: ContentPlanItem = { ...existingItem, ...itemData, id, title: itemData.title || existingItem.title, structure: itemData.structure || existingItem.structure, keywords: itemData.keywords || existingItem.keywords, status: itemData.status || existingItem.status, scheduled_on: itemData.scheduled_on !== undefined ? itemData.scheduled_on : existingItem.scheduled_on, post: itemData.post !== undefined ? itemData.post : existingItem.post, created_at: existingItem.created_at, updated_at: new Date().toISOString(), }; setContentPlan(prev => ({ ...prev, contentPlanItems: prev.contentPlanItems.map(item => item.id === id ? updatedItem : item ), })); try { const result: ApiResponse = await apiFetch({ path: `/cpepai/v1/items/${id}`, method: 'PUT', data: itemData, }); if (!result.success) { throw new Error( result.message || 'Failed to update content plan item' ); } } catch (err: any) { // Rollback to previous state on error setContentPlan(prev => ({ ...prev, contentPlanItems: previousItems, })); setError(err.message); console.error('Error updating content plan item:', err); throw err; } finally { setSaving(false); } }, [contentPlan.contentPlanItems] ); const deleteContentPlanItemAPI = useCallback( async (id: number, planId: number = 1): Promise => { setSaving(true); setError(null); // Store previous state for rollback if needed const previousItems = contentPlan.contentPlanItems; const itemToDelete = previousItems.find(item => item.id === id); // Optimistically remove the item from local state deleteContentPlanItemLocal(id); try { const result: ApiResponse = await apiFetch({ path: `/cpepai/v1/items/${id}`, method: 'DELETE', }); if (!result.success) { throw new Error( result.message || 'Failed to delete content plan item' ); } } catch (err: any) { // Rollback to previous state on error setContentPlan(prev => ({ ...prev, contentPlanItems: previousItems, })); setError(err.message); console.error('Error deleting content plan item:', err); throw err; } finally { setSaving(false); } }, [contentPlan.contentPlanItems, deleteContentPlanItemLocal] ); // Bulk delete multiple content plan items const bulkDeleteContentPlanItems = useCallback( async (ids: number[], planId: number = 1): Promise => { setSaving(true); setError(null); // Store previous state for rollback if needed const previousItems = contentPlan.contentPlanItems; // Optimistically remove the items from local state setContentPlan(prev => ({ ...prev, contentPlanItems: prev.contentPlanItems.filter( item => !ids.includes(item.id) ), })); try { const result: ApiResponse = await apiFetch({ path: `/cpepai/v1/items/bulk-delete`, method: 'POST', data: { ids }, }); if (!result.success) { throw new Error( result.message || 'Failed to delete content plan items' ); } } catch (err: any) { // Rollback to previous state on error setContentPlan(prev => ({ ...prev, contentPlanItems: previousItems, })); setError(err.message); console.error('Error bulk deleting content plan items:', err); throw err; } finally { setSaving(false); } }, [contentPlan.contentPlanItems] ); // Create multiple content plan items with date scheduling const createMultipleContentPlanItems = useCallback( async ( items: ContentPlanItem[], publishInterval: number, planId: number = 1, latestScheduledDate?: string | null ): Promise => { setSaving(true); setError(null); // Calculate dates for approved items based on the latest scheduled date from backend let startDate: Date; if ( latestScheduledDate && typeof latestScheduledDate === 'string' && latestScheduledDate.length > 0 ) { // Parse the date from backend (might be "YYYY-MM-DD" or "YYYY-MM-DD HH:MM:SS") const dateOnly = latestScheduledDate.split(' ')[0]; const [year, month, day] = dateOnly.split('-').map(Number); startDate = new Date(year, month - 1, day); } else { // Fallback to today if no date provided const today = new Date(); startDate = new Date( today.getFullYear(), today.getMonth(), today.getDate() ); } // Calculate scheduled dates for each approved item const itemsWithScheduledDates = items.map((item, index) => { const year = startDate.getFullYear(); const month = startDate.getMonth(); const day = startDate.getDate(); const scheduledDate = new Date( year, month, day + (index + 1) * publishInterval ); // Format date manually to avoid timezone conversion issues with toISOString() // Include time as 10:00:00 AM for publishing const scheduledDateStr = `${scheduledDate.getFullYear()}-${String( scheduledDate.getMonth() + 1 ).padStart(2, '0')}-${String(scheduledDate.getDate()).padStart( 2, '0' )} 10:00:00`; return { ...item, scheduled_on: scheduledDateStr, status: 'planned' as const, }; }); // Store previous state for rollback if needed const previousItems = contentPlan.contentPlanItems; // Store the temporary IDs to track which items to replace const tempIds = itemsWithScheduledDates.map(item => item.id); // Optimistically add items to local state setContentPlan(prev => ({ ...prev, contentPlanItems: [ ...prev.contentPlanItems, ...itemsWithScheduledDates, ], })); try { const result: ApiResponse = await apiFetch({ path: `/cpepai/v1/items/bulk`, method: 'POST', data: { items: itemsWithScheduledDates.map(item => ({ title: item.title, structure: item.structure, keywords: item.keywords, scheduled_on: item.scheduled_on, status: item.status, })), }, }); if (!result.success) { throw new Error( result.message || 'Failed to create content plan items' ); } // Replace optimistic items with actual created items from the server if (result.data && Array.isArray(result.data)) { setContentPlan(prev => ({ ...prev, contentPlanItems: [ // Keep items that are not temporary (not in tempIds) ...prev.contentPlanItems.filter( item => !tempIds.includes(item.id) ), // Add the real items from the server ...result.data, ], })); } // Refresh usage data after creating multiple items refreshUsage().catch(err => { console.error( 'Error refreshing usage after creating multiple items:', err ); }); } catch (err: any) { // Rollback to previous state on error setContentPlan(prev => ({ ...prev, contentPlanItems: previousItems, })); setError(err.message); console.error( 'Error creating multiple content plan items:', err ); throw err; } finally { setSaving(false); } }, [contentPlan.contentPlanItems, refreshUsage] ); // Create Post API Function const createPost = useCallback( async (postData: PostCreationFormData): Promise => { setSaving(true); setError(null); try { const result: ApiResponse = await apiFetch({ path: '/cpepai/v1/posts', method: 'POST', data: { title: postData.title.trim(), content: postData.content.trim(), excerpt: postData.excerpt.trim(), status: postData.status, category: postData.category.trim(), tags: postData.tags .split(',') .map(tag => tag.trim()) .filter(tag => tag), meta_description: postData.meta_description.trim(), keywords: postData.keywords.trim(), scheduled_on: postData.scheduled_on, }, }); if (!result.success) { throw new Error(result.message || 'Failed to create post'); } } catch (err: any) { setError(err.message); console.error('Error creating post:', err); throw err; } finally { setSaving(false); } }, [] ); // Generate Content Plan API Function const generateContentPlan = useCallback( async ( formData: ContentPlanGenerationFormData ): Promise<{ items: ContentPlanItem[]; devInfo?: any; latestScheduledDate?: string; taskId?: string; }> => { setSaving(true); setError(null); try { let result: ApiResponse; try { result = await apiFetch({ path: '/cpepai/v1/generate-plan', method: 'POST', data: formData, }); } catch (fetchError: any) { // WordPress apiFetch might throw errors with different structures console.error('Context: apiFetch threw error:', fetchError); console.error( 'Context: fetchError type:', typeof fetchError ); console.error( 'Context: fetchError.data:', fetchError?.data ); console.error( 'Context: fetchError.response:', fetchError?.response ); console.error( 'Context: fetchError.message:', fetchError?.message ); // Extract error from various possible locations in the thrown error let errorMsg = 'Failed to generate content plan'; if (fetchError && typeof fetchError === 'object') { // WordPress apiFetch error structure: error.data contains the response body // Check multiple possible locations const errorData = fetchError.data || fetchError.response?.data || fetchError.response || fetchError; console.error( 'Context: Extracted errorData:', errorData ); // Check for error field (highest priority) if (errorData.error) { errorMsg = errorData.error; console.error( 'Context: Found error in errorData.error:', errorMsg ); } // Check for message field else if (errorData.message) { errorMsg = errorData.message; console.error( 'Context: Found error in errorData.message:', errorMsg ); } // Check for validation_errors else if ( errorData.validation_errors && typeof errorData.validation_errors === 'object' ) { const validationErrors = errorData.validation_errors; const firstError = Object.values(validationErrors)[0]; errorMsg = typeof firstError === 'string' ? firstError : errorMsg; console.error( 'Context: Found error in validation_errors:', errorMsg ); } // Check fetchError.message as fallback else if (fetchError.message) { errorMsg = fetchError.message; console.error( 'Context: Found error in fetchError.message:', errorMsg ); } } else if (typeof fetchError === 'string') { errorMsg = fetchError; console.error( 'Context: fetchError is string:', errorMsg ); } console.error( 'Context: Final error message to throw:', errorMsg ); throw new Error(errorMsg); } // IMMEDIATE check: If the response itself looks like an error object, throw immediately const resultAny = result as any; if (resultAny && typeof resultAny === 'object') { // Check if this IS an error response (has error field and status_code) if (resultAny.error && resultAny.status_code >= 400) { console.error( 'Context: IMMEDIATE ERROR DETECTION - Response is error object:', resultAny ); let errorMsg = resultAny.error || (resultAny.validation_errors && typeof resultAny.validation_errors === 'object' ? Object.values(resultAny.validation_errors)[0] : null) || 'Failed to generate content plan'; console.error( 'Context: Throwing immediate error:', errorMsg ); throw new Error( typeof errorMsg === 'string' ? errorMsg : 'Failed to generate content plan' ); } } // Check for error responses - API might return error object even with 200 status // Check for error field, status_code indicating error (4xx, 5xx), or success === false // (resultAny already declared above) const hasError = resultAny.error || (resultAny.status_code && resultAny.status_code >= 400) || resultAny.success === false; if (hasError) { console.error( 'Context: Error detected in response:', result ); // Extract error message from various possible locations let errorMsg = resultAny.error || resultAny.message || (resultAny.validation_errors && typeof resultAny.validation_errors === 'object' ? (Object.values( resultAny.validation_errors )[0] as string) : null) || 'Failed to generate content plan'; console.error( 'Context: Extracted error message:', errorMsg ); console.error('Context: Throwing error:', errorMsg); throw new Error(errorMsg); } // Extract taskId from response (could be at top level or in data) const taskId = result.taskId || result.data?.taskId; const responseData = result.data || {}; // Convert the API response data to ContentPlanItem format and return them if (responseData.content_plan) { const newItems: ContentPlanItem[] = responseData.content_plan.map( (item: any, index: number) => { // Normalize structure data to match the format expected by manual creation let normalizedStructure = item['Structure of the post']; // If structure is an array, convert to the expected format if (Array.isArray(normalizedStructure)) { normalizedStructure = normalizedStructure.map( (structureItem: any) => { // If structureItem is not a string, return it as-is or handle it appropriately if ( typeof structureItem !== 'string' ) { console.warn( 'Structure item is not a string:', structureItem ); // If it's already an object with type and subtitle, return it if ( structureItem && typeof structureItem === 'object' && structureItem.type ) { return structureItem; } // Otherwise, try to convert it to a string structureItem = String(structureItem); } // Handle different conclusion naming conventions if ( structureItem .toLowerCase() .includes( 'conclusion' ) || structureItem .toLowerCase() .includes('summary') ) { return 'conclusion_summary'; } // Handle excerpt/tldr naming if ( structureItem .toLowerCase() .includes('excerpt') || structureItem .toLowerCase() .includes('tldr') ) { return 'excerpt_tldr'; } // For other items, check if they match predefined types const predefinedTypes = [ 'excerpt_tldr', 'paragraph', 'key_takeaways', 'faq', 'conclusion_summary', ]; if ( predefinedTypes.includes( structureItem ) ) { return structureItem; } // For custom content, treat as paragraph with subtitle return { type: 'paragraph', subtitle: structureItem, }; } ); } return { id: -(Date.now() + index + 1), // Negative temporary IDs to avoid conflicts title: item['post title'], structure: JSON.stringify(normalizedStructure), keywords: item['Keywords.'].join(', '), status: 'planned' as const, scheduled_on: item['Scheduled on'], post: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }; } ); // Return items, dev info, and latest scheduled date from backend return { items: newItems, devInfo: responseData.dev_info || null, latestScheduledDate: responseData.latest_scheduled_date || null, taskId: taskId || undefined, }; } // If no content_plan in response, check if taskId is present (async task) if (taskId) { return { items: [], latestScheduledDate: undefined, taskId: taskId, }; } // Defensive check: if we reach here with no content_plan and no taskId, // but there are error indicators, throw an error // (resultAny already declared above) if ( resultAny.error || (resultAny.status_code && resultAny.status_code >= 400) ) { console.error( 'Context: Error detected after initial check:', resultAny ); let errorMsg = resultAny.error || resultAny.message || (resultAny.validation_errors && typeof resultAny.validation_errors === 'object' ? (Object.values( resultAny.validation_errors )[0] as string) : null) || 'Failed to generate content plan'; throw new Error(errorMsg); } // Refresh usage data after generating content plan refreshUsage().catch(err => { console.error( 'Error refreshing usage after content plan generation:', err ); }); return { items: [], latestScheduledDate: undefined }; } catch (err: any) { console.error('Context: Error generating content plan:', err); // Don't set error in context - modal handles error display throw err; } finally { setSaving(false); } }, [refreshUsage] ); // Fetch Posts API Function const [postsLoading, setPostsLoading] = useState(false); const [postsError, setPostsError] = useState(null); const [lastFetchTime, setLastFetchTime] = useState(0); const FETCH_COOLDOWN = 1000; // 1 second cooldown between fetches const fetchPosts = useCallback( async ( fields: string[] = ['id', 'title'], postTypes: string[] = ['post', 'page'], args: any = {} ): Promise => { const now = Date.now(); if (postsLoading || now - lastFetchTime < FETCH_COOLDOWN) { return; } setPostsLoading(true); setPostsError(null); setLastFetchTime(now); try { const queryParams = new URLSearchParams(); // Add fields parameter if (fields.length > 0) { fields.forEach(field => queryParams.append('fields[]', field) ); } // Add post_types parameter if (postTypes.length > 0) { postTypes.forEach(type => queryParams.append('post_types[]', type) ); } // Add other parameters if (args.search) queryParams.append('search', args.search); if (args.limit && args.limit > 0) queryParams.append('limit', args.limit.toString()); if (args.orderby) queryParams.append('orderby', args.orderby); if (args.order) queryParams.append('order', args.order); const queryString = queryParams.toString(); const path = `/cpepai/v1/posts${ queryString ? `?${queryString}` : '' }`; const posts: Post[] = await apiFetch({ path, method: 'GET', }); setPosts(posts || []); } catch (err: any) { console.error('Error fetching posts:', err); setPostsError(err.message); } finally { setPostsLoading(false); } }, [postsLoading, lastFetchTime] ); // Fetch Tasks API Function const fetchTasks = useCallback( async (args?: { status?: string; type?: string; limit?: number; }): Promise => { // Prevent concurrent calls - if a fetch is already in progress, skip this call if (fetchTasksInProgressRef.current) { return; } // Wait for preset to load before fetching tasks if (!presetLoadedRef.current) { // Wait up to 5 seconds for preset to load let waitCount = 0; while (!presetLoadedRef.current && waitCount < 50) { await new Promise(resolve => setTimeout(resolve, 100)); waitCount++; } } // Mark fetch as in progress fetchTasksInProgressRef.current = true; setTasksLoading(true); setTasksError(null); try { const queryParams = new URLSearchParams(); if (args?.status) { queryParams.append('status', args.status); } if (args?.type) { queryParams.append('type', args.type); } if (args?.limit) { queryParams.append('limit', args.limit.toString()); } const queryString = queryParams.toString(); const path = `/cpepai/v1/tasks${queryString ? `?${queryString}` : ''}`; const result: ApiResponse = await apiFetch({ path, method: 'GET', }); if (result.success && result.data) { // Check if any task changed from processing/queued to completed const previousTasks = previousTasksRef.current; const newTasks = result.data; // Find tasks that changed from processing/queued to completed const completedTasks = newTasks.filter(newTask => { if (newTask.status !== 'completed') { return false; } // Find the previous version of this task const previousTask = previousTasks.find( prevTask => prevTask.id === newTask.id ); // Check if it was previously processing or queued return ( previousTask && (previousTask.status === 'processing' || previousTask.status === 'queued') ); }); // Also find newly completed tasks that consume tokens // (plan generation or post generation tasks that are completed) const tokenConsumingTaskTypes = [ 'content_plan_generation', 'post_generation', ]; const tokenConsumingCompletedTasks = newTasks.filter( newTask => { if (newTask.status !== 'completed') { return false; } // Skip if we've already refreshed usage for this task if ( refreshedUsageForTasksRef.current.has( newTask.id ) ) { return false; } // Check if this is a token-consuming task type const taskType = (newTask.type || '').toLowerCase(); const isTokenConsuming = tokenConsumingTaskTypes.some(type => taskType.includes(type) ); if (!isTokenConsuming) { return false; } // Check if we've seen this task before const previousTask = previousTasks.find( prevTask => prevTask.id === newTask.id ); // Refresh usage if: // 1. We haven't seen this task before (newly completed) // 2. It just transitioned from processing/queued to completed const justCompleted = previousTask && (previousTask.status === 'processing' || previousTask.status === 'queued'); const newlySeen = !previousTask; return justCompleted || newlySeen; } ); // If any task completed, refetch content plan items and refresh usage if (completedTasks.length > 0) { // Refetch content plan items to get the newly generated items fetchContentPlanItems(1); } // Refresh usage for token-consuming tasks // Add a small delay to ensure database update has completed if (tokenConsumingCompletedTasks.length > 0) { // Mark these tasks as having refreshed usage tokenConsumingCompletedTasks.forEach(task => { refreshedUsageForTasksRef.current.add(task.id); }); // Refresh usage with retry mechanism // First attempt after 500ms, retry after 1.5s if needed const refreshUsageWithRetry = async ( attempt: number = 1 ) => { try { await refreshUsage(); } catch (err) { console.error( `Error refreshing usage after task completion (attempt ${attempt}):`, err ); // Retry once after a longer delay if (attempt === 1) { setTimeout(() => { refreshUsageWithRetry(2); }, 1500); } } }; // Wait a bit to ensure database update has completed setTimeout(() => { refreshUsageWithRetry(); }, 500); // 500ms delay to ensure DB update completes } // Note: Polling for failed tasks without results is handled by the polling useEffect // No need for immediate retry here as the polling interval (2 seconds) will handle it // Update tasks state setTasks(newTasks); // Store current tasks as previous for next comparison previousTasksRef.current = newTasks; // Update should_poll flag from backend response if (typeof result.should_poll === 'boolean') { setShouldPollTasks(result.should_poll); } // Note: Usage data is refreshed: // - On app load (via fetchPresetData) // - After content plan generation // - After post generation // - When content plan items are created // - When tasks complete (to reflect token consumption) } else { throw new Error(result.message || 'Failed to fetch tasks'); } } catch (err: any) { console.error('Error fetching tasks:', err); setTasksError(err.message); } finally { setTasksLoading(false); // Reset the in-progress flag so subsequent calls can proceed fetchTasksInProgressRef.current = false; } }, [fetchContentPlanItems, refreshUsage] ); // Mark Task as Watched API Function const markTaskAsWatched = useCallback( async (taskId: number): Promise => { try { const result: ApiResponse = await apiFetch({ path: `/cpepai/v1/tasks/${taskId}/watch`, method: 'POST', }); if (!result.success) { throw new Error( result.message || 'Failed to mark task as watched' ); } // Update the task in local state setTasks(prevTasks => prevTasks.map(task => task.id === taskId ? { ...task, watched: true } : task ) ); } catch (err: any) { console.error('Error marking task as watched:', err); setTasksError(err.message); throw err; } }, [] ); // Fetch Logs API Function const fetchLogs = useCallback( async (args?: { level?: string; limit?: number; offset?: number; }): Promise => { setLogsLoading(true); setLogsError(null); try { const queryParams = new URLSearchParams(); if (args?.level) { queryParams.append('level', args.level); } if (args?.limit) { queryParams.append('limit', args.limit.toString()); } if (args?.offset) { queryParams.append('offset', args.offset.toString()); } const queryString = queryParams.toString(); const path = `/cpepai/v1/logs${queryString ? `?${queryString}` : ''}`; const result: ApiResponse = await apiFetch({ path, method: 'GET', }); if (result.success && result.data) { setLogs(result.data); } else { throw new Error(result.message || 'Failed to fetch logs'); } } catch (err: any) { console.error('Error fetching logs:', err); setLogsError(err.message); } finally { setLogsLoading(false); } }, [] ); // Add Failed Task Function - Creates a failed task locally and adds it to the top of the tasks list const addFailedTask = useCallback( (type: string, errorMessage: string, itemId?: number): void => { const now = new Date().toISOString(); const taskId = `failed-${Date.now()}`; // Generate a unique negative ID for local failed tasks const localId = -Date.now(); const failedTask: Task = { id: localId, task_id: taskId, type: type, status: 'failed', result: { error: errorMessage, }, created_at: now, updated_at: now, }; // Add the failed task at the top of the tasks array setTasks(prevTasks => [failedTask, ...prevTasks]); // Set the current task ID to highlight the failed task setCurrentTaskId(taskId); }, [] ); // Polling for tasks with processing status or failed tasks without result // Use shouldPollTasks flag from backend, but also check local tasks as fallback useEffect(() => { // Check if we should poll based on backend flag or local tasks const hasProcessingTasks = shouldPollTasks || tasks.some( task => task.status === 'processing' || task.status === 'queued' ); // Also poll for failed tasks that don't have a result yet (webhook might still be updating) // A task needs polling if: it's failed AND (no result OR result is empty object OR result has no error field) const failedTasksWithoutResult = tasks.filter(task => { if (task.status !== 'failed') return false; // No result at all if (!task.result) return true; // Result is empty object if ( typeof task.result === 'object' && Object.keys(task.result).length === 0 ) return true; // Result exists but doesn't have error field (webhook might still be updating) if (typeof task.result === 'object' && !task.result.error) return true; return false; }); // Track when we first see failed tasks without results const now = Date.now(); failedTasksWithoutResult.forEach(task => { if (!failedTasksStartTimeRef.current.has(task.task_id)) { failedTasksStartTimeRef.current.set(task.task_id, now); } }); // Remove tracking for tasks that now have results with error messages tasks.forEach(task => { if ( task.status === 'failed' && task.result && typeof task.result === 'object' && task.result.error && failedTasksStartTimeRef.current.has(task.task_id) ) { failedTasksStartTimeRef.current.delete(task.task_id); } }); // Stop polling for failed tasks after 30 seconds (webhook should arrive much sooner) const hasFailedTasksWithoutResult = failedTasksWithoutResult.some( task => { const startTime = failedTasksStartTimeRef.current.get( task.task_id ); return startTime && now - startTime < 30000; // 30 seconds timeout } ); // Clear any existing interval first to prevent multiple intervals if (pollingIntervalRef.current) { clearInterval(pollingIntervalRef.current); pollingIntervalRef.current = null; } if (!hasProcessingTasks && !hasFailedTasksWithoutResult) { return; } // Set up polling interval - poll every 2 seconds for faster updates pollingIntervalRef.current = setInterval(() => { fetchTasks({ limit: 20 }); }, 2000); return () => { if (pollingIntervalRef.current) { clearInterval(pollingIntervalRef.current); pollingIntervalRef.current = null; } }; }, [tasks, shouldPollTasks, fetchTasks]); // Load data on mount only (not on every render or when functions change) // Preset must be loaded first, then tasks and content plan items // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { const loadInitialData = async () => { // First, fetch preset data and wait for it to complete await fetchPresetData(); // Then fetch tasks and content plan items (can be parallel) // Note: fetchTasks will check presetLoadedRef internally, but we've already set it above fetchContentPlanItems(1); // Load content plan items on mount await fetchTasks({ limit: 20 }); // Load tasks on mount (await to ensure it completes) // Set mock data for now setData({ message: 'Content Plan Plugin Active', version: '1.0.0', }); setUser({ name: 'Admin User', role: 'Administrator', }); }; loadInitialData(); }, []); // Empty array ensures this only runs on mount const value: ContentPlanContextState = { // State loading, saving, error, contentPlan, posts, postsLoading, postsError, tasks, tasksLoading, tasksError, logs, logsLoading, logsError, settings, fieldConfigs, strings, data, user, usage, usageError, backendConfigured, sidebarOpen, sidebarType, generationModalOpen, currentTaskId, taskFilterId, isNewWebsite, opted, optinUrl, timezone, // Actions updateContentPlan, addContentPlanItem, updateContentPlanItem: updateContentPlanItemLocal, deleteContentPlanItem: deleteContentPlanItemLocal, updateSettings, updateFieldConfigs, updateStrings, clearError, setLoadingState, setSavingState, setErrorState, getString, getLatestContentPlanDate, // Helper function to get latest date saveSettings, refreshData, refreshUsage, openSidebar, closeSidebar, openGenerationModal, closeGenerationModal, setCurrentTaskId: setCurrentTaskIdState, setTaskFilter, clearTaskFilter, // API Functions fetchContentPlanItems, createContentPlanItem, updateContentPlanItemAPI, deleteContentPlanItemAPI, bulkDeleteContentPlanItems, createMultipleContentPlanItems, createPost, fetchPosts, generateContentPlan, fetchTasks, markTaskAsWatched, addFailedTask, fetchLogs, recalculateSchedule, }; return ( {children} ); }; export default ContentPlanContext;