diff --git a/backend/celerybeat-schedule b/backend/celerybeat-schedule index 4162dc55..c59458f2 100644 Binary files a/backend/celerybeat-schedule and b/backend/celerybeat-schedule differ diff --git a/backend/igny8_core/business/site_building/models.py b/backend/igny8_core/business/site_building/models.py index 4a43546d..580aeaa4 100644 --- a/backend/igny8_core/business/site_building/models.py +++ b/backend/igny8_core/business/site_building/models.py @@ -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. diff --git a/backend/igny8_core/business/site_building/services/__init__.py b/backend/igny8_core/business/site_building/services/__init__.py index b4aceef1..2890d800 100644 --- a/backend/igny8_core/business/site_building/services/__init__.py +++ b/backend/igny8_core/business/site_building/services/__init__.py @@ -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', ] diff --git a/backend/igny8_core/business/site_building/services/validators.py b/backend/igny8_core/business/site_building/services/validators.py deleted file mode 100644 index e21e2823..00000000 --- a/backend/igny8_core/business/site_building/services/validators.py +++ /dev/null @@ -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 - - diff --git a/backend/igny8_core/business/site_building/services/wizard_context_service.py b/backend/igny8_core/business/site_building/services/wizard_context_service.py deleted file mode 100644 index b6d31ed1..00000000 --- a/backend/igny8_core/business/site_building/services/wizard_context_service.py +++ /dev/null @@ -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, - } - diff --git a/backend/igny8_core/business/site_building/services/workflow_state_service.py b/backend/igny8_core/business/site_building/services/workflow_state_service.py deleted file mode 100644 index c5441e9c..00000000 --- a/backend/igny8_core/business/site_building/services/workflow_state_service.py +++ /dev/null @@ -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, - ) - diff --git a/backend/igny8_core/modules/site_builder/serializers.py b/backend/igny8_core/modules/site_builder/serializers.py index 3f8422be..6410d906 100644 --- a/backend/igny8_core/modules/site_builder/serializers.py +++ b/backend/igny8_core/modules/site_builder/serializers.py @@ -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): diff --git a/backend/igny8_core/modules/site_builder/views.py b/backend/igny8_core/modules/site_builder/views.py index 9e17c463..0b9124b6 100644 --- a/backend/igny8_core/modules/site_builder/views.py +++ b/backend/igny8_core/modules/site_builder/views.py @@ -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): """ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 697c3b56..c1e6ac4d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> - {/* Site Builder */} - - - - } /> - - - - } /> - - - - } /> {/* Help */} ([]); - const [loading, setLoading] = useState(false); - const [selectedIds, setSelectedIds] = useState>(new Set()); - const [deleteConfirm, setDeleteConfirm] = useState<{ - isOpen: boolean; - blueprint: SiteBlueprint | null; - isBulk: boolean; - }>({ isOpen: false, blueprint: null, isBulk: false }); - const [deployingId, setDeployingId] = useState(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: }, - { label: 'Create Site', path: '/sites/builder', icon: }, - { label: 'Blueprints', path: '/sites/blueprints', icon: }, - ]; - - return ( -
- - - {/* In-page navigation tabs */} - - -
-
-

- Sites / Blueprints -

-

- Blueprints -

-

- Review and preview structures generated for your active site. -

-
-
- {selectedIds.size > 0 && ( - - )} - -
-
- - {!activeSite ? ( - -

- Select a site using the header switcher to view its blueprints. -

-
- ) : loading ? ( -
-
- Loading blueprints… -
- ) : blueprints.length === 0 ? ( - - -

- No blueprints created yet for {activeSite.name}. -

- -
- ) : ( -
- {blueprints.length > 0 && ( -
- - {selectedIds.size > 0 && ( - - {selectedIds.size} of {blueprints.length} selected - - )} -
- )} -
- {blueprints.map((blueprint) => { - return ( - -
-
-

- Blueprint #{blueprint.id} -

-

- {blueprint.name} -

-
- -
- {blueprint.description && ( -

- {blueprint.description} -

- )} -
- Status - {blueprint.status} -
-
- - {(blueprint.status === 'ready' || blueprint.status === 'deployed') && ( - - )} - -
- - -
-
-
- ); - })} -
-
- )} - - 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" - /> -
- ); -} - diff --git a/frontend/src/pages/Sites/Builder/Preview.tsx b/frontend/src/pages/Sites/Builder/Preview.tsx deleted file mode 100644 index 3821c39e..00000000 --- a/frontend/src/pages/Sites/Builder/Preview.tsx +++ /dev/null @@ -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 ( -
- - - -

- Run the Site Builder wizard or open a blueprint to preview it. -

- -
-
- ); - } - - return ( -
- -
-
-

- Sites / Preview -

-

- {activeBlueprint.name} -

-

- Inspect the generated structure before publishing or deploying. -

-
-
- {activeBlueprint.status === 'ready' && ( - - )} - {(activeBlueprint.status === 'ready' || activeBlueprint.status === 'deployed') && ( - - )} - -
-
- - - -
- - Pages - Choose a page to inspect its blocks. -
- {pages.length === 0 && ( -

- No pages yet. Run the wizard to generate structure. -

- )} - {pages.map((page) => ( - - ))} -
-
- - - {selectedPageDefinition ? ( -
-
-

- Page -

-

- {selectedPageDefinition.title} -

-

- {selectedPageDefinition.objective || - "No objective provided"} -

-
-
- {selectedPageDefinition.blocks?.map((block, index) => ( -
-

- Block {index + 1} -

-

- {block.type.replace(/_/g, " ")} -

- {Array.isArray(block.content) ? ( -
    - {block.content.map((line, idx) => ( -
  • {line}
  • - ))} -
- ) : block.content ? ( -
-                        {JSON.stringify(block.content, null, 2)}
-                      
- ) : ( -

- No content provided -

- )} -
- ))} - {!selectedPageDefinition.blocks?.length && ( -

- This page has no block definitions yet. -

- )} -
-
- ) : ( -
- Select a page to see its details. -
- )} -
-
- - 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} - /> -
- ); -} - diff --git a/frontend/src/pages/Sites/Builder/Wizard.tsx b/frontend/src/pages/Sites/Builder/Wizard.tsx deleted file mode 100644 index 8fc92060..00000000 --- a/frontend/src/pages/Sites/Builder/Wizard.tsx +++ /dev/null @@ -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>([]); - - // Track last logged step to avoid duplicates - const lastLoggedStepRef = useRef(null); - const lastLoggedPercentageRef = useRef(-1); - const hasReloadedRef = useRef(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: ( - - ), - }, - { - title: "Brand brief", - component: , - }, - { - title: "Objectives", - component: ( - - ), - }, - { - title: "Look & feel", - component: ( - - ), - }, - ], - [ - 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 ( - - - - ); - } - if (isLastStep) { - return ; - } - return undefined; - }; - - // Navigation tabs for Sites module - const sitesTabs = [ - { label: 'All Sites', path: '/sites', icon: }, - { label: 'Create Site', path: '/sites/builder', icon: }, - { label: 'Blueprints', path: '/sites/blueprints', icon: }, - ]; - - return ( -
- - - {/* In-page navigation tabs */} - - -
- , color: "purple" }} - hideSiteSector - /> -
-

- Use the AI-powered wizard to capture business context, brand direction, and tone before generating blueprints. -

- -
-
- - - - {metadataError && ( - - )} - {isMetadataLoading && !metadata && ( - - )} - - {missingContext && ( - - )} - -
-
- -
- {steps.map((step, index) => ( - - ))} -
-
- - {steps[currentStep].component} - - {error && ( - - )} - -
-
- Step {currentStep + 1} of {steps.length} -
-
- - -
-
-
- -
- - Latest blueprint - - Once the wizard finishes, the most recent blueprint appears here. - - {activeBlueprint ? ( -
-
- Status - {activeBlueprint.status} -
-
- Pages generated - {pages.length} -
-
- - -
-
- ) : ( -
- Run the wizard to create your first blueprint. -
- )} -
- - {generationProgress && ( - - Generation progress - - Tracking background tasks queued for this blueprint. - -
-
- Pages queued - {generationProgress.pagesQueued} -
-
-

- Task IDs -

-

- {generationProgress.taskIds.join(", ")} -

-
-
- {generationProgress.celeryTaskId && ( -

- Celery task ID: {generationProgress.celeryTaskId} -

- )} -
- )} - - {isGenerating && ( - - )} -
-
- - { - 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 && ( -
-
-

- AI Function Logs -

- -
-
- {aiLogs.slice().reverse().map((log, index) => ( -
-
-
- - [{log.type.toUpperCase()}] - - - {log.action} - - {log.stepName && ( - - {log.stepName} - - )} - {log.percentage !== undefined && ( - - {log.percentage}% - - )} -
- - {new Date(log.timestamp).toLocaleTimeString()} - -
-
-                  {JSON.stringify(log.data, null, 2)}
-                
-
- ))} -
-
- )} -
- ); -} - diff --git a/frontend/src/pages/Sites/Builder/components/HelperDrawer.tsx b/frontend/src/pages/Sites/Builder/components/HelperDrawer.tsx deleted file mode 100644 index 46be6838..00000000 --- a/frontend/src/pages/Sites/Builder/components/HelperDrawer.tsx +++ /dev/null @@ -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 = { - 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 */} -