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:
Binary file not shown.
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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. We’ll 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user