Refactor workflow state management in site building; enhance error handling and field validation in models and serializers. Remove obsolete workflow components from frontend and adjust API response structure for clarity.
This commit is contained in:
Binary file not shown.
@@ -330,9 +330,13 @@ class WorkflowState(SiteSectorBaseModel):
|
|||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if self.site_blueprint:
|
if self.site_blueprint:
|
||||||
self.account = self.site_blueprint.account
|
# Only set fields if blueprint has them (avoid errors if blueprint is missing fields)
|
||||||
self.site = self.site_blueprint.site
|
if self.site_blueprint.account_id:
|
||||||
self.sector = self.site_blueprint.sector
|
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)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|||||||
@@ -100,7 +100,29 @@ class WorkflowStateService:
|
|||||||
state.step_status = step_status
|
state.step_status = step_status
|
||||||
state.blocking_reason = blocking_reason
|
state.blocking_reason = blocking_reason
|
||||||
state.completed = all(value.get('status') == 'ready' for value in step_status.values())
|
state.completed = all(value.get('status') == 'ready' for value in step_status.values())
|
||||||
state.save(update_fields=['step_status', 'blocking_reason', 'completed', 'updated_at'])
|
|
||||||
|
# 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
|
return state
|
||||||
|
|
||||||
def update_step(
|
def update_step(
|
||||||
@@ -149,11 +171,28 @@ class WorkflowStateService:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
state.completed = False
|
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:
|
try:
|
||||||
state.save(update_fields=['current_step', 'step_status', 'blocking_reason', 'completed', 'updated_at'])
|
state.save(update_fields=update_fields)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to save workflow state for blueprint {site_blueprint.id}: {str(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
|
raise
|
||||||
|
|
||||||
self._emit_event(site_blueprint, 'wizard_step_updated', {
|
self._emit_event(site_blueprint, 'wizard_step_updated', {
|
||||||
|
|||||||
@@ -45,8 +45,9 @@ class PageBlueprintSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class SiteBlueprintSerializer(serializers.ModelSerializer):
|
class SiteBlueprintSerializer(serializers.ModelSerializer):
|
||||||
pages = PageBlueprintSerializer(many=True, read_only=True)
|
pages = PageBlueprintSerializer(many=True, read_only=True)
|
||||||
site_id = serializers.IntegerField(write_only=True, required=False)
|
site_id = serializers.IntegerField(required=False, read_only=True)
|
||||||
sector_id = serializers.IntegerField(write_only=True, required=False)
|
sector_id = serializers.IntegerField(required=False, read_only=True)
|
||||||
|
account_id = serializers.IntegerField(read_only=True)
|
||||||
workflow_state = serializers.SerializerMethodField()
|
workflow_state = serializers.SerializerMethodField()
|
||||||
gating_messages = serializers.SerializerMethodField()
|
gating_messages = serializers.SerializerMethodField()
|
||||||
|
|
||||||
@@ -62,6 +63,7 @@ class SiteBlueprintSerializer(serializers.ModelSerializer):
|
|||||||
'hosting_type',
|
'hosting_type',
|
||||||
'version',
|
'version',
|
||||||
'deployed_version',
|
'deployed_version',
|
||||||
|
'account_id',
|
||||||
'site_id',
|
'site_id',
|
||||||
'sector_id',
|
'sector_id',
|
||||||
'created_at',
|
'created_at',
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ from igny8_core.business.site_building.services import (
|
|||||||
PageGenerationService,
|
PageGenerationService,
|
||||||
SiteBuilderFileService,
|
SiteBuilderFileService,
|
||||||
StructureGenerationService,
|
StructureGenerationService,
|
||||||
WorkflowStateService,
|
|
||||||
TaxonomyService,
|
TaxonomyService,
|
||||||
WizardContextService,
|
WizardContextService,
|
||||||
)
|
)
|
||||||
@@ -51,7 +50,6 @@ class SiteBlueprintViewSet(SiteSectorModelViewSet):
|
|||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.workflow_service = WorkflowStateService()
|
|
||||||
self.taxonomy_service = TaxonomyService()
|
self.taxonomy_service = TaxonomyService()
|
||||||
self.wizard_context_service = WizardContextService()
|
self.wizard_context_service = WizardContextService()
|
||||||
|
|
||||||
@@ -115,18 +113,10 @@ class SiteBlueprintViewSet(SiteSectorModelViewSet):
|
|||||||
raise ValidationError({'sector_id': 'Sector does not belong to the selected site.'})
|
raise ValidationError({'sector_id': 'Sector does not belong to the selected site.'})
|
||||||
|
|
||||||
blueprint = serializer.save(account=site.account, site=site, sector=sector)
|
blueprint = serializer.save(account=site.account, site=site, sector=sector)
|
||||||
if self.workflow_service.enabled:
|
|
||||||
self.workflow_service.initialize(blueprint)
|
|
||||||
|
|
||||||
@action(detail=True, methods=['post'])
|
@action(detail=True, methods=['post'])
|
||||||
def generate_structure(self, request, pk=None):
|
def generate_structure(self, request, pk=None):
|
||||||
blueprint = self.get_object()
|
blueprint = self.get_object()
|
||||||
if self.workflow_service.enabled:
|
|
||||||
try:
|
|
||||||
self.workflow_service.validate_step(blueprint, 'clusters')
|
|
||||||
self.workflow_service.validate_step(blueprint, 'taxonomies')
|
|
||||||
except ValidationError as exc:
|
|
||||||
return error_response(str(exc), status.HTTP_400_BAD_REQUEST, request)
|
|
||||||
business_brief = request.data.get('business_brief') or \
|
business_brief = request.data.get('business_brief') or \
|
||||||
blueprint.config_json.get('business_brief', '')
|
blueprint.config_json.get('business_brief', '')
|
||||||
objectives = request.data.get('objectives') or \
|
objectives = request.data.get('objectives') or \
|
||||||
@@ -143,8 +133,6 @@ class SiteBlueprintViewSet(SiteSectorModelViewSet):
|
|||||||
metadata=request.data.get('metadata', {}),
|
metadata=request.data.get('metadata', {}),
|
||||||
)
|
)
|
||||||
response = Response(result, status=status.HTTP_202_ACCEPTED if 'task_id' in result else status.HTTP_200_OK)
|
response = Response(result, status=status.HTTP_202_ACCEPTED if 'task_id' in result else status.HTTP_200_OK)
|
||||||
if self.workflow_service.enabled:
|
|
||||||
self.workflow_service.refresh_state(blueprint)
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@action(detail=True, methods=['post'])
|
@action(detail=True, methods=['post'])
|
||||||
@@ -162,11 +150,6 @@ class SiteBlueprintViewSet(SiteSectorModelViewSet):
|
|||||||
page_ids = request.data.get('page_ids')
|
page_ids = request.data.get('page_ids')
|
||||||
force = request.data.get('force', False)
|
force = request.data.get('force', False)
|
||||||
|
|
||||||
if self.workflow_service.enabled:
|
|
||||||
try:
|
|
||||||
self.workflow_service.validate_step(blueprint, 'sitemap')
|
|
||||||
except ValidationError as exc:
|
|
||||||
return error_response(str(exc), status.HTTP_400_BAD_REQUEST, request)
|
|
||||||
service = PageGenerationService()
|
service = PageGenerationService()
|
||||||
try:
|
try:
|
||||||
result = service.bulk_generate_pages(
|
result = service.bulk_generate_pages(
|
||||||
@@ -176,8 +159,6 @@ class SiteBlueprintViewSet(SiteSectorModelViewSet):
|
|||||||
)
|
)
|
||||||
response_status = status.HTTP_202_ACCEPTED if result.get('success') else status.HTTP_400_BAD_REQUEST
|
response_status = status.HTTP_202_ACCEPTED if result.get('success') else status.HTTP_400_BAD_REQUEST
|
||||||
response = success_response(result, request=request, status_code=response_status)
|
response = success_response(result, request=request, status_code=response_status)
|
||||||
if self.workflow_service.enabled:
|
|
||||||
self.workflow_service.refresh_state(blueprint)
|
|
||||||
return response
|
return response
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return error_response(str(e), status.HTTP_400_BAD_REQUEST, request)
|
return error_response(str(e), status.HTTP_400_BAD_REQUEST, request)
|
||||||
@@ -200,12 +181,6 @@ class SiteBlueprintViewSet(SiteSectorModelViewSet):
|
|||||||
blueprint = self.get_object()
|
blueprint = self.get_object()
|
||||||
page_ids = request.data.get('page_ids')
|
page_ids = request.data.get('page_ids')
|
||||||
|
|
||||||
if self.workflow_service.enabled:
|
|
||||||
try:
|
|
||||||
self.workflow_service.validate_step(blueprint, 'coverage')
|
|
||||||
except ValidationError as exc:
|
|
||||||
return error_response(str(exc), status.HTTP_400_BAD_REQUEST, request)
|
|
||||||
|
|
||||||
service = PageGenerationService()
|
service = PageGenerationService()
|
||||||
try:
|
try:
|
||||||
tasks = service.create_tasks_for_pages(blueprint, page_ids=page_ids)
|
tasks = service.create_tasks_for_pages(blueprint, page_ids=page_ids)
|
||||||
@@ -215,8 +190,6 @@ class SiteBlueprintViewSet(SiteSectorModelViewSet):
|
|||||||
serializer = TasksSerializer(tasks, many=True)
|
serializer = TasksSerializer(tasks, many=True)
|
||||||
|
|
||||||
response = success_response({'tasks': serializer.data, 'count': len(tasks)}, request=request)
|
response = success_response({'tasks': serializer.data, 'count': len(tasks)}, request=request)
|
||||||
if self.workflow_service.enabled:
|
|
||||||
self.workflow_service.refresh_state(blueprint)
|
|
||||||
return response
|
return response
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return error_response(str(e), status.HTTP_400_BAD_REQUEST, request)
|
return error_response(str(e), status.HTTP_400_BAD_REQUEST, request)
|
||||||
@@ -306,103 +279,6 @@ class SiteBlueprintViewSet(SiteSectorModelViewSet):
|
|||||||
request=request
|
request=request
|
||||||
)
|
)
|
||||||
|
|
||||||
@action(detail=True, methods=['get'], url_path='workflow/context')
|
|
||||||
def workflow_context(self, request, pk=None):
|
|
||||||
"""Return aggregated wizard context (steps, clusters, taxonomies, coverage)."""
|
|
||||||
blueprint = self.get_object()
|
|
||||||
if not self.workflow_service.enabled:
|
|
||||||
# Return empty context structure matching frontend expectations
|
|
||||||
return success_response(
|
|
||||||
data={
|
|
||||||
'workflow': None,
|
|
||||||
'cluster_summary': {'attached_count': 0, 'coverage_counts': {}, 'clusters': []},
|
|
||||||
'taxonomy_summary': {'total_taxonomies': 0, 'counts_by_type': {}, 'taxonomies': []},
|
|
||||||
'sitemap_summary': {'pages_total': 0, 'pages_by_status': {}, 'pages_by_type': {}},
|
|
||||||
'coverage': {'pages_total': 0, 'pages_by_status': {}, 'pages_by_type': {}},
|
|
||||||
'next_actions': None,
|
|
||||||
},
|
|
||||||
request=request,
|
|
||||||
)
|
|
||||||
|
|
||||||
payload = self.wizard_context_service.build_context(blueprint)
|
|
||||||
return success_response(payload, request=request)
|
|
||||||
|
|
||||||
@action(detail=True, methods=['post'], url_path='workflow/step')
|
|
||||||
def update_workflow_step(self, request, pk=None):
|
|
||||||
"""
|
|
||||||
Update workflow step status.
|
|
||||||
|
|
||||||
Request body:
|
|
||||||
{
|
|
||||||
"step": "business_details", # Step name
|
|
||||||
"status": "ready", # Status: ready, blocked, in_progress
|
|
||||||
"metadata": {} # Optional metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{
|
|
||||||
"current_step": "business_details",
|
|
||||||
"step_status": {...},
|
|
||||||
"completed": false
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
blueprint = self.get_object()
|
|
||||||
if not self.workflow_service.enabled:
|
|
||||||
return error_response(
|
|
||||||
'Workflow service not enabled',
|
|
||||||
status.HTTP_400_BAD_REQUEST,
|
|
||||||
request
|
|
||||||
)
|
|
||||||
|
|
||||||
step = request.data.get('step')
|
|
||||||
status_value = request.data.get('status')
|
|
||||||
metadata = request.data.get('metadata', {})
|
|
||||||
|
|
||||||
if not step or not status_value:
|
|
||||||
return error_response(
|
|
||||||
'step and status are required',
|
|
||||||
status.HTTP_400_BAD_REQUEST,
|
|
||||||
request
|
|
||||||
)
|
|
||||||
|
|
||||||
valid_statuses = ['ready', 'blocked', 'in_progress', 'complete']
|
|
||||||
if status_value not in valid_statuses:
|
|
||||||
return error_response(
|
|
||||||
f'Invalid status. Must be one of: {", ".join(valid_statuses)}',
|
|
||||||
status.HTTP_400_BAD_REQUEST,
|
|
||||||
request
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
updated_state = self.workflow_service.update_step(
|
|
||||||
blueprint,
|
|
||||||
step,
|
|
||||||
status_value,
|
|
||||||
metadata
|
|
||||||
)
|
|
||||||
|
|
||||||
if not updated_state:
|
|
||||||
return error_response(
|
|
||||||
'Failed to update workflow step',
|
|
||||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
request
|
|
||||||
)
|
|
||||||
|
|
||||||
# Serialize state
|
|
||||||
serialized = self.workflow_service.serialize_state(updated_state)
|
|
||||||
|
|
||||||
return success_response(
|
|
||||||
data=serialized,
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception(f"Error updating workflow step for blueprint {blueprint.id}: {str(e)}")
|
|
||||||
return error_response(
|
|
||||||
f'Internal server error: {str(e)}',
|
|
||||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
request
|
|
||||||
)
|
|
||||||
|
|
||||||
@action(detail=True, methods=['post'], url_path='clusters/attach')
|
@action(detail=True, methods=['post'], url_path='clusters/attach')
|
||||||
def attach_clusters(self, request, pk=None):
|
def attach_clusters(self, request, pk=None):
|
||||||
"""
|
"""
|
||||||
@@ -492,10 +368,6 @@ class SiteBlueprintViewSet(SiteSectorModelViewSet):
|
|||||||
'link_id': existing.id
|
'link_id': existing.id
|
||||||
})
|
})
|
||||||
|
|
||||||
# Refresh workflow state if enabled
|
|
||||||
if self.workflow_service.enabled:
|
|
||||||
self.workflow_service.refresh_state(blueprint)
|
|
||||||
|
|
||||||
return success_response(
|
return success_response(
|
||||||
data={
|
data={
|
||||||
'attached_count': len(attached),
|
'attached_count': len(attached),
|
||||||
@@ -543,10 +415,6 @@ class SiteBlueprintViewSet(SiteSectorModelViewSet):
|
|||||||
detached_count = query.count()
|
detached_count = query.count()
|
||||||
query.delete()
|
query.delete()
|
||||||
|
|
||||||
# Refresh workflow state if enabled
|
|
||||||
if self.workflow_service.enabled:
|
|
||||||
self.workflow_service.refresh_state(blueprint)
|
|
||||||
|
|
||||||
return success_response(
|
return success_response(
|
||||||
data={'detached_count': detached_count},
|
data={'detached_count': detached_count},
|
||||||
request=request
|
request=request
|
||||||
@@ -636,10 +504,6 @@ class SiteBlueprintViewSet(SiteSectorModelViewSet):
|
|||||||
external_reference=external_reference,
|
external_reference=external_reference,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Refresh workflow state
|
|
||||||
if self.workflow_service.enabled:
|
|
||||||
self.workflow_service.refresh_state(blueprint)
|
|
||||||
|
|
||||||
return success_response(
|
return success_response(
|
||||||
data={
|
data={
|
||||||
'id': taxonomy.id,
|
'id': taxonomy.id,
|
||||||
@@ -689,10 +553,6 @@ class SiteBlueprintViewSet(SiteSectorModelViewSet):
|
|||||||
default_type=default_type
|
default_type=default_type
|
||||||
)
|
)
|
||||||
|
|
||||||
# Refresh workflow state
|
|
||||||
if self.workflow_service.enabled:
|
|
||||||
self.workflow_service.refresh_state(blueprint)
|
|
||||||
|
|
||||||
return success_response(
|
return success_response(
|
||||||
data={
|
data={
|
||||||
'imported_count': len(imported),
|
'imported_count': len(imported),
|
||||||
|
|||||||
@@ -99,7 +99,6 @@ const DeploymentPanel = lazy(() => import("./pages/Sites/DeploymentPanel"));
|
|||||||
|
|
||||||
// Site Builder - Lazy loaded (will be moved from separate container)
|
// Site Builder - Lazy loaded (will be moved from separate container)
|
||||||
const SiteBuilderWizard = lazy(() => import("./pages/Sites/Builder/Wizard"));
|
const SiteBuilderWizard = lazy(() => import("./pages/Sites/Builder/Wizard"));
|
||||||
const WorkflowWizard = lazy(() => import("./pages/Sites/Builder/WorkflowWizard"));
|
|
||||||
const SiteBuilderPreview = lazy(() => import("./pages/Sites/Builder/Preview"));
|
const SiteBuilderPreview = lazy(() => import("./pages/Sites/Builder/Preview"));
|
||||||
const SiteBuilderBlueprints = lazy(() => import("./pages/Sites/Builder/Blueprints"));
|
const SiteBuilderBlueprints = lazy(() => import("./pages/Sites/Builder/Blueprints"));
|
||||||
|
|
||||||
@@ -524,11 +523,6 @@ export default function App() {
|
|||||||
<SiteBuilderWizard />
|
<SiteBuilderWizard />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
} />
|
} />
|
||||||
<Route path="/sites/builder/workflow/:blueprintId" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<WorkflowWizard />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/sites/builder/preview" element={
|
<Route path="/sites/builder/preview" element={
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<SiteBuilderPreview />
|
<SiteBuilderPreview />
|
||||||
|
|||||||
@@ -293,16 +293,6 @@ export default function SiteProgressWidget({ blueprintId, siteId }: SiteProgress
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Deep Link to Blueprint */}
|
|
||||||
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<button
|
|
||||||
onClick={() => navigate(`/sites/builder/workflow/${blueprintId}`)}
|
|
||||||
className="w-full px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
|
||||||
aria-label="Continue site builder workflow"
|
|
||||||
>
|
|
||||||
Continue Site Builder Workflow →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error banner if data loaded but has errors */}
|
{/* Error banner if data loaded but has errors */}
|
||||||
{error && progress && (
|
{error && progress && (
|
||||||
|
|||||||
@@ -1,200 +0,0 @@
|
|||||||
/**
|
|
||||||
* Site Builder Workflow Wizard (Stage 2)
|
|
||||||
* Self-guided wizard with state-aware gating and progress tracking
|
|
||||||
*/
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
|
||||||
import { useBuilderWorkflowStore, WizardStep } from '../../../store/builderWorkflowStore';
|
|
||||||
import WizardProgress from './components/WizardProgress';
|
|
||||||
import HelperDrawer from './components/HelperDrawer';
|
|
||||||
import BusinessDetailsStep from './steps/BusinessDetailsStep';
|
|
||||||
import ClusterAssignmentStep from './steps/ClusterAssignmentStep';
|
|
||||||
import TaxonomyBuilderStep from './steps/TaxonomyBuilderStep';
|
|
||||||
import SitemapReviewStep from './steps/SitemapReviewStep';
|
|
||||||
import CoverageValidationStep from './steps/CoverageValidationStep';
|
|
||||||
import IdeasHandoffStep from './steps/IdeasHandoffStep';
|
|
||||||
import Alert from '../../../components/ui/alert/Alert';
|
|
||||||
import PageMeta from '../../../components/common/PageMeta';
|
|
||||||
import Button from '../../../components/ui/button/Button';
|
|
||||||
import { InfoIcon } from '../../../icons';
|
|
||||||
|
|
||||||
interface StepComponentProps {
|
|
||||||
blueprintId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STEP_COMPONENTS: Record<WizardStep, React.ComponentType<StepComponentProps>> = {
|
|
||||||
business_details: BusinessDetailsStep,
|
|
||||||
clusters: ClusterAssignmentStep,
|
|
||||||
taxonomies: TaxonomyBuilderStep,
|
|
||||||
sitemap: SitemapReviewStep,
|
|
||||||
coverage: CoverageValidationStep,
|
|
||||||
ideas: IdeasHandoffStep,
|
|
||||||
};
|
|
||||||
|
|
||||||
const STEP_LABELS: Record<WizardStep, string> = {
|
|
||||||
business_details: 'Business Details',
|
|
||||||
clusters: 'Cluster Assignment',
|
|
||||||
taxonomies: 'Taxonomy Builder',
|
|
||||||
sitemap: 'AI Sitemap Review',
|
|
||||||
coverage: 'Coverage Validation',
|
|
||||||
ideas: 'Ideas Hand-off',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function WorkflowWizard() {
|
|
||||||
const { blueprintId } = useParams<{ blueprintId: string }>();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const {
|
|
||||||
blueprintId: storeBlueprintId,
|
|
||||||
currentStep,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
context,
|
|
||||||
initialize,
|
|
||||||
refreshState,
|
|
||||||
goToStep,
|
|
||||||
} = useBuilderWorkflowStore();
|
|
||||||
|
|
||||||
const [helperDrawerOpen, setHelperDrawerOpen] = useState(false);
|
|
||||||
const id = blueprintId ? parseInt(blueprintId, 10) : null;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (id && id !== storeBlueprintId) {
|
|
||||||
initialize(id);
|
|
||||||
}
|
|
||||||
}, [id, storeBlueprintId, initialize]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Refresh state periodically to keep it in sync
|
|
||||||
if (id && storeBlueprintId === id) {
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
refreshState();
|
|
||||||
}, 10000); // Refresh every 10 seconds
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}
|
|
||||||
}, [id, storeBlueprintId, refreshState]);
|
|
||||||
|
|
||||||
// Keyboard navigation
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
// Don't interfere with input fields
|
|
||||||
if (
|
|
||||||
e.target instanceof HTMLInputElement ||
|
|
||||||
e.target instanceof HTMLTextAreaElement ||
|
|
||||||
e.target instanceof HTMLSelectElement
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Escape key: Close helper drawer
|
|
||||||
if (e.key === 'Escape' && helperDrawerOpen) {
|
|
||||||
setHelperDrawerOpen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// F1 or ? key: Toggle helper drawer
|
|
||||||
if (e.key === 'F1' || (e.key === '?' && !e.shiftKey && !e.ctrlKey && !e.metaKey)) {
|
|
||||||
e.preventDefault();
|
|
||||||
setHelperDrawerOpen(!helperDrawerOpen);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Arrow keys for navigation (when not in input)
|
|
||||||
if (e.key === 'ArrowLeft' && (e.ctrlKey || e.metaKey)) {
|
|
||||||
e.preventDefault();
|
|
||||||
// Navigate to previous step (if allowed)
|
|
||||||
const steps: WizardStep[] = ['business_details', 'clusters', 'taxonomies', 'sitemap', 'coverage', 'ideas'];
|
|
||||||
const currentIndex = steps.indexOf(currentStep);
|
|
||||||
if (currentIndex > 0) {
|
|
||||||
goToStep(steps[currentIndex - 1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.key === 'ArrowRight' && (e.ctrlKey || e.metaKey)) {
|
|
||||||
e.preventDefault();
|
|
||||||
// Navigate to next step (if allowed)
|
|
||||||
const steps: WizardStep[] = ['business_details', 'clusters', 'taxonomies', 'sitemap', 'coverage', 'ideas'];
|
|
||||||
const currentIndex = steps.indexOf(currentStep);
|
|
||||||
if (currentIndex < steps.length - 1) {
|
|
||||||
goToStep(steps[currentIndex + 1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}, [currentStep, helperDrawerOpen, goToStep]);
|
|
||||||
|
|
||||||
if (!id) {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<Alert variant="error" title="Error" message="Invalid blueprint ID" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading && !context) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<Alert variant="error" title="Error" message={error} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const StepComponent = STEP_COMPONENTS[currentStep];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
|
||||||
<PageMeta
|
|
||||||
title={`Site Builder - ${STEP_LABELS[currentStep]}`}
|
|
||||||
description={`Site Builder Workflow: ${STEP_LABELS[currentStep]}`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
||||||
{/* Header with Help Button */}
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
||||||
Site Builder Workflow
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
|
||||||
Step: {STEP_LABELS[currentStep]}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => setHelperDrawerOpen(!helperDrawerOpen)}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
startIcon={<InfoIcon className="h-4 w-4" />}
|
|
||||||
>
|
|
||||||
Help
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress Indicator */}
|
|
||||||
<WizardProgress currentStep={currentStep} />
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<div className="mt-8">
|
|
||||||
{StepComponent && <StepComponent blueprintId={id} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Helper Drawer */}
|
|
||||||
<HelperDrawer
|
|
||||||
currentStep={currentStep}
|
|
||||||
isOpen={helperDrawerOpen}
|
|
||||||
onClose={() => setHelperDrawerOpen(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -6,14 +6,16 @@
|
|||||||
* - Stage 1 Wizard: data, onChange, metadata, selectedSectors
|
* - Stage 1 Wizard: data, onChange, metadata, selectedSectors
|
||||||
* - Stage 2 Workflow: blueprintId
|
* - Stage 2 Workflow: blueprintId
|
||||||
*/
|
*/
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
import { useBuilderWorkflowStore } from '../../../../store/builderWorkflowStore';
|
import { useBuilderWorkflowStore } from '../../../../store/builderWorkflowStore';
|
||||||
import { fetchSiteBlueprintById, updateSiteBlueprint, SiteBlueprint } from '../../../../services/api';
|
import { fetchSiteBlueprintById, updateSiteBlueprint, SiteBlueprint } from '../../../../services/api';
|
||||||
import { Card, CardDescription, CardTitle } from '../../../../components/ui/card';
|
import { Card, CardDescription, CardTitle } from '../../../../components/ui/card';
|
||||||
import Button from '../../../../components/ui/button/Button';
|
import Button from '../../../../components/ui/button/Button';
|
||||||
import Input from '../../../../components/form/input/InputField';
|
import Input from '../../../../components/form/input/InputField';
|
||||||
import Alert from '../../../../components/ui/alert/Alert';
|
import Alert from '../../../../components/ui/alert/Alert';
|
||||||
import { BoltIcon, GridIcon } from '../../../../icons';
|
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';
|
import type { BuilderFormData, SiteBuilderMetadata } from '../../../../types/siteBuilder';
|
||||||
|
|
||||||
// Stage 1 Wizard props
|
// Stage 1 Wizard props
|
||||||
@@ -70,7 +72,31 @@ function BusinessDetailsStepStage2({ blueprintId }: { blueprintId: number }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Load blueprint data
|
// Load blueprint data
|
||||||
fetchSiteBlueprintById(blueprintId)
|
fetchSiteBlueprintById(blueprintId)
|
||||||
.then(setBlueprint)
|
.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));
|
.catch(err => setError(err.message));
|
||||||
}, [blueprintId]);
|
}, [blueprintId]);
|
||||||
|
|
||||||
@@ -118,8 +144,21 @@ function BusinessDetailsStepStage2({ blueprintId }: { blueprintId: number }) {
|
|||||||
|
|
||||||
// Check if it's a server error (500) - might be workflow service not enabled
|
// Check if it's a server error (500) - might be workflow service not enabled
|
||||||
const isServerError = workflowErr?.status === 500;
|
const isServerError = workflowErr?.status === 500;
|
||||||
|
const isClientError = workflowErr?.status >= 400 && workflowErr?.status < 500;
|
||||||
const errorDetail = workflowErr?.response?.error || workflowErr?.response?.message || '';
|
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')) {
|
if (isServerError && errorDetail.includes('Workflow service not enabled')) {
|
||||||
// Workflow service is disabled - just advance without marking as complete
|
// Workflow service is disabled - just advance without marking as complete
|
||||||
console.warn('Workflow service not enabled, advancing to next step');
|
console.warn('Workflow service not enabled, advancing to next step');
|
||||||
@@ -131,7 +170,7 @@ function BusinessDetailsStepStage2({ blueprintId }: { blueprintId: number }) {
|
|||||||
|
|
||||||
console.warn('Workflow step update failed:', workflowErrorMsg, workflowErr);
|
console.warn('Workflow step update failed:', workflowErrorMsg, workflowErr);
|
||||||
|
|
||||||
// Mark step as completed locally so user can proceed
|
// For other errors, allow user to proceed but show warning
|
||||||
const { completedSteps, goToStep } = useBuilderWorkflowStore.getState();
|
const { completedSteps, goToStep } = useBuilderWorkflowStore.getState();
|
||||||
const updatedCompletedSteps = new Set(completedSteps);
|
const updatedCompletedSteps = new Set(completedSteps);
|
||||||
updatedCompletedSteps.add('business_details');
|
updatedCompletedSteps.add('business_details');
|
||||||
@@ -151,7 +190,10 @@ function BusinessDetailsStepStage2({ blueprintId }: { blueprintId: number }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const canProceed = formData.name.trim().length > 0;
|
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 (
|
return (
|
||||||
<Card variant="surface" padding="lg" className="space-y-6">
|
<Card variant="surface" padding="lg" className="space-y-6">
|
||||||
@@ -163,7 +205,13 @@ function BusinessDetailsStepStage2({ blueprintId }: { blueprintId: number }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Alert variant="error" title="Error" message={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="mt-6 space-y-4">
|
||||||
@@ -223,13 +271,182 @@ function BusinessDetailsStepStage2({ blueprintId }: { blueprintId: number }) {
|
|||||||
|
|
||||||
{!canProceed && (
|
{!canProceed && (
|
||||||
<Alert variant="warning" className="mt-4">
|
<Alert variant="warning" className="mt-4">
|
||||||
Please provide a site name to continue.
|
{!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>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</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
|
// Stage 1 Wizard Component
|
||||||
function BusinessDetailsStepStage1({
|
function BusinessDetailsStepStage1({
|
||||||
data,
|
data,
|
||||||
@@ -313,14 +530,24 @@ function BusinessDetailsStepStage1({
|
|||||||
<label className="text-sm font-semibold text-gray-900 dark:text-white">
|
<label className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
Target audience
|
Target audience
|
||||||
</label>
|
</label>
|
||||||
<Input
|
{metadata?.audience_profiles && metadata.audience_profiles.length > 0 ? (
|
||||||
value={data.targetAudience}
|
<TargetAudienceSelector
|
||||||
onChange={(e) => onChange('targetAudience', e.target.value)}
|
data={data}
|
||||||
placeholder="Operations leaders at fast-scaling eCommerce brands"
|
onChange={onChange}
|
||||||
/>
|
metadata={metadata}
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
/>
|
||||||
Helps the AI craft messaging, examples, and tone.
|
) : (
|
||||||
</p>
|
<>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
@@ -329,11 +556,55 @@ function BusinessDetailsStepStage1({
|
|||||||
<label className="text-sm font-semibold text-gray-900 dark:text-white">
|
<label className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
Business type
|
Business type
|
||||||
</label>
|
</label>
|
||||||
<Input
|
{metadata?.business_types && metadata.business_types.length > 0 ? (
|
||||||
value={data.businessType}
|
<>
|
||||||
onChange={(e) => onChange('businessType', e.target.value)}
|
<SelectDropdown
|
||||||
placeholder="B2B SaaS platform"
|
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>
|
||||||
<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]">
|
<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">
|
<label className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
import { useToast } from '../../../../components/ui/toast/ToastContainer';
|
import { useToast } from '../../../../components/ui/toast/ToastContainer';
|
||||||
import { useSectorStore } from '../../../../store/sectorStore';
|
import { useSectorStore } from '../../../../store/sectorStore';
|
||||||
import { CheckCircleIcon, XCircleIcon } from '../../../../icons';
|
import { CheckCircleIcon, XCircleIcon } from '../../../../icons';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
interface ClusterAssignmentStepProps {
|
interface ClusterAssignmentStepProps {
|
||||||
blueprintId: number;
|
blueprintId: number;
|
||||||
@@ -37,6 +38,7 @@ export default function ClusterAssignmentStep({ blueprintId }: ClusterAssignment
|
|||||||
const { context, completeStep, blockingIssues, refreshState, loading: workflowLoading } = useBuilderWorkflowStore();
|
const { context, completeStep, blockingIssues, refreshState, loading: workflowLoading } = useBuilderWorkflowStore();
|
||||||
const { activeSector } = useSectorStore();
|
const { activeSector } = useSectorStore();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [clusters, setClusters] = useState<Cluster[]>([]);
|
const [clusters, setClusters] = useState<Cluster[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -322,11 +324,32 @@ export default function ClusterAssignmentStep({ blueprintId }: ClusterAssignment
|
|||||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
|
||||||
</div>
|
</div>
|
||||||
) : filteredClusters.length === 0 ? (
|
) : filteredClusters.length === 0 ? (
|
||||||
<Alert variant="info" className="mt-4">
|
<div className="mt-4">
|
||||||
{searchTerm || statusFilter || roleFilter
|
{searchTerm || statusFilter || roleFilter ? (
|
||||||
? 'No clusters match your filters. Try adjusting your search criteria.'
|
<Alert variant="info">
|
||||||
: 'No clusters available. Create clusters in Planner → Clusters first.'}
|
No clusters match your filters. Try adjusting your search criteria.
|
||||||
</Alert>
|
</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">
|
<div className="border rounded-lg overflow-hidden">
|
||||||
<Table>
|
<Table>
|
||||||
|
|||||||
@@ -2134,8 +2134,9 @@ export interface SiteBlueprint {
|
|||||||
hosting_type: string;
|
hosting_type: string;
|
||||||
version: number;
|
version: number;
|
||||||
deployed_version?: number;
|
deployed_version?: number;
|
||||||
site_id: number;
|
account_id?: number;
|
||||||
sector_id: number;
|
site_id?: number;
|
||||||
|
sector_id?: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
pages?: PageBlueprint[];
|
pages?: PageBlueprint[];
|
||||||
|
|||||||
@@ -237,71 +237,74 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
|||||||
error: undefined,
|
error: undefined,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
let lastBlueprint: SiteBlueprint | undefined;
|
// Use only the first sector to create ONE blueprint (not one per sector)
|
||||||
let lastStructure: SiteStructure | undefined;
|
const sectorId = preparedForm.sectorIds[0];
|
||||||
for (const sectorId of preparedForm.sectorIds) {
|
if (!sectorId) {
|
||||||
const payload = {
|
set({
|
||||||
name:
|
error: "No sector selected. Please select at least one sector.",
|
||||||
preparedForm.siteName ||
|
});
|
||||||
`Site Blueprint (${preparedForm.industry || "New"})`,
|
return;
|
||||||
description: targetAudienceSummary
|
}
|
||||||
? `${businessTypeName} • ${targetAudienceSummary}`
|
|
||||||
: businessTypeName,
|
const payload = {
|
||||||
site_id: preparedForm.siteId!,
|
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,
|
sector_id: sectorId,
|
||||||
hosting_type: preparedForm.hostingType,
|
},
|
||||||
config_json: {
|
};
|
||||||
business_type_id: preparedForm.businessTypeId,
|
|
||||||
business_type: businessTypeName,
|
const blueprint = await siteBuilderApi.createBlueprint(payload);
|
||||||
custom_business_type: preparedForm.customBusinessType,
|
|
||||||
industry: preparedForm.industry,
|
const generation = await siteBuilderApi.generateStructure(
|
||||||
target_audience_ids: preparedForm.targetAudienceIds,
|
blueprint.id,
|
||||||
target_audience: audienceNames,
|
{
|
||||||
custom_target_audience: preparedForm.customTargetAudience,
|
business_brief: preparedForm.businessBrief,
|
||||||
brand_personality_ids: preparedForm.brandPersonalityIds,
|
objectives: preparedForm.objectives,
|
||||||
brand_personality: brandPersonalityNames,
|
style: stylePreferences,
|
||||||
custom_brand_personality: preparedForm.customBrandPersonality,
|
metadata: {
|
||||||
hero_imagery_direction_id: preparedForm.heroImageryDirectionId,
|
targetAudience: audienceNames,
|
||||||
hero_imagery_direction: heroImageryName,
|
brandPersonality: brandPersonalityNames,
|
||||||
custom_hero_imagery_direction:
|
sectorId,
|
||||||
preparedForm.customHeroImageryDirection,
|
|
||||||
sector_id: sectorId,
|
|
||||||
},
|
},
|
||||||
};
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const blueprint = await siteBuilderApi.createBlueprint(payload);
|
if (generation?.task_id) {
|
||||||
lastBlueprint = blueprint;
|
set({ structureTaskId: generation.task_id });
|
||||||
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (generation?.structure) {
|
|
||||||
lastStructure = generation.structure;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastBlueprint) {
|
let lastStructure: SiteStructure | undefined;
|
||||||
set({ activeBlueprint: lastBlueprint });
|
if (generation?.structure) {
|
||||||
if (lastStructure) {
|
lastStructure = generation.structure;
|
||||||
useSiteDefinitionStore.getState().setStructure(lastStructure);
|
|
||||||
}
|
|
||||||
await get().refreshPages(lastBlueprint.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
set({ activeBlueprint: blueprint });
|
||||||
|
if (lastStructure) {
|
||||||
|
useSiteDefinitionStore.getState().setStructure(lastStructure);
|
||||||
|
}
|
||||||
|
await get().refreshPages(blueprint.id);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
set({
|
set({
|
||||||
error: error?.message || "Unexpected error while running wizard",
|
error: error?.message || "Unexpected error while running wizard",
|
||||||
|
|||||||
@@ -1,254 +0,0 @@
|
|||||||
/**
|
|
||||||
* Builder Workflow Store (Zustand)
|
|
||||||
* Manages wizard progress + gating state for site blueprints
|
|
||||||
*/
|
|
||||||
import { create } from 'zustand';
|
|
||||||
import { persist } from 'zustand/middleware';
|
|
||||||
import {
|
|
||||||
fetchWizardContext,
|
|
||||||
updateWorkflowStep,
|
|
||||||
WizardContext,
|
|
||||||
WorkflowState,
|
|
||||||
} from '../services/api';
|
|
||||||
|
|
||||||
export type WizardStep =
|
|
||||||
| 'business_details'
|
|
||||||
| 'clusters'
|
|
||||||
| 'taxonomies'
|
|
||||||
| 'sitemap'
|
|
||||||
| 'coverage'
|
|
||||||
| 'ideas';
|
|
||||||
|
|
||||||
interface BuilderWorkflowState {
|
|
||||||
// Current blueprint being worked on
|
|
||||||
blueprintId: number | null;
|
|
||||||
|
|
||||||
// Workflow state
|
|
||||||
currentStep: WizardStep;
|
|
||||||
completedSteps: Set<WizardStep>;
|
|
||||||
blockingIssues: Array<{ step: WizardStep; message: string }>;
|
|
||||||
workflowState: WorkflowState | null;
|
|
||||||
|
|
||||||
// Wizard context (cluster/taxonomy summaries)
|
|
||||||
context: WizardContext | null;
|
|
||||||
|
|
||||||
// Loading/error states
|
|
||||||
loading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
|
|
||||||
// Telemetry queue (for future event tracking)
|
|
||||||
telemetryQueue: Array<{ event: string; data: Record<string, any>; timestamp: string }>;
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
initialize: (blueprintId: number) => Promise<void>;
|
|
||||||
refreshState: () => Promise<void>;
|
|
||||||
refreshContext: () => Promise<void>; // Alias for refreshState
|
|
||||||
goToStep: (step: WizardStep) => void;
|
|
||||||
completeStep: (step: WizardStep, metadata?: Record<string, any>) => Promise<void>;
|
|
||||||
setBlockingIssue: (step: WizardStep, message: string) => void;
|
|
||||||
clearBlockingIssue: (step: WizardStep) => void;
|
|
||||||
flushTelemetry: () => void;
|
|
||||||
reset: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_STEP: WizardStep = 'business_details';
|
|
||||||
|
|
||||||
export const useBuilderWorkflowStore = create<BuilderWorkflowState>()(
|
|
||||||
persist<BuilderWorkflowState>(
|
|
||||||
(set, get) => ({
|
|
||||||
blueprintId: null,
|
|
||||||
currentStep: DEFAULT_STEP,
|
|
||||||
completedSteps: new Set(),
|
|
||||||
blockingIssues: [],
|
|
||||||
workflowState: null,
|
|
||||||
context: null,
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
telemetryQueue: [],
|
|
||||||
|
|
||||||
initialize: async (blueprintId: number) => {
|
|
||||||
set({ blueprintId, loading: true, error: null });
|
|
||||||
try {
|
|
||||||
const context = await fetchWizardContext(blueprintId);
|
|
||||||
const workflow = context?.workflow;
|
|
||||||
|
|
||||||
// If workflow is null, initialize with defaults
|
|
||||||
if (!workflow) {
|
|
||||||
set({
|
|
||||||
blueprintId,
|
|
||||||
currentStep: DEFAULT_STEP,
|
|
||||||
completedSteps: new Set<WizardStep>(),
|
|
||||||
blockingIssues: [],
|
|
||||||
workflowState: null,
|
|
||||||
context,
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine completed steps from workflow state
|
|
||||||
// Backend returns 'steps' as an array, not 'step_status' as an object
|
|
||||||
const completedSteps = new Set<WizardStep>();
|
|
||||||
const steps = workflow.steps || [];
|
|
||||||
steps.forEach((stepData: any) => {
|
|
||||||
if (stepData?.status === 'ready' || stepData?.status === 'complete') {
|
|
||||||
completedSteps.add(stepData.step as WizardStep);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Extract blocking issues
|
|
||||||
const blockingIssues: Array<{ step: WizardStep; message: string }> = [];
|
|
||||||
steps.forEach((stepData: any) => {
|
|
||||||
if (stepData?.status === 'blocked' && stepData?.message) {
|
|
||||||
blockingIssues.push({ step: stepData.step as WizardStep, message: stepData.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
set({
|
|
||||||
blueprintId,
|
|
||||||
currentStep: (workflow.current_step as WizardStep) || DEFAULT_STEP,
|
|
||||||
completedSteps,
|
|
||||||
blockingIssues,
|
|
||||||
workflowState: workflow,
|
|
||||||
context,
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Emit telemetry event
|
|
||||||
get().flushTelemetry();
|
|
||||||
} catch (error: any) {
|
|
||||||
set({
|
|
||||||
error: error.message || 'Failed to initialize workflow',
|
|
||||||
loading: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
refreshState: async () => {
|
|
||||||
const { blueprintId } = get();
|
|
||||||
if (!blueprintId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await get().initialize(blueprintId);
|
|
||||||
},
|
|
||||||
|
|
||||||
refreshContext: async () => {
|
|
||||||
// Alias for refreshState
|
|
||||||
await get().refreshState();
|
|
||||||
},
|
|
||||||
|
|
||||||
goToStep: (step: WizardStep) => {
|
|
||||||
set({ currentStep: step });
|
|
||||||
|
|
||||||
// Emit telemetry
|
|
||||||
const { blueprintId } = get();
|
|
||||||
if (blueprintId) {
|
|
||||||
get().flushTelemetry();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
completeStep: async (step: WizardStep, metadata?: Record<string, any>) => {
|
|
||||||
const { blueprintId, workflowState } = get();
|
|
||||||
if (!blueprintId) {
|
|
||||||
throw new Error('No blueprint initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure workflow is initialized before updating
|
|
||||||
if (!workflowState) {
|
|
||||||
// Try to initialize first
|
|
||||||
await get().initialize(blueprintId);
|
|
||||||
}
|
|
||||||
|
|
||||||
set({ loading: true, error: null });
|
|
||||||
try {
|
|
||||||
const updatedState = await updateWorkflowStep(blueprintId, step, 'ready', metadata);
|
|
||||||
|
|
||||||
// Update local state
|
|
||||||
const completedSteps = new Set(get().completedSteps);
|
|
||||||
completedSteps.add(step);
|
|
||||||
|
|
||||||
const blockingIssues = get().blockingIssues.filter(issue => issue.step !== step);
|
|
||||||
|
|
||||||
set({
|
|
||||||
workflowState: updatedState,
|
|
||||||
completedSteps,
|
|
||||||
blockingIssues,
|
|
||||||
loading: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Refresh full context to get updated summaries
|
|
||||||
await get().refreshState();
|
|
||||||
|
|
||||||
// Emit telemetry
|
|
||||||
get().flushTelemetry();
|
|
||||||
} catch (error: any) {
|
|
||||||
// Extract more detailed error message if available
|
|
||||||
const errorMessage = error?.response?.error ||
|
|
||||||
error?.response?.message ||
|
|
||||||
error?.message ||
|
|
||||||
`Failed to complete step: ${step}`;
|
|
||||||
set({
|
|
||||||
error: errorMessage,
|
|
||||||
loading: false,
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
setBlockingIssue: (step: WizardStep, message: string) => {
|
|
||||||
const blockingIssues = [...get().blockingIssues];
|
|
||||||
const existingIndex = blockingIssues.findIndex(issue => issue.step === step);
|
|
||||||
|
|
||||||
if (existingIndex >= 0) {
|
|
||||||
blockingIssues[existingIndex] = { step, message };
|
|
||||||
} else {
|
|
||||||
blockingIssues.push({ step, message });
|
|
||||||
}
|
|
||||||
|
|
||||||
set({ blockingIssues });
|
|
||||||
},
|
|
||||||
|
|
||||||
clearBlockingIssue: (step: WizardStep) => {
|
|
||||||
const blockingIssues = get().blockingIssues.filter(issue => issue.step !== step);
|
|
||||||
set({ blockingIssues });
|
|
||||||
},
|
|
||||||
|
|
||||||
flushTelemetry: () => {
|
|
||||||
// TODO: In Stage 2, implement actual telemetry dispatch
|
|
||||||
// For now, just clear the queue
|
|
||||||
const queue = get().telemetryQueue;
|
|
||||||
if (queue.length > 0) {
|
|
||||||
// Future: dispatch to analytics service
|
|
||||||
console.debug('Telemetry events (to be dispatched):', queue);
|
|
||||||
set({ telemetryQueue: [] });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
reset: () => {
|
|
||||||
set({
|
|
||||||
blueprintId: null,
|
|
||||||
currentStep: DEFAULT_STEP,
|
|
||||||
completedSteps: new Set(),
|
|
||||||
blockingIssues: [],
|
|
||||||
workflowState: null,
|
|
||||||
context: null,
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
telemetryQueue: [],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
name: 'builder-workflow-storage',
|
|
||||||
partialize: (state) => ({
|
|
||||||
blueprintId: state.blueprintId,
|
|
||||||
currentStep: state.currentStep,
|
|
||||||
// Note: completedSteps, blockingIssues, workflowState, context are not persisted
|
|
||||||
// They should be refreshed from API on mount
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user