Files
contexta_fe/src/pages/ChatbotBuilderPage.tsx
2026-04-15 18:20:33 +00:00

1965 lines
75 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Tab>("settings");
const [form, setForm] = useState<ChatbotFormData>(DEFAULT_FORM);
const [showTemplatePicker, setShowTemplatePicker] = useState(isNew);
const [toast, setToast] = useState<{
msg: string;
type: "success" | "error";
} | null>(null);
const [chatbotId, setChatbotId] = useState<string | null>(
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<ChatbotFormData>) =>
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 (
<div className="flex items-center justify-center h-full">
<div className="flex flex-col items-center gap-3">
<Spinner className="text-primary-600 w-6 h-6" />
<p className="text-sm text-gray-400">Loading chatbot</p>
</div>
</div>
);
}
if (isNew && showTemplatePicker) {
return (
<div className="flex text-start flex-col h-full">
{/* Template picker header */}
<div className="flex items-center gap-4 px-6 py-4 bg-white border-b border-gray-200 shadow-sm">
<Link
to="/dashboard"
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors text-gray-500 hover:text-gray-700"
>
<ArrowLeft className="w-4 h-4" />
</Link>
<div className="flex-1">
<h1 className="font-semibold text-gray-900 text-lg">
Choose a template
</h1>
<p className="text-xs text-gray-400">
Start from a template or build from scratch
</p>
</div>
</div>
<Card className="p-5">
<div className="flex-1 overflow-y-auto bg-gray-50/50">
<div className="max-w-8xl mx-auto p-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
{CHATBOT_TEMPLATES.map((template) => (
<button
key={template.id}
onClick={() => {
setForm((prev) => ({
...prev,
name: template.name,
description: template.description,
system_prompt: template.system_prompt,
welcome_message: template.welcome_message,
category: template.category,
industry: template.industry,
lead_capture_enabled: template.lead_capture_enabled,
}));
setShowTemplatePicker(false);
}}
className="text-left p-5 border-2 border-gray-200 rounded-xl bg-white hover:border-primary-400 hover:bg-primary-50/50 hover:shadow-md hover:-translate-y-0.5 transition-all duration-200 group"
>
<div className="text-2xl mb-3">{template.icon}</div>
<h3 className="font-semibold text-gray-900 text-sm group-hover:text-primary-700 transition-colors">
{template.name}
</h3>
<p className="text-xs text-gray-500 mt-1 leading-relaxed">
{template.description}
</p>
</button>
))}
</div>
<button
onClick={() => setShowTemplatePicker(false)}
className="w-full mt-5 py-3.5 border-2 border-dashed border-gray-300 rounded-xl text-sm text-gray-500 hover:border-primary-300 hover:text-primary-600 hover:bg-primary-50/30 transition-all duration-200"
>
Start from scratch
</button>
</div>
</div>
</Card>
</div>
);
}
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 (
<div className="flex text-start flex-col h-full bg-gray-50/30">
{/* Top bar */}
<div className="flex items-center gap-3 px-4 sm:px-6 py-3 bg-white border-b border-gray-200 shadow-sm">
<Link
to="/dashboard"
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors text-gray-500 hover:text-gray-700 flex-shrink-0"
>
<ArrowLeft className="w-4 h-4" />
</Link>
{/* Chatbot name + status */}
<div className="flex-1 min-w-0">
<h1 className="font-semibold text-gray-900 text-lg truncate">
{isNew ? "Create Chatbot" : form.name || "Untitled Chatbot"}
</h1>
{!isNew && existingChatbot && (
<p className="text-xs text-gray-400">
{existingChatbot.is_published ? (
<span className="text-green-600 font-medium">Published</span>
) : (
<span className="text-gray-400">Draft</span>
)}
</p>
)}
</div>
<Button
variant="primary"
onClick={handleSave}
loading={createMutation.isPending || updateMutation.isPending}
className="flex-shrink-0"
>
<Save className="w-4.5 h-4.5" />
{isNew ? "Create" : "Save"}
</Button>
</div>
{/* Pill tab switcher */}
<div className="px-4 sm:px-6 py-3 bg-white border-b text-center border-gray-200 overflow-x-auto w-full">
<div className="inline-flex bg-gray-100 rounded-xl justify-center p-1 gap-10">
{TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 whitespace-nowrap ${
tab === t.key
? "bg-white text-primary-700 shadow-sm"
: "text-gray-500 hover:text-gray-700 hover:bg-white/60"
}`}
>
<t.icon className="w-3.5 h-3.5" />
{t.label}
</button>
))}
</div>
</div>
{/* Tab content with fade transition */}
<div className="flex-1 overflow-y-auto">
<div key={tab} className="animate-fade-in p-4 sm:p-6">
{tab === "settings" && (
<SettingsTab
form={form}
setForm={setForm}
userPlan={user?.plan || "free"}
/>
)}
{tab === "documents" && chatbotId && (
<DocumentsTab chatbotId={chatbotId} />
)}
{tab === "documents" && !chatbotId && (
<SaveFirstPrompt
icon={<FileText className="w-7 h-7 text-amber-500" />}
message="Save your chatbot first to upload documents."
accent="amber"
/>
)}
{tab === "preview" && chatbotId && (
<div className="h-[calc(100vh-220px)] min-h-[400px] max-h-[700px] rounded-2xl overflow-hidden border border-gray-200 shadow-sm">
<ChatInterface
chatbotId={chatbotId}
chatbotName={form.name || "Preview"}
welcomeMessage={form.welcome_message}
primaryColor={form.primary_color}
logoUrl={form.logo_url}
isPreview
/>
</div>
)}
{tab === "preview" && !chatbotId && (
<SaveFirstPrompt
icon={<Eye className="w-7 h-7 text-gray-400" />}
message="Save your chatbot first to preview it."
accent="gray"
/>
)}
{tab === "deploy" && chatbotId && (
<DeployTab
chatbotId={chatbotId}
form={form}
setForm={setForm}
isPublished={existingChatbot?.is_published || false}
/>
)}
{tab === "deploy" && !chatbotId && (
<SaveFirstPrompt
icon={<Share2 className="w-7 h-7 text-gray-400" />}
message="Save your chatbot first to access deployment options."
accent="gray"
/>
)}
</div>
<div className="lg:px-32 px-5">
<Button
variant="primary"
onClick={handleSave}
loading={createMutation.isPending || updateMutation.isPending}
className="flex-shrink-0 mb-10 mx-auto w-full"
>
<Save className="w-4.5 h-4.5" />
{isNew ? "Create" : "Save"}
</Button>
</div>
</div>
{/* Toast notification */}
{toast && (
<div
className={`fixed bottom-6 right-6 z-50 flex items-center gap-2.5 px-4 py-3 rounded-xl shadow-xl text-sm font-medium text-white animate-fade-in-up ${
toast.type === "success" ? "bg-green-600" : "bg-red-600"
}`}
>
{toast.type === "success" ? (
<CheckCircle className="w-4 h-4 flex-shrink-0" />
) : (
<AlertCircle className="w-4 h-4 flex-shrink-0" />
)}
{toast.msg}
</div>
)}
</div>
);
};
// ═══════════════════════════════════════════════════════════════════════════════
// SAVE FIRST PROMPT — shown when chatbot not yet saved
// ═══════════════════════════════════════════════════════════════════════════════
const SaveFirstPrompt: React.FC<{
icon: React.ReactNode;
message: string;
accent: "amber" | "gray";
}> = ({ icon, message, accent }) => (
<div className="max-w-2xl mx-auto">
<div
className={`flex items-start gap-4 p-5 rounded-xl border ${
accent === "amber"
? "bg-amber-50 border-amber-200"
: "bg-gray-50 border-gray-200"
}`}
>
<div className="flex-shrink-0 mt-0.5">{icon}</div>
<div>
<p
className={`text-sm font-medium ${accent === "amber" ? "text-amber-800" : "text-gray-700"}`}
>
{message}
</p>
<p
className={`text-xs mt-1 ${accent === "amber" ? "text-amber-600" : "text-gray-500"}`}
>
Fill in the Settings tab and click Save to continue.
</p>
</div>
</div>
</div>
);
// ═══════════════════════════════════════════════════════════════════════════════
// SECTION HEADER — reusable divider with title
// ═══════════════════════════════════════════════════════════════════════════════
const SectionHeader: React.FC<{
icon?: React.ReactNode;
title: string;
description?: string;
}> = ({ icon, title, description }) => (
<div className="flex items-start gap-2.5 mb-5">
{icon && (
<div className="w-8 h-8 rounded-lg bg-primary-50 flex items-center justify-center text-primary-600 flex-shrink-0 mt-0.5">
{icon}
</div>
)}
<div>
<h2 className="font-semibold text-gray-900 text-sm">{title}</h2>
{description && (
<p className="text-xs text-gray-500 mt-0.5">{description}</p>
)}
</div>
</div>
);
// ═══════════════════════════════════════════════════════════════════════════════
// SETTINGS TAB
// ═══════════════════════════════════════════════════════════════════════════════
interface SettingsTabProps {
form: ChatbotFormData;
setForm: React.Dispatch<React.SetStateAction<ChatbotFormData>>;
userPlan: string;
}
const SettingsTab: React.FC<SettingsTabProps> = ({ form, setForm }) => {
const [advancedOpen, setAdvancedOpen] = useState(false);
const set =
<K extends keyof ChatbotFormData>(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 (
<div className="p-6 mx-auto space-y-5">
{/* ── Basic Info ──────────────────────────────────────────────────────── */}
<Card className="overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/60">
<SectionHeader
icon={<MessageSquare className="w-4 h-4" />}
title="Basic Info"
description="Name, description, and greeting message for your chatbot"
/>
</div>
<div className="p-6 space-y-4">
<Input
label="Chatbot Name"
value={form.name}
onChange={(e) => set("name")(e.target.value)}
placeholder="e.g. Customer Support Bot"
/>
<Textarea
label="Description"
value={form.description}
onChange={(e) => set("description")(e.target.value)}
placeholder="What does this chatbot do?"
rows={2}
/>
<Textarea
label="Welcome Message"
value={form.welcome_message}
onChange={(e) => set("welcome_message")(e.target.value)}
rows={2}
hint="The first message visitors will see when opening the chat"
/>
<Textarea
label="System Prompt"
value={form.system_prompt}
onChange={(e) => set("system_prompt")(e.target.value)}
placeholder="You are a helpful assistant for..."
rows={3}
hint="Custom instructions for the AI's behavior and personality (optional)"
/>
</div>
</Card>
{/* ── Appearance ──────────────────────────────────────────────────────── */}
<Card className="overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/60">
<SectionHeader
icon={<ImagePlus className="w-4 h-4" />}
title="Appearance"
description="Logo and brand color shown in the chat widget"
/>
</div>
<div className="p-6 space-y-5">
{/* Logo upload */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
Chatbot Logo
</label>
<p className="text-xs text-gray-400 mb-2.5">
Upload your company logo. It will appear in the chat header.
</p>
<LogoUploader
logoUrl={form.logo_url}
onLogoChange={set("logo_url")}
/>
</div>
<div className="border-t border-gray-100 pt-5">
<label className="block text-sm font-medium text-gray-700 mb-3">
Brand Color
</label>
<div className="flex items-center gap-3 flex-wrap">
<input
type="color"
value={form.primary_color}
onChange={(e) => set("primary_color")(e.target.value)}
className="w-10 h-10 rounded-lg border border-gray-200 cursor-pointer p-0.5 bg-white"
/>
<Input
value={form.primary_color}
onChange={(e) => set("primary_color")(e.target.value)}
className="w-32"
/>
<div className="flex items-center gap-2">
{[
"#6366f1",
"#0ea5e9",
"#10b981",
"#f59e0b",
"#ef4444",
"#8b5cf6",
].map((c) => (
<button
key={c}
onClick={() => set("primary_color")(c)}
title={c}
className={`w-7 h-7 rounded-full border-2 shadow-sm hover:scale-110 transition-transform ${
form.primary_color === c
? "border-gray-800 scale-110"
: "border-white"
}`}
style={{ background: c }}
/>
))}
</div>
</div>
{/* Live preview */}
<div className="mt-4 flex items-center gap-3">
<div
className="w-9 h-9 rounded-xl flex items-center justify-center text-white shadow-sm"
style={{ background: form.primary_color }}
>
<MessageSquare className="w-4 h-4" />
</div>
<p className="text-xs text-gray-400">
Preview of how the chat button will look
</p>
</div>
</div>
</div>
</Card>
{/* ── Advanced Settings (collapsible) ─────────────────────────────────── */}
<Card className="overflow-hidden">
<button
type="button"
onClick={() => setAdvancedOpen(!advancedOpen)}
className="w-full flex items-center justify-between px-6 py-4 text-left hover:bg-gray-50/80 transition-colors"
>
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center text-gray-500">
<Settings2 className="w-4 h-4" />
</div>
<div>
<p className="font-semibold text-gray-900 text-sm">
Advanced Settings
</p>
<p className="text-xs text-gray-400">
AI model, temperature, response length
</p>
</div>
</div>
<div
className={`transition-transform duration-200 ${advancedOpen ? "rotate-180" : ""}`}
>
<ChevronDown className="w-4 h-4 text-gray-400" />
</div>
</button>
{advancedOpen && (
<div className="border-t border-gray-100">
<div className="p-6 space-y-5">
{/* AI Model Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
AI Model
</label>
{modelsLoading ? (
<div className="flex items-center gap-2.5 p-4 bg-gray-50 rounded-xl border border-gray-100">
<Spinner className="w-4 h-4 text-primary-600" />
<span className="text-sm text-gray-500">
Loading available models...
</span>
</div>
) : (
<div className="space-y-2">
{availableModels.map((m: AvailableModel) => (
<label
key={m.id}
className={`flex items-center gap-3 p-3.5 rounded-xl border cursor-pointer transition-all ${
form.model === m.id
? "border-primary-400 bg-primary-50 shadow-sm"
: "border-gray-200 hover:border-gray-300 hover:bg-gray-50/50"
}`}
>
<input
type="radio"
name="model"
value={m.id}
checked={form.model === m.id}
onChange={() => set("model")(m.id)}
className="sr-only"
/>
<div className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center text-xs font-bold text-gray-600 flex-shrink-0">
{m.provider[0]}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900">
{m.name}
{m.is_default && (
<span className="ml-1.5 text-xs text-primary-600 font-normal">
(default)
</span>
)}
</p>
<p className="text-xs text-gray-500">
{m.provider}
{m.description && ` · ${m.description}`}
</p>
</div>
<Badge
variant={
m.badge === "Powerful"
? "purple"
: m.badge === "Free"
? "default"
: "default"
}
>
{m.badge}
</Badge>
</label>
))}
{availableModels.length === 0 && !modelsLoading && (
<div className="p-4 bg-amber-50 rounded-xl border border-amber-200 text-center">
<p className="text-sm text-amber-700">
No models available on your current plan.{" "}
<Link
to="/pricing"
className="text-primary-600 font-medium underline"
>
Upgrade
</Link>{" "}
to access AI models.
</p>
</div>
)}
{upgradeLabel && (
<div className="p-3 bg-gray-50 rounded-xl border border-dashed border-gray-300 text-center">
<p className="text-xs text-gray-500">
🔒{" "}
<Link
to="/pricing"
className="text-primary-600 font-medium"
>
{upgradeLabel}
</Link>
</p>
</div>
)}
</div>
)}
</div>
{/* Temperature & Max Tokens */}
<div className="border-t border-gray-100 pt-5">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-4">
Response Parameters
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Temperature
<span className="ml-1.5 text-xs text-gray-400 font-normal">
({form.temperature})
</span>
</label>
<input
type="range"
min="0"
max="1"
step="0.1"
value={form.temperature}
onChange={(e) =>
set("temperature")(parseFloat(e.target.value))
}
className="w-full accent-primary-600"
/>
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>Precise</span>
<span>Creative</span>
</div>
</div>
<Input
label="Max Tokens"
type="number"
value={form.max_tokens}
onChange={(e) =>
set("max_tokens")(parseInt(e.target.value))
}
min={100}
max={8000}
hint="Max response length"
/>
</div>
</div>
</div>
</div>
)}
</Card>
{/* ── Classification ──────────────────────────────────────────────────── */}
<Card className="overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/60">
<SectionHeader
title="Classification"
description="Helps users discover your chatbot in the marketplace"
/>
</div>
<div className="p-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Select
label="Category"
value={form.category}
onChange={(e) => set("category")(e.target.value)}
options={[
{ value: "", label: "Select category" },
...CATEGORIES.map((c) => ({ value: c, label: c })),
]}
/>
<Select
label="Industry"
value={form.industry}
onChange={(e) => set("industry")(e.target.value)}
options={[
{ value: "", label: "Select industry" },
...INDUSTRIES.map((i) => ({ value: i, label: i })),
]}
/>
</div>
</div>
</Card>
</div>
);
};
// ═══════════════════════════════════════════════════════════════════════════════
// LOGO UPLOADER COMPONENT
// ═══════════════════════════════════════════════════════════════════════════════
interface LogoUploaderProps {
logoUrl: string;
onLogoChange: (url: string) => void;
}
const LogoUploader: React.FC<LogoUploaderProps> = ({
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 (
<div className="flex items-center gap-4">
<div className="relative group">
<img
src={logoUrl}
alt="Chatbot logo"
className="w-16 h-16 rounded-xl object-cover border border-gray-200 shadow-sm"
/>
<button
type="button"
onClick={() => onLogoChange("")}
className="absolute -top-2 -right-2 w-5 h-5 bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-sm hover:bg-red-600"
>
<X className="w-3 h-3" />
</button>
</div>
<div className="flex-1">
<p className="text-sm text-gray-700 font-medium">Logo uploaded</p>
<button
type="button"
onClick={() => onLogoChange("")}
className="text-xs text-red-500 hover:text-red-600 mt-0.5 transition-colors"
>
Remove logo
</button>
</div>
</div>
);
}
return (
<div>
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all duration-200 ${
isDragActive
? "border-primary-400 bg-primary-50 scale-[1.01]"
: "border-gray-300 hover:border-primary-300 hover:bg-gray-50 bg-gray-50/50"
}`}
>
<input {...getInputProps()} />
{uploading ? (
<div className="flex items-center justify-center gap-2">
<Spinner className="w-4 h-4 text-primary-600" />
<span className="text-sm text-gray-500">Processing...</span>
</div>
) : (
<>
<ImagePlus
className={`w-8 h-8 mx-auto mb-2 transition-colors ${isDragActive ? "text-primary-400" : "text-gray-400"}`}
/>
<p className="text-sm text-gray-600">
{isDragActive
? "Drop your logo here"
: "Click or drag to upload a logo"}
</p>
<p className="text-xs text-gray-400 mt-1">
PNG, JPG, SVG, or WebP · Max 2MB
</p>
</>
)}
</div>
{error && (
<p className="text-xs text-red-500 mt-1.5 flex items-center gap-1">
<AlertCircle className="w-3 h-3" /> {error}
</p>
)}
</div>
);
};
// ═══════════════════════════════════════════════════════════════════════════════
// 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 (
<div className="mx-auto space-y-5">
{/* Upload dropzone */}
<Card className="overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/60">
<SectionHeader
icon={<Upload className="w-4 h-4" />}
title="Upload Documents"
description="PDF, DOCX, CSV, XLSX, TXT, MD — used to train your chatbot's knowledge base"
/>
</div>
<div className="p-6">
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all duration-200 ${
isDragActive
? "border-primary-400 bg-primary-50 scale-[1.01]"
: "border-gray-300 hover:border-primary-300 hover:bg-gray-50/80 bg-gray-50/40"
}`}
>
<input {...getInputProps()} />
<Upload
className={`w-10 h-10 mx-auto mb-3 transition-colors ${isDragActive ? "text-primary-400" : "text-gray-300"}`}
/>
<p className="text-sm font-medium text-gray-600">
{isDragActive
? "Drop files here"
: "Click or drag files to upload"}
</p>
<p className="text-xs text-gray-400 mt-1">
PDF, DOCX, CSV, XLSX, TXT, MD
</p>
</div>
{uploading && (
<div className="mt-4 space-y-1.5">
<div className="flex items-center justify-between text-xs text-gray-500">
<span className="flex items-center gap-1.5">
<Spinner className="w-3 h-3 text-primary-600" />
Uploading...
</span>
<span className="font-medium text-primary-600">
{uploadProgress}%
</span>
</div>
<div className="w-full bg-gray-100 rounded-full h-1.5">
<div
className="bg-primary-600 h-1.5 rounded-full transition-all duration-200"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
)}
</div>
</Card>
{/* URL Sources section */}
<UrlSourcesSection chatbotId={chatbotId} />
{/* Document list */}
{isLoading ? (
<div className="flex justify-center py-10">
<Spinner className="text-primary-600" />
</div>
) : documents.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-14 h-14 rounded-2xl bg-gray-100 flex items-center justify-center mb-3">
<FileText className="w-7 h-7 text-gray-300" />
</div>
<p className="text-sm font-medium text-gray-600 mb-1">
No documents yet
</p>
<p className="text-xs text-gray-400 max-w-xs">
Upload files above to build your chatbot's knowledge base.
</p>
</div>
) : (
<Card className="overflow-hidden">
<div className="px-5 py-3 border-b border-gray-100 bg-gray-50/60 flex items-center justify-between">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
{documents.length} document{documents.length !== 1 ? "s" : ""}
</p>
</div>
<div className="divide-y divide-gray-100">
{documents.map(
(doc: {
id: string;
file_name: string;
file_type: string;
file_size: number;
chunk_count: number;
status: string;
}) => (
<div
key={doc.id}
className="flex items-center gap-3 px-5 py-3.5 hover:bg-gray-50/60 transition-colors"
>
<span className="text-lg flex-shrink-0">
{getFileIcon(doc.file_type)}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{doc.file_name}
</p>
<p className="text-xs text-gray-400">
{formatBytes(doc.file_size)}
{doc.chunk_count > 0 && ` · ${doc.chunk_count} chunks`}
</p>
</div>
<StatusDot
status={
doc.status === "completed"
? "completed"
: doc.status === "failed"
? "failed"
: "processing"
}
/>
<span className="text-xs text-gray-400 capitalize hidden sm:block">
{doc.status}
</span>
<button
onClick={() => deleteMutation.mutate(doc.id)}
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors flex-shrink-0"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
),
)}
</div>
</Card>
)}
{/* Toast */}
{toast && (
<div className="fixed bottom-6 right-6 z-50 bg-gray-900 text-white px-4 py-2.5 rounded-xl shadow-xl text-sm font-medium animate-fade-in-up">
{toast}
</div>
)}
</div>
);
};
// ═══════════════════════════════════════════════════════════════════════════════
// 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<UrlSource[]>({
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 (
<Card className="overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/60">
<SectionHeader
icon={<Globe className="w-4 h-4" />}
title="URL Sources"
description="Add web pages to your chatbot's knowledge base"
/>
</div>
<div className="p-6 space-y-4">
<div className="flex gap-2">
<div className="relative flex-1">
<Link2 className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
<input
type="url"
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
placeholder="https://example.com/docs"
className="w-full pl-9 pr-3 border border-gray-200 rounded-lg py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-400 transition-colors"
/>
</div>
<button
onClick={handleAdd}
disabled={adding || !urlInput.trim()}
className="px-4 py-2 bg-primary-600 text-white text-sm rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
>
{adding ? <Spinner className="w-4 h-4 text-white" /> : "Add URL"}
</button>
</div>
{error && (
<p className="text-xs text-red-500 flex items-center gap-1">
<AlertCircle className="w-3 h-3" /> {error}
</p>
)}
{isLoading ? (
<div className="flex justify-center py-4">
<Spinner className="text-primary-600" />
</div>
) : sources.length > 0 ? (
<div className="divide-y divide-gray-100 border border-gray-200 rounded-xl overflow-hidden">
{sources.map((src: UrlSource) => (
<div
key={src.id}
className="flex items-center gap-3 p-3 hover:bg-gray-50/60 transition-colors"
>
<Link2 className="w-4 h-4 text-gray-400 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-gray-700 truncate">
{src.page_title || src.url}
</p>
<p className="text-[10px] text-gray-400 truncate">
{src.url}
</p>
</div>
<StatusDot
status={
src.status === "completed"
? "completed"
: src.status === "failed"
? "failed"
: "processing"
}
/>
{src.chunk_count > 0 && (
<span className="text-[10px] text-gray-400 hidden sm:block">
{src.chunk_count} chunks
</span>
)}
<button
onClick={() => deleteMutation.mutate(src.id)}
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
) : null}
</div>
</Card>
);
};
// ═══════════════════════════════════════════════════════════════════════════════
// 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<EmbedTab>("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<EmbedTab, { code: string; hint: string }> = {
HTML: {
hint: "Paste before the closing </body> tag in your HTML file.",
code: embedScript,
},
"React / Vue": {
hint: "Add to your index.html (in the public/ folder) before the closing </body> tag. The widget runs outside React/Vue's DOM tree — no component needed.",
code: embedScript,
},
"Next.js": {
hint: "Use the built-in Script component inside your root layout so it loads on every page.",
code: `// app/layout.tsx
import Script from 'next/script'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
{children}
<Script
src="${embedScript.match(/src="([^"]+)"/)?.[1] ?? ""}"
data-chatbot="${embedScript.match(/data-chatbot="([^"]+)"/)?.[1] ?? ""}"
strategy="afterInteractive"
/>
</body>
</html>
)
}`,
},
WordPress: {
hint: 'Go to Appearance → Theme File Editor → footer.php and paste before </body>. Or use a plugin like "Insert Headers and Footers".',
code: embedScript,
},
Webflow: {
hint: "Go to Site Settings → Custom Code → Footer Code and paste the script there. Republish your site.",
code: embedScript,
},
Shopify: {
hint: "Go to Online Store → Themes → Edit code → layout/theme.liquid and paste before </body>.",
code: embedScript,
},
};
const { code, hint } = instructions[tab];
return (
<div className="p-6 space-y-4">
{/* Tabs */}
<div className="flex flex-wrap gap-1.5 border-b border-gray-100 pb-4">
{EMBED_TABS.map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
tab === t
? "bg-primary-50 text-primary-700 border border-primary-200"
: "text-gray-500 hover:text-gray-700 hover:bg-gray-50"
}`}
>
{t}
</button>
))}
</div>
{/* Hint */}
<p className="text-xs text-gray-500 leading-relaxed">{hint}</p>
{/* Code block */}
<div className="relative rounded-xl border border-gray-200 bg-gray-50 overflow-hidden">
<pre className="p-4 text-xs text-gray-800 overflow-x-auto whitespace-pre font-mono">
<code>{code}</code>
</pre>
<button
onClick={() => copy(code)}
className="absolute top-2.5 right-2.5 flex items-center gap-1.5 px-2.5 py-1.5 bg-white border border-gray-200 text-gray-500 rounded-lg text-xs hover:bg-gray-50 hover:text-gray-700 transition-colors shadow-sm"
>
{copied ? (
<>
<CheckCircle className="w-3 h-3 text-green-500" /> Copied
</>
) : (
<>
<Copy className="w-3 h-3" /> Copy
</>
)}
</button>
</div>
</div>
);
};
// ═══════════════════════════════════════════════════════════════════════════════
// DEPLOY TAB
// ═══════════════════════════════════════════════════════════════════════════════
interface DeployTabProps {
chatbotId: string;
form: ChatbotFormData;
setForm: React.Dispatch<React.SetStateAction<ChatbotFormData>>;
isPublished: boolean;
}
const DeployTab: React.FC<DeployTabProps> = ({
chatbotId,
form,
setForm,
isPublished,
}) => {
const [copied, setCopied] = useState("");
const apiUrl = import.meta.env.VITE_API_URL || "http://localhost:8000";
const appUrl = import.meta.env.VITE_APP_URL || "http://localhost:5173";
const chatUrl = `${appUrl}/chat/${chatbotId}`;
const embedScript = `<script src="${apiUrl}/widget.js" data-chatbot="${chatbotId}"></script>`;
const copy = async (text: string, key: string) => {
await navigator.clipboard.writeText(text);
setCopied(key);
setTimeout(() => setCopied(""), 2000);
};
const set =
<K extends keyof ChatbotFormData>(field: K) =>
(value: ChatbotFormData[K]) => {
setForm((prev) => ({ ...prev, [field]: value }));
};
return (
<div className="max-w-2xl mx-auto space-y-5">
{/* ── Public Chat Link ─────────────────────────────────────────────────── */}
<Card className="overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/60">
<SectionHeader
icon={<Globe className="w-4 h-4" />}
title="Public Chat Link"
description="Share a direct link to your chatbot with anyone"
/>
</div>
<div className="p-6">
{isPublished ? (
<div className="space-y-3">
<div className="flex gap-2">
<input
readOnly
value={chatUrl}
className="flex-1 border border-gray-200 rounded-lg px-3 py-2 text-sm bg-gray-50 text-gray-700 font-mono truncate"
/>
<button
onClick={() => copy(chatUrl, "chatUrl")}
title="Copy link"
className="px-3 py-2 border border-gray-200 rounded-lg text-sm hover:bg-gray-50 transition-colors flex items-center gap-1.5 text-gray-600"
>
{copied === "chatUrl" ? (
<>
<CheckCircle className="w-4 h-4 text-green-600" />{" "}
<span className="text-xs text-green-600">Copied</span>
</>
) : (
<>
<Copy className="w-4 h-4" />{" "}
<span className="text-xs">Copy</span>
</>
)}
</button>
<a
href={chatUrl}
target="_blank"
rel="noopener noreferrer"
title="Open chat"
className="px-3 py-2 border border-gray-200 rounded-lg text-sm hover:bg-gray-50 transition-colors text-gray-600"
>
<Share2 className="w-4 h-4" />
</a>
</div>
</div>
) : (
<div className="flex items-center gap-3 p-4 bg-amber-50 border border-amber-200 rounded-xl">
<AlertCircle className="w-4 h-4 text-amber-500 flex-shrink-0" />
<p className="text-sm text-amber-700">
Publish your chatbot in the Deploy settings to get a public chat
link.
</p>
</div>
)}
</div>
</Card>
{/* ── Embed Code ──────────────────────────────────────────────────────── */}
<Card className="overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/60">
<SectionHeader
icon={<Code className="w-4 h-4" />}
title="Embed Code"
description="Add a chat widget to any website with one line of code"
/>
</div>
{!isPublished ? (
<div className="p-6">
<div className="flex items-center gap-3 p-4 bg-amber-50 border border-amber-200 rounded-xl">
<AlertCircle className="w-4 h-4 text-amber-500 flex-shrink-0" />
<p className="text-sm text-amber-700">
Publish your chatbot first to get the embed code.
</p>
</div>
</div>
) : (
<EmbedInstructions embedScript={embedScript} />
)}
</Card>
{/* ── Lead Capture ────────────────────────────────────────────────────── */}
<Card className="overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/60">
<SectionHeader
title="Lead Capture"
description="Collect visitor information before or during the conversation"
/>
</div>
<div className="p-6 space-y-4">
<label className="flex items-center gap-3 cursor-pointer group">
<div
className={`relative w-10 h-6 rounded-full transition-colors duration-200 ${form.lead_capture_enabled ? "bg-primary-600" : "bg-gray-200"}`}
>
<div
className={`absolute top-1 w-4 h-4 rounded-full bg-white shadow-sm transition-all duration-200 ${form.lead_capture_enabled ? "left-5" : "left-1"}`}
/>
<input
type="checkbox"
checked={form.lead_capture_enabled}
onChange={(e) => set("lead_capture_enabled")(e.target.checked)}
className="sr-only"
/>
</div>
<div>
<span className="text-sm font-medium text-gray-700">
Enable lead capture
</span>
<p className="text-xs text-gray-400">
Ask visitors for their contact info
</p>
</div>
</label>
{form.lead_capture_enabled && (
<div className="space-y-4 pl-0 border-t border-gray-100 pt-4 animate-fade-in">
<div>
<p className="text-xs font-semibold text-gray-600 uppercase tracking-wide mb-2.5">
Collect fields
</p>
<div className="grid grid-cols-2 gap-2">
{["email", "name", "phone", "company"].map((field) => (
<label
key={field}
className="flex items-center gap-2.5 cursor-pointer p-2.5 rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors"
>
<input
type="checkbox"
checked={form.lead_capture_fields.includes(field)}
disabled={field === "email"}
onChange={(e) => {
const fields = e.target.checked
? [...form.lead_capture_fields, field]
: form.lead_capture_fields.filter(
(f) => f !== field,
);
set("lead_capture_fields")(fields);
}}
className="w-3.5 h-3.5 rounded accent-primary-600"
/>
<span className="text-sm text-gray-700 capitalize">
{field}
{field === "email" && (
<span className="text-xs text-gray-400 ml-1">
(required)
</span>
)}
</span>
</label>
))}
</div>
</div>
<div>
<p className="text-xs font-semibold text-gray-600 uppercase tracking-wide mb-2.5">
When to show form
</p>
<select
value={form.lead_capture_trigger}
onChange={(e) => set("lead_capture_trigger")(e.target.value)}
className="border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-400 bg-white transition-colors w-full sm:w-auto"
>
<option value="after_first_message">
After first message
</option>
<option value="before_first_message">
Before first message
</option>
</select>
</div>
</div>
)}
</div>
</Card>
{/* ── Human Handoff ───────────────────────────────────────────────────── */}
<Card className="overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/60">
<SectionHeader
title="Human Handoff"
description="Let visitors request to speak with a human agent"
/>
</div>
<div className="p-6 space-y-4">
<label className="flex items-center gap-3 cursor-pointer">
<div
className={`relative w-10 h-6 rounded-full transition-colors duration-200 ${form.handoff_enabled ? "bg-primary-600" : "bg-gray-200"}`}
>
<div
className={`absolute top-1 w-4 h-4 rounded-full bg-white shadow-sm transition-all duration-200 ${form.handoff_enabled ? "left-5" : "left-1"}`}
/>
<input
type="checkbox"
checked={form.handoff_enabled}
onChange={(e) => set("handoff_enabled")(e.target.checked)}
className="sr-only"
/>
</div>
<div>
<span className="text-sm font-medium text-gray-700">
Enable human handoff
</span>
<p className="text-xs text-gray-400">
Triggered when user says "human", "agent", etc.
</p>
</div>
</label>
{form.handoff_enabled && (
<div className="border-t border-gray-100 pt-4 space-y-3 animate-fade-in">
<div>
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wide mb-2">
Handoff message
</label>
<textarea
value={form.handoff_message}
onChange={(e) => set("handoff_message")(e.target.value)}
rows={2}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-400 transition-colors"
/>
</div>
<div className="flex items-start gap-2 p-3 bg-gray-50 rounded-lg">
<Webhook className="w-3.5 h-3.5 text-gray-400 mt-0.5 flex-shrink-0" />
<p className="text-xs text-gray-500">
Configure the n8n webhook URL in your backend{" "}
<code className="bg-gray-200 px-1 rounded">
N8N_HANDOFF_WEBHOOK_URL
</code>{" "}
to receive notifications.
</p>
</div>
</div>
)}
</div>
</Card>
{/* ── Branding ────────────────────────────────────────────────────────── */}
<Card className="overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/60">
<SectionHeader
title="Branding"
description="Control the Contexta attribution in your chat widget"
/>
</div>
<div className="p-6">
<label className="flex items-start gap-3 cursor-pointer">
<div
className={`relative w-10 h-6 rounded-full transition-colors duration-200 mt-0.5 ${form.show_branding ? "bg-primary-600" : "bg-gray-200"}`}
>
<div
className={`absolute top-1 w-4 h-4 rounded-full bg-white shadow-sm transition-all duration-200 ${form.show_branding ? "left-5" : "left-1"}`}
/>
<input
type="checkbox"
checked={form.show_branding}
onChange={(e) => set("show_branding")(e.target.checked)}
className="sr-only"
/>
</div>
<div>
<span className="text-sm font-medium text-gray-700">
Show "Powered by Contexta"
</span>
<p className="text-xs text-gray-400 mt-0.5">
Remove branding by upgrading to Pro plan or above
</p>
</div>
</label>
</div>
</Card>
{/* Messaging Channels */}
<ChannelsSection chatbotId={chatbotId} />
{/* Appointment Booking */}
<Card className="overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/60">
<SectionHeader
icon={<Calendar className="w-4 h-4" />}
title="Appointment Booking"
description="Let customers book appointments directly through your chatbot"
/>
</div>
<div className="p-6 space-y-4">
<label className="flex items-center justify-between gap-4 cursor-pointer">
<div>
<span className="text-sm font-medium text-gray-700">
Enable appointment booking
</span>
<p className="text-xs text-gray-400 mt-0.5">
When enabled, the chatbot will guide users to your booking page
and mention it in conversations.
</p>
</div>
<button
type="button"
onClick={() => set("booking_enabled")(!form.booking_enabled)}
className={cn(
"relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent",
"transition-colors duration-200 ease-in-out focus:outline-none",
form.booking_enabled ? "bg-primary-600" : "bg-gray-200",
)}
>
<span
className={cn(
"pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out",
form.booking_enabled ? "translate-x-4" : "translate-x-0",
)}
/>
</button>
</label>
{form.booking_enabled && (
<div className="bg-primary-50 border border-primary-100 rounded-xl p-4 space-y-2">
<p className="text-xs font-semibold text-primary-800">
Booking page URL:
</p>
<div className="flex gap-2">
<input
readOnly
value={`${appUrl}/book/${chatbotId}`}
className="flex-1 border border-primary-200 rounded-lg px-2.5 py-1.5 text-xs bg-white text-gray-700 font-mono truncate"
/>
<button
onClick={() =>
copy(`${appUrl}/book/${chatbotId}`, "bookingUrl")
}
className="px-2.5 py-1.5 border border-primary-200 rounded-lg text-xs hover:bg-primary-100 transition-colors flex items-center gap-1"
>
{copied === "bookingUrl" ? (
<CheckCircle className="w-3.5 h-3.5 text-green-600" />
) : (
<Copy className="w-3.5 h-3.5" />
)}
</button>
</div>
<p className="text-xs text-primary-700">
Share this link on your website or social media. Set your
available hours in the{" "}
<a href="/appointments" className="underline font-medium">
Appointments page
</a>
.
</p>
</div>
)}
</div>
</Card>
</div>
);
};
// ═══════════════════════════════════════════════════════════════════════════════
// CHANNELS SECTION
// ═══════════════════════════════════════════════════════════════════════════════
const ChannelsSection: React.FC<{ chatbotId: string }> = ({ chatbotId }) => {
const queryClient = useQueryClient();
const [telegramToken, setTelegramToken] = useState("");
const [copiedKey, setCopiedKey] = useState("");
const { data: channels = [], isLoading } = useQuery<ChannelConnection[]>({
queryKey: ["channels", chatbotId],
queryFn: () => channelsAPI.list(chatbotId),
});
const telegramConn = channels.find((c) => c.channel === "telegram");
const connectTelegram = useMutation({
mutationFn: () => channelsAPI.connectTelegram(chatbotId, telegramToken),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["channels", chatbotId] });
setTelegramToken("");
},
});
const disconnect = useMutation({
mutationFn: (id: string) => channelsAPI.disconnect(id),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ["channels", chatbotId] }),
});
const copy = async (text: string, key: string) => {
await navigator.clipboard.writeText(text);
setCopiedKey(key);
setTimeout(() => setCopiedKey(""), 2000);
};
return (
<Card className="overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/60">
<SectionHeader
icon={<MessageSquare className="w-4 h-4" />}
title="Messaging Channels"
description="Connect your chatbot to Telegram"
/>
</div>
<div className="divide-y divide-gray-100">
{/* ── Telegram ── */}
<div className="p-6 space-y-3">
<div className="flex items-center gap-2">
<span className="text-base"></span>
<h3 className="font-semibold text-sm text-gray-800">Telegram</h3>
{!isLoading && telegramConn && (
<span className="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700 font-medium border border-green-200">
Connected
</span>
)}
</div>
{isLoading ? null : telegramConn ? (
<div className="bg-gray-50 rounded-xl p-4 space-y-2 border border-gray-200">
<p className="text-sm text-gray-700">
Bot:{" "}
<a
href={`https://t.me/${telegramConn.bot_username}`}
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 hover:underline font-medium"
>
@{telegramConn.bot_username}
</a>
</p>
<p className="text-xs text-gray-500">
Share this bot link with your customers they open it and start
chatting.
</p>
<button
onClick={() => disconnect.mutate(telegramConn.id)}
disabled={disconnect.isPending}
className="text-xs text-red-500 hover:text-red-700 disabled:opacity-50 transition-colors font-medium"
>
Disconnect
</button>
</div>
) : (
<div className="space-y-3">
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 space-y-2">
<p className="text-xs font-semibold text-blue-800">
How to create a Telegram bot (2 minutes):
</p>
<ol className="list-decimal list-inside space-y-1 text-xs text-blue-700">
<li>
Open Telegram and search for <strong>@BotFather</strong>
</li>
<li>
Send{" "}
<code className="bg-blue-100 px-1 rounded">/newbot</code>
</li>
<li>Choose a name and username for your bot</li>
<li>BotFather will send you a token copy it</li>
<li>Paste the token below and click Connect</li>
</ol>
<p className="text-xs text-blue-600">
Once connected, share your bot link (e.g.{" "}
<code className="bg-blue-100 px-1 rounded">
t.me/YourBotName
</code>
) with customers.
</p>
</div>
<div className="flex gap-2">
<input
type="password"
placeholder="Bot token from @BotFather"
value={telegramToken}
onChange={(e) => setTelegramToken(e.target.value)}
className="flex-1 border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-400 transition-colors"
/>
<button
onClick={() => connectTelegram.mutate()}
disabled={!telegramToken.trim() || connectTelegram.isPending}
className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium disabled:opacity-50 hover:bg-primary-700 transition-colors whitespace-nowrap"
>
{connectTelegram.isPending ? (
<Spinner className="w-4 h-4 text-white" />
) : (
"Connect"
)}
</button>
</div>
{connectTelegram.isError && (
<p className="text-xs text-red-600 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{(connectTelegram.error as any)?.response?.data?.detail ||
"Failed to connect. Check your token."}
</p>
)}
</div>
)}
</div>
</div>
</Card>
);
};