fixed bugs

This commit is contained in:
belviskhoremk
2026-02-23 16:46:51 +00:00
parent 979fb43228
commit cec36ee298
7 changed files with 866 additions and 544 deletions

View File

@@ -1,16 +1,17 @@
import React, { useState, useEffect } from 'react'
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 } from '@/services/api'
import { chatbotsAPI, documentsAPI, modelsAPI } 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 { CATEGORIES, INDUSTRIES, LANGUAGES, formatBytes, getFileIcon } from '@/lib/utils'
import { ChatInterface } from '@/components/ChatInterface'
import { useDropzone } from 'react-dropzone'
import type { ChatbotFormData } from '@/types'
import type { ChatbotFormData, AvailableModel } from '@/types'
import {
ArrowLeft, Save, Eye, Upload, Trash2, FileText,
Bot, Sliders, Globe, Code, AlertCircle, CheckCircle
Bot, Sliders, Globe, Code, AlertCircle, CheckCircle,
ChevronDown, ChevronRight, Settings2, ImagePlus, X
} from 'lucide-react'
const DEFAULT_FORM: ChatbotFormData = {
@@ -22,6 +23,7 @@ const DEFAULT_FORM: ChatbotFormData = {
max_tokens: 1000,
primary_color: '#6366f1',
welcome_message: 'Hello! How can I help you today?',
logo_url: '',
category: '',
industry: '',
languages: ['en'],
@@ -58,6 +60,7 @@ export const ChatbotBuilderPage: React.FC = () => {
max_tokens: existingChatbot.max_tokens,
primary_color: existingChatbot.primary_color,
welcome_message: existingChatbot.welcome_message,
logo_url: existingChatbot.logo_url || '',
category: existingChatbot.category || '',
industry: existingChatbot.industry || '',
languages: existingChatbot.languages,
@@ -101,10 +104,6 @@ export const ChatbotBuilderPage: React.FC = () => {
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>
}
@@ -117,76 +116,82 @@ export const ChatbotBuilderPage: React.FC = () => {
<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'}
{isNew ? 'Create Chatbot' : `Edit: ${form.name || 'Untitled'}`}
</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 className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
onClick={handleSave}
loading={createMutation.isPending || updateMutation.isPending}
>
<Save className="w-3.5 h-3.5" />
Save
</Button>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 px-6 py-2 bg-white border-b border-gray-100">
<div className="flex gap-1 px-6 py-2 bg-gray-50 border-b border-gray-200 overflow-x-auto">
{([
{ 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 }) => (
{ key: 'settings' as Tab, label: 'Settings', icon: Sliders },
{ key: 'documents' as Tab, label: 'Documents', icon: FileText },
{ key: 'preview' as Tab, label: 'Preview', icon: Eye },
]).map(t => (
<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'
key={t.key}
onClick={() => setTab(t.key)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
tab === t.key
? 'bg-white text-primary-700 shadow-sm'
: 'text-gray-500 hover:text-gray-700 hover:bg-white/50'
}`}
>
<Icon className="w-3.5 h-3.5" />
{label}
<t.icon className="w-4 h-4" />
{t.label}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
<div className="flex-1 overflow-y-auto p-6">
{tab === 'settings' && (
<SettingsTab form={form} setForm={setForm} availableModels={availableModels} userPlan={user?.plan || 'free'} />
<SettingsTab
form={form}
setForm={setForm}
userPlan={user?.plan || 'free'}
/>
)}
{tab === 'documents' && chatbotId && (
<DocumentsTab chatbotId={chatbotId} />
{tab === 'documents' && chatbotId && <DocumentsTab chatbotId={chatbotId} />}
{tab === 'documents' && !chatbotId && (
<Card className="p-8 text-center">
<AlertCircle className="w-8 h-8 text-amber-500 mx-auto mb-3" />
<p className="text-gray-600 text-sm">Save your chatbot first to upload documents.</p>
</Card>
)}
{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>
{tab === 'preview' && chatbotId && (
<div className="h-[calc(100vh-280px)] min-h-[400px] max-h-[700px]">
<ChatInterface
chatbotId={chatbotId}
chatbotName={form.name || 'Preview'}
welcomeMessage={form.welcome_message}
primaryColor={form.primary_color}
logoUrl={form.logo_url}
isPreview
/>
</div>
)}
{tab === 'preview' && !chatbotId && (
<Card className="p-8 text-center">
<Eye className="w-8 h-8 text-gray-400 mx-auto mb-3" />
<p className="text-gray-600 text-sm">Save your chatbot first to preview it.</p>
</Card>
)}
</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 ${
<div className={`fixed bottom-6 right-6 z-50 flex items-center gap-2 px-4 py-3 rounded-xl shadow-lg text-sm font-medium text-white ${
toast.type === 'success' ? 'bg-green-600' : 'bg-red-600'
}`}>
{toast.type === 'success' ? <CheckCircle className="w-4 h-4" /> : <AlertCircle className="w-4 h-4" />}
@@ -198,102 +203,251 @@ export const ChatbotBuilderPage: React.FC = () => {
}
const SettingsTab: React.FC<{
// ═══════════════════════════════════════════════════════════════════════════════
// SETTINGS TAB
// ═══════════════════════════════════════════════════════════════════════════════
interface SettingsTabProps {
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 }))
}
const SettingsTab: React.FC<SettingsTabProps> = ({ form, setForm, userPlan }) => {
const [advancedOpen, setAdvancedOpen] = useState(false)
const set = (field: keyof ChatbotFormData) => (value: any) => {
setForm(prev => ({ ...prev, [field]: value }))
}
// ── IMPROVEMENT #3: Load models dynamically from backend ──────────────────
const { data: modelsData, isLoading: modelsLoading } = useQuery({
queryKey: ['available-models'],
queryFn: modelsAPI.available,
staleTime: 5 * 60 * 1000, // cache for 5 minutes
})
const availableModels = modelsData?.models || []
const hasPremiumAccess = modelsData?.has_premium_access || false
const upgradeLabel = modelsData?.upgrade_label || null
return (
<div className="p-6 max-w-2xl mx-auto space-y-6">
<div className="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} />
<h2 className="font-semibold 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="e.g. Customer Support Bot"
/>
<Textarea
label="Description"
value={form.description}
onChange={e => set('description')(e.target.value)}
placeholder="What does this chatbot do?"
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} />
<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" />
<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 */}
{/* ── IMPROVEMENT #2: Appearance with logo upload ──────────────────────── */}
<Card className="p-6 space-y-4">
<h2 className="font-semibold text-gray-900 text-sm uppercase tracking-wide text-gray-500">Appearance</h2>
<h2 className="font-semibold text-sm uppercase tracking-wide text-gray-500">Appearance</h2>
{/* Logo upload */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Chatbot Logo</label>
<p className="text-xs text-gray-400 mb-2">Upload your company logo. It will appear in the chat header.</p>
<LogoUploader logoUrl={form.logo_url} onLogoChange={set('logo_url')} />
</div>
{/* Brand Color */}
<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 items-center gap-3 flex-wrap">
<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 }} />
<button
key={c}
onClick={() => set('primary_color')(c)}
className="w-6 h-6 rounded-full border-2 border-white shadow-sm hover:scale-110 transition-transform"
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>
{/* ── IMPROVEMENT #1: Advanced Settings (collapsible) ──────────────────── */}
<Card className="overflow-hidden">
<button
type="button"
onClick={() => setAdvancedOpen(!advancedOpen)}
className="w-full flex items-center justify-between p-6 text-left hover:bg-gray-50 transition-colors"
>
<div className="flex items-center gap-2">
<Settings2 className="w-4 h-4 text-gray-500" />
<h2 className="font-semibold text-sm uppercase tracking-wide text-gray-500">
Advanced Settings
</h2>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400">AI Model, Temperature, Max Tokens</span>
{advancedOpen
? <ChevronDown className="w-4 h-4 text-gray-400" />
: <ChevronRight className="w-4 h-4 text-gray-400" />
}
</div>
</button>
<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>
{advancedOpen && (
<div className="px-6 pb-6 space-y-4 border-t border-gray-100 pt-4">
{/* AI Model Selection - loaded from backend */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">AI Model</label>
{modelsLoading ? (
<div className="flex items-center gap-2 p-4 bg-gray-50 rounded-xl">
<Spinner className="w-4 h-4 text-primary-600" />
<span className="text-sm text-gray-500">Loading available models...</span>
</div>
) : (
<div className="space-y-2">
{availableModels.map((m: AvailableModel) => (
<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 min-w-0">
<p className="text-sm font-medium text-gray-900">
{m.name}
{m.is_default && (
<span className="ml-1.5 text-xs text-primary-600 font-normal">(default)</span>
)}
</p>
<p className="text-xs text-gray-500">
{m.provider}
{m.description && ` · ${m.description}`}
</p>
</div>
<Badge variant={
m.badge === 'Powerful' ? 'purple'
: m.badge === 'Free' ? 'default'
: 'default'
}>
{m.badge}
</Badge>
</label>
))}
{availableModels.length === 0 && !modelsLoading && (
<div className="p-4 bg-amber-50 rounded-xl border border-amber-200 text-center">
<p className="text-sm text-amber-700">
No models available on your current plan.{' '}
<Link to="/pricing" className="text-primary-600 font-medium underline">
Upgrade
</Link>{' '}
to access AI models.
</p>
</div>
)}
{upgradeLabel && (
<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">
{upgradeLabel}
</Link>
</p>
</div>
)}
</div>
)}
</div>
{/* Temperature & Max Tokens */}
<div className="grid grid-cols-1 sm: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>
</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">
<h2 className="font-semibold text-sm uppercase tracking-wide text-gray-500">Classification</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Select
label="Category"
value={form.category}
@@ -313,6 +467,141 @@ const SettingsTab: React.FC<{
}
// ═══════════════════════════════════════════════════════════════════════════════
// LOGO UPLOADER COMPONENT
// ═══════════════════════════════════════════════════════════════════════════════
interface LogoUploaderProps {
logoUrl: string
onLogoChange: (url: string) => void
}
const LogoUploader: React.FC<LogoUploaderProps> = ({ logoUrl, onLogoChange }) => {
const [uploading, setUploading] = useState(false)
const [error, setError] = useState('')
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)
} catch {
setError('Upload failed. Please try again.')
setUploading(false)
}
}, [onLogoChange])
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop: (files) => files[0] && handleFile(files[0]),
accept: {
'image/png': ['.png'],
'image/jpeg': ['.jpg', '.jpeg'],
'image/gif': ['.gif'],
'image/svg+xml': ['.svg'],
'image/webp': ['.webp'],
},
maxFiles: 1,
multiple: false,
})
if (logoUrl) {
return (
<div className="flex items-center gap-4">
<div className="relative group">
<img
src={logoUrl}
alt="Chatbot logo"
className="w-16 h-16 rounded-xl object-cover border border-gray-200 shadow-sm"
/>
<button
type="button"
onClick={() => onLogoChange('')}
className="absolute -top-2 -right-2 w-5 h-5 bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-sm hover:bg-red-600"
>
<X className="w-3 h-3" />
</button>
</div>
<div className="flex-1">
<p className="text-sm text-gray-700 font-medium">Logo uploaded</p>
<button
type="button"
onClick={() => onLogoChange('')}
className="text-xs text-red-500 hover:text-red-600 mt-0.5"
>
Remove logo
</button>
</div>
</div>
)
}
return (
<div>
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-colors ${
isDragActive
? 'border-primary-400 bg-primary-50'
: 'border-gray-300 hover:border-gray-400 bg-gray-50'
}`}
>
<input {...getInputProps()} />
{uploading ? (
<div className="flex items-center justify-center gap-2">
<Spinner className="w-4 h-4 text-primary-600" />
<span className="text-sm text-gray-500">Processing...</span>
</div>
) : (
<>
<ImagePlus className="w-8 h-8 text-gray-400 mx-auto mb-2" />
<p className="text-sm text-gray-600">
{isDragActive ? 'Drop your logo here' : 'Click or drag to upload a logo'}
</p>
<p className="text-xs text-gray-400 mt-1">PNG, JPG, SVG, or WebP · Max 2MB</p>
</>
)}
</div>
{error && (
<p className="text-xs text-red-500 mt-1.5 flex items-center gap-1">
<AlertCircle className="w-3 h-3" /> {error}
</p>
)}
</div>
)
}
// ═══════════════════════════════════════════════════════════════════════════════
// DOCUMENTS TAB (unchanged from original)
// ═══════════════════════════════════════════════════════════════════════════════
const DocumentsTab: React.FC<{ chatbotId: string }> = ({ chatbotId }) => {
const queryClient = useQueryClient()
const [uploading, setUploading] = useState(false)
@@ -322,9 +611,10 @@ const DocumentsTab: React.FC<{ chatbotId: string }> = ({ chatbotId }) => {
const { data: documents = [], isLoading } = useQuery({
queryKey: ['documents', chatbotId],
queryFn: () => documentsAPI.list(chatbotId),
refetchInterval: (data) => {
refetchInterval: (query) => {
const data = query.state.data
if (data && Array.isArray(data)) {
const hasProcessing = data.some(d => d.status === 'processing' || d.status === 'pending')
const hasProcessing = data.some((d: any) => d.status === 'processing' || d.status === 'pending')
return hasProcessing ? 3000 : false
}
return false
@@ -333,112 +623,117 @@ const DocumentsTab: React.FC<{ chatbotId: string }> = ({ chatbotId }) => {
const deleteMutation = useMutation({
mutationFn: (docId: string) => documentsAPI.delete(chatbotId, docId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['documents', chatbotId] })
queryClient.invalidateQueries({ queryKey: ['chatbots'] })
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['documents', chatbotId] }),
})
const handleUpload = async (files: File[]) => {
if (!files.length) return
setUploading(true)
setUploadProgress(0)
try {
for (const file of files) {
await documentsAPI.upload(chatbotId, file, (pct) => setUploadProgress(pct))
}
queryClient.invalidateQueries({ queryKey: ['documents', chatbotId] })
setToast('Documents uploaded successfully!')
setTimeout(() => setToast(''), 3000)
} catch (err: any) {
setToast(err.response?.data?.detail || 'Upload failed')
setTimeout(() => setToast(''), 3000)
} finally {
setUploading(false)
setUploadProgress(0)
}
}
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop: handleUpload,
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)
}
}
'text/markdown': ['.md'],
},
})
return (
<div className="p-6 max-w-2xl mx-auto space-y-4">
<div className="max-w-2xl mx-auto space-y-6">
{/* 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>
<Card className="p-6">
<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()} />
<Upload className="w-10 h-10 text-gray-400 mx-auto mb-3" />
<p className="text-sm text-gray-600 font-medium">
{isDragActive ? 'Drop files here' : 'Click or drag files to upload'}
</p>
<p className="text-xs text-gray-400 mt-1">PDF, DOCX, CSV, XLSX, TXT, MD</p>
</div>
{uploading && (
<div className="mt-4">
<div className="flex items-center justify-between text-xs text-gray-500 mb-1">
<span>Uploading...</span>
<span>{uploadProgress}%</span>
</div>
<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
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>
</Card>
{/* Documents list */}
{/* Document list */}
{isLoading ? (
<div className="flex justify-center py-8"><Spinner /></div>
<div className="flex justify-center py-8">
<Spinner className="text-primary-600" />
</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>
<p className="text-gray-500 text-sm">No documents yet. Upload files to build your chatbot's knowledge base.</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>
<Card className="divide-y divide-gray-100">
{documents.map((doc: any) => (
<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">
<p className="text-sm font-medium text-gray-900 truncate">{doc.file_name}</p>
<p className="text-xs text-gray-400">
{formatBytes(doc.file_size)}
{doc.chunk_count > 0 && ` · ${doc.chunk_count} chunks`}
</p>
</div>
</Card>
<StatusDot
status={doc.status === 'completed' ? 'success' : doc.status === 'failed' ? 'error' : 'warning'}
label={doc.status}
/>
<button
onClick={() => deleteMutation.mutate(doc.id)}
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
</Card>
)}
{/* 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">
<div className="fixed bottom-6 right-6 z-50 bg-gray-900 text-white px-4 py-2.5 rounded-xl shadow-lg text-sm">
{toast}
<button onClick={() => setToast('')} className="ml-3 opacity-60 hover:opacity-100">×</button>
</div>
)}
</div>

View File

@@ -220,12 +220,20 @@ const ChatbotCard: React.FC<{
<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>
{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>
)}
<div>
<h3 className="font-semibold text-gray-900 text-sm">{chatbot.name}</h3>
<div className="flex items-center gap-1.5 mt-0.5">

View File

@@ -1,230 +1,264 @@
import React, { useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { useNavigate, useParams, Link } from 'react-router-dom'
import { marketplaceAPI, chatAPI } from '@/services/api'
import { Card, Spinner, EmptyState, Button } from '@/components/ui'
import { marketplaceAPI } from '@/services/api'
import { Card, Spinner, EmptyState, Button, Badge } from '@/components/ui'
import { ChatInterface } from '@/components/ChatInterface'
import { CATEGORIES, INDUSTRIES, useDebounce } from '@/lib/utils'
import { CATEGORIES, INDUSTRIES } from '@/lib/utils'
import { Search, Bot, Star, MessageSquare, ArrowLeft } from 'lucide-react'
import type { ChatbotPublic } from '@/types'
// ═══════════════════════════════════════════════════════════════════════════════
// MARKETPLACE LISTING PAGE
// ═══════════════════════════════════════════════════════════════════════════════
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 navigate = useNavigate()
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [category, setCategory] = useState('')
const [industry, setIndustry] = useState('')
const [page, setPage] = useState(1)
// IMP-07 FIX: Debounce search input by 300ms
const debouncedSearch = useDebounce(search, 300)
// Debounce search
const searchTimeout = React.useRef<ReturnType<typeof setTimeout>>()
const handleSearch = (value: string) => {
setSearch(value)
clearTimeout(searchTimeout.current)
searchTimeout.current = setTimeout(() => {
setDebouncedSearch(value)
setPage(1)
}, 300)
}
const { data, isLoading } = useQuery({
queryKey: ['marketplace', debouncedSearch, category, industry, page],
queryFn: () => marketplaceAPI.list({ search: debouncedSearch, category, industry, page, limit: 20 }),
})
const { data, isLoading } = useQuery({
queryKey: ['marketplace', debouncedSearch, category, industry, page],
queryFn: () => marketplaceAPI.list({ search: debouncedSearch, category, industry, page, limit: 20 }),
})
return (
<div className="p-4 sm:p-6 max-w-6xl mx-auto">
<div className="mb-6 sm:mb-8">
<h1 className="text-xl sm: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>
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-sm text-gray-500 mt-1">Discover and interact with AI chatbots built by businesses</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!"
/>
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{data.chatbots.map((chatbot) => (
<ChatbotMarketplaceCard
key={chatbot.id}
chatbot={chatbot}
onClick={() => navigate(`/marketplace/${chatbot.id}`)}
{/* 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 => 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"
/>
))}
</div>
</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"
>
<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-200 rounded-xl 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>
{/* Pagination */}
{data.has_more && (
<div className="flex justify-center mt-8 gap-2">
<Button
variant="outline"
size="sm"
disabled={page === 1}
onClick={() => setPage(p => Math.max(1, p - 1))}
>
Previous
</Button>
<span className="px-3 py-1.5 text-sm text-gray-500">Page {page}</span>
<Button
variant="outline"
size="sm"
disabled={!data.has_more}
onClick={() => setPage(p => p + 1)}
>
Next
</Button>
</div>
)}
</>
)}
</div>
)
{/* Results */}
{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 sm: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>
{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>
)
}
// ═══════════════════════════════════════════════════════════════════════════════
// 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"
onClick={onClick}
>
<div className="flex items-center gap-3 mb-3">
<div
className="w-10 h-10 rounded-xl flex items-center justify-center text-white"
style={{ background: chatbot.primary_color }}
>
<Bot className="w-5 h-5" />
<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" />
</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>
<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-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>
{chatbot.description && (
<p className="text-xs text-gray-500 mb-3 line-clamp-2">{chatbot.description}</p>
)}
<span className="flex items-center gap-1">
<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
{chatbot.total_conversations} chats
</span>
{chatbot.category && <span className="truncate">🏷 {chatbot.category}</span>}
</div>
{chatbot.category && <span className="truncate">🏷 {chatbot.category}</span>}
</div>
</Card>
)
// ═══════════════════════════════════════════════════════════════════════════════
// CHATBOT DETAIL PAGE — shows logo in header + passes to ChatInterface
// ═══════════════════════════════════════════════════════════════════════════════
export const ChatbotDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { data: chatbot, isLoading, error } = useQuery({
queryKey: ['marketplace-chatbot', id],
queryFn: () => marketplaceAPI.get(id!),
enabled: !!id,
})
const { data: chatbot, isLoading, error } = 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 (error || !chatbot) {
return (
<div className="p-6 max-w-2xl mx-auto text-center">
<EmptyState
icon={<Bot className="w-8 h-8" />}
title="Chatbot not found"
description="This chatbot may have been unpublished or removed."
action={
<Button onClick={() => navigate('/marketplace')} variant="outline">
<ArrowLeft className="w-4 h-4" />
Back to Marketplace
</Button>
}
/>
</div>
)
}
if (isLoading) {
return (
<div className="flex justify-center py-20">
<Spinner className="text-primary-600" />
</div>
)
}
<div className="p-4 sm:p-6 max-w-4xl mx-auto">
{/* 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"
>
<ArrowLeft className="w-4 h-4" />
Back to Marketplace
</button>
if (error || !chatbot) {
return (
<div className="p-6 max-w-2xl mx-auto text-center">
<EmptyState
icon={<Bot className="w-8 h-8" />}
title="Chatbot not found"
description="This chatbot may have been unpublished or removed."
action={
<Button onClick={() => navigate('/marketplace')} variant="outline">
<ArrowLeft className="w-4 h-4" />
Back to Marketplace
</Button>
}
/>
</div>
)
}
{/* Chatbot info — logo or fallback */}
<div className="flex items-center gap-4 mb-6">
{chatbot.logo_url ? (
<img
src={chatbot.logo_url}
alt={chatbot.name}
className="w-14 h-14 rounded-2xl object-cover"
/>
) : (
<div
className="w-14 h-14 rounded-2xl flex items-center justify-center text-white"
style={{ background: chatbot.primary_color }}
>
<Bot className="w-7 h-7" />
</div>
)}
<div>
<h1 className="text-xl sm:text-2xl 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>
return (
<div className="p-4 sm:p-6 max-w-4xl mx-auto">
{/* 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"
>
<ArrowLeft className="w-4 h-4" />
Back to Marketplace
</button>
{/* Chatbot info */}
<div className="flex items-center gap-4 mb-6">
<div
className="w-14 h-14 rounded-2xl flex items-center justify-center text-white"
style={{ background: chatbot.primary_color }}
>
<Bot className="w-7 h-7" />
</div>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900">{chatbot.name}</h1>
{chatbot.company_name && (
<p className="text-sm text-gray-500">by {chatbot.company_name}</p>
{chatbot.description && (
<p className="text-gray-600 text-sm mb-6">{chatbot.description}</p>
)}
</div>
</div>
{chatbot.description && (
<p className="text-gray-600 text-sm mb-6">{chatbot.description}</p>
)}
{/* R-05 FIX: Use viewport-relative height instead of fixed h-[600px] */}
<div className="h-[calc(100vh-280px)] min-h-[400px] max-h-[700px]">
<ChatInterface
chatbotId={chatbot.id}
chatbotName={chatbot.name}
welcomeMessage={chatbot.welcome_message}
primaryColor={chatbot.primary_color}
/>
{/* Chat — passes logoUrl so header and bot avatars show the logo */}
<div className="h-[calc(100vh-280px)] min-h-[400px] max-h-[700px]">
<ChatInterface
chatbotId={chatbot.id}
chatbotName={chatbot.name}
welcomeMessage={chatbot.welcome_message}
primaryColor={chatbot.primary_color}
logoUrl={chatbot.logo_url}
/>
</div>
</div>
</div>
)
)
}