SiteMente - AI-Powered Lead Generation Platform
Features: - Mission Control dashboard - HP Submissions tracking - AI Agents integration - Lead management CRM - Marketing email templates - Chrome extension support Tech: Next.js, TypeScript, Tailwind CSS, MySQL
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { text } = body;
|
||||
|
||||
const prompt = `You are Cleopatra, a professional Spanish-speaking sales agent. Keep responses SHORT (1-2 sentences), friendly, in Spanish.
|
||||
|
||||
User: ${text}
|
||||
|
||||
Response in Spanish:`;
|
||||
|
||||
const res = await fetch("http://127.0.0.1:11434/api/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
signal: AbortSignal.timeout(60000),
|
||||
body: JSON.stringify({
|
||||
model: "phi3:mini",
|
||||
prompt: prompt,
|
||||
stream: false
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
const reply = (data.response || "¡Hola! Estoy aquí.").substring(0, 200);
|
||||
|
||||
return NextResponse.json({ response: reply });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return NextResponse.json({
|
||||
response: "Tengo problemas para conectar. Intenta de nuevo."
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// Get all Hookd items
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url)
|
||||
const action = url.searchParams.get('action') || 'list'
|
||||
|
||||
try {
|
||||
const possiblePaths = [
|
||||
path.join(process.cwd(), '..', 'chrome-extensions', 'hookd', 'data.json'),
|
||||
'/var/www/hostpioneers.com/public_html/contacts.json',
|
||||
]
|
||||
|
||||
let data: any[] = []
|
||||
|
||||
// Try to read from Hookd storage
|
||||
for (const filePath of possiblePaths) {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8')
|
||||
data = JSON.parse(content)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'stats') {
|
||||
return NextResponse.json({
|
||||
total: data.length,
|
||||
byCategory: data.reduce((acc: any, item) => {
|
||||
const cat = item.category || 'Uncategorized'
|
||||
acc[cat] = (acc[cat] || 0) + 1
|
||||
return acc
|
||||
}, {}),
|
||||
aiProcessed: data.filter((i: any) => i.aiProcessed).length,
|
||||
saved: data.filter((i: any) => i.saved).length,
|
||||
recent: data.slice(0, 10)
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ items: data })
|
||||
} catch (error) {
|
||||
console.error('Hookd API error:', error)
|
||||
return NextResponse.json({ items: [], error: 'Failed to fetch' }, { status: 200 })
|
||||
}
|
||||
}
|
||||
|
||||
// Process items with AI
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { items, instruction } = body
|
||||
|
||||
// This would call MiniMax API to process items
|
||||
// For now, return mock AI analysis
|
||||
|
||||
const results = items.map((item: any) => ({
|
||||
id: item.id,
|
||||
category: guessCategory(item),
|
||||
score: guessScore(item),
|
||||
tags: guessTags(item),
|
||||
summary: item.content?.slice(0, 100) + '...'
|
||||
}))
|
||||
|
||||
return NextResponse.json({ results })
|
||||
} catch (error) {
|
||||
console.error('AI processing error:', error)
|
||||
return NextResponse.json({ error: 'Processing failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
function guessCategory(item: any): string {
|
||||
const content = (item.content || '').toLowerCase()
|
||||
const link = (item.link || '').toLowerCase()
|
||||
|
||||
if (content.includes('ai') || content.includes('gpt') || content.includes('claude') ||
|
||||
content.includes('openai') || content.includes('anthropic') ||
|
||||
link.includes('github.com') || link.includes('huggingface')) {
|
||||
return 'AI'
|
||||
}
|
||||
if (content.includes('news') || content.includes('breaking') || content.includes('just in')) {
|
||||
return 'News'
|
||||
}
|
||||
if (content.includes('idea') || content.includes('thought') || content.includes('what if')) {
|
||||
return 'Ideas'
|
||||
}
|
||||
if (content.includes('lol') || content.includes('meme') || content.includes('funny') || content.includes('😂')) {
|
||||
return 'Memes'
|
||||
}
|
||||
return 'Other'
|
||||
}
|
||||
|
||||
function guessScore(item: any): number {
|
||||
const content = (item.content || '').toLowerCase()
|
||||
let score = 5
|
||||
|
||||
if (item.link) score += 1
|
||||
if (content.length > 100) score += 1
|
||||
if (content.includes('github')) score += 1
|
||||
if (content.includes('ai') || content.includes('gpt')) score += 2
|
||||
|
||||
return Math.min(10, score)
|
||||
}
|
||||
|
||||
function guessTags(item: any): string[] {
|
||||
const tags: string[] = []
|
||||
const content = (item.content || '').toLowerCase()
|
||||
|
||||
if (content.includes('ai')) tags.push('AI')
|
||||
if (content.includes('tool')) tags.push('Tools')
|
||||
if (content.includes('news')) tags.push('News')
|
||||
if (content.includes('tutorial')) tags.push('Tutorial')
|
||||
if (content.includes('github')) tags.push('Code')
|
||||
|
||||
return tags
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Try multiple possible paths
|
||||
const possiblePaths = [
|
||||
path.join(process.cwd(), '..', 'hostpioneers.com', 'public_html', 'contacts.json'),
|
||||
path.join(process.cwd(), 'public', '..', 'contacts.json'),
|
||||
'/var/www/hostpioneers.com/public_html/contacts.json',
|
||||
]
|
||||
|
||||
let data = '[]'
|
||||
for (const filePath of possiblePaths) {
|
||||
if (fs.existsSync(filePath)) {
|
||||
data = fs.readFileSync(filePath, 'utf-8')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const submissions = JSON.parse(data || '[]')
|
||||
return NextResponse.json({ submissions: submissions.reverse() })
|
||||
} catch (error) {
|
||||
console.error('Error:', error)
|
||||
return NextResponse.json({ submissions: [], error: 'Failed to fetch' }, { status: 200 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import mysql from 'mysql2/promise'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
async function getPool() {
|
||||
return mysql.createPool({
|
||||
host: 'localhost',
|
||||
user: 'sitemente',
|
||||
password: 'pa0ndoQRPDYBHzTXX3rbWd6E',
|
||||
database: 'sitemente',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10
|
||||
})
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const segment = searchParams.get('segment')
|
||||
const dormancy = searchParams.get('dormancy')
|
||||
const search = searchParams.get('search')
|
||||
const sortBy = searchParams.get('sortBy') || 'total_spent'
|
||||
const sortOrder = searchParams.get('sortOrder') || 'DESC'
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '100')
|
||||
const offset = (page - 1) * limit
|
||||
|
||||
// Validate sortBy to prevent SQL injection
|
||||
const allowedSortColumns = ['firstname', 'lastname', 'email', 'company', 'country', 'total_spent', 'segment', 'dormancy_flag', 'id']
|
||||
const safeSortBy = allowedSortColumns.includes(sortBy) ? sortBy : 'total_spent'
|
||||
const safeSortOrder = sortOrder === 'ASC' ? 'ASC' : 'DESC'
|
||||
|
||||
try {
|
||||
const pool = await getPool()
|
||||
|
||||
let countQuery = 'SELECT COUNT(*) as total FROM hostpioneers_leads WHERE 1=1'
|
||||
let query = 'SELECT * FROM hostpioneers_leads WHERE 1=1'
|
||||
const params: any[] = []
|
||||
|
||||
if (segment && segment !== 'all') {
|
||||
countQuery += ' AND segment = ?'
|
||||
query += ' AND segment = ?'
|
||||
params.push(segment)
|
||||
}
|
||||
if (dormancy && dormancy !== 'all') {
|
||||
countQuery += ' AND dormancy_flag = ?'
|
||||
query += ' AND dormancy_flag = ?'
|
||||
params.push(dormancy)
|
||||
}
|
||||
if (search) {
|
||||
const searchClause = ' AND (firstname LIKE ? OR lastname LIKE ? OR email LIKE ? OR company LIKE ? OR phone LIKE ? OR country LIKE ?)'
|
||||
countQuery += searchClause
|
||||
query += searchClause
|
||||
const searchTerm = `%${search}%`
|
||||
params.push(searchTerm, searchTerm, searchTerm, searchTerm, searchTerm, searchTerm)
|
||||
}
|
||||
|
||||
const [countResult] = await pool.query(countQuery, params)
|
||||
const total = (countResult as any[])[0].total
|
||||
|
||||
query += ` ORDER BY ${safeSortBy} ${safeSortOrder} LIMIT ? OFFSET ?`
|
||||
const [rows] = await pool.query(query, [...params, limit, offset])
|
||||
|
||||
await pool.end()
|
||||
|
||||
return NextResponse.json({
|
||||
leads: rows,
|
||||
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import mysql from 'mysql2/promise';
|
||||
|
||||
const dbConfig = {
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
user: process.env.DB_USER || 'sitemente',
|
||||
password: process.env.DB_PASS || 'SiteMente2026!',
|
||||
database: process.env.DB_NAME || 'sitemente'
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
let conn;
|
||||
try {
|
||||
conn = await mysql.createConnection(dbConfig);
|
||||
const [rows] = await conn.execute('SELECT * FROM research ORDER BY created_at DESC');
|
||||
return NextResponse.json(rows);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return NextResponse.json([]);
|
||||
} finally {
|
||||
if (conn) conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let conn;
|
||||
try {
|
||||
const body = await request.json();
|
||||
conn = await mysql.createConnection(dbConfig);
|
||||
|
||||
await conn.execute(
|
||||
'INSERT INTO research (name, query, results_count, summary, results, created_at) VALUES (?, ?, ?, ?, ?, NOW())',
|
||||
[body.name, body.query, body.results_count || 0, body.summary || '', JSON.stringify(body.results || [])]
|
||||
);
|
||||
|
||||
const [rows] = await conn.execute('SELECT * FROM research ORDER BY created_at DESC');
|
||||
return NextResponse.json(rows);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return NextResponse.json({ error: 'Failed to save research' }, { status: 500 });
|
||||
} finally {
|
||||
if (conn) conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
let conn;
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get('id');
|
||||
|
||||
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
||||
|
||||
conn = await mysql.createConnection(dbConfig);
|
||||
await conn.execute('DELETE FROM research WHERE id = ?', [id]);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return NextResponse.json({ error: 'Failed to delete' }, { status: 500 });
|
||||
} finally {
|
||||
if (conn) conn.end();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
export default function VoiceWidget() {
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [transcript, setTranscript] = useState("");
|
||||
const [response, setResponse] = useState("");
|
||||
const [isSpeaking, setIsSpeaking] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const recognitionRef = useRef<any>(null);
|
||||
const synthRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
||||
const synth = window.speechSynthesis;
|
||||
|
||||
if (SpeechRecognition) {
|
||||
recognitionRef.current = new SpeechRecognition();
|
||||
recognitionRef.current.continuous = false;
|
||||
recognitionRef.current.interimResults = true;
|
||||
recognitionRef.current.lang = "es-ES";
|
||||
|
||||
recognitionRef.current.onresult = (event: any) => {
|
||||
const last = event.results.length - 1;
|
||||
const text = event.results[last][0].transcript;
|
||||
setTranscript(text);
|
||||
|
||||
if (event.results[last].isFinal) {
|
||||
handleSend(text);
|
||||
}
|
||||
};
|
||||
|
||||
recognitionRef.current.onend = () => {
|
||||
setIsListening(false);
|
||||
};
|
||||
}
|
||||
|
||||
if (synth) {
|
||||
synthRef.current = synth;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (recognitionRef.current) recognitionRef.current.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startListening = () => {
|
||||
if (recognitionRef.current && !isListening) {
|
||||
setTranscript("");
|
||||
recognitionRef.current.start();
|
||||
setIsListening(true);
|
||||
}
|
||||
};
|
||||
|
||||
const stopListening = () => {
|
||||
if (recognitionRef.current && isListening) {
|
||||
recognitionRef.current.stop();
|
||||
setIsListening(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async (text: string) => {
|
||||
if (!text.trim()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setResponse("Pensando...");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
const reply = (data.response || "¡Hola! Estoy aquí. ¿En qué puedo ayudarte?").substring(0, 200);
|
||||
|
||||
setResponse(reply);
|
||||
speak(reply);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setResponse("Tengo problemas para conectar. Intenta de nuevo.");
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const speak = (text: string) => {
|
||||
if (!synthRef.current) return;
|
||||
|
||||
synthRef.current.cancel();
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
utterance.lang = "es-ES";
|
||||
utterance.rate = 0.85;
|
||||
utterance.pitch = 0.9;
|
||||
|
||||
const voices = synthRef.current.getVoices();
|
||||
const spanishVoice = voices.find((v: any) => v.lang.includes("es"));
|
||||
if (spanishVoice) utterance.voice = spanishVoice;
|
||||
|
||||
utterance.onstart = () => setIsSpeaking(true);
|
||||
utterance.onend = () => setIsSpeaking(false);
|
||||
utterance.onerror = () => setIsSpeaking(false);
|
||||
|
||||
synthRef.current.speak(utterance);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-purple-900 to-indigo-900 p-6 rounded-2xl shadow-2xl max-w-md mx-auto">
|
||||
<div className="text-center mb-6">
|
||||
<div className="text-4xl mb-2">👑</div>
|
||||
<h3 className="text-xl font-bold text-white">Cleopatra Voice</h3>
|
||||
<p className="text-purple-200 text-sm">Asistente de Ventas IA</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className={`px-4 py-2 rounded-full text-sm ${
|
||||
isListening ? "bg-red-500 animate-pulse" :
|
||||
isSpeaking ? "bg-green-500 animate-pulse" :
|
||||
isLoading ? "bg-yellow-500" :
|
||||
"bg-gray-600"
|
||||
} text-white`}>
|
||||
{isListening ? "🎤 Escuchando..." :
|
||||
isSpeaking ? "🔊 Hablando..." :
|
||||
isLoading ? "⏳ Pensando..." :
|
||||
"💬 Lista"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-16 flex items-center justify-center mb-4 gap-1">
|
||||
{[...Array(12)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-1 bg-purple-400 rounded-full transition-all duration-100 ${
|
||||
isListening || isSpeaking ? "animate-pulse" : ""
|
||||
}`}
|
||||
style={{
|
||||
height: isListening || isSpeaking ? `${20 + Math.random() * 40}px` : "8px",
|
||||
animationDelay: `${i * 50}ms`
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{transcript && (
|
||||
<div className="bg-black/30 rounded-lg p-3 mb-3">
|
||||
<p className="text-xs text-purple-300">Dijiste:</p>
|
||||
<p className="text-white">{transcript}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{response && (
|
||||
<div className="bg-purple-800/50 rounded-lg p-3 mb-4">
|
||||
<p className="text-xs text-green-300">Cleopatra:</p>
|
||||
<p className="text-white">{response}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center gap-3">
|
||||
<button
|
||||
onClick={isListening ? stopListening : startListening}
|
||||
className={`w-16 h-16 rounded-full flex items-center justify-center text-2xl transition-all ${
|
||||
isListening
|
||||
? "bg-red-500 hover:bg-red-600 animate-pulse"
|
||||
: "bg-purple-500 hover:bg-purple-600"
|
||||
} text-white shadow-lg`}
|
||||
>
|
||||
{isListening ? "⏹" : "🎤"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => speak(response || "¡Hola! Soy Cleopatra. ¿En qué puedo ayudarte?")}
|
||||
disabled={isSpeaking || !response}
|
||||
className="w-12 h-12 rounded-full bg-blue-500 hover:bg-blue-600 disabled:opacity-50 flex items-center justify-center text-xl"
|
||||
>
|
||||
🔊
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-purple-300 mt-4">
|
||||
{isListening ? "Habla ahora..." : "Toca el micrófono para empezar"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import VoiceWidget from "../../components/VoiceWidget";
|
||||
|
||||
export default function DemoPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-purple-900 via-indigo-900 to-black flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">
|
||||
🏆 SiteMente AI Demo
|
||||
</h1>
|
||||
<p className="text-purple-200">
|
||||
Meet Cleopatra - Your AI Voice Assistant
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<VoiceWidget />
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-sm text-purple-300">
|
||||
Powered by Ollama (local AI)
|
||||
</p>
|
||||
<p className="text-xs text-purple-400 mt-2">
|
||||
Voice processing happens locally - no external APIs!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
export default function HookdPage() {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #0f172a 0%, #1e293b 100%)',
|
||||
color: '#fff',
|
||||
padding: '2rem',
|
||||
fontFamily: 'system-ui, sans-serif'
|
||||
}}>
|
||||
<div style={{ maxWidth: '800px', margin: '0 auto' }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: '2rem' }}>
|
||||
<div style={{ fontSize: '64px', marginBottom: '1rem' }}>📌</div>
|
||||
<h1 style={{ fontSize: '2.5rem', marginBottom: '0.5rem' }}>Hookd</h1>
|
||||
<p style={{ color: '#94a3b8', fontSize: '1.1rem' }}>
|
||||
Save, organize and AI-sort your X content
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'rgba(79, 70, 229, 0.1)',
|
||||
border: '1px solid rgba(79, 70, 229, 0.3)',
|
||||
borderRadius: '12px',
|
||||
padding: '1.5rem',
|
||||
marginBottom: '2rem',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<p style={{ marginBottom: '1rem' }}>Download the Hookd Chrome Extension</p>
|
||||
<a
|
||||
href="/hookd.zip"
|
||||
download="hookd.zip"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
background: 'linear-gradient(135deg, #4F46E5, #7C3AED)',
|
||||
color: 'white',
|
||||
padding: '12px 32px',
|
||||
borderRadius: '8px',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '1rem'
|
||||
}}
|
||||
>
|
||||
⬇️ Download Hookd.zip
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h2 style={{ marginBottom: '1rem' }}>Features</h2>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '1rem' }}>
|
||||
{[
|
||||
{ icon: '📥', title: 'DM Organizer', desc: 'Extract and categorize your X DMs' },
|
||||
{ icon: '🐦', title: 'Feed Saver', desc: 'Checkmark posts to save in bulk' },
|
||||
{ icon: '🤖', title: 'AI Processing', desc: 'Connect to OpenClaw for auto-categorization' },
|
||||
{ icon: '👥', title: 'Contact Lists', desc: 'Share to Dev Friends, Meme Buddies & more' },
|
||||
].map((f, i) => (
|
||||
<div key={i} style={{
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: '8px',
|
||||
padding: '1rem'
|
||||
}}>
|
||||
<div style={{ fontSize: '1.5rem', marginBottom: '0.5rem' }}>{f.icon}</div>
|
||||
<h3 style={{ fontSize: '0.9rem', marginBottom: '0.25rem' }}>{f.title}</h3>
|
||||
<p style={{ color: '#94a3b8', fontSize: '0.8rem' }}>{f.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h2 style={{ marginTop: '2rem', marginBottom: '1rem' }}>Installation</h2>
|
||||
<ol style={{ color: '#94a3b8', paddingLeft: '1.5rem', lineHeight: '1.8' }}>
|
||||
<li>Download the extension (button above)</li>
|
||||
<li>Unzip the downloaded file</li>
|
||||
<li>Open Chrome → <code style={{ background: '#334155', padding: '2px 6px', borderRadius: '4px' }}>chrome://extensions/</code></li>
|
||||
<li>Enable <strong>Developer mode</strong> (top right toggle)</li>
|
||||
<li>Click <strong>Load unpacked</strong></li>
|
||||
<li>Select the <code>hookd</code> folder (the folder, not individual files)</li>
|
||||
<li>Pin 📌 Hookd to your toolbar!</li>
|
||||
</ol>
|
||||
|
||||
<div style={{ marginTop: '2rem', padding: '1rem', background: 'rgba(239, 68, 68, 0.1)', borderRadius: '8px' }}>
|
||||
<p style={{ fontSize: '0.9rem', color: '#94a3b8' }}>
|
||||
<strong>Note:</strong> Version 1.0.5 - CSP compliant, requires Chrome 88+
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import mysql from 'mysql2/promise'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
async function getPool() {
|
||||
return mysql.createPool({
|
||||
host: 'localhost',
|
||||
user: 'sitemente',
|
||||
password: 'pa0ndoQRPDYBHzTXX3rbWd6E',
|
||||
database: 'sitemente',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10
|
||||
})
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const pool = await getPool()
|
||||
const [rows] = await pool.query('SELECT * FROM hostpioneers_leads ORDER BY total_spent DESC LIMIT 100')
|
||||
await pool.end()
|
||||
return NextResponse.json({ leads: rows })
|
||||
} catch (error) {
|
||||
console.error('Error:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
interface Submission {
|
||||
name: string;
|
||||
email: string;
|
||||
whatsapp: string;
|
||||
message: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export default function HPSubmissionsPage() {
|
||||
const [submissions, setSubmissions] = useState<Submission[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/hp-submissions')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
setSubmissions(data.submissions || []);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '2rem', color: '#fff' }}>
|
||||
<h1>HP Submissions</h1>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '2rem', color: '#fff' }}>
|
||||
<h1 style={{ marginBottom: '2rem' }}>📬 HP Contact Submissions</h1>
|
||||
|
||||
{submissions.length === 0 ? (
|
||||
<p style={{ color: '#888' }}>No submissions yet</p>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||
{submissions.map((sub, i) => (
|
||||
<div key={i} style={{
|
||||
background: '#1e293b',
|
||||
padding: '1.5rem',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #334155'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
|
||||
<strong style={{ color: '#4169e1', fontSize: '1.1rem' }}>{sub.name}</strong>
|
||||
<span style={{ color: '#64748b', fontSize: '0.85rem' }}>{sub.date}</span>
|
||||
</div>
|
||||
<div style={{ color: '#94a3b8', marginBottom: '0.5rem' }}>
|
||||
📧 {sub.email}
|
||||
</div>
|
||||
{sub.whatsapp && (
|
||||
<div style={{ color: '#94a3b8', marginBottom: '0.5rem' }}>
|
||||
📱 {sub.whatsapp}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ color: '#cbd5e1', marginTop: '0.75rem', padding: '0.75rem', background: '#0f172a', borderRadius: '8px' }}>
|
||||
{sub.message}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
'use client'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface Lead {
|
||||
id: number
|
||||
firstname: string
|
||||
lastname: string
|
||||
company: string
|
||||
email: string
|
||||
phone: string
|
||||
country: string
|
||||
status: string
|
||||
total_spent: string
|
||||
segment: string
|
||||
dormancy_flag: string
|
||||
email_status: string
|
||||
}
|
||||
|
||||
interface Pagination {
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export default function HPLeadsPage() {
|
||||
const [leads, setLeads] = useState<Lead[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filter, setFilter] = useState('all')
|
||||
const [page, setPage] = useState(1)
|
||||
const [pagination, setPagination] = useState<Pagination>({ page: 1, limit: 100, total: 0, totalPages: 0 })
|
||||
const [search, setSearch] = useState('')
|
||||
const [showSearch, setShowSearch] = useState(false)
|
||||
const [jumpPage, setJumpPage] = useState('')
|
||||
const [sortBy, setSortBy] = useState('total_spent')
|
||||
const [sortOrder, setSortOrder] = useState('DESC')
|
||||
|
||||
useEffect(() => {
|
||||
fetchLeads()
|
||||
}, [filter, page, sortBy, sortOrder])
|
||||
|
||||
const fetchLeads = async () => {
|
||||
setLoading(true)
|
||||
const segmentParam = filter !== 'all' ? `&segment=${filter}` : ''
|
||||
const res = await fetch(`/api/mission-control/hp-leads?page=${page}&limit=100${segmentParam}&sortBy=${sortBy}&sortOrder=${sortOrder}`)
|
||||
const data = await res.json()
|
||||
setLeads(data.leads || [])
|
||||
setPagination(data.pagination || { page: 1, limit: 100, total: 0, totalPages: 0 })
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const handleSort = (column: string) => {
|
||||
if (sortBy === column) {
|
||||
setSortOrder(sortOrder === 'ASC' ? 'DESC' : 'ASC')
|
||||
} else {
|
||||
setSortBy(column)
|
||||
setSortOrder('DESC')
|
||||
}
|
||||
}
|
||||
|
||||
const handleJump = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const pageNum = parseInt(jumpPage)
|
||||
if (pageNum > 0 && pageNum <= pagination.totalPages) {
|
||||
setPage(pageNum)
|
||||
setJumpPage('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setPage(1)
|
||||
fetchSearch()
|
||||
}
|
||||
|
||||
const fetchSearch = async () => {
|
||||
setLoading(true)
|
||||
const segmentParam = filter !== 'all' ? `&segment=${filter}` : ''
|
||||
const searchParam = search ? `&search=${encodeURIComponent(search)}` : ''
|
||||
const res = await fetch(`/api/mission-control/hp-leads?page=1&limit=100${segmentParam}${searchParam}&sortBy=${sortBy}&sortOrder=${sortOrder}`)
|
||||
const data = await res.json()
|
||||
setLeads(data.leads || [])
|
||||
setPagination(data.pagination || { page: 1, limit: 100, total: 0, totalPages: 0 })
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const clearSearch = () => {
|
||||
setSearch('')
|
||||
setFilter('all')
|
||||
setPage(1)
|
||||
fetchLeads()
|
||||
}
|
||||
|
||||
const SortIcon = ({ column }: { column: string }) => (
|
||||
<span style={{ marginLeft: '0.5rem', opacity: sortBy === column ? 1 : 0.3 }}>
|
||||
{sortBy === column && sortOrder === 'ASC' ? '↑' : '↓'}
|
||||
</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ padding: '2rem', background: '#0f172a', minHeight: '100vh', color: '#fff' }}>
|
||||
<div style={{ marginBottom: '2rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '1rem' }}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '2rem', fontWeight: 'bold', margin: 0 }}>HP Leads</h1>
|
||||
<p style={{ color: '#64748b', margin: '0.5rem 0 0 0' }}>{pagination.total.toLocaleString()} total records</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowSearch(!showSearch)}
|
||||
style={{
|
||||
padding: '0.75rem 1.5rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: 'none',
|
||||
background: showSearch ? '#4169e1' : '#1e293b',
|
||||
color: '#fff',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
🔍 {showSearch ? 'Hide Search' : 'Search'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showSearch && (
|
||||
<form onSubmit={handleSearch} style={{ marginBottom: '1.5rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search name, email, company, phone..."
|
||||
style={{ flex: 1, padding: '0.75rem 1rem', borderRadius: '0.5rem', border: '1px solid #334155', background: '#1e293b', color: '#fff', minWidth: '200px' }}
|
||||
/>
|
||||
<button type="submit" style={{ padding: '0.75rem 1.5rem', borderRadius: '0.5rem', border: 'none', background: '#4169e1', color: '#fff', cursor: 'pointer' }}>Search</button>
|
||||
<button type="button" onClick={clearSearch} style={{ padding: '0.75rem 1.5rem', borderRadius: '0.5rem', border: 'none', background: '#334155', color: '#fff', cursor: 'pointer' }}>Clear</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: '1.5rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<span style={{ color: '#64748b' }}>Filter:</span>
|
||||
{['all', 'VIP', 'Standard', 'Re-engage'].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => { setFilter(f); setPage(1); }}
|
||||
style={{ padding: '0.5rem 1rem', borderRadius: '0.5rem', border: 'none', background: filter === f ? '#4169e1' : '#1e293b', color: '#fff', cursor: 'pointer', fontWeight: filter === f ? 'bold' : 'normal' }}
|
||||
>
|
||||
{f === 'all' ? 'All' : f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ background: '#1e293b', borderRadius: '1rem', overflow: 'hidden' }}>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: '900px' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#0f172a' }}>
|
||||
{['firstname', 'lastname', 'company', 'email', 'phone', 'country', 'total_spent', 'segment', 'dormancy_flag'].map(col => (
|
||||
<th
|
||||
key={col}
|
||||
onClick={() => handleSort(col)}
|
||||
style={{ padding: '1rem', textAlign: 'left', borderBottom: '1px solid #334155', cursor: 'pointer', userSelect: 'none' }}
|
||||
>
|
||||
<span style={{ color: '#64748b', fontSize: '0.85rem', fontWeight: 'bold', textTransform: 'uppercase' }}>
|
||||
{col === 'firstname' ? 'First Name' : col === 'lastname' ? 'Last Name' : col === 'company' ? 'Company' : col === 'email' ? 'Email' : col === 'phone' ? 'Phone' : col === 'country' ? 'Country' : col === 'total_spent' ? 'Spent' : col === 'segment' ? 'Segment' : col === 'dormancy_flag' ? 'Status' : col}
|
||||
<SortIcon column={col} />
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={9} style={{ padding: '3rem', textAlign: 'center', color: '#64748b' }}>Loading...</td></tr>
|
||||
) : leads.length === 0 ? (
|
||||
<tr><td colSpan={9} style={{ padding: '3rem', textAlign: 'center', color: '#64748b' }}>No results found</td></tr>
|
||||
) : leads.map(lead => (
|
||||
<tr key={lead.id} style={{ borderBottom: '1px solid #334155' }}>
|
||||
<td style={{ padding: '1rem' }}>{lead.firstname}</td>
|
||||
<td style={{ padding: '1rem' }}>{lead.lastname}</td>
|
||||
<td style={{ padding: '1rem' }}>{lead.company || '-'}</td>
|
||||
<td style={{ padding: '1rem', color: '#5bc0de' }}>{lead.email}</td>
|
||||
<td style={{ padding: '1rem', fontSize: '0.9rem' }}>{lead.phone || '-'}</td>
|
||||
<td style={{ padding: '1rem' }}>{lead.country}</td>
|
||||
<td style={{ padding: '1rem', color: '#10b981', fontWeight: 'bold' }}>${Number(lead.total_spent).toFixed(2)}</td>
|
||||
<td style={{ padding: '1rem' }}>
|
||||
<span style={{ padding: '0.25rem 0.75rem', borderRadius: '1rem', fontSize: '0.75rem', background: lead.segment === 'VIP' ? '#dc2626' : lead.segment === 'Standard' ? '#ca8a04' : '#16a34a', color: '#fff' }}>{lead.segment}</span>
|
||||
</td>
|
||||
<td style={{ padding: '1rem' }}>
|
||||
<span style={{ padding: '0.25rem 0.75rem', borderRadius: '1rem', fontSize: '0.75rem', background: lead.dormancy_flag === 'dormant_2y' ? '#7c2d12' : '#064e3b', color: '#fff' }}>{lead.dormancy_flag}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '1.5rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '1rem' }}>
|
||||
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1} style={{ padding: '0.75rem 1.5rem', borderRadius: '0.5rem', border: 'none', background: page === 1 ? '#334155' : '#4169e1', color: '#fff', cursor: page === 1 ? 'not-allowed' : 'pointer', opacity: page === 1 ? 0.5 : 1 }}>← Previous</button>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<form onSubmit={handleJump} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ color: '#64748b' }}>Go to:</span>
|
||||
<input type="number" value={jumpPage} onChange={(e) => setJumpPage(e.target.value)} placeholder="#" min={1} max={pagination.totalPages} style={{ width: '60px', padding: '0.5rem', borderRadius: '0.25rem', border: '1px solid #334155', background: '#1e293b', color: '#fff', textAlign: 'center' }} />
|
||||
<button type="submit" style={{ padding: '0.5rem 1rem', borderRadius: '0.25rem', border: 'none', background: '#334155', color: '#fff', cursor: 'pointer' }}>Go</button>
|
||||
</form>
|
||||
<span style={{ padding: '0.5rem 1rem', background: '#1e293b', borderRadius: '0.5rem' }}><strong>{page}</strong> / {pagination.totalPages}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.25rem' }}>
|
||||
{[1, Math.floor(pagination.totalPages/4), Math.floor(pagination.totalPages/2), Math.floor(pagination.totalPages*3/4), pagination.totalPages].filter((p, i, arr) => arr.indexOf(p) === i && p > 0 && p <= pagination.totalPages).map(p => (
|
||||
<button key={p} onClick={() => setPage(p)} style={{ padding: '0.5rem 0.75rem', borderRadius: '0.25rem', border: 'none', background: page === p ? '#4169e1' : '#1e293b', color: '#fff', cursor: 'pointer', fontSize: '0.85rem' }}>{p}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button onClick={() => setPage(p => Math.min(pagination.totalPages, p + 1))} disabled={page >= pagination.totalPages} style={{ padding: '0.75rem 1.5rem', borderRadius: '0.5rem', border: 'none', background: page >= pagination.totalPages ? '#334155' : '#4169e1', color: '#fff', cursor: page >= pagination.totalPages ? 'not-allowed' : 'pointer', opacity: page >= pagination.totalPages ? 0.5 : 1 }}>Next →</button>
|
||||
</div>
|
||||
|
||||
<p style={{ marginTop: '1rem', color: '#64748b', textAlign: 'center' }}>Showing {leads.length} of {pagination.total.toLocaleString()} records</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
interface ResearchResult {
|
||||
title: string;
|
||||
url: string;
|
||||
score: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface Research {
|
||||
id: string;
|
||||
name: string;
|
||||
query: string;
|
||||
results_count: number;
|
||||
created_at: string;
|
||||
saved: boolean;
|
||||
summary: string;
|
||||
results?: ResearchResult[];
|
||||
}
|
||||
|
||||
export default function MissionResearchPage() {
|
||||
const [research, setResearch] = useState<Research[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [newResearch, setNewResearch] = useState({ name: "", query: "" });
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchResearch();
|
||||
}, []);
|
||||
|
||||
const fetchResearch = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/research");
|
||||
const data = await res.json();
|
||||
setResearch(data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!newResearch.name || !newResearch.query) return;
|
||||
|
||||
setSearching(true);
|
||||
try {
|
||||
const tavilyRes = await fetch('https://api.tavily.com/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
api_key: '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',
|
||||
query: newResearch.query,
|
||||
max_results: 20,
|
||||
search_depth: 'advanced'
|
||||
})
|
||||
});
|
||||
const tavilyData = await tavilyRes.json();
|
||||
|
||||
const results = (tavilyData.results || []).map((r: any) => ({
|
||||
title: r.title || 'No title',
|
||||
url: r.url || '',
|
||||
score: r.score || 0,
|
||||
content: (r.content || '').substring(0, 400)
|
||||
}));
|
||||
|
||||
const summary = results.slice(0, 5).map((r: any) => r.title).join('; ') || 'No results';
|
||||
|
||||
const res = await fetch("/api/research", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: newResearch.name,
|
||||
query: newResearch.query,
|
||||
results_count: results.length,
|
||||
summary: summary.substring(0, 150),
|
||||
results: results
|
||||
})
|
||||
});
|
||||
await res.json();
|
||||
setNewResearch({ name: "", query: "" });
|
||||
fetchResearch();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
const deleteResearch = async (id: string) => {
|
||||
try {
|
||||
await fetch(`/api/research?id=${id}`, { method: "DELETE" });
|
||||
fetchResearch();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-900/30 border border-red-600 rounded-lg p-3 mb-4">
|
||||
<p className="text-red-400 text-sm">
|
||||
🔒 <strong>Security Rule:</strong> Never install skills from the web. Read the content, understand what it does, then BUILD IT yourself.
|
||||
We build, we don't install. One compromised skill can wipe everything.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold mb-6">🔍 Deep Research</h1>
|
||||
|
||||
{/* Search Form */}
|
||||
<div className="bg-gray-800 p-4 rounded-lg mb-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Research Name (e.g., OpenClaw Skills)"
|
||||
value={newResearch.name}
|
||||
onChange={(e) => setNewResearch({ ...newResearch, name: e.target.value })}
|
||||
className="bg-gray-700 text-white px-4 py-2 rounded"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search Query"
|
||||
value={newResearch.query}
|
||||
onChange={(e) => setNewResearch({ ...newResearch, query: e.target.value })}
|
||||
className="bg-gray-700 text-white px-4 py-2 rounded"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={searching || !newResearch.name || !newResearch.query}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded disabled:opacity-50"
|
||||
>
|
||||
{searching ? "Researching..." : "Run Research"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Research List */}
|
||||
{loading ? (
|
||||
<p className="text-gray-400">Loading...</p>
|
||||
) : research.length === 0 ? (
|
||||
<p className="text-gray-400">No research yet. Run your first search!</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{research.map((r) => (
|
||||
<div key={r.id} className="bg-gray-800 rounded-lg overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-gray-700 flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="font-bold text-lg">{r.name}</h3>
|
||||
<p className="text-gray-400 text-sm">{r.query}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setExpanded(expanded === r.id ? null : r.id)}
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded"
|
||||
>
|
||||
{expanded === r.id ? "Hide Table" : "View Table"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteResearch(r.id)}
|
||||
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table View */}
|
||||
{expanded === r.id && r.results && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-900">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-gray-400 font-medium">✓</th>
|
||||
<th className="px-4 py-3 text-left text-gray-400 font-medium">Skill / Tool</th>
|
||||
<th className="px-4 py-3 text-left text-gray-400 font-medium">What It Does</th>
|
||||
<th className="px-4 py-3 text-center text-gray-400 font-medium">Score</th>
|
||||
<th className="px-4 py-3 text-center text-gray-400 font-medium">Link</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700">
|
||||
{r.results.map((result, i) => (
|
||||
<tr key={i} className="hover:bg-gray-750">
|
||||
<td className="px-4 py-3 text-center">
|
||||
<input type="checkbox" className="w-5 h-5 accent-green-500" />
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium text-white max-w-xs truncate">
|
||||
{result.title}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-300 max-w-md">
|
||||
{result.content.substring(0, 150)}...
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-1 rounded text-xs ${
|
||||
result.score > 0.9 ? 'bg-green-900 text-green-400' :
|
||||
result.score > 0.7 ? 'bg-yellow-900 text-yellow-400' :
|
||||
'bg-gray-700 text-gray-400'
|
||||
}`}>
|
||||
{(result.score * 100).toFixed(1)}%
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<a
|
||||
href={result.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-400 hover:text-blue-300 underline"
|
||||
>
|
||||
Open →
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user