mirror of
http://88.130.71.182:3000/BlitTech/contexta_fe.git
synced 2026-06-12 23:23:22 +00:00
1965 lines
75 KiB
TypeScript
1965 lines
75 KiB
TypeScript
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>
|
||
);
|
||
};
|