stage2-2
This commit is contained in:
41
frontend/src/components/ui/button/ButtonWithTooltip.tsx
Normal file
41
frontend/src/components/ui/button/ButtonWithTooltip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
158
frontend/src/pages/Sites/Builder/components/HelperDrawer.tsx
Normal file
158
frontend/src/pages/Sites/Builder/components/HelperDrawer.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user