import React, { useState, useEffect, useCallback } from "react";
import { useParams, useNavigate, Link } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
chatbotsAPI,
documentsAPI,
modelsAPI,
uploadAPI,
urlSourcesAPI,
leadsAPI,
channelsAPI,
} from "@/services/api";
import { useAuthStore } from "@/store/authStore";
import {
Button,
Input,
Textarea,
Select,
Card,
Badge,
StatusDot,
Spinner,
} from "@/components/ui";
import {
CATEGORIES,
INDUSTRIES,
formatBytes,
getFileIcon,
cn,
} from "@/lib/utils";
import { ChatInterface } from "@/components/ChatInterface";
import { useDropzone } from "react-dropzone";
import type {
ChatbotFormData,
AvailableModel,
UrlSource,
ChannelConnection,
} from "@/types";
import { CHATBOT_TEMPLATES } from "@/data/templates";
import {
ArrowLeft,
Save,
Eye,
Upload,
Trash2,
FileText,
Sliders,
AlertCircle,
CheckCircle,
ChevronDown,
ChevronRight,
Settings2,
ImagePlus,
X,
Link2,
Copy,
Globe,
Webhook,
Share2,
Code,
MessageSquare,
Calendar,
} from "lucide-react";
const DEFAULT_FORM: ChatbotFormData = {
name: "",
description: "",
system_prompt: "",
model: "accounts/fireworks/models/kimi-k2-instruct-0905",
temperature: 0.7,
max_tokens: 1000,
primary_color: "#6366f1",
welcome_message: "Hello! How can I help you today?",
logo_url: "",
category: "",
industry: "",
languages: ["en"],
show_branding: true,
lead_capture_enabled: false,
lead_capture_fields: ["email"],
lead_capture_trigger: "after_first_message",
handoff_enabled: false,
handoff_message: "I'll connect you with our team. Please wait.",
handoff_email: "",
handoff_keywords: ["human", "agent", "speak to someone"],
booking_enabled: false,
};
type Tab = "settings" | "documents" | "preview" | "deploy";
export const ChatbotBuilderPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const isNew = !id || id === "new";
const navigate = useNavigate();
const queryClient = useQueryClient();
const { user } = useAuthStore();
const [tab, setTab] = useState("settings");
const [form, setForm] = useState(DEFAULT_FORM);
const [showTemplatePicker, setShowTemplatePicker] = useState(isNew);
const [toast, setToast] = useState<{
msg: string;
type: "success" | "error";
} | null>(null);
const [chatbotId, setChatbotId] = useState(
isNew ? null : id || null,
);
// Load existing chatbot
const { data: existingChatbot, isLoading: loadingChatbot } = useQuery({
queryKey: ["chatbot", id],
queryFn: () => chatbotsAPI.get(id!),
enabled: !isNew,
});
useEffect(() => {
if (existingChatbot) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setForm({
name: existingChatbot.name,
description: existingChatbot.description || "",
system_prompt: existingChatbot.system_prompt || "",
model: existingChatbot.model,
temperature: existingChatbot.temperature,
max_tokens: existingChatbot.max_tokens,
primary_color: existingChatbot.primary_color,
welcome_message: existingChatbot.welcome_message,
logo_url: existingChatbot.logo_url || "",
category: existingChatbot.category || "",
industry: existingChatbot.industry || "",
languages: existingChatbot.languages,
show_branding: existingChatbot.show_branding,
lead_capture_enabled: existingChatbot.lead_capture_enabled,
lead_capture_fields: existingChatbot.lead_capture_fields,
lead_capture_trigger: existingChatbot.lead_capture_trigger,
handoff_enabled: existingChatbot.handoff_enabled,
handoff_message: existingChatbot.handoff_message,
handoff_email: existingChatbot.handoff_email || "",
handoff_keywords: existingChatbot.handoff_keywords,
booking_enabled: existingChatbot.booking_enabled ?? false,
});
setChatbotId(existingChatbot.id);
}
}, [existingChatbot]);
const createMutation = useMutation({
mutationFn: chatbotsAPI.create,
onSuccess: (data) => {
setChatbotId(data.id);
queryClient.invalidateQueries({ queryKey: ["chatbots"] });
navigate(`/chatbots/${data.id}/edit`, { replace: true });
showToast("Chatbot created!", "success");
},
onError: (err: { response?: { data?: { detail?: string } } }) =>
showToast(err.response?.data?.detail || "Failed to create", "error"),
});
const updateMutation = useMutation({
mutationFn: (data: Partial) =>
chatbotsAPI.update(chatbotId!, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["chatbot", chatbotId] });
queryClient.invalidateQueries({ queryKey: ["chatbots"] });
showToast("Settings saved!", "success");
},
onError: (err: { response?: { data?: { detail?: string } } }) =>
showToast(err.response?.data?.detail || "Save failed", "error"),
});
const handleSave = () => {
if (!form.name.trim()) {
showToast("Chatbot name is required", "error");
return;
}
if (isNew || !chatbotId) {
createMutation.mutate(form);
} else {
updateMutation.mutate(form);
}
};
const showToast = (msg: string, type: "success" | "error") => {
setToast({ msg, type });
setTimeout(() => setToast(null), 3000);
};
if (loadingChatbot) {
return (
);
}
if (isNew && showTemplatePicker) {
return (
{/* Template picker header */}
Choose a template
Start from a template or build from scratch
{CHATBOT_TEMPLATES.map((template) => (
))}
);
}
const TABS: { key: Tab; label: string; icon: React.ElementType }[] = [
{ key: "settings", label: "Settings", icon: Sliders },
{ key: "documents", label: "Documents", icon: FileText },
{ key: "preview", label: "Preview", icon: Eye },
{ key: "deploy", label: "Deploy", icon: Share2 },
];
return (
{/* Top bar */}
{/* Chatbot name + status */}
{isNew ? "Create Chatbot" : form.name || "Untitled Chatbot"}
{!isNew && existingChatbot && (
{existingChatbot.is_published ? (
Published
) : (
Draft
)}
)}
{/* Pill tab switcher */}
{TABS.map((t) => (
))}
{/* Tab content with fade transition */}
{tab === "settings" && (
)}
{tab === "documents" && chatbotId && (
)}
{tab === "documents" && !chatbotId && (
}
message="Save your chatbot first to upload documents."
accent="amber"
/>
)}
{tab === "preview" && chatbotId && (
)}
{tab === "preview" && !chatbotId && (
}
message="Save your chatbot first to preview it."
accent="gray"
/>
)}
{tab === "deploy" && chatbotId && (
)}
{tab === "deploy" && !chatbotId && (
}
message="Save your chatbot first to access deployment options."
accent="gray"
/>
)}
{/* Toast notification */}
{toast && (
{toast.type === "success" ? (
) : (
)}
{toast.msg}
)}
);
};
// ═══════════════════════════════════════════════════════════════════════════════
// SAVE FIRST PROMPT — shown when chatbot not yet saved
// ═══════════════════════════════════════════════════════════════════════════════
const SaveFirstPrompt: React.FC<{
icon: React.ReactNode;
message: string;
accent: "amber" | "gray";
}> = ({ icon, message, accent }) => (
{icon}
{message}
Fill in the Settings tab and click Save to continue.
);
// ═══════════════════════════════════════════════════════════════════════════════
// SECTION HEADER — reusable divider with title
// ═══════════════════════════════════════════════════════════════════════════════
const SectionHeader: React.FC<{
icon?: React.ReactNode;
title: string;
description?: string;
}> = ({ icon, title, description }) => (
{icon && (
{icon}
)}
{title}
{description && (
{description}
)}
);
// ═══════════════════════════════════════════════════════════════════════════════
// SETTINGS TAB
// ═══════════════════════════════════════════════════════════════════════════════
interface SettingsTabProps {
form: ChatbotFormData;
setForm: React.Dispatch>;
userPlan: string;
}
const SettingsTab: React.FC = ({ form, setForm }) => {
const [advancedOpen, setAdvancedOpen] = useState(false);
const set =
(field: K) =>
(value: ChatbotFormData[K]) => {
setForm((prev) => ({ ...prev, [field]: value }));
};
// ── IMPROVEMENT #3: Load models dynamically from backend ──────────────────
const { data: modelsData, isLoading: modelsLoading } = useQuery({
queryKey: ["available-models"],
queryFn: modelsAPI.available,
staleTime: 5 * 60 * 1000, // cache for 5 minutes
});
const availableModels = modelsData?.models || [];
const upgradeLabel = modelsData?.upgrade_label || null;
return (
{/* ── Basic Info ──────────────────────────────────────────────────────── */}
}
title="Basic Info"
description="Name, description, and greeting message for your chatbot"
/>
set("name")(e.target.value)}
placeholder="e.g. Customer Support Bot"
/>
{/* ── Appearance ──────────────────────────────────────────────────────── */}
}
title="Appearance"
description="Logo and brand color shown in the chat widget"
/>
{/* Logo upload */}
Upload your company logo. It will appear in the chat header.
{/* Live preview */}
Preview of how the chat button will look
{/* ── Advanced Settings (collapsible) ─────────────────────────────────── */}
{advancedOpen && (
{/* AI Model Selection */}
{modelsLoading ? (
Loading available models...
) : (
{availableModels.map((m: AvailableModel) => (
))}
{availableModels.length === 0 && !modelsLoading && (
No models available on your current plan.{" "}
Upgrade
{" "}
to access AI models.
)}
{upgradeLabel && (
)}
)}
{/* Temperature & Max Tokens */}
)}
{/* ── Classification ──────────────────────────────────────────────────── */}
);
};
// ═══════════════════════════════════════════════════════════════════════════════
// LOGO UPLOADER COMPONENT
// ═══════════════════════════════════════════════════════════════════════════════
interface LogoUploaderProps {
logoUrl: string;
onLogoChange: (url: string) => void;
}
const LogoUploader: React.FC = ({
logoUrl,
onLogoChange,
}) => {
const [uploading, setUploading] = useState(false);
const [error, setError] = useState("");
const handleFile = useCallback(
async (file: File) => {
setError("");
const allowedTypes = [
"image/png",
"image/jpeg",
"image/jpg",
"image/gif",
"image/svg+xml",
"image/webp",
];
if (!allowedTypes.includes(file.type)) {
setError("Please upload a PNG, JPG, GIF, SVG, or WebP image.");
return;
}
if (file.size > 2 * 1024 * 1024) {
setError("Image must be under 2MB.");
return;
}
setUploading(true);
try {
const result = await uploadAPI.logo(file);
onLogoChange(result.url);
} catch {
setError("Upload failed. Please try again.");
} finally {
setUploading(false);
}
},
[onLogoChange],
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop: (files) => files[0] && handleFile(files[0]),
accept: {
"image/png": [".png"],
"image/jpeg": [".jpg", ".jpeg"],
"image/gif": [".gif"],
"image/svg+xml": [".svg"],
"image/webp": [".webp"],
},
maxFiles: 1,
multiple: false,
});
if (logoUrl) {
return (
Logo uploaded
);
}
return (
{uploading ? (
Processing...
) : (
<>
{isDragActive
? "Drop your logo here"
: "Click or drag to upload a logo"}
PNG, JPG, SVG, or WebP · Max 2MB
>
)}
{error && (
{error}
)}
);
};
// ═══════════════════════════════════════════════════════════════════════════════
// DOCUMENTS TAB
// ═══════════════════════════════════════════════════════════════════════════════
const DocumentsTab: React.FC<{ chatbotId: string }> = ({ chatbotId }) => {
const queryClient = useQueryClient();
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [toast, setToast] = useState("");
const { data: documents = [], isLoading } = useQuery({
queryKey: ["documents", chatbotId],
queryFn: () => documentsAPI.list(chatbotId),
refetchInterval: (query) => {
const data = query.state.data;
if (data && Array.isArray(data)) {
const hasProcessing = data.some(
(d: { status: string }) =>
d.status === "processing" || d.status === "pending",
);
return hasProcessing ? 3000 : false;
}
return false;
},
});
const deleteMutation = useMutation({
mutationFn: (docId: string) => documentsAPI.delete(chatbotId, docId),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ["documents", chatbotId] }),
});
const handleUpload = async (files: File[]) => {
if (!files.length) return;
setUploading(true);
setUploadProgress(0);
try {
for (const file of files) {
await documentsAPI.upload(chatbotId, file, (pct) =>
setUploadProgress(pct),
);
}
queryClient.invalidateQueries({ queryKey: ["documents", chatbotId] });
setToast("Documents uploaded successfully!");
setTimeout(() => setToast(""), 3000);
} catch (err) {
const e = err as { response?: { data?: { detail?: string } } };
setToast(e.response?.data?.detail || "Upload failed");
setTimeout(() => setToast(""), 3000);
} finally {
setUploading(false);
setUploadProgress(0);
}
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop: handleUpload,
accept: {
"application/pdf": [".pdf"],
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
[".docx"],
"text/csv": [".csv"],
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [
".xlsx",
],
"text/plain": [".txt"],
"text/markdown": [".md"],
},
});
return (
{/* Upload dropzone */}
}
title="Upload Documents"
description="PDF, DOCX, CSV, XLSX, TXT, MD — used to train your chatbot's knowledge base"
/>
{uploading && (
Uploading...
{uploadProgress}%
)}
{/* URL Sources section */}
{/* Document list */}
{isLoading ? (
) : documents.length === 0 ? (
No documents yet
Upload files above to build your chatbot's knowledge base.
) : (
{documents.length} document{documents.length !== 1 ? "s" : ""}
{documents.map(
(doc: {
id: string;
file_name: string;
file_type: string;
file_size: number;
chunk_count: number;
status: string;
}) => (
{getFileIcon(doc.file_type)}
{doc.file_name}
{formatBytes(doc.file_size)}
{doc.chunk_count > 0 && ` · ${doc.chunk_count} chunks`}
{doc.status}
),
)}
)}
{/* Toast */}
{toast && (
{toast}
)}
);
};
// ═══════════════════════════════════════════════════════════════════════════════
// URL SOURCES SECTION
// ═══════════════════════════════════════════════════════════════════════════════
const UrlSourcesSection: React.FC<{ chatbotId: string }> = ({ chatbotId }) => {
const queryClient = useQueryClient();
const [urlInput, setUrlInput] = useState("");
const [adding, setAdding] = useState(false);
const [error, setError] = useState("");
const { data: sources = [], isLoading } = useQuery({
queryKey: ["url-sources", chatbotId],
queryFn: () => urlSourcesAPI.list(chatbotId),
refetchInterval: (query) => {
const d = query.state.data;
if (d && Array.isArray(d)) {
return d.some(
(s: UrlSource) => s.status === "pending" || s.status === "processing",
)
? 3000
: false;
}
return false;
},
});
const deleteMutation = useMutation({
mutationFn: (sourceId: string) => urlSourcesAPI.delete(chatbotId, sourceId),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ["url-sources", chatbotId] }),
});
const handleAdd = async () => {
if (!urlInput.trim()) return;
setError("");
setAdding(true);
try {
await urlSourcesAPI.add(chatbotId, urlInput.trim());
setUrlInput("");
queryClient.invalidateQueries({ queryKey: ["url-sources", chatbotId] });
} catch (err) {
const e = err as { response?: { data?: { detail?: string } } };
setError(e.response?.data?.detail || "Failed to add URL");
} finally {
setAdding(false);
}
};
return (
}
title="URL Sources"
description="Add web pages to your chatbot's knowledge base"
/>
{error && (
{error}
)}
{isLoading ? (
) : sources.length > 0 ? (
{sources.map((src: UrlSource) => (
{src.page_title || src.url}
{src.url}
{src.chunk_count > 0 && (
{src.chunk_count} chunks
)}
))}
) : null}
);
};
// ═══════════════════════════════════════════════════════════════════════════════
// EMBED INSTRUCTIONS
// ═══════════════════════════════════════════════════════════════════════════════
const EMBED_TABS = [
"HTML",
"React / Vue",
"Next.js",
"WordPress",
"Webflow",
"Shopify",
] as const;
type EmbedTab = (typeof EMBED_TABS)[number];
const EmbedInstructions: React.FC<{ embedScript: string }> = ({
embedScript,
}) => {
const [tab, setTab] = useState("HTML");
const [copied, setCopied] = useState(false);
const copy = async (text: string) => {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const instructions: Record = {
HTML: {
hint: "Paste before the closing
{children}