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:
IGNY8 VPS (Salman)
2025-11-20 23:08:07 +00:00
parent 1b4cd59e5b
commit c31567ec9f
13 changed files with 437 additions and 704 deletions

Binary file not shown.

View File

@@ -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):

View File

@@ -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', {

View File

@@ -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',

View File

@@ -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),

View File

@@ -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 />

View File

@@ -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 && (

View File

@@ -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>
);
}

View File

@@ -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">

View File

@@ -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>

View File

@@ -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[];

View File

@@ -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",

View File

@@ -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
}),
}
)
);