feat: Phase 1 - Send to Factory Implementation

Core Features:
- API endpoint: /api/factory/send-to-factory
- SendToFactoryButton component (3 variants: default, small, icon)
- FactoryOptionsModal with template/location/mode selection
- Integration with Jumpstart QC screen

Features:
- One-click article generation from WordPress posts
- Template selection (Long-Tail SEO, Local Authority, etc.)
- Geo-targeting from Intelligence Library
- Processing modes (Refactor, Rewrite, Enhance, Localize)
- Auto-publish toggle
- Success/error callbacks
- Loading states and notifications

Integration Points:
- Added to Jumpstart QC items
- Fetches geo clusters for location targeting
- Creates generation job in Directus
- Calls article generation API
- Returns preview URL

Ready for testing!
This commit is contained in:
cawcenter
2025-12-13 19:13:51 -05:00
parent 847209b023
commit 05a273d5b1
7 changed files with 1450 additions and 1 deletions

View File

@@ -0,0 +1,211 @@
import React, { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { getDirectusClient, readItems } from '@/lib/directus/client';
interface FactoryOptionsModalProps {
postTitle: string;
onClose: () => void;
onSubmit: (options: FactoryOptions) => void;
isLoading: boolean;
}
interface FactoryOptions {
template: string;
location?: string;
mode: string;
autoPublish: boolean;
}
export default function FactoryOptionsModal({
postTitle,
onClose,
onSubmit,
isLoading
}: FactoryOptionsModalProps) {
const [template, setTemplate] = useState('long_tail_seo');
const [location, setLocation] = useState('');
const [mode, setMode] = useState('refactor');
const [autoPublish, setAutoPublish] = useState(false);
const [geoClusters, setGeoClusters] = useState<any[]>([]);
useEffect(() => {
loadGeoClusters();
}, []);
const loadGeoClusters = async () => {
try {
const client = getDirectusClient();
// @ts-ignore
const clusters = await client.request(readItems('geo_clusters', {
fields: ['id', 'cluster_name'],
limit: -1
}));
setGeoClusters(clusters);
} catch (error) {
console.error('Error loading geo clusters:', error);
}
};
const handleSubmit = () => {
onSubmit({
template,
location,
mode,
autoPublish
});
};
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent className="bg-slate-800 border-slate-700 text-white max-w-2xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold text-white">
🏭 Send to Factory
</DialogTitle>
<DialogDescription className="text-slate-400">
Configure how you want to transform: <span className="text-purple-400 font-semibold">{postTitle}</span>
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Template Selection */}
<div>
<Label htmlFor="template" className="text-white mb-2 block">
Content Template
</Label>
<Select value={template} onValueChange={setTemplate}>
<SelectTrigger className="bg-slate-900 border-slate-700 text-white">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value="long_tail_seo">Long-Tail SEO</SelectItem>
<SelectItem value="local_authority">Local Authority</SelectItem>
<SelectItem value="thought_leadership">Thought Leadership</SelectItem>
<SelectItem value="problem_solution">Problem Solution</SelectItem>
<SelectItem value="listicle">Listicle Format</SelectItem>
<SelectItem value="how_to_guide">How-To Guide</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-slate-500 mt-1">
Choose the content structure and SEO strategy
</p>
</div>
{/* Location Targeting */}
<div>
<Label htmlFor="location" className="text-white mb-2 block">
Location Targeting (Optional)
</Label>
<Select value={location} onValueChange={setLocation}>
<SelectTrigger className="bg-slate-900 border-slate-700 text-white">
<SelectValue placeholder="No location targeting" />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value="">No location targeting</SelectItem>
{geoClusters.map((cluster) => (
<SelectItem key={cluster.id} value={cluster.cluster_name}>
{cluster.cluster_name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-slate-500 mt-1">
Apply geo-specific keywords and local SEO
</p>
</div>
{/* Processing Mode */}
<div>
<Label htmlFor="mode" className="text-white mb-2 block">
Processing Mode
</Label>
<Select value={mode} onValueChange={setMode}>
<SelectTrigger className="bg-slate-900 border-slate-700 text-white">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value="refactor">Refactor (Improve existing)</SelectItem>
<SelectItem value="rewrite">Rewrite (Complete rewrite)</SelectItem>
<SelectItem value="enhance">Enhance (Add sections)</SelectItem>
<SelectItem value="localize">Localize (Geo-optimize)</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-slate-500 mt-1">
How aggressively to transform the content
</p>
</div>
{/* Auto-Publish Toggle */}
<div className="flex items-center justify-between p-4 bg-slate-900 rounded-lg border border-slate-700">
<div>
<Label htmlFor="autoPublish" className="text-white font-semibold">
Auto-Publish to WordPress
</Label>
<p className="text-xs text-slate-500 mt-1">
Automatically publish back to WordPress after generation
</p>
</div>
<button
onClick={() => setAutoPublish(!autoPublish)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${autoPublish ? 'bg-purple-600' : 'bg-slate-700'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${autoPublish ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{/* Preview Info */}
<div className="bg-blue-500/10 border border-blue-500/30 rounded-lg p-4">
<div className="flex items-start gap-3">
<span className="text-2xl"></span>
<div>
<h4 className="font-semibold text-blue-400 mb-1">What happens next?</h4>
<ul className="text-sm text-slate-300 space-y-1">
<li> Content will be sent to the Spark Factory</li>
<li> Intelligence Library will be applied (spintax, patterns, geo)</li>
<li> Article will be generated with your selected template</li>
<li> You'll get a preview link to review before publishing</li>
{autoPublish && <li className="text-yellow-400"> Article will auto-publish to WordPress</li>}
</ul>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button
onClick={onClose}
disabled={isLoading}
variant="ghost"
className="text-slate-400 hover:text-white"
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={isLoading}
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white font-semibold"
>
{isLoading ? (
<>
<span className="animate-spin mr-2"></span>
Processing...
</>
) : (
<>
<span className="mr-2">🚀</span>
Send to Factory
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,138 @@
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import FactoryOptionsModal from './FactoryOptionsModal';
interface SendToFactoryButtonProps {
postId: number;
postTitle: string;
siteUrl: string;
siteAuth?: string;
onSuccess?: (result: any) => void;
onError?: (error: string) => void;
variant?: 'default' | 'small' | 'icon';
}
export default function SendToFactoryButton({
postId,
postTitle,
siteUrl,
siteAuth,
onSuccess,
onError,
variant = 'default'
}: SendToFactoryButtonProps) {
const [isLoading, setIsLoading] = useState(false);
const [showModal, setShowModal] = useState(false);
const handleClick = () => {
setShowModal(true);
};
const handleSend = async (options: any) => {
setIsLoading(true);
try {
const response = await fetch('/api/factory/send-to-factory', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source: {
type: 'wordpress',
url: siteUrl,
postId: postId,
auth: siteAuth
},
options: options
})
});
const result = await response.json();
if (result.success) {
onSuccess?.(result);
setShowModal(false);
} else {
onError?.(result.error);
}
} catch (error: any) {
onError?.(error.message);
} finally {
setIsLoading(false);
}
};
if (variant === 'icon') {
return (
<>
<button
onClick={handleClick}
disabled={isLoading}
className="p-2 text-purple-400 hover:text-purple-300 hover:bg-purple-500/10 rounded transition-colors"
title="Send to Factory"
>
🏭
</button>
{showModal && (
<FactoryOptionsModal
postTitle={postTitle}
onClose={() => setShowModal(false)}
onSubmit={handleSend}
isLoading={isLoading}
/>
)}
</>
);
}
if (variant === 'small') {
return (
<>
<Button
onClick={handleClick}
disabled={isLoading}
size="sm"
className="bg-purple-600 hover:bg-purple-500 text-white"
>
{isLoading ? '⏳ Processing...' : '🏭 Send to Factory'}
</Button>
{showModal && (
<FactoryOptionsModal
postTitle={postTitle}
onClose={() => setShowModal(false)}
onSubmit={handleSend}
isLoading={isLoading}
/>
)}
</>
);
}
return (
<>
<Button
onClick={handleClick}
disabled={isLoading}
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white font-semibold px-6 py-3"
>
{isLoading ? (
<>
<span className="animate-spin mr-2"></span>
Processing...
</>
) : (
<>
<span className="mr-2">🏭</span>
Send to Factory
</>
)}
</Button>
{showModal && (
<FactoryOptionsModal
postTitle={postTitle}
onClose={() => setShowModal(false)}
onSubmit={handleSend}
isLoading={isLoading}
/>
)}
</>
);
}

View File

@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import SendToFactoryButton from '@/components/admin/factory/SendToFactoryButton';
type Step = 'connect' | 'inventory' | 'qc' | 'launch';
@@ -253,7 +254,23 @@ export default function JumpstartWizard() {
<span className="text-slate-200 font-medium truncate w-64">{item.title}</span>
<a href={item.link} target="_blank" className="text-xs text-blue-400 hover:underline">View Original</a>
</div>
<Badge variant="outline" className="text-yellow-400 border-yellow-400">Review Needed</Badge>
<div className="flex items-center gap-2">
<SendToFactoryButton
postId={item.id}
postTitle={item.title}
siteUrl={siteUrl}
siteAuth={appPassword ? `${username}:${appPassword}` : undefined}
variant="small"
onSuccess={(result) => {
addLog(`✅ Article generated: ${result.article.title}`);
addLog(`🔗 Preview: ${result.previewUrl}`);
}}
onError={(error) => {
addLog(`❌ Factory error: ${error}`);
}}
/>
<Badge variant="outline" className="text-yellow-400 border-yellow-400">Review Needed</Badge>
</div>
</div>
))}
</div>

View File

@@ -0,0 +1,125 @@
import type { APIRoute } from 'astro';
import { getDirectusClient, readItems, createItem, updateItem } from '@/lib/directus/client';
import { WordPressClient } from '@/lib/wordpress/WordPressClient';
export const POST: APIRoute = async ({ request }) => {
try {
const body = await request.json();
const { source, options } = body;
// Validate input
if (!source?.postId || !source?.url) {
return new Response(JSON.stringify({
success: false,
error: 'Missing required fields: source.postId and source.url'
}), { status: 400 });
}
const client = getDirectusClient();
// 1. Fetch WordPress post
const wpClient = new WordPressClient(source.url, source.auth);
const post = await wpClient.getPost(source.postId);
if (!post) {
return new Response(JSON.stringify({
success: false,
error: 'WordPress post not found'
}), { status: 404 });
}
// 2. Get or create site record
// @ts-ignore
const sites = await client.request(readItems('sites', {
filter: { url: { _eq: source.url } },
limit: 1
}));
let siteId;
if (sites.length > 0) {
siteId = sites[0].id;
} else {
// @ts-ignore
const newSite = await client.request(createItem('sites', {
name: new URL(source.url).hostname,
url: source.url
}));
siteId = newSite.id;
}
// 3. Create generation job
// @ts-ignore
const job = await client.request(createItem('generation_jobs', {
site_id: siteId,
status: 'Pending',
type: options.mode || 'Refactor',
target_quantity: 1,
config: {
wordpress_url: source.url,
wordpress_auth: source.auth,
wordpress_post_id: source.postId,
mode: options.mode || 'refactor',
template: options.template || 'long_tail_seo',
location: options.location,
auto_publish: options.autoPublish || false,
source_title: post.title.rendered,
source_slug: post.slug
}
}));
// 4. Generate article
const generateResponse = await fetch(`${request.url.origin}/api/seo/generate-article`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
siteId: siteId,
template: options.template || 'long_tail_seo',
targetKeyword: post.title.rendered,
sourceContent: post.content.rendered,
metadata: {
originalSlug: post.slug,
originalId: post.id,
originalDate: post.date,
location: options.location,
mode: options.mode
}
})
});
if (!generateResponse.ok) {
throw new Error('Article generation failed');
}
const article = await generateResponse.json();
// 5. Update job status
// @ts-ignore
await client.request(updateItem('generation_jobs', job.id, {
status: 'Complete',
current_offset: 1
}));
return new Response(JSON.stringify({
success: true,
jobId: job.id,
articleId: article.id,
previewUrl: `/preview/article/${article.id}`,
status: 'complete',
article: {
title: article.title,
slug: article.slug,
seoScore: article.metadata?.seo_score
}
}), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
} catch (error: any) {
console.error('Send to Factory error:', error);
return new Response(JSON.stringify({
success: false,
error: error.message || 'Unknown error occurred'
}), { status: 500 });
}
};