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

View File

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

View File

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

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 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,14 +530,24 @@ function BusinessDetailsStepStage1({
<label className="text-sm font-semibold text-gray-900 dark:text-white">
Target audience
</label>
<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>
{metadata?.audience_profiles && metadata.audience_profiles.length > 0 ? (
<TargetAudienceSelector
data={data}
onChange={onChange}
metadata={metadata}
/>
) : (
<>
<Input
value={data.targetAudience}
onChange={(e) => onChange('targetAudience', e.target.value)}
placeholder="Operations leaders at fast-scaling eCommerce brands"
/>
<p className="text-xs text-gray-500 dark:text-gray-400">
Helps the AI craft messaging, examples, and tone.
</p>
</>
)}
</div>
</div>
@@ -329,11 +556,55 @@ function BusinessDetailsStepStage1({
<label className="text-sm font-semibold text-gray-900 dark:text-white">
Business type
</label>
<Input
value={data.businessType}
onChange={(e) => onChange('businessType', e.target.value)}
placeholder="B2B SaaS platform"
/>
{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">

View File

@@ -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.'}
</Alert>
<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>

View File

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

View File

@@ -237,71 +237,74 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
error: undefined,
});
try {
let lastBlueprint: SiteBlueprint | undefined;
let lastStructure: SiteStructure | undefined;
for (const sectorId of preparedForm.sectorIds) {
const payload = {
name:
preparedForm.siteName ||
`Site Blueprint (${preparedForm.industry || "New"})`,
description: targetAudienceSummary
? `${businessTypeName}${targetAudienceSummary}`
: businessTypeName,
site_id: preparedForm.siteId!,
// Use only the first sector to create ONE blueprint (not one per sector)
const sectorId = preparedForm.sectorIds[0];
if (!sectorId) {
set({
error: "No sector selected. Please select at least one sector.",
});
return;
}
const payload = {
name:
preparedForm.siteName ||
`Site Blueprint (${preparedForm.industry || "New"})`,
description: targetAudienceSummary
? `${businessTypeName}${targetAudienceSummary}`
: businessTypeName,
site_id: preparedForm.siteId!,
sector_id: sectorId,
hosting_type: preparedForm.hostingType,
config_json: {
business_type_id: preparedForm.businessTypeId,
business_type: businessTypeName,
custom_business_type: preparedForm.customBusinessType,
industry: preparedForm.industry,
target_audience_ids: preparedForm.targetAudienceIds,
target_audience: audienceNames,
custom_target_audience: preparedForm.customTargetAudience,
brand_personality_ids: preparedForm.brandPersonalityIds,
brand_personality: brandPersonalityNames,
custom_brand_personality: preparedForm.customBrandPersonality,
hero_imagery_direction_id: preparedForm.heroImageryDirectionId,
hero_imagery_direction: heroImageryName,
custom_hero_imagery_direction:
preparedForm.customHeroImageryDirection,
sector_id: sectorId,
hosting_type: preparedForm.hostingType,
config_json: {
business_type_id: preparedForm.businessTypeId,
business_type: businessTypeName,
custom_business_type: preparedForm.customBusinessType,
industry: preparedForm.industry,
target_audience_ids: preparedForm.targetAudienceIds,
target_audience: audienceNames,
custom_target_audience: preparedForm.customTargetAudience,
brand_personality_ids: preparedForm.brandPersonalityIds,
brand_personality: brandPersonalityNames,
custom_brand_personality: preparedForm.customBrandPersonality,
hero_imagery_direction_id: preparedForm.heroImageryDirectionId,
hero_imagery_direction: heroImageryName,
custom_hero_imagery_direction:
preparedForm.customHeroImageryDirection,
sector_id: sectorId,
},
};
const blueprint = await siteBuilderApi.createBlueprint(payload);
const generation = await siteBuilderApi.generateStructure(
blueprint.id,
{
business_brief: preparedForm.businessBrief,
objectives: preparedForm.objectives,
style: stylePreferences,
metadata: {
targetAudience: audienceNames,
brandPersonality: brandPersonalityNames,
sectorId,
},
};
},
);
const blueprint = await siteBuilderApi.createBlueprint(payload);
lastBlueprint = blueprint;
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 (generation?.task_id) {
set({ structureTaskId: generation.task_id });
}
if (lastBlueprint) {
set({ activeBlueprint: lastBlueprint });
if (lastStructure) {
useSiteDefinitionStore.getState().setStructure(lastStructure);
}
await get().refreshPages(lastBlueprint.id);
let lastStructure: SiteStructure | undefined;
if (generation?.structure) {
lastStructure = generation.structure;
}
set({ activeBlueprint: blueprint });
if (lastStructure) {
useSiteDefinitionStore.getState().setStructure(lastStructure);
}
await get().refreshPages(blueprint.id);
} catch (error: any) {
set({
error: error?.message || "Unexpected error while running wizard",

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