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 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() {
|
||||
<Route path="/sites/:id/settings" element={<SiteSettings />} />
|
||||
<Route path="/sites/:id/sync" element={<SyncDashboard />} />
|
||||
<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/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,
|
||||
ArrowRightIcon,
|
||||
ArrowUpIcon,
|
||||
ClockIcon,
|
||||
} from '../../icons';
|
||||
|
||||
interface Site {
|
||||
@@ -331,6 +332,20 @@ export default function SiteDashboard() {
|
||||
</div>
|
||||
<ArrowRightIcon className="h-5 w-5 text-gray-400 group-hover:text-[var(--color-primary)] transition" />
|
||||
</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>
|
||||
</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