SEction 9-10
This commit is contained in:
@@ -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,
|
||||||
|
)
|
||||||
@@ -105,6 +105,7 @@ const PostEditor = lazy(() => import("./pages/Sites/PostEditor"));
|
|||||||
const SiteSettings = lazy(() => import("./pages/Sites/Settings"));
|
const SiteSettings = lazy(() => import("./pages/Sites/Settings"));
|
||||||
const SyncDashboard = lazy(() => import("./pages/Sites/SyncDashboard"));
|
const SyncDashboard = lazy(() => import("./pages/Sites/SyncDashboard"));
|
||||||
const DeploymentPanel = lazy(() => import("./pages/Sites/DeploymentPanel"));
|
const DeploymentPanel = lazy(() => import("./pages/Sites/DeploymentPanel"));
|
||||||
|
const PublishingQueue = lazy(() => import("./pages/Sites/PublishingQueue"));
|
||||||
|
|
||||||
// Help - Lazy loaded
|
// Help - Lazy loaded
|
||||||
const Help = lazy(() => import("./pages/Help/Help"));
|
const Help = lazy(() => import("./pages/Help/Help"));
|
||||||
@@ -264,6 +265,7 @@ export default function App() {
|
|||||||
<Route path="/sites/:id/settings" element={<SiteSettings />} />
|
<Route path="/sites/:id/settings" element={<SiteSettings />} />
|
||||||
<Route path="/sites/:id/sync" element={<SyncDashboard />} />
|
<Route path="/sites/:id/sync" element={<SyncDashboard />} />
|
||||||
<Route path="/sites/:id/deploy" element={<DeploymentPanel />} />
|
<Route path="/sites/:id/deploy" element={<DeploymentPanel />} />
|
||||||
|
<Route path="/sites/:id/publishing-queue" element={<PublishingQueue />} />
|
||||||
<Route path="/sites/:id/posts/:postId" element={<PostEditor />} />
|
<Route path="/sites/:id/posts/:postId" element={<PostEditor />} />
|
||||||
<Route path="/sites/:id/posts/:postId/edit" element={<PostEditor />} />
|
<Route path="/sites/:id/posts/:postId/edit" element={<PostEditor />} />
|
||||||
|
|
||||||
|
|||||||
280
frontend/src/components/onboarding/OnboardingWizard.tsx
Normal file
280
frontend/src/components/onboarding/OnboardingWizard.tsx
Normal file
@@ -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<WizardData>({
|
||||||
|
siteName: '',
|
||||||
|
domain: '',
|
||||||
|
hostingType: 'wordpress',
|
||||||
|
industryId: null,
|
||||||
|
industrySlug: '',
|
||||||
|
selectedSectors: [],
|
||||||
|
createdSiteId: null,
|
||||||
|
integrationTested: false,
|
||||||
|
keywordsAdded: false,
|
||||||
|
keywordsCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateWizardData = useCallback((updates: Partial<WizardData>) => {
|
||||||
|
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 (
|
||||||
|
<Step1Welcome
|
||||||
|
onNext={handleNext}
|
||||||
|
onSkip={handleSkipAll}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 2:
|
||||||
|
return (
|
||||||
|
<Step2AddSite
|
||||||
|
data={wizardData}
|
||||||
|
updateData={updateWizardData}
|
||||||
|
onNext={handleNext}
|
||||||
|
onBack={handleBack}
|
||||||
|
setIsLoading={setIsLoading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 3:
|
||||||
|
return (
|
||||||
|
<Step3ConnectIntegration
|
||||||
|
data={wizardData}
|
||||||
|
updateData={updateWizardData}
|
||||||
|
onNext={handleNext}
|
||||||
|
onBack={handleBack}
|
||||||
|
onSkip={handleSkipStep}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 4:
|
||||||
|
return (
|
||||||
|
<Step4AddKeywords
|
||||||
|
data={wizardData}
|
||||||
|
updateData={updateWizardData}
|
||||||
|
onNext={handleNext}
|
||||||
|
onBack={handleBack}
|
||||||
|
onSkip={handleSkipStep}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 5:
|
||||||
|
return (
|
||||||
|
<Step5Complete
|
||||||
|
data={wizardData}
|
||||||
|
onComplete={handleComplete}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
|
||||||
|
<Card className="w-full max-w-2xl bg-white dark:bg-gray-900 rounded-2xl shadow-2xl overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="size-10 rounded-xl bg-gradient-to-br from-brand-500 to-brand-600 flex items-center justify-center text-white">
|
||||||
|
<BoltIcon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-gray-900 dark:text-white">
|
||||||
|
Getting Started
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Step {currentStep} of {STEPS.length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSkipAll}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<CloseIcon className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
{STEPS.map((step) => (
|
||||||
|
<div
|
||||||
|
key={step.id}
|
||||||
|
className={`flex items-center ${step.id < STEPS.length ? 'flex-1' : ''}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`size-8 rounded-full flex items-center justify-center text-sm font-medium transition-colors ${
|
||||||
|
step.id < currentStep
|
||||||
|
? 'bg-green-500 text-white'
|
||||||
|
: step.id === currentStep
|
||||||
|
? 'bg-brand-500 text-white'
|
||||||
|
: 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{step.id < currentStep ? (
|
||||||
|
<CheckCircleIcon className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
|
step.id
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{step.id < STEPS.length && (
|
||||||
|
<div
|
||||||
|
className={`h-1 flex-1 mx-2 rounded transition-colors ${
|
||||||
|
step.id < currentStep
|
||||||
|
? 'bg-green-500'
|
||||||
|
: 'bg-gray-200 dark:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{STEPS.map((step) => (
|
||||||
|
<span
|
||||||
|
key={step.id}
|
||||||
|
className={`text-center ${
|
||||||
|
step.id === currentStep ? 'text-brand-600 dark:text-brand-400 font-medium' : ''
|
||||||
|
}`}
|
||||||
|
style={{ width: `${100 / STEPS.length}%` }}
|
||||||
|
>
|
||||||
|
{step.title}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step Content */}
|
||||||
|
<div className="p-6">
|
||||||
|
{renderStepContent()}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
frontend/src/components/onboarding/steps/Step1Welcome.tsx
Normal file
112
frontend/src/components/onboarding/steps/Step1Welcome.tsx
Normal file
@@ -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: <FileTextIcon className="h-5 w-5" />,
|
||||||
|
title: 'AI Content Creation',
|
||||||
|
description: 'Generate high-quality articles with AI assistance',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <PlugInIcon className="h-5 w-5" />,
|
||||||
|
title: 'WordPress Integration',
|
||||||
|
description: 'Publish directly to your WordPress site',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <BoltIcon className="h-5 w-5" />,
|
||||||
|
title: 'Automated Pipeline',
|
||||||
|
description: 'Set it and forget it content scheduling',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <PieChartIcon className="h-5 w-5" />,
|
||||||
|
title: 'Smart Analytics',
|
||||||
|
description: 'Track content performance and optimize',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Step1Welcome({ onNext, onSkip }: Step1WelcomeProps) {
|
||||||
|
return (
|
||||||
|
<div className="text-center">
|
||||||
|
{/* Hero Section */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="inline-flex items-center justify-center size-20 rounded-2xl bg-gradient-to-br from-brand-500 to-brand-600 text-white mb-4 shadow-lg">
|
||||||
|
<BoltIcon className="h-10 w-10" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
Welcome to IGNY8
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 max-w-md mx-auto">
|
||||||
|
Your complete AI-powered content creation and publishing platform.
|
||||||
|
Let's get you set up in just a few minutes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Features Grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 mb-8">
|
||||||
|
{FEATURES.map((feature, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="p-4 rounded-xl bg-gray-50 dark:bg-gray-800 text-left"
|
||||||
|
>
|
||||||
|
<div className="size-10 rounded-lg bg-brand-100 dark:bg-brand-900/50 text-brand-600 dark:text-brand-400 flex items-center justify-center mb-3">
|
||||||
|
{feature.icon}
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white text-sm mb-1">
|
||||||
|
{feature.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{feature.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* What's Next */}
|
||||||
|
<div className="bg-brand-50 dark:bg-brand-900/20 rounded-xl p-4 mb-6">
|
||||||
|
<h3 className="font-semibold text-brand-900 dark:text-brand-100 mb-2">
|
||||||
|
What we'll do together:
|
||||||
|
</h3>
|
||||||
|
<ul className="text-sm text-brand-700 dark:text-brand-300 space-y-1">
|
||||||
|
<li>✓ Create your first site with optimized defaults</li>
|
||||||
|
<li>✓ Connect your WordPress installation</li>
|
||||||
|
<li>✓ Add keywords to start your content pipeline</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onSkip}
|
||||||
|
className="text-gray-500"
|
||||||
|
>
|
||||||
|
Skip for now
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={onNext}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
Let's Get Started
|
||||||
|
<ArrowRightIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
299
frontend/src/components/onboarding/steps/Step2AddSite.tsx
Normal file
299
frontend/src/components/onboarding/steps/Step2AddSite.tsx
Normal file
@@ -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<WizardData>) => 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<Industry[]>([]);
|
||||||
|
const [selectedIndustry, setSelectedIndustry] = useState<Industry | null>(null);
|
||||||
|
const [loadingIndustries, setLoadingIndustries] = useState(false);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
Add Your First Site
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
We'll set up your site with optimized defaults for automated content publishing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert
|
||||||
|
variant="error"
|
||||||
|
title="Error"
|
||||||
|
message={error}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Site Name */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Site Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={data.siteName}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Website URL */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Website URL (optional)
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<PageIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={data.domain}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Industry Selection */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Industry *
|
||||||
|
</label>
|
||||||
|
{loadingIndustries ? (
|
||||||
|
<div className="text-sm text-gray-500">Loading industries...</div>
|
||||||
|
) : (
|
||||||
|
<SelectDropdown
|
||||||
|
options={industries.map(i => ({ 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"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sector Selection */}
|
||||||
|
{selectedIndustry && availableSectors.length > 0 && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Sectors * <span className="text-gray-400 font-normal">(Select up to 5)</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg max-h-40 overflow-y-auto">
|
||||||
|
{availableSectors.map((sector: Sector) => {
|
||||||
|
const isSelected = data.selectedSectors.includes(sector.slug);
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={sector.slug}
|
||||||
|
tone={isSelected ? 'success' : 'neutral'}
|
||||||
|
variant="soft"
|
||||||
|
className={`cursor-pointer transition-all ${
|
||||||
|
isSelected ? 'ring-2 ring-green-500' : 'hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span onClick={() => handleSectorToggle(sector.slug)} className="flex items-center">
|
||||||
|
{isSelected && <CheckCircleIcon className="w-3 h-3 mr-1" />}
|
||||||
|
{sector.name}
|
||||||
|
</span>
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{data.selectedSectors.length > 0 && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
{data.selectedSectors.length} sector{data.selectedSectors.length !== 1 ? 's' : ''} selected
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Defaults Info */}
|
||||||
|
<Card className="p-4 bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800 mb-6">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<GridIcon className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-blue-900 dark:text-blue-100 text-sm mb-1">
|
||||||
|
Optimized Defaults Applied
|
||||||
|
</h4>
|
||||||
|
<ul className="text-xs text-blue-700 dark:text-blue-300 space-y-0.5">
|
||||||
|
<li>• Auto-approval enabled</li>
|
||||||
|
<li>• Auto-publish to site enabled</li>
|
||||||
|
<li>• 3 articles/day limit</li>
|
||||||
|
<li>• Publishing Mon-Fri at 9am, 2pm, 6pm</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-xs text-blue-600 dark:text-blue-400 mt-2">
|
||||||
|
You can customize these in Site Settings anytime.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onBack}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="w-4 h-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleCreateSite}
|
||||||
|
disabled={isCreating || !data.siteName.trim() || !selectedIndustry || data.selectedSectors.length === 0}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{isCreating ? 'Creating...' : 'Create Site'}
|
||||||
|
<ArrowRightIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<WizardData>) => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onBack: () => void;
|
||||||
|
onSkip: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Step3ConnectIntegration({
|
||||||
|
data,
|
||||||
|
updateData,
|
||||||
|
onNext,
|
||||||
|
onBack,
|
||||||
|
onSkip
|
||||||
|
}: Step3ConnectIntegrationProps) {
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const [apiKey, setApiKey] = useState<string>('');
|
||||||
|
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: (
|
||||||
|
<Button variant="outline" size="sm" className="gap-1">
|
||||||
|
<ArrowUpIcon className="w-3 h-3" />
|
||||||
|
Download Plugin
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Connect WordPress
|
||||||
|
</h2>
|
||||||
|
<Badge tone="neutral" variant="soft">Optional</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Install our WordPress plugin to enable automatic publishing. You can do this later if you prefer.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API Key Section */}
|
||||||
|
{apiKey && (
|
||||||
|
<Card className="p-4 mb-6 bg-gray-50 dark:bg-gray-800">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Your API Key
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm font-mono truncate">
|
||||||
|
{apiKey}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCopyApiKey}
|
||||||
|
className="gap-1 flex-shrink-0"
|
||||||
|
>
|
||||||
|
<CopyIcon className="w-4 h-4" />
|
||||||
|
Copy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Installation Steps */}
|
||||||
|
<div className="space-y-3 mb-6">
|
||||||
|
{INSTALLATION_STEPS.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.step}
|
||||||
|
className="flex items-start gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="size-6 rounded-full bg-brand-100 dark:bg-brand-900 text-brand-600 dark:text-brand-400 flex items-center justify-center text-sm font-medium flex-shrink-0">
|
||||||
|
{item.step}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="font-medium text-gray-900 dark:text-white text-sm">
|
||||||
|
{item.title}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{item.action && (
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{item.action}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Test Connection */}
|
||||||
|
<Card className="p-4 mb-6 border-2 border-dashed border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`size-10 rounded-lg flex items-center justify-center ${
|
||||||
|
testResult === 'success'
|
||||||
|
? 'bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400'
|
||||||
|
: testResult === 'failed'
|
||||||
|
? 'bg-red-100 dark:bg-red-900/50 text-red-600 dark:text-red-400'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-500'
|
||||||
|
}`}>
|
||||||
|
{testResult === 'success' ? (
|
||||||
|
<CheckCircleIcon className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
|
<PlugInIcon className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-gray-900 dark:text-white text-sm">
|
||||||
|
Connection Status
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{testResult === 'success'
|
||||||
|
? 'Successfully connected to WordPress'
|
||||||
|
: testResult === 'failed'
|
||||||
|
? 'Connection failed - check plugin installation'
|
||||||
|
: 'Test the connection after installing the plugin'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant={testResult === 'success' ? 'primary' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={handleTestConnection}
|
||||||
|
disabled={isTesting}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
{isTesting ? (
|
||||||
|
<>
|
||||||
|
<TimeIcon className="w-4 h-4 animate-spin" />
|
||||||
|
Testing...
|
||||||
|
</>
|
||||||
|
) : testResult === 'success' ? (
|
||||||
|
<>
|
||||||
|
<CheckCircleIcon className="w-4 h-4" />
|
||||||
|
Connected
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TimeIcon className="w-4 h-4" />
|
||||||
|
Test Connection
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Info Alert */}
|
||||||
|
<Alert
|
||||||
|
variant="info"
|
||||||
|
title="Skip for now"
|
||||||
|
message="You can skip this step and set up the WordPress integration later from Site Settings → Integrations."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onBack}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="w-4 h-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onSkip}
|
||||||
|
>
|
||||||
|
Skip for now
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={onNext}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
<ArrowRightIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
280
frontend/src/components/onboarding/steps/Step4AddKeywords.tsx
Normal file
280
frontend/src/components/onboarding/steps/Step4AddKeywords.tsx
Normal file
@@ -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<WizardData>) => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onBack: () => void;
|
||||||
|
onSkip: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Step4AddKeywords({
|
||||||
|
data,
|
||||||
|
updateData,
|
||||||
|
onNext,
|
||||||
|
onBack,
|
||||||
|
onSkip
|
||||||
|
}: Step4AddKeywordsProps) {
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const [keywords, setKeywords] = useState<string[]>([]);
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Add Target Keywords
|
||||||
|
</h2>
|
||||||
|
<Badge tone="neutral" variant="soft">Optional</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Add keywords to start your content pipeline. These will be used to generate content ideas and articles.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="error" title="Error" message={error} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Keyword Input */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Enter keywords (press Enter or paste multiple)
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<ListIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleAddKeyword}
|
||||||
|
disabled={!inputValue.trim()}
|
||||||
|
>
|
||||||
|
<PlusIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Tip: Paste a comma-separated list or one keyword per line
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Keywords List */}
|
||||||
|
<Card className="p-4 mb-4 min-h-[120px] bg-gray-50 dark:bg-gray-800">
|
||||||
|
{keywords.length === 0 ? (
|
||||||
|
<div className="text-center py-6 text-gray-500 dark:text-gray-400">
|
||||||
|
<ListIcon className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||||
|
<p className="text-sm">No keywords added yet</p>
|
||||||
|
<p className="text-xs">Start typing or paste keywords above</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{keywords.map((keyword) => (
|
||||||
|
<Badge
|
||||||
|
key={keyword}
|
||||||
|
tone="neutral"
|
||||||
|
variant="soft"
|
||||||
|
className="gap-1 pr-1"
|
||||||
|
>
|
||||||
|
{keyword}
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveKeyword(keyword)}
|
||||||
|
className="ml-1 p-0.5 hover:bg-gray-300 dark:hover:bg-gray-600 rounded"
|
||||||
|
>
|
||||||
|
<CloseIcon className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm text-gray-500 mb-4">
|
||||||
|
<span>{keywords.length} keyword{keywords.length !== 1 ? 's' : ''} added</span>
|
||||||
|
{keywords.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setKeywords([])}
|
||||||
|
className="text-red-500 hover:text-red-600"
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Keyword Suggestions */}
|
||||||
|
<Card className="p-4 bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800 mb-6">
|
||||||
|
<h4 className="font-medium text-blue-900 dark:text-blue-100 text-sm mb-2">
|
||||||
|
💡 Keyword Ideas
|
||||||
|
</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{SUGGESTIONS.map((suggestion, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="text-xs text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-800/50 px-2 py-1 rounded"
|
||||||
|
>
|
||||||
|
{suggestion}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Info Alert */}
|
||||||
|
<Alert
|
||||||
|
variant="info"
|
||||||
|
title="Add keywords later"
|
||||||
|
message="You can add more keywords later from the Planner page. The automation will process these keywords and generate content automatically."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onBack}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="w-4 h-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onSkip}
|
||||||
|
>
|
||||||
|
Skip for now
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleSubmitKeywords}
|
||||||
|
disabled={isAdding || keywords.length === 0}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{isAdding ? 'Adding...' : `Add ${keywords.length} Keyword${keywords.length !== 1 ? 's' : ''}`}
|
||||||
|
<ArrowRightIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
167
frontend/src/components/onboarding/steps/Step5Complete.tsx
Normal file
167
frontend/src/components/onboarding/steps/Step5Complete.tsx
Normal file
@@ -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: <BoltIcon className="w-5 h-5" />,
|
||||||
|
title: 'Run Automation',
|
||||||
|
description: 'Start your content pipeline to generate articles',
|
||||||
|
link: data.createdSiteId ? `/sites/${data.createdSiteId}/automation` : '/automation',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <FileTextIcon className="w-5 h-5" />,
|
||||||
|
title: 'Add More Keywords',
|
||||||
|
description: 'Expand your content strategy with more target keywords',
|
||||||
|
link: '/planner/keywords',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <PieChartIcon className="w-5 h-5" />,
|
||||||
|
title: 'View Dashboard',
|
||||||
|
description: 'Monitor your content pipeline and metrics',
|
||||||
|
link: '/dashboard',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <BoxCubeIcon className="w-5 h-5" />,
|
||||||
|
title: 'Customize Settings',
|
||||||
|
description: 'Fine-tune publishing schedules and preferences',
|
||||||
|
link: data.createdSiteId ? `/sites/${data.createdSiteId}/settings` : '/account/settings',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-center">
|
||||||
|
{/* Success Animation */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="inline-flex items-center justify-center size-20 rounded-full bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400 mb-4">
|
||||||
|
<CheckCircleIcon className="h-10 w-10" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
You're All Set! 🎉
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 max-w-md mx-auto">
|
||||||
|
Your content pipeline is ready to go. IGNY8 will start processing your keywords and generating content automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<Card className="p-4 mb-6 bg-gray-50 dark:bg-gray-800 text-left">
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white mb-3">
|
||||||
|
What we set up:
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
<li className="flex items-center gap-2 text-sm">
|
||||||
|
<CheckCircleIcon className="w-4 h-4 text-green-500" />
|
||||||
|
<span className="text-gray-700 dark:text-gray-300">
|
||||||
|
Site: <span className="font-medium">{data.siteName || 'Your Site'}</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{data.integrationTested && (
|
||||||
|
<li className="flex items-center gap-2 text-sm">
|
||||||
|
<CheckCircleIcon className="w-4 h-4 text-green-500" />
|
||||||
|
<span className="text-gray-700 dark:text-gray-300">
|
||||||
|
WordPress integration connected
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{data.keywordsAdded && (
|
||||||
|
<li className="flex items-center gap-2 text-sm">
|
||||||
|
<CheckCircleIcon className="w-4 h-4 text-green-500" />
|
||||||
|
<span className="text-gray-700 dark:text-gray-300">
|
||||||
|
{data.keywordsCount} keyword{data.keywordsCount !== 1 ? 's' : ''} added to pipeline
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
<li className="flex items-center gap-2 text-sm">
|
||||||
|
<CheckCircleIcon className="w-4 h-4 text-green-500" />
|
||||||
|
<span className="text-gray-700 dark:text-gray-300">
|
||||||
|
Auto-approval & auto-publish enabled
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2 text-sm">
|
||||||
|
<CheckCircleIcon className="w-4 h-4 text-green-500" />
|
||||||
|
<span className="text-gray-700 dark:text-gray-300">
|
||||||
|
Daily automation scheduled
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Expected Timeline */}
|
||||||
|
<Card className="p-4 mb-6 bg-brand-50 dark:bg-brand-900/20 border-brand-200 dark:border-brand-800 text-left">
|
||||||
|
<h3 className="font-semibold text-brand-900 dark:text-brand-100 mb-2">
|
||||||
|
📅 What to expect:
|
||||||
|
</h3>
|
||||||
|
<ul className="text-sm text-brand-700 dark:text-brand-300 space-y-1">
|
||||||
|
<li>• First content ideas: Within 24 hours</li>
|
||||||
|
<li>• First articles ready: 2-3 days</li>
|
||||||
|
<li>• First published to site: Based on your schedule</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-xs text-brand-600 dark:text-brand-400 mt-2">
|
||||||
|
Run automation manually anytime to speed things up.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Next Steps */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white mb-3 text-left">
|
||||||
|
What's next:
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{NEXT_STEPS.map((step, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg text-left hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="size-8 rounded-lg bg-brand-100 dark:bg-brand-900/50 text-brand-600 dark:text-brand-400 flex items-center justify-center mb-2">
|
||||||
|
{step.icon}
|
||||||
|
</div>
|
||||||
|
<h4 className="font-medium text-gray-900 dark:text-white text-sm">
|
||||||
|
{step.title}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{step.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
onClick={onComplete}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="gap-2 w-full"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Loading...' : 'Go to Dashboard'}
|
||||||
|
<ArrowRightIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
frontend/src/components/onboarding/steps/index.ts
Normal file
9
frontend/src/components/onboarding/steps/index.ts
Normal file
@@ -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';
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
PageIcon,
|
PageIcon,
|
||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
ArrowUpIcon,
|
ArrowUpIcon,
|
||||||
|
ClockIcon,
|
||||||
} from '../../icons';
|
} from '../../icons';
|
||||||
|
|
||||||
interface Site {
|
interface Site {
|
||||||
@@ -331,6 +332,20 @@ export default function SiteDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
<ArrowRightIcon className="h-5 w-5 text-gray-400 group-hover:text-[var(--color-primary)] transition" />
|
<ArrowRightIcon className="h-5 w-5 text-gray-400 group-hover:text-[var(--color-primary)] transition" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/sites/${siteId}/publishing-queue`)}
|
||||||
|
className="flex items-center gap-4 p-6 rounded-xl border-2 border-gray-200 bg-white hover:border-amber-500 hover:shadow-lg transition-all group"
|
||||||
|
>
|
||||||
|
<div className="size-12 rounded-xl bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center text-white shadow-lg">
|
||||||
|
<ClockIcon className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-left">
|
||||||
|
<h4 className="font-semibold text-gray-900 mb-1">Publishing Queue</h4>
|
||||||
|
<p className="text-sm text-gray-600">View scheduled content</p>
|
||||||
|
</div>
|
||||||
|
<ArrowRightIcon className="h-5 w-5 text-gray-400 group-hover:text-amber-500 transition" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</ComponentCard>
|
</ComponentCard>
|
||||||
|
|
||||||
|
|||||||
455
frontend/src/pages/Sites/PublishingQueue.tsx
Normal file
455
frontend/src/pages/Sites/PublishingQueue.tsx
Normal file
@@ -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<QueueItem[]>([]);
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||||
|
const [draggedItem, setDraggedItem] = useState<QueueItem | null>(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 (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||||
|
<PauseIcon className="w-3 h-3" />
|
||||||
|
Paused
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (item.site_status === 'publishing') {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300">
|
||||||
|
<ArrowRightIcon className="w-3 h-3 animate-pulse" />
|
||||||
|
Publishing...
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300">
|
||||||
|
<ClockIcon className="w-3 h-3" />
|
||||||
|
Scheduled
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="p-6">
|
||||||
|
<PageMeta title="Publishing Queue" description="View and manage scheduled content" />
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-gray-500">Loading queue...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<PageMeta title="Publishing Queue" description="View and manage scheduled content" />
|
||||||
|
<PageHeader
|
||||||
|
title="Publishing Queue"
|
||||||
|
badge={{ icon: <ClockIcon />, color: 'amber' }}
|
||||||
|
breadcrumb="Sites / Publishing Queue"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Stats Overview */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="size-10 rounded-lg bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
|
||||||
|
<ClockIcon className="w-5 h-5 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">{stats.scheduled}</p>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">Scheduled</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="size-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||||
|
<ArrowRightIcon className="w-5 h-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">{stats.publishing}</p>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">Publishing</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="size-10 rounded-lg bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||||
|
<CheckCircleIcon className="w-5 h-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">{stats.published}</p>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">Published</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="size-10 rounded-lg bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
|
||||||
|
<TrashBinIcon className="w-5 h-5 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">{stats.failed}</p>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">Failed</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* View Toggle */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{queueItems.length} items in queue
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-2 bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
viewMode === 'list'
|
||||||
|
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||||
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ListIcon className="w-4 h-4" />
|
||||||
|
List
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('calendar')}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
viewMode === 'calendar'
|
||||||
|
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||||
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CalendarIcon className="w-4 h-4" />
|
||||||
|
Calendar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Queue Content */}
|
||||||
|
{queueItems.length === 0 ? (
|
||||||
|
<Card className="p-12 text-center">
|
||||||
|
<ClockIcon className="w-12 h-12 mx-auto text-gray-400 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||||
|
No content scheduled
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
Content will appear here when it's scheduled for publishing.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" onClick={() => navigate(`/sites/${siteId}/settings?tab=publishing`)}>
|
||||||
|
Configure Publishing Settings
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
) : viewMode === 'list' ? (
|
||||||
|
/* List View */
|
||||||
|
<ComponentCard title="Queue" desc="Drag items to reorder. Content publishes in order from top to bottom.">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{queueItems.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => 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 */}
|
||||||
|
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="font-medium text-gray-900 dark:text-white truncate">
|
||||||
|
{item.title}
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center gap-3 mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<ClockIcon className="w-3.5 h-3.5" />
|
||||||
|
{formatScheduledTime(item.scheduled_publish_at)}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-300 dark:text-gray-600">•</span>
|
||||||
|
<span>{item.content_type}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status badge */}
|
||||||
|
{getStatusBadge(item)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => handleViewContent(item)}
|
||||||
|
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
title="View content"
|
||||||
|
>
|
||||||
|
<EyeIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handlePauseItem(item)}
|
||||||
|
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
title={item.isPaused ? 'Resume' : 'Pause'}
|
||||||
|
>
|
||||||
|
{item.isPaused ? <PlayIcon className="w-4 h-4" /> : <PauseIcon className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveFromQueue(item)}
|
||||||
|
className="p-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||||
|
title="Remove from queue"
|
||||||
|
>
|
||||||
|
<TrashBinIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ComponentCard>
|
||||||
|
) : (
|
||||||
|
/* Calendar View */
|
||||||
|
<ComponentCard title="Calendar View" desc="Content scheduled for the next 14 days">
|
||||||
|
<div className="grid grid-cols-7 gap-2">
|
||||||
|
{/* Day headers */}
|
||||||
|
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
|
||||||
|
<div key={day} className="text-center text-sm font-medium text-gray-500 dark:text-gray-400 py-2">
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Calendar days */}
|
||||||
|
{getCalendarDays().map((date, index) => {
|
||||||
|
const dayItems = getItemsForDate(date);
|
||||||
|
const isToday = date.toDateString() === new Date().toDateString();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`
|
||||||
|
min-h-[100px] p-2 rounded-lg border
|
||||||
|
${isToday
|
||||||
|
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className={`text-sm font-medium mb-1 ${isToday ? 'text-brand-600' : 'text-gray-600 dark:text-gray-400'}`}>
|
||||||
|
{date.getDate()}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{dayItems.slice(0, 3).map(item => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => 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}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{dayItems.length > 3 && (
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
+{dayItems.length - 3} more
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ComponentCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="mt-6 flex justify-between">
|
||||||
|
<Button variant="outline" onClick={() => navigate(`/sites/${siteId}`)}>
|
||||||
|
Back to Site
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={() => navigate(`/sites/${siteId}/settings?tab=publishing`)}>
|
||||||
|
Publishing Settings
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user