Implement Stage 3: Enhance content metadata and validation features
- Added entity metadata fields to the Tasks model, including entity_type, taxonomy, and cluster_role. - Updated CandidateEngine to prioritize content relevance based on cluster mappings. - Introduced metadata completeness scoring in ContentAnalyzer. - Enhanced validation services to check for entity type and mapping completeness. - Updated frontend components to display and validate new metadata fields. - Implemented API endpoints for content validation and metadata persistence. - Migrated existing data to populate new metadata fields for Tasks and Content.
This commit is contained in:
@@ -5,6 +5,7 @@ interface InputProps {
|
||||
type?: "text" | "number" | "email" | "password" | "date" | "time" | string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
value?: string | number;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
@@ -16,12 +17,15 @@ interface InputProps {
|
||||
success?: boolean;
|
||||
error?: boolean;
|
||||
hint?: string;
|
||||
multiline?: boolean;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
const Input: FC<InputProps> = ({
|
||||
type = "text",
|
||||
id,
|
||||
name,
|
||||
label,
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
@@ -33,6 +37,8 @@ const Input: FC<InputProps> = ({
|
||||
success = false,
|
||||
error = false,
|
||||
hint,
|
||||
multiline = false,
|
||||
rows = 3,
|
||||
}) => {
|
||||
let inputClasses = ` h-9 w-full rounded-lg border appearance-none px-3 py-2 text-sm shadow-theme-xs placeholder:text-gray-400 focus:outline-hidden focus:ring-3 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 ${className}`;
|
||||
|
||||
@@ -46,21 +52,44 @@ const Input: FC<InputProps> = ({
|
||||
inputClasses += ` bg-transparent text-gray-800 border-gray-300 focus:border-brand-300 focus:ring-brand-500/20 dark:border-gray-700 dark:text-white/90 dark:focus:border-brand-800`;
|
||||
}
|
||||
|
||||
const inputId = id || name || `input-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
type={type}
|
||||
id={id}
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={disabled}
|
||||
className={inputClasses}
|
||||
/>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
{multiline ? (
|
||||
<textarea
|
||||
id={inputId}
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
value={value as string}
|
||||
onChange={onChange as any}
|
||||
disabled={disabled}
|
||||
rows={rows}
|
||||
className={inputClasses.replace('h-9', '')}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type={type}
|
||||
id={inputId}
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={disabled}
|
||||
className={inputClasses}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hint && (
|
||||
<p
|
||||
|
||||
@@ -6,11 +6,15 @@ interface ScoreData {
|
||||
readability_score: number;
|
||||
engagement_score: number;
|
||||
overall_score: number;
|
||||
metadata_completeness_score?: number; // Stage 3: Metadata completeness
|
||||
word_count?: number;
|
||||
has_meta_title?: boolean;
|
||||
has_meta_description?: boolean;
|
||||
has_primary_keyword?: boolean;
|
||||
internal_links_count?: number;
|
||||
has_cluster_mapping?: boolean; // Stage 3: Cluster mapping
|
||||
has_taxonomy_mapping?: boolean; // Stage 3: Taxonomy mapping
|
||||
has_attributes?: boolean; // Stage 3: Attributes
|
||||
}
|
||||
|
||||
interface OptimizationScoresProps {
|
||||
@@ -53,7 +57,7 @@ export const OptimizationScores: React.FC<OptimizationScoresProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`grid grid-cols-1 md:grid-cols-4 gap-4 ${className}`}>
|
||||
<div className={`grid grid-cols-1 md:grid-cols-5 gap-4 ${className}`}>
|
||||
{/* Overall Score */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
@@ -149,6 +153,51 @@ export const OptimizationScores: React.FC<OptimizationScoresProps> = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata Completeness Score - Stage 3 */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-600 dark:text-gray-400">Metadata</span>
|
||||
{before && scores.metadata_completeness_score !== undefined && getChangeIcon(
|
||||
scores.metadata_completeness_score,
|
||||
before.metadata_completeness_score
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className={`text-2xl font-bold ${getScoreColor(scores.metadata_completeness_score || 0)}`}>
|
||||
{(scores.metadata_completeness_score || 0).toFixed(1)}
|
||||
</span>
|
||||
{before && scores.metadata_completeness_score !== undefined && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{getChangeText(scores.metadata_completeness_score, before.metadata_completeness_score)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={`mt-2 h-2 rounded-full ${getScoreBgColor(scores.metadata_completeness_score || 0)}`}>
|
||||
<div
|
||||
className={`h-2 rounded-full ${getScoreColor(scores.metadata_completeness_score || 0).replace('text-', 'bg-')}`}
|
||||
style={{ width: `${scores.metadata_completeness_score || 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
{/* Metadata indicators */}
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs">
|
||||
{scores.has_cluster_mapping && (
|
||||
<span className="px-2 py-0.5 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded">
|
||||
Cluster
|
||||
</span>
|
||||
)}
|
||||
{scores.has_taxonomy_mapping && (
|
||||
<span className="px-2 py-0.5 bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200 rounded">
|
||||
Taxonomy
|
||||
</span>
|
||||
)}
|
||||
{scores.has_attributes && (
|
||||
<span className="px-2 py-0.5 bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 rounded">
|
||||
Attributes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -204,6 +204,94 @@ export const createContentPageConfig = (
|
||||
<SourceBadge source={(row.source as ContentSource) || 'igny8'} />
|
||||
),
|
||||
},
|
||||
// Stage 3: Metadata columns
|
||||
{
|
||||
key: 'entity_type',
|
||||
label: 'Entity Type',
|
||||
sortable: true,
|
||||
sortField: 'entity_type',
|
||||
width: '120px',
|
||||
defaultVisible: true,
|
||||
render: (value: string, row: Content) => {
|
||||
const entityType = value || row.entity_type;
|
||||
if (!entityType) {
|
||||
return <span className="text-gray-400 dark:text-gray-500">-</span>;
|
||||
}
|
||||
const typeLabels: Record<string, string> = {
|
||||
'blog_post': 'Blog Post',
|
||||
'article': 'Article',
|
||||
'product': 'Product',
|
||||
'service': 'Service',
|
||||
'taxonomy': 'Taxonomy',
|
||||
'page': 'Page',
|
||||
};
|
||||
return (
|
||||
<Badge color="info" size="sm" variant="light">
|
||||
{typeLabels[entityType] || entityType}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'cluster',
|
||||
label: 'Cluster',
|
||||
sortable: false,
|
||||
width: '150px',
|
||||
defaultVisible: true,
|
||||
render: (_value: any, row: Content) => {
|
||||
const clusterName = row.cluster_name;
|
||||
if (!clusterName) {
|
||||
return <span className="text-gray-400 dark:text-gray-500">-</span>;
|
||||
}
|
||||
return (
|
||||
<Badge color="primary" size="sm" variant="light">
|
||||
{clusterName}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'cluster_role',
|
||||
label: 'Role',
|
||||
sortable: true,
|
||||
sortField: 'cluster_role',
|
||||
width: '100px',
|
||||
defaultVisible: false,
|
||||
render: (value: string, row: Content) => {
|
||||
const role = value || row.cluster_role;
|
||||
if (!role) {
|
||||
return <span className="text-gray-400 dark:text-gray-500">-</span>;
|
||||
}
|
||||
const roleColors: Record<string, 'primary' | 'success' | 'warning'> = {
|
||||
'hub': 'primary',
|
||||
'supporting': 'success',
|
||||
'attribute': 'warning',
|
||||
};
|
||||
return (
|
||||
<Badge color={roleColors[role] || 'primary'} size="sm" variant="light">
|
||||
{role.charAt(0).toUpperCase() + role.slice(1)}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'taxonomy',
|
||||
label: 'Taxonomy',
|
||||
sortable: false,
|
||||
width: '150px',
|
||||
defaultVisible: false,
|
||||
render: (_value: any, row: Content) => {
|
||||
const taxonomyName = row.taxonomy_name;
|
||||
if (!taxonomyName) {
|
||||
return <span className="text-gray-400 dark:text-gray-500">-</span>;
|
||||
}
|
||||
return (
|
||||
<Badge color="purple" size="sm" variant="light">
|
||||
{taxonomyName}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'sync_status',
|
||||
label: 'Sync Status',
|
||||
@@ -349,6 +437,21 @@ export const createContentPageConfig = (
|
||||
{ value: 'publish', label: 'Publish' },
|
||||
],
|
||||
},
|
||||
// Stage 3: Entity type filter
|
||||
{
|
||||
key: 'entity_type',
|
||||
label: 'Entity Type',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: '', label: 'All Types' },
|
||||
{ value: 'blog_post', label: 'Blog Post' },
|
||||
{ value: 'article', label: 'Article' },
|
||||
{ value: 'product', label: 'Product' },
|
||||
{ value: 'service', label: 'Service' },
|
||||
{ value: 'taxonomy', label: 'Taxonomy' },
|
||||
{ value: 'page', label: 'Page' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'source',
|
||||
label: 'Source',
|
||||
|
||||
@@ -214,11 +214,20 @@ const LayoutContent: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load balance if not already loaded
|
||||
if (!balance && !useBillingStore.getState().loading) {
|
||||
const billingState = useBillingStore.getState();
|
||||
// Load balance if not already loaded and not currently loading
|
||||
if (!balance && !billingState.loading) {
|
||||
loadBalance().catch((error) => {
|
||||
console.error('AppLayout: Error loading credit balance:', error);
|
||||
// Don't show error to user - balance is not critical for app functionality
|
||||
// But retry after a delay
|
||||
setTimeout(() => {
|
||||
if (!useBillingStore.getState().balance && !useBillingStore.getState().loading) {
|
||||
loadBalance().catch(() => {
|
||||
// Silently fail on retry too
|
||||
});
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
}, [isAuthenticated, balance, loadBalance, setMetrics]);
|
||||
@@ -226,12 +235,18 @@ const LayoutContent: React.FC = () => {
|
||||
// Update header metrics when balance changes
|
||||
// This sets credit balance which will be merged with page metrics by HeaderMetricsContext
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !balance) {
|
||||
// Clear credit balance but keep page metrics
|
||||
if (!isAuthenticated) {
|
||||
// Only clear metrics when not authenticated (user logged out)
|
||||
setMetrics([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// If balance is null, don't clear metrics - let page metrics stay visible
|
||||
// Only set credit metrics when balance is loaded
|
||||
if (!balance) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine accent color based on credit level
|
||||
let accentColor: 'blue' | 'green' | 'amber' | 'purple' = 'blue';
|
||||
if (balance.credits > 1000) {
|
||||
|
||||
@@ -121,6 +121,9 @@ export default function LinkerContentList() {
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Source
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Cluster
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Links
|
||||
</th>
|
||||
@@ -147,6 +150,20 @@ export default function LinkerContentList() {
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<SourceBadge source={(item.source as ContentSource) || 'igny8'} />
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{item.cluster_name ? (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{item.cluster_name}
|
||||
{item.cluster_role && (
|
||||
<span className="ml-1 text-blue-600 dark:text-blue-400">
|
||||
({item.cluster_role})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400 dark:text-gray-500">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{item.internal_links?.length || 0}
|
||||
</td>
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
} from "../../services/api";
|
||||
import { useSiteStore } from "../../store/siteStore";
|
||||
import { useSectorStore } from "../../store/sectorStore";
|
||||
import { Link } from "react-router";
|
||||
import Alert from "../../components/ui/alert/Alert";
|
||||
|
||||
interface DashboardStats {
|
||||
|
||||
@@ -1,21 +1,61 @@
|
||||
/**
|
||||
* Step 1: Business Details
|
||||
* Site type selection, hosting detection, brand inputs
|
||||
*
|
||||
* Supports both:
|
||||
* - Stage 1 Wizard: data, onChange, metadata, selectedSectors
|
||||
* - Stage 2 Workflow: blueprintId
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useBuilderWorkflowStore } from '../../../../store/builderWorkflowStore';
|
||||
import { fetchSiteBlueprintById, updateSiteBlueprint, SiteBlueprint } from '../../../../services/api';
|
||||
import { Card, CardDescription, CardTitle } from '../../../../components/ui/card';
|
||||
import ButtonWithTooltip from '../../../../components/ui/button/ButtonWithTooltip';
|
||||
import Input from '../../../../components/ui/input/Input';
|
||||
import Input from '../../../../components/form/input/InputField';
|
||||
import Alert from '../../../../components/ui/alert/Alert';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import type { BuilderFormData, SiteBuilderMetadata } from '../../../../types/siteBuilder';
|
||||
|
||||
interface BusinessDetailsStepProps {
|
||||
blueprintId: number;
|
||||
// Stage 1 Wizard props
|
||||
interface Stage1Props {
|
||||
data: BuilderFormData;
|
||||
onChange: <K extends keyof BuilderFormData>(key: K, value: BuilderFormData[K]) => void;
|
||||
metadata?: SiteBuilderMetadata;
|
||||
selectedSectors?: Array<{ id: number; name: string }>;
|
||||
blueprintId?: never;
|
||||
}
|
||||
|
||||
export default function BusinessDetailsStep({ blueprintId }: BusinessDetailsStepProps) {
|
||||
// Stage 2 Workflow props
|
||||
interface Stage2Props {
|
||||
blueprintId: number;
|
||||
data?: never;
|
||||
onChange?: never;
|
||||
metadata?: never;
|
||||
selectedSectors?: never;
|
||||
}
|
||||
|
||||
type BusinessDetailsStepProps = Stage1Props | Stage2Props;
|
||||
|
||||
export function BusinessDetailsStep(props: BusinessDetailsStepProps) {
|
||||
// Check if this is Stage 2 (has blueprintId)
|
||||
const isStage2 = 'blueprintId' in props && props.blueprintId !== undefined;
|
||||
|
||||
// Stage 2 implementation
|
||||
if (isStage2) {
|
||||
return <BusinessDetailsStepStage2 blueprintId={props.blueprintId} />;
|
||||
}
|
||||
|
||||
// Stage 1 implementation
|
||||
return <BusinessDetailsStepStage1
|
||||
data={props.data}
|
||||
onChange={props.onChange}
|
||||
metadata={props.metadata}
|
||||
selectedSectors={props.selectedSectors}
|
||||
/>;
|
||||
}
|
||||
|
||||
// Stage 2 Workflow Component
|
||||
function BusinessDetailsStepStage2({ blueprintId }: { blueprintId: number }) {
|
||||
const { context, completeStep, loading } = useBuilderWorkflowStore();
|
||||
const [blueprint, setBlueprint] = useState<SiteBlueprint | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -154,3 +194,88 @@ export default function BusinessDetailsStep({ blueprintId }: BusinessDetailsStep
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Stage 1 Wizard Component
|
||||
function BusinessDetailsStepStage1({
|
||||
data,
|
||||
onChange,
|
||||
metadata,
|
||||
selectedSectors
|
||||
}: {
|
||||
data: BuilderFormData;
|
||||
onChange: <K extends keyof BuilderFormData>(key: K, value: BuilderFormData[K]) => void;
|
||||
metadata?: SiteBuilderMetadata;
|
||||
selectedSectors?: Array<{ id: number; name: string }>;
|
||||
}) {
|
||||
return (
|
||||
<Card variant="surface" padding="lg">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wider text-gray-500 dark:text-white/50">
|
||||
Business context
|
||||
</p>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Business details
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
These details help the AI understand what kind of site we are building.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Site name</label>
|
||||
<Input
|
||||
value={data.siteName}
|
||||
onChange={(e) => onChange('siteName', e.target.value)}
|
||||
placeholder="Acme Robotics"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Business type</label>
|
||||
<Input
|
||||
value={data.businessType}
|
||||
onChange={(e) => onChange('businessType', e.target.value)}
|
||||
placeholder="B2B SaaS platform"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Industry</label>
|
||||
<Input
|
||||
value={data.industry}
|
||||
onChange={(e) => onChange('industry', e.target.value)}
|
||||
placeholder="Supply chain automation"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Target audience</label>
|
||||
<Input
|
||||
value={data.targetAudience}
|
||||
onChange={(e) => onChange('targetAudience', e.target.value)}
|
||||
placeholder="Operations leaders at fast-scaling eCommerce brands"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Hosting preference</label>
|
||||
<select
|
||||
value={data.hostingType}
|
||||
onChange={(e) => onChange('hostingType', e.target.value as BuilderFormData['hostingType'])}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
>
|
||||
<option value="igny8_sites">IGNY8 Sites</option>
|
||||
<option value="wordpress">WordPress</option>
|
||||
<option value="shopify">Shopify</option>
|
||||
<option value="multi">Multiple destinations</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Also export as default for WorkflowWizard compatibility
|
||||
export default BusinessDetailsStep;
|
||||
|
||||
@@ -13,8 +13,9 @@ import {
|
||||
} from '../../../../services/api';
|
||||
import { Card, CardDescription, CardTitle } from '../../../../components/ui/card';
|
||||
import ButtonWithTooltip from '../../../../components/ui/button/ButtonWithTooltip';
|
||||
import Button from '../../../../components/ui/button/Button';
|
||||
import Alert from '../../../../components/ui/alert/Alert';
|
||||
import Input from '../../../../components/ui/input/Input';
|
||||
import Input from '../../../../components/form/input/InputField';
|
||||
import Checkbox from '../../../../components/form/input/Checkbox';
|
||||
import SelectDropdown from '../../../../components/form/SelectDropdown';
|
||||
import {
|
||||
|
||||
@@ -10,10 +10,11 @@ import {
|
||||
import { siteBuilderApi } from '../../../../services/siteBuilder.api';
|
||||
import { Card, CardDescription, CardTitle } from '../../../../components/ui/card';
|
||||
import ButtonWithTooltip from '../../../../components/ui/button/ButtonWithTooltip';
|
||||
import Button from '../../../../components/ui/button/Button';
|
||||
import Alert from '../../../../components/ui/alert/Alert';
|
||||
import Checkbox from '../../../../components/form/input/Checkbox';
|
||||
import Input from '../../../../components/ui/input/Input';
|
||||
import { useToast } from '../../../../hooks/useToast';
|
||||
import Input from '../../../../components/form/input/InputField';
|
||||
import { useToast } from '../../../../components/ui/toast/ToastContainer';
|
||||
|
||||
interface IdeasHandoffStepProps {
|
||||
blueprintId: number;
|
||||
|
||||
@@ -12,9 +12,10 @@ import {
|
||||
import { siteBuilderApi } from '../../../../services/siteBuilder.api';
|
||||
import { Card, CardDescription, CardTitle } from '../../../../components/ui/card';
|
||||
import ButtonWithTooltip from '../../../../components/ui/button/ButtonWithTooltip';
|
||||
import Button from '../../../../components/ui/button/Button';
|
||||
import Alert from '../../../../components/ui/alert/Alert';
|
||||
import Input from '../../../../components/ui/input/Input';
|
||||
import { useToast } from '../../../../hooks/useToast';
|
||||
import Input from '../../../../components/form/input/InputField';
|
||||
import { useToast } from '../../../../components/ui/toast/ToastContainer';
|
||||
|
||||
interface SitemapReviewStepProps {
|
||||
blueprintId: number;
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Card, CardDescription, CardTitle } from '../../../../components/ui/card
|
||||
import ButtonWithTooltip from '../../../../components/ui/button/ButtonWithTooltip';
|
||||
import Button from '../../../../components/ui/button/Button';
|
||||
import Alert from '../../../../components/ui/alert/Alert';
|
||||
import Input from '../../../../components/ui/input/Input';
|
||||
import Input from '../../../../components/form/input/InputField';
|
||||
import SelectDropdown from '../../../../components/form/SelectDropdown';
|
||||
import {
|
||||
Table,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { SaveIcon, XIcon, EyeIcon, FileTextIcon, SettingsIcon, TagIcon } from 'lucide-react';
|
||||
import { SaveIcon, XIcon, EyeIcon, FileTextIcon, SettingsIcon, TagIcon, CheckCircleIcon, XCircleIcon, AlertCircleIcon } from 'lucide-react';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import Button from '../../components/ui/button/Button';
|
||||
@@ -13,7 +13,7 @@ import Label from '../../components/form/Label';
|
||||
import TextArea from '../../components/form/input/TextArea';
|
||||
import SelectDropdown from '../../components/form/SelectDropdown';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { fetchAPI } from '../../services/api';
|
||||
import { fetchAPI, fetchContentValidation, validateContent, ContentValidationResult } from '../../services/api';
|
||||
|
||||
interface Content {
|
||||
id?: number;
|
||||
@@ -40,7 +40,9 @@ export default function PostEditor() {
|
||||
const toast = useToast();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'content' | 'seo' | 'metadata'>('content');
|
||||
const [activeTab, setActiveTab] = useState<'content' | 'seo' | 'metadata' | 'validation'>('content');
|
||||
const [validationResult, setValidationResult] = useState<ContentValidationResult | null>(null);
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [content, setContent] = useState<Content>({
|
||||
title: '',
|
||||
html_content: '',
|
||||
@@ -64,12 +66,44 @@ export default function PostEditor() {
|
||||
loadSite();
|
||||
if (postId && postId !== 'new') {
|
||||
loadPost();
|
||||
loadValidation();
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [siteId, postId]);
|
||||
|
||||
const loadValidation = async () => {
|
||||
if (!postId || postId === 'new') return;
|
||||
try {
|
||||
const result = await fetchContentValidation(Number(postId));
|
||||
setValidationResult(result);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load validation:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleValidate = async () => {
|
||||
if (!content.id) {
|
||||
toast.error('Please save the content first before validating');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setValidating(true);
|
||||
const result = await validateContent(content.id);
|
||||
await loadValidation();
|
||||
if (result.is_valid) {
|
||||
toast.success('Content validation passed!');
|
||||
} else {
|
||||
toast.warning(`Validation found ${result.errors.length} issue(s)`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(`Validation failed: ${error.message}`);
|
||||
} finally {
|
||||
setValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSite = async () => {
|
||||
try {
|
||||
const site = await fetchAPI(`/v1/auth/sites/${siteId}/`);
|
||||
@@ -302,6 +336,28 @@ export default function PostEditor() {
|
||||
<TagIcon className="w-4 h-4 inline mr-2" />
|
||||
Metadata
|
||||
</button>
|
||||
{content.id && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveTab('validation');
|
||||
loadValidation();
|
||||
}}
|
||||
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'validation'
|
||||
? 'border-brand-500 text-brand-600 dark:text-brand-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<CheckCircleIcon className="w-4 h-4 inline mr-2" />
|
||||
Validation
|
||||
{validationResult && !validationResult.is_valid && (
|
||||
<span className="ml-2 px-2 py-0.5 text-xs bg-red-100 dark:bg-red-900 text-red-600 dark:text-red-400 rounded-full">
|
||||
{validationResult.validation_errors.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -514,6 +570,152 @@ export default function PostEditor() {
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Validation Tab - Stage 3 */}
|
||||
{activeTab === 'validation' && content.id && (
|
||||
<Card className="p-6">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Content Validation
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Check if your content meets all requirements before publishing
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleValidate}
|
||||
disabled={validating}
|
||||
>
|
||||
{validating ? 'Validating...' : 'Run Validation'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{validationResult ? (
|
||||
<div className="space-y-4">
|
||||
{/* Validation Status */}
|
||||
<div className={`p-4 rounded-lg ${
|
||||
validationResult.is_valid
|
||||
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800'
|
||||
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
{validationResult.is_valid ? (
|
||||
<CheckCircleIcon className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<XCircleIcon className="w-5 h-5 text-red-600 dark:text-red-400" />
|
||||
)}
|
||||
<span className={`font-medium ${
|
||||
validationResult.is_valid
|
||||
? 'text-green-800 dark:text-green-300'
|
||||
: 'text-red-800 dark:text-red-300'
|
||||
}`}>
|
||||
{validationResult.is_valid
|
||||
? 'Content is valid and ready to publish'
|
||||
: `Content has ${validationResult.validation_errors.length} validation error(s)`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata Summary */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Metadata Summary
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-600 dark:text-gray-400">Entity Type:</span>
|
||||
<span className="ml-2 font-medium text-gray-900 dark:text-white">
|
||||
{validationResult.metadata.entity_type || 'Not set'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600 dark:text-gray-400">Cluster Mapping:</span>
|
||||
<span className={`ml-2 font-medium ${
|
||||
validationResult.metadata.has_cluster_mapping
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
}`}>
|
||||
{validationResult.metadata.has_cluster_mapping ? 'Yes' : 'No'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600 dark:text-gray-400">Taxonomy Mapping:</span>
|
||||
<span className={`ml-2 font-medium ${
|
||||
validationResult.metadata.has_taxonomy_mapping
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
}`}>
|
||||
{validationResult.metadata.has_taxonomy_mapping ? 'Yes' : 'No'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation Errors */}
|
||||
{validationResult.validation_errors.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Validation Errors
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{validationResult.validation_errors.map((error, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800"
|
||||
>
|
||||
<AlertCircleIcon className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-red-800 dark:text-red-300">
|
||||
{error.field || error.code}
|
||||
</div>
|
||||
<div className="text-sm text-red-600 dark:text-red-400 mt-1">
|
||||
{error.message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Publish Errors */}
|
||||
{validationResult.publish_errors && validationResult.publish_errors.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Publish Blockers
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{validationResult.publish_errors.map((error, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-start gap-2 p-3 bg-orange-50 dark:bg-orange-900/20 rounded-lg border border-orange-200 dark:border-orange-800"
|
||||
>
|
||||
<XCircleIcon className="w-5 h-5 text-orange-600 dark:text-orange-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-orange-800 dark:text-orange-300">
|
||||
{error.field || error.code}
|
||||
</div>
|
||||
<div className="text-sm text-orange-600 dark:text-orange-400 mt-1">
|
||||
{error.message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<p>Click "Run Validation" to check your content</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1601,14 +1601,29 @@ export interface UsageSummary {
|
||||
}
|
||||
|
||||
export async function fetchCreditBalance(): Promise<CreditBalance> {
|
||||
const response = await fetchAPI('/v1/billing/credits/balance/balance/');
|
||||
// fetchAPI automatically extracts data field from unified format
|
||||
return response || {
|
||||
credits: 0,
|
||||
plan_credits_per_month: 0,
|
||||
credits_used_this_month: 0,
|
||||
credits_remaining: 0,
|
||||
};
|
||||
try {
|
||||
const response = await fetchAPI('/v1/billing/credits/balance/balance/');
|
||||
// fetchAPI automatically extracts data field from unified format
|
||||
if (response && typeof response === 'object' && 'credits' in response) {
|
||||
return response as CreditBalance;
|
||||
}
|
||||
// Return default if response is invalid
|
||||
return {
|
||||
credits: 0,
|
||||
plan_credits_per_month: 0,
|
||||
credits_used_this_month: 0,
|
||||
credits_remaining: 0,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.warn('Failed to fetch credit balance, using defaults:', error.message);
|
||||
// Return default balance on error so UI can still render
|
||||
return {
|
||||
credits: 0,
|
||||
plan_credits_per_month: 0,
|
||||
credits_used_this_month: 0,
|
||||
credits_remaining: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchCreditUsage(filters?: {
|
||||
@@ -1867,6 +1882,28 @@ export interface Content {
|
||||
updated_at: string;
|
||||
has_image_prompts?: boolean;
|
||||
has_generated_images?: boolean;
|
||||
// Stage 3: Metadata fields
|
||||
entity_type?: string | null;
|
||||
cluster_name?: string | null;
|
||||
cluster_id?: number | null;
|
||||
taxonomy_name?: string | null;
|
||||
taxonomy_id?: number | null;
|
||||
cluster_role?: string | null;
|
||||
// Additional fields used in Linker/Optimizer
|
||||
source?: string;
|
||||
sync_status?: string;
|
||||
internal_links?: Array<{ anchor_text: string; target_content_id: number }>;
|
||||
linker_version?: number;
|
||||
optimization_scores?: {
|
||||
seo_score: number;
|
||||
readability_score: number;
|
||||
engagement_score: number;
|
||||
overall_score: number;
|
||||
metadata_completeness_score?: number;
|
||||
has_cluster_mapping?: boolean;
|
||||
has_taxonomy_mapping?: boolean;
|
||||
has_attributes?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContentResponse {
|
||||
@@ -1914,6 +1951,47 @@ export async function fetchContentById(id: number): Promise<Content> {
|
||||
return fetchAPI(`/v1/writer/content/${id}/`);
|
||||
}
|
||||
|
||||
// Stage 3: Content Validation API
|
||||
export interface ContentValidationResult {
|
||||
content_id: number;
|
||||
is_valid: boolean;
|
||||
ready_to_publish: boolean;
|
||||
validation_errors: Array<{
|
||||
field: string;
|
||||
code: string;
|
||||
message: string;
|
||||
}>;
|
||||
publish_errors: Array<{
|
||||
field: string;
|
||||
code: string;
|
||||
message: string;
|
||||
}>;
|
||||
metadata: {
|
||||
has_entity_type: boolean;
|
||||
entity_type: string | null;
|
||||
has_cluster_mapping: boolean;
|
||||
has_taxonomy_mapping: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchContentValidation(id: number): Promise<ContentValidationResult> {
|
||||
return fetchAPI(`/v1/writer/content/${id}/validation/`);
|
||||
}
|
||||
|
||||
export async function validateContent(id: number): Promise<{
|
||||
content_id: number;
|
||||
is_valid: boolean;
|
||||
errors: Array<{
|
||||
field: string;
|
||||
code: string;
|
||||
message: string;
|
||||
}>;
|
||||
}> {
|
||||
return fetchAPI(`/v1/writer/content/${id}/validate/`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
// Site Builder API
|
||||
export interface SiteBlueprint {
|
||||
id: number;
|
||||
@@ -2015,29 +2093,32 @@ export async function fetchSiteBlueprints(filters?: {
|
||||
if (filters?.page_size) params.append('page_size', filters.page_size.toString());
|
||||
|
||||
const queryString = params.toString();
|
||||
return fetchAPI(`/v1/site-builder/siteblueprint/${queryString ? `?${queryString}` : ''}`);
|
||||
const endpoint = queryString
|
||||
? `/v1/site-builder/blueprints/?${queryString}`
|
||||
: `/v1/site-builder/blueprints/`;
|
||||
return fetchAPI(endpoint);
|
||||
}
|
||||
|
||||
export async function fetchSiteBlueprintById(id: number): Promise<SiteBlueprint> {
|
||||
return fetchAPI(`/v1/site-builder/siteblueprint/${id}/`);
|
||||
return fetchAPI(`/v1/site-builder/blueprints/${id}/`);
|
||||
}
|
||||
|
||||
export async function createSiteBlueprint(data: Partial<SiteBlueprint>): Promise<SiteBlueprint> {
|
||||
return fetchAPI('/v1/site-builder/siteblueprint/', {
|
||||
return fetchAPI('/v1/site-builder/blueprints/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateSiteBlueprint(id: number, data: Partial<SiteBlueprint>): Promise<SiteBlueprint> {
|
||||
return fetchAPI(`/v1/site-builder/siteblueprint/${id}/`, {
|
||||
return fetchAPI(`/v1/site-builder/blueprints/${id}/`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchWizardContext(blueprintId: number): Promise<WizardContext> {
|
||||
return fetchAPI(`/v1/site-builder/siteblueprint/${blueprintId}/workflow/context/`);
|
||||
return fetchAPI(`/v1/site-builder/blueprints/${blueprintId}/workflow/context/`);
|
||||
}
|
||||
|
||||
export async function updateWorkflowStep(
|
||||
@@ -2046,7 +2127,7 @@ export async function updateWorkflowStep(
|
||||
status: string,
|
||||
metadata?: Record<string, any>
|
||||
): Promise<WorkflowState> {
|
||||
return fetchAPI(`/v1/site-builder/siteblueprint/${blueprintId}/workflow/step/`, {
|
||||
return fetchAPI(`/v1/site-builder/blueprints/${blueprintId}/workflow/step/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ step, status, metadata }),
|
||||
});
|
||||
@@ -2058,7 +2139,7 @@ export async function attachClustersToBlueprint(
|
||||
clusterIds: number[],
|
||||
role: 'hub' | 'supporting' | 'attribute' = 'hub'
|
||||
): Promise<{ attached_count: number; clusters: Array<{ id: number; name: string; role: string; link_id: number }> }> {
|
||||
return fetchAPI(`/v1/site-builder/siteblueprint/${blueprintId}/clusters/attach/`, {
|
||||
return fetchAPI(`/v1/site-builder/blueprints/${blueprintId}/clusters/attach/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ cluster_ids: clusterIds, role }),
|
||||
});
|
||||
@@ -2069,7 +2150,7 @@ export async function detachClustersFromBlueprint(
|
||||
clusterIds?: number[],
|
||||
role?: 'hub' | 'supporting' | 'attribute'
|
||||
): Promise<{ detached_count: number }> {
|
||||
return fetchAPI(`/v1/site-builder/siteblueprint/${blueprintId}/clusters/detach/`, {
|
||||
return fetchAPI(`/v1/site-builder/blueprints/${blueprintId}/clusters/detach/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ cluster_ids: clusterIds, role }),
|
||||
});
|
||||
@@ -2106,14 +2187,14 @@ export interface TaxonomyImportRecord {
|
||||
}
|
||||
|
||||
export async function fetchBlueprintsTaxonomies(blueprintId: number): Promise<{ count: number; taxonomies: Taxonomy[] }> {
|
||||
return fetchAPI(`/v1/site-builder/siteblueprint/${blueprintId}/taxonomies/`);
|
||||
return fetchAPI(`/v1/site-builder/blueprints/${blueprintId}/taxonomies/`);
|
||||
}
|
||||
|
||||
export async function createBlueprintTaxonomy(
|
||||
blueprintId: number,
|
||||
data: TaxonomyCreateData
|
||||
): Promise<Taxonomy> {
|
||||
return fetchAPI(`/v1/site-builder/siteblueprint/${blueprintId}/taxonomies/`, {
|
||||
return fetchAPI(`/v1/site-builder/blueprints/${blueprintId}/taxonomies/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
@@ -2124,7 +2205,7 @@ export async function importBlueprintsTaxonomies(
|
||||
records: TaxonomyImportRecord[],
|
||||
defaultType: string = 'blog_category'
|
||||
): Promise<{ imported_count: number; taxonomies: Taxonomy[] }> {
|
||||
return fetchAPI(`/v1/site-builder/siteblueprint/${blueprintId}/taxonomies/import/`, {
|
||||
return fetchAPI(`/v1/site-builder/blueprints/${blueprintId}/taxonomies/import/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ records, default_type: defaultType }),
|
||||
});
|
||||
@@ -2135,7 +2216,7 @@ export async function updatePageBlueprint(
|
||||
pageId: number,
|
||||
data: Partial<PageBlueprint>
|
||||
): Promise<PageBlueprint> {
|
||||
return fetchAPI(`/v1/site-builder/pageblueprint/${pageId}/`, {
|
||||
return fetchAPI(`/v1/site-builder/pages/${pageId}/`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
@@ -2144,7 +2225,7 @@ export async function updatePageBlueprint(
|
||||
export async function regeneratePageBlueprint(
|
||||
pageId: number
|
||||
): Promise<{ success: boolean; task_id?: string }> {
|
||||
return fetchAPI(`/v1/site-builder/pageblueprint/${pageId}/regenerate/`, {
|
||||
return fetchAPI(`/v1/site-builder/pages/${pageId}/regenerate/`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
@@ -2153,7 +2234,7 @@ export async function generatePageContent(
|
||||
pageId: number,
|
||||
force?: boolean
|
||||
): Promise<{ success: boolean; task_id?: string }> {
|
||||
return fetchAPI(`/v1/site-builder/pageblueprint/${pageId}/generate_content/`, {
|
||||
return fetchAPI(`/v1/site-builder/pages/${pageId}/generate_content/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ force: force || false }),
|
||||
});
|
||||
|
||||
@@ -42,6 +42,7 @@ interface BuilderWorkflowState {
|
||||
// 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;
|
||||
@@ -116,6 +117,11 @@ export const useBuilderWorkflowStore = create<BuilderWorkflowState>()(
|
||||
await get().initialize(blueprintId);
|
||||
},
|
||||
|
||||
refreshContext: async () => {
|
||||
// Alias for refreshState
|
||||
await get().refreshState();
|
||||
},
|
||||
|
||||
goToStep: (step: WizardStep) => {
|
||||
set({ currentStep: step });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user