Add LICENSE, README, and Docs tab to Mission Control
This commit is contained in:
+153
-468
@@ -1,496 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useState, useRef } from "react";
|
||||
import Vapi from "@vapi-ai/web";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type SpeechRecognitionInstance = any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type SpeechRecognitionConstructor = new () => SpeechRecognitionInstance;
|
||||
const VAPI_PUBLIC_KEY = "d44a0025-24bb-426d-919a-cb0a96416ed4";
|
||||
const ASSISTANT_ID = "92630ca5-e165-4360-bce0-dd8730882569";
|
||||
|
||||
type ChatMessage = {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
type ApiResponse = {
|
||||
response: string;
|
||||
shouldCaptureEmail: boolean;
|
||||
suggestedActions: string[];
|
||||
};
|
||||
|
||||
type SiteMenteVoiceWidgetProps = {
|
||||
initialLang?: "es" | "en";
|
||||
};
|
||||
|
||||
const quickActions = {
|
||||
es: [
|
||||
{ label: "¿Cuánto cuesta?", icon: "💰" },
|
||||
{ label: "Ver casos de éxito", icon: "🎯" },
|
||||
{ label: "¿Cómo funciona?", icon: "⚙️" },
|
||||
],
|
||||
en: [
|
||||
{ label: "Pricing?", icon: "💰" },
|
||||
{ label: "Success stories", icon: "🎯" },
|
||||
{ label: "How it works?", icon: "⚙️" },
|
||||
],
|
||||
} as const;
|
||||
|
||||
const initialGreeting = {
|
||||
es: "Hola, soy el cerebro de SiteMente. ¿En qué te puedo ayudar hoy?",
|
||||
en: "Hi, I'm the SiteMente brain. How can I help you today?",
|
||||
} as const;
|
||||
interface SiteMenteVoiceWidgetProps {
|
||||
businessName?: string;
|
||||
businessType?: "restaurant" | "real-estate" | "clinic" | "car-rental" | "default";
|
||||
theme?: "dark" | "light";
|
||||
}
|
||||
|
||||
export default function SiteMenteVoiceWidget({
|
||||
initialLang = "es",
|
||||
businessName = "SiteMente",
|
||||
businessType = "default",
|
||||
theme = "dark"
|
||||
}: SiteMenteVoiceWidgetProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [lang, setLang] = useState<"es" | "en">(initialLang);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([
|
||||
{
|
||||
role: "assistant",
|
||||
content: initialGreeting[initialLang],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
const [input, setInput] = useState("");
|
||||
const [voiceMode, setVoiceMode] = useState(true);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isSpeaking, setIsSpeaking] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [status, setStatus] = useState<"idle" | "connecting" | "active" | "error">("idle");
|
||||
const [transcript, setTranscript] = useState("");
|
||||
const [speechSupported, setSpeechSupported] = useState(true);
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
|
||||
const recognitionRef = useRef<SpeechRecognitionInstance | null>(null);
|
||||
const isRecordingRef = useRef(false);
|
||||
const transcriptRef = useRef("");
|
||||
const messagesEndRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const localeLabel = useMemo(
|
||||
() => (lang === "es" ? "ES" : "EN"),
|
||||
[lang]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLang(initialLang);
|
||||
}, [initialLang]);
|
||||
|
||||
useEffect(() => {
|
||||
const seen = window.localStorage.getItem("sitemente:voice-tooltip");
|
||||
if (!seen) {
|
||||
setShowTooltip(true);
|
||||
window.localStorage.setItem("sitemente:voice-tooltip", "1");
|
||||
const timeout = window.setTimeout(() => setShowTooltip(false), 4000);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}
|
||||
return undefined;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
isRecordingRef.current = isRecording;
|
||||
}, [isRecording]);
|
||||
|
||||
useEffect(() => {
|
||||
transcriptRef.current = transcript;
|
||||
}, [transcript]);
|
||||
|
||||
useEffect(() => {
|
||||
const SpeechRecognitionImpl =
|
||||
typeof window !== "undefined"
|
||||
? ((window as typeof window & {
|
||||
webkitSpeechRecognition?: SpeechRecognitionConstructor;
|
||||
}).SpeechRecognition ||
|
||||
(window as typeof window & {
|
||||
webkitSpeechRecognition?: SpeechRecognitionConstructor;
|
||||
}).webkitSpeechRecognition)
|
||||
: undefined;
|
||||
|
||||
if (!SpeechRecognitionImpl) {
|
||||
setSpeechSupported(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const recognition = new SpeechRecognitionImpl();
|
||||
recognition.lang = lang === "es" ? "es-ES" : "en-US";
|
||||
recognition.interimResults = true;
|
||||
recognition.continuous = false;
|
||||
|
||||
recognition.onresult = (event) => {
|
||||
const result = Array.from(event.results)
|
||||
.map((res) => res[0]?.transcript ?? "")
|
||||
.join(" ");
|
||||
setTranscript(result.trim());
|
||||
};
|
||||
|
||||
recognition.onerror = () => {
|
||||
setIsRecording(false);
|
||||
};
|
||||
|
||||
recognition.onend = () => {
|
||||
if (isRecordingRef.current) {
|
||||
setIsRecording(false);
|
||||
const finalTranscript = transcriptRef.current.trim();
|
||||
if (finalTranscript) {
|
||||
handleSend(finalTranscript);
|
||||
}
|
||||
setTranscript("");
|
||||
}
|
||||
};
|
||||
|
||||
recognitionRef.current = recognition;
|
||||
}, [lang]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSpeaking) return;
|
||||
return () => {
|
||||
window.speechSynthesis?.cancel();
|
||||
};
|
||||
}, [isSpeaking]);
|
||||
|
||||
useEffect(() => {
|
||||
setMessages((prev) => {
|
||||
if (prev.length === 0) return prev;
|
||||
const updated = [...prev];
|
||||
if (updated[0].role === "assistant") {
|
||||
updated[0] = {
|
||||
...updated[0],
|
||||
content: initialGreeting[lang],
|
||||
};
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}, [lang]);
|
||||
|
||||
const startRecording = () => {
|
||||
if (!speechSupported || !recognitionRef.current) return;
|
||||
setTranscript("");
|
||||
setIsRecording(true);
|
||||
recognitionRef.current.start();
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
if (!recognitionRef.current) return;
|
||||
recognitionRef.current.stop();
|
||||
setIsRecording(false);
|
||||
};
|
||||
|
||||
const speak = (text: string) => {
|
||||
if (!("speechSynthesis" in window)) return;
|
||||
window.speechSynthesis.cancel();
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
utterance.lang = lang === "es" ? "es-ES" : "en-US";
|
||||
utterance.onstart = () => setIsSpeaking(true);
|
||||
utterance.onend = () => setIsSpeaking(false);
|
||||
utterance.onerror = () => setIsSpeaking(false);
|
||||
window.speechSynthesis.speak(utterance);
|
||||
};
|
||||
|
||||
const handleSend = async (text: string) => {
|
||||
if (!text.trim() || isLoading) return;
|
||||
const userMessage: ChatMessage = {
|
||||
role: "user",
|
||||
content: text,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
setInput("");
|
||||
setIsLoading(true);
|
||||
const [errorMsg, setErrorMsg] = useState<string>("");
|
||||
const vapiRef = useRef<any>(null);
|
||||
|
||||
const startCall = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/chat/agent", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
message: text,
|
||||
locale: lang,
|
||||
history: messages.slice(-6),
|
||||
}),
|
||||
console.log("Starting call - initializing Vapi inside click handler...");
|
||||
setErrorMsg("");
|
||||
setStatus("connecting");
|
||||
|
||||
// Step 1: Verify mic exists
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
console.log("✅ Mic stream created:", stream);
|
||||
console.log("✅ Audio tracks:", stream.getAudioTracks().length);
|
||||
console.log("✅ Track enabled:", stream.getAudioTracks()[0]?.enabled);
|
||||
console.log("✅ Track settings:", stream.getAudioTracks()[0]?.getSettings());
|
||||
} catch (micErr) {
|
||||
console.log("❌ Mic error:", micErr);
|
||||
}
|
||||
|
||||
// Initialize Vapi INSIDE the click handler (required for iOS)
|
||||
const vapi = new Vapi(VAPI_PUBLIC_KEY);
|
||||
vapiRef.current = vapi;
|
||||
|
||||
// Set up event listeners
|
||||
vapi.on("error", (error: any) => {
|
||||
console.log("Vapi error:", error);
|
||||
const msg = String(error?.message || error?.error?.message || JSON.stringify(error) || "Error desconocido");
|
||||
setErrorMsg(msg);
|
||||
setStatus("error");
|
||||
setIsActive(false);
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch response.");
|
||||
}
|
||||
vapi.on("call-start", () => {
|
||||
console.log("✅ Call started!");
|
||||
setStatus("active");
|
||||
|
||||
// Check peer connection for audio senders
|
||||
setTimeout(() => {
|
||||
try {
|
||||
// @ts-ignore - internal property
|
||||
const pc = vapiRef.current?._call?._pc;
|
||||
if (pc) {
|
||||
console.log("📡 PeerConnection found");
|
||||
pc.getSenders().forEach((sender: any, i: number) => {
|
||||
console.log(`Sender ${i}:`, sender.track?.kind, sender.track?.enabled);
|
||||
});
|
||||
} else {
|
||||
console.log("⚠️ No PeerConnection found");
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Error checking PC:", e);
|
||||
}
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
const data = (await response.json()) as ApiResponse;
|
||||
const assistantMessage: ChatMessage = {
|
||||
role: "assistant",
|
||||
content: data.response,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setMessages((prev) => [...prev, assistantMessage]);
|
||||
vapi.on("call-end", (e: any) => {
|
||||
console.log("Call ended", e);
|
||||
setStatus("idle");
|
||||
setIsActive(false);
|
||||
});
|
||||
|
||||
if (voiceMode) {
|
||||
speak(data.response);
|
||||
}
|
||||
} catch (error) {
|
||||
const fallbackMessage: ChatMessage = {
|
||||
role: "assistant",
|
||||
content:
|
||||
lang === "es"
|
||||
? "Hubo un problema al responder. ¿Quieres intentarlo de nuevo?"
|
||||
: "There was a problem responding. Want to try again?",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setMessages((prev) => [...prev, fallbackMessage]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
vapi.on("message", (m: any) => {
|
||||
console.log("Vapi message:", m);
|
||||
});
|
||||
|
||||
vapi.on("speech-start", () => {
|
||||
console.log("User speech detected!");
|
||||
});
|
||||
|
||||
vapi.on("speech-end", () => {
|
||||
console.log("User speech ended");
|
||||
});
|
||||
|
||||
vapi.on("transcript", (transcript: any) => {
|
||||
console.log("Transcript:", transcript);
|
||||
if (typeof transcript === "string") {
|
||||
setTranscript(transcript);
|
||||
} else if (transcript?.text) {
|
||||
setTranscript(transcript.text);
|
||||
}
|
||||
});
|
||||
|
||||
console.log("Calling assistant:", ASSISTANT_ID);
|
||||
|
||||
// Start the call
|
||||
await vapi.start(ASSISTANT_ID);
|
||||
|
||||
console.log("Call started successfully");
|
||||
setIsActive(true);
|
||||
} catch (error: any) {
|
||||
console.log("Start error:", error);
|
||||
const msg = String(error?.message || error?.error?.message || JSON.stringify(error) || "Error al iniciar");
|
||||
setErrorMsg(msg);
|
||||
setStatus("error");
|
||||
}
|
||||
};
|
||||
|
||||
const voiceIndicator = isRecording
|
||||
? "🎤"
|
||||
: isSpeaking
|
||||
? "🔊"
|
||||
: "🎤";
|
||||
const endCall = async () => {
|
||||
try {
|
||||
if (vapiRef.current) {
|
||||
await vapiRef.current.stop();
|
||||
}
|
||||
setIsActive(false);
|
||||
setStatus("idle");
|
||||
setTranscript("");
|
||||
} catch (error) {
|
||||
console.error("End call error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const buttonColor = theme === "dark" ? "bg-brand-pink" : "bg-blue-600";
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isOpen && (
|
||||
<div className="fixed bottom-6 right-6 z-[9999] flex flex-col items-end gap-2">
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="relative flex h-[68px] w-[68px] items-center justify-center rounded-full bg-gradient-to-br from-[#8B5CF6] to-[#EC4899] text-white shadow-lg transition hover:scale-110 hover:shadow-[0_12px_30px_rgba(236,72,153,0.45)]"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="h-3 w-1 rounded-full bg-white/80 animate-pulse" />
|
||||
<span className="h-5 w-1 rounded-full bg-white/90 animate-pulse" />
|
||||
<span className="h-4 w-1 rounded-full bg-white/80 animate-pulse" />
|
||||
</div>
|
||||
</button>
|
||||
{showTooltip && (
|
||||
<div className="absolute right-[76px] top-1/2 -translate-y-1/2 rounded-full bg-white px-3 py-1 text-xs font-semibold text-brand-purple-dark shadow-md">
|
||||
{lang === "es" ? "Prueba la voz" : "Try voice"}
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute -top-6 right-2 rounded-full bg-white/20 px-2 py-1 text-[10px] font-semibold text-white backdrop-blur">
|
||||
▶ Demo
|
||||
</div>
|
||||
<div className="fixed bottom-6 right-6 z-50">
|
||||
{status === "error" && errorMsg && (
|
||||
<div className="absolute bottom-16 right-0 w-64 bg-red-600 text-white text-xs p-2 rounded-lg mb-2">
|
||||
⚠️ {errorMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isActive && (
|
||||
<div className="absolute bottom-16 right-0 w-80 bg-[#1a1625] border border-white/20 rounded-xl p-4 shadow-2xl mb-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-white">🤖 AI</span>
|
||||
<span className={`w-2 h-2 rounded-full ${status === "active" ? "bg-green-500 animate-pulse" : "bg-yellow-500"}`}></span>
|
||||
</div>
|
||||
<div className="h-32 overflow-y-auto text-sm text-white/70 bg-white/5 rounded-lg p-2">
|
||||
{transcript || "Escuchando..."}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-[9999] flex items-end justify-end p-4 sm:p-6">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={() => setIsOpen(false)} />
|
||||
<div className="relative z-10 flex h-full w-full max-w-[440px] flex-col overflow-hidden rounded-3xl border border-white/15 bg-[#4f3a78] shadow-[0_30px_80px_rgba(0,0,0,0.45)] sm:h-[700px]">
|
||||
<div className="flex items-center justify-between bg-gradient-to-r from-[#6d4cc2] to-[#ff66b5] px-5 py-4 text-white">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-lg font-semibold">
|
||||
<span>SiteMente IA</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="h-2 w-1 rounded-full bg-white/80 animate-pulse" />
|
||||
<span className="h-3 w-1 rounded-full bg-white/90 animate-pulse" />
|
||||
<span className="h-2 w-1 rounded-full bg-white/80 animate-pulse" />
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-white/80">
|
||||
{lang === "es" ? "El cerebro de tu web" : "Your website brain"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-lg">
|
||||
<span>{voiceIndicator}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLang((prev) => (prev === "es" ? "en" : "es"))}
|
||||
className="rounded-full border border-white/30 px-2 py-1 text-xs font-semibold"
|
||||
>
|
||||
{localeLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-xl"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={isActive ? endCall : startCall}
|
||||
className={`${buttonColor} w-14 h-14 rounded-full shadow-lg flex items-center justify-center transition-all hover:scale-110 ${
|
||||
isActive ? "animate-pulse ring-4 ring-red-500/50" : ""
|
||||
}`}
|
||||
title={isActive ? "Colgar" : "Hablar con IA"}
|
||||
>
|
||||
{isActive ? (
|
||||
<svg className="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 8l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2M5 3a2 2 0 00-2 2v1c0 8.284 6.716 15 15 15h1a2 2 0 002-2v-3.28a1 1 0 00-.684-.948l-4.493-1.498a1 1 0 00-1.21.502l-1.13 2.257a11.042 11.042 0 01-5.516-5.517l2.257-1.128a1 1 0 00.502-1.21L9.228 3.683A1 1 0 008.279 3H5z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{messages.length === 1 && (
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{quickActions[lang].map((action) => (
|
||||
<button
|
||||
key={action.label}
|
||||
type="button"
|
||||
onClick={() => handleSend(action.label)}
|
||||
className="rounded-full border border-white/20 bg-white/10 px-3 py-1 text-xs text-white/90 transition hover:bg-white/20"
|
||||
>
|
||||
{action.icon} {action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={`${message.timestamp}-${message.role}`}
|
||||
className={`group flex ${
|
||||
message.role === "user" ? "justify-end" : "justify-start"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[80%] rounded-2xl px-4 py-3 text-sm shadow-md ${
|
||||
message.role === "user"
|
||||
? "bg-[#c4a1ff] text-[#3b1c66]"
|
||||
: "bg-[#6a4bb0] text-white"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{message.role === "assistant" && (
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-white/20 text-xs font-semibold">
|
||||
SM
|
||||
</span>
|
||||
)}
|
||||
<p>{message.content}</p>
|
||||
</div>
|
||||
<span className="mt-2 block text-[10px] text-white/60 opacity-0 transition group-hover:opacity-100">
|
||||
{new Date(message.timestamp).toLocaleTimeString(
|
||||
lang === "es" ? "es-ES" : "en-US",
|
||||
{ hour: "2-digit", minute: "2-digit" }
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 text-white/70">
|
||||
<span className="inline-flex h-2 w-2 animate-bounce rounded-full bg-white/70" />
|
||||
<span className="inline-flex h-2 w-2 animate-bounce rounded-full bg-white/60 delay-150" />
|
||||
<span className="inline-flex h-2 w-2 animate-bounce rounded-full bg-white/50 delay-300" />
|
||||
</div>
|
||||
)}
|
||||
{isSpeaking && (
|
||||
<div className="flex items-center gap-2 text-xs text-white/70">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="h-2 w-1 rounded-full bg-white/80 animate-pulse" />
|
||||
<span className="h-3 w-1 rounded-full bg-white/90 animate-pulse" />
|
||||
<span className="h-2 w-1 rounded-full bg-white/80 animate-pulse" />
|
||||
</span>
|
||||
{lang === "es" ? "Hablando..." : "Speaking..."}
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/10 bg-[#4a3572] px-5 py-4">
|
||||
{voiceMode ? (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{transcript && (
|
||||
<p className="w-full rounded-xl bg-white/10 px-4 py-2 text-sm text-white/90">
|
||||
{transcript}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isRecording) {
|
||||
stopRecording();
|
||||
return;
|
||||
}
|
||||
if (isSpeaking) {
|
||||
window.speechSynthesis?.cancel();
|
||||
setIsSpeaking(false);
|
||||
return;
|
||||
}
|
||||
startRecording();
|
||||
}}
|
||||
className={`flex h-14 w-14 items-center justify-center rounded-full text-white transition ${
|
||||
isRecording
|
||||
? "bg-red-500 animate-pulse"
|
||||
: isSpeaking
|
||||
? "bg-blue-500 animate-pulse"
|
||||
: "bg-white/20 hover:bg-white/30"
|
||||
}`}
|
||||
disabled={!speechSupported}
|
||||
>
|
||||
{isRecording ? "🔴" : isSpeaking ? "🔊" : "🎤"}
|
||||
</button>
|
||||
<p className="text-xs text-white/80">
|
||||
{isRecording
|
||||
? lang === "es"
|
||||
? "Escuchando..."
|
||||
: "Listening..."
|
||||
: isSpeaking
|
||||
? lang === "es"
|
||||
? "Hablando..."
|
||||
: "Speaking..."
|
||||
: lang === "es"
|
||||
? "Toca para hablar"
|
||||
: "Tap to talk"}
|
||||
</p>
|
||||
{isSpeaking && (
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-white/10">
|
||||
<div className="h-full w-1/2 animate-pulse rounded-full bg-brand-pink/80" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex w-full items-center justify-between text-xs text-white/70">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVoiceMode(false)}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
⌨️ {lang === "es" ? "Texto" : "Text"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.speechSynthesis?.cancel();
|
||||
setIsSpeaking(false);
|
||||
}}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
⏸ {lang === "es" ? "Pausar" : "Pause"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
placeholder={
|
||||
lang === "es"
|
||||
? "Escribe tu mensaje..."
|
||||
: "Type your message..."
|
||||
}
|
||||
className="flex-1 rounded-full border border-white/20 bg-white/10 px-4 py-2 text-sm text-white placeholder:text-white/50 focus:border-white/50 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSend(input)}
|
||||
className="rounded-full bg-brand-pink px-4 py-2 text-sm font-semibold text-white"
|
||||
>
|
||||
➤
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVoiceMode(true)}
|
||||
className="rounded-full border border-white/20 px-3 py-2 text-white/80"
|
||||
>
|
||||
🎤
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!speechSupported && (
|
||||
<p className="mt-2 text-center text-xs text-white/60">
|
||||
{lang === "es"
|
||||
? "Tu navegador no soporta voz. Usa el modo texto."
|
||||
: "Your browser doesn't support voice. Use text mode."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{status === "connecting" && (
|
||||
<div className="absolute -top-8 right-0 bg-white/10 backdrop-blur px-3 py-1 rounded-full text-xs text-white">
|
||||
Conectando...
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ import { useState, useEffect } from "react";
|
||||
import { useMissionControl } from "@/lib/mission-control/store";
|
||||
import { TaskStatus } from "@/lib/mission-control/types";
|
||||
import VoiceChat from "./VoiceChat";
|
||||
import MondayBoard from "./MondayBoard";
|
||||
import { TaskCardsPanel } from "./TaskCardsPanel";
|
||||
import { TaskHistoryPanel } from "./TaskHistoryPanel";
|
||||
import { TradingPanel } from "./TradingPanel";
|
||||
import AIManagement from "@/components/ai-management/AIManagement";
|
||||
import Council from "@/components/council/Council";
|
||||
|
||||
@@ -23,13 +27,27 @@ interface SidebarCategory {
|
||||
}
|
||||
|
||||
const sidebarCategories: SidebarCategory[] = [
|
||||
{ id: "leads", name: "Leads", icon: "📈", items: [
|
||||
{ id: "leads-crm", name: "CRM", icon: "📊", category: "leads" },
|
||||
{ id: "dashboard", name: "Client Dashboard", icon: "🏢", category: "dashboard" },
|
||||
]},
|
||||
{ id: "projects", name: "Projects", icon: "🎯", items: [
|
||||
{ id: "monday", name: "Monday Board", icon: "📊", category: "monday" },
|
||||
{ id: "sitemente", name: "SiteMente", icon: "🌐", color: "#ff7bc0", category: "projects" },
|
||||
{ id: "demos", name: "Demo Pages", icon: "🎨", category: "demos" },
|
||||
{ id: "holacompi", name: "HolaCompi", icon: "🤝", color: "#6366f1", category: "projects" },
|
||||
{ id: "arabredox", name: "Arabredox", icon: "💚", color: "#22c55e", category: "projects" },
|
||||
{ id: "infrastructure", name: "Infra", icon: "⚙️", color: "#10b981", category: "projects" },
|
||||
]},
|
||||
{ id: "trading", name: "Trading", icon: "📈", items: [
|
||||
{ id: "trading-research", name: "Deep Research", icon: "🔬", category: "trading" },
|
||||
{ id: "trading-strategies", name: "Strategies", icon: "🎯", category: "trading" },
|
||||
{ id: "trading-execution", name: "Execution", icon: "⚡", category: "trading" },
|
||||
{ id: "trading-journal", name: "Journal", icon: "📔", category: "trading" },
|
||||
]},
|
||||
{ id: "tasks", name: "Tasks", icon: "✓", items: [
|
||||
{ id: "all", name: "All Tasks", icon: "📋", category: "tasks" },
|
||||
{ id: "task-cards", name: "Task Cards", icon: "☑️", category: "task-cards" },
|
||||
{ id: "task-history", name: "History", icon: "📜", category: "task-history" },
|
||||
]},
|
||||
{ id: "chat", name: "Chat", icon: "💬", items: [
|
||||
{ id: "voice", name: "Voice Chat", icon: "🎤", category: "chat" },
|
||||
@@ -44,6 +62,9 @@ const sidebarCategories: SidebarCategory[] = [
|
||||
{ id: "memory", name: "Memory", icon: "🧠", items: [
|
||||
{ id: "logs", name: "Session Logs", icon: "📝", category: "memory" },
|
||||
]},
|
||||
{ id: "docs", name: "Docs", icon: "📚", items: [
|
||||
{ id: "docs-index", name: "Documentation", icon: "📚", category: "docs" },
|
||||
]},
|
||||
];
|
||||
|
||||
const statusConfig: Record<TaskStatus, { label: string; color: string }> = {
|
||||
@@ -54,8 +75,13 @@ const statusConfig: Record<TaskStatus, { label: string; color: string }> = {
|
||||
paused: { label: "Paused", color: "text-gray-400" },
|
||||
};
|
||||
|
||||
export default function MissionControlDashboard() {
|
||||
interface MissionControlDashboardProps {
|
||||
onLogout?: () => void;
|
||||
}
|
||||
|
||||
export default function MissionControlDashboard({ onLogout }: MissionControlDashboardProps) {
|
||||
const { tasks, toggleTask, updateTaskStatus, addTask, getProjectProgress, getTasksByProject } = useMissionControl();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState<string>("sitemente");
|
||||
const [filter, setFilter] = useState<TaskStatus | "all">("all");
|
||||
const [expandedCategories, setExpandedCategories] = useState<string[]>(["projects"]);
|
||||
@@ -64,6 +90,10 @@ export default function MissionControlDashboard() {
|
||||
const [newTaskTitle, setNewTaskTitle] = useState("");
|
||||
const [newTaskProject, setNewTaskProject] = useState("sitemente");
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "/" && !e.ctrlKey && !e.metaKey) {
|
||||
@@ -115,8 +145,36 @@ export default function MissionControlDashboard() {
|
||||
setNewTaskTitle("");
|
||||
};
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#1a1625] text-white flex items-center justify-center">
|
||||
<div className="text-white/60">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Golden Wisdom Banner
|
||||
const goldenNotes = (
|
||||
<div className="fixed bottom-4 right-4 z-50 max-w-sm">
|
||||
<div className="bg-gradient-to-br from-[#1a1625] to-[#2d1f3d] border border-amber-500/30 rounded-xl p-4 shadow-2xl shadow-amber-500/10">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-amber-400">🔥</span>
|
||||
<h3 className="font-bold text-amber-400 text-sm">GOLDEN NOTES</h3>
|
||||
</div>
|
||||
<ul className="text-xs text-white/80 space-y-1">
|
||||
<li>• Fun first. Learning second. Outcome last.</li>
|
||||
<li>• Signal intent early — don't wait too long</li>
|
||||
<li>• Warmth + tension = desire, not friendzone</li>
|
||||
<li>• Lead more — calm + leading = attractive</li>
|
||||
<li>• Feel more, optimize less</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#1a1625] text-white flex">
|
||||
{goldenNotes}
|
||||
<aside className="w-64 border-r border-white/10 bg-[#1a1625] p-4">
|
||||
<div className="flex items-center gap-3 mb-6 pb-4 border-b border-white/10">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-pink flex items-center justify-center text-xl">👁️</div>
|
||||
@@ -145,6 +203,9 @@ export default function MissionControlDashboard() {
|
||||
<p className="text-xs text-white/40 uppercase mb-2 px-3">Quick</p>
|
||||
<a href="/" className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-white/60 hover:bg-white/5 hover:text-white transition">🏠 SiteMente Site</a>
|
||||
<button onClick={collapseAll} className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-white/60 hover:bg-white/5 hover:text-white transition">⊖ Collapse All</button>
|
||||
{onLogout && (
|
||||
<button onClick={onLogout} className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-red-400 hover:bg-red-500/10 transition">🚪 Logout</button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -169,10 +230,14 @@ export default function MissionControlDashboard() {
|
||||
|
||||
{currentItem?.category === "chat" && <div className="rounded-xl border border-white/10 bg-white/5 p-6"><VoiceChat /></div>}
|
||||
|
||||
{currentItem?.category === "monday" && <div className="h-full"><MondayBoard /></div>}
|
||||
|
||||
{currentItem?.category === "council" && currentItem?.id === "teams" && <div className="rounded-xl border border-white/10 bg-white/5 p-6"><Council /></div>}
|
||||
|
||||
{currentItem?.category === "council-settings" && <div className="rounded-xl border border-white/10 bg-white/5 p-6"><AIManagement /></div>}
|
||||
|
||||
{currentItem?.category === "trading" && <TradingPanel />}
|
||||
|
||||
{currentItem?.category === "calendar" && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-white/10 bg-white/5 p-6">
|
||||
@@ -191,6 +256,37 @@ export default function MissionControlDashboard() {
|
||||
<div className="space-y-2 text-sm text-white/70"><p>Memory is stored in:</p><ul className="text-white/50 space-y-1"><li>• localStorage (browser)</li><li>• GitHub repo (daily commits)</li><li>• MEMORY.md (curated)</li></ul></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentItem?.category === "docs" && (
|
||||
<div className="rounded-xl border border-white/10 bg-white/5 p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">📚 Documentation</h3>
|
||||
<p className="text-white/60 mb-4">Long-term docs and guides</p>
|
||||
<div className="space-y-3">
|
||||
<a href="/mission-control/docs" target="_blank" className="flex items-center gap-3 p-3 rounded-lg bg-white/5 hover:bg-white/10 transition">
|
||||
<span className="text-2xl">📖</span>
|
||||
<div><p className="font-medium">Docs Index</p><p className="text-xs text-white/50">All documentation</p></div>
|
||||
</a>
|
||||
<a href="https://github.com/HaithamEKhalifa/SiteMente" target="_blank" className="flex items-center gap-3 p-3 rounded-lg bg-white/5 hover:bg-white/10 transition">
|
||||
<span className="text-2xl">🐙</span>
|
||||
<div><p className="font-medium">GitHub Repo</p><p className="text-xs text-white/50">Source code & issues</p></div>
|
||||
</a>
|
||||
<a href="https://sitemente.com" target="_blank" className="flex items-center gap-3 p-3 rounded-lg bg-white/5 hover:bg-white/10 transition">
|
||||
<span className="text-2xl">🌐</span>
|
||||
<div><p className="font-medium">Live Site</p><p className="text-xs text-white/50">sitemente.com</p></div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Task Cards View */}
|
||||
{currentItem?.category === "task-cards" && (
|
||||
<TaskCardsPanel tasks={tasks} toggleTask={toggleTask} />
|
||||
)}
|
||||
|
||||
{/* Task History View */}
|
||||
{currentItem?.category === "task-history" && (
|
||||
<TaskHistoryPanel />
|
||||
)}
|
||||
{currentItem?.category === "demos" && (
|
||||
<div className="rounded-xl border border-white/10 bg-white/5 p-6">
|
||||
<div className="text-center py-8">
|
||||
@@ -208,6 +304,32 @@ export default function MissionControlDashboard() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentItem?.category === "leads" && (
|
||||
<div className="rounded-xl border border-white/10 bg-white/5 p-6">
|
||||
<div className="text-center py-8">
|
||||
<div className="text-5xl mb-4">📈</div>
|
||||
<h3 className="text-xl font-bold mb-2">Leads CRM</h3>
|
||||
<p className="text-white/60 mb-6">Track and manage your leads</p>
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
<a href="/leads" target="_blank" className="px-4 py-2 bg-brand-pink rounded-lg text-sm font-medium hover:bg-[#ff7bc0] transition">📈 Open Leads CRM</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentItem?.category === "dashboard" && (
|
||||
<div className="rounded-xl border border-white/10 bg-white/5 p-6">
|
||||
<div className="text-center py-8">
|
||||
<div className="text-5xl mb-4">🏢</div>
|
||||
<h3 className="text-xl font-bold mb-2">Client Dashboard</h3>
|
||||
<p className="text-white/60 mb-6">Where your clients see bookings & leads</p>
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
<a href="/dashboard" target="_blank" className="px-4 py-2 bg-brand-pink rounded-lg text-sm font-medium hover:bg-[#ff7bc0] transition">🏢 Open Dashboard Demo</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{currentItem?.category === "projects" && (
|
||||
<>
|
||||
@@ -237,6 +359,7 @@ export default function MissionControlDashboard() {
|
||||
<select value={newTaskProject} onChange={(e) => setNewTaskProject(e.target.value)} className="bg-white/10 border border-white/20 rounded-lg px-3 py-1.5 text-sm">
|
||||
<option value="sitemente">SiteMente</option>
|
||||
<option value="holacompi">HolaCompi</option>
|
||||
<option value="arabredox">Arabredox</option>
|
||||
<option value="infrastructure">Infrastructure</option>
|
||||
</select>
|
||||
<button onClick={handleAddTask} className="px-4 py-1.5 bg-brand-pink rounded-lg text-sm font-medium">Add Task</button>
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
// Monday-style columns
|
||||
const COLUMNS = [
|
||||
{ id: "brainstorming", name: "💡 Brainstorming", color: "#ff6b6b" },
|
||||
{ id: "planning", name: "📋 Planning", color: "#ffd93d" },
|
||||
{ id: "ready", name: "✅ Ready", color: "#6bcb77" },
|
||||
{ id: "in-progress", name: "🚀 In Progress", color: "#4d96ff" },
|
||||
{ id: "review", name: "👀 Review", color: "#9b59b6" },
|
||||
{ id: "done", name: "🎉 Done", color: "#00d2d3" },
|
||||
];
|
||||
|
||||
interface Feature {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
column: string;
|
||||
projectId: string;
|
||||
approved: boolean;
|
||||
implemented: boolean;
|
||||
}
|
||||
|
||||
// Project features - these will be listed for approval
|
||||
const PROJECT_FEATURES_INITIAL: Feature[] = [
|
||||
// SiteMente v1
|
||||
{ id: "s1", title: "Vertical Pack Cards", description: "Beautiful cards for Real Estate, Restaurant, Clinic verticals on landing", column: "brainstorming", projectId: "sitemente-v1", approved: false, implemented: false },
|
||||
{ id: "s2", title: "Contact/Onboarding Form", description: "Lead capture form with business type selection", column: "brainstorming", projectId: "sitemente-v1", approved: false, implemented: false },
|
||||
{ id: "s3", title: "AI Widget Live Demo", description: "Working voice/chat AI widget on landing page", column: "brainstorming", projectId: "sitemente-v1", approved: false, implemented: false },
|
||||
{ id: "s4", title: "Mobile Responsive Pass", description: "Full mobile responsiveness audit and fixes", column: "planning", projectId: "sitemente-v1", approved: false, implemented: false },
|
||||
{ id: "s5", title: "Demo Pages SEO", description: "Per-vertical landing pages with unique meta tags", column: "planning", projectId: "sitemente-v1", approved: false, implemented: false },
|
||||
{ id: "s6", title: "Lead Capture Integration", description: "Connect forms to email/CRM notification", column: "planning", projectId: "sitemente-v1", approved: false, implemented: false },
|
||||
{ id: "s7", title: "WhatsApp Business Integration", description: "Auto-send leads to WhatsApp", column: "planning", projectId: "sitemente-v1", approved: false, implemented: false },
|
||||
{ id: "s8", title: "Pricing Toggle", description: "Monthly/Annual toggle with discount display", column: "ready", projectId: "sitemente-v1", approved: false, implemented: false },
|
||||
{ id: "s9", title: "Case Studies Section", description: "Real testimonials from Costa del Sol clients", column: "ready", projectId: "sitemente-v1", approved: false, implemented: false },
|
||||
{ id: "s10", title: "Blog Section", description: "SEO blog with AI-generated content tips", column: "in-progress", projectId: "sitemente-v1", approved: false, implemented: false },
|
||||
// HolaCompi v1
|
||||
{ id: "h1", title: "Voice AI Agent", description: "Real voice conversation with callers", column: "brainstorming", projectId: "holacompi-v1", approved: false, implemented: false },
|
||||
{ id: "h2", title: "WhatsApp Integration", description: "AI responds on WhatsApp Business", column: "brainstorming", projectId: "holacompi-v1", approved: false, implemented: false },
|
||||
{ id: "h3", title: "Consumer Rights Chatbot", description: "Legal info chatbot for immigrants", column: "brainstorming", projectId: "holacompi-v1", approved: false, implemented: false },
|
||||
{ id: "h4", title: "Spanish Bureaucracy Guide", description: "AI guide through Spanish paperwork", column: "planning", projectId: "holacompi-v1", approved: false, implemented: false },
|
||||
{ id: "h5", title: "Multi-language Support", description: "ES, EN, FR, AR, RO language options", column: "planning", projectId: "holacompi-v1", approved: false, implemented: false },
|
||||
{ id: "h6", title: "Emergency Contacts", description: "Quick access to emergency services by location", column: "ready", projectId: "holacompi-v1", approved: false, implemented: false },
|
||||
// Infrastructure
|
||||
{ id: "i1", title: "Production Deployment", description: "Deploy to production domain (sitemente.com)", column: "planning", projectId: "infrastructure", approved: false, implemented: false },
|
||||
{ id: "i2", title: "Cloud Backups", description: "Automated daily backups to cloud storage", column: "planning", projectId: "infrastructure", approved: false, implemented: false },
|
||||
{ id: "i3", title: "Monitoring Setup", description: "Uptime monitoring and alerts", column: "planning", projectId: "infrastructure", approved: false, implemented: false },
|
||||
{ id: "i4", title: "SSL Certificate", description: "Proper HTTPS for production", column: "ready", projectId: "infrastructure", approved: false, implemented: false },
|
||||
{ id: "i5", title: "Email Notifications", description: "System sends emails for important events", column: "in-progress", projectId: "infrastructure", approved: false, implemented: false },
|
||||
];
|
||||
|
||||
const PROJECTS = [
|
||||
{ id: "sitemente-v1", name: "SiteMente v1", emoji: "🏢", description: "AI website platform for local businesses", color: "#8B5CF6" },
|
||||
{ id: "holacompi-v1", name: "HolaCompi v1", emoji: "🤝", description: "AI ally for immigrants & consumers", color: "#EC4899" },
|
||||
{ id: "infrastructure", name: "Infrastructure", emoji: "⚙️", description: "DevOps, deployment & maintenance", color: "#10B981" },
|
||||
];
|
||||
|
||||
export default function MondayBoard() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [selectedProject, setSelectedProject] = useState<string>("sitemente-v1");
|
||||
const [features, setFeatures] = useState<Feature[]>([]);
|
||||
const [showApproval, setShowApproval] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
setFeatures(PROJECT_FEATURES_INITIAL);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-white/60">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const projectFeatures = features.filter(f => f.projectId === selectedProject);
|
||||
|
||||
const getColumnFeatures = (columnId: string) => {
|
||||
return projectFeatures.filter(f => f.column === columnId);
|
||||
};
|
||||
|
||||
const approveFeature = (featureId: string) => {
|
||||
setFeatures(prev => prev.map(f =>
|
||||
f.id === featureId ? { ...f, approved: true, column: "ready" } : f
|
||||
));
|
||||
setShowApproval(null);
|
||||
};
|
||||
|
||||
const markImplemented = (featureId: string) => {
|
||||
setFeatures(prev => prev.map(f =>
|
||||
f.id === featureId ? { ...f, implemented: true, column: "done" } : f
|
||||
));
|
||||
};
|
||||
|
||||
const getProgress = () => {
|
||||
const total = projectFeatures.length;
|
||||
const done = projectFeatures.filter(f => f.column === "done").length;
|
||||
return total > 0 ? Math.round((done / total) * 100) : 0;
|
||||
};
|
||||
|
||||
const currentFeature = showApproval ? features.find(f => f.id === showApproval) : null;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Project Selector */}
|
||||
<div className="flex items-center gap-3 p-4 border-b border-white/10">
|
||||
<span className="text-white/60 text-sm">Project:</span>
|
||||
<div className="flex gap-2">
|
||||
{PROJECTS.map(proj => (
|
||||
<button
|
||||
key={proj.id}
|
||||
onClick={() => setSelectedProject(proj.id)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition ${
|
||||
selectedProject === proj.id
|
||||
? "bg-white/20 text-white"
|
||||
: "text-white/60 hover:text-white hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
<span>{proj.emoji}</span>
|
||||
<span>{proj.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<div className="w-32 h-2 bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-brand-pink transition-all"
|
||||
style={{ width: `${getProgress()}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-white/60">{getProgress()}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Board */}
|
||||
<div className="flex-1 overflow-x-auto p-4">
|
||||
<div className="flex gap-3 min-w-max h-full">
|
||||
{COLUMNS.map(column => {
|
||||
const columnFeatures = getColumnFeatures(column.id);
|
||||
return (
|
||||
<div
|
||||
key={column.id}
|
||||
className="w-72 flex-shrink-0 flex flex-col rounded-xl bg-white/5 border border-white/10"
|
||||
>
|
||||
{/* Column Header */}
|
||||
<div
|
||||
className="p-3 rounded-t-xl border-b border-white/10"
|
||||
style={{ backgroundColor: `${column.color}20` }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold text-sm" style={{ color: column.color }}>
|
||||
{column.name}
|
||||
</span>
|
||||
<span className="text-xs bg-white/10 px-2 py-0.5 rounded-full text-white/70">
|
||||
{columnFeatures.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features List */}
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-2">
|
||||
{columnFeatures.map(feature => (
|
||||
<div
|
||||
key={feature.id}
|
||||
className={`p-3 rounded-lg border transition-all cursor-pointer ${
|
||||
feature.approved
|
||||
? "bg-brand-pink/20 border-brand-pink/50"
|
||||
: feature.implemented
|
||||
? "bg-green-500/20 border-green-500/50"
|
||||
: "bg-white/5 border-white/10 hover:border-white/30"
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (!feature.approved && !feature.implemented) {
|
||||
setShowApproval(feature.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium text-white">{feature.title}</h4>
|
||||
<p className="text-xs text-white/50 mt-1">{feature.description}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{feature.approved && !feature.implemented && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
markImplemented(feature.id);
|
||||
}}
|
||||
className="text-xs bg-green-500/30 hover:bg-green-500/50 text-green-300 px-2 py-1 rounded transition"
|
||||
>
|
||||
✓ Done
|
||||
</button>
|
||||
)}
|
||||
{feature.approved && (
|
||||
<span className="text-xs text-brand-pink">✓ Approved</span>
|
||||
)}
|
||||
{feature.implemented && (
|
||||
<span className="text-xs text-green-400">🎉 Live</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{columnFeatures.length === 0 && (
|
||||
<div className="text-center text-white/30 text-xs py-4">
|
||||
No items
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Approval Modal */}
|
||||
{showApproval && currentFeature && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50"
|
||||
onClick={() => setShowApproval(null)}
|
||||
>
|
||||
<div
|
||||
className="bg-[#1a1625] border border-white/20 rounded-2xl p-6 max-w-md w-full mx-4"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-xl font-bold text-white mb-2">🚀 Approve Feature?</h3>
|
||||
<p className="text-white/70 mb-4">{currentFeature.description}</p>
|
||||
|
||||
<div className="bg-white/5 rounded-xl p-4 mb-6">
|
||||
<h4 className="text-sm font-semibold text-brand-pink mb-2">{currentFeature.title}</h4>
|
||||
<p className="text-xs text-white/50">{currentFeature.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowApproval(null)}
|
||||
className="flex-1 px-4 py-2 rounded-lg border border-white/20 text-white/70 hover:bg-white/10 transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => approveFeature(currentFeature.id)}
|
||||
className="flex-1 px-4 py-2 rounded-lg bg-brand-pink text-white font-semibold hover:bg-[#ff7bc0] transition"
|
||||
>
|
||||
✅ Approve
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-white/40 mt-4 text-center">
|
||||
Clicking approve will move this to Ready column for implementation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Task, TaskStatus } from '@/lib/mission-control/types'
|
||||
|
||||
interface TaskCardsPanelProps {
|
||||
tasks: Task[]
|
||||
toggleTask: (id: string) => void
|
||||
}
|
||||
|
||||
const projects = ['sitemente', 'holacompi', 'arabredox', 'infrastructure', 'trading']
|
||||
|
||||
export function TaskCardsPanel({ tasks, toggleTask }: TaskCardsPanelProps) {
|
||||
const [selectedProject, setSelectedProject] = useState<string>('sitemente')
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null)
|
||||
const [command, setCommand] = useState('')
|
||||
const [responses, setResponses] = useState<{id: string, task: string, command: string, reply: string, createdAt: string}[]>([])
|
||||
|
||||
// Poll for replies every 10 seconds
|
||||
useEffect(() => {
|
||||
const pollReplies = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/command-history')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
// Get entries with replies that haven't been shown
|
||||
const withReplies = (data.history || []).filter((h: any) => h.reply && h.reply.length > 0)
|
||||
setResponses(withReplies.slice(-5).reverse())
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
pollReplies()
|
||||
const interval = setInterval(pollReplies, 10000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const projectTasks = tasks.filter(t =>
|
||||
t.project === selectedProject &&
|
||||
(t.status === 'todo' || t.status === 'in_progress')
|
||||
)
|
||||
|
||||
const handleTaskClick = (taskId: string) => {
|
||||
setSelectedTaskId(selectedTaskId === taskId ? null : taskId)
|
||||
setCommand('')
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!selectedTaskId || !command.trim()) return
|
||||
|
||||
const task = tasks.find(t => t.id === selectedTaskId)
|
||||
if (!task) return
|
||||
|
||||
fetch('/api/command-history', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
task: task.title,
|
||||
command: command,
|
||||
project: selectedProject,
|
||||
action: 'continue-task'
|
||||
})
|
||||
}).catch(() => {})
|
||||
|
||||
fetch('/api/command', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
command: command,
|
||||
task: task.title,
|
||||
action: 'continue-task'
|
||||
})
|
||||
}).catch(() => {})
|
||||
|
||||
alert(`✅ Sent to Horus: "${command}"`)
|
||||
setCommand('')
|
||||
setSelectedTaskId(null)
|
||||
}
|
||||
|
||||
const handleQuickChat = () => {
|
||||
if (!command.trim()) return
|
||||
|
||||
// Save quick chat to history - this will notify Horus via the API
|
||||
fetch('/api/command-history', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
task: `Quick message - ${selectedProject}`,
|
||||
command: command,
|
||||
project: selectedProject,
|
||||
action: 'quick-message'
|
||||
})
|
||||
}).catch(() => {})
|
||||
|
||||
alert(`✅ Sent to Horus! I'll reply shortly.`)
|
||||
setCommand('')
|
||||
}
|
||||
|
||||
const selectedTask = tasks.find(t => t.id === selectedTaskId)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Project Tabs */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{projects.map(project => {
|
||||
const count = tasks.filter(t =>
|
||||
t.project === project &&
|
||||
(t.status === 'todo' || t.status === 'in_progress')
|
||||
).length
|
||||
|
||||
return (
|
||||
<button
|
||||
key={project}
|
||||
onClick={() => {
|
||||
setSelectedProject(project)
|
||||
setSelectedTaskId(null)
|
||||
}}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition ${
|
||||
selectedProject === project
|
||||
? 'bg-brand-pink text-white'
|
||||
: 'bg-white/10 text-white/70 hover:bg-white/20'
|
||||
}`}
|
||||
>
|
||||
{project.charAt(0).toUpperCase() + project.slice(1)}
|
||||
<span className="ml-2 text-xs opacity-70">({count})</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Task Cards */}
|
||||
<div className="border border-white/20 rounded-lg p-4 bg-white/5">
|
||||
<h3 className="text-lg font-bold mb-4">
|
||||
📋 {selectedProject.toUpperCase()} TASKS ({projectTasks.length})
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
{projectTasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className={`p-3 rounded-lg border transition-all ${
|
||||
selectedTaskId === task.id
|
||||
? 'border-brand-pink bg-brand-pink/10'
|
||||
: 'border-white/10 bg-white/5 hover:border-white/30'
|
||||
}`}
|
||||
onClick={() => handleTaskClick(task.id)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleTask(task.id)
|
||||
}}
|
||||
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center transition ${
|
||||
task.status === 'done'
|
||||
? 'border-green-500 bg-green-500'
|
||||
: task.status === 'in_progress'
|
||||
? 'border-yellow-500 bg-yellow-500'
|
||||
: 'border-white/30 hover:border-white/50'
|
||||
}`}
|
||||
>
|
||||
{task.status === 'done' && (
|
||||
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex-1">
|
||||
<p className={`font-medium ${task.status === 'done' ? 'line-through opacity-50' : ''}`}>
|
||||
{task.title}
|
||||
</p>
|
||||
{task.description && (
|
||||
<p className="text-xs text-white/50 truncate">{task.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="text-lg">
|
||||
{selectedTaskId === task.id ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Expanded Input */}
|
||||
{selectedTaskId === task.id && (
|
||||
<div className="mt-3 ml-8">
|
||||
<textarea
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
placeholder={`What to do with "${task.title}"?`}
|
||||
className="w-full px-3 py-2 bg-black/50 border border-white/20 rounded text-sm text-white placeholder:text-white/40 focus:outline-none focus:border-brand-pink resize-none"
|
||||
rows={3}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleConfirm()
|
||||
}}
|
||||
disabled={!command.trim()}
|
||||
className={`px-4 py-1.5 rounded text-xs font-bold ${
|
||||
command.trim()
|
||||
? 'bg-brand-pink text-white hover:bg-[#ff7bc0]'
|
||||
: 'bg-white/10 text-white/30 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
▶ SEND TO HORUS
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setSelectedTaskId(null)
|
||||
setCommand('')
|
||||
}}
|
||||
className="px-3 py-1.5 text-xs text-white/60 hover:text-white"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{projectTasks.length === 0 && (
|
||||
<p className="text-white/50 text-center py-4">No pending tasks ✓</p>
|
||||
)}
|
||||
|
||||
{/* Quick Chat - always available */}
|
||||
<div className="mt-4 pt-4 border-t border-white/10">
|
||||
<p className="text-xs text-white/50 mb-2">💬 Quick message for {selectedProject}</p>
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
placeholder={`New task or message for ${selectedProject}...`}
|
||||
className="flex-1 px-3 py-2 bg-black/50 border border-white/20 rounded text-sm text-white placeholder:text-white/40 focus:outline-none focus:border-brand-pink resize-none"
|
||||
rows={2}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleQuickChat()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleQuickChat}
|
||||
disabled={!command.trim()}
|
||||
className={`px-4 rounded text-sm font-bold ${
|
||||
command.trim()
|
||||
? 'bg-brand-pink text-white hover:bg-[#ff7bc0]'
|
||||
: 'bg-white/10 text-white/30 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
➤
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Responses from Horus */}
|
||||
{responses.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-white/10">
|
||||
<p className="text-xs text-white/50 mb-2">💬 Recent replies from Horus</p>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{responses.map((r) => (
|
||||
<div key={r.id} className="p-2 rounded bg-brand-pink/10 border border-brand-pink/30 text-sm">
|
||||
<p className="text-white/60 text-xs">You: {r.command}</p>
|
||||
<p className="text-brand-pink mt-1">👁️ {r.reply}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface HistoryEntry {
|
||||
id: string
|
||||
task: string
|
||||
command: string
|
||||
project: string
|
||||
action: string
|
||||
createdAt: string
|
||||
status: string
|
||||
reply?: string
|
||||
}
|
||||
|
||||
const projects = ['all', 'sitemente', 'holacompi', 'arabredox', 'infrastructure', 'trading']
|
||||
|
||||
export function TaskHistoryPanel() {
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedProject, setSelectedProject] = useState<string>('all')
|
||||
|
||||
useEffect(() => {
|
||||
loadHistory()
|
||||
}, [selectedProject])
|
||||
|
||||
const loadHistory = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const url = selectedProject === 'all'
|
||||
? '/api/command-history'
|
||||
: `/api/command-history?project=${selectedProject}`
|
||||
const response = await fetch(url)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setHistory(data.history || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load history:', error)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Project Filter Tabs */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{projects.map(project => (
|
||||
<button
|
||||
key={project}
|
||||
onClick={() => setSelectedProject(project)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition ${
|
||||
selectedProject === project
|
||||
? 'bg-brand-pink text-white'
|
||||
: 'bg-white/10 text-white/70 hover:bg-white/20'
|
||||
}`}
|
||||
>
|
||||
{project === 'all' ? 'All Projects' : project.charAt(0).toUpperCase() + project.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* History List */}
|
||||
<div className="border border-white/20 rounded-lg p-4 bg-white/5">
|
||||
<h3 className="text-lg font-bold mb-4 flex items-center gap-2">
|
||||
📜 TASK HISTORY
|
||||
<span className="text-sm font-normal text-white/50">({history.length} entries)</span>
|
||||
</h3>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-white/50 text-center py-8">Loading...</p>
|
||||
) : history.length === 0 ? (
|
||||
<p className="text-white/50 text-center py-8">
|
||||
No history for this project.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4 max-h-[500px] overflow-y-auto">
|
||||
{history.slice().reverse().map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="p-4 rounded-lg bg-white/5 border border-white/10"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<p className="font-medium text-brand-pink">{entry.task}</p>
|
||||
<p className="text-xs text-white/40">
|
||||
{new Date(entry.createdAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2 py-1 rounded text-xs bg-white/10 text-white/70">
|
||||
{entry.project}
|
||||
</span>
|
||||
<span className={`px-2 py-1 rounded text-xs ${
|
||||
entry.status === 'completed'
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: 'bg-white/10 text-white/50'
|
||||
}`}>
|
||||
{entry.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 p-3 rounded bg-black/30 border border-white/10">
|
||||
<p className="text-xs text-white/50 mb-1">You sent:</p>
|
||||
<p className="text-sm">{entry.command}</p>
|
||||
</div>
|
||||
|
||||
{entry.reply && (
|
||||
<div className="mt-2 p-3 rounded bg-brand-pink/10 border border-brand-pink/30">
|
||||
<p className="text-xs text-brand-pink mb-1">Horus replied:</p>
|
||||
<p className="text-sm text-white/90">{entry.reply}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
interface Trade {
|
||||
id: string
|
||||
date: string
|
||||
pair: string
|
||||
direction: 'long' | 'short'
|
||||
entry: number
|
||||
stopLoss: number
|
||||
takeProfit: number
|
||||
result?: 'win' | 'loss' | 'open'
|
||||
pnl?: number
|
||||
rr?: number
|
||||
}
|
||||
|
||||
interface ChartData {
|
||||
time: number
|
||||
open: number
|
||||
high: number
|
||||
low: number
|
||||
close: number
|
||||
volume?: number
|
||||
}
|
||||
|
||||
interface ThothView {
|
||||
thought: string
|
||||
trend: string
|
||||
phase: string
|
||||
key_level: number
|
||||
bias: string
|
||||
confidence: number
|
||||
reason: string
|
||||
next_action: string
|
||||
updated_at: string
|
||||
bias_history?: { time: number; bias: string; price: number }[]
|
||||
support_zones?: { level: number; strength: number }[]
|
||||
resistance_zones?: { level: number; strength: number }[]
|
||||
}
|
||||
|
||||
interface PriceData {
|
||||
price: number
|
||||
change24h: number
|
||||
}
|
||||
|
||||
interface IndicatorState {
|
||||
ema20: boolean
|
||||
ema50: boolean
|
||||
ema200: boolean
|
||||
bb: boolean
|
||||
rsi: boolean
|
||||
macd: boolean
|
||||
thoth: boolean
|
||||
volume: boolean
|
||||
srZones: boolean
|
||||
news: boolean
|
||||
patterns: boolean
|
||||
fib: boolean
|
||||
countdown: boolean
|
||||
calendar: boolean
|
||||
correlation: boolean
|
||||
funding: boolean
|
||||
}
|
||||
|
||||
interface PatternMatch {
|
||||
index: number
|
||||
type: string
|
||||
}
|
||||
|
||||
// Indicator calculations
|
||||
const calculateEMA = (data: number[], period: number): (number | null)[] => {
|
||||
const ema: (number | null)[] = []
|
||||
const multiplier = 2 / (period + 1)
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (i < period - 1) ema.push(null)
|
||||
else if (i === period - 1) {
|
||||
let sum = 0; for (let j = 0; j < period; j++) sum += data[i - j]
|
||||
ema.push(sum / period)
|
||||
} else {
|
||||
ema.push(data[i] * multiplier + ema[i - 1]! * (1 - multiplier))
|
||||
}
|
||||
}
|
||||
return ema
|
||||
}
|
||||
|
||||
const calculateSMA = (data: number[], period: number): (number | null)[] => {
|
||||
const sma: (number | null)[] = []
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (i < period - 1) sma.push(null)
|
||||
else { let sum = 0; for (let j = 0; j < period; j++) sum += data[i - j]; sma.push(sum / period) }
|
||||
}
|
||||
return sma
|
||||
}
|
||||
|
||||
const calculateStdDev = (data: number[], period: number): (number | null)[] => {
|
||||
const stdDev: (number | null)[] = []
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (i < period - 1) stdDev.push(null)
|
||||
else {
|
||||
const slice = data.slice(i - period + 1, i + 1)
|
||||
const mean = slice.reduce((a, b) => a + b, 0) / period
|
||||
stdDev.push(Math.sqrt(slice.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / period))
|
||||
}
|
||||
}
|
||||
return stdDev
|
||||
}
|
||||
|
||||
const calculateBollingerBands = (data: number[], period: number = 20, stdDevMult: number = 2) => {
|
||||
const sma = calculateSMA(data, period)
|
||||
const stdDev = calculateStdDev(data, period)
|
||||
const upper: (number | null)[] = [], lower: (number | null)[] = []
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (sma[i] === null || stdDev[i] === null) { upper.push(null); lower.push(null) }
|
||||
else { upper.push(sma[i]! + stdDev[i]! * stdDevMult); lower.push(sma[i]! - stdDev[i]! * stdDevMult) }
|
||||
}
|
||||
return { middle: sma, upper, lower }
|
||||
}
|
||||
|
||||
const calculateRSI = (data: number[], period: number = 14): (number | null)[] => {
|
||||
const rsi: (number | null)[] = []
|
||||
let gains: number[] = [], losses: number[] = []
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (i === 0) { rsi.push(null); continue }
|
||||
const change = data[i] - data[i - 1]
|
||||
gains.push(change > 0 ? change : 0)
|
||||
losses.push(change < 0 ? Math.abs(change) : 0)
|
||||
if (i < period) rsi.push(null)
|
||||
else {
|
||||
const avgGain = gains.slice(-period).reduce((a, b) => a + b, 0) / period
|
||||
const avgLoss = losses.slice(-period).reduce((a, b) => a + b, 0) / period
|
||||
const rs = avgLoss === 0 ? 100 : avgGain / avgLoss
|
||||
rsi.push(100 - (100 / (1 + rs)))
|
||||
}
|
||||
}
|
||||
return rsi
|
||||
}
|
||||
|
||||
const calculateMACD = (data: number[], fast: number = 12, slow: number = 26, signal: number = 9) => {
|
||||
const emaFast = calculateEMA(data, fast)
|
||||
const emaSlow = calculateEMA(data, slow)
|
||||
const macdLine: (number | null)[] = []
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (emaFast[i] === null || emaSlow[i] === null) macdLine.push(null)
|
||||
else macdLine.push(emaFast[i]! - emaSlow[i]!)
|
||||
}
|
||||
const validMacd = macdLine.filter((v): v is number => v !== null)
|
||||
const signalLine = calculateEMA(validMacd, signal)
|
||||
const signalLineAligned: (number | null)[] = []
|
||||
let signalIdx = 0
|
||||
for (let i = 0; i < macdLine.length; i++) {
|
||||
if (macdLine[i] === null) signalLineAligned.push(null)
|
||||
else { signalLineAligned.push(signalLine[signalIdx] ?? null); signalIdx++ }
|
||||
}
|
||||
const histogram: (number | null)[] = []
|
||||
for (let i = 0; i < macdLine.length; i++) {
|
||||
if (macdLine[i] === null || signalLineAligned[i] === null) histogram.push(null)
|
||||
else histogram.push(macdLine[i]! - signalLineAligned[i]!)
|
||||
}
|
||||
return { macd: macdLine, signal: signalLineAligned, histogram }
|
||||
}
|
||||
|
||||
const detectPatterns = (data: ChartData[]): PatternMatch[] => {
|
||||
const patterns: PatternMatch[] = []
|
||||
const closes = data.map(d => d.close)
|
||||
const highs = data.map(d => d.high)
|
||||
const lows = data.map(d => d.low)
|
||||
for (let i = 20; i < data.length - 5; i++) {
|
||||
if (highs[i] > highs[i-1] && highs[i] > highs[i+1] && highs[i-5] > highs[i-4] && Math.abs(highs[i] - highs[i-5]) < highs[i] * 0.02)
|
||||
patterns.push({ index: i, type: 'double_top' })
|
||||
if (lows[i] < lows[i-1] && lows[i] < lows[i+1] && lows[i-5] < lows[i-4] && Math.abs(lows[i] - lows[i-5]) < lows[i] * 0.02)
|
||||
patterns.push({ index: i, type: 'double_bottom' })
|
||||
if (closes[i] > highs[i-5] && closes[i-1] < highs[i-5]) patterns.push({ index: i, type: 'breakout' })
|
||||
if (closes[i] < lows[i-5] && closes[i-1] > lows[i-5]) patterns.push({ index: i, type: 'breakdown' })
|
||||
}
|
||||
return patterns
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Chart: any
|
||||
}
|
||||
}
|
||||
|
||||
export function TradingChart() {
|
||||
const mainChartRef = useRef<HTMLCanvasElement>(null)
|
||||
const rsiChartRef = useRef<HTMLCanvasElement>(null)
|
||||
const macdChartRef = useRef<HTMLCanvasElement>(null)
|
||||
const [selectedAsset, setSelectedAsset] = useState<'BTC' | 'SOL' | 'ETH'>('BTC')
|
||||
const [selectedTimeframe, setSelectedTimeframe] = useState<'15m' | '1h' | '4h' | '1D'>('1h')
|
||||
const [secondTimeframe, setSecondTimeframe] = useState<'15m' | '1h' | '4h' | '1D' | null>(null)
|
||||
const [chartData, setChartData] = useState<ChartData[]>([])
|
||||
const [secondChartData, setSecondChartData] = useState<ChartData[]>([])
|
||||
const [trades, setTrades] = useState<Trade[]>([])
|
||||
const [thothView, setThothView] = useState<Record<string, ThothView>>({})
|
||||
const [priceData, setPriceData] = useState<PriceData>({ price: 0, change24h: 0 })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [patterns, setPatterns] = useState<PatternMatch[]>([])
|
||||
const [indicators, setIndicators] = useState<IndicatorState>({
|
||||
ema20: false, ema50: false, ema200: false, bb: false, rsi: false, macd: false, thoth: true, volume: true, srZones: true, news: false, patterns: true, fib: false, countdown: true, calendar: false, correlation: false, funding: false
|
||||
})
|
||||
const mainChartRefInstance = useRef<any>(null)
|
||||
const rsiChartRefInstance = useRef<any>(null)
|
||||
const macdChartRefInstance = useRef<any>(null)
|
||||
|
||||
const getCandleLimit = (tf: string) => ({ '15m': 80, '1h': 100, '4h': 60, '1D': 90 }[tf] || 100)
|
||||
|
||||
useEffect(() => { fetchChartData(); fetchSecondChartData(); fetchPriceData(); const i = setInterval(() => { fetchChartData(); fetchSecondChartData(); fetchPriceData(); }, 60000); return () => clearInterval(i) }, [selectedAsset, selectedTimeframe, secondTimeframe])
|
||||
useEffect(() => { fetchTrades(); fetchThothView() }, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (chartData.length > 0) { setPatterns(detectPatterns(chartData)); renderCharts() }
|
||||
return () => { if (mainChartRefInstance.current) mainChartRefInstance.current.destroy(); if (rsiChartRefInstance.current) rsiChartRefInstance.current.destroy(); if (macdChartRefInstance.current) macdChartRefInstance.current.destroy() }
|
||||
}, [chartData, indicators])
|
||||
|
||||
const fetchPriceData = async () => {
|
||||
try {
|
||||
const idMap: Record<string, string> = { 'BTC': 'bitcoin', 'SOL': 'solana', 'ETH': 'ethereum' }
|
||||
const res = await fetch(`https://api.coingecko.com/api/v3/simple/price?ids=${idMap[selectedAsset]}&vs_currencies=usd&include_24hr_change=true`)
|
||||
const data = await res.json()
|
||||
setPriceData({ price: data[idMap[selectedAsset]].usd, change24h: data[idMap[selectedAsset]].usd_24h_change })
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
const fetchChartData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const symbol = selectedAsset === 'BTC' ? 'BTCUSDT' : selectedAsset === 'SOL' ? 'SOLUSDT' : 'ETHUSDT'
|
||||
const res = await fetch(`https://api.binance.com/api/v3/klines?symbol=${symbol}&interval={${{ '15m': '15', '1h': '1h', '4h': '4h', '1D': '1d' }[selectedTimeframe]}&limit=${getCandleLimit(selectedTimeframe)}`)
|
||||
const data = await res.json()
|
||||
setChartData(data.map((k: any[]) => ({ time: k[0], open: parseFloat(k[1]), high: parseFloat(k[2]), low: parseFloat(k[3]), close: parseFloat(k[4]), volume: parseFloat(k[5]) })))
|
||||
} catch (e) { console.error(e) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
const fetchSecondChartData = async () => {
|
||||
if (!secondTimeframe) return
|
||||
try {
|
||||
const symbol = selectedAsset === 'BTC' ? 'BTCUSDT' : selectedAsset === 'SOL' ? 'SOLUSDT' : 'ETHUSDT'
|
||||
const res = await fetch(`https://api.binance.com/api/v3/klines?symbol=${symbol}&interval={${{ '15m': '15', '1h': '1h', '4h': '4h', '1D': '1d' }[secondTimeframe]}&limit=${getCandleLimit(secondTimeframe)}`)
|
||||
const data = await res.json()
|
||||
setSecondChartData(data.map((k: any[]) => ({ time: k[0], open: parseFloat(k[1]), high: parseFloat(k[2]), low: parseFloat(k[3]), close: parseFloat(k[4]) })))
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
const fetchTrades = async () => { try { const res = await fetch('/api/trading/trades'); if (res.ok) setTrades((await res.json()).trades || []) } catch (e) { console.warn(e) } }
|
||||
const fetchThothView = async () => { try { const res = await fetch('/thoth_view.json'); if (res.ok) setThothView(await res.json()) } catch (e) { console.warn(e) } }
|
||||
const toggleIndicator = (key: keyof IndicatorState) => setIndicators(p => ({ ...p, [key]: !p[key] }))
|
||||
|
||||
const renderCharts = () => {
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/chart.js'
|
||||
script.onload = () => { renderMainChart(); if (indicators.rsi) renderRSIChart(); if (indicators.macd) renderMACDChart() }
|
||||
document.head.appendChild(script)
|
||||
}
|
||||
|
||||
const renderMainChart = () => {
|
||||
if (!mainChartRef.current) return
|
||||
if (mainChartRefInstance.current) mainChartRefInstance.current.destroy()
|
||||
const ctx = mainChartRef.current.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const closes = chartData.map(d => d.close)
|
||||
const labels = chartData.map(d => new Date(d.time))
|
||||
const ema20 = calculateEMA(closes, 20), ema50 = calculateEMA(closes, 50), ema200 = calculateEMA(closes, 200), bb = calculateBollingerBands(closes, 20, 2)
|
||||
const minP = Math.min(...chartData.flatMap(d => [d.high, d.low])), maxP = Math.max(...chartData.flatMap(d => [d.high, d.low])), pad = (maxP - minP) * 0.15
|
||||
|
||||
const datasets: any[] = [{ type: 'bar', label: 'Price', data: chartData.map(d => [d.low, d.high]), backgroundColor: chartData.map(d => d.close >= d.open ? '#22c55e' : '#ef4444'), borderColor: chartData.map(d => d.close >= d.open ? '#22c55e' : '#ef4444'), borderWidth: 1, borderSkipped: false }]
|
||||
|
||||
if (indicators.volume && chartData[0]?.volume) datasets.push({ type: 'bar', label: 'Volume', data: chartData.map(d => d.volume || 0), backgroundColor: chartData.map(d => d.close >= d.open ? 'rgba(34,197,94,0.3)' : 'rgba(239,68,68,0.3)'), borderWidth: 0, yAxisID: 'y_vol' })
|
||||
if (indicators.ema20) datasets.push({ type: 'line', data: ema20, borderColor: '#eab308', borderWidth: 2, pointRadius: 0, tension: 0.4, yAxisID: 'y' })
|
||||
if (indicators.ema50) datasets.push({ type: 'line', data: ema50, borderColor: '#3b82f6', borderWidth: 2, pointRadius: 0, tension: 0.4, yAxisID: 'y' })
|
||||
if (indicators.ema200) datasets.push({ type: 'line', data: ema200, borderColor: '#ffffff', borderWidth: 2, pointRadius: 0, tension: 0.4, yAxisID: 'y' })
|
||||
if (indicators.bb) { datasets.push({ type: 'line', data: bb.upper, borderColor: '#a855f7', borderWidth: 1, pointRadius: 0, yAxisID: 'y' }); datasets.push({ type: 'line', data: bb.lower, borderColor: '#a855f7', borderWidth: 1, pointRadius: 0, backgroundColor: 'rgba(168,85,247,0.1)', fill: '-1', yAxisID: 'y' }) }
|
||||
|
||||
const currentView = thothView[selectedAsset]
|
||||
if (indicators.srZones && currentView?.support_zones) currentView.support_zones.forEach(z => datasets.push({ type: 'line', data: chartData.map(() => z.level), borderColor: 'rgba(34,197,94,0.5)', borderWidth: 2, borderDash: [5,5], pointRadius: 0, yAxisID: 'y' }))
|
||||
if (indicators.srZones && currentView?.resistance_zones) currentView.resistance_zones.forEach(z => datasets.push({ type: 'line', data: chartData.map(() => z.level), borderColor: 'rgba(239,68,68,0.5)', borderWidth: 2, borderDash: [5,5], pointRadius: 0, yAxisID: 'y' }))
|
||||
|
||||
if (indicators.patterns && patterns.length) datasets.push({ type: 'scatter', data: patterns.map(p => ({ x: labels[p.index], y: chartData[p.index]?.high || 0 })), backgroundColor: patterns.map(p => p.type === 'breakout' ? '#22c55e' : p.type === 'breakdown' ? '#ef4444' : '#fbbf24'), pointStyle: 'star', pointRadius: 10, yAxisID: 'y' })
|
||||
if (indicators.thoth && currentView?.bias_history) {
|
||||
const bc = currentView.bias_history.map(b => { let ci = 0, md = Infinity; chartData.forEach((d, i) => { const df = Math.abs(d.time - b.time); if (df < md) { md = df; ci = i } }); return { idx: ci, bias: b.bias } })
|
||||
datasets.push({ type: 'scatter', data: bc.map(b => ({ x: labels[b.idx], y: chartData[b.idx]?.high || 0 })), backgroundColor: bc.map(b => b.bias === 'bullish' ? '#fbbf24' : b.bias === 'bearish' ? '#ef4444' : '#a0a0a0'), pointStyle: 'rectRot', pointRadius: 12, yAxisID: 'y' })
|
||||
}
|
||||
|
||||
const scales: any = { x: { display: true, grid: { color: '#1a1a2e' }, ticks: { color: '#a0a0a0', maxTicksLimit: 12 } }, y: { position: 'right', min: minP - pad, max: maxP + pad, grid: { color: '#1a1a2e' }, ticks: { color: '#a0a0a0', callback: (v: any) => '$' + v.toFixed(0) } } }
|
||||
if (indicators.volume && chartData[0]?.volume) scales.y_vol = { display: false, max: Math.max(...chartData.map(d => d.volume || 0)) * 3 }
|
||||
|
||||
mainChartRefInstance.current = new window.Chart(ctx, { type: 'bar', data: { labels, datasets }, options: { responsive: true, maintainAspectRatio: false, interaction: { intersect: false, mode: 'index' }, plugins: { legend: { display: false }, tooltip: { backgroundColor: '#1a1a2e', titleColor: '#fff', bodyColor: '#a0a0a0', borderColor: '#2a2a4e', borderWidth: 1 } }, scales } })
|
||||
}
|
||||
|
||||
const renderRSIChart = () => {
|
||||
if (!rsiChartRef.current) return
|
||||
if (rsiChartRefInstance.current) rsiChartRefInstance.current.destroy()
|
||||
const ctx = rsiChartRef.current.getContext('2d')
|
||||
if (!ctx) return
|
||||
const rsi = calculateRSI(chartData.map(d => d.close), 14)
|
||||
rsiChartRefInstance.current = new window.Chart(ctx, { type: 'line', data: { labels: chartData.map(d => d.time), datasets: [{ data: rsi, borderColor: '#a0a0a0', borderWidth: 2, pointRadius: 0, tension: 0.4 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { display: false }, y: { min: 0, max: 100, position: 'right', grid: { color: '#1a1a2e' }, ticks: { color: '#a0a0a0' } } } } })
|
||||
}
|
||||
|
||||
const renderMACDChart = () => {
|
||||
if (!macdChartRef.current) return
|
||||
if (macdChartRefInstance.current) macdChartRefInstance.current.destroy()
|
||||
const ctx = macdChartRef.current.getContext('2d')
|
||||
if (!ctx) return
|
||||
const { macd, signal, histogram } = calculateMACD(chartData.map(d => d.close))
|
||||
macdChartRefInstance.current = new window.Chart(ctx, { type: 'bar', data: { labels: chartData.map(d => d.time), datasets: [{ data: histogram, backgroundColor: histogram.map(v => v === null ? 'transparent' : v >= 0 ? '#22c55e' : '#ef4444'), borderWidth: 0 }, { type: 'line', data: macd, borderColor: '#3b82f6', borderWidth: 2, pointRadius: 0 }, { type: 'line', data: signal, borderColor: '#f59e0b', borderWidth: 2, pointRadius: 0 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { display: false }, y: { position: 'right', grid: { color: '#1a1a2e' }, ticks: { color: '#a0a0a0' } } } } })
|
||||
}
|
||||
|
||||
const closedTrades = trades.filter(t => t.result === 'win' || t.result === 'loss')
|
||||
const wins = closedTrades.filter(t => t.result === 'win').length, winRate = closedTrades.length ? Math.round(wins / closedTrades.length * 100) : 0, totalPnl = closedTrades.reduce((s, t) => s + (t.pnl || 0), 0), avgRr = closedTrades.length ? closedTrades.reduce((s, t) => s + (t.rr || 0), 0) / closedTrades.length : 0
|
||||
const cv = thothView[selectedAsset]
|
||||
const getTE = (t: string) => t === 'uptrend' ? '🟢' : t === 'downtrend' ? '🔴' : '⚪️'
|
||||
const getBC = (b: string) => b === 'bullish' ? 'text-green-400' : b === 'bearish' ? 'text-red-400' : 'text-yellow-400'
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-4 justify-between flex-wrap">
|
||||
<div className="flex gap-2">{(['BTC', 'SOL', 'ETH'] as const).map(a => <button key={a} onClick={() => setSelectedAsset(a)} className={`px-4 py-2 rounded-lg font-medium ${selectedAsset === a ? 'bg-brand-pink text-white' : 'bg-white/10 text-white/70'}`}>{a}</button>)}</div>
|
||||
<div className="flex gap-2">{(['15m', '1h', '4h', '1D'] as const).map(tf => <button key={tf} onClick={() => setSelectedTimeframe(tf)} className={`px-3 py-1 rounded text-sm ${selectedTimeframe === tf ? 'bg-green-500/20 text-green-400 border border-green-500/30' : 'bg-white/10 text-white/50'}`}>{tf}</button>)}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center"><span className="text-white/50 text-sm">Compare:</span><button onClick={() => setSecondTimeframe(null)} className={`px-2 py-1 rounded text-xs ${!secondTimeframe ? 'bg-brand-pink text-white' : 'bg-white/10 text-white/50'}`}>None</button>{(['15m', '1h', '4h', '1D'] as const).map(tf => <button key={tf} onClick={() => setSecondTimeframe(tf)} className={`px-2 py-1 rounded text-xs ${secondTimeframe === tf ? 'bg-brand-pink text-white' : 'bg-white/10 text-white/50'}`}>{tf}</button>)}</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">{(Object.keys(indicators) as (keyof IndicatorState)[]).map(k => <button key={k} onClick={() => toggleIndicator(k)} className={`px-3 py-1 rounded text-sm ${indicators[k] ? 'bg-brand-pink text-white' : 'bg-white/10 text-white/50'}`}>{k === 'thoth' ? '👁 THOTH' : k === 'srZones' ? 'S/R' : k === 'volume' ? 'VOL' : k === 'news' ? 'NEWS' : k === 'patterns' ? 'PATTERNS' : k === 'fib' ? 'FIB' : k === 'countdown' ? '⏱️' : k === 'calendar' ? '📅' : k === 'correlation' ? '📊 CORR' : k === 'funding' ? '💰 FUND' : k.toUpperCase()}</button>)}</div>
|
||||
|
||||
<div className="flex justify-between p-4 rounded-lg bg-black/50 border border-white/10"><div><span className="text-2xl font-bold">{selectedAsset}/USD</span></div><div className="text-right"><div className="text-2xl font-bold">${priceData.price.toLocaleString()}</div><div className={`text-sm ${priceData.change24h >= 0 ? 'text-green-400' : 'text-red-400'}`}>{priceData.change24h >= 0 ? '↑' : '↓'} {Math.abs(priceData.change24h).toFixed(2)}%</div></div></div>
|
||||
|
||||
{cv && <div className="rounded-lg border border-brand-pink/30 bg-brand-pink/5 p-4"><div className="flex items-center gap-2 mb-3"><span className="text-xl">👁️</span><h3 className="font-bold text-brand-pink">THOTH'S VIEW</h3><span className="text-xs text-white/40 ml-auto">{new Date(cv.updated_at).toLocaleString()}</span></div><p className="text-white/90 mb-4 italic">"{cv.thought}"</p><div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm"><div><p className="text-white/50 text-xs">Trend</p><p className="font-medium">{getTE(cv.trend)} {cv.trend}</p></div><div><p className="text-white/50 text-xs">Phase</p><p className="font-medium">{cv.phase}</p></div><div><p className="text-white/50 text-xs">Key Level</p><p className="font-medium">${cv.key_level.toLocaleString()}</p></div><div><p className="text-white/50 text-xs">Bias</p><p className={`font-medium ${getBC(cv.bias)}`}>{cv.bias.toUpperCase()} ({cv.confidence}/10)</p></div></div></div>}
|
||||
|
||||
<div className="relative rounded-lg bg-black/50 border border-white/10" style={{ height: '400px' }}>{loading && <div className="absolute inset-0 flex items-center justify-center bg-black/50 z-10"><span className="text-white/50">Loading...</span></div>}<canvas ref={mainChartRef} /></div>
|
||||
|
||||
{secondTimeframe && secondChartData.length > 0 && <div className="rounded-lg bg-black/50 border border-white/10" style={{ height: '200px' }}><div className="px-4 py-2 border-b border-white/10 text-sm font-bold">{secondTimeframe} Chart</div><SecondChart data={secondChartData} /></div>}
|
||||
|
||||
{indicators.rsi && <div className="rounded-lg bg-black/50 border border-white/10" style={{ height: '150px' }}><canvas ref={rsiChartRef} /></div>}
|
||||
{indicators.macd && <div className="rounded-lg bg-black/50 border border-white/10" style={{ height: '150px' }}><canvas ref={macdChartRef} /></div>}
|
||||
|
||||
<div className="grid grid-cols-5 gap-3">{[{ v: trades.filter(t => t.result === 'open').length, l: 'Open' }, { v: totalPnl, l: 'P&L', c: 'text-green-400' }, { v: winRate + '%', l: 'Win Rate' }, { v: closedTrades.length, l: 'Trades' }, { v: avgRr.toFixed(1) + ':1', l: 'Avg R:R' }].map((s, i) => <div key={i} className="p-3 rounded-lg bg-white/5 border border-white/10 text-center"><p className={`text-2xl font-bold ${s.c || ''}`}>{s.v}</p><p className="text-xs text-white/50">{s.l}</p></div>)}</div>
|
||||
|
||||
<div className="rounded-lg border border-white/10 bg-white/5 overflow-hidden"><div className="px-4 py-3 border-b border-white/10"><h3 className="font-bold">📊 Trade History</h3></div>{trades.length === 0 ? <div className="p-8 text-center text-white/50">No trades yet</div> : <div className="max-h-48 overflow-y-auto"><table className="w-full text-sm"><thead className="bg-white/5 text-white/70 sticky top-0"><tr><th className="px-4 py-2 text-left">Date</th><th className="px-4 py-2">Asset</th><th className="px-4 py-2">Dir</th><th className="px-4 py-2 text-right">Entry</th><th className="px-4 py-2 text-right">SL</th><th className="px-4 py-2 text-right">TP</th><th className="px-4 py-2 text-right">R:R</th><th className="px-4 py-2 text-right">Result</th></tr></thead><tbody>{trades.map((t, i) => <tr key={i} className={`border-t border-white/5 ${t.result === 'win' ? 'bg-green-500/10' : t.result === 'loss' ? 'bg-red-500/10' : 'bg-yellow-500/10'}`}><td className="px-4 py-2">{t.date}</td><td className="px-4 py-2 text-center">{t.pair}</td><td className="px-4 py-2 text-center"><span className={t.direction === 'long' ? 'text-green-400' : 'text-red-400'}>{t.direction.toUpperCase()}</span></td><td className="px-4 py-2 text-right">${t.entry.toLocaleString()}</td><td className="px-4 py-2 text-right text-red-400">${t.stopLoss.toLocaleString()}</td><td className="px-4 py-2 text-right text-green-400">${t.takeProfit.toLocaleString()}</td><td className="px-4 py-2 text-right">{t.rr?.toFixed(1)}:1</td><td className="px-4 py-2 text-right">{t.result === 'win' ? '✅' : t.result === 'loss' ? '❌' : '⏳'}</td></tr>)}</tbody></table></div>}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SecondChart({ data }: { data: ChartData[] }) {
|
||||
const ref = useRef<HTMLCanvasElement>(null)
|
||||
useEffect(() => {
|
||||
if (!ref.current || !data.length) return
|
||||
const ctx = ref.current.getContext('2d')
|
||||
if (!ctx) return
|
||||
if (ref.current.chart) ref.current.chart.destroy()
|
||||
const chart = new window.Chart(ctx, { type: 'line', data: { labels: data.map(d => new Date(d.time).toLocaleTimeString()), datasets: [{ data: data.map(d => d.close), borderColor: '#a0a0a0', borderWidth: 2, pointRadius: 0, tension: 0.4 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { display: false }, y: { position: 'right', grid: { color: '#1a1a2e' }, ticks: { color: '#a0a0a0' } } } } })
|
||||
ref.current.chart = chart
|
||||
}, [data])
|
||||
return <canvas ref={ref} />
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { TradingChart } from './TradingChart'
|
||||
|
||||
type TradingTab = 'research' | 'strategies' | 'execution' | 'journal'
|
||||
|
||||
interface Trader {
|
||||
id: string
|
||||
name: string
|
||||
status: 'learning' | 'active' | 'paused'
|
||||
framesAnalyzed: number
|
||||
patterns: string[]
|
||||
entryRules: string[]
|
||||
exitRules: string[]
|
||||
indicators: string[]
|
||||
riskParams: string[]
|
||||
}
|
||||
|
||||
interface Trade {
|
||||
id: string
|
||||
trader: string
|
||||
pair: string
|
||||
direction: 'long' | 'short'
|
||||
entryPrice: number
|
||||
exitPrice?: number
|
||||
status: 'open' | 'closed' | 'cancelled'
|
||||
pnl?: number
|
||||
pnlPercent?: number
|
||||
reason: string
|
||||
setup: string
|
||||
timeframe: string
|
||||
openedAt: string
|
||||
closedAt?: string
|
||||
notes: string
|
||||
isDemo: boolean
|
||||
}
|
||||
|
||||
const defaultTraders: Trader[] = [
|
||||
{
|
||||
id: 'dopetrades',
|
||||
name: 'DopeTrades',
|
||||
status: 'learning',
|
||||
framesAnalyzed: 0,
|
||||
patterns: [],
|
||||
entryRules: [],
|
||||
exitRules: [],
|
||||
indicators: [],
|
||||
riskParams: []
|
||||
}
|
||||
]
|
||||
|
||||
export function TradingPanel() {
|
||||
const [activeTab, setActiveTab] = useState<TradingTab>('research')
|
||||
const [traders, setTraders] = useState<Trader[]>(defaultTraders)
|
||||
const [selectedTrader, setSelectedTrader] = useState<string>('dopetrades')
|
||||
const [trades, setTrades] = useState<Trade[]>([])
|
||||
const [journalFilter, setJournalFilter] = useState<'all' | 'demo' | 'real'>('all')
|
||||
|
||||
// Load data
|
||||
useEffect(() => {
|
||||
loadTraders()
|
||||
loadTrades()
|
||||
}, [])
|
||||
|
||||
const loadTraders = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/trading/traders')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.traders?.length > 0) setTraders(data.traders)
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const loadTrades = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/trading/trades')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.trades) setTrades(data.trades)
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'research', label: '🔬 Deep Research', count: traders.filter(t => t.status === 'learning').length },
|
||||
{ id: 'strategies', label: '🎯 Strategies', count: traders.filter(t => t.status === 'active').length },
|
||||
{ id: 'execution', label: '⚡ Execution', count: trades.filter(t => t.status === 'open').length },
|
||||
{ id: 'journal', label: '📔 Journal', count: trades.length },
|
||||
]
|
||||
|
||||
const filteredTrades = trades.filter(t => {
|
||||
if (journalFilter === 'all') return true
|
||||
if (journalFilter === 'demo') return t.isDemo
|
||||
return !t.isDemo
|
||||
})
|
||||
|
||||
const openTrades = trades.filter(t => t.status === 'open')
|
||||
const closedDemoTrades = trades.filter(t => t.status === 'closed' && t.isDemo)
|
||||
const closedRealTrades = trades.filter(t => t.status === 'closed' && !t.isDemo)
|
||||
|
||||
const totalPnl = closedRealTrades.reduce((sum, t) => sum + (t.pnl || 0), 0)
|
||||
const winRate = closedRealTrades.length > 0
|
||||
? Math.round((closedRealTrades.filter(t => (t.pnl || 0) > 0).length / closedRealTrades.length) * 100)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as TradingTab)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition ${
|
||||
activeTab === tab.id
|
||||
? 'bg-brand-pink text-white'
|
||||
: 'bg-white/10 text-white/70 hover:bg-white/20'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
<span className="ml-2 text-xs opacity-70">({tab.count})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Research Tab */}
|
||||
{activeTab === 'research' && (
|
||||
<div className="border border-white/20 rounded-lg p-4 bg-white/5">
|
||||
<h3 className="text-lg font-bold mb-4">🔬 Deep Research</h3>
|
||||
<p className="text-white/60 mb-4">Learn trading strategies from experts by analyzing their content.</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{traders.map(trader => (
|
||||
<div
|
||||
key={trader.id}
|
||||
className="p-4 rounded-lg bg-white/5 border border-white/10"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">👨🏫</span>
|
||||
<div>
|
||||
<p className="font-medium">{trader.name}</p>
|
||||
<p className="text-xs text-white/50">{trader.framesAnalyzed} frames analyzed</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-2 py-1 rounded text-xs ${
|
||||
trader.status === 'learning' ? 'bg-yellow-500/20 text-yellow-400' :
|
||||
trader.status === 'active' ? 'bg-green-500/20 text-green-400' :
|
||||
'bg-white/10 text-white/50'
|
||||
}`}>
|
||||
{trader.status}
|
||||
</span>
|
||||
</div>
|
||||
{trader.patterns.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{trader.patterns.map(p => (
|
||||
<span key={p} className="px-2 py-0.5 bg-white/10 rounded text-xs">{p}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button className="mt-3 text-sm text-brand-pink hover:underline">
|
||||
View full analysis →
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button className="mt-4 w-full py-2 border border-dashed border-white/20 rounded-lg text-white/50 hover:border-white/40 hover:text-white transition">
|
||||
+ Add new trader to research
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Strategies Tab */}
|
||||
{activeTab === 'strategies' && (
|
||||
<div className="border border-white/20 rounded-lg p-4 bg-white/5">
|
||||
<h3 className="text-lg font-bold mb-4">🎯 Trading Strategies</h3>
|
||||
<p className="text-white/60 mb-4">Select a trader style to follow for your next trade.</p>
|
||||
|
||||
<div className="flex gap-2 flex-wrap mb-4">
|
||||
{traders.filter(t => t.status === 'active').map(trader => (
|
||||
<button
|
||||
key={trader.id}
|
||||
onClick={() => setSelectedTrader(trader.id)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition ${
|
||||
selectedTrader === trader.id
|
||||
? 'bg-brand-pink text-white'
|
||||
: 'bg-white/10 text-white/70 hover:bg-white/20'
|
||||
}`}
|
||||
>
|
||||
{trader.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{traders.find(t => t.id === selectedTrader) && (
|
||||
<div className="p-4 rounded-lg bg-black/30 border border-white/10">
|
||||
<h4 className="font-medium mb-3">{traders.find(t => t.id === selectedTrader)?.name} Strategy</h4>
|
||||
|
||||
<div className="space-y-3 text-sm">
|
||||
<div>
|
||||
<p className="text-white/50 text-xs mb-1">Entry Rules</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
{traders.find(t => t.id === selectedTrader)?.entryRules.map(r => (
|
||||
<li key={r}>{r}</li>
|
||||
))}
|
||||
</ul>
|
||||
{traders.find(t => t.id === selectedTrader)?.entryRules.length === 0 && (
|
||||
<p className="text-white/30 italic">No entry rules defined yet</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-white/50 text-xs mb-1">Exit Rules</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
{traders.find(t => t.id === selectedTrader)?.exitRules.map(r => (
|
||||
<li key={r}>{r}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-white/50 text-xs mb-1">Indicators</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{traders.find(t => t.id === selectedTrader)?.indicators.map(i => (
|
||||
<span key={i} className="px-2 py-0.5 bg-blue-500/20 text-blue-400 rounded text-xs">{i}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-white/50 text-xs mb-1">Risk Parameters</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
{traders.find(t => t.id === selectedTrader)?.riskParams.map(r => (
|
||||
<li key={r}>{r}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="mt-4 w-full py-2 bg-brand-pink rounded-lg font-medium hover:bg-[#ff7bc0] transition">
|
||||
Execute Trade in {traders.find(t => t.id === selectedTrader)?.name} Style →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Execution Tab */}
|
||||
{activeTab === 'execution' && (
|
||||
<TradingChart />
|
||||
)}
|
||||
|
||||
{/* Journal Tab */}
|
||||
{activeTab === 'journal' && (
|
||||
<div className="border border-white/20 rounded-lg p-4 bg-white/5">
|
||||
<h3 className="text-lg font-bold mb-4">📔 Trading Journal</h3>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||
<div className="p-3 rounded bg-white/5 text-center">
|
||||
<p className="text-2xl font-bold text-green-400">${totalPnl.toFixed(2)}</p>
|
||||
<p className="text-xs text-white/50">Real P&L</p>
|
||||
</div>
|
||||
<div className="p-3 rounded bg-white/5 text-center">
|
||||
<p className="text-2xl font-bold">{winRate}%</p>
|
||||
<p className="text-xs text-white/50">Win Rate</p>
|
||||
</div>
|
||||
<div className="p-3 rounded bg-white/5 text-center">
|
||||
<p className="text-2xl font-bold">{closedRealTrades.length}</p>
|
||||
<p className="text-xs text-white/50">Real Trades</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
{(['all', 'demo', 'real'] as const).map(filter => (
|
||||
<button
|
||||
key={filter}
|
||||
onClick={() => setJournalFilter(filter)}
|
||||
className={`px-3 py-1 rounded text-xs ${
|
||||
journalFilter === filter
|
||||
? 'bg-brand-pink text-white'
|
||||
: 'bg-white/10 text-white/60'
|
||||
}`}
|
||||
>
|
||||
{filter === 'all' ? 'All' : filter === 'demo' ? 'Demo' : 'Real'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Trades List */}
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
||||
{filteredTrades.length === 0 ? (
|
||||
<p className="text-white/40 text-center py-4">No trades yet</p>
|
||||
) : (
|
||||
filteredTrades.map(trade => (
|
||||
<div
|
||||
key={trade.id}
|
||||
className={`p-3 rounded border ${
|
||||
trade.isDemo
|
||||
? 'bg-white/5 border-white/10'
|
||||
: trade.status === 'open'
|
||||
? 'bg-yellow-500/10 border-yellow-500/30'
|
||||
: (trade.pnl || 0) > 0
|
||||
? 'bg-green-500/10 border-green-500/30'
|
||||
: 'bg-red-500/10 border-red-500/30'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<span className="font-medium">{trade.pair}</span>
|
||||
<span className={`ml-2 text-xs px-1.5 py-0.5 rounded ${
|
||||
trade.direction === 'long' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'
|
||||
}`}>
|
||||
{trade.direction.toUpperCase()}
|
||||
</span>
|
||||
{trade.isDemo && <span className="ml-1 text-xs text-white/40">(Demo)</span>}
|
||||
</div>
|
||||
{trade.status === 'closed' && (
|
||||
<span className={`font-bold ${(trade.pnl || 0) >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{trade.pnl >= 0 ? '+' : ''}{trade.pnl?.toFixed(2)} ({trade.pnlPercent?.toFixed(1)}%)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-white/50 mt-1">{trade.setup}</p>
|
||||
<div className="flex gap-4 mt-2 text-xs text-white/40">
|
||||
<span>{trade.timeframe}</span>
|
||||
<span>{new Date(trade.openedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -168,6 +168,31 @@ export default function MorningBriefCalendar() {
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* OpenClaw Use Cases */}
|
||||
{todayBrief.openclowUseCases && todayBrief.openclowUseCases.skillIdeas.length > 0 && (
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<p className="text-xs text-white/50 uppercase">🦞 OpenClaw Skill Ideas</p>
|
||||
<ul className="space-y-1">
|
||||
{todayBrief.openclowUseCases.skillIdeas.map((idea, i) => (
|
||||
<li key={i} className="text-sm text-purple-300 flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={idea.selected}
|
||||
onChange={() => {
|
||||
const updated = { ...todayBrief.openclowUseCases };
|
||||
updated.skillIdeas[i].selected = !idea.selected;
|
||||
// Save to localStorage
|
||||
localStorage.setItem("sitemente:openclaw-usecases", JSON.stringify(updated));
|
||||
}}
|
||||
className="w-4 h-4 accent-purple-500"
|
||||
/>
|
||||
{idea.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -248,6 +273,26 @@ export default function MorningBriefCalendar() {
|
||||
}`}>{viewingBrief.market.sentiment.toUpperCase()}</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* OpenClaw Use Cases */}
|
||||
{viewingBrief.openclowUseCases && viewingBrief.openclowUseCases.skillIdeas.length > 0 && (
|
||||
<section>
|
||||
<h4 className="text-sm font-semibold text-white/70 mb-2">🦞 OpenClaw Skill Ideas</h4>
|
||||
<ul className="space-y-1">
|
||||
{viewingBrief.openclowUseCases.skillIdeas.map((idea, i) => (
|
||||
<li key={i} className="text-sm text-purple-300">• {idea}</li>
|
||||
))}
|
||||
</ul>
|
||||
{viewingBrief.openclowUseCases.topUseCases.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs text-white/50">Top Use Cases:</p>
|
||||
{viewingBrief.openclowUseCases.topUseCases.map((uc, i) => (
|
||||
<p key={i} className="text-sm text-white/60">• {uc}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -128,7 +128,8 @@ export default function PricingTable({ lang, onContact }: PricingTableProps) {
|
||||
monthly: 299,
|
||||
term: "6-12 months",
|
||||
features: [
|
||||
"AI website chat (1 language)",
|
||||
"AI website chat (24/7)",
|
||||
"2 languages (ES+EN)",
|
||||
"Booking & lead capture",
|
||||
"Light lead inbox",
|
||||
"Basic analytics",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import PaymentButton from "@/components/stripe/PaymentButton";
|
||||
|
||||
type ServicesAndPricingProps = {
|
||||
lang: "es" | "en";
|
||||
@@ -103,19 +104,20 @@ const serviceCards: Record<"es" | "en", ServiceCard[]> = {
|
||||
es: [
|
||||
{
|
||||
id: "starter",
|
||||
icon: "💬",
|
||||
title: "AI Chat + Reservas Automatizadas",
|
||||
for: "Restaurantes, clínicas, negocios locales con web existente",
|
||||
icon: "🤖",
|
||||
title: "Empleado IA - Chat",
|
||||
for: "Restaurantes, clínicas, negocios que quieren su primer empleado IA",
|
||||
description: [
|
||||
"¿Tu web pierde clientes fuera de horario? Tu sitio responde preguntas, toma reservas y captura leads mientras duermes.",
|
||||
"Un empleado virtual que trabaja 24/7, 365 días al año. Responde preguntas, toma reservas y captura leads mientras tú duermes.",
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
title: "Resultados típicos:",
|
||||
title: "Un empleado IA que:",
|
||||
items: [
|
||||
"24/7 reservas sin intervención humana",
|
||||
"Reduce llamadas repetitivas en un 70%",
|
||||
"ROI positivo en el primer mes",
|
||||
"Responde 24/7 (también a las 3 AM)",
|
||||
"Habla español, inglés, francés, alemán...",
|
||||
"Toma reservas y citas automáticas",
|
||||
"Nunca se enferma, nunca se va de vacaciones",
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -123,75 +125,80 @@ const serviceCards: Record<"es" | "en", ServiceCard[]> = {
|
||||
monthly: 299,
|
||||
term: "6-12 meses",
|
||||
features: [
|
||||
"Chat IA en la web (1 idioma)",
|
||||
"Reservas y captación de leads",
|
||||
"Analíticas básicas",
|
||||
"🤖 Chat IA en tu web",
|
||||
"🌍 Hasta 3 idiomas",
|
||||
"📅 Reservas automáticas",
|
||||
"📊 Dashboard de clientes",
|
||||
"📱 Notificaciones WhatsApp/SMS",
|
||||
"📱 Notificaciones en tiempo real",
|
||||
],
|
||||
optional: ["+WhatsApp (+100€/mes)", "+Idioma extra (+80€/mes)"],
|
||||
optional: [],
|
||||
cta: "Empezar",
|
||||
},
|
||||
{
|
||||
id: "site",
|
||||
icon: "🌐",
|
||||
title: "Web Inteligente Completa",
|
||||
for: "Negocios listos para una web moderna + cerebro IA",
|
||||
icon: "📞",
|
||||
title: "Empleado IA - Chat + Voz",
|
||||
for: "Negocios que no quieren perder llamadas",
|
||||
description: [
|
||||
"Construimos tu nuevo sitio web Next.js (rápido, seguro, SEO) e integramos IA que vende, reserva y da soporte en español e inglés.",
|
||||
"Tu empleado IA ahora también contesta el teléfono. Disponible 24/7 para responder, cualificar yivar llamadas a tu móvil o agendar directamente.",
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
title: "Incluye:",
|
||||
title: "Todo lo del plan Chat, más:",
|
||||
items: [
|
||||
"Sitio que convierte visitas en ventas",
|
||||
"Chat + WhatsApp incluidos",
|
||||
"2 idiomas desde el día uno",
|
||||
"Analíticas avanzadas",
|
||||
"📞 Contesta llamadas entrantes",
|
||||
"🎯 Cualifica leads por ti",
|
||||
"📋 Envía resúmenes post-llamada",
|
||||
"🔗 Integración con tu calendario",
|
||||
],
|
||||
},
|
||||
],
|
||||
setup: "3.500 €",
|
||||
monthly: 749,
|
||||
setup: "2.500 €",
|
||||
monthly: 599,
|
||||
term: "6-12 meses",
|
||||
features: [
|
||||
"Todo lo de Starter",
|
||||
"Nuevo sitio web Next.js",
|
||||
"WhatsApp incluido",
|
||||
"2 idiomas (ES+EN)",
|
||||
"✅ Todo lo de Chat IA",
|
||||
"📞 Contesta llamadas",
|
||||
"🌍 Hasta 5 idiomas",
|
||||
"📅 Sincroniza con tu calendario",
|
||||
"📋 Resúmenes de conversaciones",
|
||||
],
|
||||
optional: ["+Llamadas de voz (+150€/mes)", "+Ubicación extra (+120€/mes)"],
|
||||
optional: [],
|
||||
cta: "Empezar",
|
||||
popular: true,
|
||||
},
|
||||
{
|
||||
id: "growth",
|
||||
icon: "🚀",
|
||||
title: "Partner Estratégico de IA",
|
||||
for: "Grupos de restaurantes, inmobiliarias, cadenas de alquiler",
|
||||
title: "Empresa IA",
|
||||
for: "Cadenas, grupos o negocios con alto volumen",
|
||||
description: [
|
||||
"Socio estratégico: IA multicanal (web, WhatsApp, llamadas), integraciones CRM y consultoría mensual.",
|
||||
"IA para toda tu operación: múltiples ubicaciones, CRM, analytics avanzado y consultoría mensual para maximizar resultados.",
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
title: "Incluye:",
|
||||
items: [
|
||||
"Todo lo de Smart Site",
|
||||
"Llamadas de voz incluidas",
|
||||
"Soporte multiubicación",
|
||||
"Integraciones CRM",
|
||||
"Llamada estratégica mensual",
|
||||
"Todo de Chat + Voz",
|
||||
"Múltiples ubicaciones",
|
||||
"CRM completo",
|
||||
"Analytics avanzado",
|
||||
"Consultoría mensual",
|
||||
],
|
||||
},
|
||||
],
|
||||
setup: "5.000 €",
|
||||
monthly: 1950,
|
||||
monthly: 1499,
|
||||
term: "12 meses",
|
||||
features: [
|
||||
"Todo lo de Smart Site",
|
||||
"Llamadas de voz incluidas",
|
||||
"Integraciones CRM",
|
||||
"Soporte prioritario",
|
||||
"✅ Todo de Chat + Voz",
|
||||
"🏢 Múltiples ubicaciones",
|
||||
"🔗 Integraciones CRM",
|
||||
"📈 Analytics avanzado",
|
||||
"👤 Account manager dedicado",
|
||||
],
|
||||
optional: ["+Pack contenido IA (+200€/mes)"],
|
||||
optional: [],
|
||||
cta: "Hablemos",
|
||||
},
|
||||
{
|
||||
@@ -219,95 +226,100 @@ const serviceCards: Record<"es" | "en", ServiceCard[]> = {
|
||||
en: [
|
||||
{
|
||||
id: "starter",
|
||||
icon: "💬",
|
||||
title: "AI Chat + Automated Bookings",
|
||||
for: "Restaurants, clinics, local businesses with existing websites",
|
||||
icon: "🤖",
|
||||
title: "AI Employee - Chat",
|
||||
for: "Restaurants, clinics, businesses wanting their first AI employee",
|
||||
description: [
|
||||
"Is your website losing customers after hours? Your site answers questions, takes bookings, and captures leads while you sleep.",
|
||||
"A virtual employee that works 24/7, 365 days a year. Answers questions, takes bookings and captures leads while you sleep.",
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
title: "Typical results:",
|
||||
title: "An AI employee that:",
|
||||
items: [
|
||||
"24/7 bookings without human intervention",
|
||||
"Reduce repetitive calls by 70%",
|
||||
"Positive ROI in the first month",
|
||||
"Responds 24/7 (even at 3 AM)",
|
||||
"Speaks Spanish, English, French, German...",
|
||||
"Takes bookings and appointments automatically",
|
||||
"Never gets sick, never goes on vacation",
|
||||
],
|
||||
},
|
||||
],
|
||||
setup: "900 €",
|
||||
setup: "€900",
|
||||
monthly: 299,
|
||||
term: "6-12 months",
|
||||
features: [
|
||||
"AI website chat (1 language)",
|
||||
"Booking & lead capture",
|
||||
"Basic analytics",
|
||||
"🤖 AI Chat on your website",
|
||||
"🌍 Up to 3 languages",
|
||||
"📅 Automated bookings",
|
||||
"📊 Customer dashboard",
|
||||
"📱 Real-time notifications",
|
||||
],
|
||||
optional: ["+WhatsApp (+100€/mo)", "+Extra language (+80€/mo)"],
|
||||
cta: "Get started",
|
||||
optional: [],
|
||||
cta: "Get Started",
|
||||
},
|
||||
{
|
||||
id: "site",
|
||||
icon: "🌐",
|
||||
title: "Full Smart Website",
|
||||
for: "Businesses ready for a modern site + AI brain",
|
||||
icon: "📞",
|
||||
title: "AI Employee - Chat + Voice",
|
||||
for: "Businesses that can't miss calls",
|
||||
description: [
|
||||
"We build your new Next.js website (fast, secure, SEO) and integrate AI that sells, books, and supports in Spanish and English.",
|
||||
"Your AI employee now also answers the phone. Available 24/7 to respond, qualify, and forward calls to your mobile or book directly in your calendar.",
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
title: "Includes:",
|
||||
title: "Everything in Chat plan, plus:",
|
||||
items: [
|
||||
"Site that converts visits into sales",
|
||||
"Chat + WhatsApp included",
|
||||
"2 languages from day one",
|
||||
"Advanced analytics",
|
||||
"📞 Answers incoming calls",
|
||||
"🎯 Qualifies leads for you",
|
||||
"📋 Post-call summaries",
|
||||
"🔗 Calendar integration",
|
||||
],
|
||||
},
|
||||
],
|
||||
setup: "3,500 €",
|
||||
monthly: 749,
|
||||
setup: "€2,500",
|
||||
monthly: 599,
|
||||
term: "6-12 months",
|
||||
features: [
|
||||
"Everything in Starter",
|
||||
"New Next.js website",
|
||||
"WhatsApp included",
|
||||
"2 languages (ES+EN)",
|
||||
"✅ Everything in Chat AI",
|
||||
"📞 Answers calls",
|
||||
"🌍 Up to 5 languages",
|
||||
"📅 Syncs with your calendar",
|
||||
"📋 Conversation summaries",
|
||||
],
|
||||
optional: ["+Voice calls (+150€/mo)", "+Extra location (+120€/mo)"],
|
||||
cta: "Get started",
|
||||
optional: [],
|
||||
cta: "Get Started",
|
||||
popular: true,
|
||||
},
|
||||
{
|
||||
id: "growth",
|
||||
icon: "🚀",
|
||||
title: "AI Strategy Partner",
|
||||
for: "Restaurant groups, real estate teams, rental chains",
|
||||
title: "AI Company",
|
||||
for: "Chains, groups or high-volume businesses",
|
||||
description: [
|
||||
"Strategic partner: multichannel AI (web, WhatsApp, calls), CRM integrations, monthly consulting.",
|
||||
"AI for your entire operation: multiple locations, CRM, advanced analytics and monthly consulting to maximize results.",
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
title: "Includes:",
|
||||
items: [
|
||||
"Everything in Smart Site",
|
||||
"Voice calls included",
|
||||
"Multi-location support",
|
||||
"CRM integrations",
|
||||
"Monthly strategy call",
|
||||
"Everything in Chat + Voice",
|
||||
"Multiple locations",
|
||||
"Full CRM",
|
||||
"Advanced analytics",
|
||||
"Monthly consulting",
|
||||
],
|
||||
},
|
||||
],
|
||||
setup: "5,000 €",
|
||||
monthly: 1950,
|
||||
setup: "€5,000",
|
||||
monthly: 1499,
|
||||
term: "12 months",
|
||||
features: [
|
||||
"Everything in Smart Site",
|
||||
"Voice calls included",
|
||||
"CRM integrations",
|
||||
"Priority support",
|
||||
"✅ Everything in Chat + Voice",
|
||||
"🏢 Multiple locations",
|
||||
"🔗 CRM integrations",
|
||||
"📈 Advanced analytics",
|
||||
"👤 Dedicated account manager",
|
||||
],
|
||||
optional: ["+AI content pack (+200€/mo)"],
|
||||
optional: [],
|
||||
cta: "Let's talk",
|
||||
},
|
||||
{
|
||||
@@ -485,12 +497,22 @@ export default function ServicesAndPricing({
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onContact}
|
||||
className="mt-auto mt-6 w-full rounded-lg bg-brand-pink py-2.5 text-sm font-semibold text-white transition hover:bg-[#ff7bc0]"
|
||||
>
|
||||
{card.cta}
|
||||
</button>
|
||||
{/* Enterprise/Custom plan: contact form, others: Stripe payment */}
|
||||
{card.id === "enterprise" ? (
|
||||
<button
|
||||
onClick={onContact}
|
||||
className="mt-auto mt-6 w-full rounded-lg bg-brand-pink py-2.5 text-sm font-semibold text-white transition hover:bg-[#ff7bc0]"
|
||||
>
|
||||
{card.cta}
|
||||
</button>
|
||||
) : (
|
||||
<PaymentButton
|
||||
planId={card.id}
|
||||
planType="monthly"
|
||||
label={card.cta}
|
||||
variant="primary"
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
interface PaymentButtonProps {
|
||||
planId: string;
|
||||
planType: "setup" | "monthly";
|
||||
label: string;
|
||||
variant?: "primary" | "secondary";
|
||||
}
|
||||
|
||||
export default function PaymentButton({ planId, planType, label, variant = "primary" }: PaymentButtonProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const handleCheckout = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/stripe/checkout", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
planId,
|
||||
planType,
|
||||
email,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.url) {
|
||||
window.location.href = data.url;
|
||||
} else {
|
||||
alert("Error initiating checkout. Please try again.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Checkout error:", error);
|
||||
alert("Error initiating checkout. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const buttonClass = variant === "primary"
|
||||
? "bg-brand-pink hover:bg-[#ff7bc0]"
|
||||
: "border border-white/20 hover:bg-white/10";
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className={`w-full mt-6 py-2.5 rounded-lg font-semibold text-white transition ${buttonClass}`}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Processing..." : label}
|
||||
</button>
|
||||
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-[#1a1625] border border-white/20 rounded-2xl p-6 max-w-md w-full">
|
||||
<h3 className="text-xl font-bold text-white mb-4">Enter your email to continue</h3>
|
||||
<p className="text-white/60 text-sm mb-4">
|
||||
We'll send the payment link to your email.
|
||||
</p>
|
||||
<form onSubmit={handleCheckout}>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
placeholder="your@email.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-lg bg-white/10 border border-white/20 text-white placeholder:text-white/40 focus:outline-none focus:border-brand-pink mb-4"
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
className="flex-1 px-4 py-2 rounded-lg border border-white/20 text-white/70 hover:bg-white/10 transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 px-4 py-2 rounded-lg bg-brand-pink text-white font-semibold hover:bg-[#ff7bc0] transition disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Processing..." : "Continue to Payment"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user