mirror of
http://88.130.71.182:3000/BlitTech/contexta_fe.git
synced 2026-06-12 23:23:22 +00:00
Updates Mar6
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "contexta-fe",
|
"name": "contexta_fe",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
12
src/App.tsx
12
src/App.tsx
@@ -10,6 +10,8 @@ import './App.css'
|
|||||||
const LandingPage = lazy(() => import('@/pages/LandingPage').then(m => ({ default: m.LandingPage })))
|
const LandingPage = lazy(() => import('@/pages/LandingPage').then(m => ({ default: m.LandingPage })))
|
||||||
const LoginPage = lazy(() => import('@/pages/AuthPages').then(m => ({ default: m.LoginPage })))
|
const LoginPage = lazy(() => import('@/pages/AuthPages').then(m => ({ default: m.LoginPage })))
|
||||||
const SignupPage = lazy(() => import('@/pages/AuthPages').then(m => ({ default: m.SignupPage })))
|
const SignupPage = lazy(() => import('@/pages/AuthPages').then(m => ({ default: m.SignupPage })))
|
||||||
|
const ForgotPasswordPage = lazy(() => import('@/pages/ForgotPasswordPage').then(m => ({ default: m.ForgotPasswordPage })))
|
||||||
|
const ResetPasswordPage = lazy(() => import('@/pages/ResetPasswordPage').then(m => ({ default: m.ResetPasswordPage })))
|
||||||
const DashboardPage = lazy(() => import('@/pages/DashboardPage').then(m => ({ default: m.DashboardPage })))
|
const DashboardPage = lazy(() => import('@/pages/DashboardPage').then(m => ({ default: m.DashboardPage })))
|
||||||
const ChatbotBuilderPage = lazy(() => import('@/pages/ChatbotBuilderPage').then(m => ({ default: m.ChatbotBuilderPage })))
|
const ChatbotBuilderPage = lazy(() => import('@/pages/ChatbotBuilderPage').then(m => ({ default: m.ChatbotBuilderPage })))
|
||||||
const MarketplacePage = lazy(() => import('@/pages/MarketplacePage').then(m => ({ default: m.MarketplacePage })))
|
const MarketplacePage = lazy(() => import('@/pages/MarketplacePage').then(m => ({ default: m.MarketplacePage })))
|
||||||
@@ -17,6 +19,9 @@ const ChatbotDetailPage = lazy(() => import('@/pages/MarketplacePage').then(m =>
|
|||||||
const PricingPage = lazy(() => import('@/pages/PricingPage').then(m => ({ default: m.PricingPage })))
|
const PricingPage = lazy(() => import('@/pages/PricingPage').then(m => ({ default: m.PricingPage })))
|
||||||
const SettingsPage = lazy(() => import('@/pages/SettingsPage').then(m => ({ default: m.SettingsPage })))
|
const SettingsPage = lazy(() => import('@/pages/SettingsPage').then(m => ({ default: m.SettingsPage })))
|
||||||
const AnalyticsPage = lazy(() => import('@/pages/AnalyticsPage').then(m => ({ default: m.AnalyticsPage })))
|
const AnalyticsPage = lazy(() => import('@/pages/AnalyticsPage').then(m => ({ default: m.AnalyticsPage })))
|
||||||
|
const PublicChatPage = lazy(() => import('@/pages/PublicChatPage').then(m => ({ default: m.PublicChatPage })))
|
||||||
|
const InboxPage = lazy(() => import('@/pages/InboxPage').then(m => ({ default: m.InboxPage })))
|
||||||
|
const LeadsPage = lazy(() => import('@/pages/LeadsPage').then(m => ({ default: m.LeadsPage })))
|
||||||
|
|
||||||
const PageLoader = () => (
|
const PageLoader = () => (
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
@@ -55,9 +60,14 @@ export const App: React.FC = () => (
|
|||||||
<Route path="/marketplace" element={<SmartPublicRoute><MarketplacePage /></SmartPublicRoute>} />
|
<Route path="/marketplace" element={<SmartPublicRoute><MarketplacePage /></SmartPublicRoute>} />
|
||||||
<Route path="/marketplace/:id" element={<SmartPublicRoute><ChatbotDetailPage /></SmartPublicRoute>} />
|
<Route path="/marketplace/:id" element={<SmartPublicRoute><ChatbotDetailPage /></SmartPublicRoute>} />
|
||||||
|
|
||||||
|
{/* Public chat - no auth, no layout */}
|
||||||
|
<Route path="/chat/:id" element={<PublicChatPage />} />
|
||||||
|
|
||||||
{/* Auth */}
|
{/* Auth */}
|
||||||
<Route path="/login" element={<PublicOnlyRoute><LoginPage /></PublicOnlyRoute>} />
|
<Route path="/login" element={<PublicOnlyRoute><LoginPage /></PublicOnlyRoute>} />
|
||||||
<Route path="/signup" element={<PublicOnlyRoute><SignupPage /></PublicOnlyRoute>} />
|
<Route path="/signup" element={<PublicOnlyRoute><SignupPage /></PublicOnlyRoute>} />
|
||||||
|
<Route path="/forgot-password" element={<PublicOnlyRoute><ForgotPasswordPage /></PublicOnlyRoute>} />
|
||||||
|
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||||
|
|
||||||
{/* Protected */}
|
{/* Protected */}
|
||||||
<Route path="/dashboard" element={<PrivateRoute><DashboardPage /></PrivateRoute>} />
|
<Route path="/dashboard" element={<PrivateRoute><DashboardPage /></PrivateRoute>} />
|
||||||
@@ -65,6 +75,8 @@ export const App: React.FC = () => (
|
|||||||
<Route path="/chatbots/new" element={<PrivateRoute><ChatbotBuilderPage /></PrivateRoute>} />
|
<Route path="/chatbots/new" element={<PrivateRoute><ChatbotBuilderPage /></PrivateRoute>} />
|
||||||
<Route path="/chatbots/:id/edit" element={<PrivateRoute><ChatbotBuilderPage /></PrivateRoute>} />
|
<Route path="/chatbots/:id/edit" element={<PrivateRoute><ChatbotBuilderPage /></PrivateRoute>} />
|
||||||
<Route path="/chatbots/:id/preview" element={<PrivateRoute><ChatbotBuilderPage /></PrivateRoute>} />
|
<Route path="/chatbots/:id/preview" element={<PrivateRoute><ChatbotBuilderPage /></PrivateRoute>} />
|
||||||
|
<Route path="/inbox" element={<PrivateRoute><InboxPage /></PrivateRoute>} />
|
||||||
|
<Route path="/leads" element={<PrivateRoute><LeadsPage /></PrivateRoute>} />
|
||||||
<Route path="/settings" element={<PrivateRoute><SettingsPage /></PrivateRoute>} />
|
<Route path="/settings" element={<PrivateRoute><SettingsPage /></PrivateRoute>} />
|
||||||
<Route path="/settings/billing" element={<PrivateRoute><SettingsPage /></PrivateRoute>} />
|
<Route path="/settings/billing" element={<PrivateRoute><SettingsPage /></PrivateRoute>} />
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { chatAPI } from '@/services/api'
|
import { chatAPI, leadsAPI } from '@/services/api'
|
||||||
import type { ChatMessage, SourceDocument } from '@/types'
|
import type { ChatMessage } from '@/types'
|
||||||
import { Send, Bot, User, FileText, ChevronDown, ChevronUp } from 'lucide-react'
|
import { Send, Bot, FileText, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
|
|
||||||
interface ChatInterfaceProps {
|
interface ChatInterfaceProps {
|
||||||
chatbotId: string
|
chatbotId: string
|
||||||
@@ -12,10 +12,23 @@ interface ChatInterfaceProps {
|
|||||||
logoUrl?: string
|
logoUrl?: string
|
||||||
isPreview?: boolean
|
isPreview?: boolean
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
|
showBranding?: boolean
|
||||||
|
leadCaptureEnabled?: boolean
|
||||||
|
leadCaptureFields?: string[]
|
||||||
|
leadCaptureTrigger?: string
|
||||||
|
handoffEnabled?: boolean
|
||||||
|
handoffMessage?: string
|
||||||
|
chatbotIdForLeads?: string
|
||||||
|
conversationId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChatInterface: React.FC<ChatInterfaceProps> = ({
|
export const ChatInterface: React.FC<ChatInterfaceProps> = ({
|
||||||
chatbotId, chatbotName, welcomeMessage, primaryColor, logoUrl, isPreview = false, sessionId: externalSessionId
|
chatbotId, chatbotName, welcomeMessage, primaryColor, logoUrl,
|
||||||
|
isPreview = false, sessionId: externalSessionId,
|
||||||
|
showBranding = false, leadCaptureEnabled = false,
|
||||||
|
leadCaptureFields = ['email'], leadCaptureTrigger = 'after_first_message',
|
||||||
|
handoffEnabled = false, handoffMessage: _handoffMessage,
|
||||||
|
chatbotIdForLeads, conversationId,
|
||||||
}) => {
|
}) => {
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([
|
const [messages, setMessages] = useState<ChatMessage[]>([
|
||||||
{ id: '0', role: 'assistant', content: welcomeMessage }
|
{ id: '0', role: 'assistant', content: welcomeMessage }
|
||||||
@@ -33,6 +46,12 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
|
|||||||
return newId
|
return newId
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [feedbackSent, setFeedbackSent] = useState<Set<string>>(new Set())
|
||||||
|
const [showLeadForm, setShowLeadForm] = useState(false)
|
||||||
|
const [leadSubmitted, setLeadSubmitted] = useState(false)
|
||||||
|
const [leadFormData, setLeadFormData] = useState({ email: '', name: '', phone: '', company: '' })
|
||||||
|
const [activeConversationId, setActiveConversationId] = useState<string | null>(conversationId || null)
|
||||||
|
|
||||||
const [expandedSources, setExpandedSources] = useState<Set<string>>(new Set())
|
const [expandedSources, setExpandedSources] = useState<Set<string>>(new Set())
|
||||||
const bottomRef = useRef<HTMLDivElement>(null)
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||||
@@ -68,11 +87,27 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
|
|||||||
sources: response.sources,
|
sources: response.sources,
|
||||||
}
|
}
|
||||||
setMessages(prev => [...prev, assistantMsg])
|
setMessages(prev => [...prev, assistantMsg])
|
||||||
} catch (err: any) {
|
|
||||||
|
// Track conversation ID if returned
|
||||||
|
if ((response as { conversation_id?: string }).conversation_id) {
|
||||||
|
setActiveConversationId((response as { conversation_id?: string }).conversation_id!)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if lead capture needed (after first message)
|
||||||
|
if (response.needs_lead_capture && !leadSubmitted) {
|
||||||
|
setShowLeadForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle handoff
|
||||||
|
if (response.handoff) {
|
||||||
|
// handoffMessage will be shown via the assistantMsg content (backend sends it)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as { response?: { data?: { detail?: string } } }
|
||||||
const errMsg: ChatMessage = {
|
const errMsg: ChatMessage = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: err.response?.data?.detail || 'Sorry, I encountered an error. Please try again.',
|
content: e.response?.data?.detail || 'Sorry, I encountered an error. Please try again.',
|
||||||
}
|
}
|
||||||
setMessages(prev => [...prev, errMsg])
|
setMessages(prev => [...prev, errMsg])
|
||||||
} finally {
|
} finally {
|
||||||
@@ -81,6 +116,31 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleFeedback = async (msgId: string, feedback: 'positive' | 'negative') => {
|
||||||
|
if (feedbackSent.has(msgId)) return
|
||||||
|
try {
|
||||||
|
await chatAPI.feedback(chatbotId, msgId, feedback)
|
||||||
|
setFeedbackSent(prev => new Set(prev).add(msgId))
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLeadSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
try {
|
||||||
|
await leadsAPI.submit(chatbotId, {
|
||||||
|
...leadFormData,
|
||||||
|
conversation_id: activeConversationId || undefined,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
} finally {
|
||||||
|
setLeadSubmitted(true)
|
||||||
|
setShowLeadForm(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -91,7 +151,7 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
|
|||||||
const toggleSources = (msgId: string) => {
|
const toggleSources = (msgId: string) => {
|
||||||
setExpandedSources(prev => {
|
setExpandedSources(prev => {
|
||||||
const n = new Set(prev)
|
const n = new Set(prev)
|
||||||
n.has(msgId) ? n.delete(msgId) : n.add(msgId)
|
if (n.has(msgId)) { n.delete(msgId) } else { n.add(msgId) }
|
||||||
return n
|
return n
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -126,56 +186,64 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
|
|||||||
<div className="flex flex-col h-full bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden">
|
<div className="flex flex-col h-full bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-3 px-4 py-3 border-b border-gray-100"
|
className="flex items-center gap-3 px-4 py-3.5 border-b border-black/10"
|
||||||
style={{ background: primaryColor }}
|
style={{ background: primaryColor }}
|
||||||
>
|
>
|
||||||
{logoUrl ? (
|
{logoUrl ? (
|
||||||
<img
|
<img
|
||||||
src={logoUrl}
|
src={logoUrl}
|
||||||
alt={chatbotName}
|
alt={chatbotName}
|
||||||
className="w-8 h-8 rounded-lg object-cover bg-white/20"
|
className="w-8 h-8 rounded-lg object-cover bg-white/20 shadow-sm"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-8 h-8 bg-white/20 rounded-lg flex items-center justify-center">
|
<div className="w-8 h-8 bg-white/20 rounded-lg flex items-center justify-center backdrop-blur-sm">
|
||||||
<Bot className="w-4 h-4 text-white" />
|
<Bot className="w-4 h-4 text-white" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="text-sm font-semibold text-white">{chatbotName}</h3>
|
<h3 className="text-sm font-semibold text-white leading-tight">{chatbotName}</h3>
|
||||||
{isPreview && <span className="text-xs text-white/70">Preview mode</span>}
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-green-300 animate-pulse-soft" />
|
||||||
|
<span className="text-xs text-white/70">{isPreview ? 'Preview mode' : 'Online'}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-gray-50/30">
|
||||||
{messages.map((msg) => (
|
{messages.map((msg, msgIdx) => (
|
||||||
<div key={msg.id} className={cn('flex gap-3', msg.role === 'user' ? 'justify-end' : '')}>
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className={cn('flex gap-2.5 animate-fade-in-up', msg.role === 'user' ? 'justify-end' : '')}
|
||||||
|
style={{ animationDelay: `${msgIdx * 30}ms`, animationFillMode: 'both' }}
|
||||||
|
>
|
||||||
{msg.role === 'assistant' && <BotAvatar />}
|
{msg.role === 'assistant' && <BotAvatar />}
|
||||||
|
<div className="flex flex-col gap-1 max-w-[80%]">
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'max-w-[80%] rounded-2xl px-4 py-2.5 text-sm',
|
'rounded-2xl px-4 py-2.5 text-sm leading-relaxed',
|
||||||
msg.role === 'user'
|
msg.role === 'user'
|
||||||
? 'text-white rounded-br-md'
|
? 'text-white rounded-br-sm shadow-sm'
|
||||||
: 'bg-gray-100 text-gray-800 rounded-bl-md'
|
: 'bg-white text-gray-800 rounded-bl-sm border border-gray-100 shadow-sm'
|
||||||
)} style={msg.role === 'user' ? { background: primaryColor } : undefined}>
|
)} style={msg.role === 'user' ? { background: primaryColor } : undefined}>
|
||||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||||
|
|
||||||
{/* Sources */}
|
{/* Sources */}
|
||||||
{msg.sources && msg.sources.length > 0 && (
|
{msg.sources && msg.sources.length > 0 && (
|
||||||
<div className="mt-2 pt-2 border-t border-gray-200/50">
|
<div className="mt-2 pt-2 border-t border-gray-200/60">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleSources(msg.id)}
|
onClick={() => toggleSources(msg.id)}
|
||||||
className="flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700"
|
className="flex items-center gap-1 text-xs text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
>
|
>
|
||||||
<FileText className="w-3 h-3" />
|
<FileText className="w-3 h-3" />
|
||||||
{msg.sources.length} source{msg.sources.length > 1 ? 's' : ''}
|
{msg.sources.length} source{msg.sources.length > 1 ? 's' : ''}
|
||||||
{expandedSources.has(msg.id) ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
{expandedSources.has(msg.id) ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
||||||
</button>
|
</button>
|
||||||
{expandedSources.has(msg.id) && (
|
{expandedSources.has(msg.id) && (
|
||||||
<div className="mt-2 space-y-2">
|
<div className="mt-2 space-y-1.5">
|
||||||
{msg.sources.map((src, i) => (
|
{msg.sources.map((src, i) => (
|
||||||
<div key={i} className="bg-white/80 rounded-lg p-2 text-xs">
|
<div key={i} className="bg-gray-50 rounded-lg p-2.5 text-xs border border-gray-100">
|
||||||
<p className="font-medium text-gray-700">{src.document_name}</p>
|
<p className="font-medium text-gray-600 mb-1">{src.document_name}</p>
|
||||||
<p className="text-gray-500 mt-1 line-clamp-3">{src.chunk_text}</p>
|
<p className="text-gray-400 line-clamp-3 leading-relaxed">{src.chunk_text}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -183,17 +251,49 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{msg.role === 'assistant' && msg.id !== '0' && (
|
||||||
|
<div className="flex items-center gap-0.5 ml-1">
|
||||||
|
<button
|
||||||
|
onClick={() => handleFeedback(msg.id, 'positive')}
|
||||||
|
disabled={feedbackSent.has(msg.id)}
|
||||||
|
className={cn(
|
||||||
|
'text-xs px-1.5 py-0.5 rounded-lg transition-all',
|
||||||
|
feedbackSent.has(msg.id)
|
||||||
|
? 'text-gray-200 cursor-default'
|
||||||
|
: 'text-gray-300 hover:text-green-600 hover:bg-green-50 active:scale-90'
|
||||||
|
)}
|
||||||
|
title="Helpful"
|
||||||
|
>
|
||||||
|
👍
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleFeedback(msg.id, 'negative')}
|
||||||
|
disabled={feedbackSent.has(msg.id)}
|
||||||
|
className={cn(
|
||||||
|
'text-xs px-1.5 py-0.5 rounded-lg transition-all',
|
||||||
|
feedbackSent.has(msg.id)
|
||||||
|
? 'text-gray-200 cursor-default'
|
||||||
|
: 'text-gray-300 hover:text-red-500 hover:bg-red-50 active:scale-90'
|
||||||
|
)}
|
||||||
|
title="Not helpful"
|
||||||
|
>
|
||||||
|
👎
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-2.5 animate-fade-in">
|
||||||
<BotAvatar />
|
<BotAvatar />
|
||||||
<div className="bg-gray-100 rounded-2xl rounded-bl-md px-4 py-3">
|
<div className="bg-white rounded-2xl rounded-bl-sm border border-gray-100 shadow-sm px-4 py-3">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1.5 items-center">
|
||||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
<div className="w-2 h-2 bg-gray-300 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
<div className="w-2 h-2 bg-gray-300 rounded-full animate-bounce" style={{ animationDelay: '160ms' }} />
|
||||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
<div className="w-2 h-2 bg-gray-300 rounded-full animate-bounce" style={{ animationDelay: '320ms' }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -201,29 +301,97 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
|
|||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showLeadForm && !leadSubmitted && (
|
||||||
|
<div className="mx-4 mb-3 p-4 bg-gradient-to-br from-blue-50 to-indigo-50 rounded-xl border border-blue-100 shadow-sm animate-fade-in-up">
|
||||||
|
<p className="text-sm font-semibold text-blue-900 mb-0.5">
|
||||||
|
Quick question before we continue
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-blue-600 mb-3">Share your details and we'll follow up if needed.</p>
|
||||||
|
<form onSubmit={handleLeadSubmit} className="space-y-2">
|
||||||
|
{(leadCaptureFields || ['email']).includes('email') && (
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="Email address *"
|
||||||
|
value={leadFormData.email}
|
||||||
|
onChange={e => setLeadFormData(prev => ({ ...prev, email: e.target.value }))}
|
||||||
|
className="w-full border border-blue-200 bg-white rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400/30 placeholder-blue-300"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(leadCaptureFields || []).includes('name') && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Your name"
|
||||||
|
value={leadFormData.name}
|
||||||
|
onChange={e => setLeadFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||||
|
className="w-full border border-blue-200 bg-white rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400/30 placeholder-blue-300"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(leadCaptureFields || []).includes('phone') && (
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
placeholder="Phone number"
|
||||||
|
value={leadFormData.phone}
|
||||||
|
onChange={e => setLeadFormData(prev => ({ ...prev, phone: e.target.value }))}
|
||||||
|
className="w-full border border-blue-200 bg-white rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400/30 placeholder-blue-300"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(leadCaptureFields || []).includes('company') && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Company name"
|
||||||
|
value={leadFormData.company}
|
||||||
|
onChange={e => setLeadFormData(prev => ({ ...prev, company: e.target.value }))}
|
||||||
|
className="w-full border border-blue-200 bg-white rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400/30 placeholder-blue-300"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 bg-blue-600 hover:bg-blue-700 active:scale-95 text-white text-sm py-2 rounded-lg transition-all font-medium"
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setShowLeadForm(false); setLeadSubmitted(true) }}
|
||||||
|
className="px-3 text-sm text-blue-400 hover:text-blue-600 transition-colors"
|
||||||
|
>
|
||||||
|
Skip
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Input */}
|
{/* Input */}
|
||||||
<div className="border-t border-gray-100 p-3">
|
<div className="border-t border-gray-100 p-3 bg-white">
|
||||||
<div className="flex gap-2 items-end">
|
<div className="flex gap-2 items-end">
|
||||||
<textarea
|
<textarea
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={e => setInput(e.target.value)}
|
onChange={e => setInput(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Type a message..."
|
placeholder="Type a message…"
|
||||||
rows={1}
|
rows={1}
|
||||||
className="flex-1 resize-none border border-gray-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-400 transition-all"
|
className="flex-1 resize-none border border-gray-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-400 transition-all bg-gray-50 focus:bg-white placeholder-gray-400"
|
||||||
style={{ minHeight: '42px', maxHeight: '120px' }}
|
style={{ minHeight: '42px', maxHeight: '120px' }}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={send}
|
onClick={send}
|
||||||
disabled={loading || !input.trim()}
|
disabled={loading || !input.trim()}
|
||||||
className="p-2.5 rounded-xl text-white transition-colors disabled:opacity-50"
|
className="p-2.5 rounded-xl text-white transition-all disabled:opacity-40 active:scale-90 hover:brightness-110 shadow-sm"
|
||||||
style={{ background: primaryColor }}
|
style={{ background: primaryColor }}
|
||||||
>
|
>
|
||||||
<Send className="w-4 h-4" />
|
<Send className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{showBranding && (
|
||||||
|
<div className="text-center py-1.5 bg-gray-50 border-t border-gray-100">
|
||||||
|
<span className="text-[10px] text-gray-300 font-medium tracking-wide">Powered by Contexta</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -5,12 +5,14 @@ import { useAuthStore } from '@/store/authStore'
|
|||||||
import { authAPI } from '@/services/api'
|
import { authAPI } from '@/services/api'
|
||||||
import { getPlanColor } from '@/lib/utils'
|
import { getPlanColor } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
Bot, LayoutDashboard, ShoppingBag, Settings,
|
LayoutDashboard, ShoppingBag, Settings,
|
||||||
LogOut, Menu, X, Sparkles, ChevronDown, BarChart3
|
LogOut, Menu, Sparkles, BarChart3, Mail, Users
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
{ label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||||
|
{ label: 'Inbox', href: '/inbox', icon: Mail },
|
||||||
|
{ label: 'Leads', href: '/leads', icon: Users },
|
||||||
{ label: 'Analytics', href: '/analytics', icon: BarChart3 },
|
{ label: 'Analytics', href: '/analytics', icon: BarChart3 },
|
||||||
{ label: 'Marketplace', href: '/marketplace', icon: ShoppingBag },
|
{ label: 'Marketplace', href: '/marketplace', icon: ShoppingBag },
|
||||||
{ label: 'Settings', href: '/settings', icon: Settings },
|
{ label: 'Settings', href: '/settings', icon: Settings },
|
||||||
@@ -23,7 +25,7 @@ export const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children })
|
|||||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try { await authAPI.logout() } catch {}
|
try { await authAPI.logout() } catch { /* intentionally ignored */ }
|
||||||
logout()
|
logout()
|
||||||
navigate('/login')
|
navigate('/login')
|
||||||
}
|
}
|
||||||
@@ -42,10 +44,10 @@ export const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children })
|
|||||||
)}>
|
)}>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex items-center gap-2 px-6 py-5 border-b border-gray-100">
|
<div className="flex items-center gap-2 px-6 py-5 border-b border-gray-100">
|
||||||
<div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
|
<div className="w-8 h-8 bg-gradient-to-br from-primary-500 to-primary-700 rounded-lg flex items-center justify-center shadow-sm">
|
||||||
<Sparkles className="w-4 h-4 text-white" />
|
<Sparkles className="w-4 h-4 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-bold text-gray-900 text-lg">Contexta</span>
|
<span className="font-bold text-gray-900 text-lg tracking-tight">Contexta</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Nav */}
|
{/* Nav */}
|
||||||
@@ -57,24 +59,25 @@ export const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children })
|
|||||||
key={href}
|
key={href}
|
||||||
to={href}
|
to={href}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors',
|
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-150',
|
||||||
active
|
active
|
||||||
? 'bg-primary-50 text-primary-700'
|
? 'bg-primary-50 text-primary-700 shadow-sm'
|
||||||
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
|
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 hover:translate-x-0.5'
|
||||||
)}
|
)}
|
||||||
onClick={() => setSidebarOpen(false)}
|
onClick={() => setSidebarOpen(false)}
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4" />
|
<Icon className={cn('w-4 h-4 transition-transform duration-150', active && 'scale-110')} />
|
||||||
{label}
|
{label}
|
||||||
|
{active && <span className="ml-auto w-1.5 h-1.5 rounded-full bg-primary-500" />}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* User profile */}
|
{/* User profile */}
|
||||||
<div className="px-4 py-4 border-t border-gray-100">
|
<div className="px-4 py-4 border-t border-gray-100 bg-gray-50/50">
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<div className="w-9 h-9 rounded-full bg-primary-100 flex items-center justify-center text-primary-700 font-semibold text-sm">
|
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center text-white font-semibold text-sm shadow-sm">
|
||||||
{user?.email?.charAt(0).toUpperCase() || '?'}
|
{user?.email?.charAt(0).toUpperCase() || '?'}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|||||||
export const Button: React.FC<ButtonProps> = ({
|
export const Button: React.FC<ButtonProps> = ({
|
||||||
variant = 'primary', size = 'md', loading, disabled, children, className, ...props
|
variant = 'primary', size = 'md', loading, disabled, children, className, ...props
|
||||||
}) => {
|
}) => {
|
||||||
const base = 'inline-flex items-center justify-center gap-2 font-medium rounded-lg transition-all focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed'
|
const base = 'inline-flex items-center justify-center gap-2 font-medium rounded-lg transition-all focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed active:scale-95'
|
||||||
const variants = {
|
const variants = {
|
||||||
primary: 'bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500',
|
primary: 'bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500',
|
||||||
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-400',
|
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-400',
|
||||||
@@ -131,7 +131,11 @@ export const Card: React.FC<{ children: React.ReactNode; className?: string; onC
|
|||||||
children, className, onClick
|
children, className, onClick
|
||||||
}) => (
|
}) => (
|
||||||
<div
|
<div
|
||||||
className={cn('bg-white rounded-xl border border-gray-200 shadow-sm', onClick && 'cursor-pointer hover:shadow-md transition-shadow', className)}
|
className={cn(
|
||||||
|
'bg-white rounded-xl border border-gray-200 shadow-sm transition-all duration-200',
|
||||||
|
onClick && 'cursor-pointer hover:-translate-y-1 hover:shadow-lg hover:border-gray-300',
|
||||||
|
className
|
||||||
|
)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -173,8 +177,8 @@ export const Spinner: React.FC<{ className?: string }> = ({ className }) => (
|
|||||||
export const EmptyState: React.FC<{
|
export const EmptyState: React.FC<{
|
||||||
icon: React.ReactNode; title: string; description: string; action?: React.ReactNode
|
icon: React.ReactNode; title: string; description: string; action?: React.ReactNode
|
||||||
}> = ({ icon, title, description, action }) => (
|
}> = ({ icon, title, description, action }) => (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
<div className="flex flex-col items-center justify-center py-16 text-center animate-fade-in-up">
|
||||||
<div className="w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center mb-4 text-gray-400">
|
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-primary-50 to-primary-100 flex items-center justify-center mb-4 text-primary-400 shadow-sm">
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
|
||||||
@@ -195,9 +199,9 @@ interface ModalProps {
|
|||||||
export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, className }) => {
|
export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, className }) => {
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 animate-fade-in">
|
||||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
|
||||||
<div className={cn('relative bg-white rounded-2xl shadow-xl w-full max-w-lg', className)}>
|
<div className={cn('relative bg-white rounded-2xl shadow-2xl w-full max-w-lg animate-scale-in', className)}>
|
||||||
{title && (
|
{title && (
|
||||||
<div className="flex items-center justify-between p-6 border-b">
|
<div className="flex items-center justify-between p-6 border-b">
|
||||||
<h2 className="text-lg font-semibold">{title}</h2>
|
<h2 className="text-lg font-semibold">{title}</h2>
|
||||||
|
|||||||
81
src/data/templates.ts
Normal file
81
src/data/templates.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import type { ChatbotTemplate } from '@/types'
|
||||||
|
|
||||||
|
export const CHATBOT_TEMPLATES: ChatbotTemplate[] = [
|
||||||
|
{
|
||||||
|
id: 'customer-support',
|
||||||
|
name: 'Customer Support',
|
||||||
|
description: 'Handle customer inquiries, returns, and product questions',
|
||||||
|
icon: '🎧',
|
||||||
|
category: 'Customer Support',
|
||||||
|
industry: 'E-commerce',
|
||||||
|
system_prompt: 'You are a friendly and helpful customer support assistant. Help customers with their inquiries, returns, product questions, and order issues. Be empathetic, professional, and solution-focused. If you cannot resolve an issue, offer to escalate to a human agent.',
|
||||||
|
welcome_message: "Hi there! I'm your customer support assistant. How can I help you today?",
|
||||||
|
lead_capture_enabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sales-assistant',
|
||||||
|
name: 'Sales Assistant',
|
||||||
|
description: 'Qualify leads, answer product questions, and book demos',
|
||||||
|
icon: '💼',
|
||||||
|
category: 'Sales',
|
||||||
|
industry: 'SaaS',
|
||||||
|
system_prompt: 'You are an enthusiastic sales assistant. Help prospects understand our products and services, qualify their needs, and guide them toward the right solution. Collect their contact information so our sales team can follow up.',
|
||||||
|
welcome_message: "Welcome! I'm here to help you find the perfect solution. What are you looking to achieve?",
|
||||||
|
lead_capture_enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hr-onboarding',
|
||||||
|
name: 'HR Onboarding',
|
||||||
|
description: 'Answer employee questions about policies, benefits, and procedures',
|
||||||
|
icon: '👥',
|
||||||
|
category: 'HR',
|
||||||
|
industry: 'Human Resources',
|
||||||
|
system_prompt: 'You are an HR onboarding assistant. Help new and existing employees with questions about company policies, benefits, procedures, time-off requests, and workplace guidelines. Be accurate and direct employees to HR for complex matters.',
|
||||||
|
welcome_message: "Hello! I'm your HR assistant. I can help with policies, benefits, and onboarding questions. What do you need?",
|
||||||
|
lead_capture_enabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ecommerce',
|
||||||
|
name: 'E-commerce Helper',
|
||||||
|
description: 'Guide shoppers through products, shipping, and returns',
|
||||||
|
icon: '🛍️',
|
||||||
|
category: 'E-commerce',
|
||||||
|
industry: 'Retail',
|
||||||
|
system_prompt: 'You are a helpful shopping assistant. Help customers find products, answer questions about shipping times, return policies, product specifications, and availability. Make shopping easy and enjoyable.',
|
||||||
|
welcome_message: "Welcome to our store! I'm here to help you find exactly what you're looking for. What can I help you with?",
|
||||||
|
lead_capture_enabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'real-estate',
|
||||||
|
name: 'Real Estate Agent',
|
||||||
|
description: 'Answer questions about listings, viewings, and the buying process',
|
||||||
|
icon: '🏠',
|
||||||
|
category: 'Real Estate',
|
||||||
|
industry: 'Real Estate',
|
||||||
|
system_prompt: 'You are a knowledgeable real estate assistant. Help potential buyers and renters with property listings, neighborhood information, pricing guidance, and the buying/renting process. Collect contact details to schedule viewings.',
|
||||||
|
welcome_message: "Hello! Looking for your dream home? I can help you explore properties and answer any questions. Where shall we start?",
|
||||||
|
lead_capture_enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'restaurant',
|
||||||
|
name: 'Restaurant Assistant',
|
||||||
|
description: 'Share menu info, hours, and take reservation inquiries',
|
||||||
|
icon: '🍽️',
|
||||||
|
category: 'Food & Beverage',
|
||||||
|
industry: 'Hospitality',
|
||||||
|
system_prompt: 'You are a friendly restaurant assistant. Help guests with menu questions, dietary restrictions, opening hours, location information, and reservation inquiries. Be warm and welcoming, reflecting our hospitality.',
|
||||||
|
welcome_message: "Welcome! I'm here to help with our menu, reservations, and any questions. What can I do for you?",
|
||||||
|
lead_capture_enabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'healthcare-faq',
|
||||||
|
name: 'Healthcare FAQ',
|
||||||
|
description: 'Answer general health questions and help with appointment booking',
|
||||||
|
icon: '🏥',
|
||||||
|
category: 'Healthcare',
|
||||||
|
industry: 'Healthcare',
|
||||||
|
system_prompt: 'You are a helpful healthcare information assistant. Provide general health information, answer questions about services, help with appointment scheduling inquiries, and direct patients to appropriate resources. Always clarify that you provide general information only and patients should consult a qualified healthcare professional for medical advice.',
|
||||||
|
welcome_message: "Hello! I can help with general health information, appointment questions, and our services. How can I assist you?",
|
||||||
|
lead_capture_enabled: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate, Link } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { analyticsAPI } from '@/services/api'
|
import { analyticsAPI } from '@/services/api'
|
||||||
import { useAuthStore } from '@/store/authStore'
|
|
||||||
import { Card, Spinner, Button, Badge } from '@/components/ui'
|
import { Card, Spinner, Button, Badge } from '@/components/ui'
|
||||||
import { formatDate } from '@/lib/utils'
|
|
||||||
import {
|
import {
|
||||||
BarChart3, Users, MessageSquare, Star, TrendingUp,
|
BarChart3, Users, MessageSquare, Star,
|
||||||
Clock, Globe, ArrowRight, Lock, Bot, Calendar,
|
Clock, Globe, Lock, Bot,
|
||||||
ArrowUp, ArrowDown, Minus, ChevronDown, ChevronUp
|
ChevronDown, ChevronUp
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -43,6 +41,10 @@ interface ChatbotAnalytics {
|
|||||||
top_queries: TopQuery[]
|
top_queries: TopQuery[]
|
||||||
languages_used: Record<string, number>
|
languages_used: Record<string, number>
|
||||||
peak_hour: number | null
|
peak_hour: number | null
|
||||||
|
unanswered_count: number
|
||||||
|
unanswered_queries: TopQuery[]
|
||||||
|
feedback_positive: number
|
||||||
|
feedback_negative: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OverviewData {
|
interface OverviewData {
|
||||||
@@ -80,7 +82,7 @@ const MiniBarChart: React.FC<{ data: DailyConversation[] }> = ({ data }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-end gap-[2px] h-16">
|
<div className="flex items-end gap-[2px] h-16">
|
||||||
{days.map((d, i) => (
|
{days.map((d) => (
|
||||||
<div
|
<div
|
||||||
key={d.date}
|
key={d.date}
|
||||||
className="flex-1 min-w-[3px] rounded-t-sm bg-primary-400 hover:bg-primary-600 transition-colors cursor-default group relative"
|
className="flex-1 min-w-[3px] rounded-t-sm bg-primary-400 hover:bg-primary-600 transition-colors cursor-default group relative"
|
||||||
@@ -237,6 +239,38 @@ const ChatbotRow: React.FC<{ chatbot: ChatbotAnalytics }> = ({ chatbot }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Knowledge Gaps */}
|
||||||
|
{chatbot.unanswered_queries && chatbot.unanswered_queries.length > 0 && (
|
||||||
|
<div className="bg-amber-50 rounded-lg p-3 border border-amber-100">
|
||||||
|
<p className="text-xs font-medium text-amber-700 mb-2">
|
||||||
|
Knowledge Gaps — questions your bot couldn't answer well:
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{chatbot.unanswered_queries.slice(0, 5).map((q, i) => (
|
||||||
|
<li key={i} className="flex items-start gap-2 text-xs">
|
||||||
|
<span className="text-amber-400 font-mono">{i + 1}.</span>
|
||||||
|
<span className="text-amber-800 flex-1 truncate">{q.query}</span>
|
||||||
|
<span className="text-amber-500">{q.count}×</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Feedback */}
|
||||||
|
{(chatbot.feedback_positive > 0 || chatbot.feedback_negative > 0) && (
|
||||||
|
<div className="text-xs text-gray-500 flex items-center gap-3">
|
||||||
|
<span className="font-medium text-gray-600">Feedback:</span>
|
||||||
|
<span className="text-green-600">👍 {chatbot.feedback_positive}</span>
|
||||||
|
<span className="text-red-500">👎 {chatbot.feedback_negative}</span>
|
||||||
|
{(chatbot.feedback_positive + chatbot.feedback_negative) > 0 && (
|
||||||
|
<span className="text-gray-400">
|
||||||
|
({Math.round((chatbot.feedback_positive / (chatbot.feedback_positive + chatbot.feedback_negative)) * 100)}% helpful)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{chatbot.peak_hour !== null && (
|
{chatbot.peak_hour !== null && (
|
||||||
<div className="text-xs text-gray-500 flex items-center gap-1">
|
<div className="text-xs text-gray-500 flex items-center gap-1">
|
||||||
<Clock className="w-3 h-3" />
|
<Clock className="w-3 h-3" />
|
||||||
@@ -254,7 +288,6 @@ const ChatbotRow: React.FC<{ chatbot: ChatbotAnalytics }> = ({ chatbot }) => {
|
|||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
export const AnalyticsPage: React.FC = () => {
|
export const AnalyticsPage: React.FC = () => {
|
||||||
const { user } = useAuthStore()
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery<OverviewData>({
|
const { data, isLoading, error } = useQuery<OverviewData>({
|
||||||
@@ -265,7 +298,7 @@ export const AnalyticsPage: React.FC = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Handle plan gate (402 response)
|
// Handle plan gate (402 response)
|
||||||
if (error && (error as any)?.response?.status === 402) {
|
if (error && (error as { response?: { status?: number } })?.response?.status === 402) {
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-2xl mx-auto">
|
<div className="p-6 max-w-2xl mx-auto">
|
||||||
<Card className="p-8 text-center">
|
<Card className="p-8 text-center">
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ export const LoginPage: React.FC = () => {
|
|||||||
const data = await authAPI.login({ email, password })
|
const data = await authAPI.login({ email, password })
|
||||||
setAuth(data.user, data.access_token)
|
setAuth(data.user, data.access_token)
|
||||||
navigate('/dashboard')
|
navigate('/dashboard')
|
||||||
} catch (err: any) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.detail || 'Login failed. Please check your credentials.')
|
const e = err as { response?: { data?: { detail?: string } } }
|
||||||
|
setError(e.response?.data?.detail || 'Login failed. Please check your credentials.')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -79,7 +80,13 @@ export const LoginPage: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-6 text-center text-sm text-gray-500">
|
<div className="mt-4 text-right">
|
||||||
|
<Link to="/forgot-password" className="text-sm text-primary-600 hover:underline">
|
||||||
|
Forgot password?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 text-center text-sm text-gray-500">
|
||||||
Don't have an account?{' '}
|
Don't have an account?{' '}
|
||||||
<Link to="/signup" className="text-primary-600 font-medium hover:underline">
|
<Link to="/signup" className="text-primary-600 font-medium hover:underline">
|
||||||
Create one free
|
Create one free
|
||||||
@@ -96,6 +103,7 @@ export const SignupPage: React.FC = () => {
|
|||||||
const [showPass, setShowPass] = useState(false)
|
const [showPass, setShowPass] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [emailSent, setEmailSent] = useState(false)
|
||||||
const { setAuth } = useAuthStore()
|
const { setAuth } = useAuthStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
@@ -109,15 +117,43 @@ export const SignupPage: React.FC = () => {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const data = await authAPI.signup(form)
|
const data = await authAPI.signup(form)
|
||||||
|
if (data.access_token) {
|
||||||
|
// Email confirmation not required — go straight to dashboard
|
||||||
setAuth(data.user, data.access_token)
|
setAuth(data.user, data.access_token)
|
||||||
navigate('/dashboard')
|
navigate('/dashboard')
|
||||||
} catch (err: any) {
|
} else {
|
||||||
setError(err.response?.data?.detail || 'Signup failed. Please try again.')
|
// Supabase requires email confirmation
|
||||||
|
setEmailSent(true)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as { response?: { data?: { detail?: string } } }
|
||||||
|
setError(e.response?.data?.detail || 'Signup failed. Please try again.')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (emailSent) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-primary-50 via-white to-purple-50 flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md text-center">
|
||||||
|
<div className="inline-flex w-16 h-16 bg-green-100 rounded-2xl items-center justify-center mb-4">
|
||||||
|
<Sparkles className="w-8 h-8 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">Check your email</h1>
|
||||||
|
<p className="text-gray-500 mb-4">
|
||||||
|
We sent a confirmation link to <strong>{form.email}</strong>.<br />
|
||||||
|
Click the link to activate your account.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Already confirmed?{' '}
|
||||||
|
<Link to="/login" className="text-primary-600 hover:underline">Sign in</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 via-white to-purple-50 flex items-center justify-center p-4">
|
<div className="min-h-screen bg-gradient-to-br from-primary-50 via-white to-purple-50 flex items-center justify-center p-4">
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react'
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
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 { useAuthStore } from '@/store/authStore'
|
||||||
import { Button, Input, Textarea, Select, Card, Badge, StatusDot, Spinner } from '@/components/ui'
|
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 { ChatInterface } from '@/components/ChatInterface'
|
||||||
import { useDropzone } from 'react-dropzone'
|
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 {
|
import {
|
||||||
ArrowLeft, Save, Eye, Upload, Trash2, FileText,
|
ArrowLeft, Save, Eye, Upload, Trash2, FileText,
|
||||||
Bot, Sliders, Globe, Code, AlertCircle, CheckCircle,
|
Sliders, AlertCircle, CheckCircle,
|
||||||
ChevronDown, ChevronRight, Settings2, ImagePlus, X
|
ChevronDown, ChevronRight, Settings2, ImagePlus, X,
|
||||||
|
Link2, Copy, Globe, Webhook, Share2, Code, MessageSquare
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const DEFAULT_FORM: ChatbotFormData = {
|
const DEFAULT_FORM: ChatbotFormData = {
|
||||||
@@ -27,9 +29,17 @@ const DEFAULT_FORM: ChatbotFormData = {
|
|||||||
category: '',
|
category: '',
|
||||||
industry: '',
|
industry: '',
|
||||||
languages: ['en'],
|
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 = () => {
|
export const ChatbotBuilderPage: React.FC = () => {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
@@ -39,6 +49,7 @@ export const ChatbotBuilderPage: React.FC = () => {
|
|||||||
const { user } = useAuthStore()
|
const { user } = useAuthStore()
|
||||||
const [tab, setTab] = useState<Tab>('settings')
|
const [tab, setTab] = useState<Tab>('settings')
|
||||||
const [form, setForm] = useState<ChatbotFormData>(DEFAULT_FORM)
|
const [form, setForm] = useState<ChatbotFormData>(DEFAULT_FORM)
|
||||||
|
const [showTemplatePicker, setShowTemplatePicker] = useState(isNew)
|
||||||
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null)
|
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null)
|
||||||
const [chatbotId, setChatbotId] = useState<string | null>(isNew ? null : id || null)
|
const [chatbotId, setChatbotId] = useState<string | null>(isNew ? null : id || null)
|
||||||
|
|
||||||
@@ -51,6 +62,7 @@ export const ChatbotBuilderPage: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (existingChatbot) {
|
if (existingChatbot) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setForm({
|
setForm({
|
||||||
name: existingChatbot.name,
|
name: existingChatbot.name,
|
||||||
description: existingChatbot.description || '',
|
description: existingChatbot.description || '',
|
||||||
@@ -64,6 +76,14 @@ export const ChatbotBuilderPage: React.FC = () => {
|
|||||||
category: existingChatbot.category || '',
|
category: existingChatbot.category || '',
|
||||||
industry: existingChatbot.industry || '',
|
industry: existingChatbot.industry || '',
|
||||||
languages: existingChatbot.languages,
|
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)
|
setChatbotId(existingChatbot.id)
|
||||||
}
|
}
|
||||||
@@ -77,7 +97,7 @@ export const ChatbotBuilderPage: React.FC = () => {
|
|||||||
navigate(`/chatbots/${data.id}/edit`, { replace: true })
|
navigate(`/chatbots/${data.id}/edit`, { replace: true })
|
||||||
showToast('Chatbot created!', 'success')
|
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({
|
const updateMutation = useMutation({
|
||||||
@@ -87,7 +107,7 @@ export const ChatbotBuilderPage: React.FC = () => {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['chatbots'] })
|
queryClient.invalidateQueries({ queryKey: ['chatbots'] })
|
||||||
showToast('Settings saved!', 'success')
|
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 = () => {
|
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>
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
@@ -137,6 +206,7 @@ export const ChatbotBuilderPage: React.FC = () => {
|
|||||||
{ key: 'settings' as Tab, label: 'Settings', icon: Sliders },
|
{ key: 'settings' as Tab, label: 'Settings', icon: Sliders },
|
||||||
{ key: 'documents' as Tab, label: 'Documents', icon: FileText },
|
{ key: 'documents' as Tab, label: 'Documents', icon: FileText },
|
||||||
{ key: 'preview' as Tab, label: 'Preview', icon: Eye },
|
{ key: 'preview' as Tab, label: 'Preview', icon: Eye },
|
||||||
|
{ key: 'deploy' as Tab, label: 'Deploy', icon: Share2 },
|
||||||
]).map(t => (
|
]).map(t => (
|
||||||
<button
|
<button
|
||||||
key={t.key}
|
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>
|
<p className="text-gray-600 text-sm">Save your chatbot first to preview it.</p>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Toast */}
|
{/* Toast */}
|
||||||
@@ -213,10 +297,10 @@ interface SettingsTabProps {
|
|||||||
userPlan: string
|
userPlan: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const SettingsTab: React.FC<SettingsTabProps> = ({ form, setForm, userPlan }) => {
|
const SettingsTab: React.FC<SettingsTabProps> = ({ form, setForm }) => {
|
||||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
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 }))
|
setForm(prev => ({ ...prev, [field]: value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +312,6 @@ const SettingsTab: React.FC<SettingsTabProps> = ({ form, setForm, userPlan }) =>
|
|||||||
})
|
})
|
||||||
|
|
||||||
const availableModels = modelsData?.models || []
|
const availableModels = modelsData?.models || []
|
||||||
const hasPremiumAccess = modelsData?.has_premium_access || false
|
|
||||||
const upgradeLabel = modelsData?.upgrade_label || null
|
const upgradeLabel = modelsData?.upgrade_label || null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -482,38 +565,22 @@ const LogoUploader: React.FC<LogoUploaderProps> = ({ logoUrl, onLogoChange }) =>
|
|||||||
|
|
||||||
const handleFile = useCallback(async (file: File) => {
|
const handleFile = useCallback(async (file: File) => {
|
||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
// Validate file type
|
|
||||||
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/svg+xml', 'image/webp']
|
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/svg+xml', 'image/webp']
|
||||||
if (!allowedTypes.includes(file.type)) {
|
if (!allowedTypes.includes(file.type)) {
|
||||||
setError('Please upload a PNG, JPG, GIF, SVG, or WebP image.')
|
setError('Please upload a PNG, JPG, GIF, SVG, or WebP image.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate file size (max 2MB)
|
|
||||||
if (file.size > 2 * 1024 * 1024) {
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
setError('Image must be under 2MB.')
|
setError('Image must be under 2MB.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setUploading(true)
|
setUploading(true)
|
||||||
try {
|
try {
|
||||||
// Convert to base64 data URL for now.
|
const result = await uploadAPI.logo(file)
|
||||||
// In production, you'd upload to Supabase Storage and get a public URL back.
|
onLogoChange(result.url)
|
||||||
// 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)
|
|
||||||
} catch {
|
} catch {
|
||||||
setError('Upload failed. Please try again.')
|
setError('Upload failed. Please try again.')
|
||||||
|
} finally {
|
||||||
setUploading(false)
|
setUploading(false)
|
||||||
}
|
}
|
||||||
}, [onLogoChange])
|
}, [onLogoChange])
|
||||||
@@ -614,7 +681,7 @@ const DocumentsTab: React.FC<{ chatbotId: string }> = ({ chatbotId }) => {
|
|||||||
refetchInterval: (query) => {
|
refetchInterval: (query) => {
|
||||||
const data = query.state.data
|
const data = query.state.data
|
||||||
if (data && Array.isArray(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 hasProcessing ? 3000 : false
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -638,8 +705,9 @@ const DocumentsTab: React.FC<{ chatbotId: string }> = ({ chatbotId }) => {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['documents', chatbotId] })
|
queryClient.invalidateQueries({ queryKey: ['documents', chatbotId] })
|
||||||
setToast('Documents uploaded successfully!')
|
setToast('Documents uploaded successfully!')
|
||||||
setTimeout(() => setToast(''), 3000)
|
setTimeout(() => setToast(''), 3000)
|
||||||
} catch (err: any) {
|
} catch (err) {
|
||||||
setToast(err.response?.data?.detail || 'Upload failed')
|
const e = err as { response?: { data?: { detail?: string } } }
|
||||||
|
setToast(e.response?.data?.detail || 'Upload failed')
|
||||||
setTimeout(() => setToast(''), 3000)
|
setTimeout(() => setToast(''), 3000)
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false)
|
setUploading(false)
|
||||||
@@ -693,6 +761,9 @@ const DocumentsTab: React.FC<{ chatbotId: string }> = ({ chatbotId }) => {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* URL Sources section */}
|
||||||
|
<UrlSourcesSection chatbotId={chatbotId} />
|
||||||
|
|
||||||
{/* Document list */}
|
{/* Document list */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex justify-center py-8">
|
<div className="flex justify-center py-8">
|
||||||
@@ -705,7 +776,7 @@ const DocumentsTab: React.FC<{ chatbotId: string }> = ({ chatbotId }) => {
|
|||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card className="divide-y divide-gray-100">
|
<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">
|
<div key={doc.id} className="flex items-center gap-3 p-4">
|
||||||
<span className="text-xl">{getFileIcon(doc.file_type)}</span>
|
<span className="text-xl">{getFileIcon(doc.file_type)}</span>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -739,3 +810,524 @@ const DocumentsTab: React.FC<{ chatbotId: string }> = ({ chatbotId }) => {
|
|||||||
</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="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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
import React, { useState, useCallback } from 'react'
|
import React, { useState, useCallback } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { chatbotsAPI } from '@/services/api'
|
import { chatbotsAPI } from '@/services/api'
|
||||||
import { useAuthStore } from '@/store/authStore'
|
import { Button, Card, StatusDot, EmptyState, Modal, Spinner } from '@/components/ui'
|
||||||
import { Button, Card, Badge, StatusDot, EmptyState, Modal, Spinner } from '@/components/ui'
|
|
||||||
import { formatDate, getFileIcon, cn } from '@/lib/utils'
|
|
||||||
import type { Chatbot } from '@/types'
|
import type { Chatbot } from '@/types'
|
||||||
import {
|
import {
|
||||||
Bot, Plus, MoreHorizontal, Globe, Lock, Trash2,
|
Bot, Plus, MoreHorizontal, Globe, Lock, Trash2,
|
||||||
Settings, Upload, Eye, ExternalLink, Download, BarChart2
|
Settings, Eye, BarChart2
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
// BUG-05 FIX: Toast queue system using array + auto-dismiss
|
// BUG-05 FIX: Toast queue system using array + auto-dismiss
|
||||||
@@ -18,7 +16,6 @@ interface ToastItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DashboardPage: React.FC = () => {
|
export const DashboardPage: React.FC = () => {
|
||||||
const { user } = useAuthStore()
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||||
@@ -82,7 +79,7 @@ export const DashboardPage: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 sm:p-6">
|
<div className="p-4 sm:p-6">
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-6 gap-3">
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-6 gap-3 animate-fade-in">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl sm:text-2xl font-bold text-gray-900">Dashboard</h1>
|
<h1 className="text-xl sm:text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||||
<p className="text-sm text-gray-500 mt-0.5">Manage your AI chatbots</p>
|
<p className="text-sm text-gray-500 mt-0.5">Manage your AI chatbots</p>
|
||||||
@@ -110,12 +107,12 @@ export const DashboardPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
// R-02 FIX: Better responsive grid breakpoints
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{chatbots.map((chatbot) => (
|
{chatbots.map((chatbot, i) => (
|
||||||
<ChatbotCard
|
<ChatbotCard
|
||||||
key={chatbot.id}
|
key={chatbot.id}
|
||||||
chatbot={chatbot}
|
chatbot={chatbot}
|
||||||
|
index={i}
|
||||||
onEdit={() => navigate(`/chatbots/${chatbot.id}/edit`)}
|
onEdit={() => navigate(`/chatbots/${chatbot.id}/edit`)}
|
||||||
onPreview={() => navigate(`/chatbots/${chatbot.id}/preview`)}
|
onPreview={() => navigate(`/chatbots/${chatbot.id}/preview`)}
|
||||||
onPublish={() => setConfirmAction({ type: 'publish', id: chatbot.id })}
|
onPublish={() => setConfirmAction({ type: 'publish', id: chatbot.id })}
|
||||||
@@ -125,17 +122,17 @@ export const DashboardPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{/* Add new card */}
|
{/* Add new card */}
|
||||||
<Card
|
<button
|
||||||
className="border-dashed hover:border-primary-400 hover:bg-primary-50 flex items-center justify-center min-h-[200px] cursor-pointer transition-colors"
|
className="group border-2 border-dashed border-gray-200 hover:border-primary-400 hover:bg-primary-50/50 flex items-center justify-center min-h-[220px] rounded-xl cursor-pointer transition-all duration-200 hover:-translate-y-1 hover:shadow-md"
|
||||||
onClick={() => navigate('/chatbots/new')}
|
onClick={() => navigate('/chatbots/new')}
|
||||||
>
|
>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
<div className="w-12 h-12 bg-gray-100 group-hover:bg-primary-100 rounded-full flex items-center justify-center mx-auto mb-3 transition-colors duration-200">
|
||||||
<Plus className="w-6 h-6 text-gray-400" />
|
<Plus className="w-6 h-6 text-gray-400 group-hover:text-primary-500 transition-colors duration-200" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium text-gray-500">New Chatbot</p>
|
<p className="text-sm font-medium text-gray-400 group-hover:text-primary-600 transition-colors duration-200">New Chatbot</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -207,76 +204,84 @@ export const DashboardPage: React.FC = () => {
|
|||||||
|
|
||||||
const ChatbotCard: React.FC<{
|
const ChatbotCard: React.FC<{
|
||||||
chatbot: Chatbot
|
chatbot: Chatbot
|
||||||
|
index: number
|
||||||
onEdit: () => void
|
onEdit: () => void
|
||||||
onPreview: () => void
|
onPreview: () => void
|
||||||
onPublish: () => void
|
onPublish: () => void
|
||||||
onUnpublish: () => void
|
onUnpublish: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
onAnalytics: () => void
|
onAnalytics: () => void
|
||||||
}> = ({ chatbot, onEdit, onPreview, onPublish, onUnpublish, onDelete, onAnalytics }) => {
|
}> = ({ chatbot, index, onEdit, onPreview, onPublish, onUnpublish, onDelete, onAnalytics }) => {
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="p-5">
|
<div
|
||||||
|
className="group bg-white rounded-xl border border-gray-200 shadow-sm hover:-translate-y-1 hover:shadow-lg hover:border-gray-300 transition-all duration-200 overflow-hidden animate-fade-in-up"
|
||||||
|
style={{ animationDelay: `${index * 80}ms`, animationFillMode: 'both' }}
|
||||||
|
>
|
||||||
|
{/* Colored top accent */}
|
||||||
|
<div className="h-1 w-full" style={{ background: chatbot.primary_color }} />
|
||||||
|
|
||||||
|
<div className="p-5">
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{chatbot.logo_url ? (
|
{chatbot.logo_url ? (
|
||||||
<img
|
<img
|
||||||
src={chatbot.logo_url}
|
src={chatbot.logo_url}
|
||||||
alt={chatbot.name}
|
alt={chatbot.name}
|
||||||
className="w-10 h-10 rounded-xl object-cover flex-shrink-0"
|
className="w-11 h-11 rounded-xl object-cover flex-shrink-0 shadow-sm group-hover:scale-105 transition-transform duration-200"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="w-10 h-10 rounded-xl flex items-center justify-center text-white text-lg flex-shrink-0"
|
className="w-11 h-11 rounded-xl flex items-center justify-center text-white flex-shrink-0 shadow-sm group-hover:scale-105 transition-transform duration-200"
|
||||||
style={{ background: chatbot.primary_color }}
|
style={{ background: chatbot.primary_color }}
|
||||||
>
|
>
|
||||||
<Bot className="w-5 h-5" />
|
<Bot className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-gray-900 text-sm">{chatbot.name}</h3>
|
<h3 className="font-semibold text-gray-900 text-sm group-hover:text-primary-700 transition-colors">{chatbot.name}</h3>
|
||||||
<div className="flex items-center gap-1.5 mt-0.5">
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
<StatusDot status={chatbot.is_published ? 'published' : 'preview'} />
|
<StatusDot status={chatbot.is_published ? 'published' : 'preview'} />
|
||||||
<span className="text-xs text-gray-500">
|
<span className={`text-xs font-medium ${chatbot.is_published ? 'text-green-600' : 'text-gray-400'}`}>
|
||||||
{chatbot.is_published ? 'Published' : 'Preview'}
|
{chatbot.is_published ? 'Published' : 'Preview'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* R-02 FIX: Menu dropdown with viewport-aware positioning */}
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setMenuOpen(!menuOpen)}
|
onClick={() => setMenuOpen(!menuOpen)}
|
||||||
className="p-1.5 hover:bg-gray-100 rounded-lg text-gray-400"
|
className="p-1.5 hover:bg-gray-100 rounded-lg text-gray-300 hover:text-gray-600 transition-colors"
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="w-4 h-4" />
|
<MoreHorizontal className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} />
|
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} />
|
||||||
<div className="absolute right-0 mt-1 w-44 bg-white border border-gray-200 rounded-xl shadow-lg z-20 overflow-hidden text-sm max-h-64 overflow-y-auto">
|
<div className="absolute right-0 mt-1 w-44 bg-white border border-gray-200 rounded-xl shadow-xl z-20 overflow-hidden text-sm animate-scale-in">
|
||||||
<button onClick={() => { onEdit(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2 hover:bg-gray-50 text-left">
|
<button onClick={() => { onEdit(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2.5 hover:bg-gray-50 text-left text-gray-700 transition-colors">
|
||||||
<Settings className="w-3.5 h-3.5" /> Edit Settings
|
<Settings className="w-3.5 h-3.5 text-gray-400" /> Edit Settings
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => { onPreview(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2 hover:bg-gray-50 text-left">
|
<button onClick={() => { onPreview(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2.5 hover:bg-gray-50 text-left text-gray-700 transition-colors">
|
||||||
<Eye className="w-3.5 h-3.5" /> Preview
|
<Eye className="w-3.5 h-3.5 text-gray-400" /> Preview
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => { onAnalytics(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2 hover:bg-gray-50 text-left">
|
<button onClick={() => { onAnalytics(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2.5 hover:bg-gray-50 text-left text-gray-700 transition-colors">
|
||||||
<BarChart2 className="w-3.5 h-3.5" /> Analytics
|
<BarChart2 className="w-3.5 h-3.5 text-gray-400" /> Analytics
|
||||||
</button>
|
</button>
|
||||||
<div className="h-px bg-gray-100" />
|
<div className="h-px bg-gray-100 mx-2" />
|
||||||
{chatbot.is_published ? (
|
{chatbot.is_published ? (
|
||||||
<button onClick={() => { onUnpublish(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2 hover:bg-orange-50 text-orange-600 text-left">
|
<button onClick={() => { onUnpublish(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2.5 hover:bg-orange-50 text-orange-600 text-left transition-colors">
|
||||||
<Lock className="w-3.5 h-3.5" /> Unpublish
|
<Lock className="w-3.5 h-3.5" /> Unpublish
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button onClick={() => { onPublish(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2 hover:bg-green-50 text-green-600 text-left">
|
<button onClick={() => { onPublish(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2.5 hover:bg-green-50 text-green-600 text-left transition-colors">
|
||||||
<Globe className="w-3.5 h-3.5" /> Publish
|
<Globe className="w-3.5 h-3.5" /> Publish
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="h-px bg-gray-100" />
|
<div className="h-px bg-gray-100 mx-2" />
|
||||||
<button onClick={() => { onDelete(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2 hover:bg-red-50 text-red-600 text-left">
|
<button onClick={() => { onDelete(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2.5 hover:bg-red-50 text-red-600 text-left transition-colors">
|
||||||
<Trash2 className="w-3.5 h-3.5" /> Delete
|
<Trash2 className="w-3.5 h-3.5" /> Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -286,29 +291,32 @@ const ChatbotCard: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{chatbot.description && (
|
{chatbot.description && (
|
||||||
<p className="text-xs text-gray-500 mb-3 line-clamp-2">{chatbot.description}</p>
|
<p className="text-xs text-gray-500 mb-3 line-clamp-2 leading-relaxed">{chatbot.description}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="flex flex-wrap gap-3 mb-4 text-xs text-gray-500">
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
<span>📄 {chatbot.document_count} docs</span>
|
<span className="flex items-center gap-1 text-xs text-gray-500 bg-gray-50 px-2 py-1 rounded-lg">
|
||||||
<span>💬 {chatbot.conversation_count} chats</span>
|
<span className="text-gray-400">📄</span> {chatbot.document_count} docs
|
||||||
{chatbot.category && <span>🏷 {chatbot.category}</span>}
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-xs text-gray-500 bg-gray-50 px-2 py-1 rounded-lg">
|
||||||
|
<span className="text-gray-400">💬</span> {chatbot.conversation_count.toLocaleString()} chats
|
||||||
|
</span>
|
||||||
|
{chatbot.category && (
|
||||||
|
<span className="text-xs text-primary-700 bg-primary-50 px-2 py-1 rounded-lg font-medium">
|
||||||
|
{chatbot.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={onPreview} className="flex-1">
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={onPreview}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
<Eye className="w-3.5 h-3.5" />
|
<Eye className="w-3.5 h-3.5" />
|
||||||
Preview
|
Preview
|
||||||
</Button>
|
</Button>
|
||||||
{chatbot.is_published ? (
|
{chatbot.is_published ? (
|
||||||
<Button variant="outline" size="sm" onClick={onUnpublish} className="flex-1">
|
<Button variant="outline" size="sm" onClick={onUnpublish} className="flex-1 text-orange-600 border-orange-200 hover:bg-orange-50">
|
||||||
<Lock className="w-3.5 h-3.5" />
|
<Lock className="w-3.5 h-3.5" />
|
||||||
Unpublish
|
Unpublish
|
||||||
</Button>
|
</Button>
|
||||||
@@ -319,6 +327,7 @@ const ChatbotCard: React.FC<{
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
85
src/pages/ForgotPasswordPage.tsx
Normal file
85
src/pages/ForgotPasswordPage.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { authAPI } from '@/services/api'
|
||||||
|
import { Button, Input } from '@/components/ui'
|
||||||
|
import { Sparkles, ArrowLeft } from 'lucide-react'
|
||||||
|
|
||||||
|
export const ForgotPasswordPage: React.FC = () => {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [sent, setSent] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await authAPI.forgotPassword(email)
|
||||||
|
setSent(true)
|
||||||
|
} catch {
|
||||||
|
setError('Something went wrong. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-primary-50 via-white to-purple-50 flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="inline-flex w-12 h-12 bg-primary-600 rounded-2xl items-center justify-center mb-4">
|
||||||
|
<Sparkles className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Reset your password</h1>
|
||||||
|
<p className="text-gray-500 mt-1 text-sm">We'll send a reset link to your email</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-8">
|
||||||
|
{sent ? (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<span className="text-green-600 text-xl">✓</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="font-semibold text-gray-900 mb-2">Check your email</h2>
|
||||||
|
<p className="text-sm text-gray-500 mb-4">
|
||||||
|
If <strong>{email}</strong> is registered, a reset link has been sent.
|
||||||
|
</p>
|
||||||
|
<Link to="/login" className="text-sm text-primary-600 hover:underline">
|
||||||
|
Back to sign in
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
placeholder="you@company.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button type="submit" loading={loading} className="w-full" size="lg">
|
||||||
|
Send reset link
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!sent && (
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<Link to="/login" className="text-sm text-gray-500 hover:text-gray-700 flex items-center justify-center gap-1">
|
||||||
|
<ArrowLeft className="w-3.5 h-3.5" />
|
||||||
|
Back to sign in
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
206
src/pages/InboxPage.tsx
Normal file
206
src/pages/InboxPage.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { inboxAPI } from '@/services/api'
|
||||||
|
import { Card, Spinner } from '@/components/ui'
|
||||||
|
import { Mail, MessageSquare, Bot, AlertTriangle, ArrowRight, Trash2 } from 'lucide-react'
|
||||||
|
import type { InboxConversation, InboxMessage } from '@/types'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface ConversationDetail {
|
||||||
|
conversation_id: string
|
||||||
|
chatbot_name: string
|
||||||
|
language: string
|
||||||
|
session_id?: string
|
||||||
|
created_at?: string
|
||||||
|
messages: InboxMessage[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InboxPage: React.FC = () => {
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
|
const [chatbotFilter, setChatbotFilter] = useState('')
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const { data: conversations = [], isLoading, error } = useQuery<InboxConversation[]>({
|
||||||
|
queryKey: ['inbox-conversations', chatbotFilter],
|
||||||
|
queryFn: () => inboxAPI.conversations(chatbotFilter ? { chatbot_id: chatbotFilter } : undefined),
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleDelete = async (e: React.MouseEvent, convId: string) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (!confirm('Delete this conversation?')) return
|
||||||
|
setDeletingId(convId)
|
||||||
|
try {
|
||||||
|
await inboxAPI.deleteConversation(convId)
|
||||||
|
if (selectedId === convId) setSelectedId(null)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['inbox-conversations'] })
|
||||||
|
} catch {
|
||||||
|
alert('Failed to delete conversation')
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: detail, isLoading: detailLoading } = useQuery<ConversationDetail>({
|
||||||
|
queryKey: ['inbox-conversation', selectedId],
|
||||||
|
queryFn: () => inboxAPI.conversation(selectedId!),
|
||||||
|
enabled: !!selectedId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isPlanError = (error as { response?: { status?: number } })?.response?.status === 402
|
||||||
|
|
||||||
|
if (isPlanError) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-2xl mx-auto">
|
||||||
|
<Card className="p-8 text-center">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-primary-50 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Mail className="w-6 h-6 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 mb-2">Conversation Inbox</h2>
|
||||||
|
<p className="text-gray-500 text-sm mb-6">
|
||||||
|
Upgrade to Starter to read all your chatbot conversations in one place.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full">
|
||||||
|
{/* Left panel - conversation list */}
|
||||||
|
<div className="w-80 flex-shrink-0 border-r border-gray-200 bg-white flex flex-col">
|
||||||
|
<div className="p-4 border-b border-gray-200">
|
||||||
|
<h1 className="text-lg font-bold text-gray-900 flex items-center gap-2">
|
||||||
|
<Mail className="w-5 h-5 text-primary-600" />
|
||||||
|
Inbox
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">All chatbot conversations</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<Spinner className="text-primary-600" />
|
||||||
|
</div>
|
||||||
|
) : conversations.length === 0 ? (
|
||||||
|
<div className="p-6 text-center">
|
||||||
|
<MessageSquare className="w-8 h-8 text-gray-300 mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-gray-500">No conversations yet</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
conversations.map((conv) => (
|
||||||
|
<div
|
||||||
|
key={conv.id}
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left border-b border-gray-100 hover:bg-gray-50 transition-colors group relative',
|
||||||
|
selectedId === conv.id && 'bg-primary-50 border-l-2 border-l-primary-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedId(conv.id)}
|
||||||
|
className="w-full text-left p-4 pr-10"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Bot className="w-3.5 h-3.5 text-primary-500 flex-shrink-0" />
|
||||||
|
<span className="text-xs font-medium text-primary-700 truncate max-w-[120px]">
|
||||||
|
{conv.chatbot_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-gray-400 flex-shrink-0">
|
||||||
|
{conv.created_at ? new Date(conv.created_at).toLocaleDateString() : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-700 truncate">
|
||||||
|
{conv.first_message || '(No messages)'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">
|
||||||
|
{conv.message_count} messages · {conv.language.toUpperCase()}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleDelete(e, conv.id)}
|
||||||
|
disabled={deletingId === conv.id}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded-lg text-gray-300 hover:text-red-500 hover:bg-red-50 opacity-0 group-hover:opacity-100 transition-all"
|
||||||
|
title="Delete conversation"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right panel - conversation detail */}
|
||||||
|
<div className="flex-1 flex flex-col bg-gray-50 overflow-hidden">
|
||||||
|
{!selectedId ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<ArrowRight className="w-8 h-8 text-gray-300 mx-auto mb-3" />
|
||||||
|
<p className="text-sm text-gray-500">Select a conversation to view</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : detailLoading ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<Spinner className="text-primary-600" />
|
||||||
|
</div>
|
||||||
|
) : detail ? (
|
||||||
|
<>
|
||||||
|
<div className="p-4 bg-white border-b border-gray-200">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Bot className="w-4 h-4 text-primary-600" />
|
||||||
|
<h2 className="font-semibold text-gray-900 text-sm">{detail.chatbot_name}</h2>
|
||||||
|
<span className="text-xs text-gray-400">·</span>
|
||||||
|
<span className="text-xs text-gray-500 uppercase">{detail.language}</span>
|
||||||
|
{detail.created_at && (
|
||||||
|
<>
|
||||||
|
<span className="text-xs text-gray-400">·</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{new Date(detail.created_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||||
|
{detail.messages.map((msg) => (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className={cn('flex gap-2', msg.role === 'user' ? 'justify-end' : '')}
|
||||||
|
>
|
||||||
|
{msg.role === 'assistant' && (
|
||||||
|
<div className="w-6 h-6 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
|
<Bot className="w-3 h-3 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={cn(
|
||||||
|
'max-w-[75%] rounded-2xl px-3 py-2 text-sm',
|
||||||
|
msg.role === 'user'
|
||||||
|
? 'bg-primary-600 text-white rounded-br-sm'
|
||||||
|
: 'bg-white border border-gray-200 text-gray-800 rounded-bl-sm'
|
||||||
|
)}>
|
||||||
|
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
{msg.is_handoff && (
|
||||||
|
<span className="text-[10px] bg-orange-100 text-orange-700 px-1.5 py-0.5 rounded">
|
||||||
|
Handoff
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{msg.confidence_score !== undefined && msg.confidence_score !== null && msg.confidence_score < 0.2 && (
|
||||||
|
<span className="text-[10px] flex items-center gap-0.5 text-amber-600">
|
||||||
|
<AlertTriangle className="w-2.5 h-2.5" /> Low confidence
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Sparkles, Bot, Globe, Code, Database, Shield, Zap, ArrowRight,
|
Sparkles, Bot, Globe, Code, Database, Shield, Zap, ArrowRight,
|
||||||
Check, MessageSquare, Upload, Play, ChevronRight, Star, Users,
|
Check, MessageSquare, Upload, Play, ChevronRight, Star,
|
||||||
FileText, Cpu, Lock, Download, Menu, X
|
FileText, Cpu, Lock, Download, Menu, X
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
@@ -20,11 +20,20 @@ function useInView(options?: IntersectionObserverInit) {
|
|||||||
)
|
)
|
||||||
observer.observe(el)
|
observer.observe(el)
|
||||||
return () => observer.disconnect()
|
return () => observer.disconnect()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return { ref, isInView }
|
return { ref, isInView }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Static conversation data (outside component to avoid re-creation) ─────────
|
||||||
|
const CHAT_CONVERSATION = [
|
||||||
|
{ role: 'user', text: 'What are your return policies?' },
|
||||||
|
{ role: 'bot', text: 'Based on your company documents, we offer a 30-day return policy for all items in original condition. Refunds are processed within 5-7 business days.' },
|
||||||
|
{ role: 'user', text: 'Can I return sale items?' },
|
||||||
|
{ role: 'bot', text: 'According to Section 4.2 of your policy, sale items can be exchanged within 14 days but are not eligible for refunds. Would you like to know more?' },
|
||||||
|
]
|
||||||
|
|
||||||
// ─── Animated Counter ──────────────────────────────────────────────────────────
|
// ─── Animated Counter ──────────────────────────────────────────────────────────
|
||||||
const AnimatedCounter: React.FC<{ end: number; suffix?: string; label: string; isInView: boolean }> = ({ end, suffix = '', label, isInView }) => {
|
const AnimatedCounter: React.FC<{ end: number; suffix?: string; label: string; isInView: boolean }> = ({ end, suffix = '', label, isInView }) => {
|
||||||
const [count, setCount] = useState(0)
|
const [count, setCount] = useState(0)
|
||||||
@@ -58,28 +67,21 @@ const FloatingChatPreview: React.FC = () => {
|
|||||||
const [isTyping, setIsTyping] = useState(false)
|
const [isTyping, setIsTyping] = useState(false)
|
||||||
const [step, setStep] = useState(0)
|
const [step, setStep] = useState(0)
|
||||||
|
|
||||||
const conversation = [
|
|
||||||
{ role: 'user', text: 'What are your return policies?' },
|
|
||||||
{ role: 'bot', text: 'Based on your company documents, we offer a 30-day return policy for all items in original condition. Refunds are processed within 5-7 business days.' },
|
|
||||||
{ role: 'user', text: 'Can I return sale items?' },
|
|
||||||
{ role: 'bot', text: 'According to Section 4.2 of your policy, sale items can be exchanged within 14 days but are not eligible for refunds. Would you like to know more?' },
|
|
||||||
]
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (step >= conversation.length) {
|
if (step >= CHAT_CONVERSATION.length) {
|
||||||
const timeout = setTimeout(() => { setMessages([]); setStep(0) }, 4000)
|
const timeout = setTimeout(() => { setMessages([]); setStep(0) }, 4000)
|
||||||
return () => clearTimeout(timeout)
|
return () => clearTimeout(timeout)
|
||||||
}
|
}
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
if (conversation[step].role === 'bot') {
|
if (CHAT_CONVERSATION[step].role === 'bot') {
|
||||||
setIsTyping(true)
|
setIsTyping(true)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setIsTyping(false)
|
setIsTyping(false)
|
||||||
setMessages(prev => [...prev, conversation[step]])
|
setMessages(prev => [...prev, CHAT_CONVERSATION[step]])
|
||||||
setStep(s => s + 1)
|
setStep(s => s + 1)
|
||||||
}, 1500)
|
}, 1500)
|
||||||
} else {
|
} else {
|
||||||
setMessages(prev => [...prev, conversation[step]])
|
setMessages(prev => [...prev, CHAT_CONVERSATION[step]])
|
||||||
setStep(s => s + 1)
|
setStep(s => s + 1)
|
||||||
}
|
}
|
||||||
}, step === 0 ? 1500 : 1000)
|
}, step === 0 ? 1500 : 1000)
|
||||||
@@ -240,12 +242,12 @@ export const LandingPage: React.FC = () => {
|
|||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||||
const [scrolled, setScrolled] = useState(false)
|
const [scrolled, setScrolled] = useState(false)
|
||||||
|
|
||||||
const features = useInView()
|
const { ref: featuresRef, isInView: featuresInView } = useInView()
|
||||||
const howItWorks = useInView()
|
const { ref: howItWorksRef, isInView: howItWorksInView } = useInView()
|
||||||
const stats = useInView()
|
const { ref: statsRef, isInView: statsInView } = useInView()
|
||||||
const testimonials = useInView()
|
const { ref: testimonialsRef, isInView: testimonialsInView } = useInView()
|
||||||
const pricing = useInView()
|
const { ref: pricingRef, isInView: pricingInView } = useInView()
|
||||||
const cta = useInView()
|
const { ref: ctaRef, isInView: ctaInView } = useInView()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleScroll = () => setScrolled(window.scrollY > 20)
|
const handleScroll = () => setScrolled(window.scrollY > 20)
|
||||||
@@ -385,32 +387,32 @@ export const LandingPage: React.FC = () => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Trusted By / Stats ── */}
|
{/* ── Trusted By / Stats ── */}
|
||||||
<section ref={stats.ref} className="py-16 border-t border-b border-gray-100 bg-gray-50/50">
|
<section ref={statsRef} className="py-16 border-t border-b border-gray-100 bg-gray-50/50">
|
||||||
<div className="max-w-5xl mx-auto px-6">
|
<div className="max-w-5xl mx-auto px-6">
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
|
||||||
<AnimatedCounter end={2500} suffix="+" label="Chatbots Created" isInView={stats.isInView} />
|
<AnimatedCounter end={2500} suffix="+" label="Chatbots Created" isInView={statsInView} />
|
||||||
<AnimatedCounter end={500} suffix="+" label="Companies" isInView={stats.isInView} />
|
<AnimatedCounter end={500} suffix="+" label="Companies" isInView={statsInView} />
|
||||||
<AnimatedCounter end={10} suffix="M+" label="Messages Processed" isInView={stats.isInView} />
|
<AnimatedCounter end={10} suffix="M+" label="Messages Processed" isInView={statsInView} />
|
||||||
<AnimatedCounter end={99} suffix="%" label="Uptime" isInView={stats.isInView} />
|
<AnimatedCounter end={99} suffix="%" label="Uptime" isInView={statsInView} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Features ── */}
|
{/* ── Features ── */}
|
||||||
<section id="features" ref={features.ref} className="py-20 md:py-28">
|
<section id="features" ref={featuresRef} className="py-20 md:py-28">
|
||||||
<div className="max-w-6xl mx-auto px-6">
|
<div className="max-w-6xl mx-auto px-6">
|
||||||
<div className="text-center mb-16">
|
<div className="text-center mb-16">
|
||||||
<div className={`inline-flex items-center gap-2 bg-primary-50 text-primary-700 text-xs px-3 py-1 rounded-full
|
<div className={`inline-flex items-center gap-2 bg-primary-50 text-primary-700 text-xs px-3 py-1 rounded-full
|
||||||
mb-4 font-semibold uppercase tracking-wider transition-all duration-500
|
mb-4 font-semibold uppercase tracking-wider transition-all duration-500
|
||||||
${features.isInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
|
${featuresInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
|
||||||
Features
|
Features
|
||||||
</div>
|
</div>
|
||||||
<h2 className={`text-3xl md:text-4xl font-extrabold text-gray-900 mb-4 tracking-tight transition-all duration-700
|
<h2 className={`text-3xl md:text-4xl font-extrabold text-gray-900 mb-4 tracking-tight transition-all duration-700
|
||||||
${features.isInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
|
${featuresInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
|
||||||
Everything you need to build
|
Everything you need to build
|
||||||
</h2>
|
</h2>
|
||||||
<p className={`text-gray-500 max-w-lg mx-auto transition-all duration-700 delay-100
|
<p className={`text-gray-500 max-w-lg mx-auto transition-all duration-700 delay-100
|
||||||
${features.isInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
|
${featuresInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
|
||||||
From document upload to deployment — we handle the heavy lifting so you can focus on your business.
|
From document upload to deployment — we handle the heavy lifting so you can focus on your business.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -424,24 +426,24 @@ export const LandingPage: React.FC = () => {
|
|||||||
{ icon: <Sparkles className="w-6 h-6" />, title: 'Premium AI Models', desc: 'Access GPT-4o, Claude 3.5 Sonnet, Gemini 1.5 Pro, and open-source models.', color: 'bg-yellow-100 text-yellow-700' },
|
{ icon: <Sparkles className="w-6 h-6" />, title: 'Premium AI Models', desc: 'Access GPT-4o, Claude 3.5 Sonnet, Gemini 1.5 Pro, and open-source models.', color: 'bg-yellow-100 text-yellow-700' },
|
||||||
{ icon: <Shield className="w-6 h-6" />, title: 'Data Isolation', desc: 'Each company gets its own isolated vector database. Your data is never mixed with others.', color: 'bg-red-100 text-red-600' },
|
{ icon: <Shield className="w-6 h-6" />, title: 'Data Isolation', desc: 'Each company gets its own isolated vector database. Your data is never mixed with others.', color: 'bg-red-100 text-red-600' },
|
||||||
].map((f, i) => (
|
].map((f, i) => (
|
||||||
<FeatureCard key={f.title} {...f} delay={i * 100} isInView={features.isInView} />
|
<FeatureCard key={f.title} {...f} delay={i * 100} isInView={featuresInView} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── How It Works ── */}
|
{/* ── How It Works ── */}
|
||||||
<section ref={howItWorks.ref} className="py-20 md:py-28 bg-gray-50/70 relative">
|
<section ref={howItWorksRef} className="py-20 md:py-28 bg-gray-50/70 relative">
|
||||||
<div className="absolute inset-0 bg-dots" />
|
<div className="absolute inset-0 bg-dots" />
|
||||||
<div className="relative max-w-5xl mx-auto px-6">
|
<div className="relative max-w-5xl mx-auto px-6">
|
||||||
<div className="text-center mb-16">
|
<div className="text-center mb-16">
|
||||||
<div className={`inline-flex items-center gap-2 bg-primary-50 text-primary-700 text-xs px-3 py-1 rounded-full
|
<div className={`inline-flex items-center gap-2 bg-primary-50 text-primary-700 text-xs px-3 py-1 rounded-full
|
||||||
mb-4 font-semibold uppercase tracking-wider transition-all duration-500
|
mb-4 font-semibold uppercase tracking-wider transition-all duration-500
|
||||||
${howItWorks.isInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
|
${howItWorksInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
|
||||||
How It Works
|
How It Works
|
||||||
</div>
|
</div>
|
||||||
<h2 className={`text-3xl md:text-4xl font-extrabold text-gray-900 mb-4 tracking-tight transition-all duration-700
|
<h2 className={`text-3xl md:text-4xl font-extrabold text-gray-900 mb-4 tracking-tight transition-all duration-700
|
||||||
${howItWorks.isInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
|
${howItWorksInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
|
||||||
Three steps to your AI chatbot
|
Three steps to your AI chatbot
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -452,13 +454,13 @@ export const LandingPage: React.FC = () => {
|
|||||||
|
|
||||||
<Step num={1} icon={<Upload className="w-7 h-7 text-white" />}
|
<Step num={1} icon={<Upload className="w-7 h-7 text-white" />}
|
||||||
title="Upload Documents" desc="Drag and drop your PDFs, DOCX, CSV, Excel, or text files. We handle parsing and chunking automatically."
|
title="Upload Documents" desc="Drag and drop your PDFs, DOCX, CSV, Excel, or text files. We handle parsing and chunking automatically."
|
||||||
isInView={howItWorks.isInView} delay={0} />
|
isInView={howItWorksInView} delay={0} />
|
||||||
<Step num={2} icon={<Cpu className="w-7 h-7 text-white" />}
|
<Step num={2} icon={<Cpu className="w-7 h-7 text-white" />}
|
||||||
title="Configure & Train" desc="Choose your AI model, customize the system prompt, and let our RAG engine build your knowledge base."
|
title="Configure & Train" desc="Choose your AI model, customize the system prompt, and let our RAG engine build your knowledge base."
|
||||||
isInView={howItWorks.isInView} delay={200} />
|
isInView={howItWorksInView} delay={200} />
|
||||||
<Step num={3} icon={<Play className="w-7 h-7 text-white" />}
|
<Step num={3} icon={<Play className="w-7 h-7 text-white" />}
|
||||||
title="Deploy Anywhere" desc="Publish to the marketplace, embed on your website, or export the full source code for self-hosting."
|
title="Deploy Anywhere" desc="Publish to the marketplace, embed on your website, or export the full source code for self-hosting."
|
||||||
isInView={howItWorks.isInView} delay={400} />
|
isInView={howItWorksInView} delay={400} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -534,17 +536,17 @@ async def chat(message: str):
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Testimonials ── */}
|
{/* ── Testimonials ── */}
|
||||||
<section ref={testimonials.ref} className="py-20 md:py-28 bg-gray-50/70 relative">
|
<section ref={testimonialsRef} className="py-20 md:py-28 bg-gray-50/70 relative">
|
||||||
<div className="absolute inset-0 bg-grid opacity-30" />
|
<div className="absolute inset-0 bg-grid opacity-30" />
|
||||||
<div className="relative max-w-6xl mx-auto px-6">
|
<div className="relative max-w-6xl mx-auto px-6">
|
||||||
<div className="text-center mb-16">
|
<div className="text-center mb-16">
|
||||||
<div className={`inline-flex items-center gap-2 bg-primary-50 text-primary-700 text-xs px-3 py-1 rounded-full
|
<div className={`inline-flex items-center gap-2 bg-primary-50 text-primary-700 text-xs px-3 py-1 rounded-full
|
||||||
mb-4 font-semibold uppercase tracking-wider transition-all duration-500
|
mb-4 font-semibold uppercase tracking-wider transition-all duration-500
|
||||||
${testimonials.isInView ? 'opacity-100' : 'opacity-0'}`}>
|
${testimonialsInView ? 'opacity-100' : 'opacity-0'}`}>
|
||||||
Testimonials
|
Testimonials
|
||||||
</div>
|
</div>
|
||||||
<h2 className={`text-3xl md:text-4xl font-extrabold text-gray-900 mb-4 tracking-tight transition-all duration-700
|
<h2 className={`text-3xl md:text-4xl font-extrabold text-gray-900 mb-4 tracking-tight transition-all duration-700
|
||||||
${testimonials.isInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
|
${testimonialsInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
|
||||||
Loved by builders
|
Loved by builders
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -553,36 +555,36 @@ async def chat(message: str):
|
|||||||
<TestimonialCard
|
<TestimonialCard
|
||||||
quote="We had our support chatbot live in under an hour. The code export feature meant we could customize everything to match our brand perfectly."
|
quote="We had our support chatbot live in under an hour. The code export feature meant we could customize everything to match our brand perfectly."
|
||||||
name="Sarah Chen" role="CTO" company="TechFlow"
|
name="Sarah Chen" role="CTO" company="TechFlow"
|
||||||
isInView={testimonials.isInView} delay={0}
|
isInView={testimonialsInView} delay={0}
|
||||||
/>
|
/>
|
||||||
<TestimonialCard
|
<TestimonialCard
|
||||||
quote="The data isolation gives us confidence to use this with sensitive healthcare data. Each client's information stays completely separate."
|
quote="The data isolation gives us confidence to use this with sensitive healthcare data. Each client's information stays completely separate."
|
||||||
name="Marcus Johnson" role="Head of Engineering" company="MedAssist"
|
name="Marcus Johnson" role="Head of Engineering" company="MedAssist"
|
||||||
isInView={testimonials.isInView} delay={150}
|
isInView={testimonialsInView} delay={150}
|
||||||
/>
|
/>
|
||||||
<TestimonialCard
|
<TestimonialCard
|
||||||
quote="No other platform lets you export the full source code. That was the dealbreaker for us — we needed to own the entire stack."
|
quote="No other platform lets you export the full source code. That was the dealbreaker for us — we needed to own the entire stack."
|
||||||
name="Elena Kowalski" role="VP of Product" company="DataBridge"
|
name="Elena Kowalski" role="VP of Product" company="DataBridge"
|
||||||
isInView={testimonials.isInView} delay={300}
|
isInView={testimonialsInView} delay={300}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Pricing Teaser ── */}
|
{/* ── Pricing Teaser ── */}
|
||||||
<section ref={pricing.ref} className="py-20 md:py-28">
|
<section ref={pricingRef} className="py-20 md:py-28">
|
||||||
<div className="max-w-4xl mx-auto px-6 text-center">
|
<div className="max-w-4xl mx-auto px-6 text-center">
|
||||||
<h2 className={`text-3xl md:text-4xl font-extrabold text-gray-900 mb-4 tracking-tight transition-all duration-700
|
<h2 className={`text-3xl md:text-4xl font-extrabold text-gray-900 mb-4 tracking-tight transition-all duration-700
|
||||||
${pricing.isInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
|
${pricingInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
|
||||||
Start free, scale as you grow
|
Start free, scale as you grow
|
||||||
</h2>
|
</h2>
|
||||||
<p className={`text-gray-500 mb-10 transition-all duration-700 delay-100
|
<p className={`text-gray-500 mb-10 transition-all duration-700 delay-100
|
||||||
${pricing.isInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
|
${pricingInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
|
||||||
Build unlimited chatbots for free. Upgrade to publish and unlock premium features.
|
Build unlimited chatbots for free. Upgrade to publish and unlock premium features.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className={`flex flex-wrap justify-center gap-x-6 gap-y-3 mb-10 transition-all duration-700 delay-200
|
<div className={`flex flex-wrap justify-center gap-x-6 gap-y-3 mb-10 transition-all duration-700 delay-200
|
||||||
${pricing.isInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
|
${pricingInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
|
||||||
{[
|
{[
|
||||||
{ feature: 'Free forever plan', included: true },
|
{ feature: 'Free forever plan', included: true },
|
||||||
{ feature: 'Unlimited chatbot creation', included: true },
|
{ feature: 'Unlimited chatbot creation', included: true },
|
||||||
@@ -604,14 +606,14 @@ async def chat(message: str):
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link to="/pricing" className={`text-primary-600 font-semibold hover:text-primary-700 text-sm inline-flex items-center gap-1.5
|
<Link to="/pricing" className={`text-primary-600 font-semibold hover:text-primary-700 text-sm inline-flex items-center gap-1.5
|
||||||
transition-all duration-700 delay-300 hover:gap-2.5 ${pricing.isInView ? 'opacity-100' : 'opacity-0'}`}>
|
transition-all duration-700 delay-300 hover:gap-2.5 ${pricingInView ? 'opacity-100' : 'opacity-0'}`}>
|
||||||
View full pricing <ArrowRight className="w-4 h-4" />
|
View full pricing <ArrowRight className="w-4 h-4" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── CTA ── */}
|
{/* ── CTA ── */}
|
||||||
<section ref={cta.ref} className="relative py-20 md:py-24 overflow-hidden">
|
<section ref={ctaRef} className="relative py-20 md:py-24 overflow-hidden">
|
||||||
{/* Gradient background */}
|
{/* Gradient background */}
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-primary-600 via-primary-700 to-indigo-800" />
|
<div className="absolute inset-0 bg-gradient-to-br from-primary-600 via-primary-700 to-indigo-800" />
|
||||||
<div className="absolute inset-0 bg-grid opacity-10" />
|
<div className="absolute inset-0 bg-grid opacity-10" />
|
||||||
@@ -621,15 +623,15 @@ async def chat(message: str):
|
|||||||
|
|
||||||
<div className="relative max-w-2xl mx-auto px-6 text-center">
|
<div className="relative max-w-2xl mx-auto px-6 text-center">
|
||||||
<h2 className={`text-3xl md:text-4xl font-extrabold text-white mb-5 tracking-tight transition-all duration-700
|
<h2 className={`text-3xl md:text-4xl font-extrabold text-white mb-5 tracking-tight transition-all duration-700
|
||||||
${cta.isInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
|
${ctaInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
|
||||||
Ready to build your first chatbot?
|
Ready to build your first chatbot?
|
||||||
</h2>
|
</h2>
|
||||||
<p className={`text-primary-100 mb-10 text-lg transition-all duration-700 delay-100
|
<p className={`text-primary-100 mb-10 text-lg transition-all duration-700 delay-100
|
||||||
${cta.isInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
|
${ctaInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
|
||||||
Join hundreds of companies using Contexta to power their AI experiences.
|
Join hundreds of companies using Contexta to power their AI experiences.
|
||||||
</p>
|
</p>
|
||||||
<div className={`flex flex-col sm:flex-row items-center justify-center gap-4 transition-all duration-700 delay-200
|
<div className={`flex flex-col sm:flex-row items-center justify-center gap-4 transition-all duration-700 delay-200
|
||||||
${cta.isInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
|
${ctaInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
|
||||||
<Link to="/signup" className="bg-white text-primary-700 px-8 py-3.5 rounded-xl font-semibold
|
<Link to="/signup" className="bg-white text-primary-700 px-8 py-3.5 rounded-xl font-semibold
|
||||||
hover:bg-primary-50 transition-all inline-flex items-center gap-2 shadow-lg shadow-black/10
|
hover:bg-primary-50 transition-all inline-flex items-center gap-2 shadow-lg shadow-black/10
|
||||||
hover:-translate-y-0.5 w-full sm:w-auto justify-center text-base">
|
hover:-translate-y-0.5 w-full sm:w-auto justify-center text-base">
|
||||||
|
|||||||
135
src/pages/LeadsPage.tsx
Normal file
135
src/pages/LeadsPage.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { leadsAPI, chatbotsAPI } from '@/services/api'
|
||||||
|
import { Card, Spinner, Button } from '@/components/ui'
|
||||||
|
import { Users, Download, Mail, Lock } from 'lucide-react'
|
||||||
|
import type { Lead, Chatbot } from '@/types'
|
||||||
|
|
||||||
|
export const LeadsPage: React.FC = () => {
|
||||||
|
const [chatbotFilter, setChatbotFilter] = useState('')
|
||||||
|
|
||||||
|
const { data: chatbots = [] } = useQuery<Chatbot[]>({
|
||||||
|
queryKey: ['chatbots'],
|
||||||
|
queryFn: chatbotsAPI.list,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: leads = [], isLoading, error } = useQuery<Lead[]>({
|
||||||
|
queryKey: ['leads', chatbotFilter],
|
||||||
|
queryFn: () => leadsAPI.list(chatbotFilter ? { chatbot_id: chatbotFilter } : undefined),
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isPlanError = (error as { response?: { status?: number } })?.response?.status === 402
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
try {
|
||||||
|
const blob = await leadsAPI.exportCsv(chatbotFilter || undefined)
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = 'leads.csv'
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
} catch {
|
||||||
|
alert('Export failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlanError) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-2xl mx-auto">
|
||||||
|
<Card className="p-8 text-center">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-primary-50 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Lock className="w-6 h-6 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 mb-2">Lead Capture</h2>
|
||||||
|
<p className="text-gray-500 text-sm mb-6">
|
||||||
|
Upgrade to Starter to capture and manage leads from your chatbots.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||||
|
<Users className="w-6 h-6 text-primary-600" />
|
||||||
|
Leads
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">Contacts collected by your chatbots</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleExport} variant="secondary" size="sm">
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter */}
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-sm font-medium text-gray-700 flex-shrink-0">Filter by chatbot:</label>
|
||||||
|
<select
|
||||||
|
value={chatbotFilter}
|
||||||
|
onChange={e => setChatbotFilter(e.target.value)}
|
||||||
|
className="flex-1 max-w-xs 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="">All chatbots</option>
|
||||||
|
{chatbots.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<Spinner className="text-primary-600" />
|
||||||
|
</div>
|
||||||
|
) : leads.length === 0 ? (
|
||||||
|
<Card className="p-12 text-center">
|
||||||
|
<Mail className="w-10 h-10 text-gray-300 mx-auto mb-3" />
|
||||||
|
<h3 className="font-semibold text-gray-700 mb-1">No leads yet</h3>
|
||||||
|
<p className="text-sm text-gray-500 max-w-sm mx-auto">
|
||||||
|
Enable lead capture on your chatbots to start collecting contact information from visitors.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">Email</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">Name</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">Phone</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">Company</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{leads.map((lead) => (
|
||||||
|
<tr key={lead.id} className="hover:bg-gray-50 transition-colors">
|
||||||
|
<td className="px-4 py-3 text-gray-900">{lead.email || '—'}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-700">{lead.name || '—'}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-700">{lead.phone || '—'}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-700">{lead.company || '—'}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500">
|
||||||
|
{lead.created_at ? new Date(lead.created_at).toLocaleDateString() : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import React, { useState } from 'react'
|
|||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { marketplaceAPI } from '@/services/api'
|
import { marketplaceAPI } from '@/services/api'
|
||||||
import { Card, Spinner, EmptyState, Button, Badge } from '@/components/ui'
|
import { Card, Spinner, EmptyState, Button } from '@/components/ui'
|
||||||
import { ChatInterface } from '@/components/ChatInterface'
|
import { ChatInterface } from '@/components/ChatInterface'
|
||||||
import { CATEGORIES, INDUSTRIES } from '@/lib/utils'
|
import { CATEGORIES, INDUSTRIES } from '@/lib/utils'
|
||||||
import { Search, Bot, Star, MessageSquare, ArrowLeft } from 'lucide-react'
|
import { Search, Bot, Star, MessageSquare, ArrowLeft } from 'lucide-react'
|
||||||
@@ -38,27 +38,28 @@ export const MarketplacePage: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-6xl mx-auto">
|
<div className="p-6 max-w-6xl mx-auto">
|
||||||
<div className="mb-8">
|
{/* Header */}
|
||||||
|
<div className="mb-8 animate-fade-in">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">AI Chatbot Marketplace</h1>
|
<h1 className="text-2xl font-bold text-gray-900">AI Chatbot Marketplace</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1">Discover and interact with AI chatbots built by businesses</p>
|
<p className="text-sm text-gray-500 mt-1">Discover and interact with AI chatbots built by businesses</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
<div className="flex flex-col sm:flex-row gap-3 mb-6 animate-fade-in-down">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => handleSearch(e.target.value)}
|
onChange={e => handleSearch(e.target.value)}
|
||||||
placeholder="Search chatbots..."
|
placeholder="Search chatbots..."
|
||||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white"
|
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-400 bg-white shadow-sm transition-all placeholder-gray-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
value={category}
|
value={category}
|
||||||
onChange={e => { setCategory(e.target.value); setPage(1) }}
|
onChange={e => { setCategory(e.target.value); setPage(1) }}
|
||||||
className="px-3 py-2 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white"
|
className="px-3 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white shadow-sm transition-all text-gray-700"
|
||||||
>
|
>
|
||||||
<option value="">All Categories</option>
|
<option value="">All Categories</option>
|
||||||
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
|
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||||||
@@ -66,7 +67,7 @@ export const MarketplacePage: React.FC = () => {
|
|||||||
<select
|
<select
|
||||||
value={industry}
|
value={industry}
|
||||||
onChange={e => { setIndustry(e.target.value); setPage(1) }}
|
onChange={e => { setIndustry(e.target.value); setPage(1) }}
|
||||||
className="px-3 py-2 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white"
|
className="px-3 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white shadow-sm transition-all text-gray-700"
|
||||||
>
|
>
|
||||||
<option value="">All Industries</option>
|
<option value="">All Industries</option>
|
||||||
{INDUSTRIES.map(i => <option key={i} value={i}>{i}</option>)}
|
{INDUSTRIES.map(i => <option key={i} value={i}>{i}</option>)}
|
||||||
@@ -75,7 +76,10 @@ export const MarketplacePage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Results */}
|
{/* Results */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex justify-center py-20"><Spinner className="text-primary-600" /></div>
|
<div className="flex flex-col items-center justify-center py-24 gap-3">
|
||||||
|
<Spinner className="text-primary-600 w-7 h-7" />
|
||||||
|
<p className="text-sm text-gray-400">Loading chatbots…</p>
|
||||||
|
</div>
|
||||||
) : !data?.chatbots?.length ? (
|
) : !data?.chatbots?.length ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<Bot className="w-8 h-8" />}
|
icon={<Bot className="w-8 h-8" />}
|
||||||
@@ -85,23 +89,26 @@ export const MarketplacePage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="mb-4 text-sm text-gray-500">{data.total} chatbot{data.total !== 1 ? 's' : ''} available</div>
|
<div className="mb-4 text-xs text-gray-400 font-medium uppercase tracking-wide">
|
||||||
|
{data.total} chatbot{data.total !== 1 ? 's' : ''} available
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
|
||||||
{data.chatbots.map(chatbot => (
|
{data.chatbots.map((chatbot, i) => (
|
||||||
<ChatbotMarketplaceCard
|
<ChatbotMarketplaceCard
|
||||||
key={chatbot.id}
|
key={chatbot.id}
|
||||||
chatbot={chatbot}
|
chatbot={chatbot}
|
||||||
|
index={i}
|
||||||
onClick={() => navigate(`/marketplace/${chatbot.id}`)}
|
onClick={() => navigate(`/marketplace/${chatbot.id}`)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data.total > 20 && (
|
{data.total > 20 && (
|
||||||
<div className="flex justify-center gap-2">
|
<div className="flex justify-center items-center gap-3">
|
||||||
<Button variant="outline" size="sm" onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}>
|
<Button variant="outline" size="sm" onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}>
|
||||||
Previous
|
Previous
|
||||||
</Button>
|
</Button>
|
||||||
<span className="flex items-center px-3 text-sm text-gray-600">
|
<span className="text-sm text-gray-500 bg-white border border-gray-200 px-4 py-1.5 rounded-lg shadow-sm">
|
||||||
Page {page} of {Math.ceil(data.total / 20)}
|
Page {page} of {Math.ceil(data.total / 20)}
|
||||||
</span>
|
</span>
|
||||||
<Button variant="outline" size="sm" onClick={() => setPage(p => p + 1)} disabled={!data.has_more}>
|
<Button variant="outline" size="sm" onClick={() => setPage(p => p + 1)} disabled={!data.has_more}>
|
||||||
@@ -120,53 +127,62 @@ export const MarketplacePage: React.FC = () => {
|
|||||||
// MARKETPLACE CARD — shows logo when available
|
// MARKETPLACE CARD — shows logo when available
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const ChatbotMarketplaceCard: React.FC<{ chatbot: ChatbotPublic; onClick: () => void }> = ({ chatbot, onClick }) => (
|
const ChatbotMarketplaceCard: React.FC<{ chatbot: ChatbotPublic; onClick: () => void; index: number }> = ({ chatbot, onClick, index }) => (
|
||||||
<Card
|
<div
|
||||||
className="p-5 cursor-pointer hover:shadow-md hover:border-gray-300 transition-all"
|
className="group bg-white rounded-xl border border-gray-200 shadow-sm hover:-translate-y-1.5 hover:shadow-xl hover:border-gray-300 transition-all duration-200 cursor-pointer overflow-hidden"
|
||||||
|
style={{ animationDelay: `${index * 60}ms`, animationFillMode: 'both' }}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
|
{/* Colored accent bar */}
|
||||||
|
<div className="h-1 w-full" style={{ background: chatbot.primary_color }} />
|
||||||
|
|
||||||
|
<div className="p-5">
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
{/* Logo or fallback icon */}
|
|
||||||
{chatbot.logo_url ? (
|
{chatbot.logo_url ? (
|
||||||
<img
|
<img
|
||||||
src={chatbot.logo_url}
|
src={chatbot.logo_url}
|
||||||
alt={chatbot.name}
|
alt={chatbot.name}
|
||||||
className="w-10 h-10 rounded-xl object-cover flex-shrink-0"
|
className="w-11 h-11 rounded-xl object-cover flex-shrink-0 shadow-sm group-hover:scale-105 transition-transform duration-200"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="w-10 h-10 rounded-xl flex items-center justify-center text-white flex-shrink-0"
|
className="w-11 h-11 rounded-xl flex items-center justify-center text-white flex-shrink-0 shadow-sm group-hover:scale-105 transition-transform duration-200"
|
||||||
style={{ background: chatbot.primary_color }}
|
style={{ background: chatbot.primary_color }}
|
||||||
>
|
>
|
||||||
<Bot className="w-5 h-5" />
|
<Bot className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-semibold text-gray-900 text-sm truncate">{chatbot.name}</h3>
|
<h3 className="font-semibold text-gray-900 text-sm truncate group-hover:text-primary-700 transition-colors">{chatbot.name}</h3>
|
||||||
{chatbot.company_name && (
|
{chatbot.company_name && (
|
||||||
<p className="text-xs text-gray-500 truncate">by {chatbot.company_name}</p>
|
<p className="text-xs text-gray-400 truncate">by {chatbot.company_name}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{chatbot.description && (
|
{chatbot.description && (
|
||||||
<p className="text-xs text-gray-500 mb-3 line-clamp-2">{chatbot.description}</p>
|
<p className="text-xs text-gray-500 mb-4 line-clamp-2 leading-relaxed">{chatbot.description}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-3 text-xs text-gray-400">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{chatbot.average_rating && chatbot.average_rating > 0 && (
|
{chatbot.average_rating && chatbot.average_rating > 0 && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1 bg-yellow-50 text-yellow-700 px-2 py-0.5 rounded-full text-xs font-medium">
|
||||||
<Star className="w-3 h-3 text-yellow-400 fill-yellow-400" />
|
<Star className="w-3 h-3 fill-yellow-400 text-yellow-400" />
|
||||||
{chatbot.average_rating.toFixed(1)}
|
{chatbot.average_rating.toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1 bg-gray-50 text-gray-500 px-2 py-0.5 rounded-full text-xs">
|
||||||
<MessageSquare className="w-3 h-3" />
|
<MessageSquare className="w-3 h-3" />
|
||||||
{chatbot.total_conversations} chats
|
{chatbot.total_conversations.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
{chatbot.category && <span className="truncate">🏷 {chatbot.category}</span>}
|
{chatbot.category && (
|
||||||
|
<span className="bg-primary-50 text-primary-700 px-2 py-0.5 rounded-full text-xs font-medium truncate">
|
||||||
|
{chatbot.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -211,30 +227,30 @@ export const ChatbotDetailPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 sm:p-6 max-w-4xl mx-auto">
|
<div className="p-4 sm:p-6 max-w-4xl mx-auto animate-fade-in">
|
||||||
{/* Back link */}
|
{/* Back link */}
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/marketplace')}
|
onClick={() => navigate('/marketplace')}
|
||||||
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 mb-4 transition-colors"
|
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-primary-600 mb-5 transition-colors group"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-0.5 transition-transform" />
|
||||||
Back to Marketplace
|
Back to Marketplace
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Chatbot info — logo or fallback */}
|
{/* Chatbot info */}
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="flex items-center gap-4 mb-5">
|
||||||
{chatbot.logo_url ? (
|
{chatbot.logo_url ? (
|
||||||
<img
|
<img
|
||||||
src={chatbot.logo_url}
|
src={chatbot.logo_url}
|
||||||
alt={chatbot.name}
|
alt={chatbot.name}
|
||||||
className="w-14 h-14 rounded-2xl object-cover"
|
className="w-16 h-16 rounded-2xl object-cover shadow-md"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="w-14 h-14 rounded-2xl flex items-center justify-center text-white"
|
className="w-16 h-16 rounded-2xl flex items-center justify-center text-white shadow-md"
|
||||||
style={{ background: chatbot.primary_color }}
|
style={{ background: chatbot.primary_color }}
|
||||||
>
|
>
|
||||||
<Bot className="w-7 h-7" />
|
<Bot className="w-8 h-8" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
@@ -242,15 +258,27 @@ export const ChatbotDetailPage: React.FC = () => {
|
|||||||
{chatbot.company_name && (
|
{chatbot.company_name && (
|
||||||
<p className="text-sm text-gray-500">by {chatbot.company_name}</p>
|
<p className="text-sm text-gray-500">by {chatbot.company_name}</p>
|
||||||
)}
|
)}
|
||||||
|
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||||
|
{chatbot.average_rating && chatbot.average_rating > 0 && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full font-medium">
|
||||||
|
<Star className="w-3 h-3 fill-yellow-400 text-yellow-400" />
|
||||||
|
{chatbot.average_rating.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex items-center gap-1 text-xs text-gray-500 bg-gray-50 px-2 py-0.5 rounded-full">
|
||||||
|
<MessageSquare className="w-3 h-3" />
|
||||||
|
{chatbot.total_conversations.toLocaleString()} conversations
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{chatbot.description && (
|
{chatbot.description && (
|
||||||
<p className="text-gray-600 text-sm mb-6">{chatbot.description}</p>
|
<p className="text-gray-500 text-sm mb-5 leading-relaxed">{chatbot.description}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Chat — passes logoUrl so header and bot avatars show the logo */}
|
{/* Chat */}
|
||||||
<div className="h-[calc(100vh-280px)] min-h-[400px] max-h-[700px]">
|
<div className="h-[calc(100vh-300px)] min-h-[400px] max-h-[680px] rounded-2xl overflow-hidden border border-gray-200 shadow-sm">
|
||||||
<ChatInterface
|
<ChatInterface
|
||||||
chatbotId={chatbot.id}
|
chatbotId={chatbot.id}
|
||||||
chatbotName={chatbot.name}
|
chatbotName={chatbot.name}
|
||||||
|
|||||||
@@ -1,86 +1,92 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { useNavigate, Link } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { billingAPI } from '@/services/api'
|
import { billingAPI } from '@/services/api'
|
||||||
import { useAuthStore } from '@/store/authStore'
|
import { useAuthStore } from '@/store/authStore'
|
||||||
import { Button, Card } from '@/components/ui'
|
import { Button } from '@/components/ui'
|
||||||
import { Check, Zap, Building2, Star } from 'lucide-react'
|
import { Check } from 'lucide-react'
|
||||||
|
|
||||||
const PLANS = [
|
const PLANS = [
|
||||||
{
|
{
|
||||||
id: 'free',
|
id: 'free',
|
||||||
name: 'Free',
|
name: 'Free',
|
||||||
price: 0,
|
price: 0,
|
||||||
description: 'Build and test chatbots, no credit card needed',
|
description: 'Build, test and launch your first chatbot — no card needed',
|
||||||
icon: '🆓',
|
icon: '🆓',
|
||||||
color: 'gray',
|
|
||||||
features: [
|
features: [
|
||||||
{ text: 'Unlimited chatbot creation', included: true },
|
{ text: '1 published chatbot', included: true },
|
||||||
{ text: 'Upload PDF, DOCX, CSV, XLSX', included: true },
|
{ text: '100 conversations/month', included: true },
|
||||||
{ text: 'Unlimited preview testing', included: true },
|
|
||||||
{ text: 'Shareable preview links', included: true },
|
|
||||||
{ text: '50 preview conversations/month', included: true },
|
|
||||||
{ text: '3 documents per chatbot', included: true },
|
{ text: '3 documents per chatbot', included: true },
|
||||||
|
{ text: 'Public chat link + website embed', included: true },
|
||||||
{ text: 'Llama 3.3 70B model', included: true },
|
{ text: 'Llama 3.3 70B model', included: true },
|
||||||
{ text: 'Publish to marketplace', included: false },
|
|
||||||
{ text: 'Analytics dashboard', included: false },
|
{ text: 'Analytics dashboard', included: false },
|
||||||
{ text: 'Code export', included: false },
|
{ text: 'Lead capture', included: false },
|
||||||
|
{ text: 'Messaging channels', included: false },
|
||||||
|
{ text: 'Remove "Powered by Contexta"', included: false },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'starter',
|
id: 'starter',
|
||||||
name: 'Starter',
|
name: 'Starter',
|
||||||
price: 3,
|
price: 12,
|
||||||
description: 'Go live with your first chatbot',
|
description: 'For individuals and solo businesses going live',
|
||||||
icon: '🚀',
|
icon: '🚀',
|
||||||
color: 'blue',
|
|
||||||
badge: 'Most Popular',
|
|
||||||
features: [
|
features: [
|
||||||
{ text: 'Everything in Free', included: true },
|
{ text: 'Everything in Free', included: true },
|
||||||
{ text: 'Publish 1 chatbot to marketplace', included: true },
|
{ text: '1,500 conversations/month', included: true },
|
||||||
{ text: '500 conversations/month', included: true },
|
|
||||||
{ text: '10 documents per chatbot', included: true },
|
{ text: '10 documents per chatbot', included: true },
|
||||||
{ text: '4 Fireworks AI models (Qwen3, DeepSeek, Kimi, Llama)', included: true },
|
{ text: '4 Fireworks AI models (Qwen3, DeepSeek, Kimi, Llama)', included: true },
|
||||||
{ text: 'Analytics dashboard', included: true },
|
{ text: 'Lead capture + inbox', included: true },
|
||||||
{ text: 'Custom branding', included: true },
|
{ text: 'Analytics + knowledge gaps', included: true },
|
||||||
{ text: 'Email support', included: true },
|
{ text: 'Telegram channel', included: true },
|
||||||
|
{ text: 'WhatsApp channel', included: false },
|
||||||
{ text: 'Premium models (GPT-4o, Claude, Gemini)', included: false },
|
{ text: 'Premium models (GPT-4o, Claude, Gemini)', included: false },
|
||||||
{ text: 'Code export', included: false },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pro',
|
id: 'business',
|
||||||
name: 'Pro',
|
name: 'Business',
|
||||||
price: 20,
|
price: 29,
|
||||||
description: 'For growing businesses with multiple chatbots',
|
description: 'For growing businesses that need more reach and power',
|
||||||
icon: '⚡',
|
icon: '⚡',
|
||||||
color: 'purple',
|
|
||||||
highlighted: true,
|
highlighted: true,
|
||||||
|
badge: 'Most Popular',
|
||||||
features: [
|
features: [
|
||||||
{ text: 'Everything in Starter', included: true },
|
{ text: 'Everything in Starter', included: true },
|
||||||
{ text: 'Build & publish up to 5 chatbots', included: true },
|
{ text: 'Up to 3 published chatbots', included: true },
|
||||||
{ text: '2,000 conversations/month', included: true },
|
{ text: '5,000 conversations/month', included: true },
|
||||||
{ text: '50 documents per chatbot', included: true },
|
{ text: '50 documents per chatbot', included: true },
|
||||||
{ text: 'GPT-4o, GPT-4o Mini', included: true },
|
{ text: 'WhatsApp + Telegram channels', included: true },
|
||||||
{ text: 'Claude Haiku 4.5', included: true },
|
{ text: 'GPT-4o, Claude Haiku, Gemini 2.5', included: true },
|
||||||
{ text: 'Gemini 2.5 Flash, Lite & Pro', included: true },
|
{ text: 'Remove "Powered by Contexta"', included: true },
|
||||||
{ text: 'Code export (FastAPI + React widget)', included: true },
|
{ text: 'Unlimited URL sources', included: true },
|
||||||
{ text: 'Advanced analytics', included: true },
|
|
||||||
{ text: 'Priority support', included: true },
|
{ text: 'Priority support', included: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'agency',
|
||||||
|
name: 'Agency',
|
||||||
|
price: 79,
|
||||||
|
description: 'For agencies and large businesses managing many chatbots',
|
||||||
|
icon: '🏗️',
|
||||||
|
features: [
|
||||||
|
{ text: 'Everything in Business', included: true },
|
||||||
|
{ text: 'Unlimited published chatbots', included: true },
|
||||||
|
{ text: '20,000 conversations/month', included: true },
|
||||||
|
{ text: 'Unlimited documents', included: true },
|
||||||
|
{ text: 'Code export (FastAPI + React)', included: true },
|
||||||
|
{ text: 'Dedicated support', included: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'enterprise',
|
id: 'enterprise',
|
||||||
name: 'Enterprise',
|
name: 'Enterprise',
|
||||||
price: null,
|
price: null,
|
||||||
description: 'For large organizations with custom needs',
|
description: 'For large organizations with custom needs and SLAs',
|
||||||
icon: '🏢',
|
icon: '🏢',
|
||||||
color: 'orange',
|
|
||||||
features: [
|
features: [
|
||||||
{ text: 'Everything in Pro', included: true },
|
{ text: 'Everything in Agency', included: true },
|
||||||
{ text: 'Unlimited chatbots', included: true },
|
|
||||||
{ text: 'Unlimited conversations', included: true },
|
{ text: 'Unlimited conversations', included: true },
|
||||||
{ text: 'Custom model fine-tuning', included: true },
|
|
||||||
{ text: 'White-label platform', included: true },
|
{ text: 'White-label platform', included: true },
|
||||||
{ text: 'SSO (SAML)', included: true },
|
{ text: 'SSO (SAML)', included: true },
|
||||||
{ text: 'SLA guarantees', included: true },
|
{ text: 'SLA guarantees', included: true },
|
||||||
@@ -109,7 +115,7 @@ export const PricingPage: React.FC = () => {
|
|||||||
window.open('mailto:enterprise@contexta.ai?subject=Enterprise Inquiry', '_blank')
|
window.open('mailto:enterprise@contexta.ai?subject=Enterprise Inquiry', '_blank')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (planId === 'free') {
|
if (planId === 'free' || planId === currentPlan) {
|
||||||
navigate('/dashboard')
|
navigate('/dashboard')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -123,8 +129,9 @@ export const PricingPage: React.FC = () => {
|
|||||||
`${window.location.origin}/pricing`
|
`${window.location.origin}/pricing`
|
||||||
)
|
)
|
||||||
window.location.href = checkout_url
|
window.location.href = checkout_url
|
||||||
} catch (err: any) {
|
} catch (err) {
|
||||||
alert(err.response?.data?.detail || 'Failed to create checkout session')
|
const e = err as { response?: { data?: { detail?: string } } }
|
||||||
|
alert(e.response?.data?.detail || 'Failed to create checkout session')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(null)
|
setLoading(null)
|
||||||
}
|
}
|
||||||
@@ -145,11 +152,11 @@ export const PricingPage: React.FC = () => {
|
|||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-3">Simple, affordable pricing</h1>
|
<h1 className="text-3xl font-bold text-gray-900 mb-3">Simple, affordable pricing</h1>
|
||||||
<p className="text-gray-500 max-w-xl mx-auto">
|
<p className="text-gray-500 max-w-xl mx-auto">
|
||||||
Start free, go live for just $3/month. Built for individuals, small businesses, and enterprises alike.
|
Start free and go live for $12/month. Built for individuals, small businesses, agencies, and enterprises alike.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-5">
|
||||||
{PLANS.map((plan) => (
|
{PLANS.map((plan) => (
|
||||||
<div
|
<div
|
||||||
key={plan.id}
|
key={plan.id}
|
||||||
@@ -234,15 +241,15 @@ export const PricingPage: React.FC = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: 'What is code export?',
|
q: 'What is code export?',
|
||||||
a: 'Pro plan users can export their chatbot as a complete, production-ready package including a FastAPI backend and React TypeScript widget — giving you full control to self-host.'
|
a: 'Agency plan users can export their chatbot as a complete, production-ready package including a FastAPI backend and React TypeScript widget — giving you full control to self-host.'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: 'Do I need my own API keys?',
|
q: 'Do I need my own API keys?',
|
||||||
a: 'No! API keys are handled by Contexta. If you export the code, you\'ll need your own keys for self-hosted deployment.'
|
a: 'No — API keys are handled by Contexta. If you export the code on the Agency plan, you\'ll need your own keys for self-hosted deployment.'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: 'Can I cancel anytime?',
|
q: 'Can I cancel anytime?',
|
||||||
a: 'Yes, cancel anytime. Your chatbots will revert to preview mode at the end of your billing period.'
|
a: 'Yes, cancel anytime. Your chatbots will revert to the free tier at the end of your billing period.'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: 'What happens if I hit my conversation limit?',
|
q: 'What happens if I hit my conversation limit?',
|
||||||
@@ -250,7 +257,7 @@ export const PricingPage: React.FC = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: 'I\'m a small business. Which plan is right for me?',
|
q: 'I\'m a small business. Which plan is right for me?',
|
||||||
a: 'Start with Starter at just $3/month — it gives you 1 published chatbot, 500 conversations, and analytics. Perfect for restaurants, barbershops, shops, and more.'
|
a: 'Start with Starter at $12/month — 1 published chatbot, 1,500 conversations, analytics, lead capture and Telegram. Perfect for restaurants, shops, salons, and more. Upgrade to Business when you want WhatsApp.'
|
||||||
},
|
},
|
||||||
].map(({ q, a }) => (
|
].map(({ q, a }) => (
|
||||||
<div key={q} className="bg-white border border-gray-200 rounded-xl p-4">
|
<div key={q} className="bg-white border border-gray-200 rounded-xl p-4">
|
||||||
|
|||||||
88
src/pages/PublicChatPage.tsx
Normal file
88
src/pages/PublicChatPage.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import { chatbotsAPI } from '@/services/api'
|
||||||
|
import { ChatInterface } from '@/components/ChatInterface'
|
||||||
|
import { Spinner } from '@/components/ui'
|
||||||
|
import { Bot, Sparkles } from 'lucide-react'
|
||||||
|
|
||||||
|
interface PublicChatbotInfo {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
welcome_message: string
|
||||||
|
primary_color: string
|
||||||
|
logo_url?: string
|
||||||
|
show_branding: boolean
|
||||||
|
is_published: boolean
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PublicChatPage: React.FC = () => {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const [chatbot, setChatbot] = useState<PublicChatbotInfo | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return
|
||||||
|
chatbotsAPI.getPublic(id)
|
||||||
|
.then((data: PublicChatbotInfo) => {
|
||||||
|
setChatbot(data)
|
||||||
|
document.title = `${data.name} — Powered by Contexta`
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setError('This chatbot is not available.')
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen bg-gray-50">
|
||||||
|
<Spinner className="text-primary-600" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !chatbot) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 p-6">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-gray-200 flex items-center justify-center mb-4">
|
||||||
|
<Bot className="w-7 h-7 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-800 mb-2">Chatbot Not Found</h1>
|
||||||
|
<p className="text-gray-500 text-sm text-center max-w-sm">
|
||||||
|
{error || 'This chatbot is not available or has been unpublished.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col min-h-screen bg-gray-50">
|
||||||
|
{/* Full page chat */}
|
||||||
|
<div className="flex-1 flex flex-col max-w-2xl mx-auto w-full p-4 pb-0" style={{ height: '100vh' }}>
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<ChatInterface
|
||||||
|
chatbotId={chatbot.id}
|
||||||
|
chatbotName={chatbot.name}
|
||||||
|
welcomeMessage={chatbot.welcome_message}
|
||||||
|
primaryColor={chatbot.primary_color}
|
||||||
|
logoUrl={chatbot.logo_url}
|
||||||
|
showBranding={chatbot.show_branding}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{chatbot.show_branding && (
|
||||||
|
<div className="py-2 text-center">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-3 h-3" />
|
||||||
|
Powered by Contexta
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
118
src/pages/ResetPasswordPage.tsx
Normal file
118
src/pages/ResetPasswordPage.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useNavigate, Link } from 'react-router-dom'
|
||||||
|
import { authAPI } from '@/services/api'
|
||||||
|
import { Button, Input } from '@/components/ui'
|
||||||
|
import { Sparkles, Eye, EyeOff } from 'lucide-react'
|
||||||
|
|
||||||
|
export const ResetPasswordPage: React.FC = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [confirm, setConfirm] = useState('')
|
||||||
|
const [showPass, setShowPass] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [accessToken, setAccessToken] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Parse recovery token from URL hash: #access_token=xxx&type=recovery
|
||||||
|
useEffect(() => {
|
||||||
|
const hash = window.location.hash.substring(1)
|
||||||
|
const params = new URLSearchParams(hash)
|
||||||
|
const token = params.get('access_token')
|
||||||
|
const type = params.get('type')
|
||||||
|
if (token && type === 'recovery') {
|
||||||
|
setAccessToken(token)
|
||||||
|
} else {
|
||||||
|
setError('Invalid or expired reset link. Please request a new one.')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError('Password must be at least 8 characters')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password !== confirm) {
|
||||||
|
setError('Passwords do not match')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!accessToken) {
|
||||||
|
setError('Invalid reset token. Please request a new password reset.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await authAPI.resetPassword(accessToken, password)
|
||||||
|
navigate('/login?reset=success')
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as { response?: { data?: { detail?: string } } }
|
||||||
|
setError(e.response?.data?.detail || 'Failed to reset password. The link may have expired.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-primary-50 via-white to-purple-50 flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="inline-flex w-12 h-12 bg-primary-600 rounded-2xl items-center justify-center mb-4">
|
||||||
|
<Sparkles className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Set new password</h1>
|
||||||
|
<p className="text-gray-500 mt-1 text-sm">Choose a strong password for your account</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-8">
|
||||||
|
{!accessToken && error ? (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700 mb-4">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
<Link to="/forgot-password" className="text-primary-600 hover:underline text-sm">
|
||||||
|
Request a new reset link
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
label="New Password"
|
||||||
|
type={showPass ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
placeholder="Min 8 characters"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPass(!showPass)}
|
||||||
|
className="absolute right-3 top-8 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
{showPass ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="Confirm Password"
|
||||||
|
type={showPass ? 'text' : 'password'}
|
||||||
|
value={confirm}
|
||||||
|
onChange={e => setConfirm(e.target.value)}
|
||||||
|
placeholder="Repeat password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button type="submit" loading={loading} className="w-full" size="lg">
|
||||||
|
Set new password
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,35 +1,20 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react'
|
import React, { useState, useCallback } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate, useLocation, Link } from 'react-router-dom'
|
import { useNavigate, useLocation, Link } from 'react-router-dom'
|
||||||
import { billingAPI } from '@/services/api'
|
import { billingAPI, authAPI } from '@/services/api'
|
||||||
import { useAuthStore } from '@/store/authStore'
|
import { useAuthStore } from '@/store/authStore'
|
||||||
import { Button, Card, Input, Badge } from '@/components/ui'
|
import { Button, Card, Input } from '@/components/ui'
|
||||||
import { getPlanColor, formatDate } from '@/lib/utils'
|
import { getPlanColor, formatDate } from '@/lib/utils'
|
||||||
import { CreditCard, User, Shield, Download, ExternalLink } from 'lucide-react'
|
import { CreditCard, User, ExternalLink, AlertTriangle } from 'lucide-react'
|
||||||
|
|
||||||
export const SettingsPage: React.FC = () => {
|
export const SettingsPage: React.FC = () => {
|
||||||
// BUG-04 FIX: Removed unused 'updateUser' from destructuring
|
|
||||||
const { user } = useAuthStore()
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
// BUG-06 FIX: Sync tab with URL path on mount and when path changes
|
|
||||||
const getTabFromPath = (pathname: string): 'profile' | 'billing' => {
|
|
||||||
if (pathname === '/settings/billing') return 'billing'
|
|
||||||
return 'profile'
|
|
||||||
}
|
|
||||||
|
|
||||||
const [tab, setTab] = useState<'profile' | 'billing'>(getTabFromPath(location.pathname))
|
|
||||||
const [toast, setToast] = useState('')
|
const [toast, setToast] = useState('')
|
||||||
|
|
||||||
// Keep tab in sync if URL changes externally
|
const tab: 'profile' | 'billing' = location.pathname === '/settings/billing' ? 'billing' : 'profile'
|
||||||
useEffect(() => {
|
|
||||||
setTab(getTabFromPath(location.pathname))
|
|
||||||
}, [location.pathname])
|
|
||||||
|
|
||||||
// Update URL when tab changes
|
|
||||||
const handleTabChange = useCallback((newTab: 'profile' | 'billing') => {
|
const handleTabChange = useCallback((newTab: 'profile' | 'billing') => {
|
||||||
setTab(newTab)
|
|
||||||
const newPath = newTab === 'billing' ? '/settings/billing' : '/settings'
|
const newPath = newTab === 'billing' ? '/settings/billing' : '/settings'
|
||||||
if (location.pathname !== newPath) {
|
if (location.pathname !== newPath) {
|
||||||
navigate(newPath, { replace: true })
|
navigate(newPath, { replace: true })
|
||||||
@@ -38,7 +23,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
|
|
||||||
const showToast = (msg: string) => {
|
const showToast = (msg: string) => {
|
||||||
setToast(msg)
|
setToast(msg)
|
||||||
setTimeout(() => setToast(''), 3000)
|
setTimeout(() => setToast(''), 3500)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -80,13 +65,67 @@ export const SettingsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ProfileSettings: React.FC<{ onToast: (msg: string) => void }> = ({ onToast }) => {
|
const ProfileSettings: React.FC<{ onToast: (msg: string) => void }> = ({ onToast }) => {
|
||||||
const { user } = useAuthStore()
|
const { user, setAuth, token, logout } = useAuthStore()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [companyName, setCompanyName] = useState(user?.company_name || '')
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('')
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState('')
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const payload: { company_name?: string; current_password?: string; new_password?: string } = {}
|
||||||
|
if (companyName !== user?.company_name) payload.company_name = companyName
|
||||||
|
if (newPassword) {
|
||||||
|
payload.current_password = currentPassword
|
||||||
|
payload.new_password = newPassword
|
||||||
|
}
|
||||||
|
if (Object.keys(payload).length === 0) {
|
||||||
|
onToast('No changes to save')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const updated = await authAPI.updateProfile(payload)
|
||||||
|
setAuth(updated, token || '')
|
||||||
|
setCurrentPassword('')
|
||||||
|
setNewPassword('')
|
||||||
|
onToast('Profile updated successfully')
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as { response?: { data?: { detail?: string } } }
|
||||||
|
onToast(e.response?.data?.detail || 'Failed to update profile')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteAccount = async () => {
|
||||||
|
if (deleteConfirm !== 'DELETE') return
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
await authAPI.deleteAccount()
|
||||||
|
logout()
|
||||||
|
navigate('/')
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as { response?: { data?: { detail?: string } } }
|
||||||
|
onToast(e.response?.data?.detail || 'Failed to delete account')
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
<Card className="p-6 space-y-4">
|
<Card className="p-6 space-y-4">
|
||||||
<h2 className="font-semibold text-gray-900">Profile Information</h2>
|
<h2 className="font-semibold text-gray-900">Profile Information</h2>
|
||||||
<Input label="Email" value={user?.email || ''} disabled />
|
<Input label="Email" value={user?.email || ''} disabled hint="Email cannot be changed" />
|
||||||
<Input label="Company Name" value={user?.company_name || ''} disabled hint="Contact support to change company name" />
|
<Input
|
||||||
|
label="Company Name"
|
||||||
|
value={companyName}
|
||||||
|
onChange={e => setCompanyName(e.target.value)}
|
||||||
|
placeholder="Your company name"
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Plan</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">Plan</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -99,11 +138,80 @@ const ProfileSettings: React.FC<{ onToast: (msg: string) => void }> = ({ onToast
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-6 space-y-4">
|
||||||
|
<h2 className="font-semibold text-gray-900">Change Password</h2>
|
||||||
|
<Input
|
||||||
|
label="Current Password"
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={e => setCurrentPassword(e.target.value)}
|
||||||
|
placeholder="Enter current password"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="New Password"
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={e => setNewPassword(e.target.value)}
|
||||||
|
placeholder="Min 8 characters"
|
||||||
|
hint="Leave blank to keep current password"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Button onClick={handleSave} loading={saving}>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Danger Zone */}
|
||||||
|
<Card className="p-6 border-red-200 bg-red-50/30">
|
||||||
|
<h2 className="font-semibold text-red-800 mb-2 flex items-center gap-1.5">
|
||||||
|
<AlertTriangle className="w-4 h-4" />
|
||||||
|
Danger Zone
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-red-700 mb-4">
|
||||||
|
Permanently delete your account, all chatbots, documents, and data. This cannot be undone.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" className="border-red-300 text-red-700 hover:bg-red-50" onClick={() => setShowDeleteModal(true)}>
|
||||||
|
Delete Account
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Delete Account Modal */}
|
||||||
|
{showDeleteModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-2xl p-6 w-full max-w-md shadow-xl">
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 mb-2">Delete Account</h3>
|
||||||
|
<p className="text-sm text-gray-500 mb-4">
|
||||||
|
This will permanently delete your account and all associated data including chatbots, documents, conversations, and leads.
|
||||||
|
<strong className="text-red-600"> This action cannot be undone.</strong>
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-700 mb-2">Type <strong>DELETE</strong> to confirm:</p>
|
||||||
|
<Input
|
||||||
|
value={deleteConfirm}
|
||||||
|
onChange={e => setDeleteConfirm(e.target.value)}
|
||||||
|
placeholder="DELETE"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-3 mt-4">
|
||||||
|
<Button variant="outline" className="flex-1" onClick={() => { setShowDeleteModal(false); setDeleteConfirm('') }}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="flex-1 bg-red-600 hover:bg-red-700"
|
||||||
|
disabled={deleteConfirm !== 'DELETE'}
|
||||||
|
loading={deleting}
|
||||||
|
onClick={handleDeleteAccount}
|
||||||
|
>
|
||||||
|
Delete Account
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const BillingSettings: React.FC<{ onToast: (msg: string) => void }> = ({ onToast }) => {
|
const BillingSettings: React.FC<{ onToast: (msg: string) => void }> = ({ onToast }) => {
|
||||||
const { user } = useAuthStore()
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
@@ -117,14 +225,25 @@ const BillingSettings: React.FC<{ onToast: (msg: string) => void }> = ({ onToast
|
|||||||
try {
|
try {
|
||||||
const { url } = await billingAPI.createPortal(window.location.href)
|
const { url } = await billingAPI.createPortal(window.location.href)
|
||||||
window.location.href = url
|
window.location.href = url
|
||||||
} catch (err: any) {
|
} catch (err) {
|
||||||
onToast(err.response?.data?.detail || 'Failed to open billing portal')
|
const e = err as { response?: { data?: { detail?: string } } }
|
||||||
|
onToast(e.response?.data?.detail || 'Failed to open billing portal')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPaid = subscription?.plan && subscription.plan !== 'free'
|
const plan = subscription?.plan || 'free'
|
||||||
|
const isPaid = plan !== 'free'
|
||||||
|
|
||||||
|
const planFeatures: Record<string, { published: string; conversations: string; codeExport: string }> = {
|
||||||
|
free: { published: '1', conversations: '100/month', codeExport: '❌ Agency+ only' },
|
||||||
|
starter: { published: '1', conversations: '1,500/month', codeExport: '❌ Agency+ only' },
|
||||||
|
business: { published: '3', conversations: '5,000/month', codeExport: '❌ Agency+ only' },
|
||||||
|
agency: { published: 'Unlimited', conversations: '20,000/month', codeExport: '✅ Included' },
|
||||||
|
enterprise: { published: 'Unlimited', conversations: 'Unlimited', codeExport: '✅ Included' },
|
||||||
|
}
|
||||||
|
const features = planFeatures[plan] || planFeatures.free
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -132,11 +251,12 @@ const BillingSettings: React.FC<{ onToast: (msg: string) => void }> = ({ onToast
|
|||||||
<h2 className="font-semibold text-gray-900 mb-4">Current Plan</h2>
|
<h2 className="font-semibold text-gray-900 mb-4">Current Plan</h2>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<span className={`px-3 py-1 text-sm font-semibold rounded-full capitalize ${getPlanColor(subscription?.plan || 'free')}`}>
|
<span className={`px-3 py-1 text-sm font-semibold rounded-full capitalize ${getPlanColor(plan)}`}>
|
||||||
{subscription?.plan || 'free'}
|
{plan}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
Status: <span className={subscription?.status === 'active' ? 'text-green-600' : 'text-red-600'}>
|
Status:{' '}
|
||||||
|
<span className={subscription?.status === 'active' ? 'text-green-600' : 'text-red-600'}>
|
||||||
{subscription?.status || 'active'}
|
{subscription?.status || 'active'}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -168,10 +288,9 @@ const BillingSettings: React.FC<{ onToast: (msg: string) => void }> = ({ onToast
|
|||||||
<h3 className="font-semibold text-gray-900 mb-4">Plan Features</h3>
|
<h3 className="font-semibold text-gray-900 mb-4">Plan Features</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{[
|
{[
|
||||||
{ label: 'Chatbots created', value: 'Unlimited' },
|
{ label: 'Chatbots published', value: features.published },
|
||||||
{ label: 'Chatbots published', value: subscription?.plan === 'pro' ? '3' : subscription?.plan === 'starter' ? '1' : '0 (upgrade to publish)' },
|
{ label: 'Conversations/month', value: features.conversations },
|
||||||
{ label: 'Conversations/month', value: subscription?.plan === 'pro' ? '20,000' : subscription?.plan === 'starter' ? '5,000' : 'Unlimited (preview)' },
|
{ label: 'Code export', value: features.codeExport },
|
||||||
{ label: 'Code export', value: subscription?.plan === 'pro' || subscription?.plan === 'enterprise' ? '✅ Included' : '❌ Pro+ only' },
|
|
||||||
].map(({ label, value }) => (
|
].map(({ label, value }) => (
|
||||||
<div key={label} className="flex justify-between py-2 border-b border-gray-100 last:border-0">
|
<div key={label} className="flex justify-between py-2 border-b border-gray-100 last:border-0">
|
||||||
<span className="text-sm text-gray-600">{label}</span>
|
<span className="text-sm text-gray-600">{label}</span>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useAuthStore } from '@/store/authStore'
|
|||||||
import type {
|
import type {
|
||||||
AuthResponse, Chatbot, ChatbotPublic, ChatResponse,
|
AuthResponse, Chatbot, ChatbotPublic, ChatResponse,
|
||||||
Document, MarketplaceResponse, Subscription,
|
Document, MarketplaceResponse, Subscription,
|
||||||
ModelsResponse
|
ModelsResponse, Lead, UrlSource, InboxConversation, ChannelConnection
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||||
@@ -43,6 +43,17 @@ export const authAPI = {
|
|||||||
me: () => api.get('/auth/me').then(r => r.data),
|
me: () => api.get('/auth/me').then(r => r.data),
|
||||||
|
|
||||||
logout: () => api.post('/auth/logout').then(r => r.data),
|
logout: () => api.post('/auth/logout').then(r => r.data),
|
||||||
|
|
||||||
|
forgotPassword: (email: string) =>
|
||||||
|
api.post('/auth/forgot-password', { email }).then(r => r.data),
|
||||||
|
|
||||||
|
resetPassword: (access_token: string, new_password: string) =>
|
||||||
|
api.post('/auth/reset-password', { access_token, new_password }).then(r => r.data),
|
||||||
|
|
||||||
|
updateProfile: (data: { company_name?: string; current_password?: string; new_password?: string }) =>
|
||||||
|
api.patch('/auth/profile', data).then(r => r.data),
|
||||||
|
|
||||||
|
deleteAccount: () => api.delete('/auth/account').then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Chatbots ─────────────────────────────────────────────────────────────────
|
// ─── Chatbots ─────────────────────────────────────────────────────────────────
|
||||||
@@ -65,6 +76,12 @@ export const chatbotsAPI = {
|
|||||||
|
|
||||||
export: (id: string) =>
|
export: (id: string) =>
|
||||||
api.post(`/chatbots/${id}/export`, {}, { responseType: 'blob' }).then(r => r.data),
|
api.post(`/chatbots/${id}/export`, {}, { responseType: 'blob' }).then(r => r.data),
|
||||||
|
|
||||||
|
getPublic: (id: string) =>
|
||||||
|
api.get(`/chatbots/${id}/public`).then(r => r.data),
|
||||||
|
|
||||||
|
getEmbed: (id: string) =>
|
||||||
|
api.get(`/chatbots/${id}/embed`).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Documents ────────────────────────────────────────────────────────────────
|
// ─── Documents ────────────────────────────────────────────────────────────────
|
||||||
@@ -94,6 +111,9 @@ export const chatAPI = {
|
|||||||
|
|
||||||
history: (chatbotId: string, sessionId: string) =>
|
history: (chatbotId: string, sessionId: string) =>
|
||||||
api.get(`/chat/${chatbotId}/history/${sessionId}`).then(r => r.data),
|
api.get(`/chat/${chatbotId}/history/${sessionId}`).then(r => r.data),
|
||||||
|
|
||||||
|
feedback: (chatbotId: string, messageId: string, feedback: 'positive' | 'negative') =>
|
||||||
|
api.post(`/chat/${chatbotId}/feedback`, { message_id: messageId, feedback }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Marketplace ──────────────────────────────────────────────────────────────
|
// ─── Marketplace ──────────────────────────────────────────────────────────────
|
||||||
@@ -120,6 +140,9 @@ export const billingAPI = {
|
|||||||
cancel_url: cancelUrl,
|
cancel_url: cancelUrl,
|
||||||
}).then(r => r.data),
|
}).then(r => r.data),
|
||||||
|
|
||||||
|
createPortal: (returnUrl: string) =>
|
||||||
|
api.post<{ url: string }>('/billing/portal', { return_url: returnUrl }).then(r => r.data),
|
||||||
|
|
||||||
getPortalUrl: () =>
|
getPortalUrl: () =>
|
||||||
api.post<{ url: string }>('/billing/portal').then(r => r.data),
|
api.post<{ url: string }>('/billing/portal').then(r => r.data),
|
||||||
|
|
||||||
@@ -140,4 +163,72 @@ export const analyticsAPI = {
|
|||||||
|
|
||||||
chatbot: (chatbotId: string) =>
|
chatbot: (chatbotId: string) =>
|
||||||
api.get(`/analytics/chatbot/${chatbotId}`).then(r => r.data),
|
api.get(`/analytics/chatbot/${chatbotId}`).then(r => r.data),
|
||||||
|
|
||||||
|
gaps: (chatbotId: string) =>
|
||||||
|
api.get(`/analytics/chatbot/${chatbotId}/gaps`).then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Upload ───────────────────────────────────────────────────────────────────
|
||||||
|
export const uploadAPI = {
|
||||||
|
logo: (file: File) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
return api.post<{ url: string }>('/upload/logo', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
}).then(r => r.data)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── URL Sources ──────────────────────────────────────────────────────────────
|
||||||
|
export const urlSourcesAPI = {
|
||||||
|
list: (chatbotId: string) =>
|
||||||
|
api.get<UrlSource[]>(`/chatbots/${chatbotId}/url-sources`).then(r => r.data),
|
||||||
|
|
||||||
|
add: (chatbotId: string, url: string) =>
|
||||||
|
api.post<UrlSource>(`/chatbots/${chatbotId}/url-sources`, { url }).then(r => r.data),
|
||||||
|
|
||||||
|
delete: (chatbotId: string, sourceId: string) =>
|
||||||
|
api.delete(`/chatbots/${chatbotId}/url-sources/${sourceId}`).then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Leads ────────────────────────────────────────────────────────────────────
|
||||||
|
export const leadsAPI = {
|
||||||
|
list: (params?: { chatbot_id?: string; page?: number; limit?: number }) =>
|
||||||
|
api.get<Lead[]>('/leads', { params }).then(r => r.data),
|
||||||
|
|
||||||
|
submit: (chatbotId: string, data: { email?: string; name?: string; phone?: string; company?: string; conversation_id?: string }) =>
|
||||||
|
api.post<Lead>(`/chatbots/${chatbotId}/leads`, data).then(r => r.data),
|
||||||
|
|
||||||
|
exportCsv: (chatbotId?: string) =>
|
||||||
|
api.get('/leads/export', {
|
||||||
|
params: chatbotId ? { chatbot_id: chatbotId } : undefined,
|
||||||
|
responseType: 'blob',
|
||||||
|
}).then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Inbox ────────────────────────────────────────────────────────────────────
|
||||||
|
export const inboxAPI = {
|
||||||
|
conversations: (params?: { chatbot_id?: string; page?: number; limit?: number }) =>
|
||||||
|
api.get<InboxConversation[]>('/inbox/conversations', { params }).then(r => r.data),
|
||||||
|
|
||||||
|
conversation: (id: string) =>
|
||||||
|
api.get(`/inbox/conversations/${id}`).then(r => r.data),
|
||||||
|
|
||||||
|
deleteConversation: (id: string) =>
|
||||||
|
api.delete(`/inbox/conversations/${id}`).then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Channels ─────────────────────────────────────────────────────────────────
|
||||||
|
export const channelsAPI = {
|
||||||
|
list: (chatbotId: string) =>
|
||||||
|
api.get<ChannelConnection[]>('/channels', { params: { chatbot_id: chatbotId } }).then(r => r.data),
|
||||||
|
|
||||||
|
connectTelegram: (chatbotId: string, botToken: string) =>
|
||||||
|
api.post('/channels/telegram', { chatbot_id: chatbotId, bot_token: botToken }).then(r => r.data),
|
||||||
|
|
||||||
|
connectWhatsapp: (chatbotId: string, waKeyword?: string) =>
|
||||||
|
api.post('/channels/whatsapp', { chatbot_id: chatbotId, wa_keyword: waKeyword || null }).then(r => r.data),
|
||||||
|
|
||||||
|
disconnect: (connectionId: string) =>
|
||||||
|
api.delete(`/channels/${connectionId}`).then(r => r.data),
|
||||||
}
|
}
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
export interface User {
|
export interface User {
|
||||||
id: string
|
id: string
|
||||||
email: string
|
email: string
|
||||||
full_name: string
|
full_name?: string
|
||||||
company_name?: string
|
company_name?: string
|
||||||
plan: 'free' | 'starter' | 'pro' | 'enterprise'
|
plan: 'free' | 'starter' | 'business' | 'agency' | 'enterprise'
|
||||||
created_at?: string
|
created_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +38,14 @@ export interface Chatbot {
|
|||||||
average_rating?: number
|
average_rating?: number
|
||||||
created_at?: string
|
created_at?: string
|
||||||
published_at?: string
|
published_at?: string
|
||||||
|
show_branding: boolean
|
||||||
|
lead_capture_enabled: boolean
|
||||||
|
lead_capture_fields: string[]
|
||||||
|
lead_capture_trigger: string
|
||||||
|
handoff_enabled: boolean
|
||||||
|
handoff_message: string
|
||||||
|
handoff_email?: string
|
||||||
|
handoff_keywords: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatbotPublic {
|
export interface ChatbotPublic {
|
||||||
@@ -71,6 +79,14 @@ export interface ChatbotFormData {
|
|||||||
category: string
|
category: string
|
||||||
industry: string
|
industry: string
|
||||||
languages: string[]
|
languages: string[]
|
||||||
|
show_branding: boolean
|
||||||
|
lead_capture_enabled: boolean
|
||||||
|
lead_capture_fields: string[]
|
||||||
|
lead_capture_trigger: string
|
||||||
|
handoff_enabled: boolean
|
||||||
|
handoff_message: string
|
||||||
|
handoff_email: string
|
||||||
|
handoff_keywords: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Document ─────────────────────────────────────────────────────────────────
|
// ─── Document ─────────────────────────────────────────────────────────────────
|
||||||
@@ -108,6 +124,8 @@ export interface ChatResponse {
|
|||||||
sources: SourceDocument[]
|
sources: SourceDocument[]
|
||||||
model_used: string
|
model_used: string
|
||||||
tokens_used: number
|
tokens_used: number
|
||||||
|
needs_lead_capture?: boolean
|
||||||
|
handoff?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Subscription ─────────────────────────────────────────────────────────────
|
// ─── Subscription ─────────────────────────────────────────────────────────────
|
||||||
@@ -176,3 +194,73 @@ export interface ModelsResponse {
|
|||||||
has_premium_access: boolean
|
has_premium_access: boolean
|
||||||
upgrade_label: string | null
|
upgrade_label: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Leads ────────────────────────────────────────────────────────────────────
|
||||||
|
export interface Lead {
|
||||||
|
id: string
|
||||||
|
chatbot_id: string
|
||||||
|
conversation_id?: string
|
||||||
|
email?: string
|
||||||
|
name?: string
|
||||||
|
phone?: string
|
||||||
|
company?: string
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── URL Sources ──────────────────────────────────────────────────────────────
|
||||||
|
export interface UrlSource {
|
||||||
|
id: string
|
||||||
|
chatbot_id: string
|
||||||
|
url: string
|
||||||
|
status: 'pending' | 'processing' | 'completed' | 'failed'
|
||||||
|
page_title?: string
|
||||||
|
chunk_count: number
|
||||||
|
error_message?: string
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Inbox ────────────────────────────────────────────────────────────────────
|
||||||
|
export interface InboxConversation {
|
||||||
|
id: string
|
||||||
|
chatbot_id: string
|
||||||
|
chatbot_name: string
|
||||||
|
session_id?: string
|
||||||
|
language: string
|
||||||
|
message_count: number
|
||||||
|
first_message?: string
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InboxMessage {
|
||||||
|
id: string
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
sources?: SourceDocument[]
|
||||||
|
confidence_score?: number
|
||||||
|
is_handoff: boolean
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Channels ─────────────────────────────────────────────────────────────────
|
||||||
|
export interface ChannelConnection {
|
||||||
|
id: string
|
||||||
|
channel: 'telegram' | 'whatsapp'
|
||||||
|
bot_username?: string
|
||||||
|
wa_keyword?: string
|
||||||
|
wa_link?: string
|
||||||
|
is_active: boolean
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Templates ────────────────────────────────────────────────────────────────
|
||||||
|
export interface ChatbotTemplate {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
icon: string
|
||||||
|
category: string
|
||||||
|
industry: string
|
||||||
|
system_prompt: string
|
||||||
|
welcome_message: string
|
||||||
|
lead_capture_enabled: boolean
|
||||||
|
}
|
||||||
@@ -22,7 +22,13 @@
|
|||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true,
|
||||||
|
|
||||||
|
/* Path aliases */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user