Updates Mar6

This commit is contained in:
belviskhoremk
2026-03-06 23:05:33 +00:00
parent f2a0fd1260
commit d07111a4f2
22 changed files with 2390 additions and 479 deletions

View File

@@ -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 &lt;/body&gt; 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>
)
}