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:
IGNY8 VPS (Salman)
2025-11-19 19:21:30 +00:00
parent 38f6026e73
commit bae9ea47d8
33 changed files with 2388 additions and 73 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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