Remove obsolete workflow components from site building; delete WorkflowState model, related services, and frontend steps. Update serializers and routes to reflect the removal of the site builder wizard functionality.

This commit is contained in:
IGNY8 VPS (Salman)
2025-11-20 23:25:00 +00:00
parent c31567ec9f
commit b38553cfc3
25 changed files with 0 additions and 5268 deletions

Binary file not shown.

View File

@@ -292,57 +292,6 @@ class SiteBlueprintTaxonomy(SiteSectorBaseModel):
return f"{self.name} ({self.get_taxonomy_type_display()})"
class WorkflowState(SiteSectorBaseModel):
"""
Persists wizard progress + gating data for each site blueprint.
"""
DEFAULT_STEP = 'business_details'
site_blueprint = models.OneToOneField(
SiteBlueprint,
on_delete=models.CASCADE,
related_name='workflow_state',
help_text="Blueprint whose progress is being tracked",
)
current_step = models.CharField(max_length=50, default=DEFAULT_STEP)
step_status = models.JSONField(
default=dict,
blank=True,
help_text="Dictionary of step → status/progress metadata",
)
blocking_reason = models.TextField(blank=True, null=True, help_text="Human-readable explanation when blocked")
completed = models.BooleanField(default=False, help_text="Marks wizard completion")
metadata = models.JSONField(default=dict, blank=True)
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
app_label = 'site_building'
db_table = 'igny8_site_blueprint_workflow_states'
verbose_name = 'Workflow State'
verbose_name_plural = 'Workflow States'
indexes = [
models.Index(fields=['site_blueprint']),
models.Index(fields=['current_step']),
models.Index(fields=['completed']),
]
def save(self, *args, **kwargs):
if self.site_blueprint:
# Only set fields if blueprint has them (avoid errors if blueprint is missing fields)
if self.site_blueprint.account_id:
self.account_id = self.site_blueprint.account_id
if self.site_blueprint.site_id:
self.site_id = self.site_blueprint.site_id
if self.site_blueprint.sector_id:
self.sector_id = self.site_blueprint.sector_id
super().save(*args, **kwargs)
def __str__(self):
return f"Workflow for {self.site_blueprint.name} ({self.current_step})"
class SiteBuilderOption(models.Model):
"""
Base model for Site Builder dropdown metadata.

View File

@@ -5,15 +5,11 @@ Site Building Services
from igny8_core.business.site_building.services.file_management_service import SiteBuilderFileService
from igny8_core.business.site_building.services.structure_generation_service import StructureGenerationService
from igny8_core.business.site_building.services.page_generation_service import PageGenerationService
from igny8_core.business.site_building.services.workflow_state_service import WorkflowStateService
from igny8_core.business.site_building.services.taxonomy_service import TaxonomyService
from igny8_core.business.site_building.services.wizard_context_service import WizardContextService
__all__ = [
'SiteBuilderFileService',
'StructureGenerationService',
'PageGenerationService',
'WorkflowStateService',
'TaxonomyService',
'WizardContextService',
]

View File

@@ -1,41 +0,0 @@
"""
Reusable validation helpers for the site builder workflow.
"""
from __future__ import annotations
from django.core.exceptions import ValidationError
from igny8_core.business.site_building.models import SiteBlueprint
def ensure_clusters_attached(site_blueprint: SiteBlueprint) -> bool:
if not site_blueprint.cluster_links.exists():
raise ValidationError("Attach at least one planner cluster before proceeding.")
return True
def ensure_taxonomies_defined(site_blueprint: SiteBlueprint) -> bool:
if not site_blueprint.taxonomies.exists():
raise ValidationError("Define or import at least one taxonomy to continue.")
return True
def ensure_sitemap_ready(site_blueprint: SiteBlueprint) -> bool:
if not site_blueprint.pages.exists():
raise ValidationError("Generate the AI sitemap before reviewing this step.")
return True
def ensure_coverage_ready(site_blueprint: SiteBlueprint) -> bool:
incomplete = site_blueprint.cluster_links.exclude(coverage_status='complete').exists()
if incomplete:
raise ValidationError("Complete coverage for all attached clusters.")
return True
def ensure_ideas_ready(site_blueprint: SiteBlueprint) -> bool:
if not site_blueprint.cluster_links.exists() or not site_blueprint.pages.exists():
raise ValidationError("Attach clusters and generate pages before sending ideas.")
return True

View File

@@ -1,138 +0,0 @@
"""
Wizard Context Service
Provides aggregated data for the site builder wizard UI.
"""
from __future__ import annotations
from collections import Counter
from typing import Dict, List, Optional
from django.db.models import Prefetch
from igny8_core.business.planning.models import Clusters
from igny8_core.business.site_building.models import (
PageBlueprint,
SiteBlueprint,
SiteBlueprintCluster,
SiteBlueprintTaxonomy,
)
from igny8_core.business.site_building.services.workflow_state_service import WorkflowStateService
class WizardContextService:
"""Builds blueprint-centric context for the guided wizard experience."""
def __init__(self):
self.workflow_service = WorkflowStateService()
def build_context(self, site_blueprint: SiteBlueprint) -> Dict[str, object]:
if not site_blueprint:
return {}
workflow_state = None
if self.workflow_service.enabled:
workflow_state = self.workflow_service.refresh_state(site_blueprint)
workflow_payload = self.workflow_service.serialize_state(workflow_state) if workflow_state else None
coverage_data = self._coverage_summary(site_blueprint)
context = {
'workflow': workflow_payload,
'cluster_summary': self._cluster_summary(site_blueprint),
'taxonomy_summary': self._taxonomy_summary(site_blueprint),
'sitemap_summary': coverage_data, # Frontend expects 'sitemap_summary' not 'coverage'
'coverage': coverage_data, # Keep for backward compatibility
}
context['next_actions'] = self._next_actions(workflow_payload)
return context
# ------------------------------------------------------------------
# Private helpers
# ------------------------------------------------------------------
def _cluster_summary(self, site_blueprint: SiteBlueprint) -> Dict[str, object]:
cluster_links: List[SiteBlueprintCluster] = list(
site_blueprint.cluster_links.select_related('cluster')
)
coverage_counts = Counter(link.coverage_status for link in cluster_links)
clusters_payload = []
for link in cluster_links:
cluster = link.cluster
dimension_meta = cluster.dimension_meta or {}
clusters_payload.append({
'id': cluster.id,
'name': cluster.name,
'context_type': cluster.context_type,
'dimension_meta': dimension_meta,
'keyword_count': cluster.keywords_count,
'volume': cluster.volume,
'coverage_status': link.coverage_status,
'role': link.role,
'metadata': link.metadata or {},
'suggested_taxonomies': dimension_meta.get('suggested_taxonomies', []),
'attribute_hints': dimension_meta.get('attributes', []),
})
return {
'attached_count': len(cluster_links),
'coverage_counts': dict(coverage_counts),
'clusters': clusters_payload,
}
def _taxonomy_summary(self, site_blueprint: SiteBlueprint) -> Dict[str, object]:
taxonomies: List[SiteBlueprintTaxonomy] = list(
site_blueprint.taxonomies.prefetch_related(
Prefetch('clusters', queryset=Clusters.objects.only('id'))
)
)
counts_by_type = Counter(taxonomy.taxonomy_type for taxonomy in taxonomies)
taxonomy_payload = []
for taxonomy in taxonomies:
taxonomy_payload.append({
'id': taxonomy.id,
'name': taxonomy.name,
'slug': taxonomy.slug,
'taxonomy_type': taxonomy.taxonomy_type,
'description': taxonomy.description,
'cluster_ids': [cluster.id for cluster in taxonomy.clusters.all()],
'metadata': taxonomy.metadata or {},
'external_reference': taxonomy.external_reference,
})
return {
'total_taxonomies': len(taxonomies),
'counts_by_type': dict(counts_by_type),
'taxonomies': taxonomy_payload,
}
def _coverage_summary(self, site_blueprint: SiteBlueprint) -> Dict[str, object]:
pages: List[PageBlueprint] = list(site_blueprint.pages.all())
per_status = Counter(page.status for page in pages)
per_type = Counter(page.type for page in pages)
return {
'pages_total': len(pages),
'pages_by_status': dict(per_status),
'pages_by_type': dict(per_type),
}
def _next_actions(self, workflow_payload: Optional[Dict[str, object]]) -> Optional[Dict[str, object]]:
if not workflow_payload:
return None
for step in workflow_payload.get('steps', []):
if step.get('status') != 'ready':
return {
'step': step.get('step'),
'status': step.get('status'),
'message': step.get('message'),
'code': step.get('code'),
}
return {
'step': None,
'status': 'ready',
'message': None,
'code': None,
}

View File

@@ -1,266 +0,0 @@
"""
Workflow State Service
Manages wizard progress + gating checks for site blueprints.
"""
from __future__ import annotations
import logging
from typing import Dict, List, Optional
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils import timezone
from igny8_core.business.site_building.models import SiteBlueprint, WorkflowState
from igny8_core.business.site_building.services import validators
logger = logging.getLogger(__name__)
DEFAULT_STEPS: List[str] = [
'business_details',
'clusters',
'taxonomies',
'sitemap',
'coverage',
'ideas',
]
STEP_VALIDATORS = {
'clusters': validators.ensure_clusters_attached,
'taxonomies': validators.ensure_taxonomies_defined,
'sitemap': validators.ensure_sitemap_ready,
'coverage': validators.ensure_coverage_ready,
'ideas': validators.ensure_ideas_ready,
}
STEP_CODES = {
'business_details': 'missing_business_details',
'clusters': 'missing_clusters',
'taxonomies': 'missing_taxonomies',
'sitemap': 'sitemap_not_generated',
'coverage': 'coverage_incomplete',
'ideas': 'ideas_not_ready',
}
class WorkflowStateService:
"""Centralizes workflow persistence + validation logic."""
def __init__(self):
self.enabled = getattr(settings, 'USE_SITE_BUILDER_REFACTOR', False)
def initialize(self, site_blueprint: SiteBlueprint) -> Optional[WorkflowState]:
if not self.enabled or not site_blueprint:
return None
state, _ = WorkflowState.objects.get_or_create(
site_blueprint=site_blueprint,
defaults={
'current_step': WorkflowState.DEFAULT_STEP,
'step_status': {},
},
)
return state
def refresh_state(self, site_blueprint: SiteBlueprint) -> Optional[WorkflowState]:
"""Re-run validators to keep the state snapshot fresh."""
state = self.initialize(site_blueprint)
if not state:
return None
timestamp = timezone.now().isoformat()
step_status: Dict[str, Dict[str, str]] = state.step_status or {}
blocking_reason = None
for step in DEFAULT_STEPS:
validator = STEP_VALIDATORS.get(step)
try:
if validator:
validator(site_blueprint)
step_status[step] = self._build_step_entry(
step=step,
status='ready',
message=None,
timestamp=timestamp,
)
except ValidationError as exc:
message = str(exc)
step_status[step] = self._build_step_entry(
step=step,
status='blocked',
message=message,
timestamp=timestamp,
)
if not blocking_reason:
blocking_reason = message
self._emit_event(site_blueprint, 'wizard_blocking_issue', {
'step': step,
'message': message,
})
state.step_status = step_status
state.blocking_reason = blocking_reason
state.completed = all(value.get('status') == 'ready' for value in step_status.values())
# Ensure account/site/sector are set from blueprint before saving
update_fields = ['step_status', 'blocking_reason', 'completed', 'updated_at']
if state.site_blueprint:
if state.site_blueprint.account_id:
state.account_id = state.site_blueprint.account_id
update_fields.append('account')
if state.site_blueprint.site_id:
state.site_id = state.site_blueprint.site_id
update_fields.append('site')
if state.site_blueprint.sector_id:
state.sector_id = state.site_blueprint.sector_id
update_fields.append('sector')
try:
state.save(update_fields=update_fields)
except Exception as e:
logger.error(
f"Failed to save workflow state for blueprint {site_blueprint.id}: {str(e)}. "
f"Blueprint fields: account_id={site_blueprint.account_id}, site_id={site_blueprint.site_id}, sector_id={site_blueprint.sector_id}",
exc_info=True
)
raise
return state
def update_step(
self,
site_blueprint: SiteBlueprint,
step: str,
status: str,
metadata: Optional[Dict[str, str]] = None,
) -> Optional[WorkflowState]:
"""Persist explicit step updates coming from the wizard."""
if not self.enabled:
return None
state = self.initialize(site_blueprint)
if not state:
return None
metadata = metadata or {}
timestamp = timezone.now().isoformat()
# Ensure step_status is a dict (handle None case)
if state.step_status is None:
state.step_status = {}
step_status = dict(state.step_status) # Create a copy to avoid mutation issues
entry = self._build_step_entry(
step=step,
status=status,
message=metadata.get('message'),
timestamp=timestamp,
)
entry.update({k: v for k, v in metadata.items() if k not in entry})
step_status[step] = entry
if step in DEFAULT_STEPS:
state.current_step = step
state.step_status = step_status
state.blocking_reason = metadata.get('message')
# Calculate completed status - only true if all steps are ready and we have at least one step
if step_status:
state.completed = all(
value.get('status') == 'ready' or value.get('status') == 'complete'
for value in step_status.values()
)
else:
state.completed = False
# Ensure account/site/sector are set from blueprint before saving
update_fields = ['current_step', 'step_status', 'blocking_reason', 'completed', 'updated_at']
if state.site_blueprint:
if state.site_blueprint.account_id:
state.account_id = state.site_blueprint.account_id
update_fields.append('account')
if state.site_blueprint.site_id:
state.site_id = state.site_blueprint.site_id
update_fields.append('site')
if state.site_blueprint.sector_id:
state.sector_id = state.site_blueprint.sector_id
update_fields.append('sector')
try:
state.save(update_fields=update_fields)
except Exception as e:
logger.error(
f"Failed to save workflow state for blueprint {site_blueprint.id}: {str(e)}. "
f"Blueprint fields: account_id={site_blueprint.account_id}, site_id={site_blueprint.site_id}, sector_id={site_blueprint.sector_id}",
exc_info=True
)
raise
self._emit_event(site_blueprint, 'wizard_step_updated', {
'step': step,
'status': status,
})
return state
def validate_step(self, site_blueprint: SiteBlueprint, step: str) -> None:
"""Run validator for a single step (raises ValidationError when blocked)."""
if not self.enabled:
return
validator = STEP_VALIDATORS.get(step)
if not validator:
return
validator(site_blueprint)
def serialize_state(self, state: Optional[WorkflowState]) -> Optional[Dict[str, object]]:
"""Return a stable payload for API consumers."""
if not self.enabled or not state:
return None
step_status = state.step_status or {}
steps_payload = []
for step in DEFAULT_STEPS:
meta = step_status.get(step, {})
steps_payload.append({
'step': step,
'status': meta.get('status', 'pending'),
'code': meta.get('code') or STEP_CODES.get(step),
'message': meta.get('message'),
'updated_at': meta.get('updated_at') or state.updated_at.isoformat(),
})
return {
'current_step': state.current_step,
'completed': state.completed,
'blocking_reason': state.blocking_reason,
'steps': steps_payload,
'updated_at': state.updated_at.isoformat() if hasattr(state.updated_at, 'isoformat') else str(state.updated_at),
}
def _build_step_entry(
self,
step: str,
status: str,
message: Optional[str],
timestamp: str,
) -> Dict[str, Optional[str]]:
return {
'status': status,
'code': STEP_CODES.get(step),
'message': message,
'updated_at': timestamp,
}
def _emit_event(self, site_blueprint: SiteBlueprint, event: str, payload: Optional[Dict[str, object]] = None) -> None:
if not self.enabled:
return
payload = payload or {}
logger.info(
"Wizard event: %s blueprint=%s site=%s account=%s payload=%s",
event,
site_blueprint.id,
site_blueprint.site_id,
site_blueprint.account_id,
payload,
)

View File

@@ -8,9 +8,7 @@ from igny8_core.business.site_building.models import (
HeroImageryDirection,
PageBlueprint,
SiteBlueprint,
WorkflowState,
)
from igny8_core.business.site_building.services.workflow_state_service import WorkflowStateService
class PageBlueprintSerializer(serializers.ModelSerializer):
@@ -48,8 +46,6 @@ class SiteBlueprintSerializer(serializers.ModelSerializer):
site_id = serializers.IntegerField(required=False, read_only=True)
sector_id = serializers.IntegerField(required=False, read_only=True)
account_id = serializers.IntegerField(read_only=True)
workflow_state = serializers.SerializerMethodField()
gating_messages = serializers.SerializerMethodField()
class Meta:
model = SiteBlueprint
@@ -69,8 +65,6 @@ class SiteBlueprintSerializer(serializers.ModelSerializer):
'created_at',
'updated_at',
'pages',
'workflow_state',
'gating_messages',
]
read_only_fields = [
'structure_json',
@@ -92,33 +86,6 @@ class SiteBlueprintSerializer(serializers.ModelSerializer):
attrs['sector_id'] = sector_id
return attrs
def get_workflow_state(self, obj):
return self._get_workflow_payload(obj)
def get_gating_messages(self, obj):
workflow_payload = self._get_workflow_payload(obj)
if not workflow_payload:
return None
blocked = [step for step in workflow_payload.get('steps', []) if step.get('status') == 'blocked']
return blocked or None
def _get_workflow_payload(self, obj):
if not getattr(settings, 'USE_SITE_BUILDER_REFACTOR', False):
return None
cache = self.context.setdefault('_workflow_state_cache', {})
if obj.id in cache:
return cache[obj.id]
try:
state: WorkflowState = obj.workflow_state
except WorkflowState.DoesNotExist:
state = None
service = getattr(self, '_workflow_service', None)
if service is None:
service = WorkflowStateService()
self._workflow_service = service
payload = service.serialize_state(state)
cache[obj.id] = payload
return payload
class MetadataOptionSerializer(serializers.Serializer):

View File

@@ -28,7 +28,6 @@ from igny8_core.business.site_building.services import (
SiteBuilderFileService,
StructureGenerationService,
TaxonomyService,
WizardContextService,
)
from igny8_core.modules.site_builder.serializers import (
PageBlueprintSerializer,
@@ -51,7 +50,6 @@ class SiteBlueprintViewSet(SiteSectorModelViewSet):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.taxonomy_service = TaxonomyService()
self.wizard_context_service = WizardContextService()
def get_permissions(self):
"""

View File

@@ -97,10 +97,6 @@ const SiteSettings = lazy(() => import("./pages/Sites/Settings"));
const SyncDashboard = lazy(() => import("./pages/Sites/SyncDashboard"));
const DeploymentPanel = lazy(() => import("./pages/Sites/DeploymentPanel"));
// Site Builder - Lazy loaded (will be moved from separate container)
const SiteBuilderWizard = lazy(() => import("./pages/Sites/Builder/Wizard"));
const SiteBuilderPreview = lazy(() => import("./pages/Sites/Builder/Preview"));
const SiteBuilderBlueprints = lazy(() => import("./pages/Sites/Builder/Blueprints"));
// Help - Lazy loaded
const Help = lazy(() => import("./pages/Help/Help"));
@@ -517,22 +513,6 @@ export default function App() {
</Suspense>
} />
{/* Site Builder */}
<Route path="/sites/builder" element={
<Suspense fallback={null}>
<SiteBuilderWizard />
</Suspense>
} />
<Route path="/sites/builder/preview" element={
<Suspense fallback={null}>
<SiteBuilderPreview />
</Suspense>
} />
<Route path="/sites/blueprints" element={
<Suspense fallback={null}>
<SiteBuilderBlueprints />
</Suspense>
} />
{/* Help */}
<Route path="/help" element={

View File

@@ -75,8 +75,6 @@ export const routes: RouteConfig[] = [
icon: 'Sites',
children: [
{ path: '/sites', label: 'All Sites', breadcrumb: 'All Sites' },
{ path: '/sites/builder', label: 'Create Site', breadcrumb: 'Site Builder' },
{ path: '/sites/blueprints', label: 'Blueprints', breadcrumb: 'Blueprints' },
],
},
];

View File

@@ -1,362 +0,0 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import PageMeta from "../../../components/common/PageMeta";
import { Card } from "../../../components/ui/card";
import Button from "../../../components/ui/button/Button";
import { useToast } from "../../../components/ui/toast/ToastContainer";
import ModuleNavigationTabs from "../../../components/navigation/ModuleNavigationTabs";
import {
TableIcon,
PlusIcon,
FileIcon,
TrashBinIcon,
CheckLineIcon,
BoxIcon,
PaperPlaneIcon,
BoltIcon
} from "../../../icons";
import { useSiteStore } from "../../../store/siteStore";
import { useBuilderStore } from "../../../store/builderStore";
import { siteBuilderApi } from "../../../services/siteBuilder.api";
import type { SiteBlueprint } from "../../../types/siteBuilder";
import AlertModal from "../../../components/ui/alert/AlertModal";
export default function SiteBuilderBlueprints() {
const navigate = useNavigate();
const toast = useToast();
const { activeSite } = useSiteStore();
const { loadBlueprint, isLoadingBlueprint, activeBlueprint } =
useBuilderStore();
const [blueprints, setBlueprints] = useState<SiteBlueprint[]>([]);
const [loading, setLoading] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [deleteConfirm, setDeleteConfirm] = useState<{
isOpen: boolean;
blueprint: SiteBlueprint | null;
isBulk: boolean;
}>({ isOpen: false, blueprint: null, isBulk: false });
const [deployingId, setDeployingId] = useState<number | null>(null);
const loadBlueprints = async (siteId: number) => {
try {
setLoading(true);
const results = await siteBuilderApi.listBlueprints(siteId);
setBlueprints(results);
} catch (error: any) {
toast.error(error?.message || "Failed to load blueprints");
} finally {
setLoading(false);
}
};
useEffect(() => {
if (activeSite?.id) {
loadBlueprints(activeSite.id);
} else {
setBlueprints([]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeSite?.id]);
const handleOpenPreview = async (blueprintId: number) => {
try {
await loadBlueprint(blueprintId);
toast.success("Loaded blueprint preview");
navigate("/sites/builder/preview");
} catch (error: any) {
toast.error(error?.message || "Unable to open blueprint");
}
};
const handleDeleteClick = (blueprint: SiteBlueprint) => {
setDeleteConfirm({ isOpen: true, blueprint, isBulk: false });
};
const handleBulkDeleteClick = () => {
if (selectedIds.size === 0) {
toast.error("No blueprints selected");
return;
}
setDeleteConfirm({ isOpen: true, blueprint: null, isBulk: true });
};
const handleDeleteConfirm = async () => {
try {
if (deleteConfirm.isBulk) {
// Bulk delete
const ids = Array.from(selectedIds);
const result = await siteBuilderApi.bulkDeleteBlueprints(ids);
const count = result?.deleted_count || ids.length;
toast.success(`${count} blueprint${count !== 1 ? 's' : ''} deleted successfully`);
setSelectedIds(new Set());
} else if (deleteConfirm.blueprint) {
// Single delete
await siteBuilderApi.deleteBlueprint(deleteConfirm.blueprint.id);
toast.success("Blueprint deleted successfully");
}
setDeleteConfirm({ isOpen: false, blueprint: null, isBulk: false });
if (activeSite?.id) {
await loadBlueprints(activeSite.id);
}
} catch (error: any) {
toast.error(error?.message || "Failed to delete blueprint(s)");
}
};
const toggleSelection = (id: number) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const toggleSelectAll = () => {
if (selectedIds.size === blueprints.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(blueprints.map(b => b.id)));
}
};
const handleDeploy = async (blueprint: SiteBlueprint) => {
try {
setDeployingId(blueprint.id);
const result = await siteBuilderApi.deployBlueprint(blueprint.id);
if (result.success) {
toast.success("Site deployed successfully!");
// Reload blueprints to get updated status
if (activeSite?.id) {
await loadBlueprints(activeSite.id);
}
} else {
toast.error("Failed to deploy site");
}
} catch (error: any) {
toast.error(error?.message || "Failed to deploy site");
} finally {
setDeployingId(null);
}
};
// Navigation tabs for Sites module
const sitesTabs = [
{ label: 'All Sites', path: '/sites', icon: <TableIcon className="w-4 h-4" /> },
{ label: 'Create Site', path: '/sites/builder', icon: <PlusIcon className="w-4 h-4" /> },
{ label: 'Blueprints', path: '/sites/blueprints', icon: <FileIcon className="w-4 h-4" /> },
];
return (
<div className="space-y-6 p-6">
<PageMeta title="Blueprints - IGNY8" description="View and manage site blueprints" />
{/* In-page navigation tabs */}
<ModuleNavigationTabs tabs={sitesTabs} />
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs uppercase tracking-wider text-gray-500 dark:text-white/50">
Sites / Blueprints
</p>
<h1 className="text-3xl font-semibold text-gray-900 dark:text-white">
Blueprints
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
Review and preview structures generated for your active site.
</p>
</div>
<div className="flex gap-2">
{selectedIds.size > 0 && (
<Button
onClick={handleBulkDeleteClick}
variant="solid"
tone="danger"
startIcon={<TrashBinIcon className="h-4 w-4" />}
>
Delete {selectedIds.size} selected
</Button>
)}
<Button
onClick={() => navigate("/sites/builder")}
variant="solid"
tone="brand"
startIcon={<PlusIcon className="h-4 w-4" />}
>
Create blueprint
</Button>
</div>
</div>
{!activeSite ? (
<Card className="p-8 text-center">
<p className="text-sm text-gray-500 dark:text-gray-400">
Select a site using the header switcher to view its blueprints.
</p>
</Card>
) : loading ? (
<div className="flex h-64 items-center justify-center text-gray-500 dark:text-gray-400">
<div className="mr-2 h-5 w-5 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
Loading blueprints
</div>
) : blueprints.length === 0 ? (
<Card className="p-12 text-center">
<FileIcon className="mx-auto mb-4 h-16 w-16 text-gray-400" />
<p className="mb-4 text-gray-600 dark:text-gray-400">
No blueprints created yet for {activeSite.name}.
</p>
<Button onClick={() => navigate("/sites/builder")} variant="solid" tone="brand">
Launch Site Builder
</Button>
</Card>
) : (
<div className="space-y-4">
{blueprints.length > 0 && (
<div className="flex items-center justify-between rounded-lg border border-gray-200 bg-gray-50 px-4 py-2 dark:border-white/10 dark:bg-white/[0.02]">
<button
onClick={toggleSelectAll}
className="flex items-center gap-2 text-sm text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white"
>
{selectedIds.size === blueprints.length ? (
<CheckLineIcon className="h-4 w-4" />
) : (
<BoxIcon className="h-4 w-4" />
)}
<span>
{selectedIds.size === blueprints.length
? "Deselect all"
: "Select all"}
</span>
</button>
{selectedIds.size > 0 && (
<span className="text-sm text-gray-600 dark:text-gray-400">
{selectedIds.size} of {blueprints.length} selected
</span>
)}
</div>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{blueprints.map((blueprint) => {
return (
<Card
key={blueprint.id}
className={`space-y-4 p-5 ${
selectedIds.has(blueprint.id)
? "ring-2 ring-brand-500 dark:ring-brand-400"
: ""
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-xs uppercase tracking-wider text-gray-500 dark:text-white/50">
Blueprint #{blueprint.id}
</p>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{blueprint.name}
</h3>
</div>
<button
onClick={() => toggleSelection(blueprint.id)}
className="ml-2 flex-shrink-0"
>
{selectedIds.has(blueprint.id) ? (
<CheckLineIcon className="h-5 w-5 text-brand-600 dark:text-brand-400" />
) : (
<BoxIcon className="h-5 w-5 text-gray-400" />
)}
</button>
</div>
{blueprint.description && (
<p className="text-sm text-gray-500 dark:text-gray-400">
{blueprint.description}
</p>
)}
<div className="flex items-center justify-between rounded-2xl bg-gray-50 px-4 py-3 text-sm font-semibold text-gray-700 dark:bg-white/[0.04] dark:text-white/80">
<span>Status</span>
<span className="capitalize">{blueprint.status}</span>
</div>
<div className="flex flex-col gap-2">
<Button
variant="solid"
tone="brand"
onClick={() => navigate(`/sites/builder/workflow/${blueprint.id}`)}
startIcon={<BoltIcon className="h-4 w-4" />}
>
Continue Workflow
</Button>
{(blueprint.status === 'ready' || blueprint.status === 'deployed') && (
<Button
variant="solid"
tone="brand"
disabled={deployingId === blueprint.id}
onClick={() => handleDeploy(blueprint)}
startIcon={deployingId === blueprint.id ? <div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" /> : <PaperPlaneIcon className="h-4 w-4" />}
>
{deployingId === blueprint.id ? "Deploying..." : blueprint.status === 'deployed' ? "Redeploy" : "Deploy Site"}
</Button>
)}
<Button
variant="outline"
tone="brand"
disabled={isLoadingBlueprint}
onClick={() => handleOpenPreview(blueprint.id)}
>
{isLoadingBlueprint && activeBlueprint?.id === blueprint.id ? (
<>
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
Loading
</>
) : (
"Open preview"
)}
</Button>
<div className="flex gap-2">
<Button
variant="ghost"
tone="neutral"
fullWidth
onClick={() => navigate(`/sites/${blueprint.site}/editor`)}
>
Open in editor
</Button>
<Button
variant="ghost"
tone="danger"
onClick={() => handleDeleteClick(blueprint)}
startIcon={<TrashBinIcon className="h-4 w-4" />}
>
Delete
</Button>
</div>
</div>
</Card>
);
})}
</div>
</div>
)}
<AlertModal
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, blueprint: null, isBulk: false })}
title={deleteConfirm.isBulk ? "Delete Blueprints" : "Delete Blueprint"}
message={
deleteConfirm.isBulk
? `Are you sure you want to delete ${selectedIds.size} blueprint${selectedIds.size !== 1 ? 's' : ''}? This will also delete all associated page blueprints. This action cannot be undone.`
: `Are you sure you want to delete "${deleteConfirm.blueprint?.name}"? This will also delete all associated page blueprints. This action cannot be undone.`
}
variant="danger"
isConfirmation={true}
onConfirm={handleDeleteConfirm}
confirmText="Delete"
cancelText="Cancel"
/>
</div>
);
}

View File

@@ -1,262 +0,0 @@
import { useMemo, useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import PageMeta from "../../../components/common/PageMeta";
import {
Card,
CardDescription,
CardTitle,
} from "../../../components/ui/card";
import Button from "../../../components/ui/button/Button";
import Alert from "../../../components/ui/alert/Alert";
import { useBuilderStore } from "../../../store/builderStore";
import { useSiteDefinitionStore } from "../../../store/siteDefinitionStore";
import ProgressModal from "../../../components/common/ProgressModal";
import { EyeIcon, PaperPlaneIcon } from "../../../icons";
import { useToast } from "../../../components/ui/toast/ToastContainer";
import { siteBuilderApi } from "../../../services/siteBuilder.api";
export default function SiteBuilderPreview() {
const navigate = useNavigate();
const toast = useToast();
const {
activeBlueprint,
pages,
generateAllPages,
isGenerating,
generationProgress
} = useBuilderStore();
const { structure, selectedSlug, selectPage } = useSiteDefinitionStore();
const [showProgress, setShowProgress] = useState(false);
const [isDeploying, setIsDeploying] = useState(false);
const selectedPageDefinition = useMemo(() => {
return structure?.pages?.find((page) => page.slug === selectedSlug);
}, [structure, selectedSlug]);
useEffect(() => {
if (generationProgress?.celeryTaskId) {
setShowProgress(true);
}
}, [generationProgress?.celeryTaskId]);
const handleGenerateAll = async () => {
if (!activeBlueprint) return;
setShowProgress(true);
try {
await generateAllPages(activeBlueprint.id);
toast.success("Page generation queued successfully");
} catch (error: any) {
toast.error(error?.message || "Failed to generate pages");
setShowProgress(false);
}
};
const handleDeploy = async () => {
if (!activeBlueprint) return;
try {
setIsDeploying(true);
const result = await siteBuilderApi.deployBlueprint(activeBlueprint.id);
if (result.success) {
toast.success("Site deployed successfully!");
// Reload blueprint to get updated status
await useBuilderStore.getState().loadBlueprint(activeBlueprint.id);
} else {
toast.error("Failed to deploy site");
}
} catch (error: any) {
toast.error(error?.message || "Failed to deploy site");
} finally {
setIsDeploying(false);
}
};
if (!activeBlueprint) {
return (
<div className="space-y-6 p-6">
<PageMeta title="Site Preview - IGNY8" description="Preview site structure and pages" />
<Card className="p-12 text-center">
<EyeIcon className="mx-auto mb-4 h-16 w-16 text-gray-400" />
<p className="text-gray-600 dark:text-gray-400">
Run the Site Builder wizard or open a blueprint to preview it.
</p>
<Button
className="mt-6"
variant="solid"
tone="brand"
onClick={() => navigate("/sites/builder")}
>
Back to wizard
</Button>
</Card>
</div>
);
}
return (
<div className="space-y-6 p-6">
<PageMeta title="Site Preview - IGNY8" description="Preview site structure and pages" />
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs uppercase tracking-wider text-gray-500 dark:text-white/50">
Sites / Preview
</p>
<h1 className="text-3xl font-semibold text-gray-900 dark:text-white">
{activeBlueprint.name}
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
Inspect the generated structure before publishing or deploying.
</p>
</div>
<div className="flex gap-2">
{activeBlueprint.status === 'ready' && (
<Button
variant="solid"
tone="brand"
onClick={handleGenerateAll}
disabled={isGenerating}
startIcon={isGenerating ? <div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" /> : <PaperPlaneIcon className="h-4 w-4" />}
>
{isGenerating ? "Generating..." : "Generate All Pages"}
</Button>
)}
{(activeBlueprint.status === 'ready' || activeBlueprint.status === 'deployed') && (
<Button
variant="solid"
tone="brand"
onClick={handleDeploy}
disabled={isDeploying}
startIcon={isDeploying ? <div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" /> : <PaperPlaneIcon className="h-4 w-4" />}
>
{isDeploying ? "Deploying..." : activeBlueprint.status === 'deployed' ? "Redeploy" : "Deploy Site"}
</Button>
)}
<Button variant="outline" onClick={() => navigate("/sites/builder")}>
Back to wizard
</Button>
</div>
</div>
<Alert
variant="info"
title="Preview snapshot"
message="This preview uses the latest blueprint data. Re-run the wizard to generate a new version."
/>
<div className="grid gap-6 lg:grid-cols-[280px,1fr]">
<Card variant="panel" padding="lg">
<CardTitle>Pages</CardTitle>
<CardDescription>Choose a page to inspect its blocks.</CardDescription>
<div className="mt-4 space-y-2">
{pages.length === 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400">
No pages yet. Run the wizard to generate structure.
</p>
)}
{pages.map((page) => (
<button
key={page.id}
type="button"
onClick={() => selectPage(page.slug)}
className={`w-full rounded-2xl border px-4 py-3 text-left ${
selectedSlug === page.slug
? "border-brand-300 bg-brand-50 text-brand-700 dark:border-brand-500/40 dark:bg-brand-500/10 dark:text-brand-50"
: "border-gray-200 bg-white text-gray-700 hover:border-brand-100 hover:bg-brand-50/60 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/80"
}`}
>
<div className="text-sm font-semibold">{page.title}</div>
<div className="text-xs capitalize text-gray-500 dark:text-gray-400">
{page.status}
</div>
</button>
))}
</div>
</Card>
<Card variant="surface" padding="lg">
{selectedPageDefinition ? (
<div className="space-y-4">
<div>
<p className="text-xs uppercase tracking-wider text-gray-500 dark:text-white/50">
Page
</p>
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white">
{selectedPageDefinition.title}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
{selectedPageDefinition.objective ||
"No objective provided"}
</p>
</div>
<div className="space-y-3">
{selectedPageDefinition.blocks?.map((block, index) => (
<div
key={`${block.type}-${index}`}
className="rounded-2xl border border-gray-100 bg-gray-50/80 p-4 dark:border-white/10 dark:bg-white/[0.03]"
>
<p className="text-xs uppercase tracking-wider text-gray-500 dark:text-white/40">
Block {index + 1}
</p>
<p className="text-lg font-semibold text-gray-900 dark:text-white">
{block.type.replace(/_/g, " ")}
</p>
{Array.isArray(block.content) ? (
<ul className="mt-2 list-disc space-y-1 pl-4 text-sm text-gray-600 dark:text-gray-300">
{block.content.map((line, idx) => (
<li key={idx}>{line}</li>
))}
</ul>
) : block.content ? (
<pre className="mt-2 rounded-lg bg-white/70 p-3 text-xs text-gray-600 dark:bg-white/[0.05] dark:text-gray-300">
{JSON.stringify(block.content, null, 2)}
</pre>
) : (
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
No content provided
</p>
)}
</div>
))}
{!selectedPageDefinition.blocks?.length && (
<p className="text-sm text-gray-500 dark:text-gray-400">
This page has no block definitions yet.
</p>
)}
</div>
</div>
) : (
<div className="text-center text-gray-500 dark:text-gray-400">
Select a page to see its details.
</div>
)}
</Card>
</div>
<ProgressModal
isOpen={showProgress}
onClose={() => setShowProgress(false)}
title="Generating Pages"
percentage={isGenerating ? 50 : 100}
status={isGenerating ? 'processing' : generationProgress ? 'completed' : 'pending'}
message={
isGenerating
? `Generating content for ${generationProgress?.pagesQueued || pages.length} page${(generationProgress?.pagesQueued || pages.length) !== 1 ? 's' : ''}...`
: 'Generation completed!'
}
details={
generationProgress
? {
current: generationProgress.pagesQueued,
total: generationProgress.pagesQueued,
completed: generationProgress.pagesQueued,
}
: undefined
}
taskId={generationProgress?.celeryTaskId}
/>
</div>
);
}

View File

@@ -1,605 +0,0 @@
import { useEffect, useMemo, useState, useCallback, useRef } from "react";
import { useNavigate } from "react-router-dom";
import {
Card,
CardDescription,
CardTitle,
} from "../../../components/ui/card";
import Button from "../../../components/ui/button/Button";
import PageMeta from "../../../components/common/PageMeta";
import SiteAndSectorSelector from "../../../components/common/SiteAndSectorSelector";
import PageHeader from "../../../components/common/PageHeader";
import Alert from "../../../components/ui/alert/Alert";
import ModuleNavigationTabs from "../../../components/navigation/ModuleNavigationTabs";
import {
GridIcon,
ArrowLeftIcon,
ArrowRightIcon,
BoltIcon,
TableIcon,
PlusIcon,
FileIcon,
} from "../../../icons";
import { useSiteStore } from "../../../store/siteStore";
import { useSectorStore } from "../../../store/sectorStore";
import { useBuilderStore } from "../../../store/builderStore";
import { BusinessDetailsStep } from "./steps/BusinessDetailsStep";
import { BriefStep } from "./steps/BriefStep";
import { ObjectivesStep } from "./steps/ObjectivesStep";
import { StyleStep } from "./steps/StyleStep";
import { useProgressModal } from "../../../hooks/useProgressModal";
import { useResourceDebug } from "../../../hooks/useResourceDebug";
import ProgressModal from "../../../components/common/ProgressModal";
export default function SiteBuilderWizard() {
const navigate = useNavigate();
const { activeSite } = useSiteStore();
const { sectors } = useSectorStore();
const {
form,
currentStep,
setStep,
setField,
updateStyle,
addObjective,
removeObjective,
nextStep,
previousStep,
submitWizard,
isSubmitting,
error,
activeBlueprint,
refreshPages,
pages,
generationProgress,
isGenerating,
syncContextFromStores,
metadata,
metadataError,
isMetadataLoading,
loadMetadata,
loadBlueprint,
} = useBuilderStore();
// Progress modal for AI functions
const progressModal = useProgressModal();
// Resource Debug toggle - controls AI Function Logs
const resourceDebugEnabled = useResourceDebug();
// AI Function Logs state
const [aiLogs, setAiLogs] = useState<Array<{
timestamp: string;
type: 'request' | 'success' | 'error' | 'step';
action: string;
data: any;
stepName?: string;
percentage?: number;
}>>([]);
// Track last logged step to avoid duplicates
const lastLoggedStepRef = useRef<string | null>(null);
const lastLoggedPercentageRef = useRef<number>(-1);
const hasReloadedRef = useRef<boolean>(false);
// Helper function to add log entry (only if Resource Debug is enabled)
const addAiLog = useCallback((log: {
timestamp: string;
type: 'request' | 'success' | 'error' | 'step';
action: string;
data: any;
stepName?: string;
percentage?: number;
}) => {
if (resourceDebugEnabled) {
setAiLogs(prev => [...prev, log]);
}
}, [resourceDebugEnabled]);
useEffect(() => {
syncContextFromStores();
}, [activeSite?.id, activeSite?.name]);
useEffect(() => {
loadMetadata();
}, [loadMetadata]);
// Track structure generation task and open progress modal
const structureTaskId = useBuilderStore((state) => state.structureTaskId);
useEffect(() => {
if (structureTaskId) {
// Log initial request
addAiLog({
timestamp: new Date().toISOString(),
type: 'request',
action: 'Generate Site Structure',
data: {
taskId: structureTaskId,
blueprintId: activeBlueprint?.id,
message: 'Structure generation task queued',
},
});
progressModal.openModal(structureTaskId, 'Generating Site Structure', 'ai-generate-site-structure-01-desktop');
}
}, [structureTaskId, progressModal, addAiLog, activeBlueprint?.id]);
// Log AI function progress steps
useEffect(() => {
if (!progressModal.taskId || !progressModal.isOpen) {
return;
}
const progress = progressModal.progress;
const currentStep = progress.details?.phase || '';
const currentPercentage = progress.percentage;
const currentMessage = progress.message;
const currentStatus = progress.status;
// Log step changes
if (currentStep && currentStep !== lastLoggedStepRef.current) {
const stepType = currentStatus === 'error' ? 'error' :
currentStatus === 'completed' ? 'success' : 'step';
addAiLog({
timestamp: new Date().toISOString(),
type: stepType,
action: progressModal.title || 'Generate Site Structure',
stepName: currentStep,
percentage: currentPercentage,
data: {
step: currentStep,
message: currentMessage,
percentage: currentPercentage,
status: currentStatus,
details: progress.details,
},
});
lastLoggedStepRef.current = currentStep;
lastLoggedPercentageRef.current = currentPercentage;
}
// Log percentage changes for same step (if significant change)
else if (currentStep && Math.abs(currentPercentage - lastLoggedPercentageRef.current) >= 10) {
const stepType = currentStatus === 'error' ? 'error' :
currentStatus === 'completed' ? 'success' : 'step';
addAiLog({
timestamp: new Date().toISOString(),
type: stepType,
action: progressModal.title || 'Generate Site Structure',
stepName: currentStep,
percentage: currentPercentage,
data: {
step: currentStep,
message: currentMessage,
percentage: currentPercentage,
status: currentStatus,
details: progress.details,
},
});
lastLoggedPercentageRef.current = currentPercentage;
}
// Log status changes (error, completed)
else if (currentStatus === 'error' || currentStatus === 'completed') {
// Only log if we haven't already logged this status for this step
if (currentStep !== lastLoggedStepRef.current ||
(currentStatus === 'error' && lastLoggedStepRef.current !== 'error') ||
(currentStatus === 'completed' && lastLoggedStepRef.current !== 'completed')) {
const stepType = currentStatus === 'error' ? 'error' : 'success';
addAiLog({
timestamp: new Date().toISOString(),
type: stepType,
action: progressModal.title || 'Generate Site Structure',
stepName: currentStep || 'Final',
percentage: currentPercentage,
data: {
step: currentStep || 'Final',
message: currentMessage,
percentage: currentPercentage,
status: currentStatus,
details: progress.details,
},
});
lastLoggedStepRef.current = currentStep || currentStatus;
}
}
}, [progressModal.progress, progressModal.taskId, progressModal.isOpen, progressModal.title, addAiLog]);
// Reset step tracking when modal closes
useEffect(() => {
if (!progressModal.isOpen) {
lastLoggedStepRef.current = null;
lastLoggedPercentageRef.current = -1;
hasReloadedRef.current = false;
} else {
hasReloadedRef.current = false;
}
}, [progressModal.isOpen]);
const selectedSectors = useMemo(
() =>
form.sectorIds.map((id) => ({
id,
name: sectors.find((sector) => sector.id === id)?.name || `Sector #${id}`,
})),
[form.sectorIds, sectors],
);
const steps = useMemo(
() => [
{
title: "Business context",
component: (
<BusinessDetailsStep
data={form}
onChange={setField}
metadata={metadata}
selectedSectors={selectedSectors}
/>
),
},
{
title: "Brand brief",
component: <BriefStep data={form} onChange={setField} />,
},
{
title: "Objectives",
component: (
<ObjectivesStep
data={form}
addObjective={addObjective}
removeObjective={removeObjective}
/>
),
},
{
title: "Look & feel",
component: (
<StyleStep
style={form.style}
metadata={metadata}
brandPersonalityIds={form.brandPersonalityIds}
customBrandPersonality={form.customBrandPersonality}
heroImageryDirectionId={form.heroImageryDirectionId}
customHeroImageryDirection={form.customHeroImageryDirection}
onStyleChange={updateStyle}
onChange={setField}
/>
),
},
],
[
form,
metadata,
selectedSectors,
setField,
updateStyle,
addObjective,
removeObjective,
],
);
const isLastStep = currentStep === steps.length - 1;
const missingContext =
!activeSite || !form.sectorIds || form.sectorIds.length === 0;
const handlePrimary = async () => {
if (isLastStep) {
await submitWizard();
} else {
nextStep();
}
};
const renderPrimaryIcon = () => {
if (isSubmitting) {
return (
<span className="inline-flex h-4 w-4 items-center justify-center">
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white/70 border-t-transparent" />
</span>
);
}
if (isLastStep) {
return <ArrowRightIcon className="size-4" />;
}
return undefined;
};
// Navigation tabs for Sites module
const sitesTabs = [
{ label: 'All Sites', path: '/sites', icon: <TableIcon className="w-4 h-4" /> },
{ label: 'Create Site', path: '/sites/builder', icon: <PlusIcon className="w-4 h-4" /> },
{ label: 'Blueprints', path: '/sites/blueprints', icon: <FileIcon className="w-4 h-4" /> },
];
return (
<div className="space-y-6 p-6">
<PageMeta title="Create Site - IGNY8" />
{/* In-page navigation tabs */}
<ModuleNavigationTabs tabs={sitesTabs} />
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<PageHeader
title="Site Builder"
badge={{ icon: <GridIcon className="text-white size-5" />, color: "purple" }}
hideSiteSector
/>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<p className="text-sm text-gray-600 dark:text-gray-400 max-w-2xl">
Use the AI-powered wizard to capture business context, brand direction, and tone before generating blueprints.
</p>
<Button
variant="outline"
onClick={() => navigate("/sites")}
startIcon={<ArrowLeftIcon className="size-4" />}
>
Back to Sites
</Button>
</div>
</div>
<SiteAndSectorSelector hideSectorSelector />
{metadataError && (
<Alert
variant="warning"
title="Metadata unavailable"
message={`${metadataError}. You can still enter custom values.`}
/>
)}
{isMetadataLoading && !metadata && (
<Alert
variant="info"
title="Loading Site Builder library"
message="Fetching business types, audiences, and style presets..."
/>
)}
{missingContext && (
<Alert
variant="warning"
title="Missing site configuration"
message="Choose an active site and ensure it has at least one sector configured (Sites → All Sites) before running the wizard."
/>
)}
<div className="grid gap-6 lg:grid-cols-[2fr,1fr]">
<div className="space-y-6">
<Card variant="panel" padding="lg">
<div className="flex flex-wrap gap-3">
{steps.map((step, index) => (
<button
key={step.title}
type="button"
onClick={() => index < currentStep + 1 && setStep(index)}
className={`flex flex-col items-start rounded-2xl border px-4 py-3 text-left transition ${
index === currentStep
? "border-brand-300 bg-brand-50 dark:border-brand-500/40 dark:bg-brand-500/10"
: "border-gray-200 bg-white dark:border-white/10 dark:bg-white/[0.02]"
}`}
disabled={index > currentStep}
>
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
Step {index + 1}
</span>
<span className="text-sm font-semibold text-gray-900 dark:text-white">
{step.title}
</span>
</button>
))}
</div>
</Card>
{steps[currentStep].component}
{error && (
<Alert variant="error" title="Something went wrong" message={error} />
)}
<div className="flex flex-col gap-3 border-t border-gray-100 pt-4 dark:border-white/10 md:flex-row md:items-center md:justify-between">
<div className="text-sm text-gray-500 dark:text-gray-400">
Step {currentStep + 1} of {steps.length}
</div>
<div className="flex gap-3">
<Button
variant="outline"
tone="neutral"
disabled={currentStep === 0 || isSubmitting}
onClick={previousStep}
>
Back
</Button>
<Button
variant="solid"
tone="brand"
disabled={missingContext || isSubmitting}
onClick={handlePrimary}
startIcon={renderPrimaryIcon()}
>
{isLastStep ? "Generate structure" : "Next"}
</Button>
</div>
</div>
</div>
<div className="space-y-6">
<Card variant="surface" padding="lg">
<CardTitle>Latest blueprint</CardTitle>
<CardDescription>
Once the wizard finishes, the most recent blueprint appears here.
</CardDescription>
{activeBlueprint ? (
<div className="mt-4 space-y-4">
<div className="flex items-center justify-between rounded-2xl bg-gray-50 px-4 py-3 text-sm font-semibold text-gray-700 dark:bg-white/[0.04] dark:text-white/80">
<span>Status</span>
<span className="capitalize">{activeBlueprint.status}</span>
</div>
<div className="flex items-center justify-between rounded-2xl bg-gray-50 px-4 py-3 text-sm font-semibold text-gray-700 dark:bg-white/[0.04] dark:text-white/80">
<span>Pages generated</span>
<span>{pages.length}</span>
</div>
<div className="flex flex-col gap-3 sm:flex-row">
<Button
variant="outline"
tone="brand"
fullWidth
startIcon={<BoltIcon className="size-4" />}
onClick={() => refreshPages(activeBlueprint.id)}
>
Sync pages
</Button>
<Button
variant="soft"
tone="brand"
fullWidth
disabled={isGenerating}
endIcon={<ArrowRightIcon className="size-4" />}
onClick={() =>
loadBlueprint(activeBlueprint.id).then(() =>
navigate("/sites/builder/preview"),
)
}
>
Open preview
</Button>
</div>
</div>
) : (
<div className="mt-4 rounded-2xl border border-dashed border-gray-200 bg-gray-50/60 p-6 text-center text-sm text-gray-500 dark:border-white/10 dark:bg-white/[0.03] dark:text-white/60">
Run the wizard to create your first blueprint.
</div>
)}
</Card>
{generationProgress && (
<Card variant="panel" padding="lg">
<CardTitle>Generation progress</CardTitle>
<CardDescription>
Tracking background tasks queued for this blueprint.
</CardDescription>
<div className="mt-4 space-y-3 text-sm text-gray-600 dark:text-gray-300">
<div className="flex items-center justify-between rounded-xl bg-white/70 px-3 py-2 dark:bg-white/[0.04]">
<span>Pages queued</span>
<span>{generationProgress.pagesQueued}</span>
</div>
<div className="rounded-xl bg-white/70 px-3 py-2 text-xs dark:bg-white/[0.04]">
<p className="font-semibold text-gray-800 dark:text-white/90">
Task IDs
</p>
<p className="break-all">
{generationProgress.taskIds.join(", ")}
</p>
</div>
</div>
{generationProgress.celeryTaskId && (
<p className="mt-3 text-xs text-gray-500 dark:text-gray-400">
Celery task ID: {generationProgress.celeryTaskId}
</p>
)}
</Card>
)}
{isGenerating && (
<Alert
variant="info"
title="Background generation running"
message="You can leave this page safely. Well keep processing and update the blueprint when tasks finish."
/>
)}
</div>
</div>
<ProgressModal
isOpen={progressModal.isOpen}
progress={progressModal.progress}
title={progressModal.title}
taskId={progressModal.taskId || undefined}
functionId={progressModal.functionId}
onClose={() => {
progressModal.closeModal();
// Reload pages when modal closes if task was completed
if (progressModal.progress.status === 'completed' && !hasReloadedRef.current && activeBlueprint) {
hasReloadedRef.current = true;
refreshPages(activeBlueprint.id);
}
// Clear structure task ID
useBuilderStore.setState({ structureTaskId: null });
}}
/>
{/* AI Function Logs - Display below content (only when Resource Debug is enabled) */}
{resourceDebugEnabled && aiLogs.length > 0 && (
<div className="mt-6 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
AI Function Logs
</h3>
<button
onClick={() => setAiLogs([])}
className="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Clear Logs
</button>
</div>
<div className="space-y-2 max-h-96 overflow-y-auto">
{aiLogs.slice().reverse().map((log, index) => (
<div
key={index}
className={`p-3 rounded border text-xs font-mono ${
log.type === 'request'
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
: log.type === 'success'
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
: log.type === 'error'
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800'
: 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800'
}`}
>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2 flex-wrap">
<span className={`font-semibold ${
log.type === 'request'
? 'text-blue-700 dark:text-blue-300'
: log.type === 'success'
? 'text-green-700 dark:text-green-300'
: log.type === 'error'
? 'text-red-700 dark:text-red-300'
: 'text-purple-700 dark:text-purple-300'
}`}>
[{log.type.toUpperCase()}]
</span>
<span className="text-gray-700 dark:text-gray-300">
{log.action}
</span>
{log.stepName && (
<span className="text-xs px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
{log.stepName}
</span>
)}
{log.percentage !== undefined && (
<span className="text-xs text-gray-500 dark:text-gray-400">
{log.percentage}%
</span>
)}
</div>
<span className="text-gray-500 dark:text-gray-400">
{new Date(log.timestamp).toLocaleTimeString()}
</span>
</div>
<pre className="text-xs text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-words">
{JSON.stringify(log.data, null, 2)}
</pre>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,158 +0,0 @@
/**
* Helper Drawer Component
* Contextual help for each wizard step
*/
import { useState } from 'react';
import { WizardStep } from '../../../../store/builderWorkflowStore';
import Button from '../../../../components/ui/button/Button';
import { CloseIcon, InfoIcon, ArrowRightIcon } from '../../../../icons';
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">
<InfoIcon 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"
>
<CloseIcon 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">
<ArrowRightIcon className="h-5 w-5 text-gray-400 dark:text-gray-500 mt-0.5 flex-shrink-0" />
<span className="text-sm text-gray-700 dark:text-gray-300">{item}</span>
</li>
))}
</ul>
{/* Additional Resources */}
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
<h4 className="text-sm font-semibold mb-3">Additional Resources</h4>
<ul className="space-y-2 text-sm">
<li>
<a
href="/help/docs"
className="text-blue-600 dark:text-blue-400 hover:underline"
onClick={onClose}
>
View Documentation
</a>
</li>
<li>
<a
href="/help"
className="text-blue-600 dark:text-blue-400 hover:underline"
onClick={onClose}
>
Contact Support
</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -1,121 +0,0 @@
/**
* Wizard Progress Indicator
* Shows breadcrumb with step completion status
*/
import { useBuilderWorkflowStore, WizardStep } from '../../../../store/builderWorkflowStore';
import { CheckCircleIcon } from '../../../../icons';
const STEPS: Array<{ key: WizardStep; label: string }> = [
{ key: 'business_details', label: 'Business Details' },
{ key: 'clusters', label: 'Clusters' },
{ key: 'taxonomies', label: 'Taxonomies' },
{ key: 'sitemap', label: 'Sitemap' },
{ key: 'coverage', label: 'Coverage' },
{ key: 'ideas', label: 'Ideas' },
];
interface WizardProgressProps {
currentStep: WizardStep;
}
export default function WizardProgress({ currentStep }: WizardProgressProps) {
const { completedSteps, blockingIssues, goToStep } = useBuilderWorkflowStore();
const currentIndex = STEPS.findIndex(s => s.key === currentStep);
const handleStepClick = (step: WizardStep, index: number) => {
// Allow navigation to:
// 1. Current step
// 2. Completed steps
// 3. Next step if current step is completed
const isCompleted = completedSteps.has(step.key);
const isCurrent = step.key === currentStep;
const canNavigate = isCurrent || isCompleted || (index === currentIndex + 1 && completedSteps.has(STEPS[currentIndex]?.key));
if (canNavigate && !blockingIssues.some(issue => issue.step === step.key)) {
goToStep(step);
}
};
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
<nav aria-label="Progress">
<ol className="flex items-center justify-between">
{STEPS.map((step, index) => {
const isCompleted = completedSteps.has(step.key);
const isCurrent = step.key === currentStep;
const isBlocked = blockingIssues.some(issue => issue.step === step.key);
const isAccessible = index <= currentIndex || isCompleted;
const canNavigate = isCurrent || isCompleted || (index === currentIndex + 1 && completedSteps.has(STEPS[currentIndex]?.key));
return (
<li key={step.key} className="flex-1 flex items-center">
<div className="flex items-center w-full">
{/* Step Circle */}
<div className="flex flex-col items-center flex-1">
<button
type="button"
onClick={() => handleStepClick(step.key, index)}
disabled={!canNavigate || isBlocked}
className={`
flex items-center justify-center w-10 h-10 rounded-full border-2 transition-all
${
isCompleted
? 'bg-green-500 border-green-500 text-white cursor-pointer hover:bg-green-600'
: isCurrent
? 'bg-primary border-primary text-white cursor-default'
: isBlocked
? 'bg-red-100 border-red-500 text-red-500 cursor-not-allowed'
: canNavigate
? 'bg-gray-100 border-gray-300 text-gray-600 cursor-pointer hover:bg-gray-200 hover:border-gray-400'
: 'bg-gray-50 border-gray-200 text-gray-400 cursor-not-allowed'
}
`}
title={isBlocked ? 'This step is blocked' : canNavigate ? `Go to ${step.label}` : 'Complete previous steps first'}
>
{isCompleted ? (
<CheckCircleIcon className="w-6 h-6" />
) : (
<span className="text-sm font-semibold">{index + 1}</span>
)}
</button>
<span
className={`
mt-2 text-xs font-medium text-center
${
isCurrent
? 'text-primary'
: isBlocked
? 'text-red-600'
: isAccessible
? 'text-gray-600'
: 'text-gray-400'
}
`}
>
{step.label}
</span>
</div>
{/* Connector Line */}
{index < STEPS.length - 1 && (
<div
className={`
flex-1 h-0.5 mx-2
${
isCompleted || (index < currentIndex)
? 'bg-green-500'
: 'bg-gray-200'
}
`}
/>
)}
</div>
</li>
);
})}
</ol>
</nav>
</div>
);
}

View File

@@ -1,48 +0,0 @@
import type { BuilderFormData } from "../../../../types/siteBuilder";
import { Card } from "../../../../components/ui/card";
const labelClass =
"text-sm font-semibold text-gray-700 dark:text-white/80 mb-2 inline-block";
const textareaClass =
"w-full rounded-2xl border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-900 placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-white/10 dark:bg-white/[0.03] dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800";
interface Props {
data: BuilderFormData;
onChange: <K extends keyof BuilderFormData>(
key: K,
value: BuilderFormData[K],
) => void;
}
export function BriefStep({ data, onChange }: Props) {
return (
<Card variant="surface" padding="lg">
<div className="space-y-4">
<div>
<p className="text-xs uppercase tracking-wider text-gray-500 dark:text-white/50">
Brand narrative
</p>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Business brief
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Describe the brand, the offer, and what makes it unique. The more
context we provide, the more precise the structure.
</p>
</div>
<div>
<label className={labelClass}>What's the story?</label>
<textarea
rows={10}
className={textareaClass}
value={data.businessBrief}
placeholder="Acme Robotics builds autonomous fulfillment robots that reduce warehouse picking time by 60%..."
onChange={(event) => onChange("businessBrief", event.target.value)}
/>
</div>
</div>
</Card>
);
}

View File

@@ -1,643 +0,0 @@
/**
* Step 1: Business Details
* Site type selection, hosting detection, brand inputs
*
* Supports both:
* - Stage 1 Wizard: data, onChange, metadata, selectedSectors
* - Stage 2 Workflow: blueprintId
*/
import { useState, useEffect, useRef, useMemo } 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 Input from '../../../../components/form/input/InputField';
import Alert from '../../../../components/ui/alert/Alert';
import { Dropdown } from '../../../../components/ui/dropdown/Dropdown';
import SelectDropdown from '../../../../components/form/SelectDropdown';
import { BoltIcon, GridIcon, CheckLineIcon } from '../../../../icons';
import type { BuilderFormData, SiteBuilderMetadata } from '../../../../types/siteBuilder';
// Stage 1 Wizard props
interface Stage1Props {
data: BuilderFormData;
onChange: <K extends keyof BuilderFormData>(key: K, value: BuilderFormData[K]) => void;
metadata?: SiteBuilderMetadata;
selectedSectors?: Array<{ id: number; name: string }>;
blueprintId?: never;
}
// Stage 2 Workflow props
interface Stage2Props {
blueprintId: number;
data?: never;
onChange?: never;
metadata?: never;
selectedSectors?: never;
}
type BusinessDetailsStepProps = Stage1Props | Stage2Props;
export function BusinessDetailsStep(props: BusinessDetailsStepProps) {
// Check if this is Stage 2 (has blueprintId)
const isStage2 = 'blueprintId' in props && props.blueprintId !== undefined;
// Stage 2 implementation
if (isStage2) {
return <BusinessDetailsStepStage2 blueprintId={props.blueprintId} />;
}
// Stage 1 implementation
return <BusinessDetailsStepStage1
data={props.data}
onChange={props.onChange}
metadata={props.metadata}
selectedSectors={props.selectedSectors}
/>;
}
// Stage 2 Workflow Component
function BusinessDetailsStepStage2({ blueprintId }: { blueprintId: number }) {
const { context, completeStep, loading, goToStep } = useBuilderWorkflowStore();
const [blueprint, setBlueprint] = useState<SiteBlueprint | null>(null);
const [formData, setFormData] = useState({
name: '',
description: '',
hosting_type: 'igny8_sites' as const,
business_type: '',
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// Load blueprint data
fetchSiteBlueprintById(blueprintId)
.then((bp) => {
setBlueprint(bp);
// Check if blueprint is missing required fields (only show error if fields are actually missing)
// Note: account_id might not be in response, but site_id and sector_id should be
// Check explicitly for null/undefined (not just falsy, since 0 could be valid)
if (bp && (bp.site_id == null || bp.sector_id == null)) {
const missing = [];
if (bp.site_id == null) missing.push('site');
if (bp.sector_id == null) missing.push('sector');
console.error('Blueprint missing required fields:', {
blueprintId: bp.id,
site_id: bp.site_id,
sector_id: bp.sector_id,
account_id: bp.account_id,
fullBlueprint: bp
});
setError(
`This blueprint is missing required fields: ${missing.join(', ')}. ` +
`Please contact support to fix this issue.`
);
} else {
// Clear any previous errors if fields are present
setError(undefined);
}
})
.catch(err => setError(err.message));
}, [blueprintId]);
useEffect(() => {
if (blueprint) {
setFormData({
name: blueprint.name || '',
description: blueprint.description || '',
hosting_type: (blueprint.hosting_type as any) || 'igny8_sites',
business_type: blueprint.config_json?.business_type || '',
});
}
}, [blueprint]);
const handleSave = async () => {
setSaving(true);
setError(null);
try {
const updated = await updateSiteBlueprint(blueprintId, {
name: formData.name,
description: formData.description,
hosting_type: formData.hosting_type,
config_json: {
...blueprint?.config_json,
business_type: formData.business_type,
},
});
setBlueprint(updated);
// Mark step as complete - catch and handle workflow errors separately
try {
await completeStep('business_details', {
blueprint_name: formData.name,
hosting_type: formData.hosting_type,
});
// If successful, automatically advance to next step
const { goToStep } = useBuilderWorkflowStore.getState();
goToStep('clusters');
} catch (workflowErr: any) {
// If workflow update fails but blueprint was saved, mark step as complete locally and advance
const workflowErrorMsg = workflowErr?.response?.error ||
workflowErr?.response?.message ||
workflowErr?.message ||
'Workflow step update failed';
// Check if it's a server error (500) - might be workflow service not enabled
const isServerError = workflowErr?.status === 500;
const isClientError = workflowErr?.status >= 400 && workflowErr?.status < 500;
const errorDetail = workflowErr?.response?.error || workflowErr?.response?.message || '';
// Check if error is about missing blueprint fields
if (isClientError && (errorDetail.includes('missing required fields') ||
errorDetail.includes('account') ||
errorDetail.includes('site') ||
errorDetail.includes('sector'))) {
setError(
`Cannot proceed: ${errorDetail}. ` +
`This blueprint needs to be configured with account, site, and sector. Please contact support.`
);
return; // Don't advance - user needs to fix this first
}
if (isServerError && errorDetail.includes('Workflow service not enabled')) {
// Workflow service is disabled - just advance without marking as complete
console.warn('Workflow service not enabled, advancing to next step');
const { goToStep } = useBuilderWorkflowStore.getState();
goToStep('clusters');
// Don't show error - workflow is optional
return;
}
console.warn('Workflow step update failed:', workflowErrorMsg, workflowErr);
// For other errors, allow user to proceed but show warning
const { completedSteps, goToStep } = useBuilderWorkflowStore.getState();
const updatedCompletedSteps = new Set(completedSteps);
updatedCompletedSteps.add('business_details');
useBuilderWorkflowStore.setState({
completedSteps: updatedCompletedSteps,
currentStep: 'clusters' // Advance to step 2
});
// Show a non-blocking warning to the user
setError(`Blueprint saved successfully, but workflow step update failed: ${workflowErrorMsg}. You can continue to the next step.`);
}
} catch (err: any) {
const errorMessage = err?.response?.error || err?.message || 'Failed to save business details';
setError(errorMessage);
} finally {
setSaving(false);
}
};
const canProceed = formData.name.trim().length > 0 &&
blueprint &&
(blueprint.site_id !== undefined && blueprint.site_id !== null) &&
(blueprint.sector_id !== undefined && blueprint.sector_id !== null);
return (
<Card variant="surface" padding="lg" className="space-y-6">
<div>
<CardTitle>Business details</CardTitle>
<CardDescription>
Tell us about your business and hosting preference to keep blueprints organized.
</CardDescription>
</div>
{error && (
<Alert
variant="error"
title={blueprint && (!blueprint.account_id || !blueprint.site_id || !blueprint.sector_id)
? "Blueprint Configuration Error"
: "Error"}
message={error}
/>
)}
<div className="mt-6 space-y-4">
<div className="grid gap-6 md:grid-cols-2">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 block">
Site name *
</label>
<Input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Acme Robotics"
required
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 block">
Hosting type
</label>
<select
value={formData.hosting_type}
onChange={(e) => setFormData({ ...formData, hosting_type: e.target.value as any })}
className="w-full rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900 shadow-theme-xs dark:border-white/10 dark:bg-white/[0.03] dark:text-white"
>
<option value="igny8_sites">IGNY8 Sites</option>
<option value="wordpress">WordPress</option>
<option value="shopify">Shopify</option>
<option value="multi">Multiple destinations</option>
</select>
</div>
</div>
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 block">
Business description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900 shadow-theme-xs dark:border-white/10 dark:bg-white/[0.03] dark:text-white"
rows={3}
placeholder="Brief description of your business and what the site should cover"
/>
</div>
</div>
<div className="mt-6 flex justify-end">
<Button
onClick={handleSave}
disabled={!canProceed || saving || loading}
variant="primary"
startIcon={<GridIcon className="size-4" />}
>
{saving ? 'Saving…' : 'Save & continue'}
</Button>
</div>
{!canProceed && (
<Alert variant="warning" className="mt-4">
{!formData.name.trim()
? 'Please provide a site name to continue.'
: blueprint && (!blueprint.site_id || !blueprint.sector_id)
? 'This blueprint is missing required configuration (site or sector). Please contact support to fix this issue.'
: 'Please complete all required fields to continue.'}
</Alert>
)}
</Card>
);
}
// Target Audience Selector Component (multi-select dropdown)
function TargetAudienceSelector({
data,
onChange,
metadata,
}: {
data: BuilderFormData;
onChange: <K extends keyof BuilderFormData>(key: K, value: BuilderFormData[K]) => void;
metadata: SiteBuilderMetadata;
}) {
const [audienceDropdownOpen, setAudienceDropdownOpen] = useState(false);
const audienceButtonRef = useRef<HTMLButtonElement>(null);
const [showCustomInput, setShowCustomInput] = useState(false);
const audienceOptions = metadata.audience_profiles ?? [];
const selectedAudienceIds = data.targetAudienceIds ?? [];
const selectedAudienceOptions = useMemo(
() => audienceOptions.filter((option) => selectedAudienceIds.includes(option.id)),
[audienceOptions, selectedAudienceIds],
);
const toggleAudience = (id: number) => {
const isSelected = selectedAudienceIds.includes(id);
const next = isSelected
? selectedAudienceIds.filter((value) => value !== id)
: [...selectedAudienceIds, id];
onChange('targetAudienceIds', next);
// Update targetAudience text field with selected names
const selectedNames = audienceOptions
.filter((opt) => next.includes(opt.id))
.map((opt) => opt.name);
onChange('targetAudience', selectedNames.join(', '));
};
const handleCustomAudienceChange = (value: string) => {
onChange('customTargetAudience', value);
// Also update targetAudience if no selections from dropdown
if (selectedAudienceIds.length === 0) {
onChange('targetAudience', value);
} else {
// Combine selected names with custom
const selectedNames = selectedAudienceOptions.map((opt) => opt.name);
if (value.trim()) {
onChange('targetAudience', [...selectedNames, value].join(', '));
} else {
onChange('targetAudience', selectedNames.join(', '));
}
}
};
return (
<div className="space-y-3">
<div>
<button
ref={audienceButtonRef}
type="button"
onClick={() => setAudienceDropdownOpen((open) => !open)}
className="dropdown-toggle flex w-full items-center justify-between rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-900 transition hover:border-brand-200 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/90"
>
<span>
{selectedAudienceIds.length > 0
? `${selectedAudienceIds.length} audience profile${
selectedAudienceIds.length > 1 ? 's' : ''
} selected`
: 'Choose audience profiles from the IGNY8 library'}
</span>
<svg
className="h-4 w-4 text-gray-500 dark:text-gray-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 10.94l3.71-3.71a.75.75 0 111.08 1.04l-4.25 4.25a.75.75 0 01-1.08 0L5.21 8.27a.75.75 0 01.02-1.06z"
clipRule="evenodd"
/>
</svg>
</button>
<Dropdown
isOpen={audienceDropdownOpen}
onClose={() => setAudienceDropdownOpen(false)}
anchorRef={audienceButtonRef}
placement="bottom-left"
className="w-80 max-h-80 overflow-y-auto p-2"
>
{audienceOptions.length === 0 ? (
<div className="px-3 py-2 text-sm text-gray-500">
No audience profiles defined yet. Use the custom field below.
</div>
) : (
audienceOptions.map((option) => {
const isSelected = selectedAudienceIds.includes(option.id);
return (
<button
key={option.id}
type="button"
onClick={() => toggleAudience(option.id)}
className={`flex w-full items-start gap-3 rounded-xl px-3 py-2 text-left text-sm ${
isSelected
? 'bg-brand-50 text-brand-700 dark:bg-brand-500/10 dark:text-brand-100'
: 'text-gray-700 hover:bg-gray-100 dark:text-white/80 dark:hover:bg-white/10'
}`}
>
<span className="flex-1">
<span className="font-semibold">{option.name}</span>
{option.description && (
<span className="block text-xs text-gray-500 dark:text-gray-400">
{option.description}
</span>
)}
</span>
{isSelected && <CheckLineIcon className="h-4 w-4" />}
</button>
);
})
)}
</Dropdown>
</div>
{selectedAudienceOptions.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedAudienceOptions.map((option) => (
<span
key={option.id}
className="inline-flex items-center gap-1.5 rounded-lg bg-brand-50 px-2.5 py-1 text-xs font-medium text-brand-700 dark:bg-brand-500/10 dark:text-brand-100"
>
{option.name}
<button
type="button"
onClick={() => toggleAudience(option.id)}
className="hover:text-brand-900 dark:hover:text-brand-200"
>
×
</button>
</span>
))}
</div>
)}
<div className="space-y-2">
<button
type="button"
onClick={() => setShowCustomInput(!showCustomInput)}
className="text-xs text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
>
{showCustomInput ? '' : '+'} Add custom audience description
</button>
{showCustomInput && (
<Input
value={data.customTargetAudience || ''}
onChange={(e) => handleCustomAudienceChange(e.target.value)}
placeholder="Operations leaders at fast-scaling eCommerce brands"
/>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
Helps the AI craft messaging, examples, and tone.
</p>
</div>
);
}
// Stage 1 Wizard Component
function BusinessDetailsStepStage1({
data,
onChange,
metadata,
selectedSectors
}: {
data: BuilderFormData;
onChange: <K extends keyof BuilderFormData>(key: K, value: BuilderFormData[K]) => void;
metadata?: SiteBuilderMetadata;
selectedSectors?: Array<{ id: number; name: string }>;
}) {
const [userPreferences, setUserPreferences] = useState<{
selectedIndustry?: string;
selectedSectors?: string[];
} | null>(null);
const [loadingPreferences, setLoadingPreferences] = useState(true);
// Load user preferences from account settings
useEffect(() => {
const loadPreferences = async () => {
try {
const { fetchAccountSetting } = await import('../../../../services/api');
const setting = await fetchAccountSetting('user_preferences');
const preferences = setting.config as { selectedIndustry?: string; selectedSectors?: string[] } | undefined;
if (preferences) {
setUserPreferences(preferences);
// Pre-populate industry if available and not already set
if (preferences.selectedIndustry && !data.industry) {
onChange('industry', preferences.selectedIndustry);
}
}
} catch (error: any) {
// 404 means preferences don't exist yet - that's fine
if (error.status !== 404) {
console.warn('Failed to load user preferences:', error);
}
} finally {
setLoadingPreferences(false);
}
};
loadPreferences();
}, []);
return (
<Card variant="surface" padding="lg" className="space-y-6">
<div>
<div className="inline-flex items-center gap-2 rounded-xl bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary-dark)] px-3 py-1 text-xs font-semibold uppercase tracking-wide text-white">
<BoltIcon className="size-3.5" />
Business context
</div>
<h3 className="mt-3 text-xl font-semibold text-gray-900 dark:text-white">
Business details
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-2xl">
These inputs help the AI understand what we're building. You can refine them later in the builder or site settings.
{userPreferences?.selectedIndustry && (
<span className="block mt-1 text-xs text-green-600 dark:text-green-400">
✓ Using your pre-selected industry and sectors from setup
</span>
)}
</p>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<div className="space-y-4 rounded-2xl border border-gray-100 bg-white p-4 shadow-theme-sm dark:border-white/5 dark:bg-white/[0.02]">
<label className="text-sm font-semibold text-gray-900 dark:text-white">
Site name
</label>
<Input
value={data.siteName}
onChange={(e) => onChange('siteName', e.target.value)}
placeholder="Acme Robotics"
/>
<p className="text-xs text-gray-500 dark:text-gray-400">
Appears in dashboards, blueprints, and deployment metadata.
</p>
</div>
<div className="space-y-4 rounded-2xl border border-gray-100 bg-white p-4 shadow-theme-sm dark:border-white/5 dark:bg-white/[0.02]">
<label className="text-sm font-semibold text-gray-900 dark:text-white">
Target audience
</label>
{metadata?.audience_profiles && metadata.audience_profiles.length > 0 ? (
<TargetAudienceSelector
data={data}
onChange={onChange}
metadata={metadata}
/>
) : (
<>
<Input
value={data.targetAudience}
onChange={(e) => onChange('targetAudience', e.target.value)}
placeholder="Operations leaders at fast-scaling eCommerce brands"
/>
<p className="text-xs text-gray-500 dark:text-gray-400">
Helps the AI craft messaging, examples, and tone.
</p>
</>
)}
</div>
</div>
<div className="grid gap-6 lg:grid-cols-3">
<div className="space-y-3 rounded-2xl border border-gray-100 bg-white p-4 shadow-theme-sm dark:border-white/5 dark:bg-white/[0.02]">
<label className="text-sm font-semibold text-gray-900 dark:text-white">
Business type
</label>
{metadata?.business_types && metadata.business_types.length > 0 ? (
<>
<SelectDropdown
value={data.businessTypeId?.toString() || ''}
onChange={(value) => {
if (value === 'custom') {
onChange('businessTypeId', null);
onChange('customBusinessType', '');
} else {
const id = value ? parseInt(value) : null;
onChange('businessTypeId', id);
if (id) {
const option = metadata.business_types?.find(bt => bt.id === id);
if (option) {
onChange('businessType', option.name);
onChange('customBusinessType', '');
}
}
}
}}
options={[
{ value: '', label: 'Select business type...' },
...(metadata.business_types.map(bt => ({
value: bt.id.toString(),
label: bt.name,
}))),
{ value: 'custom', label: '+ Add custom business type' },
]}
placeholder="Select business type..."
/>
{(data.businessTypeId === null || data.businessTypeId === undefined || data.businessTypeId === 0) && (
<Input
value={data.customBusinessType || data.businessType}
onChange={(e) => {
onChange('customBusinessType', e.target.value);
onChange('businessType', e.target.value);
}}
placeholder="B2B SaaS platform"
className="mt-2"
/>
)}
</>
) : (
<Input
value={data.businessType}
onChange={(e) => onChange('businessType', e.target.value)}
placeholder="B2B SaaS platform"
/>
)}
</div>
<div className="space-y-3 rounded-2xl border border-gray-100 bg-white p-4 shadow-theme-sm dark:border-white/5 dark:bg-white/[0.02]">
<label className="text-sm font-semibold text-gray-900 dark:text-white">
Industry
</label>
<Input
value={data.industry}
onChange={(e) => onChange('industry', e.target.value)}
placeholder={userPreferences?.selectedIndustry || "Supply chain automation"}
/>
</div>
<div className="space-y-3 rounded-2xl border border-gray-100 bg-white p-4 shadow-theme-sm dark:border-white/5 dark:bg-white/[0.02]">
<label className="text-sm font-semibold text-gray-900 dark:text-white">
Hosting preference
</label>
<select
value={data.hostingType}
onChange={(e) => onChange('hostingType', e.target.value as BuilderFormData['hostingType'])}
className="w-full rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900 shadow-theme-xs dark:border-white/10 dark:bg-white/[0.03] dark:text-white"
>
<option value="igny8_sites">IGNY8 Sites</option>
<option value="wordpress">WordPress</option>
<option value="shopify">Shopify</option>
<option value="multi">Multiple destinations</option>
</select>
<p className="text-xs text-gray-500 dark:text-gray-400">
Determines deployment targets and integration requirements.
</p>
</div>
</div>
</Card>
);
}
// Also export as default for WorkflowWizard compatibility
export default BusinessDetailsStep;

View File

@@ -1,469 +0,0 @@
/**
* 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 ButtonWithTooltip from '../../../../components/ui/button/ButtonWithTooltip';
import Button from '../../../../components/ui/button/Button';
import Alert from '../../../../components/ui/alert/Alert';
import Input from '../../../../components/form/input/InputField';
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 { CheckCircleIcon, XCircleIcon } from '../../../../icons';
import { useNavigate } from 'react-router-dom';
interface ClusterAssignmentStepProps {
blueprintId: number;
}
export default function ClusterAssignmentStep({ blueprintId }: ClusterAssignmentStepProps) {
const { context, completeStep, blockingIssues, refreshState, loading: workflowLoading } = useBuilderWorkflowStore();
const { activeSector } = useSectorStore();
const toast = useToast();
const navigate = useNavigate();
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. Select clusters and choose their role (Hub, Supporting, or Attribute).
</CardDescription>
{clusterBlocking && (
<Alert variant="error" className="mt-4">
{clusterBlocking.message}
</Alert>
)}
{context?.cluster_summary && (
<div className="mt-6">
<div className="grid grid-cols-3 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 Clusters</div>
<div className="text-2xl font-bold">{context.cluster_summary.clusters?.length || 0}</div>
</div>
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
<div className="text-sm text-gray-600 dark:text-gray-400">Attached</div>
<div className="text-2xl font-bold">{context.cluster_summary.attached_count || 0}</div>
</div>
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
<div className="text-sm text-gray-600 dark:text-gray-400">Coverage</div>
<div className="text-2xl font-bold">
{context.cluster_summary.coverage_counts?.complete || 0} / {context.cluster_summary.clusters?.length || 0}
</div>
</div>
</div>
{/* 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 ? (
<>
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
Attaching...
</>
) : (
`Attach Selected (${selectedClusterIds.size})`
)}
</Button>
<Button
onClick={handleDetach}
disabled={selectedClusterIds.size === 0 || attaching || detaching}
variant="outline"
>
{detaching ? (
<>
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
Detaching...
</>
) : (
`Detach Selected (${selectedClusterIds.size})`
)}
</Button>
</div>
</div>
</div>
{/* Cluster Table */}
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
</div>
) : filteredClusters.length === 0 ? (
<div className="mt-4">
{searchTerm || statusFilter || roleFilter ? (
<Alert variant="info">
No clusters match your filters. Try adjusting your search criteria.
</Alert>
) : (
<Alert variant="warning" className="mb-4">
<div className="font-semibold mb-2">No clusters available</div>
<div className="text-sm mb-4">
To proceed with Step 2, you need to create keyword clusters first. Here's how:
</div>
<ol className="text-sm list-decimal list-inside space-y-2 mb-4">
<li>Go to <strong>Planner → Keywords</strong> and import or create keywords</li>
<li>Select keywords and click <strong>"Auto-Cluster"</strong> (1 credit per 30 keywords)</li>
<li>Review clusters at <strong>Planner → Clusters</strong></li>
<li>Return here to attach clusters to your blueprint</li>
</ol>
<Button
variant="primary"
onClick={() => navigate('/planner/keywords')}
>
Go to Planner → Keywords
</Button>
</Alert>
)}
</div>
) : (
<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.keyword_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">
<CheckCircleIcon 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-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)
}
>
{workflowLoading ? (
<>
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
Loading...
</>
) : (
'Continue'
)}
</ButtonWithTooltip>
</div>
</Card>
);
}

View File

@@ -1,232 +0,0 @@
/**
* Step 5: Coverage Validation
* Validate cluster/taxonomy coverage before proceeding
*/
import { useBuilderWorkflowStore } from '../../../../store/builderWorkflowStore';
import { Card, CardDescription, CardTitle } from '../../../../components/ui/card';
import ButtonWithTooltip from '../../../../components/ui/button/ButtonWithTooltip';
import Alert from '../../../../components/ui/alert/Alert';
interface CoverageValidationStepProps {
blueprintId: number;
}
export default function CoverageValidationStep({ blueprintId }: CoverageValidationStepProps) {
const { context, completeStep, blockingIssues } = useBuilderWorkflowStore();
const coverageBlocking = blockingIssues.find(issue => issue.step === 'coverage');
const clusterStats = {
complete: context?.cluster_summary?.coverage_counts?.complete || 0,
in_progress: context?.cluster_summary?.coverage_counts?.in_progress || 0,
pending: context?.cluster_summary?.coverage_counts?.pending || 0,
};
const totalClusters = context?.cluster_summary?.clusters?.length || 0;
const attachedClusters = context?.cluster_summary?.attached_count || 0;
const clusterCoverage = totalClusters > 0 ? (attachedClusters / totalClusters) * 100 : 0;
const totalTaxonomies = context?.taxonomy_summary?.total_taxonomies || 0;
const taxonomyByType = context?.taxonomy_summary?.counts_by_type || {};
// Calculate sitemap coverage from pages_by_status if available
const sitemapPages = context?.sitemap_summary?.pages_total || 0;
const sitemapCoverage = sitemapPages > 0 ? 100 : 0; // Simplified - backend doesn't provide coverage_percentage
const totalPages = sitemapPages;
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 before proceeding to content generation.
</CardDescription>
{coverageBlocking && (
<Alert variant="error" className="mt-4">
{coverageBlocking.message}
</Alert>
)}
{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?.pages_by_type && (
<div className="mt-2 space-y-1">
{Object.entries(context.sitemap_summary.pages_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">
<ButtonWithTooltip
onClick={() => completeStep('coverage')}
disabled={!!coverageBlocking || hasErrors}
variant="primary"
tooltip={coverageBlocking?.message || (hasErrors ? 'Please fix critical issues before continuing' : undefined)}
>
Continue
</ButtonWithTooltip>
</div>
</Card>
);
}

View File

@@ -1,258 +0,0 @@
/**
* 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 ButtonWithTooltip from '../../../../components/ui/button/ButtonWithTooltip';
import Button from '../../../../components/ui/button/Button';
import Alert from '../../../../components/ui/alert/Alert';
import Checkbox from '../../../../components/form/input/Checkbox';
import Input from '../../../../components/form/input/InputField';
import { useToast } from '../../../../components/ui/toast/ToastContainer';
interface IdeasHandoffStepProps {
blueprintId: number;
}
interface PageSelection {
id: number;
selected: boolean;
promptOverride?: string;
}
export default function IdeasHandoffStep({ blueprintId }: IdeasHandoffStepProps) {
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 create Writer tasks for. These tasks will appear in the Writer module for content generation.
</CardDescription>
{ideasBlocking && (
<Alert variant="error" className="mt-4">
{ideasBlocking.message}
</Alert>
)}
{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">
{/* 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 gap-3">
<ButtonWithTooltip
onClick={() => completeStep('ideas')}
variant="secondary"
disabled={submitting}
tooltip={submitting ? 'Please wait...' : undefined}
>
Skip
</ButtonWithTooltip>
<ButtonWithTooltip
onClick={handleSubmit}
disabled={!!ideasBlocking || selectedCount === 0 || submitting}
variant="primary"
tooltip={
ideasBlocking?.message ||
(selectedCount === 0 ? 'Please select at least one page to create tasks' :
submitting ? 'Creating tasks...' : undefined)
}
>
{submitting ? 'Creating Tasks...' : `Create Tasks (${selectedCount})`}
</ButtonWithTooltip>
</div>
</Card>
);
}

View File

@@ -1,90 +0,0 @@
import { useState } from "react";
import type { BuilderFormData } from "../../../../types/siteBuilder";
import { Card } from "../../../../components/ui/card";
import Button from "../../../../components/ui/button/Button";
const inputClass =
"h-11 flex-1 rounded-xl border border-gray-200 bg-white px-4 text-sm font-medium text-gray-900 placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-white/10 dark:bg-white/[0.03] dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800";
interface Props {
data: BuilderFormData;
addObjective: (value: string) => void;
removeObjective: (index: number) => void;
}
export function ObjectivesStep({
data,
addObjective,
removeObjective,
}: Props) {
const [value, setValue] = useState("");
const handleAdd = () => {
const trimmed = value.trim();
if (!trimmed) return;
addObjective(trimmed);
setValue("");
};
return (
<Card variant="surface" padding="lg">
<div className="space-y-6">
<div>
<p className="text-xs uppercase tracking-wider text-gray-500 dark:text-white/50">
Conversion goals
</p>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
What should the site accomplish?
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Each objective becomes navigation, hero CTAs, and supporting
sections. Add as many as you need.
</p>
</div>
<div className="flex flex-wrap gap-2">
{data.objectives.map((objective, idx) => (
<span
key={`${objective}-${idx}`}
className="inline-flex items-center gap-3 rounded-full bg-brand-50 px-4 py-2 text-sm font-medium text-brand-700 dark:bg-brand-500/15 dark:text-brand-200"
>
{objective}
<button
type="button"
className="text-brand-600 hover:text-brand-800 dark:text-brand-200 dark:hover:text-brand-50"
onClick={() => removeObjective(idx)}
aria-label="Remove objective"
>
×
</button>
</span>
))}
{data.objectives.length === 0 && (
<span className="text-sm text-gray-500 dark:text-gray-400">
No objectives yet. Add one below.
</span>
)}
</div>
<div className="flex flex-col gap-3 md:flex-row md:items-center">
<input
className={inputClass}
type="text"
value={value}
placeholder="Offer product tour, capture demo requests, educate on ROI…"
onChange={(event) => setValue(event.target.value)}
/>
<Button
type="button"
variant="solid"
tone="brand"
onClick={handleAdd}
>
Add objective
</Button>
</div>
</div>
</Card>
);
}

View File

@@ -1,268 +0,0 @@
/**
* 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 ButtonWithTooltip from '../../../../components/ui/button/ButtonWithTooltip';
import Button from '../../../../components/ui/button/Button';
import Alert from '../../../../components/ui/alert/Alert';
import Input from '../../../../components/form/input/InputField';
import { useToast } from '../../../../components/ui/toast/ToastContainer';
interface SitemapReviewStepProps {
blueprintId: number;
}
export default function SitemapReviewStep({ blueprintId }: SitemapReviewStepProps) {
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. Edit page details or regenerate pages as needed.
</CardDescription>
{sitemapBlocking && (
<Alert variant="error" className="mt-4">
{sitemapBlocking.message}
</Alert>
)}
{context?.sitemap_summary && (
<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.pages_total || 0}</span>
</div>
<div>
<span className="text-gray-600 dark:text-gray-400">By Status:</span>
<div className="mt-1 flex flex-wrap gap-2">
{Object.entries(context.sitemap_summary.pages_by_status || {}).map(([status, count]) => (
<span key={status} className="text-xs px-2 py-1 bg-gray-200 dark:bg-gray-700 rounded">
{status}: {count}
</span>
))}
</div>
</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.pages_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>
</div>
)}
<div className="mt-6 flex justify-end">
<ButtonWithTooltip
onClick={() => completeStep('sitemap')}
disabled={!!sitemapBlocking}
variant="primary"
tooltip={sitemapBlocking?.message}
>
Continue
</ButtonWithTooltip>
</div>
</Card>
);
}

View File

@@ -1,345 +0,0 @@
import { useMemo, useRef, useState } from "react";
import type {
BuilderFormData,
SiteBuilderMetadata,
StylePreferences,
} from "../../../../types/siteBuilder";
import { Card } from "../../../../components/ui/card";
import { Dropdown } from "../../../../components/ui/dropdown/Dropdown";
import { CheckLineIcon } from "../../../../icons";
const labelClass =
"text-sm font-semibold text-gray-700 dark:text-white/80 mb-2 inline-block";
const selectClass =
"h-11 w-full rounded-xl border border-gray-200 bg-white px-4 text-sm font-medium text-gray-900 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-white/10 dark:bg-white/[0.03] dark:text-white/90 dark:focus:border-brand-800";
const inputClass =
"h-11 w-full rounded-xl border border-gray-200 bg-white px-4 text-sm font-medium text-gray-900 placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-white/10 dark:bg-white/[0.03] dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800";
const textareaClass =
"w-full rounded-2xl border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-900 placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-white/10 dark:bg-white/[0.03] dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800";
const palettes = [
"Minimal monochrome with bright accent",
"Rich jewel tones with high contrast",
"Soft gradients and glassmorphism",
"Playful pastel palette",
];
const typography = [
"Modern sans-serif for headings, serif body text",
"Editorial serif across the site",
"Geometric sans with tight tracking",
"Rounded fonts with friendly tone",
];
interface Props {
style: StylePreferences;
metadata?: SiteBuilderMetadata;
brandPersonalityIds: number[];
customBrandPersonality?: string;
heroImageryDirectionId: number | null;
customHeroImageryDirection?: string;
onStyleChange: (partial: Partial<StylePreferences>) => void;
onChange: <K extends keyof BuilderFormData>(
key: K,
value: BuilderFormData[K],
) => void;
}
export function StyleStep({
style,
metadata,
brandPersonalityIds,
customBrandPersonality,
heroImageryDirectionId,
customHeroImageryDirection,
onStyleChange,
onChange,
}: Props) {
const brandOptions = metadata?.brand_personalities ?? [];
const heroOptions = metadata?.hero_imagery_directions ?? [];
const [heroDropdownOpen, setHeroDropdownOpen] = useState(false);
const heroButtonRef = useRef<HTMLButtonElement>(null);
const [brandDropdownOpen, setBrandDropdownOpen] = useState(false);
const brandButtonRef = useRef<HTMLButtonElement>(null);
const selectedBrandOptions = useMemo(
() => brandOptions.filter((option) => brandPersonalityIds.includes(option.id)),
[brandOptions, brandPersonalityIds],
);
const selectedHero = heroOptions.find(
(option) => option.id === heroImageryDirectionId,
);
const toggleBrand = (id: number) => {
const isSelected = brandPersonalityIds.includes(id);
const next = isSelected
? brandPersonalityIds.filter((value) => value !== id)
: [...brandPersonalityIds, id];
onChange("brandPersonalityIds", next);
};
return (
<Card variant="surface" padding="lg">
<div className="space-y-6">
<div>
<p className="text-xs uppercase tracking-wider text-gray-500 dark:text-white/50">
Look &amp; feel
</p>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Visual direction
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Capture the brand personality so the preview canvas mirrors the right tone.
</p>
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<label className={labelClass}>Palette direction</label>
<select
className={selectClass}
value={style.palette}
onChange={(event) => onStyleChange({ palette: event.target.value })}
>
{palettes.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
<div>
<label className={labelClass}>Typography</label>
<select
className={selectClass}
value={style.typography}
onChange={(event) =>
onStyleChange({ typography: event.target.value })
}
>
{typography.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
</div>
<div className="space-y-4">
<div>
<label className={labelClass}>Brand personality profiles</label>
<p className="text-xs text-gray-500 dark:text-gray-400">
Select up to three descriptors that define the brand tone.
</p>
</div>
<div>
<button
ref={brandButtonRef}
type="button"
onClick={() => setBrandDropdownOpen((open) => !open)}
className="dropdown-toggle flex w-full items-center justify-between rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-900 transition hover:border-brand-200 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/90"
>
<span>
{brandPersonalityIds.length > 0
? `${brandPersonalityIds.length} personality${
brandPersonalityIds.length > 1 ? " descriptors" : " descriptor"
} selected`
: "Choose personalities from the IGNY8 library"}
</span>
<svg
className="h-4 w-4 text-gray-500 dark:text-gray-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 10.94l3.71-3.71a.75.75 0 111.08 1.04l-4.25 4.25a.75.75 0 01-1.08 0L5.21 8.27a.75.75 0 01.02-1.06z"
clipRule="evenodd"
/>
</svg>
</button>
<Dropdown
isOpen={brandDropdownOpen}
onClose={() => setBrandDropdownOpen(false)}
anchorRef={brandButtonRef}
placement="bottom-left"
className="w-80 max-h-80 overflow-y-auto p-2"
>
{brandOptions.length === 0 ? (
<div className="px-3 py-2 text-sm text-gray-500">
No brand personalities defined yet. Use the custom field below.
</div>
) : (
brandOptions.map((option) => {
const isSelected = brandPersonalityIds.includes(option.id);
const disabled =
!isSelected && brandPersonalityIds.length >= 3;
return (
<button
key={option.id}
type="button"
disabled={disabled}
onClick={() => toggleBrand(option.id)}
className={`flex w-full items-start gap-3 rounded-xl px-3 py-2 text-left text-sm ${
isSelected
? "bg-brand-50 text-brand-700 dark:bg-brand-500/10 dark:text-brand-100"
: "text-gray-700 hover:bg-gray-100 disabled:opacity-50 dark:text-white/80 dark:hover:bg-white/10"
}`}
>
<span className="flex-1">
<span className="font-semibold">{option.name}</span>
{option.description && (
<span className="block text-xs text-gray-500 dark:text-gray-400">
{option.description}
</span>
)}
</span>
{isSelected && <CheckLineIcon className="h-4 w-4" />}
</button>
);
})
)}
</Dropdown>
</div>
<div className="flex flex-wrap gap-2">
{selectedBrandOptions.map((option) => (
<span
key={option.id}
className="inline-flex items-center gap-2 rounded-full bg-brand-50 px-3 py-1 text-xs font-semibold text-brand-700 dark:bg-brand-500/10 dark:text-brand-100"
>
{option.name}
<button
type="button"
className="text-brand-600 hover:text-brand-800 dark:text-brand-100 dark:hover:text-brand-50"
onClick={() => toggleBrand(option.id)}
aria-label={`Remove ${option.name}`}
>
×
</button>
</span>
))}
{customBrandPersonality?.trim() && (
<span className="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-semibold text-gray-700 dark:bg-white/10 dark:text-white/80">
{customBrandPersonality.trim()}
</span>
)}
</div>
<input
className={inputClass}
type="text"
value={customBrandPersonality ?? ""}
placeholder="Add custom personality descriptor"
onChange={(event) =>
onChange("customBrandPersonality", event.target.value)
}
/>
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<label className={labelClass}>Brand personality narrative</label>
<textarea
className={textareaClass}
rows={4}
value={style.personality}
onChange={(event) =>
onStyleChange({ personality: event.target.value })
}
/>
</div>
<div>
<label className={labelClass}>Hero imagery direction</label>
<button
ref={heroButtonRef}
type="button"
onClick={() => setHeroDropdownOpen((open) => !open)}
className="dropdown-toggle flex w-full items-center justify-between rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-900 transition hover:border-brand-200 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/90"
>
<span>
{selectedHero?.name ||
"Select a hero imagery direction from the library"}
</span>
<svg
className="h-4 w-4 text-gray-500 dark:text-gray-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 10.94l3.71-3.71a.75.75 0 111.08 1.04l-4.25 4.25a.75.75 0 01-1.08 0L5.21 8.27a.75.75 0 01.02-1.06z"
clipRule="evenodd"
/>
</svg>
</button>
<Dropdown
isOpen={heroDropdownOpen}
onClose={() => setHeroDropdownOpen(false)}
anchorRef={heroButtonRef}
placement="bottom-left"
className="w-80 max-h-72 overflow-y-auto p-2"
>
{heroOptions.length === 0 ? (
<div className="px-3 py-2 text-sm text-gray-500">
No hero imagery directions defined yet.
</div>
) : (
heroOptions.map((option) => {
const isSelected = option.id === heroImageryDirectionId;
return (
<button
key={option.id}
type="button"
onClick={() => {
onChange("heroImageryDirectionId", option.id);
onChange("customHeroImageryDirection", "");
onStyleChange({ heroImagery: option.name });
setHeroDropdownOpen(false);
}}
className={`flex w-full items-start gap-3 rounded-xl px-3 py-2 text-left text-sm ${
isSelected
? "bg-brand-50 text-brand-700 dark:bg-brand-500/10 dark:text-brand-100"
: "text-gray-700 hover:bg-gray-100 dark:text-white/80 dark:hover:bg-white/10"
}`}
>
<span className="flex-1">
<span className="font-semibold">{option.name}</span>
{option.description && (
<span className="block text-xs text-gray-500 dark:text-gray-400">
{option.description}
</span>
)}
</span>
{isSelected && <CheckLineIcon className="h-4 w-4" />}
</button>
);
})
)}
</Dropdown>
<input
className={`${inputClass} mt-3`}
type="text"
value={customHeroImageryDirection ?? ""}
placeholder="Or describe a custom hero imagery direction"
onChange={(event) => {
onChange("customHeroImageryDirection", event.target.value);
onChange("heroImageryDirectionId", null);
}}
/>
<textarea
className={`${textareaClass} mt-3`}
rows={4}
value={style.heroImagery}
onChange={(event) =>
onStyleChange({ heroImagery: event.target.value })
}
/>
</div>
</div>
</div>
</Card>
);
}

View File

@@ -1,427 +0,0 @@
/**
* 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/form/input/InputField';
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 { PlusIcon, DownloadIcon, PencilIcon, TrashBinIcon } from '../../../../icons';
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, 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. Link taxonomies to clusters for better organization.
</CardDescription>
{taxonomyBlocking && (
<Alert variant="error" className="mt-4">
{taxonomyBlocking.message}
</Alert>
)}
{context?.taxonomy_summary && (
<div className="mt-6">
<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_taxonomies}</div>
</div>
{Object.entries(context.taxonomy_summary.counts_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>
{/* 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"
>
<DownloadIcon className="mr-2 h-4 w-4" />
Import
</Button>
</div>
</div>
</div>
{/* Taxonomy Table */}
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
</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)}
>
<PencilIcon 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">
<ButtonWithTooltip
onClick={handleContinue}
disabled={!!taxonomyBlocking || workflowLoading || saving}
variant="primary"
tooltip={
taxonomyBlocking?.message ||
(workflowLoading ? 'Loading workflow state...' :
saving ? 'Saving taxonomy...' : undefined)
}
>
{workflowLoading ? (
<>
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
Loading...
</>
) : (
'Continue'
)}
</ButtonWithTooltip>
</div>
</Card>
);
}

View File

@@ -1,423 +0,0 @@
import { create } from "zustand";
import { useSiteStore } from "./siteStore";
import { useSiteDefinitionStore } from "./siteDefinitionStore";
import { siteBuilderApi } from "../services/siteBuilder.api";
import type {
BuilderFormData,
PageBlueprint,
SiteBlueprint,
StylePreferences,
SiteStructure,
SiteBuilderMetadata,
SiteBuilderMetadataOption,
} from "../types/siteBuilder";
const defaultStyle: StylePreferences = {
palette: "Vibrant modern palette with rich accent color",
typography: "Sans-serif display for headings, humanist body font",
personality: "Confident, energetic, optimistic",
heroImagery: "Real people interacting with the product/service",
};
const buildDefaultForm = (): BuilderFormData => {
const site = useSiteStore.getState().activeSite;
return {
siteId: site?.id ?? null,
sectorIds: site?.selected_sectors ?? [],
siteName: site?.name ?? "",
businessTypeId: null,
businessType: "",
customBusinessType: "",
industry: site?.industry_name ?? "",
targetAudienceIds: [],
targetAudience: "",
customTargetAudience: "",
hostingType: "igny8_sites",
businessBrief: "",
objectives: ["Launch a conversion-focused marketing site"],
brandPersonalityIds: [],
customBrandPersonality: "",
heroImageryDirectionId: null,
customHeroImageryDirection: "",
style: { ...defaultStyle },
};
};
interface BuilderState {
form: BuilderFormData;
currentStep: number;
isSubmitting: boolean;
isGenerating: boolean;
isLoadingBlueprint: boolean;
metadata?: SiteBuilderMetadata;
isMetadataLoading: boolean;
metadataError?: string;
error?: string;
activeBlueprint?: SiteBlueprint;
pages: PageBlueprint[];
selectedPageIds: number[];
structureTaskId?: string | null;
generationProgress?: {
pagesQueued: number;
taskIds: number[];
celeryTaskId?: string;
};
// Actions
setField: <K extends keyof BuilderFormData>(
key: K,
value: BuilderFormData[K],
) => void;
updateStyle: (partial: Partial<StylePreferences>) => void;
addObjective: (value: string) => void;
removeObjective: (index: number) => void;
setStep: (step: number) => void;
nextStep: () => void;
previousStep: () => void;
reset: () => void;
syncContextFromStores: () => void;
submitWizard: () => Promise<void>;
refreshPages: (blueprintId: number) => Promise<void>;
togglePageSelection: (pageId: number) => void;
selectAllPages: () => void;
clearPageSelection: () => void;
loadBlueprint: (blueprintId: number) => Promise<void>;
generateAllPages: (blueprintId: number, force?: boolean) => Promise<void>;
loadMetadata: () => Promise<void>;
}
export const useBuilderStore = create<BuilderState>((set, get) => ({
form: buildDefaultForm(),
currentStep: 0,
isSubmitting: false,
isGenerating: false,
isLoadingBlueprint: false,
metadata: undefined,
isMetadataLoading: false,
metadataError: undefined,
pages: [],
selectedPageIds: [],
structureTaskId: null,
setField: (key, value) =>
set((state) => ({
form: { ...state.form, [key]: value },
})),
updateStyle: (partial) =>
set((state) => ({
form: { ...state.form, style: { ...state.form.style, ...partial } },
})),
addObjective: (value) =>
set((state) => ({
form: { ...state.form, objectives: [...state.form.objectives, value] },
})),
removeObjective: (index) =>
set((state) => ({
form: {
...state.form,
objectives: state.form.objectives.filter((_, idx) => idx !== index),
},
})),
setStep: (step) => set({ currentStep: step }),
nextStep: () =>
set((state) => ({
currentStep: Math.min(state.currentStep + 1, 3),
})),
previousStep: () =>
set((state) => ({
currentStep: Math.max(state.currentStep - 1, 0),
})),
reset: () =>
set({
form: buildDefaultForm(),
currentStep: 0,
isSubmitting: false,
error: undefined,
activeBlueprint: undefined,
pages: [],
selectedPageIds: [],
generationProgress: undefined,
}),
syncContextFromStores: () => {
const site = useSiteStore.getState().activeSite;
set((state) => ({
form: {
...state.form,
siteId: site?.id ?? state.form.siteId,
siteName: site?.name ?? state.form.siteName,
sectorIds: site?.selected_sectors ?? state.form.sectorIds,
industry: site?.industry_name ?? state.form.industry,
},
}));
},
submitWizard: async () => {
const { form, metadata } = get();
if (!form.siteId) {
set({
error: "Select an active site before running the Site Builder wizard.",
});
return;
}
if (!form.sectorIds?.length) {
set({
error:
"This site has no sectors configured. Add sectors in Sites → All Sites before running the wizard.",
});
return;
}
const findOptionName = (
options: SiteBuilderMetadataOption[] | undefined,
id: number | null | undefined,
) => options?.find((option) => option.id === id)?.name;
const businessTypeName =
findOptionName(metadata?.business_types, form.businessTypeId) ||
form.customBusinessType?.trim() ||
form.businessType ||
"General business";
const selectedAudienceOptions =
metadata?.audience_profiles?.filter((option) =>
form.targetAudienceIds.includes(option.id),
) ?? [];
const audienceNames = selectedAudienceOptions.map((option) => option.name);
if (form.customTargetAudience?.trim()) {
audienceNames.push(form.customTargetAudience.trim());
}
const targetAudienceSummary = audienceNames.join(", ");
const selectedBrandPersonalities =
metadata?.brand_personalities?.filter((option) =>
form.brandPersonalityIds.includes(option.id),
) ?? [];
const brandPersonalityNames = selectedBrandPersonalities.map(
(option) => option.name,
);
if (form.customBrandPersonality?.trim()) {
brandPersonalityNames.push(form.customBrandPersonality.trim());
}
const personalityDescription =
brandPersonalityNames.length > 0
? brandPersonalityNames.join(", ")
: form.style.personality;
const heroImageryName =
findOptionName(
metadata?.hero_imagery_directions,
form.heroImageryDirectionId,
) ||
form.customHeroImageryDirection?.trim() ||
form.style.heroImagery;
const preparedForm: BuilderFormData = {
...form,
businessType: businessTypeName,
targetAudience: targetAudienceSummary,
};
const stylePreferences: StylePreferences = {
...preparedForm.style,
personality: personalityDescription,
heroImagery: heroImageryName,
};
set({
form: { ...preparedForm, style: stylePreferences },
isSubmitting: true,
error: undefined,
});
try {
// Use only the first sector to create ONE blueprint (not one per sector)
const sectorId = preparedForm.sectorIds[0];
if (!sectorId) {
set({
error: "No sector selected. Please select at least one sector.",
});
return;
}
const payload = {
name:
preparedForm.siteName ||
`Site Blueprint (${preparedForm.industry || "New"})`,
description: targetAudienceSummary
? `${businessTypeName}${targetAudienceSummary}`
: businessTypeName,
site_id: preparedForm.siteId!,
sector_id: sectorId,
hosting_type: preparedForm.hostingType,
config_json: {
business_type_id: preparedForm.businessTypeId,
business_type: businessTypeName,
custom_business_type: preparedForm.customBusinessType,
industry: preparedForm.industry,
target_audience_ids: preparedForm.targetAudienceIds,
target_audience: audienceNames,
custom_target_audience: preparedForm.customTargetAudience,
brand_personality_ids: preparedForm.brandPersonalityIds,
brand_personality: brandPersonalityNames,
custom_brand_personality: preparedForm.customBrandPersonality,
hero_imagery_direction_id: preparedForm.heroImageryDirectionId,
hero_imagery_direction: heroImageryName,
custom_hero_imagery_direction:
preparedForm.customHeroImageryDirection,
sector_id: sectorId,
},
};
const blueprint = await siteBuilderApi.createBlueprint(payload);
const generation = await siteBuilderApi.generateStructure(
blueprint.id,
{
business_brief: preparedForm.businessBrief,
objectives: preparedForm.objectives,
style: stylePreferences,
metadata: {
targetAudience: audienceNames,
brandPersonality: brandPersonalityNames,
sectorId,
},
},
);
if (generation?.task_id) {
set({ structureTaskId: generation.task_id });
}
let lastStructure: SiteStructure | undefined;
if (generation?.structure) {
lastStructure = generation.structure;
}
set({ activeBlueprint: blueprint });
if (lastStructure) {
useSiteDefinitionStore.getState().setStructure(lastStructure);
}
await get().refreshPages(blueprint.id);
} catch (error: any) {
set({
error: error?.message || "Unexpected error while running wizard",
});
} finally {
set({ isSubmitting: false });
}
},
refreshPages: async (blueprintId: number) => {
try {
const pages = await siteBuilderApi.listPages(blueprintId);
set({ pages });
useSiteDefinitionStore.getState().setPages(pages);
} catch (error: any) {
set({
error: error?.message || "Unable to load generated pages",
});
}
},
togglePageSelection: (pageId: number) =>
set((state) => {
const isSelected = state.selectedPageIds.includes(pageId);
return {
selectedPageIds: isSelected
? state.selectedPageIds.filter((id) => id !== pageId)
: [...state.selectedPageIds, pageId],
};
}),
selectAllPages: () =>
set((state) => ({
selectedPageIds: state.pages.map((page) => page.id),
})),
clearPageSelection: () => set({ selectedPageIds: [] }),
loadBlueprint: async (blueprintId: number) => {
set({ isLoadingBlueprint: true, error: undefined });
try {
const [blueprint, pages] = await Promise.all([
siteBuilderApi.getBlueprint(blueprintId),
siteBuilderApi.listPages(blueprintId),
]);
set({
activeBlueprint: blueprint,
pages,
selectedPageIds: [],
});
if (blueprint.structure_json) {
useSiteDefinitionStore.getState().setStructure(blueprint.structure_json);
} else {
useSiteDefinitionStore.getState().setStructure({
site: undefined,
pages: pages.map((page) => ({
slug: page.slug,
title: page.title,
type: page.type,
blocks: page.blocks_json,
})),
});
}
useSiteDefinitionStore.getState().setPages(pages);
} catch (error: any) {
set({
error: error?.message || "Unable to load blueprint",
});
} finally {
set({ isLoadingBlueprint: false });
}
},
generateAllPages: async (blueprintId: number, force = false) => {
const { selectedPageIds } = get();
set({ isGenerating: true, error: undefined, generationProgress: undefined });
try {
const result = await siteBuilderApi.generateAllPages(blueprintId, {
pageIds: selectedPageIds.length > 0 ? selectedPageIds : undefined,
force,
});
set({
generationProgress: {
pagesQueued: result.pages_queued,
taskIds: result.task_ids,
celeryTaskId: result.celery_task_id,
},
});
await get().refreshPages(blueprintId);
} catch (error: any) {
set({
error: error?.message || "Failed to queue page generation",
});
} finally {
set({ isGenerating: false });
}
},
loadMetadata: async () => {
if (get().metadata || get().isMetadataLoading) return;
set({ isMetadataLoading: true, metadataError: undefined });
try {
const metadata = await siteBuilderApi.getMetadata();
set({ metadata, isMetadataLoading: false });
} catch (error: any) {
set({
metadataError:
error?.message || "Unable to load Site Builder metadata",
isMetadataLoading: false,
});
}
},
}));