mirror of
http://88.130.71.182:3000/BlitTech/contexta_fe.git
synced 2026-06-13 10:36:49 +00:00
Initial commit
This commit is contained in:
207
src/pages/AuthPages.tsx
Normal file
207
src/pages/AuthPages.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { authAPI } from '@/services/api'
|
||||
import { Button, Input } from '@/components/ui'
|
||||
import { Sparkles, Eye, EyeOff } from 'lucide-react'
|
||||
|
||||
export const LoginPage: React.FC = () => {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPass, setShowPass] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { setAuth } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
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.')
|
||||
} 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">Welcome back</h1>
|
||||
<p className="text-gray-500 mt-1 text-sm">Sign in to your Contexta account</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-8">
|
||||
<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
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
label="Password"
|
||||
type={showPass ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
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>
|
||||
|
||||
{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">
|
||||
Sign in
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 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
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SignupPage: React.FC = () => {
|
||||
const [form, setForm] = useState({ email: '', password: '', company_name: '' })
|
||||
const [showPass, setShowPass] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { setAuth } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
if (form.password.length < 8) {
|
||||
setError('Password must be at least 8 characters')
|
||||
return
|
||||
}
|
||||
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.')
|
||||
} 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">Create your account</h1>
|
||||
<p className="text-gray-500 mt-1 text-sm">Start building AI chatbots for free</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Company Name"
|
||||
type="text"
|
||||
value={form.company_name}
|
||||
onChange={e => setForm(f => ({ ...f, company_name: e.target.value }))}
|
||||
placeholder="Acme Corp"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
|
||||
placeholder="you@company.com"
|
||||
required
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
label="Password"
|
||||
type={showPass ? 'text' : 'password'}
|
||||
value={form.password}
|
||||
onChange={e => setForm(f => ({ ...f, password: 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>
|
||||
|
||||
{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">
|
||||
Create free account
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-center text-gray-400">
|
||||
By signing up, you agree to our Terms of Service and Privacy Policy
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-sm text-gray-500">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="text-primary-600 font-medium hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="mt-8 grid grid-cols-3 gap-4">
|
||||
{[
|
||||
{ emoji: '🤖', text: 'Build unlimited chatbots free' },
|
||||
{ emoji: '📄', text: 'Upload PDF, DOCX, CSV files' },
|
||||
{ emoji: '🏪', text: 'Publish to marketplace' },
|
||||
].map(({ emoji, text }) => (
|
||||
<div key={text} className="text-center">
|
||||
<div className="text-2xl mb-1">{emoji}</div>
|
||||
<p className="text-xs text-gray-500">{text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
446
src/pages/ChatbotBuilderPage.tsx
Normal file
446
src/pages/ChatbotBuilderPage.tsx
Normal file
@@ -0,0 +1,446 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { chatbotsAPI, documentsAPI } from '@/services/api'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { Button, Input, Textarea, Select, Card, Badge, StatusDot, Spinner } from '@/components/ui'
|
||||
import { AVAILABLE_MODELS, CATEGORIES, INDUSTRIES, LANGUAGES, formatBytes, getFileIcon } from '@/lib/utils'
|
||||
import { ChatInterface } from '@/components/ChatInterface'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import type { ChatbotFormData } from '@/types'
|
||||
import {
|
||||
ArrowLeft, Save, Eye, Upload, Trash2, FileText,
|
||||
Bot, Sliders, Globe, Code, AlertCircle, CheckCircle
|
||||
} from 'lucide-react'
|
||||
|
||||
const DEFAULT_FORM: ChatbotFormData = {
|
||||
name: '',
|
||||
description: '',
|
||||
system_prompt: '',
|
||||
model: 'accounts/fireworks/models/llama-v3p1-70b-instruct',
|
||||
temperature: 0.7,
|
||||
max_tokens: 1000,
|
||||
primary_color: '#6366f1',
|
||||
welcome_message: 'Hello! How can I help you today?',
|
||||
category: '',
|
||||
industry: '',
|
||||
languages: ['en'],
|
||||
}
|
||||
|
||||
type Tab = 'settings' | 'documents' | 'preview'
|
||||
|
||||
export const ChatbotBuilderPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const isNew = !id || id === 'new'
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const { user } = useAuthStore()
|
||||
const [tab, setTab] = useState<Tab>('settings')
|
||||
const [form, setForm] = useState<ChatbotFormData>(DEFAULT_FORM)
|
||||
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null)
|
||||
const [chatbotId, setChatbotId] = useState<string | null>(isNew ? null : id || null)
|
||||
|
||||
// Load existing chatbot
|
||||
const { data: existingChatbot, isLoading: loadingChatbot } = useQuery({
|
||||
queryKey: ['chatbot', id],
|
||||
queryFn: () => chatbotsAPI.get(id!),
|
||||
enabled: !isNew,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (existingChatbot) {
|
||||
setForm({
|
||||
name: existingChatbot.name,
|
||||
description: existingChatbot.description || '',
|
||||
system_prompt: existingChatbot.system_prompt || '',
|
||||
model: existingChatbot.model,
|
||||
temperature: existingChatbot.temperature,
|
||||
max_tokens: existingChatbot.max_tokens,
|
||||
primary_color: existingChatbot.primary_color,
|
||||
welcome_message: existingChatbot.welcome_message,
|
||||
category: existingChatbot.category || '',
|
||||
industry: existingChatbot.industry || '',
|
||||
languages: existingChatbot.languages,
|
||||
})
|
||||
setChatbotId(existingChatbot.id)
|
||||
}
|
||||
}, [existingChatbot])
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: chatbotsAPI.create,
|
||||
onSuccess: (data) => {
|
||||
setChatbotId(data.id)
|
||||
queryClient.invalidateQueries({ queryKey: ['chatbots'] })
|
||||
navigate(`/chatbots/${data.id}/edit`, { replace: true })
|
||||
showToast('Chatbot created!', 'success')
|
||||
},
|
||||
onError: (err: any) => showToast(err.response?.data?.detail || 'Failed to create', 'error'),
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: Partial<ChatbotFormData>) => chatbotsAPI.update(chatbotId!, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['chatbot', chatbotId] })
|
||||
queryClient.invalidateQueries({ queryKey: ['chatbots'] })
|
||||
showToast('Settings saved!', 'success')
|
||||
},
|
||||
onError: (err: any) => showToast(err.response?.data?.detail || 'Save failed', 'error'),
|
||||
})
|
||||
|
||||
const handleSave = () => {
|
||||
if (!form.name.trim()) { showToast('Chatbot name is required', 'error'); return }
|
||||
if (isNew || !chatbotId) {
|
||||
createMutation.mutate(form)
|
||||
} else {
|
||||
updateMutation.mutate(form)
|
||||
}
|
||||
}
|
||||
|
||||
const showToast = (msg: string, type: 'success' | 'error') => {
|
||||
setToast({ msg, type })
|
||||
setTimeout(() => setToast(null), 3000)
|
||||
}
|
||||
|
||||
const availableModels = AVAILABLE_MODELS.filter(m =>
|
||||
m.plans.includes(user?.plan || 'free') || m.plans.includes('starter')
|
||||
)
|
||||
|
||||
if (loadingChatbot) {
|
||||
return <div className="flex items-center justify-center h-full"><Spinner className="text-primary-600" /></div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Top bar */}
|
||||
<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">
|
||||
{isNew ? 'Create Chatbot' : form.name || 'Edit Chatbot'}
|
||||
</h1>
|
||||
{existingChatbot && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StatusDot status={existingChatbot.is_published ? 'published' : 'preview'} />
|
||||
<span className="text-xs text-gray-500">
|
||||
{existingChatbot.is_published ? 'Published' : 'Preview only'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
loading={createMutation.isPending || updateMutation.isPending}
|
||||
size="sm"
|
||||
>
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 px-6 py-2 bg-white border-b border-gray-100">
|
||||
{([
|
||||
{ id: 'settings', label: 'Settings', icon: Sliders },
|
||||
{ id: 'documents', label: 'Documents', icon: FileText, disabled: !chatbotId },
|
||||
{ id: 'preview', label: 'Preview', icon: Eye, disabled: !chatbotId },
|
||||
] as const).map(({ id: tid, label, icon: Icon, disabled }) => (
|
||||
<button
|
||||
key={tid}
|
||||
onClick={() => !disabled && setTab(tid as Tab)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
tab === tid
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: disabled
|
||||
? 'text-gray-300 cursor-not-allowed'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{tab === 'settings' && (
|
||||
<SettingsTab form={form} setForm={setForm} availableModels={availableModels} userPlan={user?.plan || 'free'} />
|
||||
)}
|
||||
{tab === 'documents' && chatbotId && (
|
||||
<DocumentsTab chatbotId={chatbotId} />
|
||||
)}
|
||||
{tab === 'preview' && chatbotId && existingChatbot && (
|
||||
<div className="p-6 max-w-2xl mx-auto">
|
||||
<div className="h-[600px]">
|
||||
<ChatInterface
|
||||
chatbotId={chatbotId}
|
||||
chatbotName={existingChatbot.name}
|
||||
welcomeMessage={existingChatbot.welcome_message}
|
||||
primaryColor={existingChatbot.primary_color}
|
||||
isPreview
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div className={`fixed bottom-4 right-4 px-4 py-2 rounded-lg text-sm shadow-lg z-50 text-white flex items-center gap-2 ${
|
||||
toast.type === 'success' ? 'bg-green-600' : 'bg-red-600'
|
||||
}`}>
|
||||
{toast.type === 'success' ? <CheckCircle className="w-4 h-4" /> : <AlertCircle className="w-4 h-4" />}
|
||||
{toast.msg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const SettingsTab: React.FC<{
|
||||
form: ChatbotFormData
|
||||
setForm: React.Dispatch<React.SetStateAction<ChatbotFormData>>
|
||||
availableModels: typeof AVAILABLE_MODELS
|
||||
userPlan: string
|
||||
}> = ({ form, setForm, availableModels, userPlan }) => {
|
||||
const set = (key: keyof ChatbotFormData) => (val: any) => setForm(f => ({ ...f, [key]: val }))
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-2xl mx-auto space-y-6">
|
||||
{/* Basic Info */}
|
||||
<Card className="p-6 space-y-4">
|
||||
<h2 className="font-semibold text-gray-900 text-sm uppercase tracking-wide text-gray-500">Basic Info</h2>
|
||||
<Input label="Chatbot Name *" value={form.name} onChange={e => set('name')(e.target.value)} placeholder="My Support Bot" />
|
||||
<Textarea label="Description" value={form.description} onChange={e => set('description')(e.target.value)} placeholder="What does this chatbot help with?" rows={2} />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Welcome Message</label>
|
||||
<Textarea value={form.welcome_message} onChange={e => set('welcome_message')(e.target.value)} rows={2} />
|
||||
</div>
|
||||
<Textarea label="System Prompt (optional)" value={form.system_prompt} onChange={e => set('system_prompt')(e.target.value)} placeholder="You are a helpful assistant for..." rows={3} hint="Custom instructions for the AI's behavior and personality" />
|
||||
</Card>
|
||||
|
||||
{/* Appearance */}
|
||||
<Card className="p-6 space-y-4">
|
||||
<h2 className="font-semibold text-gray-900 text-sm uppercase tracking-wide text-gray-500">Appearance</h2>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Brand Color</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="color" value={form.primary_color} onChange={e => set('primary_color')(e.target.value)}
|
||||
className="w-10 h-10 rounded-lg border border-gray-200 cursor-pointer" />
|
||||
<Input value={form.primary_color} onChange={e => set('primary_color')(e.target.value)} className="w-32" />
|
||||
<div className="flex gap-2">
|
||||
{['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'].map(c => (
|
||||
<button key={c} onClick={() => set('primary_color')(c)}
|
||||
className="w-6 h-6 rounded-full border-2 border-white shadow-sm"
|
||||
style={{ background: c }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* AI Model */}
|
||||
<Card className="p-6 space-y-4">
|
||||
<h2 className="font-semibold text-gray-900 text-sm uppercase tracking-wide text-gray-500">AI Model</h2>
|
||||
<div className="space-y-2">
|
||||
{availableModels.map(m => (
|
||||
<label key={m.id} className={`flex items-center gap-3 p-3 rounded-xl border cursor-pointer transition-colors ${
|
||||
form.model === m.id ? 'border-primary-400 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
|
||||
}`}>
|
||||
<input type="radio" name="model" value={m.id} checked={form.model === m.id} onChange={() => set('model')(m.id)} className="sr-only" />
|
||||
<div className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center text-xs font-bold text-gray-600">
|
||||
{m.provider[0]}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-900">{m.name}</p>
|
||||
<p className="text-xs text-gray-500">{m.provider}</p>
|
||||
</div>
|
||||
<Badge variant={m.badge === 'Powerful' ? 'purple' : 'default'}>{m.badge}</Badge>
|
||||
</label>
|
||||
))}
|
||||
{userPlan === 'free' || userPlan === 'starter' ? (
|
||||
<div className="p-3 bg-gray-50 rounded-xl border border-dashed border-gray-300 text-center">
|
||||
<p className="text-xs text-gray-500">🔒 <Link to="/pricing" className="text-primary-600 font-medium">Upgrade to Pro</Link> for GPT-4o, Claude, Gemini</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Temperature</label>
|
||||
<input type="range" min="0" max="1" step="0.1" value={form.temperature}
|
||||
onChange={e => set('temperature')(parseFloat(e.target.value))}
|
||||
className="w-full accent-primary-600" />
|
||||
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||
<span>Precise ({form.temperature})</span>
|
||||
<span>Creative</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
label="Max Tokens"
|
||||
type="number"
|
||||
value={form.max_tokens}
|
||||
onChange={e => set('max_tokens')(parseInt(e.target.value))}
|
||||
min={100} max={8000}
|
||||
hint="Max response length"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Classification */}
|
||||
<Card className="p-6 space-y-4">
|
||||
<h2 className="font-semibold text-gray-900 text-sm uppercase tracking-wide text-gray-500">Classification</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Select
|
||||
label="Category"
|
||||
value={form.category}
|
||||
onChange={e => set('category')(e.target.value)}
|
||||
options={[{ value: '', label: 'Select category' }, ...CATEGORIES.map(c => ({ value: c, label: c }))]}
|
||||
/>
|
||||
<Select
|
||||
label="Industry"
|
||||
value={form.industry}
|
||||
onChange={e => set('industry')(e.target.value)}
|
||||
options={[{ value: '', label: 'Select industry' }, ...INDUSTRIES.map(i => ({ value: i, label: i }))]}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const DocumentsTab: React.FC<{ chatbotId: string }> = ({ chatbotId }) => {
|
||||
const queryClient = useQueryClient()
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const [toast, setToast] = useState('')
|
||||
|
||||
const { data: documents = [], isLoading } = useQuery({
|
||||
queryKey: ['documents', chatbotId],
|
||||
queryFn: () => documentsAPI.list(chatbotId),
|
||||
refetchInterval: (data) => {
|
||||
if (data && Array.isArray(data)) {
|
||||
const hasProcessing = data.some(d => d.status === 'processing' || d.status === 'pending')
|
||||
return hasProcessing ? 3000 : false
|
||||
}
|
||||
return false
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (docId: string) => documentsAPI.delete(chatbotId, docId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['documents', chatbotId] })
|
||||
queryClient.invalidateQueries({ queryKey: ['chatbots'] })
|
||||
},
|
||||
})
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
accept: {
|
||||
'application/pdf': ['.pdf'],
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
|
||||
'text/csv': ['.csv'],
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
|
||||
'text/plain': ['.txt'],
|
||||
},
|
||||
multiple: true,
|
||||
onDrop: async (files) => {
|
||||
for (const file of files) {
|
||||
setUploading(true)
|
||||
setUploadProgress(0)
|
||||
try {
|
||||
await documentsAPI.upload(chatbotId, file, setUploadProgress)
|
||||
queryClient.invalidateQueries({ queryKey: ['documents', chatbotId] })
|
||||
setToast(`${file.name} uploaded successfully`)
|
||||
} catch (err: any) {
|
||||
setToast(err.response?.data?.detail || `Failed to upload ${file.name}`)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-2xl mx-auto space-y-4">
|
||||
{/* Dropzone */}
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors ${
|
||||
isDragActive ? 'border-primary-400 bg-primary-50' : 'border-gray-300 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{uploading ? (
|
||||
<div className="space-y-3">
|
||||
<Spinner className="mx-auto text-primary-600" />
|
||||
<p className="text-sm text-gray-600">Uploading & processing... {uploadProgress}%</p>
|
||||
<div className="w-full bg-gray-200 rounded-full h-1.5">
|
||||
<div className="bg-primary-600 h-1.5 rounded-full transition-all" style={{ width: `${uploadProgress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-8 h-8 text-gray-400 mx-auto mb-3" />
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
{isDragActive ? 'Drop files here' : 'Drag & drop files or click to browse'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Supports PDF, DOCX, CSV, XLSX, TXT • Max 50MB</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Documents list */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8"><Spinner /></div>
|
||||
) : documents.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<FileText className="w-8 h-8 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-sm font-medium text-gray-500">No documents yet</p>
|
||||
<p className="text-xs text-gray-400 mt-1">Upload files to give your chatbot knowledge</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{documents.map((doc) => (
|
||||
<Card key={doc.id} className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">{getFileIcon(doc.file_type)}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">{doc.file_name}</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${
|
||||
doc.status === 'completed' ? 'bg-green-500' :
|
||||
doc.status === 'failed' ? 'bg-red-500' : 'bg-yellow-500 animate-pulse'
|
||||
}`} />
|
||||
<p className="text-xs text-gray-400 capitalize">
|
||||
{doc.status === 'completed' ? `${doc.chunk_count} chunks indexed` :
|
||||
doc.status === 'failed' ? doc.error_message || 'Processing failed' :
|
||||
'Processing...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(doc.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toast && (
|
||||
<div className="fixed bottom-4 right-4 bg-gray-900 text-white px-4 py-2 rounded-lg text-sm shadow-lg z-50">
|
||||
{toast}
|
||||
<button onClick={() => setToast('')} className="ml-3 opacity-60 hover:opacity-100">×</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
299
src/pages/DashboardPage.tsx
Normal file
299
src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Link, 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 type { Chatbot } from '@/types'
|
||||
import {
|
||||
Bot, Plus, MoreHorizontal, Globe, Lock, Trash2,
|
||||
Settings, Upload, Eye, ExternalLink, Download, BarChart2
|
||||
} from 'lucide-react'
|
||||
|
||||
export const DashboardPage: React.FC = () => {
|
||||
const { user } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
const [toast, setToast] = useState<string>('')
|
||||
|
||||
const { data: chatbots = [], isLoading } = useQuery({
|
||||
queryKey: ['chatbots'],
|
||||
queryFn: chatbotsAPI.list,
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: chatbotsAPI.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['chatbots'] })
|
||||
setDeleteId(null)
|
||||
setToast('Chatbot deleted')
|
||||
},
|
||||
})
|
||||
|
||||
const publishMutation = useMutation({
|
||||
mutationFn: chatbotsAPI.publish,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['chatbots'] })
|
||||
setToast('Chatbot published to marketplace!')
|
||||
},
|
||||
onError: (err: any) => setToast(err.response?.data?.detail || 'Failed to publish'),
|
||||
})
|
||||
|
||||
const unpublishMutation = useMutation({
|
||||
mutationFn: chatbotsAPI.unpublish,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['chatbots'] })
|
||||
setToast('Chatbot unpublished')
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
Good {getGreeting()}, {user?.company_name || 'there'} 👋
|
||||
</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">Manage your AI chatbots</p>
|
||||
</div>
|
||||
<Button onClick={() => navigate('/chatbots/new')} size="lg">
|
||||
<Plus className="w-4 h-4" />
|
||||
New Chatbot
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
{[
|
||||
{ label: 'Total Chatbots', value: chatbots.length, icon: '🤖' },
|
||||
{ label: 'Published', value: chatbots.filter(c => c.is_published).length, icon: '🌐' },
|
||||
{ label: 'Documents', value: chatbots.reduce((sum, c) => sum + c.document_count, 0), icon: '📄' },
|
||||
{ label: 'Conversations', value: chatbots.reduce((sum, c) => sum + c.conversation_count, 0), icon: '💬' },
|
||||
].map(({ label, value, icon }) => (
|
||||
<Card key={label} className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{icon}</span>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900">{value}</p>
|
||||
<p className="text-xs text-gray-500">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Plan notice */}
|
||||
{user?.plan === 'free' && (
|
||||
<div className="mb-6 p-4 bg-gradient-to-r from-primary-50 to-purple-50 border border-primary-200 rounded-xl flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-primary-900">You're on the Free plan</p>
|
||||
<p className="text-xs text-primary-700 mt-0.5">
|
||||
Upgrade to publish chatbots to the marketplace and unlock premium AI models
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/pricing">
|
||||
<Button size="sm">Upgrade</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chatbots Grid */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Spinner className="text-primary-600" />
|
||||
</div>
|
||||
) : chatbots.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Bot className="w-8 h-8" />}
|
||||
title="No chatbots yet"
|
||||
description="Create your first AI chatbot powered by your documents. It's free to build and test."
|
||||
action={
|
||||
<Button onClick={() => navigate('/chatbots/new')} size="lg">
|
||||
<Plus className="w-4 h-4" />
|
||||
Create your first chatbot
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{chatbots.map((chatbot) => (
|
||||
<ChatbotCard
|
||||
key={chatbot.id}
|
||||
chatbot={chatbot}
|
||||
onEdit={() => navigate(`/chatbots/${chatbot.id}/edit`)}
|
||||
onPreview={() => navigate(`/chatbots/${chatbot.id}/preview`)}
|
||||
onPublish={() => publishMutation.mutate(chatbot.id)}
|
||||
onUnpublish={() => unpublishMutation.mutate(chatbot.id)}
|
||||
onDelete={() => setDeleteId(chatbot.id)}
|
||||
onAnalytics={() => navigate(`/chatbots/${chatbot.id}/analytics`)}
|
||||
/>
|
||||
))}
|
||||
{/* 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"
|
||||
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>
|
||||
<p className="text-sm font-medium text-gray-500">New Chatbot</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<Modal
|
||||
isOpen={!!deleteId}
|
||||
onClose={() => setDeleteId(null)}
|
||||
title="Delete Chatbot"
|
||||
>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Are you sure you want to delete this chatbot? All documents and conversation history will be permanently removed.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" onClick={() => setDeleteId(null)} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => deleteId && deleteMutation.mutate(deleteId)}
|
||||
loading={deleteMutation.isPending}
|
||||
className="flex-1"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div className="fixed bottom-4 right-4 bg-gray-900 text-white px-4 py-2 rounded-lg text-sm shadow-lg z-50">
|
||||
{toast}
|
||||
<button onClick={() => setToast('')} className="ml-3 opacity-60 hover:opacity-100">×</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const ChatbotCard: React.FC<{
|
||||
chatbot: Chatbot
|
||||
onEdit: () => void
|
||||
onPreview: () => void
|
||||
onPublish: () => void
|
||||
onUnpublish: () => void
|
||||
onDelete: () => void
|
||||
onAnalytics: () => void
|
||||
}> = ({ chatbot, 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">
|
||||
<div
|
||||
className="w-10 h-10 rounded-xl flex items-center justify-center text-white text-lg"
|
||||
style={{ background: chatbot.primary_color }}
|
||||
>
|
||||
<Bot className="w-5 h-5" />
|
||||
</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>
|
||||
<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">
|
||||
<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" />
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{chatbot.description && (
|
||||
<p className="text-xs text-gray-500 mb-3 line-clamp-2">{chatbot.description}</p>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex 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="ghost"
|
||||
size="sm"
|
||||
onClick={onUnpublish}
|
||||
className="flex-1 text-orange-600 hover:bg-orange-50"
|
||||
>
|
||||
<Lock className="w-3.5 h-3.5" />
|
||||
Unpublish
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={onPublish}
|
||||
className="flex-1"
|
||||
>
|
||||
<Globe className="w-3.5 h-3.5" />
|
||||
Publish
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function getGreeting() {
|
||||
const h = new Date().getHours()
|
||||
if (h < 12) return 'morning'
|
||||
if (h < 17) return 'afternoon'
|
||||
return 'evening'
|
||||
}
|
||||
148
src/pages/LandingPage.tsx
Normal file
148
src/pages/LandingPage.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Sparkles, Bot, Globe, Code, Database, Shield, Zap, ArrowRight, Check } from 'lucide-react'
|
||||
|
||||
export const LandingPage: React.FC = () => (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Nav */}
|
||||
<nav className="border-b border-gray-100 sticky top-0 bg-white/80 backdrop-blur-sm z-50">
|
||||
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
|
||||
<Sparkles className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span className="font-bold text-gray-900 text-lg">Contexta</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/marketplace" className="text-sm text-gray-600 hover:text-gray-900">Marketplace</Link>
|
||||
<Link to="/pricing" className="text-sm text-gray-600 hover:text-gray-900">Pricing</Link>
|
||||
<Link to="/login" className="text-sm text-gray-600 hover:text-gray-900">Sign in</Link>
|
||||
<Link to="/signup" className="bg-primary-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-primary-700 font-medium transition-colors">
|
||||
Get started free
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero */}
|
||||
<section className="max-w-6xl mx-auto px-6 pt-20 pb-16 text-center">
|
||||
<div className="inline-flex items-center gap-2 bg-primary-50 text-primary-700 text-sm px-3 py-1 rounded-full mb-6 font-medium">
|
||||
<Zap className="w-3.5 h-3.5" />
|
||||
Build AI chatbots powered by your data
|
||||
</div>
|
||||
<h1 className="text-5xl font-bold text-gray-900 mb-6 leading-tight">
|
||||
Turn your documents into<br />
|
||||
<span className="text-primary-600">intelligent chatbots</span>
|
||||
</h1>
|
||||
<p className="text-xl text-gray-500 max-w-2xl mx-auto mb-10">
|
||||
Upload PDFs, DOCX, and CSV files to create RAG-powered chatbots in minutes.
|
||||
Publish to our marketplace or export production-ready code.
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<Link to="/signup" className="bg-primary-600 text-white px-6 py-3 rounded-xl font-semibold hover:bg-primary-700 transition-colors flex items-center gap-2">
|
||||
Start for free <ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
<Link to="/marketplace" className="border border-gray-300 text-gray-700 px-6 py-3 rounded-xl font-semibold hover:bg-gray-50 transition-colors">
|
||||
Explore marketplace
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 mt-4">No credit card required • Free forever</p>
|
||||
</section>
|
||||
|
||||
{/* Features */}
|
||||
<section className="bg-gray-50 py-20">
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
<h2 className="text-3xl font-bold text-gray-900 text-center mb-12">Everything you need</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[
|
||||
{
|
||||
icon: <Bot className="w-6 h-6" />,
|
||||
title: 'RAG-Powered Chatbots',
|
||||
desc: 'Upload your documents and let AI answer questions based on your actual content with citations.',
|
||||
color: 'bg-blue-100 text-blue-600',
|
||||
},
|
||||
{
|
||||
icon: <Globe className="w-6 h-6" />,
|
||||
title: 'Public Marketplace',
|
||||
desc: 'Publish your chatbots to our marketplace so customers can discover and chat with them.',
|
||||
color: 'bg-green-100 text-green-600',
|
||||
},
|
||||
{
|
||||
icon: <Code className="w-6 h-6" />,
|
||||
title: 'Code Export',
|
||||
desc: 'Export a complete FastAPI backend + React widget. Deploy anywhere. No vendor lock-in.',
|
||||
color: 'bg-purple-100 text-purple-600',
|
||||
},
|
||||
{
|
||||
icon: <Database className="w-6 h-6" />,
|
||||
title: 'Multi-Format Support',
|
||||
desc: 'PDF, Word, Excel, CSV, and plain text files — all processed and indexed automatically.',
|
||||
color: 'bg-orange-100 text-orange-600',
|
||||
},
|
||||
{
|
||||
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-600',
|
||||
},
|
||||
{
|
||||
icon: <Shield className="w-6 h-6" />,
|
||||
title: 'Data Isolation',
|
||||
desc: 'Each company gets its own isolated vector database. Your data is never mixed with others.',
|
||||
color: 'bg-red-100 text-red-600',
|
||||
},
|
||||
].map(({ icon, title, desc, color }) => (
|
||||
<div key={title} className="bg-white rounded-2xl p-6 border border-gray-200">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center mb-4 ${color}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">{title}</h3>
|
||||
<p className="text-sm text-gray-500 leading-relaxed">{desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing teaser */}
|
||||
<section className="max-w-4xl mx-auto px-6 py-20 text-center">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">Start free, scale as you grow</h2>
|
||||
<p className="text-gray-500 mb-8">Build unlimited chatbots for free. Upgrade to publish and unlock premium features.</p>
|
||||
<div className="flex justify-center gap-4 mb-8">
|
||||
{[
|
||||
{ feature: 'Free forever plan', included: true },
|
||||
{ feature: 'Unlimited chatbot creation', included: true },
|
||||
{ feature: 'Publish to marketplace', included: false, note: 'Starter+' },
|
||||
{ feature: 'Code export', included: false, note: 'Pro+' },
|
||||
].map(({ feature, included, note }) => (
|
||||
<div key={feature} className="flex items-center gap-2 text-sm">
|
||||
<div className={`w-5 h-5 rounded-full flex items-center justify-center ${included ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-400'}`}>
|
||||
{included ? <Check className="w-3 h-3" /> : <span className="text-xs">–</span>}
|
||||
</div>
|
||||
<span className="text-gray-700">{feature}</span>
|
||||
{note && <span className="text-xs text-primary-600 bg-primary-50 px-2 py-0.5 rounded-full">{note}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Link to="/pricing" className="text-primary-600 font-medium hover:underline text-sm">
|
||||
View full pricing →
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="bg-primary-600 py-16 text-center">
|
||||
<div className="max-w-2xl mx-auto px-6">
|
||||
<h2 className="text-3xl font-bold text-white mb-4">Ready to build your first chatbot?</h2>
|
||||
<p className="text-primary-100 mb-8">Join thousands of companies using Contexta to power their AI experiences.</p>
|
||||
<Link to="/signup" className="bg-white text-primary-600 px-8 py-3 rounded-xl font-semibold hover:bg-primary-50 transition-colors inline-flex items-center gap-2">
|
||||
Get started for free <ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-gray-100 py-8 text-center text-sm text-gray-400">
|
||||
<p>© 2025 Contexta. Built with ❤️ for builders.</p>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
237
src/pages/MarketplacePage.tsx
Normal file
237
src/pages/MarketplacePage.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { marketplaceAPI } from '@/services/api'
|
||||
import { Card, Spinner, EmptyState, Button } from '@/components/ui'
|
||||
import { CATEGORIES, INDUSTRIES } from '@/lib/utils'
|
||||
import { Search, Bot, Star, MessageSquare } from 'lucide-react'
|
||||
import type { ChatbotPublic } from '@/types'
|
||||
|
||||
export const MarketplacePage: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const [search, setSearch] = useState('')
|
||||
const [category, setCategory] = useState('')
|
||||
const [industry, setIndustry] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['marketplace', search, category, industry, page],
|
||||
queryFn: () => marketplaceAPI.list({ search, category, industry, page, limit: 20 }),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-6xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">AI Chatbot Marketplace</h1>
|
||||
<p className="text-gray-500 mt-1 text-sm">Discover and interact with AI chatbots from companies worldwide</p>
|
||||
</div>
|
||||
|
||||
{/* Search & Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); setPage(1) }}
|
||||
placeholder="Search chatbots..."
|
||||
className="w-full pl-9 pr-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={category}
|
||||
onChange={e => { setCategory(e.target.value); setPage(1) }}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
<select
|
||||
value={industry}
|
||||
onChange={e => { setIndustry(e.target.value); setPage(1) }}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white"
|
||||
>
|
||||
<option value="">All Industries</option>
|
||||
{INDUSTRIES.map(i => <option key={i} value={i}>{i}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-20"><Spinner className="text-primary-600" /></div>
|
||||
) : !data?.chatbots?.length ? (
|
||||
<EmptyState
|
||||
icon={<Bot className="w-8 h-8" />}
|
||||
title="No chatbots found"
|
||||
description="Be the first to publish your AI chatbot to the marketplace!"
|
||||
action={<Button onClick={() => navigate('/chatbots/new')}>Create Chatbot</Button>}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4 text-sm text-gray-500">{data.total} chatbot{data.total !== 1 ? 's' : ''} available</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
|
||||
{data.chatbots.map(chatbot => (
|
||||
<ChatbotMarketplaceCard
|
||||
key={chatbot.id}
|
||||
chatbot={chatbot}
|
||||
onClick={() => navigate(`/marketplace/${chatbot.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{data.total > 20 && (
|
||||
<div className="flex justify-center gap-2">
|
||||
<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>
|
||||
<Button variant="outline" size="sm" onClick={() => setPage(p => p + 1)} disabled={!data.has_more}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ChatbotMarketplaceCard: React.FC<{ chatbot: ChatbotPublic; onClick: () => void }> = ({ chatbot, onClick }) => (
|
||||
<Card className="p-5 cursor-pointer hover:shadow-md transition-shadow" onClick={onClick}>
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<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" />
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{chatbot.description && (
|
||||
<p className="text-xs text-gray-600 mb-3 line-clamp-2">{chatbot.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{chatbot.category && (
|
||||
<span className="text-xs bg-primary-50 text-primary-700 px-2 py-0.5 rounded-full">
|
||||
{chatbot.category}
|
||||
</span>
|
||||
)}
|
||||
{chatbot.industry && (
|
||||
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">
|
||||
{chatbot.industry}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||
<div className="flex items-center gap-3">
|
||||
{chatbot.average_rating && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Star className="w-3 h-3 fill-yellow-400 text-yellow-400" />
|
||||
{chatbot.average_rating.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-0.5">
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
{chatbot.total_conversations}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-primary-600 font-medium">Chat →</span>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
|
||||
// ─── Chatbot Detail / Chat Page ────────────────────────────────────────────────
|
||||
export const ChatbotDetailPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { data: chatbot, isLoading } = useQuery({
|
||||
queryKey: ['marketplace-chatbot', id],
|
||||
queryFn: () => marketplaceAPI.get(id!),
|
||||
enabled: !!id,
|
||||
})
|
||||
|
||||
if (isLoading) return <div className="flex justify-center py-20"><Spinner className="text-primary-600" /></div>
|
||||
if (!chatbot) return <div className="text-center py-20 text-gray-500">Chatbot not found</div>
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<button onClick={() => navigate('/marketplace')} className="p-1.5 hover:bg-gray-100 rounded-lg">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900">{chatbot.name}</h1>
|
||||
{chatbot.company_name && <p className="text-sm text-gray-500">by {chatbot.company_name}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Chat */}
|
||||
<div className="lg:col-span-2 h-[600px]">
|
||||
<ChatInterface
|
||||
chatbotId={chatbot.id}
|
||||
chatbotName={chatbot.name}
|
||||
welcomeMessage={chatbot.welcome_message}
|
||||
primaryColor={chatbot.primary_color}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info sidebar */}
|
||||
<div className="space-y-4">
|
||||
<Card className="p-4">
|
||||
<h3 className="font-semibold text-gray-900 text-sm mb-3">About</h3>
|
||||
{chatbot.description && <p className="text-sm text-gray-600 mb-3">{chatbot.description}</p>}
|
||||
<div className="space-y-2 text-sm">
|
||||
{chatbot.category && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Category</span>
|
||||
<span className="font-medium">{chatbot.category}</span>
|
||||
</div>
|
||||
)}
|
||||
{chatbot.industry && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Industry</span>
|
||||
<span className="font-medium">{chatbot.industry}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Languages</span>
|
||||
<span className="font-medium uppercase">{chatbot.languages?.join(', ')}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Conversations</span>
|
||||
<span className="font-medium">{chatbot.total_conversations}</span>
|
||||
</div>
|
||||
{chatbot.average_rating && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Rating</span>
|
||||
<span className="font-medium flex items-center gap-1">
|
||||
<Star className="w-3.5 h-3.5 fill-yellow-400 text-yellow-400" />
|
||||
{chatbot.average_rating.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Import needed for ChatbotDetailPage
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { ChatInterface } from '@/components/ChatInterface'
|
||||
226
src/pages/PricingPage.tsx
Normal file
226
src/pages/PricingPage.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useMutation } 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'
|
||||
|
||||
const PLANS = [
|
||||
{
|
||||
id: 'free',
|
||||
name: 'Free',
|
||||
price: 0,
|
||||
description: 'Perfect for testing and development',
|
||||
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: 'Publish to marketplace', included: false },
|
||||
{ text: 'Premium AI models', included: false },
|
||||
{ text: 'Code export', included: false },
|
||||
{ text: 'Analytics dashboard', included: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'starter',
|
||||
name: 'Starter',
|
||||
price: 39,
|
||||
description: 'For small businesses launching their first chatbot',
|
||||
icon: '🚀',
|
||||
color: 'blue',
|
||||
badge: 'Popular',
|
||||
features: [
|
||||
{ text: 'Everything in Free', included: true },
|
||||
{ text: 'Publish 1 chatbot to marketplace', included: true },
|
||||
{ text: 'Fireworks AI models (Llama, Mixtral)', included: true },
|
||||
{ text: '5,000 conversations/month', included: true },
|
||||
{ text: 'Analytics dashboard', included: true },
|
||||
{ text: 'Custom branding', included: true },
|
||||
{ text: 'Email support', included: true },
|
||||
{ text: 'Premium AI models (GPT-4, Claude)', included: false },
|
||||
{ text: 'Code export', included: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pro',
|
||||
name: 'Pro',
|
||||
price: 119,
|
||||
description: 'For growing businesses with multiple products',
|
||||
icon: '⚡',
|
||||
color: 'purple',
|
||||
highlighted: true,
|
||||
features: [
|
||||
{ text: 'Everything in Starter', included: true },
|
||||
{ text: 'Build & publish 3 chatbots', included: true },
|
||||
{ text: 'GPT-4o, Claude 3.5, Gemini 1.5', included: true },
|
||||
{ text: '20,000 conversations/month', included: true },
|
||||
{ text: 'Code export (FastAPI + React widget)', included: true },
|
||||
{ text: 'Advanced analytics', included: true },
|
||||
{ text: 'Remove "Powered by" badge', included: true },
|
||||
{ text: 'Priority support', included: true },
|
||||
{ text: 'Custom domain', included: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'enterprise',
|
||||
name: 'Enterprise',
|
||||
price: null,
|
||||
description: 'For large organizations with custom needs',
|
||||
icon: '🏢',
|
||||
color: 'orange',
|
||||
features: [
|
||||
{ text: 'Everything in Pro', included: true },
|
||||
{ text: 'Unlimited chatbots', 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 },
|
||||
{ text: 'Dedicated account manager', included: true },
|
||||
{ text: '24/7 phone support', included: true },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const PricingPage: React.FC = () => {
|
||||
const { user } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
const [loading, setLoading] = useState<string | null>(null)
|
||||
|
||||
const handleSubscribe = async (planId: string) => {
|
||||
if (!user) { navigate('/login'); return }
|
||||
if (planId === 'enterprise') {
|
||||
window.open('mailto:enterprise@contexta.ai?subject=Enterprise Inquiry', '_blank')
|
||||
return
|
||||
}
|
||||
if (planId === 'free') {
|
||||
navigate('/dashboard')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(planId)
|
||||
try {
|
||||
const { checkout_url } = await billingAPI.createCheckout(
|
||||
planId,
|
||||
`${window.location.origin}/settings/billing?success=true`,
|
||||
`${window.location.origin}/pricing`
|
||||
)
|
||||
window.location.href = checkout_url
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || 'Failed to create checkout session')
|
||||
} finally {
|
||||
setLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-6xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-3">Simple, transparent pricing</h1>
|
||||
<p className="text-gray-500 max-w-xl mx-auto">
|
||||
Start free and build as many chatbots as you want. Upgrade when you're ready to publish and go live.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6">
|
||||
{PLANS.map((plan) => (
|
||||
<div
|
||||
key={plan.id}
|
||||
className={`relative rounded-2xl border p-6 flex flex-col ${
|
||||
plan.highlighted
|
||||
? 'border-primary-400 bg-gradient-to-b from-primary-50 to-white shadow-lg shadow-primary-100'
|
||||
: 'border-gray-200 bg-white'
|
||||
}`}
|
||||
>
|
||||
{plan.badge && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||
<span className="bg-primary-600 text-white text-xs font-semibold px-3 py-1 rounded-full">
|
||||
{plan.badge}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="text-3xl mb-2">{plan.icon}</div>
|
||||
<h2 className="text-xl font-bold text-gray-900">{plan.name}</h2>
|
||||
<p className="text-sm text-gray-500 mt-1 min-h-[40px]">{plan.description}</p>
|
||||
<div className="mt-4">
|
||||
{plan.price !== null ? (
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-3xl font-bold text-gray-900">${plan.price}</span>
|
||||
<span className="text-gray-500 text-sm">/month</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-2xl font-bold text-gray-900">Custom</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2.5 flex-1 mb-6">
|
||||
{plan.features.map(({ text, included }) => (
|
||||
<li key={text} className="flex items-start gap-2.5">
|
||||
<div className={`w-4 h-4 rounded-full flex-shrink-0 mt-0.5 flex items-center justify-center ${
|
||||
included ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-400'
|
||||
}`}>
|
||||
{included ? <Check className="w-2.5 h-2.5" /> : <span className="text-xs">–</span>}
|
||||
</div>
|
||||
<span className={`text-sm ${included ? 'text-gray-700' : 'text-gray-400'}`}>{text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
variant={plan.highlighted ? 'primary' : 'outline'}
|
||||
className="w-full"
|
||||
loading={loading === plan.id}
|
||||
onClick={() => handleSubscribe(plan.id)}
|
||||
disabled={user?.plan === plan.id}
|
||||
>
|
||||
{user?.plan === plan.id
|
||||
? 'Current Plan'
|
||||
: plan.price === null
|
||||
? 'Contact Sales'
|
||||
: plan.price === 0
|
||||
? 'Get Started Free'
|
||||
: `Subscribe – $${plan.price}/mo`}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* FAQ */}
|
||||
<div className="mt-16 max-w-2xl mx-auto">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6 text-center">Frequently Asked Questions</h2>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{
|
||||
q: 'What is preview mode?',
|
||||
a: 'Preview mode lets you build and test your chatbot for free with unlimited conversations. Only you (and people you share the link with) can access it until you publish.'
|
||||
},
|
||||
{
|
||||
q: 'Can I cancel anytime?',
|
||||
a: 'Yes, you can cancel anytime. Your chatbots will remain in preview mode but will be removed from the marketplace.'
|
||||
},
|
||||
{
|
||||
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.'
|
||||
},
|
||||
{
|
||||
q: 'Do I need my own API keys?',
|
||||
a: 'No! Your API keys are handled by Contexta. However, if you export the code, you\'ll need your own API keys for self-hosted deployment.'
|
||||
},
|
||||
].map(({ q, a }) => (
|
||||
<div key={q} className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<h3 className="font-semibold text-gray-900 text-sm mb-1">{q}</h3>
|
||||
<p className="text-sm text-gray-500">{a}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
159
src/pages/SettingsPage.tsx
Normal file
159
src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useQuery, useMutation } from '@tanstack/react-query'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { billingAPI } from '@/services/api'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { Button, Card, Input, Badge } from '@/components/ui'
|
||||
import { getPlanColor, formatDate } from '@/lib/utils'
|
||||
import { CreditCard, User, Shield, Download, ExternalLink } from 'lucide-react'
|
||||
|
||||
export const SettingsPage: React.FC = () => {
|
||||
const { user, updateUser } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
const [tab, setTab] = useState<'profile' | 'billing' | 'export'>('profile')
|
||||
const [toast, setToast] = useState('')
|
||||
|
||||
const showToast = (msg: string) => {
|
||||
setToast(msg)
|
||||
setTimeout(() => setToast(''), 3000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-3xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Settings</h1>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 mb-6 bg-gray-100 p-1 rounded-xl w-fit">
|
||||
{[
|
||||
{ id: 'profile', label: 'Profile', icon: User },
|
||||
{ id: 'billing', label: 'Billing', icon: CreditCard },
|
||||
].map(({ id, label, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setTab(id as any)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
tab === id ? 'bg-white shadow-sm text-gray-900' : 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'profile' && <ProfileSettings onToast={showToast} />}
|
||||
{tab === 'billing' && <BillingSettings onToast={showToast} />}
|
||||
|
||||
{toast && (
|
||||
<div className="fixed bottom-4 right-4 bg-gray-900 text-white px-4 py-2 rounded-lg text-sm shadow-lg z-50">
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ProfileSettings: React.FC<{ onToast: (msg: string) => void }> = ({ onToast }) => {
|
||||
const { user } = useAuthStore()
|
||||
|
||||
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>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const BillingSettings: React.FC<{ onToast: (msg: string) => void }> = ({ onToast }) => {
|
||||
const { user } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const { data: subscription } = useQuery({
|
||||
queryKey: ['subscription'],
|
||||
queryFn: billingAPI.getSubscription,
|
||||
})
|
||||
|
||||
const handlePortal = async () => {
|
||||
setLoading(true)
|
||||
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')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isPaid = subscription?.plan && subscription.plan !== 'free'
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card className="p-6">
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
{isPaid && subscription?.current_period_end && (
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-gray-500">Renews on</p>
|
||||
<p className="text-sm font-medium">{formatDate(subscription.current_period_end)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{!isPaid ? (
|
||||
<Button onClick={() => navigate('/pricing')} className="flex-1">
|
||||
Upgrade Plan
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" onClick={handlePortal} loading={loading} className="flex-1">
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
Manage Billing
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Plan features */}
|
||||
<Card className="p-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Plan Features</h3>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ label: 'Chatbots created', value: 'Unlimited', always: true },
|
||||
{ label: 'Chatbots published', value: subscription?.plan === 'pro' ? '3' : subscription?.plan === 'starter' ? '1' : '0 (upgrade to publish)', always: true },
|
||||
{ label: 'Conversations/month', value: subscription?.plan === 'pro' ? '20,000' : subscription?.plan === 'starter' ? '5,000' : 'Unlimited (preview)', always: true },
|
||||
{ label: 'Code export', value: subscription?.plan === 'pro' || subscription?.plan === 'enterprise' ? '✅ Included' : '❌ Pro+ only', always: true },
|
||||
].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>
|
||||
<span className="text-sm font-medium text-gray-900">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user