mirror of
http://88.130.71.182:3000/BlitTech/contexta_fe.git
synced 2026-06-13 08:54:09 +00:00
Updates Mar6
This commit is contained in:
@@ -1,17 +1,19 @@
|
||||
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 } from '@/services/api'
|
||||
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, LANGUAGES, formatBytes, getFileIcon } from '@/lib/utils'
|
||||
import { CATEGORIES, INDUSTRIES, formatBytes, getFileIcon } from '@/lib/utils'
|
||||
import { ChatInterface } from '@/components/ChatInterface'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import type { ChatbotFormData, AvailableModel } from '@/types'
|
||||
import type { ChatbotFormData, AvailableModel, UrlSource, ChannelConnection } from '@/types'
|
||||
import { CHATBOT_TEMPLATES } from '@/data/templates'
|
||||
import {
|
||||
ArrowLeft, Save, Eye, Upload, Trash2, FileText,
|
||||
Bot, Sliders, Globe, Code, AlertCircle, CheckCircle,
|
||||
ChevronDown, ChevronRight, Settings2, ImagePlus, X
|
||||
Sliders, AlertCircle, CheckCircle,
|
||||
ChevronDown, ChevronRight, Settings2, ImagePlus, X,
|
||||
Link2, Copy, Globe, Webhook, Share2, Code, MessageSquare
|
||||
} from 'lucide-react'
|
||||
|
||||
const DEFAULT_FORM: ChatbotFormData = {
|
||||
@@ -27,9 +29,17 @@ const DEFAULT_FORM: ChatbotFormData = {
|
||||
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'],
|
||||
}
|
||||
|
||||
type Tab = 'settings' | 'documents' | 'preview'
|
||||
type Tab = 'settings' | 'documents' | 'preview' | 'deploy'
|
||||
|
||||
export const ChatbotBuilderPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
@@ -39,6 +49,7 @@ export const ChatbotBuilderPage: React.FC = () => {
|
||||
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)
|
||||
|
||||
@@ -51,6 +62,7 @@ export const ChatbotBuilderPage: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (existingChatbot) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setForm({
|
||||
name: existingChatbot.name,
|
||||
description: existingChatbot.description || '',
|
||||
@@ -64,6 +76,14 @@ export const ChatbotBuilderPage: React.FC = () => {
|
||||
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,
|
||||
})
|
||||
setChatbotId(existingChatbot.id)
|
||||
}
|
||||
@@ -77,7 +97,7 @@ export const ChatbotBuilderPage: React.FC = () => {
|
||||
navigate(`/chatbots/${data.id}/edit`, { replace: true })
|
||||
showToast('Chatbot created!', 'success')
|
||||
},
|
||||
onError: (err: any) => showToast(err.response?.data?.detail || 'Failed to create', 'error'),
|
||||
onError: (err: { response?: { data?: { detail?: string } } }) => showToast(err.response?.data?.detail || 'Failed to create', 'error'),
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
@@ -87,7 +107,7 @@ export const ChatbotBuilderPage: React.FC = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['chatbots'] })
|
||||
showToast('Settings saved!', 'success')
|
||||
},
|
||||
onError: (err: any) => showToast(err.response?.data?.detail || 'Save failed', 'error'),
|
||||
onError: (err: { response?: { data?: { detail?: string } } }) => showToast(err.response?.data?.detail || 'Save failed', 'error'),
|
||||
})
|
||||
|
||||
const handleSave = () => {
|
||||
@@ -108,6 +128,55 @@ export const ChatbotBuilderPage: React.FC = () => {
|
||||
return <div className="flex items-center justify-center h-full"><Spinner className="text-primary-600" /></div>
|
||||
}
|
||||
|
||||
if (isNew && showTemplatePicker) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center gap-4 px-6 py-4 bg-white border-b border-gray-200">
|
||||
<Link to="/dashboard" className="p-1.5 hover:bg-gray-100 rounded-lg">
|
||||
<ArrowLeft className="w-4 h-4 text-gray-600" />
|
||||
</Link>
|
||||
<h1 className="font-semibold text-gray-900 flex-1">Choose a template</h1>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<p className="text-sm text-gray-500 mb-6">Start from a template or build from scratch</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
||||
{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-4 border-2 border-gray-200 rounded-xl hover:border-primary-400 hover:bg-primary-50 transition-colors group"
|
||||
>
|
||||
<div className="text-2xl mb-2">{template.icon}</div>
|
||||
<h3 className="font-semibold text-gray-900 text-sm group-hover:text-primary-700">{template.name}</h3>
|
||||
<p className="text-xs text-gray-500 mt-1">{template.description}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowTemplatePicker(false)}
|
||||
className="w-full py-3 border-2 border-dashed border-gray-300 rounded-xl text-sm text-gray-500 hover:border-gray-400 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
Start from scratch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Top bar */}
|
||||
@@ -137,6 +206,7 @@ export const ChatbotBuilderPage: React.FC = () => {
|
||||
{ key: 'settings' as Tab, label: 'Settings', icon: Sliders },
|
||||
{ key: 'documents' as Tab, label: 'Documents', icon: FileText },
|
||||
{ key: 'preview' as Tab, label: 'Preview', icon: Eye },
|
||||
{ key: 'deploy' as Tab, label: 'Deploy', icon: Share2 },
|
||||
]).map(t => (
|
||||
<button
|
||||
key={t.key}
|
||||
@@ -187,6 +257,20 @@ export const ChatbotBuilderPage: React.FC = () => {
|
||||
<p className="text-gray-600 text-sm">Save your chatbot first to preview it.</p>
|
||||
</Card>
|
||||
)}
|
||||
{tab === 'deploy' && chatbotId && (
|
||||
<DeployTab
|
||||
chatbotId={chatbotId}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
isPublished={existingChatbot?.is_published || false}
|
||||
/>
|
||||
)}
|
||||
{tab === 'deploy' && !chatbotId && (
|
||||
<Card className="p-8 text-center">
|
||||
<Share2 className="w-8 h-8 text-gray-400 mx-auto mb-3" />
|
||||
<p className="text-gray-600 text-sm">Save your chatbot first to access deployment options.</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Toast */}
|
||||
@@ -213,10 +297,10 @@ interface SettingsTabProps {
|
||||
userPlan: string
|
||||
}
|
||||
|
||||
const SettingsTab: React.FC<SettingsTabProps> = ({ form, setForm, userPlan }) => {
|
||||
const SettingsTab: React.FC<SettingsTabProps> = ({ form, setForm }) => {
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
|
||||
const set = (field: keyof ChatbotFormData) => (value: any) => {
|
||||
const set = <K extends keyof ChatbotFormData>(field: K) => (value: ChatbotFormData[K]) => {
|
||||
setForm(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
@@ -228,7 +312,6 @@ const SettingsTab: React.FC<SettingsTabProps> = ({ form, setForm, userPlan }) =>
|
||||
})
|
||||
|
||||
const availableModels = modelsData?.models || []
|
||||
const hasPremiumAccess = modelsData?.has_premium_access || false
|
||||
const upgradeLabel = modelsData?.upgrade_label || null
|
||||
|
||||
return (
|
||||
@@ -482,38 +565,22 @@ const LogoUploader: React.FC<LogoUploaderProps> = ({ logoUrl, onLogoChange }) =>
|
||||
|
||||
const handleFile = useCallback(async (file: File) => {
|
||||
setError('')
|
||||
|
||||
// Validate file type
|
||||
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
|
||||
}
|
||||
|
||||
// Validate file size (max 2MB)
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
setError('Image must be under 2MB.')
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
try {
|
||||
// Convert to base64 data URL for now.
|
||||
// In production, you'd upload to Supabase Storage and get a public URL back.
|
||||
// e.g.: const { data } = await supabase.storage.from('logos').upload(path, file)
|
||||
// onLogoChange(supabase.storage.from('logos').getPublicUrl(data.path).data.publicUrl)
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = () => {
|
||||
onLogoChange(reader.result as string)
|
||||
setUploading(false)
|
||||
}
|
||||
reader.onerror = () => {
|
||||
setError('Failed to read file.')
|
||||
setUploading(false)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
const result = await uploadAPI.logo(file)
|
||||
onLogoChange(result.url)
|
||||
} catch {
|
||||
setError('Upload failed. Please try again.')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}, [onLogoChange])
|
||||
@@ -614,7 +681,7 @@ const DocumentsTab: React.FC<{ chatbotId: string }> = ({ chatbotId }) => {
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data
|
||||
if (data && Array.isArray(data)) {
|
||||
const hasProcessing = data.some((d: any) => d.status === 'processing' || d.status === 'pending')
|
||||
const hasProcessing = data.some((d: { status: string }) => d.status === 'processing' || d.status === 'pending')
|
||||
return hasProcessing ? 3000 : false
|
||||
}
|
||||
return false
|
||||
@@ -638,8 +705,9 @@ const DocumentsTab: React.FC<{ chatbotId: string }> = ({ chatbotId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['documents', chatbotId] })
|
||||
setToast('Documents uploaded successfully!')
|
||||
setTimeout(() => setToast(''), 3000)
|
||||
} catch (err: any) {
|
||||
setToast(err.response?.data?.detail || 'Upload failed')
|
||||
} catch (err) {
|
||||
const e = err as { response?: { data?: { detail?: string } } }
|
||||
setToast(e.response?.data?.detail || 'Upload failed')
|
||||
setTimeout(() => setToast(''), 3000)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
@@ -693,6 +761,9 @@ const DocumentsTab: React.FC<{ chatbotId: string }> = ({ chatbotId }) => {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* URL Sources section */}
|
||||
<UrlSourcesSection chatbotId={chatbotId} />
|
||||
|
||||
{/* Document list */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
@@ -705,7 +776,7 @@ const DocumentsTab: React.FC<{ chatbotId: string }> = ({ chatbotId }) => {
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="divide-y divide-gray-100">
|
||||
{documents.map((doc: any) => (
|
||||
{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 p-4">
|
||||
<span className="text-xl">{getFileIcon(doc.file_type)}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -738,4 +809,525 @@ const DocumentsTab: React.FC<{ chatbotId: string }> = ({ chatbotId }) => {
|
||||
)}
|
||||
</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="p-6 space-y-4">
|
||||
<h3 className="font-semibold text-sm uppercase tracking-wide text-gray-500 flex items-center gap-2">
|
||||
<Globe className="w-4 h-4" />
|
||||
URL Sources
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500">Add web pages to your chatbot's knowledge base.</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={urlInput}
|
||||
onChange={e => setUrlInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
placeholder="https://example.com/docs"
|
||||
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"
|
||||
/>
|
||||
<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 transition-colors"
|
||||
>
|
||||
{adding ? '...' : 'Add URL'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{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">
|
||||
<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' ? 'success' : src.status === 'failed' ? 'error' : 'warning'}
|
||||
label={src.status}
|
||||
/>
|
||||
{src.chunk_count > 0 && (
|
||||
<span className="text-[10px] text-gray-400">{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}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// 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-6">
|
||||
{/* Public Chat Link */}
|
||||
<Card className="p-6 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-4 h-4 text-primary-600" />
|
||||
<h2 className="font-semibold text-sm uppercase tracking-wide text-gray-500">Public Chat Link</h2>
|
||||
</div>
|
||||
{isPublished ? (
|
||||
<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"
|
||||
/>
|
||||
<button
|
||||
onClick={() => copy(chatUrl, 'chatUrl')}
|
||||
className="px-3 py-2 border border-gray-200 rounded-lg text-sm hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
{copied === 'chatUrl' ? '✓' : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
<a
|
||||
href={chatUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-3 py-2 border border-gray-200 rounded-lg text-sm hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Share2 className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 bg-gray-50 rounded-lg p-3">
|
||||
Publish your chatbot first to get a public chat link.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Embed Code */}
|
||||
<Card className="p-6 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="w-4 h-4 text-primary-600" />
|
||||
<h2 className="font-semibold text-sm uppercase tracking-wide text-gray-500">Embed Code</h2>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Paste this script tag before the closing </body> tag on any website.</p>
|
||||
<div className="relative">
|
||||
<pre className="bg-gray-900 text-green-400 rounded-xl p-4 text-xs overflow-x-auto">
|
||||
<code>{embedScript}</code>
|
||||
</pre>
|
||||
<button
|
||||
onClick={() => copy(embedScript, 'embed')}
|
||||
className="absolute top-2 right-2 px-2 py-1 bg-gray-700 text-gray-300 rounded text-xs hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
{copied === 'embed' ? '✓ Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Lead Capture */}
|
||||
<Card className="p-6 space-y-4">
|
||||
<h2 className="font-semibold text-sm uppercase tracking-wide text-gray-500">Lead Capture</h2>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.lead_capture_enabled}
|
||||
onChange={e => set('lead_capture_enabled')(e.target.checked)}
|
||||
className="w-4 h-4 rounded accent-primary-600"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Enable lead capture</span>
|
||||
</label>
|
||||
{form.lead_capture_enabled && (
|
||||
<div className="space-y-3 pl-7">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-600 mb-2">Collect fields:</p>
|
||||
<div className="space-y-1.5">
|
||||
{['email', 'name', 'phone', 'company'].map(field => (
|
||||
<label key={field} className="flex items-center gap-2 cursor-pointer">
|
||||
<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' ? ' (required)' : ''}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-600 mb-1.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-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500/20"
|
||||
>
|
||||
<option value="after_first_message">After first message</option>
|
||||
<option value="before_first_message">Before first message</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Human Handoff */}
|
||||
<Card className="p-6 space-y-4">
|
||||
<h2 className="font-semibold text-sm uppercase tracking-wide text-gray-500">Human Handoff</h2>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.handoff_enabled}
|
||||
onChange={e => set('handoff_enabled')(e.target.checked)}
|
||||
className="w-4 h-4 rounded accent-primary-600"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Enable human handoff</span>
|
||||
</label>
|
||||
{form.handoff_enabled && (
|
||||
<div className="space-y-3 pl-7">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1.5">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"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 flex items-center gap-1.5">
|
||||
<Webhook className="w-3 h-3" />
|
||||
Triggers when user says: "human", "agent", "speak to someone"...
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
Configure n8n webhook URL in backend .env (N8N_HANDOFF_WEBHOOK_URL) to receive notifications.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Branding */}
|
||||
<Card className="p-6 space-y-4">
|
||||
<h2 className="font-semibold text-sm uppercase tracking-wide text-gray-500">Branding</h2>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.show_branding}
|
||||
onChange={e => set('show_branding')(e.target.checked)}
|
||||
className="w-4 h-4 rounded accent-primary-600"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm text-gray-700">Show "Powered by Contexta"</span>
|
||||
<p className="text-xs text-gray-400 mt-0.5">Remove branding on Pro plan and above</p>
|
||||
</div>
|
||||
</label>
|
||||
</Card>
|
||||
|
||||
{/* Messaging Channels */}
|
||||
<ChannelsSection chatbotId={chatbotId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// CHANNELS SECTION
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const ChannelsSection: React.FC<{ chatbotId: string }> = ({ chatbotId }) => {
|
||||
const queryClient = useQueryClient()
|
||||
const [telegramToken, setTelegramToken] = useState('')
|
||||
const [waKeyword, setWaKeyword] = 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 whatsappConn = channels.find(c => c.channel === 'whatsapp')
|
||||
|
||||
const connectTelegram = useMutation({
|
||||
mutationFn: () => channelsAPI.connectTelegram(chatbotId, telegramToken),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['channels', chatbotId] })
|
||||
setTelegramToken('')
|
||||
},
|
||||
})
|
||||
|
||||
const connectWhatsapp = useMutation({
|
||||
mutationFn: () => channelsAPI.connectWhatsapp(chatbotId, waKeyword || undefined),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['channels', chatbotId] })
|
||||
setWaKeyword('')
|
||||
},
|
||||
})
|
||||
|
||||
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="p-6 space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="w-4 h-4 text-primary-600" />
|
||||
<h2 className="font-semibold text-sm uppercase tracking-wide text-gray-500">Messaging Channels</h2>
|
||||
</div>
|
||||
|
||||
{/* ── Telegram ── */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base">✈️</span>
|
||||
<h3 className="font-medium text-sm text-gray-800">Telegram</h3>
|
||||
{telegramConn && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700 font-medium">Connected</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? null : telegramConn ? (
|
||||
<div className="bg-gray-50 rounded-lg p-3 space-y-2">
|
||||
<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"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="bg-blue-50 border border-blue-100 rounded-lg p-3 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"
|
||||
/>
|
||||
<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 ? 'Connecting…' : 'Connect'}
|
||||
</button>
|
||||
</div>
|
||||
{connectTelegram.isError && (
|
||||
<p className="text-xs text-red-600">
|
||||
{(connectTelegram.error as any)?.response?.data?.detail || 'Failed to connect. Check your token.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<hr className="border-gray-100" />
|
||||
|
||||
{/* ── WhatsApp ── */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base">💬</span>
|
||||
<h3 className="font-medium text-sm text-gray-800">WhatsApp</h3>
|
||||
{whatsappConn && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700 font-medium">Connected</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? null : whatsappConn ? (
|
||||
<div className="bg-gray-50 rounded-lg p-3 space-y-2.5">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-600 mb-1">Your chatbot keyword:</p>
|
||||
<code className="bg-white border border-gray-200 px-2 py-0.5 rounded text-sm font-mono text-gray-800">
|
||||
{whatsappConn.wa_keyword}
|
||||
</code>
|
||||
</div>
|
||||
{whatsappConn.wa_link && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-600 mb-1">Share this link with your customers:</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
readOnly
|
||||
value={whatsappConn.wa_link}
|
||||
className="flex-1 border border-gray-200 rounded-lg px-2 py-1.5 text-xs bg-white text-gray-700 font-mono"
|
||||
/>
|
||||
<button
|
||||
onClick={() => copy(whatsappConn.wa_link!, 'waLink')}
|
||||
className="px-2 py-1.5 border border-gray-200 rounded-lg text-xs hover:bg-gray-100 transition-colors"
|
||||
title="Copy link"
|
||||
>
|
||||
{copiedKey === 'waLink' ? '✓' : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1.5">
|
||||
When customers tap this link, WhatsApp opens with a pre-filled message. They just tap Send and the chat begins automatically.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => disconnect.mutate(whatsappConn.id)}
|
||||
disabled={disconnect.isPending}
|
||||
className="text-xs text-red-500 hover:text-red-700 disabled:opacity-50"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="bg-green-50 border border-green-100 rounded-lg p-3 space-y-2">
|
||||
<p className="text-xs font-semibold text-green-800">How WhatsApp works with Contexta:</p>
|
||||
<ol className="list-decimal list-inside space-y-1 text-xs text-green-700">
|
||||
<li>Contexta provides a shared WhatsApp Business number — no setup needed on your end</li>
|
||||
<li>Your chatbot gets a unique keyword (auto-generated, or you can choose one)</li>
|
||||
<li>You get a link like <code className="bg-green-100 px-1 rounded">wa.me/15551234567?text=START+ACME</code></li>
|
||||
<li>Add this link to your website, email signature, or anywhere customers can see it</li>
|
||||
<li>Customers tap the link → WhatsApp opens → they tap Send → your chatbot replies</li>
|
||||
</ol>
|
||||
<p className="text-xs text-green-600 font-medium">
|
||||
You can customise the keyword below. Letters and numbers only, max 12 characters.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
placeholder="Keyword (optional — auto-generated if blank)"
|
||||
value={waKeyword}
|
||||
onChange={e => setWaKeyword(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ''))}
|
||||
maxLength={12}
|
||||
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 uppercase tracking-wider"
|
||||
/>
|
||||
<button
|
||||
onClick={() => connectWhatsapp.mutate()}
|
||||
disabled={connectWhatsapp.isPending}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-medium disabled:opacity-50 hover:bg-green-700 transition-colors whitespace-nowrap"
|
||||
>
|
||||
{connectWhatsapp.isPending ? 'Setting up…' : 'Enable'}
|
||||
</button>
|
||||
</div>
|
||||
{connectWhatsapp.isError && (
|
||||
<p className="text-xs text-red-600">
|
||||
{(connectWhatsapp.error as any)?.response?.data?.detail || 'Failed to enable. Please try again.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user