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):
|
||||
if self.site_blueprint:
|
||||
self.account = self.site_blueprint.account
|
||||
self.site = self.site_blueprint.site
|
||||
self.sector = self.site_blueprint.sector
|
||||
# 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):
|
||||
|
||||
@@ -100,7 +100,29 @@ class WorkflowStateService:
|
||||
state.step_status = step_status
|
||||
state.blocking_reason = blocking_reason
|
||||
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
|
||||
|
||||
def update_step(
|
||||
@@ -150,10 +172,27 @@ class WorkflowStateService:
|
||||
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=['current_step', 'step_status', 'blocking_reason', 'completed', 'updated_at'])
|
||||
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)}")
|
||||
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', {
|
||||
|
||||
@@ -45,8 +45,9 @@ class PageBlueprintSerializer(serializers.ModelSerializer):
|
||||
|
||||
class SiteBlueprintSerializer(serializers.ModelSerializer):
|
||||
pages = PageBlueprintSerializer(many=True, read_only=True)
|
||||
site_id = serializers.IntegerField(write_only=True, required=False)
|
||||
sector_id = serializers.IntegerField(write_only=True, required=False)
|
||||
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()
|
||||
|
||||
@@ -62,6 +63,7 @@ class SiteBlueprintSerializer(serializers.ModelSerializer):
|
||||
'hosting_type',
|
||||
'version',
|
||||
'deployed_version',
|
||||
'account_id',
|
||||
'site_id',
|
||||
'sector_id',
|
||||
'created_at',
|
||||
|
||||
@@ -27,7 +27,6 @@ from igny8_core.business.site_building.services import (
|
||||
PageGenerationService,
|
||||
SiteBuilderFileService,
|
||||
StructureGenerationService,
|
||||
WorkflowStateService,
|
||||
TaxonomyService,
|
||||
WizardContextService,
|
||||
)
|
||||
@@ -51,7 +50,6 @@ class SiteBlueprintViewSet(SiteSectorModelViewSet):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.workflow_service = WorkflowStateService()
|
||||
self.taxonomy_service = TaxonomyService()
|
||||
self.wizard_context_service = WizardContextService()
|
||||
|
||||
@@ -115,18 +113,10 @@ class SiteBlueprintViewSet(SiteSectorModelViewSet):
|
||||
raise ValidationError({'sector_id': 'Sector does not belong to the selected site.'})
|
||||
|
||||
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'])
|
||||
def generate_structure(self, request, pk=None):
|
||||
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 \
|
||||
blueprint.config_json.get('business_brief', '')
|
||||
objectives = request.data.get('objectives') or \
|
||||
@@ -143,8 +133,6 @@ class SiteBlueprintViewSet(SiteSectorModelViewSet):
|
||||
metadata=request.data.get('metadata', {}),
|
||||
)
|
||||
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
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
@@ -162,11 +150,6 @@ class SiteBlueprintViewSet(SiteSectorModelViewSet):
|
||||
page_ids = request.data.get('page_ids')
|
||||
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()
|
||||
try:
|
||||
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 = success_response(result, request=request, status_code=response_status)
|
||||
if self.workflow_service.enabled:
|
||||
self.workflow_service.refresh_state(blueprint)
|
||||
return response
|
||||
except Exception as e:
|
||||
return error_response(str(e), status.HTTP_400_BAD_REQUEST, request)
|
||||
@@ -200,12 +181,6 @@ class SiteBlueprintViewSet(SiteSectorModelViewSet):
|
||||
blueprint = self.get_object()
|
||||
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()
|
||||
try:
|
||||
tasks = service.create_tasks_for_pages(blueprint, page_ids=page_ids)
|
||||
@@ -215,8 +190,6 @@ class SiteBlueprintViewSet(SiteSectorModelViewSet):
|
||||
serializer = TasksSerializer(tasks, many=True)
|
||||
|
||||
response = success_response({'tasks': serializer.data, 'count': len(tasks)}, request=request)
|
||||
if self.workflow_service.enabled:
|
||||
self.workflow_service.refresh_state(blueprint)
|
||||
return response
|
||||
except Exception as e:
|
||||
return error_response(str(e), status.HTTP_400_BAD_REQUEST, request)
|
||||
@@ -306,103 +279,6 @@ class SiteBlueprintViewSet(SiteSectorModelViewSet):
|
||||
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')
|
||||
def attach_clusters(self, request, pk=None):
|
||||
"""
|
||||
@@ -492,10 +368,6 @@ class SiteBlueprintViewSet(SiteSectorModelViewSet):
|
||||
'link_id': existing.id
|
||||
})
|
||||
|
||||
# Refresh workflow state if enabled
|
||||
if self.workflow_service.enabled:
|
||||
self.workflow_service.refresh_state(blueprint)
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
'attached_count': len(attached),
|
||||
@@ -543,10 +415,6 @@ class SiteBlueprintViewSet(SiteSectorModelViewSet):
|
||||
detached_count = query.count()
|
||||
query.delete()
|
||||
|
||||
# Refresh workflow state if enabled
|
||||
if self.workflow_service.enabled:
|
||||
self.workflow_service.refresh_state(blueprint)
|
||||
|
||||
return success_response(
|
||||
data={'detached_count': detached_count},
|
||||
request=request
|
||||
@@ -636,10 +504,6 @@ class SiteBlueprintViewSet(SiteSectorModelViewSet):
|
||||
external_reference=external_reference,
|
||||
)
|
||||
|
||||
# Refresh workflow state
|
||||
if self.workflow_service.enabled:
|
||||
self.workflow_service.refresh_state(blueprint)
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
'id': taxonomy.id,
|
||||
@@ -689,10 +553,6 @@ class SiteBlueprintViewSet(SiteSectorModelViewSet):
|
||||
default_type=default_type
|
||||
)
|
||||
|
||||
# Refresh workflow state
|
||||
if self.workflow_service.enabled:
|
||||
self.workflow_service.refresh_state(blueprint)
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
'imported_count': len(imported),
|
||||
|
||||
@@ -99,7 +99,6 @@ 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 WorkflowWizard = lazy(() => import("./pages/Sites/Builder/WorkflowWizard"));
|
||||
const SiteBuilderPreview = lazy(() => import("./pages/Sites/Builder/Preview"));
|
||||
const SiteBuilderBlueprints = lazy(() => import("./pages/Sites/Builder/Blueprints"));
|
||||
|
||||
@@ -524,11 +523,6 @@ export default function App() {
|
||||
<SiteBuilderWizard />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/sites/builder/workflow/:blueprintId" element={
|
||||
<Suspense fallback={null}>
|
||||
<WorkflowWizard />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/sites/builder/preview" element={
|
||||
<Suspense fallback={null}>
|
||||
<SiteBuilderPreview />
|
||||
|
||||
@@ -293,16 +293,6 @@ export default function SiteProgressWidget({ blueprintId, siteId }: SiteProgress
|
||||
</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 && 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 2 Workflow: blueprintId
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
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 { 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';
|
||||
|
||||
// Stage 1 Wizard props
|
||||
@@ -70,7 +72,31 @@ function BusinessDetailsStepStage2({ blueprintId }: { blueprintId: number }) {
|
||||
useEffect(() => {
|
||||
// Load blueprint data
|
||||
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));
|
||||
}, [blueprintId]);
|
||||
|
||||
@@ -118,8 +144,21 @@ function BusinessDetailsStepStage2({ blueprintId }: { blueprintId: number }) {
|
||||
|
||||
// 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');
|
||||
@@ -131,7 +170,7 @@ function BusinessDetailsStepStage2({ blueprintId }: { blueprintId: number }) {
|
||||
|
||||
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 updatedCompletedSteps = new Set(completedSteps);
|
||||
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 (
|
||||
<Card variant="surface" padding="lg" className="space-y-6">
|
||||
@@ -163,7 +205,13 @@ function BusinessDetailsStepStage2({ blueprintId }: { blueprintId: number }) {
|
||||
</div>
|
||||
|
||||
{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">
|
||||
@@ -223,13 +271,182 @@ function BusinessDetailsStepStage2({ blueprintId }: { blueprintId: number }) {
|
||||
|
||||
{!canProceed && (
|
||||
<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>
|
||||
)}
|
||||
</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,
|
||||
@@ -313,6 +530,14 @@ function BusinessDetailsStepStage1({
|
||||
<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)}
|
||||
@@ -321,6 +546,8 @@ function BusinessDetailsStepStage1({
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Helps the AI craft messaging, examples, and tone.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -329,11 +556,55 @@ function BusinessDetailsStepStage1({
|
||||
<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">
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
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;
|
||||
@@ -37,6 +38,7 @@ export default function ClusterAssignmentStep({ blueprintId }: ClusterAssignment
|
||||
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);
|
||||
@@ -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>
|
||||
) : filteredClusters.length === 0 ? (
|
||||
<Alert variant="info" className="mt-4">
|
||||
{searchTerm || statusFilter || roleFilter
|
||||
? 'No clusters match your filters. Try adjusting your search criteria.'
|
||||
: 'No clusters available. Create clusters in Planner → Clusters first.'}
|
||||
<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>
|
||||
|
||||
@@ -2134,8 +2134,9 @@ export interface SiteBlueprint {
|
||||
hosting_type: string;
|
||||
version: number;
|
||||
deployed_version?: number;
|
||||
site_id: number;
|
||||
sector_id: number;
|
||||
account_id?: number;
|
||||
site_id?: number;
|
||||
sector_id?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
pages?: PageBlueprint[];
|
||||
|
||||
@@ -237,9 +237,15 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||
error: undefined,
|
||||
});
|
||||
try {
|
||||
let lastBlueprint: SiteBlueprint | undefined;
|
||||
let lastStructure: SiteStructure | undefined;
|
||||
for (const sectorId of preparedForm.sectorIds) {
|
||||
// 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 ||
|
||||
@@ -270,7 +276,6 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||
};
|
||||
|
||||
const blueprint = await siteBuilderApi.createBlueprint(payload);
|
||||
lastBlueprint = blueprint;
|
||||
|
||||
const generation = await siteBuilderApi.generateStructure(
|
||||
blueprint.id,
|
||||
@@ -290,18 +295,16 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||
set({ structureTaskId: generation.task_id });
|
||||
}
|
||||
|
||||
let lastStructure: SiteStructure | undefined;
|
||||
if (generation?.structure) {
|
||||
lastStructure = generation.structure;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastBlueprint) {
|
||||
set({ activeBlueprint: lastBlueprint });
|
||||
set({ activeBlueprint: blueprint });
|
||||
if (lastStructure) {
|
||||
useSiteDefinitionStore.getState().setStructure(lastStructure);
|
||||
}
|
||||
await get().refreshPages(lastBlueprint.id);
|
||||
}
|
||||
await get().refreshPages(blueprint.id);
|
||||
} catch (error: any) {
|
||||
set({
|
||||
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