Updates Mar6

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

View File

@@ -1,5 +1,5 @@
{
"name": "contexta-fe",
"name": "contexta_fe",
"private": true,
"version": "0.0.0",
"type": "module",

View File

@@ -10,6 +10,8 @@ import './App.css'
const LandingPage = lazy(() => import('@/pages/LandingPage').then(m => ({ default: m.LandingPage })))
const LoginPage = lazy(() => import('@/pages/AuthPages').then(m => ({ default: m.LoginPage })))
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 ChatbotBuilderPage = lazy(() => import('@/pages/ChatbotBuilderPage').then(m => ({ default: m.ChatbotBuilderPage })))
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 SettingsPage = lazy(() => import('@/pages/SettingsPage').then(m => ({ default: m.SettingsPage })))
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 = () => (
<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/:id" element={<SmartPublicRoute><ChatbotDetailPage /></SmartPublicRoute>} />
{/* Public chat - no auth, no layout */}
<Route path="/chat/:id" element={<PublicChatPage />} />
{/* Auth */}
<Route path="/login" element={<PublicOnlyRoute><LoginPage /></PublicOnlyRoute>} />
<Route path="/signup" element={<PublicOnlyRoute><SignupPage /></PublicOnlyRoute>} />
<Route path="/forgot-password" element={<PublicOnlyRoute><ForgotPasswordPage /></PublicOnlyRoute>} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
{/* Protected */}
<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/:id/edit" 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/billing" element={<PrivateRoute><SettingsPage /></PrivateRoute>} />

View File

@@ -1,8 +1,8 @@
import React, { useState, useEffect, useRef } from 'react'
import { cn } from '@/lib/utils'
import { chatAPI } from '@/services/api'
import type { ChatMessage, SourceDocument } from '@/types'
import { Send, Bot, User, FileText, ChevronDown, ChevronUp } from 'lucide-react'
import { chatAPI, leadsAPI } from '@/services/api'
import type { ChatMessage } from '@/types'
import { Send, Bot, FileText, ChevronDown, ChevronUp } from 'lucide-react'
interface ChatInterfaceProps {
chatbotId: string
@@ -12,11 +12,24 @@ interface ChatInterfaceProps {
logoUrl?: string
isPreview?: boolean
sessionId?: string
showBranding?: boolean
leadCaptureEnabled?: boolean
leadCaptureFields?: string[]
leadCaptureTrigger?: string
handoffEnabled?: boolean
handoffMessage?: string
chatbotIdForLeads?: string
conversationId?: string
}
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[]>([
{ id: '0', role: 'assistant', content: welcomeMessage }
])
@@ -33,6 +46,12 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
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 bottomRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
@@ -68,11 +87,27 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
sources: response.sources,
}
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 = {
id: crypto.randomUUID(),
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])
} 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) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
@@ -91,7 +151,7 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
const toggleSources = (msgId: string) => {
setExpandedSources(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
})
}
@@ -126,74 +186,114 @@ 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">
{/* Header */}
<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 }}
>
{logoUrl ? (
<img
src={logoUrl}
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" />
</div>
)}
<div>
<h3 className="text-sm font-semibold text-white">{chatbotName}</h3>
{isPreview && <span className="text-xs text-white/70">Preview mode</span>}
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-white leading-tight">{chatbotName}</h3>
<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>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((msg) => (
<div key={msg.id} className={cn('flex gap-3', msg.role === 'user' ? 'justify-end' : '')}>
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-gray-50/30">
{messages.map((msg, msgIdx) => (
<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 />}
<div className={cn(
'max-w-[80%] rounded-2xl px-4 py-2.5 text-sm',
msg.role === 'user'
? 'text-white rounded-br-md'
: 'bg-gray-100 text-gray-800 rounded-bl-md'
)} style={msg.role === 'user' ? { background: primaryColor } : undefined}>
<p className="whitespace-pre-wrap">{msg.content}</p>
<div className="flex flex-col gap-1 max-w-[80%]">
<div className={cn(
'rounded-2xl px-4 py-2.5 text-sm leading-relaxed',
msg.role === 'user'
? 'text-white rounded-br-sm shadow-sm'
: 'bg-white text-gray-800 rounded-bl-sm border border-gray-100 shadow-sm'
)} style={msg.role === 'user' ? { background: primaryColor } : undefined}>
<p className="whitespace-pre-wrap">{msg.content}</p>
{/* Sources */}
{msg.sources && msg.sources.length > 0 && (
<div className="mt-2 pt-2 border-t border-gray-200/50">
<button
onClick={() => toggleSources(msg.id)}
className="flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700"
>
<FileText className="w-3 h-3" />
{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" />}
</button>
{expandedSources.has(msg.id) && (
<div className="mt-2 space-y-2">
{msg.sources.map((src, i) => (
<div key={i} className="bg-white/80 rounded-lg p-2 text-xs">
<p className="font-medium text-gray-700">{src.document_name}</p>
<p className="text-gray-500 mt-1 line-clamp-3">{src.chunk_text}</p>
</div>
))}
</div>
{/* Sources */}
{msg.sources && msg.sources.length > 0 && (
<div className="mt-2 pt-2 border-t border-gray-200/60">
<button
onClick={() => toggleSources(msg.id)}
className="flex items-center gap-1 text-xs text-gray-400 hover:text-gray-600 transition-colors"
>
<FileText className="w-3 h-3" />
{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" />}
</button>
{expandedSources.has(msg.id) && (
<div className="mt-2 space-y-1.5">
{msg.sources.map((src, i) => (
<div key={i} className="bg-gray-50 rounded-lg p-2.5 text-xs border border-gray-100">
<p className="font-medium text-gray-600 mb-1">{src.document_name}</p>
<p className="text-gray-400 line-clamp-3 leading-relaxed">{src.chunk_text}</p>
</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'
)}
</div>
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>
))}
{loading && (
<div className="flex gap-3">
<div className="flex gap-2.5 animate-fade-in">
<BotAvatar />
<div className="bg-gray-100 rounded-2xl rounded-bl-md px-4 py-3">
<div className="flex gap-1">
<div className="w-2 h-2 bg-gray-400 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-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
<div className="bg-white rounded-2xl rounded-bl-sm border border-gray-100 shadow-sm px-4 py-3">
<div className="flex gap-1.5 items-center">
<div className="w-2 h-2 bg-gray-300 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<div className="w-2 h-2 bg-gray-300 rounded-full animate-bounce" style={{ animationDelay: '160ms' }} />
<div className="w-2 h-2 bg-gray-300 rounded-full animate-bounce" style={{ animationDelay: '320ms' }} />
</div>
</div>
</div>
@@ -201,29 +301,97 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
<div ref={bottomRef} />
</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 */}
<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">
<textarea
ref={inputRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
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"
style={{ minHeight: '42px', maxHeight: '120px' }}
/>
<textarea
ref={inputRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message…"
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 bg-gray-50 focus:bg-white placeholder-gray-400"
style={{ minHeight: '42px', maxHeight: '120px' }}
/>
<button
onClick={send}
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 }}
>
<Send className="w-4 h-4" />
</button>
</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>
)
}

View File

@@ -5,12 +5,14 @@ import { useAuthStore } from '@/store/authStore'
import { authAPI } from '@/services/api'
import { getPlanColor } from '@/lib/utils'
import {
Bot, LayoutDashboard, ShoppingBag, Settings,
LogOut, Menu, X, Sparkles, ChevronDown, BarChart3
LayoutDashboard, ShoppingBag, Settings,
LogOut, Menu, Sparkles, BarChart3, Mail, Users
} from 'lucide-react'
const NAV_ITEMS = [
{ 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: 'Marketplace', href: '/marketplace', icon: ShoppingBag },
{ 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 handleLogout = async () => {
try { await authAPI.logout() } catch {}
try { await authAPI.logout() } catch { /* intentionally ignored */ }
logout()
navigate('/login')
}
@@ -42,10 +44,10 @@ export const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children })
)}>
{/* Logo */}
<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" />
</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>
{/* Nav */}
@@ -57,24 +59,25 @@ export const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children })
key={href}
to={href}
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
? 'bg-primary-50 text-primary-700'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
? 'bg-primary-50 text-primary-700 shadow-sm'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 hover:translate-x-0.5'
)}
onClick={() => setSidebarOpen(false)}
>
<Icon className="w-4 h-4" />
<Icon className={cn('w-4 h-4 transition-transform duration-150', active && 'scale-110')} />
{label}
{active && <span className="ml-auto w-1.5 h-1.5 rounded-full bg-primary-500" />}
</Link>
)
})}
</nav>
{/* 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="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() || '?'}
</div>
<div className="flex-1 min-w-0">

View File

@@ -11,7 +11,7 @@ interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
export const Button: React.FC<ButtonProps> = ({
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 = {
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',
@@ -131,7 +131,11 @@ export const Card: React.FC<{ children: React.ReactNode; className?: string; onC
children, className, onClick
}) => (
<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}
>
{children}
@@ -173,8 +177,8 @@ export const Spinner: React.FC<{ className?: string }> = ({ className }) => (
export const EmptyState: React.FC<{
icon: React.ReactNode; title: string; description: string; action?: React.ReactNode
}> = ({ icon, title, description, action }) => (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center mb-4 text-gray-400">
<div className="flex flex-col items-center justify-center py-16 text-center animate-fade-in-up">
<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}
</div>
<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 }) => {
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className={cn('relative bg-white rounded-2xl shadow-xl w-full max-w-lg', className)}>
<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 backdrop-blur-sm" onClick={onClose} />
<div className={cn('relative bg-white rounded-2xl shadow-2xl w-full max-w-lg animate-scale-in', className)}>
{title && (
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-lg font-semibold">{title}</h2>

81
src/data/templates.ts Normal file
View 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,
},
]

View File

@@ -1,14 +1,12 @@
import React, { useState } from 'react'
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 { useAuthStore } from '@/store/authStore'
import { Card, Spinner, Button, Badge } from '@/components/ui'
import { formatDate } from '@/lib/utils'
import {
BarChart3, Users, MessageSquare, Star, TrendingUp,
Clock, Globe, ArrowRight, Lock, Bot, Calendar,
ArrowUp, ArrowDown, Minus, ChevronDown, ChevronUp
BarChart3, Users, MessageSquare, Star,
Clock, Globe, Lock, Bot,
ChevronDown, ChevronUp
} from 'lucide-react'
// ═══════════════════════════════════════════════════════════════════════════════
@@ -43,6 +41,10 @@ interface ChatbotAnalytics {
top_queries: TopQuery[]
languages_used: Record<string, number>
peak_hour: number | null
unanswered_count: number
unanswered_queries: TopQuery[]
feedback_positive: number
feedback_negative: number
}
interface OverviewData {
@@ -80,7 +82,7 @@ const MiniBarChart: React.FC<{ data: DailyConversation[] }> = ({ data }) => {
return (
<div className="flex items-end gap-[2px] h-16">
{days.map((d, i) => (
{days.map((d) => (
<div
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"
@@ -237,6 +239,38 @@ const ChatbotRow: React.FC<{ chatbot: ChatbotAnalytics }> = ({ chatbot }) => {
)}
</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 && (
<div className="text-xs text-gray-500 flex items-center gap-1">
<Clock className="w-3 h-3" />
@@ -254,7 +288,6 @@ const ChatbotRow: React.FC<{ chatbot: ChatbotAnalytics }> = ({ chatbot }) => {
// ═══════════════════════════════════════════════════════════════════════════════
export const AnalyticsPage: React.FC = () => {
const { user } = useAuthStore()
const navigate = useNavigate()
const { data, isLoading, error } = useQuery<OverviewData>({
@@ -265,7 +298,7 @@ export const AnalyticsPage: React.FC = () => {
})
// Handle plan gate (402 response)
if (error && (error as any)?.response?.status === 402) {
if (error && (error as { response?: { status?: number } })?.response?.status === 402) {
return (
<div className="p-6 max-w-2xl mx-auto">
<Card className="p-8 text-center">

View File

@@ -22,8 +22,9 @@ export const LoginPage: React.FC = () => {
const data = await authAPI.login({ email, password })
setAuth(data.user, data.access_token)
navigate('/dashboard')
} catch (err: any) {
setError(err.response?.data?.detail || 'Login failed. Please check your credentials.')
} catch (err) {
const e = err as { response?: { data?: { detail?: string } } }
setError(e.response?.data?.detail || 'Login failed. Please check your credentials.')
} finally {
setLoading(false)
}
@@ -79,7 +80,13 @@ export const LoginPage: React.FC = () => {
</Button>
</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?{' '}
<Link to="/signup" className="text-primary-600 font-medium hover:underline">
Create one free
@@ -96,6 +103,7 @@ export const SignupPage: React.FC = () => {
const [showPass, setShowPass] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [emailSent, setEmailSent] = useState(false)
const { setAuth } = useAuthStore()
const navigate = useNavigate()
@@ -109,15 +117,43 @@ export const SignupPage: React.FC = () => {
setLoading(true)
try {
const data = await authAPI.signup(form)
setAuth(data.user, data.access_token)
navigate('/dashboard')
} catch (err: any) {
setError(err.response?.data?.detail || 'Signup failed. Please try again.')
if (data.access_token) {
// Email confirmation not required — go straight to dashboard
setAuth(data.user, data.access_token)
navigate('/dashboard')
} else {
// 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 {
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 (
<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">

View File

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

View File

@@ -1,14 +1,12 @@
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 { chatbotsAPI } from '@/services/api'
import { useAuthStore } from '@/store/authStore'
import { Button, Card, Badge, StatusDot, EmptyState, Modal, Spinner } from '@/components/ui'
import { formatDate, getFileIcon, cn } from '@/lib/utils'
import { Button, Card, StatusDot, EmptyState, Modal, Spinner } from '@/components/ui'
import type { Chatbot } from '@/types'
import {
Bot, Plus, MoreHorizontal, Globe, Lock, Trash2,
Settings, Upload, Eye, ExternalLink, Download, BarChart2
Settings, Eye, BarChart2
} from 'lucide-react'
// BUG-05 FIX: Toast queue system using array + auto-dismiss
@@ -18,7 +16,6 @@ interface ToastItem {
}
export const DashboardPage: React.FC = () => {
const { user } = useAuthStore()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [deleteId, setDeleteId] = useState<string | null>(null)
@@ -82,7 +79,7 @@ export const DashboardPage: React.FC = () => {
return (
<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>
<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>
@@ -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">
{chatbots.map((chatbot) => (
{chatbots.map((chatbot, i) => (
<ChatbotCard
key={chatbot.id}
chatbot={chatbot}
index={i}
onEdit={() => navigate(`/chatbots/${chatbot.id}/edit`)}
onPreview={() => navigate(`/chatbots/${chatbot.id}/preview`)}
onPublish={() => setConfirmAction({ type: 'publish', id: chatbot.id })}
@@ -125,17 +122,17 @@ export const DashboardPage: React.FC = () => {
/>
))}
{/* Add new card */}
<Card
className="border-dashed hover:border-primary-400 hover:bg-primary-50 flex items-center justify-center min-h-[200px] cursor-pointer transition-colors"
<button
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')}
>
<div className="text-center">
<div className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-3">
<Plus className="w-6 h-6 text-gray-400" />
<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 group-hover:text-primary-500 transition-colors duration-200" />
</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>
</Card>
</button>
</div>
)}
@@ -207,118 +204,130 @@ export const DashboardPage: React.FC = () => {
const ChatbotCard: React.FC<{
chatbot: Chatbot
index: number
onEdit: () => void
onPreview: () => void
onPublish: () => void
onUnpublish: () => void
onDelete: () => void
onAnalytics: () => void
}> = ({ chatbot, onEdit, onPreview, onPublish, onUnpublish, onDelete, onAnalytics }) => {
}> = ({ chatbot, index, onEdit, onPreview, onPublish, onUnpublish, onDelete, onAnalytics }) => {
const [menuOpen, setMenuOpen] = useState(false)
return (
<Card className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
{chatbot.logo_url ? (
<img
src={chatbot.logo_url}
alt={chatbot.name}
className="w-10 h-10 rounded-xl object-cover flex-shrink-0"
/>
) : (
<div
className="w-10 h-10 rounded-xl flex items-center justify-center text-white text-lg flex-shrink-0"
style={{ background: chatbot.primary_color }}
>
<Bot className="w-5 h-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-center gap-3">
{chatbot.logo_url ? (
<img
src={chatbot.logo_url}
alt={chatbot.name}
className="w-11 h-11 rounded-xl object-cover flex-shrink-0 shadow-sm group-hover:scale-105 transition-transform duration-200"
/>
) : (
<div
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 }}
>
<Bot className="w-5 h-5" />
</div>
)}
<div>
<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">
<StatusDot status={chatbot.is_published ? 'published' : 'preview'} />
<span className={`text-xs font-medium ${chatbot.is_published ? 'text-green-600' : 'text-gray-400'}`}>
{chatbot.is_published ? 'Published' : 'Preview'}
</span>
</div>
)}
<div>
<h3 className="font-semibold text-gray-900 text-sm">{chatbot.name}</h3>
<div className="flex items-center gap-1.5 mt-0.5">
<StatusDot status={chatbot.is_published ? 'published' : 'preview'} />
<span className="text-xs text-gray-500">
{chatbot.is_published ? 'Published' : 'Preview'}
</span>
</div>
</div>
<div className="relative">
<button
onClick={() => setMenuOpen(!menuOpen)}
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" />
</button>
{menuOpen && (
<>
<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-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.5 hover:bg-gray-50 text-left text-gray-700 transition-colors">
<Settings className="w-3.5 h-3.5 text-gray-400" /> Edit Settings
</button>
<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 text-gray-400" /> Preview
</button>
<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 text-gray-400" /> Analytics
</button>
<div className="h-px bg-gray-100 mx-2" />
{chatbot.is_published ? (
<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
</button>
) : (
<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
</button>
)}
<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.5 hover:bg-red-50 text-red-600 text-left transition-colors">
<Trash2 className="w-3.5 h-3.5" /> Delete
</button>
</div>
</>
)}
</div>
</div>
{/* R-02 FIX: Menu dropdown with viewport-aware positioning */}
<div className="relative">
<button
onClick={() => setMenuOpen(!menuOpen)}
className="p-1.5 hover:bg-gray-100 rounded-lg text-gray-400"
>
<MoreHorizontal className="w-4 h-4" />
</button>
{menuOpen && (
<>
<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">
<button onClick={() => { onEdit(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2 hover:bg-gray-50 text-left">
<Settings className="w-3.5 h-3.5" /> Edit Settings
</button>
<button onClick={() => { onPreview(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2 hover:bg-gray-50 text-left">
<Eye className="w-3.5 h-3.5" /> Preview
</button>
<button onClick={() => { onAnalytics(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2 hover:bg-gray-50 text-left">
<BarChart2 className="w-3.5 h-3.5" /> Analytics
</button>
<div className="h-px bg-gray-100" />
{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">
<Lock className="w-3.5 h-3.5" /> Unpublish
</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">
<Globe className="w-3.5 h-3.5" /> Publish
</button>
)}
<div className="h-px bg-gray-100" />
<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">
<Trash2 className="w-3.5 h-3.5" /> Delete
</button>
</div>
</>
{chatbot.description && (
<p className="text-xs text-gray-500 mb-3 line-clamp-2 leading-relaxed">{chatbot.description}</p>
)}
{/* Stats */}
<div className="flex flex-wrap gap-2 mb-4">
<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.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 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>
{/* Actions */}
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={onPreview} className="flex-1">
<Eye className="w-3.5 h-3.5" />
Preview
</Button>
{chatbot.is_published ? (
<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" />
Unpublish
</Button>
) : (
<Button size="sm" onClick={onPublish} className="flex-1">
<Globe className="w-3.5 h-3.5" />
Publish
</Button>
)}
</div>
</div>
{chatbot.description && (
<p className="text-xs text-gray-500 mb-3 line-clamp-2">{chatbot.description}</p>
)}
{/* Stats */}
<div className="flex flex-wrap gap-3 mb-4 text-xs text-gray-500">
<span>📄 {chatbot.document_count} docs</span>
<span>💬 {chatbot.conversation_count} chats</span>
{chatbot.category && <span>🏷 {chatbot.category}</span>}
</div>
{/* Actions */}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={onPreview}
className="flex-1"
>
<Eye className="w-3.5 h-3.5" />
Preview
</Button>
{chatbot.is_published ? (
<Button variant="outline" size="sm" onClick={onUnpublish} className="flex-1">
<Lock className="w-3.5 h-3.5" />
Unpublish
</Button>
) : (
<Button size="sm" onClick={onPublish} className="flex-1">
<Globe className="w-3.5 h-3.5" />
Publish
</Button>
)}
</div>
</Card>
</div>
)
}

View 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
View 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>
)
}

View File

@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'
import { Link } from 'react-router-dom'
import {
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
} from 'lucide-react'
@@ -20,11 +20,20 @@ function useInView(options?: IntersectionObserverInit) {
)
observer.observe(el)
return () => observer.disconnect()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
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 ──────────────────────────────────────────────────────────
const AnimatedCounter: React.FC<{ end: number; suffix?: string; label: string; isInView: boolean }> = ({ end, suffix = '', label, isInView }) => {
const [count, setCount] = useState(0)
@@ -58,28 +67,21 @@ const FloatingChatPreview: React.FC = () => {
const [isTyping, setIsTyping] = useState(false)
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(() => {
if (step >= conversation.length) {
if (step >= CHAT_CONVERSATION.length) {
const timeout = setTimeout(() => { setMessages([]); setStep(0) }, 4000)
return () => clearTimeout(timeout)
}
const timeout = setTimeout(() => {
if (conversation[step].role === 'bot') {
if (CHAT_CONVERSATION[step].role === 'bot') {
setIsTyping(true)
setTimeout(() => {
setIsTyping(false)
setMessages(prev => [...prev, conversation[step]])
setMessages(prev => [...prev, CHAT_CONVERSATION[step]])
setStep(s => s + 1)
}, 1500)
} else {
setMessages(prev => [...prev, conversation[step]])
setMessages(prev => [...prev, CHAT_CONVERSATION[step]])
setStep(s => s + 1)
}
}, step === 0 ? 1500 : 1000)
@@ -240,12 +242,12 @@ export const LandingPage: React.FC = () => {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [scrolled, setScrolled] = useState(false)
const features = useInView()
const howItWorks = useInView()
const stats = useInView()
const testimonials = useInView()
const pricing = useInView()
const cta = useInView()
const { ref: featuresRef, isInView: featuresInView } = useInView()
const { ref: howItWorksRef, isInView: howItWorksInView } = useInView()
const { ref: statsRef, isInView: statsInView } = useInView()
const { ref: testimonialsRef, isInView: testimonialsInView } = useInView()
const { ref: pricingRef, isInView: pricingInView } = useInView()
const { ref: ctaRef, isInView: ctaInView } = useInView()
useEffect(() => {
const handleScroll = () => setScrolled(window.scrollY > 20)
@@ -385,32 +387,32 @@ export const LandingPage: React.FC = () => {
</section>
{/* ── 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="grid grid-cols-2 md:grid-cols-4 gap-8">
<AnimatedCounter end={2500} suffix="+" label="Chatbots Created" isInView={stats.isInView} />
<AnimatedCounter end={500} suffix="+" label="Companies" isInView={stats.isInView} />
<AnimatedCounter end={10} suffix="M+" label="Messages Processed" isInView={stats.isInView} />
<AnimatedCounter end={99} suffix="%" label="Uptime" isInView={stats.isInView} />
<AnimatedCounter end={2500} suffix="+" label="Chatbots Created" isInView={statsInView} />
<AnimatedCounter end={500} suffix="+" label="Companies" isInView={statsInView} />
<AnimatedCounter end={10} suffix="M+" label="Messages Processed" isInView={statsInView} />
<AnimatedCounter end={99} suffix="%" label="Uptime" isInView={statsInView} />
</div>
</div>
</section>
{/* ── 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="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
mb-4 font-semibold uppercase tracking-wider transition-all duration-500
${features.isInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
<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
${featuresInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
Features
</div>
<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'}`}>
<h2 className={`text-3xl md:text-4xl font-extrabold text-gray-900 mb-4 tracking-tight transition-all duration-700
${featuresInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
Everything you need to build
</h2>
<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'}`}>
<p className={`text-gray-500 max-w-lg mx-auto transition-all duration-700 delay-100
${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.
</p>
</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: <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) => (
<FeatureCard key={f.title} {...f} delay={i * 100} isInView={features.isInView} />
<FeatureCard key={f.title} {...f} delay={i * 100} isInView={featuresInView} />
))}
</div>
</div>
</section>
{/* ── 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="relative max-w-5xl mx-auto px-6">
<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
mb-4 font-semibold uppercase tracking-wider transition-all duration-500
${howItWorks.isInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
<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
${howItWorksInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
How It Works
</div>
<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'}`}>
<h2 className={`text-3xl md:text-4xl font-extrabold text-gray-900 mb-4 tracking-tight transition-all duration-700
${howItWorksInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
Three steps to your AI chatbot
</h2>
</div>
@@ -452,13 +454,13 @@ export const LandingPage: React.FC = () => {
<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."
isInView={howItWorks.isInView} delay={0} />
isInView={howItWorksInView} delay={0} />
<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."
isInView={howItWorks.isInView} delay={200} />
isInView={howItWorksInView} delay={200} />
<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."
isInView={howItWorks.isInView} delay={400} />
isInView={howItWorksInView} delay={400} />
</div>
</div>
</section>
@@ -534,17 +536,17 @@ async def chat(message: str):
</section>
{/* ── 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="relative max-w-6xl mx-auto px-6">
<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
mb-4 font-semibold uppercase tracking-wider transition-all duration-500
${testimonials.isInView ? 'opacity-100' : 'opacity-0'}`}>
<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
${testimonialsInView ? 'opacity-100' : 'opacity-0'}`}>
Testimonials
</div>
<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'}`}>
<h2 className={`text-3xl md:text-4xl font-extrabold text-gray-900 mb-4 tracking-tight transition-all duration-700
${testimonialsInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
Loved by builders
</h2>
</div>
@@ -553,36 +555,36 @@ async def chat(message: str):
<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."
name="Sarah Chen" role="CTO" company="TechFlow"
isInView={testimonials.isInView} delay={0}
isInView={testimonialsInView} delay={0}
/>
<TestimonialCard
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"
isInView={testimonials.isInView} delay={150}
isInView={testimonialsInView} delay={150}
/>
<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."
name="Elena Kowalski" role="VP of Product" company="DataBridge"
isInView={testimonials.isInView} delay={300}
isInView={testimonialsInView} delay={300}
/>
</div>
</div>
</section>
{/* ── 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">
<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'}`}>
<h2 className={`text-3xl md:text-4xl font-extrabold text-gray-900 mb-4 tracking-tight transition-all duration-700
${pricingInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
Start free, scale as you grow
</h2>
<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'}`}>
<p className={`text-gray-500 mb-10 transition-all duration-700 delay-100
${pricingInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
Build unlimited chatbots for free. Upgrade to publish and unlock premium features.
</p>
<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'}`}>
<div className={`flex flex-wrap justify-center gap-x-6 gap-y-3 mb-10 transition-all duration-700 delay-200
${pricingInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
{[
{ feature: 'Free forever plan', included: true },
{ feature: 'Unlimited chatbot creation', included: true },
@@ -603,15 +605,15 @@ async def chat(message: str):
))}
</div>
<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'}`}>
<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 ${pricingInView ? 'opacity-100' : 'opacity-0'}`}>
View full pricing <ArrowRight className="w-4 h-4" />
</Link>
</div>
</section>
{/* ── 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 */}
<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" />
@@ -620,16 +622,16 @@ async def chat(message: str):
<div className="absolute bottom-10 right-10 w-60 h-60 bg-white/5 rounded-full blur-2xl animate-float-delayed" />
<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
${cta.isInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
<h2 className={`text-3xl md:text-4xl font-extrabold text-white mb-5 tracking-tight transition-all duration-700
${ctaInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
Ready to build your first chatbot?
</h2>
<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'}`}>
<p className={`text-primary-100 mb-10 text-lg transition-all duration-700 delay-100
${ctaInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
Join hundreds of companies using Contexta to power their AI experiences.
</p>
<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'}`}>
<div className={`flex flex-col sm:flex-row items-center justify-center gap-4 transition-all duration-700 delay-200
${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
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">

135
src/pages/LeadsPage.tsx Normal file
View 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>
)
}

View File

@@ -2,7 +2,7 @@ import React, { useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
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 { CATEGORIES, INDUSTRIES } from '@/lib/utils'
import { Search, Bot, Star, MessageSquare, ArrowLeft } from 'lucide-react'
@@ -38,27 +38,28 @@ export const MarketplacePage: React.FC = () => {
return (
<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>
<p className="text-sm text-gray-500 mt-1">Discover and interact with AI chatbots built by businesses</p>
</div>
{/* 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">
<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
type="text"
value={search}
onChange={e => handleSearch(e.target.value)}
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>
<select
value={category}
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>
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
@@ -66,7 +67,7 @@ export const MarketplacePage: React.FC = () => {
<select
value={industry}
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>
{INDUSTRIES.map(i => <option key={i} value={i}>{i}</option>)}
@@ -75,7 +76,10 @@ export const MarketplacePage: React.FC = () => {
{/* Results */}
{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 ? (
<EmptyState
icon={<Bot className="w-8 h-8" />}
@@ -85,25 +89,28 @@ 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">
{data.chatbots.map(chatbot => (
{data.chatbots.map((chatbot, i) => (
<ChatbotMarketplaceCard
key={chatbot.id}
chatbot={chatbot}
index={i}
onClick={() => navigate(`/marketplace/${chatbot.id}`)}
/>
))}
</div>
{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}>
Previous
</Button>
<span className="flex items-center px-3 text-sm text-gray-600">
Page {page} of {Math.ceil(data.total / 20)}
</span>
<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)}
</span>
<Button variant="outline" size="sm" onClick={() => setPage(p => p + 1)} disabled={!data.has_more}>
Next
</Button>
@@ -120,53 +127,62 @@ export const MarketplacePage: React.FC = () => {
// MARKETPLACE CARD — shows logo when available
// ═══════════════════════════════════════════════════════════════════════════════
const ChatbotMarketplaceCard: React.FC<{ chatbot: ChatbotPublic; onClick: () => void }> = ({ chatbot, onClick }) => (
<Card
className="p-5 cursor-pointer hover:shadow-md hover:border-gray-300 transition-all"
const ChatbotMarketplaceCard: React.FC<{ chatbot: ChatbotPublic; onClick: () => void; index: number }> = ({ chatbot, onClick, index }) => (
<div
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}
>
<div className="flex items-center gap-3 mb-3">
{/* Logo or fallback icon */}
{chatbot.logo_url ? (
<img
src={chatbot.logo_url}
alt={chatbot.name}
className="w-10 h-10 rounded-xl object-cover flex-shrink-0"
/>
) : (
<div
className="w-10 h-10 rounded-xl flex items-center justify-center text-white flex-shrink-0"
style={{ background: chatbot.primary_color }}
>
<Bot className="w-5 h-5" />
{/* 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">
{chatbot.logo_url ? (
<img
src={chatbot.logo_url}
alt={chatbot.name}
className="w-11 h-11 rounded-xl object-cover flex-shrink-0 shadow-sm group-hover:scale-105 transition-transform duration-200"
/>
) : (
<div
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 }}
>
<Bot className="w-5 h-5" />
</div>
)}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 text-sm truncate group-hover:text-primary-700 transition-colors">{chatbot.name}</h3>
{chatbot.company_name && (
<p className="text-xs text-gray-400 truncate">by {chatbot.company_name}</p>
)}
</div>
</div>
{chatbot.description && (
<p className="text-xs text-gray-500 mb-4 line-clamp-2 leading-relaxed">{chatbot.description}</p>
)}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 text-sm truncate">{chatbot.name}</h3>
{chatbot.company_name && (
<p className="text-xs text-gray-500 truncate">by {chatbot.company_name}</p>
<div className="flex items-center gap-2 flex-wrap">
{chatbot.average_rating && chatbot.average_rating > 0 && (
<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 fill-yellow-400 text-yellow-400" />
{chatbot.average_rating.toFixed(1)}
</span>
)}
<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" />
{chatbot.total_conversations.toLocaleString()}
</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>
{chatbot.description && (
<p className="text-xs text-gray-500 mb-3 line-clamp-2">{chatbot.description}</p>
)}
<div className="flex items-center gap-3 text-xs text-gray-400">
{chatbot.average_rating && chatbot.average_rating > 0 && (
<span className="flex items-center gap-1">
<Star className="w-3 h-3 text-yellow-400 fill-yellow-400" />
{chatbot.average_rating.toFixed(1)}
</span>
)}
<span className="flex items-center gap-1">
<MessageSquare className="w-3 h-3" />
{chatbot.total_conversations} chats
</span>
{chatbot.category && <span className="truncate">🏷 {chatbot.category}</span>}
</div>
</Card>
</div>
)
@@ -211,30 +227,30 @@ export const ChatbotDetailPage: React.FC = () => {
}
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 */}
<button
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
</button>
{/* Chatbot info — logo or fallback */}
<div className="flex items-center gap-4 mb-6">
{/* Chatbot info */}
<div className="flex items-center gap-4 mb-5">
{chatbot.logo_url ? (
<img
src={chatbot.logo_url}
alt={chatbot.name}
className="w-14 h-14 rounded-2xl object-cover"
className="w-16 h-16 rounded-2xl object-cover shadow-md"
/>
) : (
<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 }}
>
<Bot className="w-7 h-7" />
<Bot className="w-8 h-8" />
</div>
)}
<div>
@@ -242,15 +258,27 @@ export const ChatbotDetailPage: React.FC = () => {
{chatbot.company_name && (
<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>
{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 */}
<div className="h-[calc(100vh-280px)] min-h-[400px] max-h-[700px]">
{/* Chat */}
<div className="h-[calc(100vh-300px)] min-h-[400px] max-h-[680px] rounded-2xl overflow-hidden border border-gray-200 shadow-sm">
<ChatInterface
chatbotId={chatbot.id}
chatbotName={chatbot.name}

View File

@@ -1,86 +1,92 @@
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 { billingAPI } from '@/services/api'
import { useAuthStore } from '@/store/authStore'
import { Button, Card } from '@/components/ui'
import { Check, Zap, Building2, Star } from 'lucide-react'
import { Button } from '@/components/ui'
import { Check } from 'lucide-react'
const PLANS = [
{
id: 'free',
name: 'Free',
price: 0,
description: 'Build and test chatbots, no credit card needed',
description: 'Build, test and launch your first chatbot no card needed',
icon: '🆓',
color: 'gray',
features: [
{ text: 'Unlimited chatbot creation', included: true },
{ text: 'Upload PDF, DOCX, CSV, XLSX', included: true },
{ text: 'Unlimited preview testing', included: true },
{ text: 'Shareable preview links', included: true },
{ text: '50 preview conversations/month', included: true },
{ text: '1 published chatbot', included: true },
{ text: '100 conversations/month', 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: 'Publish to marketplace', 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',
name: 'Starter',
price: 3,
description: 'Go live with your first chatbot',
price: 12,
description: 'For individuals and solo businesses going live',
icon: '🚀',
color: 'blue',
badge: 'Most Popular',
features: [
{ text: 'Everything in Free', included: true },
{ text: 'Publish 1 chatbot to marketplace', included: true },
{ text: '500 conversations/month', included: true },
{ text: '1,500 conversations/month', included: true },
{ text: '10 documents per chatbot', included: true },
{ text: '4 Fireworks AI models (Qwen3, DeepSeek, Kimi, Llama)', included: true },
{ text: 'Analytics dashboard', included: true },
{ text: 'Custom branding', included: true },
{ text: 'Email support', included: true },
{ text: 'Lead capture + inbox', included: true },
{ text: 'Analytics + knowledge gaps', included: true },
{ text: 'Telegram channel', included: true },
{ text: 'WhatsApp channel', included: false },
{ text: 'Premium models (GPT-4o, Claude, Gemini)', included: false },
{ text: 'Code export', included: false },
],
},
{
id: 'pro',
name: 'Pro',
price: 20,
description: 'For growing businesses with multiple chatbots',
id: 'business',
name: 'Business',
price: 29,
description: 'For growing businesses that need more reach and power',
icon: '⚡',
color: 'purple',
highlighted: true,
badge: 'Most Popular',
features: [
{ text: 'Everything in Starter', included: true },
{ text: 'Build & publish up to 5 chatbots', included: true },
{ text: '2,000 conversations/month', included: true },
{ text: 'Up to 3 published chatbots', included: true },
{ text: '5,000 conversations/month', included: true },
{ text: '50 documents per chatbot', included: true },
{ text: 'GPT-4o, GPT-4o Mini', included: true },
{ text: 'Claude Haiku 4.5', included: true },
{ text: 'Gemini 2.5 Flash, Lite & Pro', included: true },
{ text: 'Code export (FastAPI + React widget)', included: true },
{ text: 'Advanced analytics', included: true },
{ text: 'WhatsApp + Telegram channels', included: true },
{ text: 'GPT-4o, Claude Haiku, Gemini 2.5', included: true },
{ text: 'Remove "Powered by Contexta"', included: true },
{ text: 'Unlimited URL sources', 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',
name: 'Enterprise',
price: null,
description: 'For large organizations with custom needs',
description: 'For large organizations with custom needs and SLAs',
icon: '🏢',
color: 'orange',
features: [
{ text: 'Everything in Pro', included: true },
{ text: 'Unlimited chatbots', included: true },
{ text: 'Everything in Agency', included: true },
{ text: 'Unlimited conversations', included: true },
{ text: 'Custom model fine-tuning', included: true },
{ text: 'White-label platform', included: true },
{ text: 'SSO (SAML)', 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')
return
}
if (planId === 'free') {
if (planId === 'free' || planId === currentPlan) {
navigate('/dashboard')
return
}
@@ -123,8 +129,9 @@ export const PricingPage: React.FC = () => {
`${window.location.origin}/pricing`
)
window.location.href = checkout_url
} catch (err: any) {
alert(err.response?.data?.detail || 'Failed to create checkout session')
} catch (err) {
const e = err as { response?: { data?: { detail?: string } } }
alert(e.response?.data?.detail || 'Failed to create checkout session')
} finally {
setLoading(null)
}
@@ -145,11 +152,11 @@ export const PricingPage: React.FC = () => {
<div className="text-center mb-12">
<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">
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>
</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) => (
<div
key={plan.id}
@@ -234,15 +241,15 @@ export const PricingPage: React.FC = () => {
},
{
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?',
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?',
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?',
@@ -250,7 +257,7 @@ export const PricingPage: React.FC = () => {
},
{
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 }) => (
<div key={q} className="bg-white border border-gray-200 rounded-xl p-4">

View 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>
)
}

View 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>
)
}

View File

@@ -1,35 +1,20 @@
import React, { useState, useEffect, useCallback } from 'react'
import React, { useState, useCallback } from 'react'
import { useQuery } from '@tanstack/react-query'
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 { Button, Card, Input, Badge } from '@/components/ui'
import { Button, Card, Input } from '@/components/ui'
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 = () => {
// BUG-04 FIX: Removed unused 'updateUser' from destructuring
const { user } = useAuthStore()
const navigate = useNavigate()
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('')
// Keep tab in sync if URL changes externally
useEffect(() => {
setTab(getTabFromPath(location.pathname))
}, [location.pathname])
const tab: 'profile' | 'billing' = location.pathname === '/settings/billing' ? 'billing' : 'profile'
// Update URL when tab changes
const handleTabChange = useCallback((newTab: 'profile' | 'billing') => {
setTab(newTab)
const newPath = newTab === 'billing' ? '/settings/billing' : '/settings'
if (location.pathname !== newPath) {
navigate(newPath, { replace: true })
@@ -38,7 +23,7 @@ export const SettingsPage: React.FC = () => {
const showToast = (msg: string) => {
setToast(msg)
setTimeout(() => setToast(''), 3000)
setTimeout(() => setToast(''), 3500)
}
return (
@@ -80,30 +65,153 @@ export const SettingsPage: React.FC = () => {
}
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 (
<Card className="p-6 space-y-4">
<h2 className="font-semibold text-gray-900">Profile Information</h2>
<Input label="Email" value={user?.email || ''} disabled />
<Input label="Company Name" value={user?.company_name || ''} disabled hint="Contact support to change company name" />
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Plan</label>
<div className="flex items-center gap-2">
<span className={`px-3 py-1 text-sm font-medium rounded-full capitalize ${getPlanColor(user?.plan || 'free')}`}>
{user?.plan || 'free'}
</span>
<Link to="/pricing" className="text-sm text-primary-600 hover:underline">
Manage plan
</Link>
<div className="space-y-4">
<Card className="p-6 space-y-4">
<h2 className="font-semibold text-gray-900">Profile Information</h2>
<Input label="Email" value={user?.email || ''} disabled hint="Email cannot be changed" />
<Input
label="Company Name"
value={companyName}
onChange={e => setCompanyName(e.target.value)}
placeholder="Your company name"
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Plan</label>
<div className="flex items-center gap-2">
<span className={`px-3 py-1 text-sm font-medium rounded-full capitalize ${getPlanColor(user?.plan || 'free')}`}>
{user?.plan || 'free'}
</span>
<Link to="/pricing" className="text-sm text-primary-600 hover:underline">
Manage plan
</Link>
</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 { user } = useAuthStore()
const navigate = useNavigate()
const [loading, setLoading] = useState(false)
@@ -117,14 +225,25 @@ const BillingSettings: React.FC<{ onToast: (msg: string) => void }> = ({ onToast
try {
const { url } = await billingAPI.createPortal(window.location.href)
window.location.href = url
} catch (err: any) {
onToast(err.response?.data?.detail || 'Failed to open billing portal')
} catch (err) {
const e = err as { response?: { data?: { detail?: string } } }
onToast(e.response?.data?.detail || 'Failed to open billing portal')
} finally {
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 (
<div className="space-y-4">
@@ -132,13 +251,14 @@ const BillingSettings: React.FC<{ onToast: (msg: string) => void }> = ({ onToast
<h2 className="font-semibold text-gray-900 mb-4">Current Plan</h2>
<div className="flex items-center justify-between mb-4">
<div>
<span className={`px-3 py-1 text-sm font-semibold rounded-full capitalize ${getPlanColor(subscription?.plan || 'free')}`}>
{subscription?.plan || 'free'}
</span>
<p className="text-xs text-gray-500 mt-1">
Status: <span className={subscription?.status === 'active' ? 'text-green-600' : 'text-red-600'}>
{subscription?.status || 'active'}
<span className={`px-3 py-1 text-sm font-semibold rounded-full capitalize ${getPlanColor(plan)}`}>
{plan}
</span>
<p className="text-xs text-gray-500 mt-1">
Status:{' '}
<span className={subscription?.status === 'active' ? 'text-green-600' : 'text-red-600'}>
{subscription?.status || 'active'}
</span>
</p>
</div>
{isPaid && subscription?.current_period_end && (
@@ -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>
<div className="space-y-3">
{[
{ label: 'Chatbots created', value: 'Unlimited' },
{ label: 'Chatbots published', value: subscription?.plan === 'pro' ? '3' : subscription?.plan === 'starter' ? '1' : '0 (upgrade to publish)' },
{ label: 'Conversations/month', value: subscription?.plan === 'pro' ? '20,000' : subscription?.plan === 'starter' ? '5,000' : 'Unlimited (preview)' },
{ label: 'Code export', value: subscription?.plan === 'pro' || subscription?.plan === 'enterprise' ? '✅ Included' : '❌ Pro+ only' },
{ label: 'Chatbots published', value: features.published },
{ label: 'Conversations/month', value: features.conversations },
{ label: 'Code export', value: features.codeExport },
].map(({ label, value }) => (
<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>
@@ -182,4 +301,4 @@ const BillingSettings: React.FC<{ onToast: (msg: string) => void }> = ({ onToast
</Card>
</div>
)
}
}

View File

@@ -3,7 +3,7 @@ import { useAuthStore } from '@/store/authStore'
import type {
AuthResponse, Chatbot, ChatbotPublic, ChatResponse,
Document, MarketplaceResponse, Subscription,
ModelsResponse
ModelsResponse, Lead, UrlSource, InboxConversation, ChannelConnection
} from '@/types'
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),
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 ─────────────────────────────────────────────────────────────────
@@ -65,6 +76,12 @@ export const chatbotsAPI = {
export: (id: string) =>
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 ────────────────────────────────────────────────────────────────
@@ -94,6 +111,9 @@ export const chatAPI = {
history: (chatbotId: string, sessionId: string) =>
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 ──────────────────────────────────────────────────────────────
@@ -120,6 +140,9 @@ export const billingAPI = {
cancel_url: cancelUrl,
}).then(r => r.data),
createPortal: (returnUrl: string) =>
api.post<{ url: string }>('/billing/portal', { return_url: returnUrl }).then(r => r.data),
getPortalUrl: () =>
api.post<{ url: string }>('/billing/portal').then(r => r.data),
@@ -140,4 +163,72 @@ export const analyticsAPI = {
chatbot: (chatbotId: string) =>
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),
}

View File

@@ -2,9 +2,9 @@
export interface User {
id: string
email: string
full_name: string
full_name?: string
company_name?: string
plan: 'free' | 'starter' | 'pro' | 'enterprise'
plan: 'free' | 'starter' | 'business' | 'agency' | 'enterprise'
created_at?: string
}
@@ -38,6 +38,14 @@ export interface Chatbot {
average_rating?: number
created_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 {
@@ -71,6 +79,14 @@ export interface ChatbotFormData {
category: string
industry: 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 ─────────────────────────────────────────────────────────────────
@@ -108,6 +124,8 @@ export interface ChatResponse {
sources: SourceDocument[]
model_used: string
tokens_used: number
needs_lead_capture?: boolean
handoff?: boolean
}
// ─── Subscription ─────────────────────────────────────────────────────────────
@@ -175,4 +193,74 @@ export interface ModelsResponse {
default_model: string | null
has_premium_access: boolean
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
}

View File

@@ -22,7 +22,13 @@
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
"noUncheckedSideEffectImports": true,
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}