SEction 9-10

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-01 08:10:24 +00:00
parent 0340016932
commit 41e124d8e8
11 changed files with 2180 additions and 0 deletions

View File

@@ -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,
)

View File

@@ -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 />} />

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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';

View File

@@ -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>

View 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>
);
}