From 38f6026e73c447443cc7c93b56eb625ef03612ec Mon Sep 17 00:00:00 2001 From: alorig <220087330+alorig@users.noreply.github.com> Date: Wed, 19 Nov 2025 21:56:03 +0500 Subject: [PATCH] stage2-2 --- .../igny8_core/modules/site_builder/views.py | 376 ++++++++++++++++ .../ui/button/ButtonWithTooltip.tsx | 41 ++ frontend/src/pages/Planner/Dashboard.tsx | 52 ++- .../pages/Sites/Builder/WorkflowWizard.tsx | 87 +++- .../Sites/Builder/components/HelperDrawer.tsx | 158 +++++++ .../Builder/steps/BusinessDetailsStep.tsx | 11 +- .../Builder/steps/ClusterAssignmentStep.tsx | 403 +++++++++++++++++- .../Builder/steps/CoverageValidationStep.tsx | 204 ++++++++- .../Sites/Builder/steps/IdeasHandoffStep.tsx | 235 +++++++++- .../Sites/Builder/steps/SitemapReviewStep.tsx | 232 +++++++++- .../Builder/steps/TaxonomyBuilderStep.tsx | 400 ++++++++++++++++- frontend/src/services/api.ts | 107 +++++ .../refactor-stage-2-completion-status.md | 206 ++++++--- 13 files changed, 2370 insertions(+), 142 deletions(-) create mode 100644 frontend/src/components/ui/button/ButtonWithTooltip.tsx create mode 100644 frontend/src/pages/Sites/Builder/components/HelperDrawer.tsx diff --git a/backend/igny8_core/modules/site_builder/views.py b/backend/igny8_core/modules/site_builder/views.py index f95edbc1..524cfedb 100644 --- a/backend/igny8_core/modules/site_builder/views.py +++ b/backend/igny8_core/modules/site_builder/views.py @@ -17,6 +17,8 @@ from igny8_core.business.site_building.models import ( HeroImageryDirection, PageBlueprint, SiteBlueprint, + SiteBlueprintCluster, + SiteBlueprintTaxonomy, ) from igny8_core.business.site_building.services import ( PageGenerationService, @@ -229,6 +231,380 @@ class SiteBlueprintViewSet(SiteSectorModelViewSet): payload = self.wizard_context_service.build_context(blueprint) return success_response(payload, request=request) + @action(detail=True, methods=['post'], url_path='workflow/step') + def update_workflow_step(self, request, pk=None): + """ + Update workflow step status. + + Request body: + { + "step": "business_details", # Step name + "status": "ready", # Status: ready, blocked, in_progress + "metadata": {} # Optional metadata + } + + Returns: + { + "current_step": "business_details", + "step_status": {...}, + "completed": false + } + """ + blueprint = self.get_object() + if not self.workflow_service.enabled: + return error_response( + 'Workflow service not enabled', + status.HTTP_400_BAD_REQUEST, + request + ) + + step = request.data.get('step') + status_value = request.data.get('status') + metadata = request.data.get('metadata', {}) + + if not step or not status_value: + return error_response( + 'step and status are required', + status.HTTP_400_BAD_REQUEST, + request + ) + + valid_statuses = ['ready', 'blocked', 'in_progress', 'complete'] + if status_value not in valid_statuses: + return error_response( + f'Invalid status. Must be one of: {", ".join(valid_statuses)}', + status.HTTP_400_BAD_REQUEST, + request + ) + + updated_state = self.workflow_service.update_step( + blueprint, + step, + status_value, + metadata + ) + + if not updated_state: + return error_response( + 'Failed to update workflow step', + status.HTTP_500_INTERNAL_SERVER_ERROR, + request + ) + + # Serialize state + serialized = self.workflow_service.serialize_state(updated_state) + + return success_response( + data=serialized, + request=request + ) + + @action(detail=True, methods=['post'], url_path='clusters/attach') + def attach_clusters(self, request, pk=None): + """ + Attach planner clusters to site blueprint. + + Request body: + { + "cluster_ids": [1, 2, 3], # List of cluster IDs to attach + "role": "hub" # Optional: default role (hub, supporting, attribute) + } + + Returns: + { + "attached_count": 3, + "clusters": [...] # List of attached cluster data + } + """ + blueprint = self.get_object() + cluster_ids = request.data.get('cluster_ids', []) + role = request.data.get('role', 'hub') + + if not cluster_ids: + return error_response( + 'cluster_ids is required', + status.HTTP_400_BAD_REQUEST, + request + ) + + # Validate role + valid_roles = [choice[0] for choice in SiteBlueprintCluster.ROLE_CHOICES] + if role not in valid_roles: + return error_response( + f'Invalid role. Must be one of: {", ".join(valid_roles)}', + status.HTTP_400_BAD_REQUEST, + request + ) + + # Import Clusters model + from igny8_core.business.planning.models import Clusters + + # Validate clusters exist and belong to same account/site/sector + clusters = Clusters.objects.filter( + id__in=cluster_ids, + account=blueprint.account, + site=blueprint.site, + sector=blueprint.sector + ) + + if clusters.count() != len(cluster_ids): + return error_response( + 'Some clusters not found or do not belong to this blueprint\'s site/sector', + status.HTTP_400_BAD_REQUEST, + request + ) + + # Attach clusters (create SiteBlueprintCluster records) + attached = [] + for cluster in clusters: + # Check if already attached with this role + existing = SiteBlueprintCluster.objects.filter( + site_blueprint=blueprint, + cluster=cluster, + role=role + ).first() + + if not existing: + link = SiteBlueprintCluster.objects.create( + site_blueprint=blueprint, + cluster=cluster, + role=role, + account=blueprint.account, + site=blueprint.site, + sector=blueprint.sector + ) + attached.append({ + 'id': cluster.id, + 'name': cluster.name, + 'role': role, + 'link_id': link.id + }) + else: + # Already attached, include in response + attached.append({ + 'id': cluster.id, + 'name': cluster.name, + 'role': role, + 'link_id': existing.id + }) + + # Refresh workflow state if enabled + if self.workflow_service.enabled: + self.workflow_service.refresh_state(blueprint) + + return success_response( + data={ + 'attached_count': len(attached), + 'clusters': attached + }, + request=request + ) + + @action(detail=True, methods=['post'], url_path='clusters/detach') + def detach_clusters(self, request, pk=None): + """ + Detach planner clusters from site blueprint. + + Request body: + { + "cluster_ids": [1, 2, 3], # List of cluster IDs to detach (optional: detach all if omitted) + "role": "hub" # Optional: only detach clusters with this role + } + + Returns: + { + "detached_count": 3 + } + """ + blueprint = self.get_object() + cluster_ids = request.data.get('cluster_ids', []) + role = request.data.get('role') + + # Build query + query = SiteBlueprintCluster.objects.filter(site_blueprint=blueprint) + + if cluster_ids: + query = query.filter(cluster_id__in=cluster_ids) + + if role: + valid_roles = [choice[0] for choice in SiteBlueprintCluster.ROLE_CHOICES] + if role not in valid_roles: + return error_response( + f'Invalid role. Must be one of: {", ".join(valid_roles)}', + status.HTTP_400_BAD_REQUEST, + request + ) + query = query.filter(role=role) + + detached_count = query.count() + query.delete() + + # Refresh workflow state if enabled + if self.workflow_service.enabled: + self.workflow_service.refresh_state(blueprint) + + return success_response( + data={'detached_count': detached_count}, + request=request + ) + + @action(detail=True, methods=['get'], url_path='taxonomies') + def list_taxonomies(self, request, pk=None): + """ + List taxonomies for a blueprint. + + Returns: + { + "count": 5, + "taxonomies": [...] + } + """ + blueprint = self.get_object() + taxonomies = blueprint.taxonomies.all().select_related().prefetch_related('clusters') + + # Serialize taxonomies + data = [] + for taxonomy in taxonomies: + data.append({ + 'id': taxonomy.id, + 'name': taxonomy.name, + 'slug': taxonomy.slug, + 'taxonomy_type': taxonomy.taxonomy_type, + 'description': taxonomy.description, + 'cluster_ids': list(taxonomy.clusters.values_list('id', flat=True)), + 'external_reference': taxonomy.external_reference, + 'created_at': taxonomy.created_at.isoformat(), + 'updated_at': taxonomy.updated_at.isoformat(), + }) + + return success_response( + data={'count': len(data), 'taxonomies': data}, + request=request + ) + + @action(detail=True, methods=['post'], url_path='taxonomies') + def create_taxonomy(self, request, pk=None): + """ + Create a taxonomy for a blueprint. + + Request body: + { + "name": "Product Categories", + "slug": "product-categories", + "taxonomy_type": "product_category", + "description": "Product category taxonomy", + "cluster_ids": [1, 2, 3], # Optional + "external_reference": "wp_term_123" # Optional + } + """ + blueprint = self.get_object() + name = request.data.get('name') + slug = request.data.get('slug') + taxonomy_type = request.data.get('taxonomy_type', 'blog_category') + description = request.data.get('description', '') + cluster_ids = request.data.get('cluster_ids', []) + external_reference = request.data.get('external_reference') + + if not name or not slug: + return error_response( + 'name and slug are required', + status.HTTP_400_BAD_REQUEST, + request + ) + + # Validate taxonomy type + valid_types = [choice[0] for choice in SiteBlueprintTaxonomy.TAXONOMY_TYPE_CHOICES] + if taxonomy_type not in valid_types: + return error_response( + f'Invalid taxonomy_type. Must be one of: {", ".join(valid_types)}', + status.HTTP_400_BAD_REQUEST, + request + ) + + # Create taxonomy + taxonomy = self.taxonomy_service.create_taxonomy( + blueprint, + name=name, + slug=slug, + taxonomy_type=taxonomy_type, + description=description, + clusters=cluster_ids if cluster_ids else None, + external_reference=external_reference, + ) + + # Refresh workflow state + if self.workflow_service.enabled: + self.workflow_service.refresh_state(blueprint) + + return success_response( + data={ + 'id': taxonomy.id, + 'name': taxonomy.name, + 'slug': taxonomy.slug, + 'taxonomy_type': taxonomy.taxonomy_type, + }, + request=request, + status_code=status.HTTP_201_CREATED + ) + + @action(detail=True, methods=['post'], url_path='taxonomies/import') + def import_taxonomies(self, request, pk=None): + """ + Import taxonomies from external source (WordPress/WooCommerce). + + Request body: + { + "records": [ + { + "name": "Category Name", + "slug": "category-slug", + "taxonomy_type": "blog_category", + "description": "Category description", + "external_reference": "wp_term_123" + }, + ... + ], + "default_type": "blog_category" # Optional + } + """ + blueprint = self.get_object() + records = request.data.get('records', []) + default_type = request.data.get('default_type', 'blog_category') + + if not records: + return error_response( + 'records array is required', + status.HTTP_400_BAD_REQUEST, + request + ) + + # Import taxonomies + imported = self.taxonomy_service.import_from_external( + blueprint, + records, + default_type=default_type + ) + + # Refresh workflow state + if self.workflow_service.enabled: + self.workflow_service.refresh_state(blueprint) + + return success_response( + data={ + 'imported_count': len(imported), + 'taxonomies': [ + { + 'id': t.id, + 'name': t.name, + 'slug': t.slug, + 'taxonomy_type': t.taxonomy_type, + } + for t in imported + ] + }, + request=request + ) + @action(detail=False, methods=['POST'], url_path='bulk_delete', url_name='bulk_delete') def bulk_delete(self, request): """ diff --git a/frontend/src/components/ui/button/ButtonWithTooltip.tsx b/frontend/src/components/ui/button/ButtonWithTooltip.tsx new file mode 100644 index 00000000..efc39842 --- /dev/null +++ b/frontend/src/components/ui/button/ButtonWithTooltip.tsx @@ -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 ( + + + + + + ); + } + + // Otherwise, render button normally + return ( + + ); +} + diff --git a/frontend/src/pages/Planner/Dashboard.tsx b/frontend/src/pages/Planner/Dashboard.tsx index 19e26b83..8542baed 100644 --- a/frontend/src/pages/Planner/Dashboard.tsx +++ b/frontend/src/pages/Planner/Dashboard.tsx @@ -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(null); const [loading, setLoading] = useState(true); const [lastUpdated, setLastUpdated] = useState(new Date()); + const [incompleteBlueprints, setIncompleteBlueprints] = useState([]); // 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 && ( + +
+
+ Incomplete Site Builder Workflows +

+ {incompleteBlueprints.length} blueprint{incompleteBlueprints.length > 1 ? 's' : ''} {incompleteBlueprints.length > 1 ? 'have' : 'has'} incomplete workflows that need attention: +

+
    + {incompleteBlueprints.map((bp) => ( +
  • + + {bp.name} + + {bp.workflow_state?.blocking_reason && ( + + - {bp.workflow_state.blocking_reason} + + )} +
  • + ))} +
+
+
+
+ )} +
{/* Key Metrics */}
diff --git a/frontend/src/pages/Sites/Builder/WorkflowWizard.tsx b/frontend/src/pages/Sites/Builder/WorkflowWizard.tsx index c280db87..b1381fc1 100644 --- a/frontend/src/pages/Sites/Builder/WorkflowWizard.tsx +++ b/frontend/src/pages/Sites/Builder/WorkflowWizard.tsx @@ -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 = { 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 (
@@ -97,6 +152,27 @@ export default function WorkflowWizard() {
+ {/* Header with Help Button */} +
+
+

+ Site Builder Workflow +

+

+ Step: {STEP_LABELS[currentStep]} +

+
+ +
+ {/* Progress Indicator */} @@ -105,6 +181,13 @@ export default function WorkflowWizard() { {StepComponent && }
+ + {/* Helper Drawer */} + setHelperDrawerOpen(false)} + />
); } diff --git a/frontend/src/pages/Sites/Builder/components/HelperDrawer.tsx b/frontend/src/pages/Sites/Builder/components/HelperDrawer.tsx new file mode 100644 index 00000000..a0eaa0f6 --- /dev/null +++ b/frontend/src/pages/Sites/Builder/components/HelperDrawer.tsx @@ -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 = { + 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 */} +
- +
{!canProceed && ( diff --git a/frontend/src/pages/Sites/Builder/steps/ClusterAssignmentStep.tsx b/frontend/src/pages/Sites/Builder/steps/ClusterAssignmentStep.tsx index ac6aeae6..aeecf8be 100644 --- a/frontend/src/pages/Sites/Builder/steps/ClusterAssignmentStep.tsx +++ b/frontend/src/pages/Sites/Builder/steps/ClusterAssignmentStep.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [selectedClusterIds, setSelectedClusterIds] = useState>(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(); + 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 ( Cluster Assignment - 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). {clusterBlocking && ( @@ -48,23 +231,215 @@ export default function ClusterAssignmentStep({ blueprintId }: ClusterAssignment
- {/* TODO: Add cluster selection table/list UI */} - - Cluster selection UI coming in next iteration. Use Planner → Clusters to manage clusters first. - + {/* Filters and Actions */} +
+
+
+ + setSearchTerm(e.target.value)} + placeholder="Search by cluster name..." + /> +
+
+ + setStatusFilter(value)} + options={[ + { value: '', label: 'All Status' }, + { value: 'active', label: 'Active' }, + { value: 'inactive', label: 'Inactive' }, + ]} + /> +
+
+ + setRoleFilter(value as any)} + options={[ + { value: '', label: 'All Roles' }, + { value: 'hub', label: 'Hub' }, + { value: 'supporting', label: 'Supporting' }, + { value: 'attribute', label: 'Attribute' }, + ]} + /> +
+
+ +
+
+ + setDefaultRole(value as any)} + options={[ + { value: 'hub', label: 'Hub Page' }, + { value: 'supporting', label: 'Supporting Page' }, + { value: 'attribute', label: 'Attribute Page' }, + ]} + /> +
+
+ + +
+
+
+ + {/* Cluster Table */} + {loading ? ( +
+ +
+ ) : filteredClusters.length === 0 ? ( + + {searchTerm || statusFilter || roleFilter + ? 'No clusters match your filters. Try adjusting your search criteria.' + : 'No clusters available. Create clusters in Planner → Clusters first.'} + + ) : ( +
+ + + + + + + Cluster Name + Keywords + Volume + Status + Attached + Role + + + + {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 ( + + + handleToggleCluster(cluster.id)} + /> + + +
{cluster.name}
+ {cluster.description && ( +
+ {cluster.description} +
+ )} +
+ {cluster.keywords_count || 0} + {cluster.volume?.toLocaleString() || 0} + + + {cluster.status || 'active'} + + + + {isAttached ? ( +
+ + Attached +
+ ) : ( +
+ + Not attached +
+ )} +
+ + {attachedCluster ? ( + + {attachedCluster.role} + + ) : ( + - + )} + +
+ ); + })} +
+
+
+ )} )} -
- + {workflowLoading ? ( + <> + + Loading... + + ) : ( + 'Continue' + )} +
); } - diff --git a/frontend/src/pages/Sites/Builder/steps/CoverageValidationStep.tsx b/frontend/src/pages/Sites/Builder/steps/CoverageValidationStep.tsx index 61a508a0..59bc5b99 100644 --- a/frontend/src/pages/Sites/Builder/steps/CoverageValidationStep.tsx +++ b/frontend/src/pages/Sites/Builder/steps/CoverageValidationStep.tsx @@ -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 ( Coverage Validation - Ensure all clusters and taxonomies have proper coverage. + Ensure all clusters and taxonomies have proper coverage before proceeding to content generation. {coverageBlocking && ( @@ -28,25 +56,175 @@ export default function CoverageValidationStep({ blueprintId }: CoverageValidati )} - {context && ( -
- {/* TODO: Add coverage summary cards */} - - Coverage validation UI coming in next iteration. - -
+ {hasErrors && ( + + Critical Issues Found: +
    + {attachedClusters === 0 &&
  • No clusters attached to blueprint
  • } + {clusterCoverage < 50 &&
  • Cluster coverage is below 50% ({clusterCoverage.toFixed(0)}%)
  • } + {sitemapCoverage < 50 &&
  • Sitemap coverage is below 50% ({sitemapCoverage.toFixed(0)}%)
  • } +
+
)} + {hasWarnings && !hasErrors && ( + + Warnings: +
    + {clusterCoverage < 70 &&
  • Cluster coverage is below recommended 70% ({clusterCoverage.toFixed(0)}%)
  • } + {sitemapCoverage < 70 &&
  • Sitemap coverage is below recommended 70% ({sitemapCoverage.toFixed(0)}%)
  • } + {totalTaxonomies === 0 &&
  • No taxonomies defined
  • } +
+
+ )} + +
+ {/* Cluster Coverage Card */} +
+
+

Cluster Coverage

+ + {clusterCoverage.toFixed(0)}% + +
+
+
+ Attached: + {attachedClusters} / {totalClusters} +
+
+ Complete: + {clusterStats.complete} +
+
+ In Progress: + {clusterStats.in_progress} +
+
+ Pending: + {clusterStats.pending} +
+
+
+
+
+
+ + {/* Taxonomy Coverage Card */} +
0 ? 'bg-blue-50 dark:bg-blue-900/20' : 'bg-red-50 dark:bg-red-900/20'} border-gray-200 dark:border-gray-700`}> +
+

Taxonomy Coverage

+ 0 ? 'text-blue-600 dark:text-blue-400' : 'text-red-600 dark:text-red-400'}`}> + {totalTaxonomies} defined + +
+
+ {totalTaxonomies === 0 ? ( +

+ No taxonomies defined. Define taxonomies in Step 3. +

+ ) : ( + <> + {Object.entries(taxonomyByType).map(([type, count]) => ( +
+ {type.replace('_', ' ')}: + {count as number} +
+ ))} + + )} +
+
+ + {/* Sitemap Coverage Card */} +
+
+

Sitemap Coverage

+ + {sitemapCoverage.toFixed(0)}% + +
+
+
+ Total Pages: + {totalPages} +
+ {context?.sitemap_summary?.by_type && ( +
+ {Object.entries(context.sitemap_summary.by_type).map(([type, count]) => ( +
+ {type}: + {count as number} +
+ ))} +
+ )} +
+
+
+
+
+
+ + {/* Validation Summary */} +
+

Validation Summary

+
+
+ {attachedClusters > 0 ? ( + + ) : ( + + )} + Clusters attached: {attachedClusters > 0 ? 'Yes' : 'No'} +
+
+ {clusterCoverage >= 70 ? ( + + ) : clusterCoverage >= 50 ? ( + + ) : ( + + )} + Cluster coverage: {clusterCoverage.toFixed(0)}% {clusterCoverage >= 70 ? '(Good)' : clusterCoverage >= 50 ? '(Fair)' : '(Poor)'} +
+
+ {totalTaxonomies > 0 ? ( + + ) : ( + + )} + Taxonomies defined: {totalTaxonomies > 0 ? 'Yes' : 'No'} +
+
+ {sitemapCoverage >= 70 ? ( + + ) : sitemapCoverage >= 50 ? ( + + ) : ( + + )} + Sitemap coverage: {sitemapCoverage.toFixed(0)}% {sitemapCoverage >= 70 ? '(Good)' : sitemapCoverage >= 50 ? '(Fair)' : '(Poor)'} +
+
+
+
- +
); } - diff --git a/frontend/src/pages/Sites/Builder/steps/IdeasHandoffStep.tsx b/frontend/src/pages/Sites/Builder/steps/IdeasHandoffStep.tsx index c08fe89e..3843a039 100644 --- a/frontend/src/pages/Sites/Builder/steps/IdeasHandoffStep.tsx +++ b/frontend/src/pages/Sites/Builder/steps/IdeasHandoffStep.tsx @@ -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([]); + const [selections, setSelections] = useState>(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(); + 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 = { + 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 ( Ideas Hand-off - 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. {ideasBlocking && ( @@ -28,25 +138,120 @@ export default function IdeasHandoffStep({ blueprintId }: IdeasHandoffStepProps) )} - {context && ( + {loading ? ( +
Loading pages...
+ ) : pages.length === 0 ? ( + + No pages found. Generate a sitemap first. + + ) : (
- {/* TODO: Add page selection UI */} - - Ideas hand-off UI coming in next iteration. - + {/* Selection Summary */} +
+
+ + {selectedCount} of {pages.length} pages selected + +
+
+ + +
+
+ + {/* Pages List */} +
+ {pages.map((page) => { + const selection = selections.get(page.id); + const isSelected = selection?.selected || false; + + return ( +
+
+
+ handleToggleSelection(page.id)} + /> +
+
+
+
+

{page.title}

+

+ /{page.slug} • {page.type} +

+
+ + {page.status} + +
+ + {showPrompts && isSelected && ( +
+ handlePromptChange(page.id, e.target.value)} + placeholder="Override the default content generation prompt for this page..." + multiline + rows={2} + /> +

+ Leave empty to use the default prompt generated from the page structure. +

+
+ )} +
+
+
+ ); + })} +
)} -
- + Skip + + + {submitting ? 'Creating Tasks...' : `Create Tasks (${selectedCount})`} +
); } - diff --git a/frontend/src/pages/Sites/Builder/steps/SitemapReviewStep.tsx b/frontend/src/pages/Sites/Builder/steps/SitemapReviewStep.tsx index 92136a14..635f152c 100644 --- a/frontend/src/pages/Sites/Builder/steps/SitemapReviewStep.tsx +++ b/frontend/src/pages/Sites/Builder/steps/SitemapReviewStep.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [editingId, setEditingId] = useState(null); + const [editForm, setEditForm] = useState<{ title: string; slug: string; type: string } | null>(null); + const [regeneratingId, setRegeneratingId] = useState(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 = { + 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 = { + 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 ( AI Sitemap Review - Review and adjust the AI-generated site structure. + Review and adjust the AI-generated site structure. Edit page details or regenerate pages as needed. {sitemapBlocking && ( @@ -29,27 +130,132 @@ export default function SitemapReviewStep({ blueprintId }: SitemapReviewStepProp )} {context?.sitemap_summary && ( -
-
- Total Pages: {context.sitemap_summary.total_pages} +
+
+
+ Total Pages: + {context.sitemap_summary.total_pages} +
+
+ Coverage: + {context.sitemap_summary.coverage_percentage}% +
+
+ By Type: +
+ {Object.entries(context.sitemap_summary.by_type).map(([type, count]) => ( + + {type}: {count} + + ))} +
+
+
+
+ )} + + {loading ? ( +
Loading pages...
+ ) : pages.length === 0 ? ( + + No pages found. Generate a sitemap first. + + ) : ( +
+
+ {pages.map((page) => ( +
+ {editingId === page.id ? ( +
+ setEditForm({ ...editForm!, title: e.target.value })} + placeholder="Page title" + /> + setEditForm({ ...editForm!, slug: e.target.value })} + placeholder="page-slug" + /> + setEditForm({ ...editForm!, type: e.target.value })} + placeholder="homepage, landing, blog, etc." + /> +
+ + +
+
+ ) : ( + <> +
+

{page.title}

+
+ + {page.status} + + + {page.type} + +
+
+

+ /{page.slug} +

+
+ + +
+ + )} +
+ ))}
- {/* TODO: Add sitemap grid/table UI */} - - Sitemap review UI coming in next iteration. -
)}
- +
); } - diff --git a/frontend/src/pages/Sites/Builder/steps/TaxonomyBuilderStep.tsx b/frontend/src/pages/Sites/Builder/steps/TaxonomyBuilderStep.tsx index ab328713..d8135874 100644 --- a/frontend/src/pages/Sites/Builder/steps/TaxonomyBuilderStep.tsx +++ b/frontend/src/pages/Sites/Builder/steps/TaxonomyBuilderStep.tsx @@ -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([]); + const [clusters, setClusters] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingTaxonomy, setEditingTaxonomy] = useState(null); + const [formData, setFormData] = useState({ + 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 ( Taxonomy Builder - 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. {taxonomyBlocking && ( @@ -30,26 +220,208 @@ export default function TaxonomyBuilderStep({ blueprintId }: TaxonomyBuilderStep {context?.taxonomy_summary && (
-
- Total Taxonomies: {context.taxonomy_summary.total} +
+
+
Total Taxonomies
+
{context.taxonomy_summary.total}
+
+ {Object.entries(context.taxonomy_summary.by_type || {}).slice(0, 3).map(([type, count]) => ( +
+
+ {type.replace('_', ' ')} +
+
{count as number}
+
+ ))}
- {/* TODO: Add taxonomy tree/table UI */} - - Taxonomy builder UI coming in next iteration. - + + {/* Filters and Actions */} +
+
+
+ + setSearchTerm(e.target.value)} + placeholder="Search by name or slug..." + /> +
+
+ + setTypeFilter(value)} + options={[ + { value: '', label: 'All Types' }, + ...TAXONOMY_TYPES, + ]} + /> +
+
+ + +
+
+
+ + {/* Taxonomy Table */} + {loading ? ( +
+ +
+ ) : filteredTaxonomies.length === 0 ? ( + + {searchTerm || typeFilter + ? 'No taxonomies match your filters. Try adjusting your search criteria.' + : 'No taxonomies defined yet. Create your first taxonomy to get started.'} + + ) : ( +
+ + + + Name + Slug + Type + Clusters + Description + Actions + + + + {filteredTaxonomies.map((taxonomy) => ( + + +
{taxonomy.name}
+
+ + + {taxonomy.slug} + + + + + {taxonomy.taxonomy_type.replace('_', ' ')} + + + + + {taxonomy.cluster_ids?.length || 0} linked + + + +
+ {taxonomy.description || '-'} +
+
+ +
+ +
+
+
+ ))} +
+
+
+ )}
)} + {/* Create/Edit Modal */} + +
+
+ + handleNameChange(e.target.value)} + placeholder="Product Categories" + required + /> +
+
+ + setFormData({ ...formData, slug: e.target.value })} + placeholder="product-categories" + required + /> +
+
+ + setFormData({ ...formData, taxonomy_type: value as any })} + options={TAXONOMY_TYPES} + /> +
+
+ +