This commit is contained in:
alorig
2025-11-19 21:56:03 +05:00
parent 7321803006
commit 38f6026e73
13 changed files with 2370 additions and 142 deletions

View File

@@ -0,0 +1,41 @@
/**
* Button component with tooltip support for disabled state
* Wraps Button component to show tooltip when disabled
*/
import { ReactNode } from 'react';
import Button, { ButtonProps } from './Button';
import { Tooltip } from '../tooltip/Tooltip';
interface ButtonWithTooltipProps extends ButtonProps {
tooltip?: string;
tooltipPlacement?: 'top' | 'bottom' | 'left' | 'right';
}
export default function ButtonWithTooltip({
tooltip,
tooltipPlacement = 'top',
disabled,
children,
...buttonProps
}: ButtonWithTooltipProps) {
// If button is disabled and has a tooltip, wrap it
if (disabled && tooltip) {
return (
<Tooltip text={tooltip} placement={tooltipPlacement}>
<span className="inline-block">
<Button {...buttonProps} disabled={disabled}>
{children}
</Button>
</span>
</Tooltip>
);
}
// Otherwise, render button normally
return (
<Button {...buttonProps} disabled={disabled}>
{children}
</Button>
);
}

View File

@@ -26,10 +26,14 @@ import {
fetchKeywords,
fetchClusters,
fetchContentIdeas,
fetchTasks
fetchTasks,
fetchSiteBlueprints,
SiteBlueprint,
} from "../../services/api";
import { useSiteStore } from "../../store/siteStore";
import { useSectorStore } from "../../store/sectorStore";
import { Link } from "react-router";
import Alert from "../../components/ui/alert/Alert";
interface DashboardStats {
keywords: {
@@ -70,18 +74,29 @@ export default function PlannerDashboard() {
const [stats, setStats] = useState<DashboardStats | null>(null);
const [loading, setLoading] = useState(true);
const [lastUpdated, setLastUpdated] = useState<Date>(new Date());
const [incompleteBlueprints, setIncompleteBlueprints] = useState<SiteBlueprint[]>([]);
// Fetch real data
const fetchDashboardData = async () => {
try {
setLoading(true);
const [keywordsRes, clustersRes, ideasRes, tasksRes] = await Promise.all([
const [keywordsRes, clustersRes, ideasRes, tasksRes, blueprintsRes] = await Promise.all([
fetchKeywords({ page_size: 1000, sector_id: activeSector?.id }),
fetchClusters({ page_size: 1000, sector_id: activeSector?.id }),
fetchContentIdeas({ page_size: 1000, sector_id: activeSector?.id }),
fetchTasks({ page_size: 1000, sector_id: activeSector?.id })
fetchTasks({ page_size: 1000, sector_id: activeSector?.id }),
activeSite?.id ? fetchSiteBlueprints({ site_id: activeSite.id, page_size: 100 }) : Promise.resolve({ results: [] })
]);
// Check for incomplete blueprints
if (blueprintsRes.results) {
const incomplete = blueprintsRes.results.filter((bp: SiteBlueprint) => {
const workflow = bp.workflow_state;
return workflow && !workflow.completed && workflow.blocking_reason;
});
setIncompleteBlueprints(incomplete);
}
const keywords = keywordsRes.results || [];
const mappedKeywords = keywords.filter(k => k.cluster && k.cluster.length > 0);
@@ -458,6 +473,37 @@ export default function PlannerDashboard() {
onRefresh={fetchDashboardData}
/>
{/* Incomplete Blueprints Banner */}
{incompleteBlueprints.length > 0 && (
<Alert variant="warning" className="mb-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<strong className="block mb-2">Incomplete Site Builder Workflows</strong>
<p className="text-sm mb-3">
{incompleteBlueprints.length} blueprint{incompleteBlueprints.length > 1 ? 's' : ''} {incompleteBlueprints.length > 1 ? 'have' : 'has'} incomplete workflows that need attention:
</p>
<ul className="list-disc list-inside space-y-1 text-sm mb-3">
{incompleteBlueprints.map((bp) => (
<li key={bp.id}>
<Link
to={`/sites/builder/workflow/${bp.id}`}
className="text-blue-600 dark:text-blue-400 hover:underline"
>
{bp.name}
</Link>
{bp.workflow_state?.blocking_reason && (
<span className="text-gray-600 dark:text-gray-400 ml-2">
- {bp.workflow_state.blocking_reason}
</span>
)}
</li>
))}
</ul>
</div>
</div>
</Alert>
)}
<div className="space-y-6">
{/* Key Metrics */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">

View File

@@ -2,10 +2,11 @@
* Site Builder Workflow Wizard (Stage 2)
* Self-guided wizard with state-aware gating and progress tracking
*/
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useBuilderWorkflowStore, WizardStep } from '../../../store/builderWorkflowStore';
import WizardProgress from './components/WizardProgress';
import HelperDrawer from './components/HelperDrawer';
import BusinessDetailsStep from './steps/BusinessDetailsStep';
import ClusterAssignmentStep from './steps/ClusterAssignmentStep';
import TaxonomyBuilderStep from './steps/TaxonomyBuilderStep';
@@ -14,7 +15,8 @@ import CoverageValidationStep from './steps/CoverageValidationStep';
import IdeasHandoffStep from './steps/IdeasHandoffStep';
import Alert from '../../../components/ui/alert/Alert';
import PageMeta from '../../../components/common/PageMeta';
import { Loader2 } from 'lucide-react';
import Button from '../../../components/ui/button/Button';
import { Loader2, HelpCircle } from 'lucide-react';
const STEP_COMPONENTS: Record<WizardStep, React.ComponentType> = {
business_details: BusinessDetailsStep,
@@ -45,8 +47,10 @@ export default function WorkflowWizard() {
context,
initialize,
refreshState,
goToStep,
} = useBuilderWorkflowStore();
const [helperDrawerOpen, setHelperDrawerOpen] = useState(false);
const id = blueprintId ? parseInt(blueprintId, 10) : null;
useEffect(() => {
@@ -66,6 +70,57 @@ export default function WorkflowWizard() {
}
}, [id, storeBlueprintId, refreshState]);
// Keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't interfere with input fields
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement
) {
return;
}
// Escape key: Close helper drawer
if (e.key === 'Escape' && helperDrawerOpen) {
setHelperDrawerOpen(false);
return;
}
// F1 or ? key: Toggle helper drawer
if (e.key === 'F1' || (e.key === '?' && !e.shiftKey && !e.ctrlKey && !e.metaKey)) {
e.preventDefault();
setHelperDrawerOpen(!helperDrawerOpen);
return;
}
// Arrow keys for navigation (when not in input)
if (e.key === 'ArrowLeft' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
// Navigate to previous step (if allowed)
const steps: WizardStep[] = ['business_details', 'clusters', 'taxonomies', 'sitemap', 'coverage', 'ideas'];
const currentIndex = steps.indexOf(currentStep);
if (currentIndex > 0) {
goToStep(steps[currentIndex - 1]);
}
}
if (e.key === 'ArrowRight' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
// Navigate to next step (if allowed)
const steps: WizardStep[] = ['business_details', 'clusters', 'taxonomies', 'sitemap', 'coverage', 'ideas'];
const currentIndex = steps.indexOf(currentStep);
if (currentIndex < steps.length - 1) {
goToStep(steps[currentIndex + 1]);
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [currentStep, helperDrawerOpen, goToStep]);
if (!id) {
return (
<div className="p-6">
@@ -97,6 +152,27 @@ export default function WorkflowWizard() {
<PageMeta title={`Site Builder - ${STEP_LABELS[currentStep]}`} />
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header with Help Button */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Site Builder Workflow
</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Step: {STEP_LABELS[currentStep]}
</p>
</div>
<Button
onClick={() => setHelperDrawerOpen(!helperDrawerOpen)}
variant="outline"
size="sm"
title="Press F1 or ? for help"
>
<HelpCircle className="h-4 w-4 mr-2" />
Help
</Button>
</div>
{/* Progress Indicator */}
<WizardProgress currentStep={currentStep} />
@@ -105,6 +181,13 @@ export default function WorkflowWizard() {
{StepComponent && <StepComponent blueprintId={id} />}
</div>
</div>
{/* Helper Drawer */}
<HelperDrawer
currentStep={currentStep}
isOpen={helperDrawerOpen}
onClose={() => setHelperDrawerOpen(false)}
/>
</div>
);
}

View File

@@ -0,0 +1,158 @@
/**
* Helper Drawer Component
* Contextual help for each wizard step
*/
import { useState } from 'react';
import { X, HelpCircle, ChevronRight } from 'lucide-react';
import { WizardStep } from '../../../../store/builderWorkflowStore';
import Button from '../../../../components/ui/button/Button';
interface HelperDrawerProps {
currentStep: WizardStep;
isOpen: boolean;
onClose: () => void;
}
const STEP_HELP: Record<WizardStep, { title: string; content: string[] }> = {
business_details: {
title: 'Business Details',
content: [
'Enter your site name and description to get started.',
'Select the hosting type that matches your setup.',
'Choose your business type to customize the site structure.',
'You can update these details later in settings.',
],
},
clusters: {
title: 'Cluster Assignment',
content: [
'Select keyword clusters from your Planner to attach to this blueprint.',
'Clusters help organize your content strategy and improve SEO coverage.',
'You can assign clusters as "hub" (main topics), "supporting" (related topics), or "attribute" (product features).',
'Attach at least one cluster to proceed to the next step.',
],
},
taxonomies: {
title: 'Taxonomy Builder',
content: [
'Define taxonomies (categories, tags, product attributes) for your site.',
'Taxonomies help organize content and improve site structure.',
'You can create taxonomies manually or import them from WordPress.',
'Link taxonomies to clusters to create semantic relationships.',
],
},
sitemap: {
title: 'AI Sitemap Review',
content: [
'Review the AI-generated site structure and page blueprints.',
'Edit page titles, slugs, and types as needed.',
'Regenerate individual pages if the structure needs adjustment.',
'Ensure all important pages are included before proceeding.',
],
},
coverage: {
title: 'Coverage Validation',
content: [
'Validate that your clusters and taxonomies have proper coverage.',
'Check cluster coverage percentage - aim for 70% or higher.',
'Ensure taxonomies are defined and linked to clusters.',
'Fix any critical issues before proceeding to content generation.',
],
},
ideas: {
title: 'Ideas Hand-off',
content: [
'Select pages to create Writer tasks for content generation.',
'You can override the default content generation prompt for each page.',
'Tasks will appear in the Writer module for AI content generation.',
'You can skip this step and create tasks manually later.',
],
},
};
export default function HelperDrawer({ currentStep, isOpen, onClose }: HelperDrawerProps) {
const help = STEP_HELP[currentStep];
if (!isOpen) return null;
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 z-40 transition-opacity"
onClick={onClose}
aria-hidden="true"
/>
{/* Drawer */}
<div
className="fixed right-0 top-0 bottom-0 w-96 bg-white dark:bg-gray-900 border-l border-gray-200 dark:border-gray-700 z-50 shadow-xl transform transition-transform duration-300 ease-in-out"
role="dialog"
aria-modal="true"
aria-labelledby="helper-drawer-title"
>
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2">
<HelpCircle className="h-5 w-5 text-blue-600 dark:text-blue-400" />
<h2 id="helper-drawer-title" className="text-lg font-semibold">
Help & Tips
</h2>
</div>
<Button
onClick={onClose}
variant="ghost"
size="sm"
className="p-1"
aria-label="Close help drawer"
>
<X className="h-5 w-5" />
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
<div className="mb-4">
<h3 className="text-base font-semibold mb-2">{help.title}</h3>
</div>
<ul className="space-y-3">
{help.content.map((item, index) => (
<li key={index} className="flex items-start gap-3">
<ChevronRight className="h-5 w-5 text-gray-400 dark:text-gray-500 mt-0.5 flex-shrink-0" />
<span className="text-sm text-gray-700 dark:text-gray-300">{item}</span>
</li>
))}
</ul>
{/* Additional Resources */}
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
<h4 className="text-sm font-semibold mb-3">Additional Resources</h4>
<ul className="space-y-2 text-sm">
<li>
<a
href="/help/docs"
className="text-blue-600 dark:text-blue-400 hover:underline"
onClick={onClose}
>
View Documentation
</a>
</li>
<li>
<a
href="/help"
className="text-blue-600 dark:text-blue-400 hover:underline"
onClick={onClose}
>
Contact Support
</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -6,7 +6,7 @@ import { useState, useEffect } from 'react';
import { useBuilderWorkflowStore } from '../../../../store/builderWorkflowStore';
import { fetchSiteBlueprintById, updateSiteBlueprint, SiteBlueprint } from '../../../../services/api';
import { Card, CardDescription, CardTitle } from '../../../../components/ui/card';
import Button from '../../../../components/ui/button/Button';
import ButtonWithTooltip from '../../../../components/ui/button/ButtonWithTooltip';
import Input from '../../../../components/ui/input/Input';
import Alert from '../../../../components/ui/alert/Alert';
import { Loader2 } from 'lucide-react';
@@ -125,10 +125,15 @@ export default function BusinessDetailsStep({ blueprintId }: BusinessDetailsStep
</div>
<div className="mt-6 flex justify-end">
<Button
<ButtonWithTooltip
onClick={handleSave}
disabled={!canProceed || saving || loading}
variant="primary"
tooltip={
!canProceed ? 'Please provide a site name to continue' :
saving ? 'Saving...' :
loading ? 'Loading...' : undefined
}
>
{saving ? (
<>
@@ -138,7 +143,7 @@ export default function BusinessDetailsStep({ blueprintId }: BusinessDetailsStep
) : (
'Save & Continue'
)}
</Button>
</ButtonWithTooltip>
</div>
{!canProceed && (

View File

@@ -2,25 +2,208 @@
* Step 2: Cluster Assignment
* Select/attach planner clusters with coverage metrics
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useBuilderWorkflowStore } from '../../../../store/builderWorkflowStore';
import {
fetchClusters,
Cluster,
ClusterFilters,
attachClustersToBlueprint,
detachClustersFromBlueprint,
} from '../../../../services/api';
import { Card, CardDescription, CardTitle } from '../../../../components/ui/card';
import Button from '../../../../components/ui/button/Button';
import ButtonWithTooltip from '../../../../components/ui/button/ButtonWithTooltip';
import Alert from '../../../../components/ui/alert/Alert';
import Input from '../../../../components/ui/input/Input';
import Checkbox from '../../../../components/form/input/Checkbox';
import SelectDropdown from '../../../../components/form/SelectDropdown';
import {
Table,
TableHeader,
TableBody,
TableRow,
TableCell,
} from '../../../../components/ui/table';
import { useToast } from '../../../../components/ui/toast/ToastContainer';
import { useSectorStore } from '../../../../store/sectorStore';
import { Loader2, CheckCircle2Icon, XCircleIcon } from 'lucide-react';
interface ClusterAssignmentStepProps {
blueprintId: number;
}
export default function ClusterAssignmentStep({ blueprintId }: ClusterAssignmentStepProps) {
const { context, completeStep, blockingIssues } = useBuilderWorkflowStore();
const { context, completeStep, blockingIssues, refreshState, loading: workflowLoading } = useBuilderWorkflowStore();
const { activeSector } = useSectorStore();
const toast = useToast();
const [clusters, setClusters] = useState<Cluster[]>([]);
const [loading, setLoading] = useState(true);
const [selectedClusterIds, setSelectedClusterIds] = useState<Set<number>>(new Set());
const [attaching, setAttaching] = useState(false);
const [detaching, setDetaching] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [roleFilter, setRoleFilter] = useState<'hub' | 'supporting' | 'attribute' | ''>('');
const [defaultRole, setDefaultRole] = useState<'hub' | 'supporting' | 'attribute'>('hub');
const clusterBlocking = blockingIssues.find(issue => issue.step === 'clusters');
// Get attached cluster IDs from context
const attachedClusterIds = useMemo(() => {
if (!context?.cluster_summary?.clusters) return new Set<number>();
return new Set(context.cluster_summary.clusters.map(c => c.id));
}, [context]);
// Load clusters
const loadClusters = useCallback(async () => {
if (!activeSector?.id) {
setClusters([]);
setLoading(false);
return;
}
setLoading(true);
try {
const filters: ClusterFilters = {
sector_id: activeSector.id,
page_size: 1000, // Load all clusters for selection
ordering: 'name',
...(searchTerm && { search: searchTerm }),
...(statusFilter && { status: statusFilter }),
};
const data = await fetchClusters(filters);
setClusters(data.results || []);
} catch (error: any) {
console.error('Error loading clusters:', error);
toast.error(`Failed to load clusters: ${error.message}`);
} finally {
setLoading(false);
}
}, [activeSector, searchTerm, statusFilter, toast]);
useEffect(() => {
loadClusters();
}, [loadClusters]);
// Update selected clusters when attached clusters change
useEffect(() => {
setSelectedClusterIds(new Set(attachedClusterIds));
}, [attachedClusterIds]);
// Filter clusters
const filteredClusters = useMemo(() => {
let filtered = clusters;
// Filter by role if specified
if (roleFilter && context?.cluster_summary?.clusters) {
const clustersWithRole = new Set(
context.cluster_summary.clusters
.filter(c => c.role === roleFilter)
.map(c => c.id)
);
filtered = filtered.filter(c => clustersWithRole.has(c.id));
}
return filtered;
}, [clusters, roleFilter, context]);
// Handle cluster selection
const handleToggleCluster = (clusterId: number) => {
const newSelected = new Set(selectedClusterIds);
if (newSelected.has(clusterId)) {
newSelected.delete(clusterId);
} else {
newSelected.add(clusterId);
}
setSelectedClusterIds(newSelected);
};
// Handle select all
const handleSelectAll = () => {
if (selectedClusterIds.size === filteredClusters.length) {
setSelectedClusterIds(new Set());
} else {
setSelectedClusterIds(new Set(filteredClusters.map(c => c.id)));
}
};
// Handle attach clusters
const handleAttach = async () => {
if (selectedClusterIds.size === 0) {
toast.warning('Please select at least one cluster to attach');
return;
}
setAttaching(true);
try {
const clusterIds = Array.from(selectedClusterIds);
await attachClustersToBlueprint(blueprintId, clusterIds, defaultRole);
toast.success(`Attached ${clusterIds.length} cluster(s) successfully`);
// Refresh workflow context
await refreshState();
// Reload clusters to update attached status
await loadClusters();
} catch (error: any) {
console.error('Error attaching clusters:', error);
toast.error(`Failed to attach clusters: ${error.message}`);
} finally {
setAttaching(false);
}
};
// Handle detach clusters
const handleDetach = async () => {
if (selectedClusterIds.size === 0) {
toast.warning('Please select at least one cluster to detach');
return;
}
setDetaching(true);
try {
const clusterIds = Array.from(selectedClusterIds);
await detachClustersFromBlueprint(blueprintId, clusterIds);
toast.success(`Detached ${clusterIds.length} cluster(s) successfully`);
// Refresh workflow context
await refreshState();
// Clear selection
setSelectedClusterIds(new Set());
// Reload clusters
await loadClusters();
} catch (error: any) {
console.error('Error detaching clusters:', error);
toast.error(`Failed to detach clusters: ${error.message}`);
} finally {
setDetaching(false);
}
};
// Handle continue
const handleContinue = async () => {
try {
await completeStep('clusters', {
attached_count: attachedClusterIds.size,
});
toast.success('Cluster assignment completed');
} catch (error: any) {
toast.error(`Failed to complete step: ${error.message}`);
}
};
const allSelected = filteredClusters.length > 0 && selectedClusterIds.size === filteredClusters.length;
const someSelected = selectedClusterIds.size > 0 && selectedClusterIds.size < filteredClusters.length;
return (
<Card className="p-6">
<CardTitle>Cluster Assignment</CardTitle>
<CardDescription>
Attach keyword clusters from Planner to drive your sitemap structure.
Attach keyword clusters from Planner to drive your sitemap structure. Select clusters and choose their role (Hub, Supporting, or Attribute).
</CardDescription>
{clusterBlocking && (
@@ -48,23 +231,215 @@ export default function ClusterAssignmentStep({ blueprintId }: ClusterAssignment
</div>
</div>
{/* TODO: Add cluster selection table/list UI */}
<Alert variant="info" className="mt-4">
Cluster selection UI coming in next iteration. Use Planner Clusters to manage clusters first.
</Alert>
{/* Filters and Actions */}
<div className="mb-4 space-y-4">
<div className="flex gap-4 items-end">
<div className="flex-1">
<label className="block text-sm font-medium mb-2">Search Clusters</label>
<Input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search by cluster name..."
/>
</div>
<div className="w-48">
<label className="block text-sm font-medium mb-2">Status</label>
<SelectDropdown
value={statusFilter}
onChange={(value) => setStatusFilter(value)}
options={[
{ value: '', label: 'All Status' },
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' },
]}
/>
</div>
<div className="w-48">
<label className="block text-sm font-medium mb-2">Show Role</label>
<SelectDropdown
value={roleFilter}
onChange={(value) => setRoleFilter(value as any)}
options={[
{ value: '', label: 'All Roles' },
{ value: 'hub', label: 'Hub' },
{ value: 'supporting', label: 'Supporting' },
{ value: 'attribute', label: 'Attribute' },
]}
/>
</div>
</div>
<div className="flex gap-4 items-end">
<div className="w-48">
<label className="block text-sm font-medium mb-2">Default Role for New Attachments</label>
<SelectDropdown
value={defaultRole}
onChange={(value) => setDefaultRole(value as any)}
options={[
{ value: 'hub', label: 'Hub Page' },
{ value: 'supporting', label: 'Supporting Page' },
{ value: 'attribute', label: 'Attribute Page' },
]}
/>
</div>
<div className="flex gap-2">
<Button
onClick={handleAttach}
disabled={selectedClusterIds.size === 0 || attaching || detaching}
variant="primary"
>
{attaching ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Attaching...
</>
) : (
`Attach Selected (${selectedClusterIds.size})`
)}
</Button>
<Button
onClick={handleDetach}
disabled={selectedClusterIds.size === 0 || attaching || detaching}
variant="outline"
>
{detaching ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Detaching...
</>
) : (
`Detach Selected (${selectedClusterIds.size})`
)}
</Button>
</div>
</div>
</div>
{/* Cluster Table */}
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
) : filteredClusters.length === 0 ? (
<Alert variant="info" className="mt-4">
{searchTerm || statusFilter || roleFilter
? 'No clusters match your filters. Try adjusting your search criteria.'
: 'No clusters available. Create clusters in Planner → Clusters first.'}
</Alert>
) : (
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableCell className="w-12">
<Checkbox
checked={allSelected || someSelected}
onChange={handleSelectAll}
/>
</TableCell>
<TableCell className="font-semibold">Cluster Name</TableCell>
<TableCell className="font-semibold">Keywords</TableCell>
<TableCell className="font-semibold">Volume</TableCell>
<TableCell className="font-semibold">Status</TableCell>
<TableCell className="font-semibold">Attached</TableCell>
<TableCell className="font-semibold">Role</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{filteredClusters.map((cluster) => {
const isAttached = attachedClusterIds.has(cluster.id);
const isSelected = selectedClusterIds.has(cluster.id);
const attachedCluster = context?.cluster_summary?.clusters?.find(c => c.id === cluster.id);
return (
<TableRow
key={cluster.id}
className={isSelected ? 'bg-blue-50 dark:bg-blue-900/20' : ''}
>
<TableCell>
<Checkbox
checked={isSelected}
onChange={() => handleToggleCluster(cluster.id)}
/>
</TableCell>
<TableCell>
<div className="font-medium">{cluster.name}</div>
{cluster.description && (
<div className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{cluster.description}
</div>
)}
</TableCell>
<TableCell>{cluster.keywords_count || 0}</TableCell>
<TableCell>{cluster.volume?.toLocaleString() || 0}</TableCell>
<TableCell>
<span className={`px-2 py-1 rounded text-xs ${
cluster.status === 'active'
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-400'
}`}>
{cluster.status || 'active'}
</span>
</TableCell>
<TableCell>
{isAttached ? (
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
<CheckCircle2Icon className="h-4 w-4" />
<span className="text-sm">Attached</span>
</div>
) : (
<div className="flex items-center gap-2 text-gray-400">
<XCircleIcon className="h-4 w-4" />
<span className="text-sm">Not attached</span>
</div>
)}
</TableCell>
<TableCell>
{attachedCluster ? (
<span className="px-2 py-1 rounded text-xs bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400 capitalize">
{attachedCluster.role}
</span>
) : (
<span className="text-gray-400 text-sm">-</span>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</div>
)}
<div className="mt-6 flex justify-end">
<Button
onClick={() => completeStep('clusters')}
disabled={!!clusterBlocking}
<div className="mt-6 flex justify-between items-center">
<div className="text-sm text-gray-600 dark:text-gray-400">
{selectedClusterIds.size > 0 && (
<span>{selectedClusterIds.size} cluster(s) selected</span>
)}
</div>
<ButtonWithTooltip
onClick={handleContinue}
disabled={!!clusterBlocking || workflowLoading || attaching || detaching}
variant="primary"
tooltip={
clusterBlocking?.message ||
(workflowLoading ? 'Loading workflow state...' :
attaching ? 'Attaching clusters...' :
detaching ? 'Detaching clusters...' : undefined)
}
>
Continue
</Button>
{workflowLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading...
</>
) : (
'Continue'
)}
</ButtonWithTooltip>
</div>
</Card>
);
}

View File

@@ -4,7 +4,7 @@
*/
import { useBuilderWorkflowStore } from '../../../../store/builderWorkflowStore';
import { Card, CardDescription, CardTitle } from '../../../../components/ui/card';
import Button from '../../../../components/ui/button/Button';
import ButtonWithTooltip from '../../../../components/ui/button/ButtonWithTooltip';
import Alert from '../../../../components/ui/alert/Alert';
interface CoverageValidationStepProps {
@@ -15,11 +15,39 @@ export default function CoverageValidationStep({ blueprintId }: CoverageValidati
const { context, completeStep, blockingIssues } = useBuilderWorkflowStore();
const coverageBlocking = blockingIssues.find(issue => issue.step === 'coverage');
const clusterStats = context?.cluster_summary?.coverage_stats || {
complete: 0,
in_progress: 0,
pending: 0,
};
const totalClusters = context?.cluster_summary?.total || 0;
const attachedClusters = context?.cluster_summary?.attached || 0;
const clusterCoverage = totalClusters > 0 ? (attachedClusters / totalClusters) * 100 : 0;
const totalTaxonomies = context?.taxonomy_summary?.total || 0;
const taxonomyByType = context?.taxonomy_summary?.by_type || {};
const sitemapCoverage = context?.sitemap_summary?.coverage_percentage || 0;
const totalPages = context?.sitemap_summary?.total_pages || 0;
const getCoverageStatus = (percentage: number) => {
if (percentage >= 90) return { status: 'excellent', color: 'text-green-600 dark:text-green-400', bg: 'bg-green-50 dark:bg-green-900/20' };
if (percentage >= 70) return { status: 'good', color: 'text-blue-600 dark:text-blue-400', bg: 'bg-blue-50 dark:bg-blue-900/20' };
if (percentage >= 50) return { status: 'fair', color: 'text-yellow-600 dark:text-yellow-400', bg: 'bg-yellow-50 dark:bg-yellow-900/20' };
return { status: 'poor', color: 'text-red-600 dark:text-red-400', bg: 'bg-red-50 dark:bg-red-900/20' };
};
const clusterStatus = getCoverageStatus(clusterCoverage);
const sitemapStatus = getCoverageStatus(sitemapCoverage);
const hasWarnings = clusterCoverage < 70 || sitemapCoverage < 70 || totalTaxonomies === 0;
const hasErrors = clusterCoverage < 50 || sitemapCoverage < 50 || attachedClusters === 0;
return (
<Card className="p-6">
<CardTitle>Coverage Validation</CardTitle>
<CardDescription>
Ensure all clusters and taxonomies have proper coverage.
Ensure all clusters and taxonomies have proper coverage before proceeding to content generation.
</CardDescription>
{coverageBlocking && (
@@ -28,25 +56,175 @@ export default function CoverageValidationStep({ blueprintId }: CoverageValidati
</Alert>
)}
{context && (
<div className="mt-6 space-y-4">
{/* TODO: Add coverage summary cards */}
<Alert variant="info">
Coverage validation UI coming in next iteration.
</Alert>
</div>
{hasErrors && (
<Alert variant="error" className="mt-4">
<strong>Critical Issues Found:</strong>
<ul className="mt-2 list-disc list-inside space-y-1">
{attachedClusters === 0 && <li>No clusters attached to blueprint</li>}
{clusterCoverage < 50 && <li>Cluster coverage is below 50% ({clusterCoverage.toFixed(0)}%)</li>}
{sitemapCoverage < 50 && <li>Sitemap coverage is below 50% ({sitemapCoverage.toFixed(0)}%)</li>}
</ul>
</Alert>
)}
{hasWarnings && !hasErrors && (
<Alert variant="warning" className="mt-4">
<strong>Warnings:</strong>
<ul className="mt-2 list-disc list-inside space-y-1">
{clusterCoverage < 70 && <li>Cluster coverage is below recommended 70% ({clusterCoverage.toFixed(0)}%)</li>}
{sitemapCoverage < 70 && <li>Sitemap coverage is below recommended 70% ({sitemapCoverage.toFixed(0)}%)</li>}
{totalTaxonomies === 0 && <li>No taxonomies defined</li>}
</ul>
</Alert>
)}
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Cluster Coverage Card */}
<div className={`p-4 rounded-lg border ${clusterStatus.bg} border-gray-200 dark:border-gray-700`}>
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold text-sm">Cluster Coverage</h3>
<span className={`text-xs font-medium ${clusterStatus.color}`}>
{clusterCoverage.toFixed(0)}%
</span>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Attached:</span>
<span className="font-medium">{attachedClusters} / {totalClusters}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Complete:</span>
<span className="font-medium text-green-600 dark:text-green-400">{clusterStats.complete}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">In Progress:</span>
<span className="font-medium text-yellow-600 dark:text-yellow-400">{clusterStats.in_progress}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Pending:</span>
<span className="font-medium text-red-600 dark:text-red-400">{clusterStats.pending}</span>
</div>
</div>
<div className="mt-3 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className={`h-full ${clusterStatus.color.replace('text-', 'bg-')}`}
style={{ width: `${Math.min(clusterCoverage, 100)}%` }}
/>
</div>
</div>
{/* Taxonomy Coverage Card */}
<div className={`p-4 rounded-lg border ${totalTaxonomies > 0 ? 'bg-blue-50 dark:bg-blue-900/20' : 'bg-red-50 dark:bg-red-900/20'} border-gray-200 dark:border-gray-700`}>
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold text-sm">Taxonomy Coverage</h3>
<span className={`text-xs font-medium ${totalTaxonomies > 0 ? 'text-blue-600 dark:text-blue-400' : 'text-red-600 dark:text-red-400'}`}>
{totalTaxonomies} defined
</span>
</div>
<div className="space-y-2 text-sm">
{totalTaxonomies === 0 ? (
<p className="text-red-600 dark:text-red-400 text-xs">
No taxonomies defined. Define taxonomies in Step 3.
</p>
) : (
<>
{Object.entries(taxonomyByType).map(([type, count]) => (
<div key={type} className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400 capitalize">{type.replace('_', ' ')}:</span>
<span className="font-medium">{count as number}</span>
</div>
))}
</>
)}
</div>
</div>
{/* Sitemap Coverage Card */}
<div className={`p-4 rounded-lg border ${sitemapStatus.bg} border-gray-200 dark:border-gray-700`}>
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold text-sm">Sitemap Coverage</h3>
<span className={`text-xs font-medium ${sitemapStatus.color}`}>
{sitemapCoverage.toFixed(0)}%
</span>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Total Pages:</span>
<span className="font-medium">{totalPages}</span>
</div>
{context?.sitemap_summary?.by_type && (
<div className="mt-2 space-y-1">
{Object.entries(context.sitemap_summary.by_type).map(([type, count]) => (
<div key={type} className="flex justify-between text-xs">
<span className="text-gray-600 dark:text-gray-400 capitalize">{type}:</span>
<span className="font-medium">{count as number}</span>
</div>
))}
</div>
)}
</div>
<div className="mt-3 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className={`h-full ${sitemapStatus.color.replace('text-', 'bg-')}`}
style={{ width: `${Math.min(sitemapCoverage, 100)}%` }}
/>
</div>
</div>
</div>
{/* Validation Summary */}
<div className="mt-6 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
<h3 className="font-semibold text-sm mb-3">Validation Summary</h3>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
{attachedClusters > 0 ? (
<span className="text-green-600 dark:text-green-400"></span>
) : (
<span className="text-red-600 dark:text-red-400"></span>
)}
<span>Clusters attached: {attachedClusters > 0 ? 'Yes' : 'No'}</span>
</div>
<div className="flex items-center gap-2">
{clusterCoverage >= 70 ? (
<span className="text-green-600 dark:text-green-400"></span>
) : clusterCoverage >= 50 ? (
<span className="text-yellow-600 dark:text-yellow-400"></span>
) : (
<span className="text-red-600 dark:text-red-400"></span>
)}
<span>Cluster coverage: {clusterCoverage.toFixed(0)}% {clusterCoverage >= 70 ? '(Good)' : clusterCoverage >= 50 ? '(Fair)' : '(Poor)'}</span>
</div>
<div className="flex items-center gap-2">
{totalTaxonomies > 0 ? (
<span className="text-green-600 dark:text-green-400"></span>
) : (
<span className="text-yellow-600 dark:text-yellow-400"></span>
)}
<span>Taxonomies defined: {totalTaxonomies > 0 ? 'Yes' : 'No'}</span>
</div>
<div className="flex items-center gap-2">
{sitemapCoverage >= 70 ? (
<span className="text-green-600 dark:text-green-400"></span>
) : sitemapCoverage >= 50 ? (
<span className="text-yellow-600 dark:text-yellow-400"></span>
) : (
<span className="text-red-600 dark:text-red-400"></span>
)}
<span>Sitemap coverage: {sitemapCoverage.toFixed(0)}% {sitemapCoverage >= 70 ? '(Good)' : sitemapCoverage >= 50 ? '(Fair)' : '(Poor)'}</span>
</div>
</div>
</div>
<div className="mt-6 flex justify-end">
<Button
<ButtonWithTooltip
onClick={() => completeStep('coverage')}
disabled={!!coverageBlocking}
disabled={!!coverageBlocking || hasErrors}
variant="primary"
tooltip={coverageBlocking?.message || (hasErrors ? 'Please fix critical issues before continuing' : undefined)}
>
Continue
</Button>
</ButtonWithTooltip>
</div>
</Card>
);
}

View File

@@ -2,24 +2,134 @@
* Step 6: Ideas Hand-off
* Select pages to push to Planner Ideas
*/
import { useState, useEffect, useCallback } from 'react';
import { useBuilderWorkflowStore } from '../../../../store/builderWorkflowStore';
import {
PageBlueprint,
} from '../../../../services/api';
import { siteBuilderApi } from '../../../../services/siteBuilder.api';
import { Card, CardDescription, CardTitle } from '../../../../components/ui/card';
import Button from '../../../../components/ui/button/Button';
import ButtonWithTooltip from '../../../../components/ui/button/ButtonWithTooltip';
import Alert from '../../../../components/ui/alert/Alert';
import Checkbox from '../../../../components/form/input/Checkbox';
import Input from '../../../../components/ui/input/Input';
import { useToast } from '../../../../hooks/useToast';
interface IdeasHandoffStepProps {
blueprintId: number;
}
interface PageSelection {
id: number;
selected: boolean;
promptOverride?: string;
}
export default function IdeasHandoffStep({ blueprintId }: IdeasHandoffStepProps) {
const { context, completeStep, blockingIssues } = useBuilderWorkflowStore();
const { context, completeStep, blockingIssues, refreshContext } = useBuilderWorkflowStore();
const toast = useToast();
const [pages, setPages] = useState<PageBlueprint[]>([]);
const [selections, setSelections] = useState<Map<number, PageSelection>>(new Map());
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [showPrompts, setShowPrompts] = useState(false);
const ideasBlocking = blockingIssues.find(issue => issue.step === 'ideas');
useEffect(() => {
loadPages();
}, [blueprintId]);
const loadPages = async () => {
try {
setLoading(true);
const pagesList = await siteBuilderApi.listPages(blueprintId);
const sortedPages = pagesList.sort((a, b) => a.order - b.order);
setPages(sortedPages);
// Initialize selections - select all by default
const initialSelections = new Map<number, PageSelection>();
sortedPages.forEach(page => {
initialSelections.set(page.id, {
id: page.id,
selected: true,
});
});
setSelections(initialSelections);
} catch (error: any) {
toast.error(`Failed to load pages: ${error.message}`);
} finally {
setLoading(false);
}
};
const handleToggleSelection = (pageId: number) => {
const current = selections.get(pageId);
setSelections(new Map(selections.set(pageId, {
...current!,
selected: !current?.selected,
})));
};
const handleSelectAll = () => {
const allSelected = Array.from(selections.values()).every(s => s.selected);
const newSelections = new Map(selections);
newSelections.forEach((selection) => {
selection.selected = !allSelected;
});
setSelections(newSelections);
};
const handlePromptChange = (pageId: number, prompt: string) => {
const current = selections.get(pageId);
setSelections(new Map(selections.set(pageId, {
...current!,
promptOverride: prompt,
})));
};
const handleSubmit = async () => {
const selectedPageIds = Array.from(selections.values())
.filter(s => s.selected)
.map(s => s.id);
if (selectedPageIds.length === 0) {
toast.error('Please select at least one page to create tasks for');
return;
}
try {
setSubmitting(true);
const result = await siteBuilderApi.createTasksForPages(blueprintId, selectedPageIds);
toast.success(`Successfully created ${result.count} tasks`);
await refreshContext();
completeStep('ideas');
} catch (error: any) {
toast.error(`Failed to create tasks: ${error.message}`);
} finally {
setSubmitting(false);
}
};
const selectedCount = Array.from(selections.values()).filter(s => s.selected).length;
const allSelected = pages.length > 0 && selectedCount === pages.length;
const someSelected = selectedCount > 0 && selectedCount < pages.length;
const getStatusBadge = (status: string) => {
const variants: Record<string, string> = {
draft: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200',
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
generating: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
ready: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
published: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
};
return variants[status] || variants.draft;
};
return (
<Card className="p-6">
<CardTitle>Ideas Hand-off</CardTitle>
<CardDescription>
Select pages to send to Planner Ideas for content generation.
Select pages to create Writer tasks for. These tasks will appear in the Writer module for content generation.
</CardDescription>
{ideasBlocking && (
@@ -28,25 +138,120 @@ export default function IdeasHandoffStep({ blueprintId }: IdeasHandoffStepProps)
</Alert>
)}
{context && (
{loading ? (
<div className="mt-6 text-center py-8 text-gray-500">Loading pages...</div>
) : pages.length === 0 ? (
<Alert variant="info" className="mt-6">
No pages found. Generate a sitemap first.
</Alert>
) : (
<div className="mt-6">
{/* TODO: Add page selection UI */}
<Alert variant="info">
Ideas hand-off UI coming in next iteration.
</Alert>
{/* Selection Summary */}
<div className="mb-4 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg flex items-center justify-between">
<div>
<span className="text-sm text-gray-600 dark:text-gray-400">
{selectedCount} of {pages.length} pages selected
</span>
</div>
<div className="flex gap-2">
<Button
onClick={handleSelectAll}
variant="secondary"
size="sm"
>
{allSelected ? 'Deselect All' : 'Select All'}
</Button>
<Button
onClick={() => setShowPrompts(!showPrompts)}
variant="secondary"
size="sm"
>
{showPrompts ? 'Hide' : 'Show'} Prompt Overrides
</Button>
</div>
</div>
{/* Pages List */}
<div className="space-y-2">
{pages.map((page) => {
const selection = selections.get(page.id);
const isSelected = selection?.selected || false;
return (
<div
key={page.id}
className={`border rounded-lg p-4 transition-colors ${
isSelected
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700'
}`}
>
<div className="flex items-start gap-3">
<div className="pt-1">
<Checkbox
checked={isSelected}
onChange={() => handleToggleSelection(page.id)}
/>
</div>
<div className="flex-1">
<div className="flex items-start justify-between mb-2">
<div>
<h4 className="font-semibold text-lg">{page.title}</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
/{page.slug} {page.type}
</p>
</div>
<span className={`text-xs px-2 py-1 rounded ${getStatusBadge(page.status)}`}>
{page.status}
</span>
</div>
{showPrompts && isSelected && (
<div className="mt-3">
<Input
label="Prompt Override (Optional)"
value={selection?.promptOverride || ''}
onChange={(e) => handlePromptChange(page.id, e.target.value)}
placeholder="Override the default content generation prompt for this page..."
multiline
rows={2}
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Leave empty to use the default prompt generated from the page structure.
</p>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
)}
<div className="mt-6 flex justify-end">
<Button
<div className="mt-6 flex justify-end gap-3">
<ButtonWithTooltip
onClick={() => completeStep('ideas')}
disabled={!!ideasBlocking}
variant="primary"
variant="secondary"
disabled={submitting}
tooltip={submitting ? 'Please wait...' : undefined}
>
Complete Wizard
</Button>
Skip
</ButtonWithTooltip>
<ButtonWithTooltip
onClick={handleSubmit}
disabled={!!ideasBlocking || selectedCount === 0 || submitting}
variant="primary"
tooltip={
ideasBlocking?.message ||
(selectedCount === 0 ? 'Please select at least one page to create tasks' :
submitting ? 'Creating tasks...' : undefined)
}
>
{submitting ? 'Creating Tasks...' : `Create Tasks (${selectedCount})`}
</ButtonWithTooltip>
</div>
</Card>
);
}

View File

@@ -2,24 +2,125 @@
* Step 4: AI Sitemap Review
* Review and edit AI-generated sitemap
*/
import { useState, useEffect, useCallback } from 'react';
import { useBuilderWorkflowStore } from '../../../../store/builderWorkflowStore';
import {
PageBlueprint,
updatePageBlueprint,
regeneratePageBlueprint,
} from '../../../../services/api';
import { siteBuilderApi } from '../../../../services/siteBuilder.api';
import { Card, CardDescription, CardTitle } from '../../../../components/ui/card';
import Button from '../../../../components/ui/button/Button';
import ButtonWithTooltip from '../../../../components/ui/button/ButtonWithTooltip';
import Alert from '../../../../components/ui/alert/Alert';
import Input from '../../../../components/ui/input/Input';
import { useToast } from '../../../../hooks/useToast';
interface SitemapReviewStepProps {
blueprintId: number;
}
export default function SitemapReviewStep({ blueprintId }: SitemapReviewStepProps) {
const { context, completeStep, blockingIssues } = useBuilderWorkflowStore();
const { context, completeStep, blockingIssues, refreshContext } = useBuilderWorkflowStore();
const toast = useToast();
const [pages, setPages] = useState<PageBlueprint[]>([]);
const [loading, setLoading] = useState(true);
const [editingId, setEditingId] = useState<number | null>(null);
const [editForm, setEditForm] = useState<{ title: string; slug: string; type: string } | null>(null);
const [regeneratingId, setRegeneratingId] = useState<number | null>(null);
const sitemapBlocking = blockingIssues.find(issue => issue.step === 'sitemap');
useEffect(() => {
loadPages();
}, [blueprintId]);
const loadPages = async () => {
try {
setLoading(true);
const pagesList = await siteBuilderApi.listPages(blueprintId);
setPages(pagesList.sort((a, b) => a.order - b.order));
} catch (error: any) {
toast.error(`Failed to load pages: ${error.message}`);
} finally {
setLoading(false);
}
};
const handleEdit = (page: PageBlueprint) => {
setEditingId(page.id);
setEditForm({
title: page.title,
slug: page.slug,
type: page.type,
});
};
const handleSaveEdit = async (pageId: number) => {
if (!editForm) return;
try {
await updatePageBlueprint(pageId, {
title: editForm.title,
slug: editForm.slug,
type: editForm.type,
});
toast.success('Page updated successfully');
setEditingId(null);
setEditForm(null);
await loadPages();
await refreshContext();
} catch (error: any) {
toast.error(`Failed to update page: ${error.message}`);
}
};
const handleCancelEdit = () => {
setEditingId(null);
setEditForm(null);
};
const handleRegenerate = async (pageId: number) => {
try {
setRegeneratingId(pageId);
await regeneratePageBlueprint(pageId);
toast.success('Page regeneration started');
await loadPages();
await refreshContext();
} catch (error: any) {
toast.error(`Failed to regenerate page: ${error.message}`);
} finally {
setRegeneratingId(null);
}
};
const getStatusBadge = (status: string) => {
const variants: Record<string, string> = {
draft: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200',
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
generating: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
ready: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
published: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
};
return variants[status] || variants.draft;
};
const getTypeBadge = (type: string) => {
const variants: Record<string, string> = {
homepage: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
landing: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
blog: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
product: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
category: 'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-200',
about: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200',
};
return variants[type] || 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200';
};
return (
<Card className="p-6">
<CardTitle>AI Sitemap Review</CardTitle>
<CardDescription>
Review and adjust the AI-generated site structure.
Review and adjust the AI-generated site structure. Edit page details or regenerate pages as needed.
</CardDescription>
{sitemapBlocking && (
@@ -29,27 +130,132 @@ export default function SitemapReviewStep({ blueprintId }: SitemapReviewStepProp
)}
{context?.sitemap_summary && (
<div className="mt-6">
<div className="text-sm text-gray-600 dark:text-gray-400 mb-2">
Total Pages: {context.sitemap_summary.total_pages}
<div className="mt-4 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<span className="text-gray-600 dark:text-gray-400">Total Pages:</span>
<span className="ml-2 font-semibold">{context.sitemap_summary.total_pages}</span>
</div>
<div>
<span className="text-gray-600 dark:text-gray-400">Coverage:</span>
<span className="ml-2 font-semibold">{context.sitemap_summary.coverage_percentage}%</span>
</div>
<div>
<span className="text-gray-600 dark:text-gray-400">By Type:</span>
<div className="mt-1 flex flex-wrap gap-2">
{Object.entries(context.sitemap_summary.by_type).map(([type, count]) => (
<span key={type} className="text-xs px-2 py-1 bg-gray-200 dark:bg-gray-700 rounded">
{type}: {count}
</span>
))}
</div>
</div>
</div>
</div>
)}
{loading ? (
<div className="mt-6 text-center py-8 text-gray-500">Loading pages...</div>
) : pages.length === 0 ? (
<Alert variant="info" className="mt-6">
No pages found. Generate a sitemap first.
</Alert>
) : (
<div className="mt-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{pages.map((page) => (
<div
key={page.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition-shadow"
>
{editingId === page.id ? (
<div className="space-y-3">
<Input
label="Title"
value={editForm?.title || ''}
onChange={(e) => setEditForm({ ...editForm!, title: e.target.value })}
placeholder="Page title"
/>
<Input
label="Slug"
value={editForm?.slug || ''}
onChange={(e) => setEditForm({ ...editForm!, slug: e.target.value })}
placeholder="page-slug"
/>
<Input
label="Type"
value={editForm?.type || ''}
onChange={(e) => setEditForm({ ...editForm!, type: e.target.value })}
placeholder="homepage, landing, blog, etc."
/>
<div className="flex gap-2">
<Button
onClick={() => handleSaveEdit(page.id)}
variant="primary"
size="sm"
>
Save
</Button>
<Button
onClick={handleCancelEdit}
variant="secondary"
size="sm"
>
Cancel
</Button>
</div>
</div>
) : (
<>
<div className="flex items-start justify-between mb-2">
<h4 className="font-semibold text-lg">{page.title}</h4>
<div className="flex gap-1">
<span className={`text-xs px-2 py-1 rounded ${getStatusBadge(page.status)}`}>
{page.status}
</span>
<span className={`text-xs px-2 py-1 rounded ${getTypeBadge(page.type)}`}>
{page.type}
</span>
</div>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
/{page.slug}
</p>
<div className="flex gap-2">
<Button
onClick={() => handleEdit(page)}
variant="secondary"
size="sm"
>
Edit
</Button>
<Button
onClick={() => handleRegenerate(page.id)}
variant="secondary"
size="sm"
disabled={regeneratingId === page.id}
>
{regeneratingId === page.id ? 'Regenerating...' : 'Regenerate'}
</Button>
</div>
</>
)}
</div>
))}
</div>
{/* TODO: Add sitemap grid/table UI */}
<Alert variant="info" className="mt-4">
Sitemap review UI coming in next iteration.
</Alert>
</div>
)}
<div className="mt-6 flex justify-end">
<Button
<ButtonWithTooltip
onClick={() => completeStep('sitemap')}
disabled={!!sitemapBlocking}
variant="primary"
tooltip={sitemapBlocking?.message}
>
Continue
</Button>
</ButtonWithTooltip>
</div>
</Card>
);
}

View File

@@ -2,24 +2,214 @@
* Step 3: Taxonomy Builder
* Define/import taxonomies and link to clusters
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useBuilderWorkflowStore } from '../../../../store/builderWorkflowStore';
import {
fetchBlueprintsTaxonomies,
createBlueprintTaxonomy,
importBlueprintsTaxonomies,
Taxonomy,
TaxonomyCreateData,
TaxonomyImportRecord,
fetchClusters,
Cluster,
} from '../../../../services/api';
import { Card, CardDescription, CardTitle } from '../../../../components/ui/card';
import ButtonWithTooltip from '../../../../components/ui/button/ButtonWithTooltip';
import Button from '../../../../components/ui/button/Button';
import Alert from '../../../../components/ui/alert/Alert';
import Input from '../../../../components/ui/input/Input';
import SelectDropdown from '../../../../components/form/SelectDropdown';
import {
Table,
TableHeader,
TableBody,
TableRow,
TableCell,
} from '../../../../components/ui/table';
import { useToast } from '../../../../components/ui/toast/ToastContainer';
import { useSectorStore } from '../../../../store/sectorStore';
import { Loader2, PlusIcon, UploadIcon, EditIcon, TrashIcon } from 'lucide-react';
import FormModal from '../../../../components/common/FormModal';
interface TaxonomyBuilderStepProps {
blueprintId: number;
}
const TAXONOMY_TYPES = [
{ value: 'blog_category', label: 'Blog Category' },
{ value: 'blog_tag', label: 'Blog Tag' },
{ value: 'product_category', label: 'Product Category' },
{ value: 'product_tag', label: 'Product Tag' },
{ value: 'product_attribute', label: 'Product Attribute' },
{ value: 'service_category', label: 'Service Category' },
];
export default function TaxonomyBuilderStep({ blueprintId }: TaxonomyBuilderStepProps) {
const { context, completeStep, blockingIssues } = useBuilderWorkflowStore();
const { context, completeStep, blockingIssues, refreshState, loading: workflowLoading } = useBuilderWorkflowStore();
const { activeSector } = useSectorStore();
const toast = useToast();
const [taxonomies, setTaxonomies] = useState<Taxonomy[]>([]);
const [clusters, setClusters] = useState<Cluster[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingTaxonomy, setEditingTaxonomy] = useState<Taxonomy | null>(null);
const [formData, setFormData] = useState<TaxonomyCreateData>({
name: '',
slug: '',
taxonomy_type: 'blog_category',
description: '',
cluster_ids: [],
});
const [typeFilter, setTypeFilter] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const taxonomyBlocking = blockingIssues.find(issue => issue.step === 'taxonomies');
// Load taxonomies
const loadTaxonomies = useCallback(async () => {
setLoading(true);
try {
const data = await fetchBlueprintsTaxonomies(blueprintId);
setTaxonomies(data.taxonomies || []);
} catch (error: any) {
console.error('Error loading taxonomies:', error);
toast.error(`Failed to load taxonomies: ${error.message}`);
} finally {
setLoading(false);
}
}, [blueprintId, toast]);
// Load clusters for linking
const loadClusters = useCallback(async () => {
if (!activeSector?.id) return;
try {
const data = await fetchClusters({ sector_id: activeSector.id, page_size: 1000 });
setClusters(data.results || []);
} catch (error: any) {
console.error('Error loading clusters:', error);
}
}, [activeSector]);
useEffect(() => {
loadTaxonomies();
loadClusters();
}, [loadTaxonomies, loadClusters]);
// Filter taxonomies
const filteredTaxonomies = useMemo(() => {
let filtered = taxonomies;
if (searchTerm) {
filtered = filtered.filter(t =>
t.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
t.slug.toLowerCase().includes(searchTerm.toLowerCase())
);
}
if (typeFilter) {
filtered = filtered.filter(t => t.taxonomy_type === typeFilter);
}
return filtered;
}, [taxonomies, searchTerm, typeFilter]);
// Handle create/edit
const handleOpenModal = (taxonomy?: Taxonomy) => {
if (taxonomy) {
setEditingTaxonomy(taxonomy);
setFormData({
name: taxonomy.name,
slug: taxonomy.slug,
taxonomy_type: taxonomy.taxonomy_type,
description: taxonomy.description || '',
cluster_ids: taxonomy.cluster_ids || [],
});
} else {
setEditingTaxonomy(null);
setFormData({
name: '',
slug: '',
taxonomy_type: 'blog_category',
description: '',
cluster_ids: [],
});
}
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setEditingTaxonomy(null);
setFormData({
name: '',
slug: '',
taxonomy_type: 'blog_category',
description: '',
cluster_ids: [],
});
};
const handleSave = async () => {
if (!formData.name || !formData.slug) {
toast.warning('Name and slug are required');
return;
}
setSaving(true);
try {
if (editingTaxonomy) {
// TODO: Add update endpoint when available
toast.info('Update functionality coming soon');
} else {
await createBlueprintTaxonomy(blueprintId, formData);
toast.success('Taxonomy created successfully');
await refreshState();
await loadTaxonomies();
handleCloseModal();
}
} catch (error: any) {
console.error('Error saving taxonomy:', error);
toast.error(`Failed to save taxonomy: ${error.message}`);
} finally {
setSaving(false);
}
};
// Handle import
const handleImport = async () => {
// For now, show a placeholder - actual import will be implemented with file upload
toast.info('Import functionality coming soon. Use the create form to add taxonomies manually.');
};
// Handle continue
const handleContinue = async () => {
try {
await completeStep('taxonomies', {
taxonomy_count: taxonomies.length,
});
toast.success('Taxonomy builder completed');
} catch (error: any) {
toast.error(`Failed to complete step: ${error.message}`);
}
};
// Generate slug from name
const handleNameChange = (name: string) => {
setFormData({
...formData,
name,
slug: formData.slug || name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''),
});
};
return (
<Card className="p-6">
<CardTitle>Taxonomy Builder</CardTitle>
<CardDescription>
Define categories, tags, and attributes for your site structure.
Define categories, tags, and attributes for your site structure. Link taxonomies to clusters for better organization.
</CardDescription>
{taxonomyBlocking && (
@@ -30,26 +220,208 @@ export default function TaxonomyBuilderStep({ blueprintId }: TaxonomyBuilderStep
{context?.taxonomy_summary && (
<div className="mt-6">
<div className="text-sm text-gray-600 dark:text-gray-400 mb-2">
Total Taxonomies: {context.taxonomy_summary.total}
<div className="grid grid-cols-4 gap-4 mb-6">
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
<div className="text-sm text-gray-600 dark:text-gray-400">Total Taxonomies</div>
<div className="text-2xl font-bold">{context.taxonomy_summary.total}</div>
</div>
{Object.entries(context.taxonomy_summary.by_type || {}).slice(0, 3).map(([type, count]) => (
<div key={type} className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
<div className="text-sm text-gray-600 dark:text-gray-400 capitalize">
{type.replace('_', ' ')}
</div>
<div className="text-2xl font-bold">{count as number}</div>
</div>
))}
</div>
{/* TODO: Add taxonomy tree/table UI */}
<Alert variant="info" className="mt-4">
Taxonomy builder UI coming in next iteration.
</Alert>
{/* Filters and Actions */}
<div className="mb-4 space-y-4">
<div className="flex gap-4 items-end">
<div className="flex-1">
<label className="block text-sm font-medium mb-2">Search Taxonomies</label>
<Input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search by name or slug..."
/>
</div>
<div className="w-48">
<label className="block text-sm font-medium mb-2">Type</label>
<SelectDropdown
value={typeFilter}
onChange={(value) => setTypeFilter(value)}
options={[
{ value: '', label: 'All Types' },
...TAXONOMY_TYPES,
]}
/>
</div>
<div className="flex gap-2">
<Button
onClick={() => handleOpenModal()}
variant="primary"
>
<PlusIcon className="mr-2 h-4 w-4" />
Create Taxonomy
</Button>
<Button
onClick={handleImport}
variant="outline"
>
<UploadIcon className="mr-2 h-4 w-4" />
Import
</Button>
</div>
</div>
</div>
{/* Taxonomy Table */}
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
) : filteredTaxonomies.length === 0 ? (
<Alert variant="info" className="mt-4">
{searchTerm || typeFilter
? 'No taxonomies match your filters. Try adjusting your search criteria.'
: 'No taxonomies defined yet. Create your first taxonomy to get started.'}
</Alert>
) : (
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableCell className="font-semibold">Name</TableCell>
<TableCell className="font-semibold">Slug</TableCell>
<TableCell className="font-semibold">Type</TableCell>
<TableCell className="font-semibold">Clusters</TableCell>
<TableCell className="font-semibold">Description</TableCell>
<TableCell className="font-semibold w-24">Actions</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{filteredTaxonomies.map((taxonomy) => (
<TableRow key={taxonomy.id}>
<TableCell>
<div className="font-medium">{taxonomy.name}</div>
</TableCell>
<TableCell>
<code className="text-sm bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded">
{taxonomy.slug}
</code>
</TableCell>
<TableCell>
<span className="px-2 py-1 rounded text-xs bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400 capitalize">
{taxonomy.taxonomy_type.replace('_', ' ')}
</span>
</TableCell>
<TableCell>
<span className="text-sm text-gray-600 dark:text-gray-400">
{taxonomy.cluster_ids?.length || 0} linked
</span>
</TableCell>
<TableCell>
<div className="text-sm text-gray-600 dark:text-gray-400 max-w-md truncate">
{taxonomy.description || '-'}
</div>
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenModal(taxonomy)}
>
<EditIcon className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
)}
{/* Create/Edit Modal */}
<FormModal
isOpen={isModalOpen}
onClose={handleCloseModal}
title={editingTaxonomy ? 'Edit Taxonomy' : 'Create Taxonomy'}
onSubmit={handleSave}
submitLabel={saving ? 'Saving...' : 'Save'}
submitDisabled={saving || !formData.name || !formData.slug}
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2">Name *</label>
<Input
value={formData.name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="Product Categories"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Slug *</label>
<Input
value={formData.slug}
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
placeholder="product-categories"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Type *</label>
<SelectDropdown
value={formData.taxonomy_type}
onChange={(value) => setFormData({ ...formData, taxonomy_type: value as any })}
options={TAXONOMY_TYPES}
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Description</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-3 py-2 border rounded-md"
rows={3}
placeholder="Brief description of this taxonomy..."
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Link to Clusters (Optional)</label>
<div className="text-sm text-gray-600 dark:text-gray-400 mb-2">
Cluster linking will be available in the next iteration.
</div>
</div>
</div>
</FormModal>
<div className="mt-6 flex justify-end">
<Button
onClick={() => completeStep('taxonomies')}
disabled={!!taxonomyBlocking}
<ButtonWithTooltip
onClick={handleContinue}
disabled={!!taxonomyBlocking || workflowLoading || saving}
variant="primary"
tooltip={
taxonomyBlocking?.message ||
(workflowLoading ? 'Loading workflow state...' :
saving ? 'Saving taxonomy...' : undefined)
}
>
Continue
</Button>
{workflowLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading...
</>
) : (
'Continue'
)}
</ButtonWithTooltip>
</div>
</Card>
);
}

View File

@@ -2052,3 +2052,110 @@ export async function updateWorkflowStep(
});
}
// Cluster attachment endpoints
export async function attachClustersToBlueprint(
blueprintId: number,
clusterIds: number[],
role: 'hub' | 'supporting' | 'attribute' = 'hub'
): Promise<{ attached_count: number; clusters: Array<{ id: number; name: string; role: string; link_id: number }> }> {
return fetchAPI(`/v1/site-builder/siteblueprint/${blueprintId}/clusters/attach/`, {
method: 'POST',
body: JSON.stringify({ cluster_ids: clusterIds, role }),
});
}
export async function detachClustersFromBlueprint(
blueprintId: number,
clusterIds?: number[],
role?: 'hub' | 'supporting' | 'attribute'
): Promise<{ detached_count: number }> {
return fetchAPI(`/v1/site-builder/siteblueprint/${blueprintId}/clusters/detach/`, {
method: 'POST',
body: JSON.stringify({ cluster_ids: clusterIds, role }),
});
}
// Taxonomy endpoints
export interface Taxonomy {
id: number;
name: string;
slug: string;
taxonomy_type: 'blog_category' | 'blog_tag' | 'product_category' | 'product_tag' | 'product_attribute' | 'service_category';
description?: string;
cluster_ids: number[];
external_reference?: string;
created_at: string;
updated_at: string;
}
export interface TaxonomyCreateData {
name: string;
slug: string;
taxonomy_type: 'blog_category' | 'blog_tag' | 'product_category' | 'product_tag' | 'product_attribute' | 'service_category';
description?: string;
cluster_ids?: number[];
external_reference?: string;
}
export interface TaxonomyImportRecord {
name: string;
slug: string;
taxonomy_type?: string;
description?: string;
external_reference?: string;
}
export async function fetchBlueprintsTaxonomies(blueprintId: number): Promise<{ count: number; taxonomies: Taxonomy[] }> {
return fetchAPI(`/v1/site-builder/siteblueprint/${blueprintId}/taxonomies/`);
}
export async function createBlueprintTaxonomy(
blueprintId: number,
data: TaxonomyCreateData
): Promise<Taxonomy> {
return fetchAPI(`/v1/site-builder/siteblueprint/${blueprintId}/taxonomies/`, {
method: 'POST',
body: JSON.stringify(data),
});
}
export async function importBlueprintsTaxonomies(
blueprintId: number,
records: TaxonomyImportRecord[],
defaultType: string = 'blog_category'
): Promise<{ imported_count: number; taxonomies: Taxonomy[] }> {
return fetchAPI(`/v1/site-builder/siteblueprint/${blueprintId}/taxonomies/import/`, {
method: 'POST',
body: JSON.stringify({ records, default_type: defaultType }),
});
}
// Page blueprint endpoints
export async function updatePageBlueprint(
pageId: number,
data: Partial<PageBlueprint>
): Promise<PageBlueprint> {
return fetchAPI(`/v1/site-builder/pageblueprint/${pageId}/`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
export async function regeneratePageBlueprint(
pageId: number
): Promise<{ success: boolean; task_id?: string }> {
return fetchAPI(`/v1/site-builder/pageblueprint/${pageId}/regenerate/`, {
method: 'POST',
});
}
export async function generatePageContent(
pageId: number,
force?: boolean
): Promise<{ success: boolean; task_id?: string }> {
return fetchAPI(`/v1/site-builder/pageblueprint/${pageId}/generate_content/`, {
method: 'POST',
body: JSON.stringify({ force: force || false }),
});
}