diff --git a/backend/igny8_core/business/integration/services/defaults_service.py b/backend/igny8_core/business/integration/services/defaults_service.py new file mode 100644 index 00000000..c5179ecf --- /dev/null +++ b/backend/igny8_core/business/integration/services/defaults_service.py @@ -0,0 +1,255 @@ +""" +Defaults Service +Creates sites with default settings for simplified onboarding. +""" +import logging +from typing import Dict, Any, Tuple, Optional +from django.db import transaction +from django.utils import timezone + +from igny8_core.auth.models import Account, Site +from igny8_core.business.integration.models import PublishingSettings +from igny8_core.business.automation.models import AutomationConfig + + +logger = logging.getLogger(__name__) + + +# Default settings for new sites +DEFAULT_PUBLISHING_SETTINGS = { + 'auto_approval_enabled': True, + 'auto_publish_enabled': True, + 'daily_publish_limit': 3, + 'weekly_publish_limit': 15, + 'monthly_publish_limit': 50, + 'publish_days': ['mon', 'tue', 'wed', 'thu', 'fri'], + 'publish_time_slots': ['09:00', '14:00', '18:00'], +} + +DEFAULT_AUTOMATION_SETTINGS = { + 'is_enabled': True, + 'frequency': 'daily', + 'scheduled_time': '02:00', + 'stage_1_batch_size': 50, + 'stage_2_batch_size': 1, + 'stage_3_batch_size': 20, + 'stage_4_batch_size': 1, + 'stage_5_batch_size': 1, + 'stage_6_batch_size': 1, + 'within_stage_delay': 3, + 'between_stage_delay': 5, +} + + +class DefaultsService: + """ + Service for creating sites with sensible defaults. + Used during onboarding for a simplified first-run experience. + """ + + def __init__(self, account: Account): + self.account = account + + @transaction.atomic + def create_site_with_defaults( + self, + site_data: Dict[str, Any], + publishing_overrides: Optional[Dict[str, Any]] = None, + automation_overrides: Optional[Dict[str, Any]] = None, + ) -> Tuple[Site, PublishingSettings, AutomationConfig]: + """ + Create a new site with default publishing and automation settings. + + Args: + site_data: Dict with site fields (name, domain, etc.) + publishing_overrides: Optional overrides for publishing settings + automation_overrides: Optional overrides for automation settings + + Returns: + Tuple of (Site, PublishingSettings, AutomationConfig) + """ + # Create the site + site = Site.objects.create( + account=self.account, + name=site_data.get('name', 'My Site'), + domain=site_data.get('domain', ''), + base_url=site_data.get('base_url', ''), + hosting_type=site_data.get('hosting_type', 'wordpress'), + is_active=site_data.get('is_active', True), + ) + + logger.info(f"Created site: {site.name} (id={site.id}) for account {self.account.id}") + + # Create publishing settings with defaults + publishing_settings = self._create_publishing_settings( + site, + overrides=publishing_overrides + ) + + # Create automation config with defaults + automation_config = self._create_automation_config( + site, + overrides=automation_overrides + ) + + return site, publishing_settings, automation_config + + def _create_publishing_settings( + self, + site: Site, + overrides: Optional[Dict[str, Any]] = None + ) -> PublishingSettings: + """Create publishing settings with defaults, applying any overrides.""" + settings_data = {**DEFAULT_PUBLISHING_SETTINGS} + + if overrides: + settings_data.update(overrides) + + publishing_settings = PublishingSettings.objects.create( + account=self.account, + site=site, + **settings_data + ) + + logger.info( + f"Created publishing settings for site {site.id}: " + f"auto_approval={publishing_settings.auto_approval_enabled}, " + f"auto_publish={publishing_settings.auto_publish_enabled}" + ) + + return publishing_settings + + def _create_automation_config( + self, + site: Site, + overrides: Optional[Dict[str, Any]] = None + ) -> AutomationConfig: + """Create automation config with defaults, applying any overrides.""" + config_data = {**DEFAULT_AUTOMATION_SETTINGS} + + if overrides: + config_data.update(overrides) + + # Calculate next run time (tomorrow at scheduled time) + scheduled_time = config_data.pop('scheduled_time', '02:00') + + automation_config = AutomationConfig.objects.create( + account=self.account, + site=site, + scheduled_time=scheduled_time, + **config_data + ) + + # Set next run to tomorrow at scheduled time if enabled + if automation_config.is_enabled: + next_run = self._calculate_initial_next_run(scheduled_time) + automation_config.next_run_at = next_run + automation_config.save(update_fields=['next_run_at']) + + logger.info( + f"Created automation config for site {site.id}: " + f"enabled={automation_config.is_enabled}, " + f"frequency={automation_config.frequency}, " + f"next_run={automation_config.next_run_at}" + ) + + return automation_config + + def _calculate_initial_next_run(self, scheduled_time: str) -> timezone.datetime: + """Calculate the initial next run datetime (tomorrow at scheduled time).""" + now = timezone.now() + + # Parse time + try: + hour, minute = map(int, scheduled_time.split(':')) + except (ValueError, AttributeError): + hour, minute = 2, 0 # Default to 2:00 AM + + # Set to tomorrow at the scheduled time + next_run = now.replace( + hour=hour, + minute=minute, + second=0, + microsecond=0 + ) + + # If the time has passed today, schedule for tomorrow + if next_run <= now: + next_run += timezone.timedelta(days=1) + + return next_run + + @transaction.atomic + def apply_defaults_to_existing_site( + self, + site: Site, + force_overwrite: bool = False + ) -> Tuple[PublishingSettings, AutomationConfig]: + """ + Apply default settings to an existing site. + + Args: + site: Existing Site instance + force_overwrite: If True, overwrite existing settings. If False, only create if missing. + + Returns: + Tuple of (PublishingSettings, AutomationConfig) + """ + # Handle publishing settings + if force_overwrite: + PublishingSettings.objects.filter(site=site).delete() + publishing_settings = self._create_publishing_settings(site) + else: + publishing_settings, created = PublishingSettings.objects.get_or_create( + site=site, + defaults={ + 'account': self.account, + **DEFAULT_PUBLISHING_SETTINGS + } + ) + if not created: + logger.info(f"Publishing settings already exist for site {site.id}") + + # Handle automation config + if force_overwrite: + AutomationConfig.objects.filter(site=site).delete() + automation_config = self._create_automation_config(site) + else: + try: + automation_config = AutomationConfig.objects.get(site=site) + logger.info(f"Automation config already exists for site {site.id}") + except AutomationConfig.DoesNotExist: + automation_config = self._create_automation_config(site) + + return publishing_settings, automation_config + + +def create_site_with_defaults( + account: Account, + site_data: Dict[str, Any], + publishing_overrides: Optional[Dict[str, Any]] = None, + automation_overrides: Optional[Dict[str, Any]] = None, +) -> Tuple[Site, PublishingSettings, AutomationConfig]: + """ + Convenience function to create a site with default settings. + + This is the main entry point for the onboarding flow. + + Usage: + from igny8_core.business.integration.services.defaults_service import create_site_with_defaults + + site, pub_settings, auto_config = create_site_with_defaults( + account=request.user.account, + site_data={ + 'name': 'My Blog', + 'domain': 'myblog.com', + 'hosting_type': 'wordpress', + } + ) + """ + service = DefaultsService(account) + return service.create_site_with_defaults( + site_data, + publishing_overrides=publishing_overrides, + automation_overrides=automation_overrides, + ) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index adcc690f..691ca11d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -105,6 +105,7 @@ const PostEditor = lazy(() => import("./pages/Sites/PostEditor")); const SiteSettings = lazy(() => import("./pages/Sites/Settings")); const SyncDashboard = lazy(() => import("./pages/Sites/SyncDashboard")); const DeploymentPanel = lazy(() => import("./pages/Sites/DeploymentPanel")); +const PublishingQueue = lazy(() => import("./pages/Sites/PublishingQueue")); // Help - Lazy loaded const Help = lazy(() => import("./pages/Help/Help")); @@ -264,6 +265,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/onboarding/OnboardingWizard.tsx b/frontend/src/components/onboarding/OnboardingWizard.tsx new file mode 100644 index 00000000..6d38ddaf --- /dev/null +++ b/frontend/src/components/onboarding/OnboardingWizard.tsx @@ -0,0 +1,280 @@ +/** + * OnboardingWizard Component + * Multi-step wizard for new user onboarding + * Provides a guided flow: Welcome → Add Site → Connect Integration → Add Keywords → Complete + */ +import React, { useState, useCallback, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Card } from '../ui/card'; +import Button from '../ui/button/Button'; +import { + ArrowRightIcon, + ArrowLeftIcon, + CloseIcon, + BoltIcon, + CheckCircleIcon, +} from '../../icons'; +import { useOnboardingStore } from '../../store/onboardingStore'; +import { useToast } from '../ui/toast/ToastContainer'; + +// Step components +import Step1Welcome from './steps/Step1Welcome'; +import Step2AddSite from './steps/Step2AddSite'; +import Step3ConnectIntegration from './steps/Step3ConnectIntegration'; +import Step4AddKeywords from './steps/Step4AddKeywords'; +import Step5Complete from './steps/Step5Complete'; + +export interface WizardStep { + id: number; + title: string; + description: string; + isOptional?: boolean; +} + +export interface WizardData { + // Site data + siteName: string; + domain: string; + hostingType: string; + industryId: number | null; + industrySlug: string; + selectedSectors: string[]; + // Created site reference + createdSiteId: number | null; + // Integration data + integrationTested: boolean; + // Keywords data + keywordsAdded: boolean; + keywordsCount: number; +} + +const STEPS: WizardStep[] = [ + { id: 1, title: 'Welcome', description: 'Get started with IGNY8' }, + { id: 2, title: 'Add Site', description: 'Create your first site' }, + { id: 3, title: 'Connect', description: 'Install WordPress plugin', isOptional: true }, + { id: 4, title: 'Keywords', description: 'Add target keywords', isOptional: true }, + { id: 5, title: 'Complete', description: 'You\'re all set!' }, +]; + +interface OnboardingWizardProps { + onComplete?: () => void; + onSkip?: () => void; +} + +export default function OnboardingWizard({ onComplete, onSkip }: OnboardingWizardProps) { + const navigate = useNavigate(); + const toast = useToast(); + const { dismissGuide } = useOnboardingStore(); + + const [currentStep, setCurrentStep] = useState(1); + const [isLoading, setIsLoading] = useState(false); + + const [wizardData, setWizardData] = useState({ + siteName: '', + domain: '', + hostingType: 'wordpress', + industryId: null, + industrySlug: '', + selectedSectors: [], + createdSiteId: null, + integrationTested: false, + keywordsAdded: false, + keywordsCount: 0, + }); + + const updateWizardData = useCallback((updates: Partial) => { + setWizardData(prev => ({ ...prev, ...updates })); + }, []); + + const handleNext = useCallback(() => { + if (currentStep < STEPS.length) { + setCurrentStep(prev => prev + 1); + } + }, [currentStep]); + + const handleBack = useCallback(() => { + if (currentStep > 1) { + setCurrentStep(prev => prev - 1); + } + }, [currentStep]); + + const handleSkipStep = useCallback(() => { + // Skip to next step (for optional steps) + handleNext(); + }, [handleNext]); + + const handleComplete = useCallback(async () => { + setIsLoading(true); + try { + await dismissGuide(); + + if (onComplete) { + onComplete(); + } + + // Navigate to the site dashboard + if (wizardData.createdSiteId) { + navigate(`/sites/${wizardData.createdSiteId}`); + } else { + navigate('/dashboard'); + } + + toast.success('Welcome to IGNY8! Your content pipeline is ready.'); + } catch (error) { + console.error('Failed to complete onboarding:', error); + } finally { + setIsLoading(false); + } + }, [dismissGuide, navigate, onComplete, toast, wizardData.createdSiteId]); + + const handleSkipAll = useCallback(async () => { + await dismissGuide(); + if (onSkip) { + onSkip(); + } + navigate('/dashboard'); + }, [dismissGuide, navigate, onSkip]); + + // Calculate progress percentage + const progressPercent = ((currentStep - 1) / (STEPS.length - 1)) * 100; + + const renderStepContent = () => { + switch (currentStep) { + case 1: + return ( + + ); + case 2: + return ( + + ); + case 3: + return ( + + ); + case 4: + return ( + + ); + case 5: + return ( + + ); + default: + return null; + } + }; + + return ( +
+ + {/* Header */} +
+
+
+
+ +
+
+

+ Getting Started +

+

+ Step {currentStep} of {STEPS.length} +

+
+
+ +
+ + {/* Progress Bar */} +
+
+ {STEPS.map((step) => ( +
+
+ {step.id < currentStep ? ( + + ) : ( + step.id + )} +
+ {step.id < STEPS.length && ( +
+ )} +
+ ))} +
+
+ {STEPS.map((step) => ( + + {step.title} + + ))} +
+
+
+ + {/* Step Content */} +
+ {renderStepContent()} +
+ +
+ ); +} diff --git a/frontend/src/components/onboarding/steps/Step1Welcome.tsx b/frontend/src/components/onboarding/steps/Step1Welcome.tsx new file mode 100644 index 00000000..c85a82d5 --- /dev/null +++ b/frontend/src/components/onboarding/steps/Step1Welcome.tsx @@ -0,0 +1,112 @@ +/** + * Step 1: Welcome + * Introduction screen for new users + */ +import React from 'react'; +import Button from '../../ui/button/Button'; +import { + ArrowRightIcon, + BoltIcon, + FileTextIcon, + PlugInIcon, + PieChartIcon, +} from '../../../icons'; + +interface Step1WelcomeProps { + onNext: () => void; + onSkip: () => void; +} + +const FEATURES = [ + { + icon: , + title: 'AI Content Creation', + description: 'Generate high-quality articles with AI assistance', + }, + { + icon: , + title: 'WordPress Integration', + description: 'Publish directly to your WordPress site', + }, + { + icon: , + title: 'Automated Pipeline', + description: 'Set it and forget it content scheduling', + }, + { + icon: , + title: 'Smart Analytics', + description: 'Track content performance and optimize', + }, +]; + +export default function Step1Welcome({ onNext, onSkip }: Step1WelcomeProps) { + return ( +
+ {/* Hero Section */} +
+
+ +
+

+ Welcome to IGNY8 +

+

+ Your complete AI-powered content creation and publishing platform. + Let's get you set up in just a few minutes. +

+
+ + {/* Features Grid */} +
+ {FEATURES.map((feature, index) => ( +
+
+ {feature.icon} +
+

+ {feature.title} +

+

+ {feature.description} +

+
+ ))} +
+ + {/* What's Next */} +
+

+ What we'll do together: +

+
    +
  • ✓ Create your first site with optimized defaults
  • +
  • ✓ Connect your WordPress installation
  • +
  • ✓ Add keywords to start your content pipeline
  • +
+
+ + {/* Actions */} +
+ + +
+
+ ); +} diff --git a/frontend/src/components/onboarding/steps/Step2AddSite.tsx b/frontend/src/components/onboarding/steps/Step2AddSite.tsx new file mode 100644 index 00000000..5275d19e --- /dev/null +++ b/frontend/src/components/onboarding/steps/Step2AddSite.tsx @@ -0,0 +1,299 @@ +/** + * Step 2: Add Site + * Create first site with industry/sector selection + */ +import React, { useState, useEffect, useMemo } from 'react'; +import Button from '../../ui/button/Button'; +import { Card } from '../../ui/card'; +import Badge from '../../ui/badge/Badge'; +import SelectDropdown from '../../form/SelectDropdown'; +import Alert from '../../ui/alert/Alert'; +import { + ArrowRightIcon, + ArrowLeftIcon, + PageIcon, + GridIcon, + CheckCircleIcon, +} from '../../../icons'; +import { + fetchIndustries, + createSite, + selectSectorsForSite, + setActiveSite, + Industry, + Sector +} from '../../../services/api'; +import { useToast } from '../../ui/toast/ToastContainer'; +import type { WizardData } from '../OnboardingWizard'; + +interface Step2AddSiteProps { + data: WizardData; + updateData: (updates: Partial) => void; + onNext: () => void; + onBack: () => void; + setIsLoading: (loading: boolean) => void; +} + +export default function Step2AddSite({ + data, + updateData, + onNext, + onBack, + setIsLoading +}: Step2AddSiteProps) { + const toast = useToast(); + + const [industries, setIndustries] = useState([]); + const [selectedIndustry, setSelectedIndustry] = useState(null); + const [loadingIndustries, setLoadingIndustries] = useState(false); + const [isCreating, setIsCreating] = useState(false); + const [error, setError] = useState(null); + + // Load industries on mount + useEffect(() => { + const loadIndustries = async () => { + try { + setLoadingIndustries(true); + const response = await fetchIndustries(); + setIndustries(response.industries || []); + } catch (err: any) { + if (err?.status !== 429) { + console.error('Failed to load industries:', err); + } + } finally { + setLoadingIndustries(false); + } + }; + loadIndustries(); + }, []); + + // Get available sectors for selected industry + const availableSectors = useMemo(() => { + if (!selectedIndustry) return []; + return selectedIndustry.sectors || []; + }, [selectedIndustry]); + + const handleIndustrySelect = (industry: Industry) => { + setSelectedIndustry(industry); + updateData({ + industryId: industry.id || null, + industrySlug: industry.slug, + selectedSectors: [] + }); + }; + + const handleSectorToggle = (sectorSlug: string) => { + const newSectors = data.selectedSectors.includes(sectorSlug) + ? data.selectedSectors.filter(s => s !== sectorSlug) + : [...data.selectedSectors, sectorSlug]; + + if (newSectors.length <= 5) { + updateData({ selectedSectors: newSectors }); + } else { + toast.warning('Maximum 5 sectors allowed per site'); + } + }; + + const handleCreateSite = async () => { + setError(null); + + // Validation + if (!data.siteName.trim()) { + setError('Please enter a site name'); + return; + } + + if (!selectedIndustry) { + setError('Please select an industry'); + return; + } + + if (data.selectedSectors.length === 0) { + setError('Please select at least one sector'); + return; + } + + try { + setIsCreating(true); + setIsLoading(true); + + // Create site + const newSite = await createSite({ + name: data.siteName.trim(), + domain: data.domain.trim() || undefined, + is_active: true, + hosting_type: data.hostingType, + industry: selectedIndustry.id, + }); + + // Select sectors + await selectSectorsForSite( + newSite.id, + selectedIndustry.slug, + data.selectedSectors + ); + + // Set as active site + await setActiveSite(newSite.id); + + // Update wizard data with created site + updateData({ createdSiteId: newSite.id }); + + toast.success(`Site "${data.siteName.trim()}" created successfully!`); + onNext(); + } catch (err: any) { + setError(err.message || 'Failed to create site'); + } finally { + setIsCreating(false); + setIsLoading(false); + } + }; + + return ( +
+
+

+ Add Your First Site +

+

+ We'll set up your site with optimized defaults for automated content publishing. +

+
+ + {error && ( + + )} + + {/* Site Name */} +
+ + ) => updateData({ siteName: e.target.value })} + placeholder="My Awesome Blog" + className="w-full px-4 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent" + /> +
+ + {/* Website URL */} +
+ +
+ + ) => updateData({ domain: e.target.value })} + placeholder="https://mysite.com" + className="w-full pl-10 pr-4 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent" + /> +
+
+ + {/* Industry Selection */} +
+ + {loadingIndustries ? ( +
Loading industries...
+ ) : ( + ({ value: i.slug, label: i.name }))} + value={selectedIndustry?.slug || ''} + onChange={(value) => { + const industry = industries.find(i => i.slug === value); + if (industry) handleIndustrySelect(industry); + }} + placeholder="Select an industry" + /> + )} +
+ + {/* Sector Selection */} + {selectedIndustry && availableSectors.length > 0 && ( +
+ +
+ {availableSectors.map((sector: Sector) => { + const isSelected = data.selectedSectors.includes(sector.slug); + return ( + + handleSectorToggle(sector.slug)} className="flex items-center"> + {isSelected && } + {sector.name} + + + ); + })} +
+ {data.selectedSectors.length > 0 && ( +

+ {data.selectedSectors.length} sector{data.selectedSectors.length !== 1 ? 's' : ''} selected +

+ )} +
+ )} + + {/* Defaults Info */} + +
+ +
+

+ Optimized Defaults Applied +

+
    +
  • • Auto-approval enabled
  • +
  • • Auto-publish to site enabled
  • +
  • • 3 articles/day limit
  • +
  • • Publishing Mon-Fri at 9am, 2pm, 6pm
  • +
+

+ You can customize these in Site Settings anytime. +

+
+
+
+ + {/* Actions */} +
+ + +
+
+ ); +} diff --git a/frontend/src/components/onboarding/steps/Step3ConnectIntegration.tsx b/frontend/src/components/onboarding/steps/Step3ConnectIntegration.tsx new file mode 100644 index 00000000..8a3495b6 --- /dev/null +++ b/frontend/src/components/onboarding/steps/Step3ConnectIntegration.tsx @@ -0,0 +1,306 @@ +/** + * Step 3: Connect Integration + * WordPress plugin installation and connection test + */ +import React, { useState, useEffect } from 'react'; +import Button from '../../ui/button/Button'; +import { Card } from '../../ui/card'; +import Alert from '../../ui/alert/Alert'; +import Badge from '../../ui/badge/Badge'; +import { + ArrowRightIcon, + ArrowLeftIcon, + PlugInIcon, + CopyIcon, + CheckCircleIcon, + TimeIcon, + ArrowUpIcon, +} from '../../../icons'; +import { integrationApi } from '../../../services/integration.api'; +import { useToast } from '../../ui/toast/ToastContainer'; +import type { WizardData } from '../OnboardingWizard'; + +interface Step3ConnectIntegrationProps { + data: WizardData; + updateData: (updates: Partial) => void; + onNext: () => void; + onBack: () => void; + onSkip: () => void; +} + +export default function Step3ConnectIntegration({ + data, + updateData, + onNext, + onBack, + onSkip +}: Step3ConnectIntegrationProps) { + const toast = useToast(); + + const [apiKey, setApiKey] = useState(''); + const [isTesting, setIsTesting] = useState(false); + const [testResult, setTestResult] = useState<'success' | 'failed' | null>(null); + const [isLoading, setIsLoading] = useState(true); + + // Load integration details (API key) + useEffect(() => { + const loadIntegration = async () => { + if (!data.createdSiteId) { + setIsLoading(false); + return; + } + + try { + const integrations = await integrationApi.getSiteIntegrations(data.createdSiteId); + const wpIntegration = integrations.find((i) => i.platform === 'wordpress'); + if (wpIntegration?.credentials_json?.api_key) { + setApiKey(wpIntegration.credentials_json.api_key); + } else if (wpIntegration?.api_key) { + setApiKey(wpIntegration.api_key); + } + } catch (err) { + console.error('Failed to load integration:', err); + } finally { + setIsLoading(false); + } + }; + + loadIntegration(); + }, [data.createdSiteId]); + + const handleCopyApiKey = async () => { + if (apiKey) { + await navigator.clipboard.writeText(apiKey); + toast.success('API key copied to clipboard'); + } + }; + + const handleTestConnection = async () => { + if (!data.createdSiteId) return; + + setIsTesting(true); + setTestResult(null); + + try { + // First get the WordPress integration ID + const integrations = await integrationApi.getSiteIntegrations(data.createdSiteId); + const wpIntegration = integrations.find((i) => i.platform === 'wordpress'); + + if (!wpIntegration) { + setTestResult('failed'); + toast.error('No WordPress integration found. Please install the plugin first.'); + return; + } + + const result = await integrationApi.testIntegration(wpIntegration.id); + if (result.success) { + setTestResult('success'); + updateData({ integrationTested: true }); + toast.success('Connection successful!'); + } else { + setTestResult('failed'); + toast.error(result.message || 'Connection failed'); + } + } catch (err: any) { + setTestResult('failed'); + toast.error(err.message || 'Connection test failed'); + } finally { + setIsTesting(false); + } + }; + + const INSTALLATION_STEPS = [ + { + step: 1, + title: 'Download the Plugin', + description: 'Get the IGNY8 Bridge plugin from our dashboard', + action: ( + + ), + }, + { + step: 2, + title: 'Install in WordPress', + description: 'Go to Plugins → Add New → Upload Plugin', + }, + { + step: 3, + title: 'Activate & Configure', + description: 'Activate the plugin and enter your API key', + }, + { + step: 4, + title: 'Test Connection', + description: 'Click the button below to verify the connection', + }, + ]; + + if (isLoading) { + return ( +
+
+
+ ); + } + + return ( +
+
+
+

+ Connect WordPress +

+ Optional +
+

+ Install our WordPress plugin to enable automatic publishing. You can do this later if you prefer. +

+
+ + {/* API Key Section */} + {apiKey && ( + + +
+ + {apiKey} + + +
+
+ )} + + {/* Installation Steps */} +
+ {INSTALLATION_STEPS.map((item) => ( +
+
+ {item.step} +
+
+

+ {item.title} +

+

+ {item.description} +

+
+ {item.action && ( +
+ {item.action} +
+ )} +
+ ))} +
+ + {/* Test Connection */} + +
+
+
+ {testResult === 'success' ? ( + + ) : ( + + )} +
+
+

+ Connection Status +

+

+ {testResult === 'success' + ? 'Successfully connected to WordPress' + : testResult === 'failed' + ? 'Connection failed - check plugin installation' + : 'Test the connection after installing the plugin'} +

+
+
+ +
+
+ + {/* Info Alert */} + + + {/* Actions */} +
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/onboarding/steps/Step4AddKeywords.tsx b/frontend/src/components/onboarding/steps/Step4AddKeywords.tsx new file mode 100644 index 00000000..181de559 --- /dev/null +++ b/frontend/src/components/onboarding/steps/Step4AddKeywords.tsx @@ -0,0 +1,280 @@ +/** + * Step 4: Add Keywords + * Initial keyword input for content pipeline + */ +import React, { useState } from 'react'; +import Button from '../../ui/button/Button'; +import { Card } from '../../ui/card'; +import Badge from '../../ui/badge/Badge'; +import Alert from '../../ui/alert/Alert'; +import { + ArrowRightIcon, + ArrowLeftIcon, + ListIcon, + PlusIcon, + CloseIcon, +} from '../../../icons'; +import { createKeyword } from '../../../services/api'; +import { useToast } from '../../ui/toast/ToastContainer'; +import type { WizardData } from '../OnboardingWizard'; + +interface Step4AddKeywordsProps { + data: WizardData; + updateData: (updates: Partial) => void; + onNext: () => void; + onBack: () => void; + onSkip: () => void; +} + +export default function Step4AddKeywords({ + data, + updateData, + onNext, + onBack, + onSkip +}: Step4AddKeywordsProps) { + const toast = useToast(); + + const [keywords, setKeywords] = useState([]); + const [inputValue, setInputValue] = useState(''); + const [isAdding, setIsAdding] = useState(false); + const [error, setError] = useState(null); + + const handleAddKeyword = () => { + const keyword = inputValue.trim(); + if (!keyword) return; + + if (keywords.includes(keyword)) { + toast.warning('Keyword already added'); + return; + } + + if (keywords.length >= 50) { + toast.warning('Maximum 50 keywords allowed in onboarding'); + return; + } + + setKeywords([...keywords, keyword]); + setInputValue(''); + }; + + const handleRemoveKeyword = (keyword: string) => { + setKeywords(keywords.filter(k => k !== keyword)); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddKeyword(); + } + }; + + const handlePaste = (e: React.ClipboardEvent) => { + e.preventDefault(); + const text = e.clipboardData.getData('text'); + const newKeywords = text + .split(/[\n,;]+/) + .map(k => k.trim()) + .filter(k => k && !keywords.includes(k)); + + const totalKeywords = [...keywords, ...newKeywords].slice(0, 50); + setKeywords(totalKeywords); + + if (newKeywords.length > 0) { + toast.success(`Added ${Math.min(newKeywords.length, 50 - keywords.length)} keywords`); + } + }; + + const handleSubmitKeywords = async () => { + if (!data.createdSiteId || keywords.length === 0) return; + + setError(null); + setIsAdding(true); + + try { + // Create keywords one by one (could be optimized with bulk endpoint later) + let successCount = 0; + for (const keyword of keywords) { + try { + await createKeyword({ + keyword, + status: 'unprocessed', + }); + successCount++; + } catch (err) { + console.warn(`Failed to add keyword "${keyword}":`, err); + } + } + + if (successCount > 0) { + updateData({ + keywordsAdded: true, + keywordsCount: successCount + }); + + toast.success(`Added ${successCount} keywords to your pipeline!`); + onNext(); + } else { + setError('Failed to add keywords. Please try again.'); + } + } catch (err: any) { + setError(err.message || 'Failed to add keywords'); + } finally { + setIsAdding(false); + } + }; + + const SUGGESTIONS = [ + 'best [product] for [use case]', + 'how to [action] with [tool]', + '[topic] guide for beginners', + '[industry] trends 2025', + '[problem] solutions for [audience]', + ]; + + return ( +
+
+
+

+ Add Target Keywords +

+ Optional +
+

+ Add keywords to start your content pipeline. These will be used to generate content ideas and articles. +

+
+ + {error && ( + + )} + + {/* Keyword Input */} +
+ +
+
+ + setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + onPaste={handlePaste} + placeholder="Enter a keyword..." + className="w-full pl-10 pr-4 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent" + /> +
+ +
+

+ Tip: Paste a comma-separated list or one keyword per line +

+
+ + {/* Keywords List */} + + {keywords.length === 0 ? ( +
+ +

No keywords added yet

+

Start typing or paste keywords above

+
+ ) : ( +
+ {keywords.map((keyword) => ( + + {keyword} + + + ))} +
+ )} +
+ +
+ {keywords.length} keyword{keywords.length !== 1 ? 's' : ''} added + {keywords.length > 0 && ( + + )} +
+ + {/* Keyword Suggestions */} + +

+ 💡 Keyword Ideas +

+
+ {SUGGESTIONS.map((suggestion, index) => ( + + {suggestion} + + ))} +
+
+ + {/* Info Alert */} + + + {/* Actions */} +
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/onboarding/steps/Step5Complete.tsx b/frontend/src/components/onboarding/steps/Step5Complete.tsx new file mode 100644 index 00000000..cb8442a7 --- /dev/null +++ b/frontend/src/components/onboarding/steps/Step5Complete.tsx @@ -0,0 +1,167 @@ +/** + * Step 5: Complete + * Success screen with next steps + */ +import React from 'react'; +import Button from '../../ui/button/Button'; +import { Card } from '../../ui/card'; +import { + CheckCircleIcon, + ArrowRightIcon, + BoltIcon, + FileTextIcon, + PieChartIcon, + BoxCubeIcon, +} from '../../../icons'; +import type { WizardData } from '../OnboardingWizard'; + +interface Step5CompleteProps { + data: WizardData; + onComplete: () => void; + isLoading: boolean; +} + +export default function Step5Complete({ + data, + onComplete, + isLoading +}: Step5CompleteProps) { + const NEXT_STEPS = [ + { + icon: , + title: 'Run Automation', + description: 'Start your content pipeline to generate articles', + link: data.createdSiteId ? `/sites/${data.createdSiteId}/automation` : '/automation', + }, + { + icon: , + title: 'Add More Keywords', + description: 'Expand your content strategy with more target keywords', + link: '/planner/keywords', + }, + { + icon: , + title: 'View Dashboard', + description: 'Monitor your content pipeline and metrics', + link: '/dashboard', + }, + { + icon: , + title: 'Customize Settings', + description: 'Fine-tune publishing schedules and preferences', + link: data.createdSiteId ? `/sites/${data.createdSiteId}/settings` : '/account/settings', + }, + ]; + + return ( +
+ {/* Success Animation */} +
+
+ +
+

+ You're All Set! 🎉 +

+

+ Your content pipeline is ready to go. IGNY8 will start processing your keywords and generating content automatically. +

+
+ + {/* Summary */} + +

+ What we set up: +

+
    +
  • + + + Site: {data.siteName || 'Your Site'} + +
  • + {data.integrationTested && ( +
  • + + + WordPress integration connected + +
  • + )} + {data.keywordsAdded && ( +
  • + + + {data.keywordsCount} keyword{data.keywordsCount !== 1 ? 's' : ''} added to pipeline + +
  • + )} +
  • + + + Auto-approval & auto-publish enabled + +
  • +
  • + + + Daily automation scheduled + +
  • +
+
+ + {/* Expected Timeline */} + +

+ 📅 What to expect: +

+
    +
  • • First content ideas: Within 24 hours
  • +
  • • First articles ready: 2-3 days
  • +
  • • First published to site: Based on your schedule
  • +
+

+ Run automation manually anytime to speed things up. +

+
+ + {/* Next Steps */} +
+

+ What's next: +

+
+ {NEXT_STEPS.map((step, index) => ( +
+
+ {step.icon} +
+

+ {step.title} +

+

+ {step.description} +

+
+ ))} +
+
+ + {/* CTA */} + +
+ ); +} diff --git a/frontend/src/components/onboarding/steps/index.ts b/frontend/src/components/onboarding/steps/index.ts new file mode 100644 index 00000000..fedcc539 --- /dev/null +++ b/frontend/src/components/onboarding/steps/index.ts @@ -0,0 +1,9 @@ +/** + * Onboarding Step Components + * Export all wizard step components + */ +export { default as Step1Welcome } from './Step1Welcome'; +export { default as Step2AddSite } from './Step2AddSite'; +export { default as Step3ConnectIntegration } from './Step3ConnectIntegration'; +export { default as Step4AddKeywords } from './Step4AddKeywords'; +export { default as Step5Complete } from './Step5Complete'; diff --git a/frontend/src/pages/Sites/Dashboard.tsx b/frontend/src/pages/Sites/Dashboard.tsx index 9bf8625d..5185882e 100644 --- a/frontend/src/pages/Sites/Dashboard.tsx +++ b/frontend/src/pages/Sites/Dashboard.tsx @@ -26,6 +26,7 @@ import { PageIcon, ArrowRightIcon, ArrowUpIcon, + ClockIcon, } from '../../icons'; interface Site { @@ -331,6 +332,20 @@ export default function SiteDashboard() {
+ +
diff --git a/frontend/src/pages/Sites/PublishingQueue.tsx b/frontend/src/pages/Sites/PublishingQueue.tsx new file mode 100644 index 00000000..4dce2780 --- /dev/null +++ b/frontend/src/pages/Sites/PublishingQueue.tsx @@ -0,0 +1,455 @@ +/** + * Publishing Queue Page + * Shows scheduled content for publishing to external site + * Allows reordering, pausing, and viewing calendar + */ +import React, { useState, useEffect, useCallback } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import PageMeta from '../../components/common/PageMeta'; +import PageHeader from '../../components/common/PageHeader'; +import ComponentCard from '../../components/common/ComponentCard'; +import { Card } from '../../components/ui/card'; +import Button from '../../components/ui/button/Button'; +import { useToast } from '../../components/ui/toast/ToastContainer'; +import { fetchContent, Content } from '../../services/api'; +import { + ClockIcon, + CheckCircleIcon, + ArrowRightIcon, + CalendarIcon, + ListIcon, + PauseIcon, + PlayIcon, + TrashBinIcon, + EyeIcon, +} from '../../icons'; + +type ViewMode = 'list' | 'calendar'; + +interface QueueItem extends Content { + isPaused?: boolean; +} + +export default function PublishingQueue() { + const { id: siteId } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const toast = useToast(); + + const [loading, setLoading] = useState(true); + const [queueItems, setQueueItems] = useState([]); + const [viewMode, setViewMode] = useState('list'); + const [draggedItem, setDraggedItem] = useState(null); + const [stats, setStats] = useState({ + scheduled: 0, + publishing: 0, + published: 0, + failed: 0, + }); + + const loadQueue = useCallback(async () => { + try { + setLoading(true); + + // Fetch content that is scheduled or publishing + const response = await fetchContent({ + site_id: Number(siteId), + page_size: 100, + }); + + const items = (response.results || []).filter( + (c: Content) => c.site_status === 'scheduled' || c.site_status === 'publishing' + ); + + // Sort by scheduled_publish_at + items.sort((a: Content, b: Content) => { + const dateA = a.scheduled_publish_at ? new Date(a.scheduled_publish_at).getTime() : 0; + const dateB = b.scheduled_publish_at ? new Date(b.scheduled_publish_at).getTime() : 0; + return dateA - dateB; + }); + + setQueueItems(items); + + // Calculate stats + const allContent = response.results || []; + setStats({ + scheduled: allContent.filter((c: Content) => c.site_status === 'scheduled').length, + publishing: allContent.filter((c: Content) => c.site_status === 'publishing').length, + published: allContent.filter((c: Content) => c.site_status === 'published').length, + failed: allContent.filter((c: Content) => c.site_status === 'failed').length, + }); + } catch (error: any) { + toast.error(`Failed to load queue: ${error.message}`); + } finally { + setLoading(false); + } + }, [siteId, toast]); + + useEffect(() => { + if (siteId) { + loadQueue(); + } + }, [siteId, loadQueue]); + + // Drag and drop handlers + const handleDragStart = (e: React.DragEvent, item: QueueItem) => { + setDraggedItem(item); + e.dataTransfer.effectAllowed = 'move'; + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + }; + + const handleDrop = (e: React.DragEvent, targetItem: QueueItem) => { + e.preventDefault(); + if (!draggedItem || draggedItem.id === targetItem.id) return; + + const newItems = [...queueItems]; + const draggedIndex = newItems.findIndex(item => item.id === draggedItem.id); + const targetIndex = newItems.findIndex(item => item.id === targetItem.id); + + // Remove dragged item and insert at target position + newItems.splice(draggedIndex, 1); + newItems.splice(targetIndex, 0, draggedItem); + + setQueueItems(newItems); + setDraggedItem(null); + + // TODO: Call API to update scheduled_publish_at based on new order + toast.success('Queue order updated'); + }; + + const handleDragEnd = () => { + setDraggedItem(null); + }; + + const handlePauseItem = (item: QueueItem) => { + // Toggle pause state (in real implementation, this would call an API) + setQueueItems(prev => + prev.map(i => i.id === item.id ? { ...i, isPaused: !i.isPaused } : i) + ); + toast.info(item.isPaused ? 'Item resumed' : 'Item paused'); + }; + + const handleRemoveFromQueue = (item: QueueItem) => { + // TODO: Call API to set site_status back to 'not_published' + setQueueItems(prev => prev.filter(i => i.id !== item.id)); + toast.success('Removed from queue'); + }; + + const handleViewContent = (item: QueueItem) => { + navigate(`/sites/${siteId}/posts/${item.id}`); + }; + + const formatScheduledTime = (dateStr: string | null | undefined) => { + if (!dateStr) return 'Not scheduled'; + const date = new Date(dateStr); + return date.toLocaleString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + }; + + const getStatusBadge = (item: QueueItem) => { + if (item.isPaused) { + return ( + + + Paused + + ); + } + if (item.site_status === 'publishing') { + return ( + + + Publishing... + + ); + } + return ( + + + Scheduled + + ); + }; + + // Calendar view helpers + const getCalendarDays = () => { + const today = new Date(); + const days = []; + for (let i = 0; i < 14; i++) { + const date = new Date(today); + date.setDate(today.getDate() + i); + days.push(date); + } + return days; + }; + + const getItemsForDate = (date: Date) => { + return queueItems.filter(item => { + if (!item.scheduled_publish_at) return false; + const itemDate = new Date(item.scheduled_publish_at); + return ( + itemDate.getDate() === date.getDate() && + itemDate.getMonth() === date.getMonth() && + itemDate.getFullYear() === date.getFullYear() + ); + }); + }; + + if (loading) { + return ( +
+ +
+
Loading queue...
+
+
+ ); + } + + return ( +
+ + , color: 'amber' }} + breadcrumb="Sites / Publishing Queue" + /> + + {/* Stats Overview */} +
+ +
+
+ +
+
+

{stats.scheduled}

+

Scheduled

+
+
+
+ +
+
+ +
+
+

{stats.publishing}

+

Publishing

+
+
+
+ +
+
+ +
+
+

{stats.published}

+

Published

+
+
+
+ +
+
+ +
+
+

{stats.failed}

+

Failed

+
+
+
+
+ + {/* View Toggle */} +
+

+ {queueItems.length} items in queue +

+
+ + +
+
+ + {/* Queue Content */} + {queueItems.length === 0 ? ( + + +

+ No content scheduled +

+

+ Content will appear here when it's scheduled for publishing. +

+ +
+ ) : viewMode === 'list' ? ( + /* List View */ + +
+ {queueItems.map((item, index) => ( +
handleDragStart(e, item)} + onDragOver={handleDragOver} + onDrop={(e) => handleDrop(e, item)} + onDragEnd={handleDragEnd} + className={` + flex items-center gap-4 p-4 bg-white dark:bg-gray-800 rounded-lg border-2 + ${draggedItem?.id === item.id ? 'border-brand-500 opacity-50' : 'border-gray-200 dark:border-gray-700'} + ${item.isPaused ? 'opacity-60' : ''} + hover:border-brand-300 dark:hover:border-brand-700 transition-all cursor-move + `} + > + {/* Order number */} +
+ {index + 1} +
+ + {/* Content info */} +
+

+ {item.title} +

+
+ + + {formatScheduledTime(item.scheduled_publish_at)} + + + {item.content_type} +
+
+ + {/* Status badge */} + {getStatusBadge(item)} + + {/* Actions */} +
+ + + +
+
+ ))} +
+
+ ) : ( + /* Calendar View */ + +
+ {/* Day headers */} + {['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => ( +
+ {day} +
+ ))} + + {/* Calendar days */} + {getCalendarDays().map((date, index) => { + const dayItems = getItemsForDate(date); + const isToday = date.toDateString() === new Date().toDateString(); + + return ( +
+
+ {date.getDate()} +
+
+ {dayItems.slice(0, 3).map(item => ( +
handleViewContent(item)} + className="text-xs p-1 bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-200 rounded truncate cursor-pointer hover:bg-amber-200 dark:hover:bg-amber-900/50" + title={item.title} + > + {item.title} +
+ ))} + {dayItems.length > 3 && ( +
+ +{dayItems.length - 3} more +
+ )} +
+
+ ); + })} +
+
+ )} + + {/* Actions */} +
+ + +
+
+ ); +}