Phase 6: Jumpstart Wizard UI & WP Client

This commit is contained in:
cawcenter
2025-12-12 18:45:50 -05:00
parent d8db5f42cf
commit e588711c98
9 changed files with 550 additions and 28 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,176 @@
// @ts-nocheck
import React, { useState, useEffect } from 'react';
import { WordPressClient } from '@/lib/wordpress/WordPressClient';
import { getDirectusClient, createItem } from '@/lib/directus/client';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
type Step = 'connect' | 'inventory' | 'qc' | 'launch';
export default function JumpstartWizard() {
const [step, setStep] = useState<Step>('connect');
const [logs, setLogs] = useState<string[]>([]);
// Connection State
const [siteUrl, setSiteUrl] = useState('');
const [username, setUsername] = useState('');
const [appPassword, setAppPassword] = useState('');
// Inventory State
const [inventory, setInventory] = useState<any>(null);
const [qcItems, setQcItems] = useState<any[]>([]);
const addLog = (msg: string) => setLogs(prev => [`[${new Date().toLocaleTimeString()}] ${msg}`, ...prev]);
// 1. CONNECT THE CABLES
const handleConnect = async () => {
addLog(`🔌 Connecting to ${siteUrl}...`);
try {
const wp = new WordPressClient(siteUrl, appPassword ? `${username}:${appPassword}` : undefined);
const alive = await wp.testConnection();
if (alive) {
addLog("✅ Connection Successful.");
setStep('inventory');
await scanInventory(wp);
} else {
addLog("❌ Connection Failed. Check URL.");
}
} catch (e) { addLog(`❌ Error: ${e.message}`); }
};
// 2. INVENTORY & FILTER
const scanInventory = async (wp: WordPressClient) => {
addLog("📦 Fetching Inventory (Posts, Pages, Taxonomies)...");
// Mocking inventory scan for UI dev
// In real impl, we fetch categories/tags and filter < 10
setTimeout(() => {
addLog("🔎 Filtering Taxonomies (<10 ignored)...");
addLog("📊 Found 124 Post, 12 Good Categories.");
setInventory({ total_posts: 124, valid_categories: 12 });
setStep('qc');
generateQC(wp);
}, 1500);
};
// 3. QC GENERATION (First 3)
const generateQC = async (wp: WordPressClient) => {
addLog("🧪 Generating QC Batch (First 3 Articles)...");
// Trigger API with limit=3
setTimeout(() => {
setQcItems([
{ id: 1, title: 'AI Refactored: Post One', status: 'Review Needed' },
{ id: 2, title: 'AI Refactored: Post Two', status: 'Review Needed' },
{ id: 3, title: 'AI Refactored: Post Three', status: 'Review Needed' }
]);
addLog("⚠️ QC Paused. Waiting for Approval.");
}, 2000);
};
// 4. IGNITION
const handleLaunch = () => {
setStep('launch');
addLog("🚀 IGNITION! Starting Mass Generation & Deployment...");
};
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* Header / Animation */}
<div className="flex items-center justify-between bg-slate-900 p-6 rounded-xl border border-slate-700 relative overflow-hidden">
<div className="z-10 relative">
<h1 className="text-3xl font-extrabold text-white mb-2">Guided Jumpstart Test</h1>
<p className="text-slate-400">Phase 6: Connection, Inventory, QC, Ignition.</p>
</div>
<img
src="/assets/rocket_man.webp"
className={`w-32 h-32 object-contain transition-transform duration-1000 ${step === 'launch' ? 'translate-x-[200px] -translate-y-[100px] opacity-0' : ''}`}
/>
</div>
<div className="grid grid-cols-3 gap-6">
{/* Main Control Panel */}
<Card className="col-span-2 bg-slate-800 border-slate-700">
<CardContent className="p-6 space-y-6">
{step === 'connect' && (
<div className="space-y-4">
<h2 className="text-xl font-bold text-white">1. Connect the Cables</h2>
<div className="space-y-2">
<label className="text-sm text-slate-400">Site URL</label>
<Input value={siteUrl} onChange={e => setSiteUrl(e.target.value)} className="bg-slate-900 border-slate-600" placeholder="https://..." />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm text-slate-400">Username</label>
<Input value={username} onChange={e => setUsername(e.target.value)} className="bg-slate-900 border-slate-600" />
</div>
<div className="space-y-2">
<label className="text-sm text-slate-400">App Password</label>
<Input type="password" value={appPassword} onChange={e => setAppPassword(e.target.value)} className="bg-slate-900 border-slate-600" />
</div>
</div>
<Button onClick={handleConnect} className="w-full bg-blue-600 hover:bg-blue-500">Connect & Scan</Button>
</div>
)}
{step === 'qc' && (
<div className="space-y-4">
<h2 className="text-xl font-bold text-white">2. Quality Control Gate</h2>
<div className="bg-slate-900 p-4 rounded-lg space-y-2">
{qcItems.map(item => (
<div key={item.id} className="flex justify-between items-center bg-slate-800 p-3 rounded border border-slate-700">
<span className="text-slate-200 font-medium">{item.title}</span>
<Badge variant="outline" className="text-yellow-400 border-yellow-400">Review Needed</Badge>
</div>
))}
</div>
<div className="flex gap-4">
<Button variant="outline" className="flex-1 border-slate-600 text-slate-300">Reject / Regenerate</Button>
<Button onClick={handleLaunch} className="flex-1 bg-green-600 hover:bg-green-500">Approve & Ignite 🚀</Button>
</div>
</div>
)}
{step === 'launch' && (
<div className="space-y-4 text-center py-8">
<h2 className="text-2xl font-bold text-green-400 animate-pulse">Engine Running</h2>
<p className="text-slate-400">Deployment in progress. Do not close this window.</p>
<Progress value={45} className="h-4 bg-slate-900" />
<div className="grid grid-cols-3 gap-4 pt-4">
<div className="bg-slate-900 p-3 rounded">
<div className="text-2xl font-bold text-white">124</div>
<div className="text-xs text-slate-500">Total</div>
</div>
<div className="bg-slate-900 p-3 rounded">
<div className="text-2xl font-bold text-blue-400">45</div>
<div className="text-xs text-slate-500">Processed</div>
</div>
<div className="bg-slate-900 p-3 rounded">
<div className="text-2xl font-bold text-green-400">42</div>
<div className="text-xs text-slate-500">Deployed</div>
</div>
</div>
</div>
)}
</CardContent>
</Card>
{/* Live Logs */}
<Card className="bg-slate-900 border-slate-800 shadow-inner h-[500px] overflow-hidden flex flex-col">
<div className="p-3 border-b border-slate-800 bg-slate-950">
<h3 className="text-xs font-mono text-green-500 uppercase">System Logs</h3>
</div>
<div className="flex-1 p-4 overflow-y-auto font-mono text-xs space-y-2">
{logs.map((log, i) => (
<div key={i} className="text-slate-400 border-l-2 border-slate-800 pl-2">
{log}
</div>
))}
</div>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,192 @@
// @ts-nocheck
import React, { useState } from 'react';
import { WordPressClient, type WPPost } from '@/lib/wordpress/WordPressClient';
import { getDirectusClient, createItem } from '@/lib/directus/client'; // Import Directus helper
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
export default function WPImporter() {
const [url, setUrl] = useState('');
const [connected, setConnected] = useState(false);
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState('');
const [items, setItems] = useState<WPPost[]>([]);
const [selection, setSelection] = useState<Set<number>>(new Set());
const connect = async () => {
setLoading(true);
setStatus('Scanning site...');
try {
const wp = new WordPressClient(url);
const isAlive = await wp.testConnection();
if (isAlive) {
const pages = await wp.getPages();
const posts = await wp.getPosts();
setItems([...pages, ...posts].map(i => ({...i, id: i.id}))); // Ensure ID
setConnected(true);
} else {
alert("Could not connect to WordPress site.");
}
} catch (e) {
console.error(e);
alert("Connection error");
} finally {
setLoading(false);
setStatus('');
}
};
const toggleSelection = (id: number) => {
const next = new Set(selection);
if (next.has(id)) next.delete(id);
else next.add(id);
setSelection(next);
};
const handleImport = async () => {
setLoading(true);
setStatus('Creating Site & Job...');
try {
const client = getDirectusClient();
// 1. Create Site
// We assume url is like 'https://domain.com'
const domain = new URL(url).hostname;
const sitePayload = {
name: domain,
url: url,
domain: domain,
status: 'setup'
};
const site = await client.request(createItem('sites', sitePayload));
// 2. Prepare Import Queue
const selectedItems = items.filter(i => selection.has(i.id)).map(i => ({
original_id: i.id,
slug: i.slug,
title: i.title.rendered,
type: i.type
}));
// 3. Create Generation Job
const jobPayload = {
site_id: site.id,
status: 'Pending',
target_quantity: selectedItems.length,
filters: {
mode: 'refactor',
items: selectedItems
}
};
const job = await client.request(createItem('generation_jobs', jobPayload));
// 4. Trigger Generation API
setStatus('Starting Refactor Engine...');
const res = await fetch('/api/generate-content', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ jobId: job.id, mode: 'refactor' })
});
if (res.ok) {
alert(`Success! Site created and ${selectedItems.length} items queued for refactoring.`);
window.location.href = `/admin/sites/${site.id}`; // Redirect to site
} else {
const err = await res.json();
alert('Error starting job: ' + err.error);
}
} catch (e) {
console.error(e);
alert("Import failed: " + e.message);
} finally {
setLoading(false);
setStatus('');
}
};
return (
<div className="space-y-6">
{!connected ? (
<Card className="bg-slate-800 border-slate-700 max-w-xl mx-auto">
<CardHeader>
<CardTitle>Connect WordPress Site</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Site URL</Label>
<Input
placeholder="https://example.com"
value={url}
onChange={e => setUrl(e.target.value)}
className="bg-slate-900 border-slate-700 text-white"
/>
</div>
<Button
onClick={connect}
disabled={loading || !url}
className="w-full bg-blue-600 hover:bg-blue-700"
>
{loading ? 'Connecting...' : 'Scan Site'}
</Button>
</CardContent>
</Card>
) : (
<div className="space-y-6">
<div className="flex justify-between items-center bg-slate-800 p-4 rounded-lg border border-slate-700">
<div>
<h2 className="text-xl font-bold text-white">Select Content to Import</h2>
<p className="text-slate-400 text-sm">Found {items.length} items from {url}</p>
</div>
<div className="flex gap-3">
<Button variant="outline" onClick={() => setConnected(false)} className="border-slate-600 text-slate-300">
Output
</Button>
<Button className="bg-green-600 hover:bg-green-700 text-white" onClick={handleImport} disabled={selection.size === 0}>
Import {selection.size} Items
</Button>
</div>
</div>
<div className="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
<table className="w-full text-left text-sm text-slate-400">
<thead className="bg-slate-900/50 text-slate-200 uppercase font-medium">
<tr>
<th className="px-6 py-3 w-12">
<input type="checkbox" className="rounded border-slate-600 bg-slate-800" />
</th>
<th className="px-6 py-3">Title</th>
<th className="px-6 py-3">Type</th>
<th className="px-6 py-3">Slug</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-700">
{items.map(item => (
<tr key={item.id} className="hover:bg-slate-700/50 transition-colors">
<td className="px-6 py-4">
<input
type="checkbox"
checked={selection.has(item.id)}
onChange={() => toggleSelection(item.id)}
className="rounded border-slate-600 bg-slate-800"
/>
</td>
<td className="px-6 py-4 font-medium text-slate-200" dangerouslySetInnerHTML={{ __html: item.title.rendered }} />
<td className="px-6 py-4">
<Badge variant="outline">{item.type}</Badge>
</td>
<td className="px-6 py-4 font-mono text-xs">{item.slug}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}

View File

@@ -26,8 +26,9 @@ export class CartesianEngine {
/** /**
* Generate a single article based on specific inputs. * Generate a single article based on specific inputs.
* @param overrides Optional overrides for slug, title, etc.
*/ */
async generateArticle(context: GenerationContext) { async generateArticle(context: GenerationContext, overrides?: any) {
const { avatar, niche, city, site, template } = context; const { avatar, niche, city, site, template } = context;
const variant = await this.getAvatarVariant(avatar.id, 'neutral'); // Default to neutral or specific const variant = await this.getAvatarVariant(avatar.id, 'neutral'); // Default to neutral or specific
@@ -51,19 +52,9 @@ export class CartesianEngine {
universal = result[0] || {}; universal = result[0] || {};
} catch (e) { console.error(`Block not found: ${blockId}`); } } catch (e) { console.error(`Block not found: ${blockId}`); }
// Fetch Personalized Expansion // Fetch Personalized Expansion (Skipped for MVP)
let personal: any = {};
try {
// Need a way to match block_id + avatar_id.
// Our schema imported flat structure?
// Ideally we query the offer_blocks_personalized collection
// filtering by block_related_id AND avatar_related_id
// For prototype, we might have stored it loosely.
// Let's assume we can fetch.
} catch (e) { }
// MERGE (Simplified for now - using universal only + placeholder) // MERGE
// Real merge adds personal pains to universal pains
const mergedBlock = { const mergedBlock = {
id: blockId, id: blockId,
title: universal.title, title: universal.title,
@@ -84,12 +75,12 @@ export class CartesianEngine {
const html = HTMLRenderer.renderArticle(blocksData); const html = HTMLRenderer.renderArticle(blocksData);
// 4. Generate Meta // 4. Generate Meta
const metaTitle = this.generateMetaTitle(context, variant); const metaTitle = overrides?.title || this.generateMetaTitle(context, variant);
return { return {
title: metaTitle, title: metaTitle,
html_content: html, html_content: html,
slug: this.generateSlug(metaTitle), slug: overrides?.slug || this.generateSlug(metaTitle),
meta_desc: "Generated description..." // Implementation TBD meta_desc: "Generated description..." // Implementation TBD
}; };
} }
@@ -129,17 +120,17 @@ export class CartesianEngine {
// Handle Spintax Content & Components // Handle Spintax Content & Components
if (block.spintax) { if (block.spintax) {
let content = SpintaxParser.parse(block.spintax); let content = SpintaxParser.parse(block.spintax);
// Dynamic Component Replacement // Dynamic Component Replacement
if (content.includes('{{COMPONENT_AVATAR_GRID}}')) { if (content.includes('{{COMPONENT_AVATAR_GRID}}')) {
content = content.replace('{{COMPONENT_AVATAR_GRID}}', this.generateAvatarGrid()); content = content.replace('{{COMPONENT_AVATAR_GRID}}', this.generateAvatarGrid());
} }
if (content.includes('{{COMPONENT_OPTIN_FORM}}')) { if (content.includes('{{COMPONENT_OPTIN_FORM}}')) {
content = content.replace('{{COMPONENT_OPTIN_FORM}}', this.generateOptinForm()); content = content.replace('{{COMPONENT_OPTIN_FORM}}', this.generateOptinForm());
} }
content = GrammarEngine.resolve(content, variant); content = GrammarEngine.resolve(content, variant);
resolvedBlock.content = content; resolvedBlock.content = content;
} }
return resolvedBlock; return resolvedBlock;
@@ -150,7 +141,7 @@ export class CartesianEngine {
"Scaling Founder", "Marketing Director", "Ecom Owner", "SaaS CEO", "Local Biz Owner", "Scaling Founder", "Marketing Director", "Ecom Owner", "SaaS CEO", "Local Biz Owner",
"Real Estate Agent", "Coach/Consultant", "Agency Owner", "Startup CTO", "Enterprise VP" "Real Estate Agent", "Coach/Consultant", "Agency Owner", "Startup CTO", "Enterprise VP"
]; ];
let html = '<div class="grid grid-cols-2 md:grid-cols-5 gap-4 my-8">'; let html = '<div class="grid grid-cols-2 md:grid-cols-5 gap-4 my-8">';
avatars.forEach(a => { avatars.forEach(a => {
html += ` html += `

View File

@@ -0,0 +1,75 @@
export interface WPPost {
id: number;
date: string;
slug: string;
status: string;
type: string;
link: string;
title: { rendered: string };
content: { rendered: string };
excerpt: { rendered: string };
}
export class WordPressClient {
private baseUrl: string;
private authHeader: string | null = null;
constructor(domain: string, appPassword?: string) {
// Normalize domain
this.baseUrl = domain.replace(/\/$/, '');
if (!this.baseUrl.startsWith('http')) {
this.baseUrl = `https://${this.baseUrl}`;
}
if (appPassword) {
// Assumes username is 'admin' or handled in the pass string if formatted 'user:pass'
// Usually Application Passwords are just the pwd, requiring a user.
// For now, let's assume the user passes "username:app_password" string or implemented later.
// We'll stick to public GET for now which doesn't need auth for reading content usually.
// If auth is needed:
// this.authHeader = `Basic ${btoa(appPassword)}`;
}
}
async testConnection(): Promise<boolean> {
try {
const res = await fetch(`${this.baseUrl}/wp-json/`);
return res.ok;
} catch (e) {
console.error("WP Connection Failed", e);
return false;
}
}
async getPages(limit = 100): Promise<WPPost[]> {
const url = `${this.baseUrl}/wp-json/wp/v2/pages?per_page=${limit}`;
return this.fetchCollection(url);
}
async getPosts(limit = 100): Promise<WPPost[]> {
const url = `${this.baseUrl}/wp-json/wp/v2/posts?per_page=${limit}`;
return this.fetchCollection(url);
}
async getCategories(): Promise<any[]> {
// Fetch all categories
return this.fetchCollection(`${this.baseUrl}/wp-json/wp/v2/categories?per_page=100`);
}
async getTags(): Promise<any[]> {
// Fetch all tags
return this.fetchCollection(`${this.baseUrl}/wp-json/wp/v2/tags?per_page=100`);
}
private async fetchCollection(url: string): Promise<any[]> {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`WP API Error: ${res.status}`);
return await res.json();
} catch (e) {
console.error("Fetch Error", e);
throw e;
}
}
}

View File

@@ -0,0 +1,18 @@
---
import Layout from '@/layouts/AdminLayout.astro';
import WPImporter from '@/components/admin/wordpress/WPImporter';
---
<Layout title="Import WordPress Site">
<div class="p-6 space-y-6">
<div class="flex items-center gap-2 text-slate-400 text-sm">
<a href="/admin/sites" class="hover:text-blue-400">Sites</a>
<span>/</span>
<span class="text-white">Import Wizard</span>
</div>
<h1 class="text-3xl font-bold text-slate-100">Content Import & Refactor</h1>
<WPImporter client:load />
</div>
</Layout>

View File

@@ -10,12 +10,16 @@ import SiteList from '@/components/admin/sites/SiteList';
<h1 class="text-3xl font-bold text-slate-100">My Sites</h1> <h1 class="text-3xl font-bold text-slate-100">My Sites</h1>
<p class="text-slate-400">Manage your connected WordPress and Webflow sites.</p> <p class="text-slate-400">Manage your connected WordPress and Webflow sites.</p>
</div> </div>
<a href="/admin/sites/new" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2"> <div class="flex gap-3">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <a href="/admin/sites/import" class="bg-slate-700 hover:bg-slate-600 text-white px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2 border border-slate-600">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /></svg>
</svg> Import Content
Add Site </a>
</a> <a href="/admin/sites/new" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /></svg>
Add Site
</a>
</div>
</div> </div>
<SiteList client:load /> <SiteList client:load />

View File

@@ -0,0 +1,10 @@
---
import Layout from '@/layouts/AdminLayout.astro';
import JumpstartWizard from '@/components/admin/jumpstart/JumpstartWizard';
---
<Layout title="Guided Jumpstart Test">
<div class="p-8">
<JumpstartWizard client:load />
</div>
</Layout>

View File

@@ -76,7 +76,63 @@ export const POST: APIRoute = async ({ request }) => {
generatedCount++; generatedCount++;
} }
// 4. Generate Standard Batch // 4. REFACTOR MODE (WordPress Import)
if (mode === 'refactor') {
console.log("♻️ Executing Refactor Mode...");
const queue = filters.items || [];
// Loop through queue items starting from current offset
while (generatedCount + offset < queue.length) {
const item = queue[generatedCount + offset];
// Context for Refactor
// We use a generic 'Business' avatar for now or try to infer from content?
// Let's stick to a safe default: "Scaling Founder"
const avatarItem = await client.request(readItem('avatars' as any, 'scaling_founder'));
const city = { city: 'Online', state: 'World' }; // Generic
const context = {
avatar: avatarItem,
niche: 'Business',
city: city,
site: site,
// Use a generic article structure
template: { structure_json: ['block_03_fix_first_scale_second', 'block_04_market_domination'] }
};
// Generate with Overrides
const article = await engine.generateArticle(context, {
slug: item.slug, // PRESERVE SLUG
title: `Refactored: ${item.title}` // Indicate change
});
// Save
await client.request(createItem('generated_articles' as any, {
site_id: siteId,
title: article.title,
slug: article.slug,
html_content: article.html_content,
meta_desc: article.meta_desc,
is_published: true,
job_id: jobId
}));
generatedCount++;
}
// Complete safely
await client.request(updateItem('generation_jobs' as any, jobId, {
current_offset: offset + generatedCount,
status: 'Complete'
}));
return new Response(JSON.stringify({
generated: generatedCount,
completed: true
}), { status: 200 });
}
// 5. Generate Standard Batch
// We will loop until batchSize is met or limit reached. // We will loop until batchSize is met or limit reached.
// Load Resources needed for randomization // Load Resources needed for randomization
@@ -133,7 +189,7 @@ export const POST: APIRoute = async ({ request }) => {
generatedCount++; generatedCount++;
} }
// 5. Update Job // 6. Update Job standard
await client.request(updateItem('generation_jobs' as any, jobId, { await client.request(updateItem('generation_jobs' as any, jobId, {
current_offset: offset + generatedCount, current_offset: offset + generatedCount,
status: (offset + generatedCount >= limit) ? 'Complete' : 'Processing' status: (offset + generatedCount >= limit) ? 'Complete' : 'Processing'