mirror of
http://88.130.71.182:3000/BlitTech/contexta_fe.git
synced 2026-06-12 23:23:22 +00:00
fixed bugs
This commit is contained in:
@@ -9,13 +9,13 @@ interface ChatInterfaceProps {
|
|||||||
chatbotName: string
|
chatbotName: string
|
||||||
welcomeMessage: string
|
welcomeMessage: string
|
||||||
primaryColor: string
|
primaryColor: string
|
||||||
|
logoUrl?: string
|
||||||
isPreview?: boolean
|
isPreview?: boolean
|
||||||
// BUG-09 FIX: Accept optional sessionId to persist conversations across navigation
|
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChatInterface: React.FC<ChatInterfaceProps> = ({
|
export const ChatInterface: React.FC<ChatInterfaceProps> = ({
|
||||||
chatbotId, chatbotName, welcomeMessage, primaryColor, isPreview = false, sessionId: externalSessionId
|
chatbotId, chatbotName, welcomeMessage, primaryColor, logoUrl, isPreview = false, sessionId: externalSessionId
|
||||||
}) => {
|
}) => {
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([
|
const [messages, setMessages] = useState<ChatMessage[]>([
|
||||||
{ id: '0', role: 'assistant', content: welcomeMessage }
|
{ id: '0', role: 'assistant', content: welcomeMessage }
|
||||||
@@ -23,7 +23,6 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
|
|||||||
const [input, setInput] = useState('')
|
const [input, setInput] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
// BUG-09 FIX: Use provided sessionId or persist in sessionStorage
|
|
||||||
const [sessionId] = useState(() => {
|
const [sessionId] = useState(() => {
|
||||||
if (externalSessionId) return externalSessionId
|
if (externalSessionId) return externalSessionId
|
||||||
const storageKey = `chat-session-${chatbotId}`
|
const storageKey = `chat-session-${chatbotId}`
|
||||||
@@ -82,7 +81,6 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// IMP-08: Shift+Enter for newline, Enter to send
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -98,13 +96,50 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Helper: render bot avatar (logo or fallback icon) ─────────────────────
|
||||||
|
const BotAvatar: React.FC<{ size?: 'sm' | 'md' }> = ({ size = 'sm' }) => {
|
||||||
|
const sizeClasses = size === 'sm'
|
||||||
|
? 'w-7 h-7 rounded-lg'
|
||||||
|
: 'w-8 h-8 rounded-lg'
|
||||||
|
|
||||||
|
if (logoUrl) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={logoUrl}
|
||||||
|
alt={chatbotName}
|
||||||
|
className={cn(sizeClasses, 'object-cover flex-shrink-0 mt-0.5')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(sizeClasses, 'flex items-center justify-center flex-shrink-0 mt-0.5')}
|
||||||
|
style={{ background: primaryColor }}
|
||||||
|
>
|
||||||
|
<Bot className={size === 'sm' ? 'w-3.5 h-3.5 text-white' : 'w-4 h-4 text-white'} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden">
|
<div className="flex flex-col h-full bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-gray-100" style={{ background: primaryColor }}>
|
<div
|
||||||
<div className="w-8 h-8 bg-white/20 rounded-lg flex items-center justify-center">
|
className="flex items-center gap-3 px-4 py-3 border-b border-gray-100"
|
||||||
<Bot className="w-4 h-4 text-white" />
|
style={{ background: primaryColor }}
|
||||||
</div>
|
>
|
||||||
|
{logoUrl ? (
|
||||||
|
<img
|
||||||
|
src={logoUrl}
|
||||||
|
alt={chatbotName}
|
||||||
|
className="w-8 h-8 rounded-lg object-cover bg-white/20"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-8 h-8 bg-white/20 rounded-lg flex items-center justify-center">
|
||||||
|
<Bot className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-white">{chatbotName}</h3>
|
<h3 className="text-sm font-semibold text-white">{chatbotName}</h3>
|
||||||
{isPreview && <span className="text-xs text-white/70">Preview mode</span>}
|
{isPreview && <span className="text-xs text-white/70">Preview mode</span>}
|
||||||
@@ -115,18 +150,13 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
|
|||||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
{messages.map((msg) => (
|
{messages.map((msg) => (
|
||||||
<div key={msg.id} className={cn('flex gap-3', msg.role === 'user' ? 'justify-end' : '')}>
|
<div key={msg.id} className={cn('flex gap-3', msg.role === 'user' ? 'justify-end' : '')}>
|
||||||
{msg.role === 'assistant' && (
|
{msg.role === 'assistant' && <BotAvatar />}
|
||||||
<div className="w-7 h-7 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5"
|
|
||||||
style={{ background: primaryColor }}>
|
|
||||||
<Bot className="w-3.5 h-3.5 text-white" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'max-w-[80%] rounded-2xl px-4 py-2.5 text-sm',
|
'max-w-[80%] rounded-2xl px-4 py-2.5 text-sm',
|
||||||
msg.role === 'user'
|
msg.role === 'user'
|
||||||
? 'bg-primary-600 text-white rounded-br-md'
|
? 'text-white rounded-br-md'
|
||||||
: 'bg-gray-100 text-gray-800 rounded-bl-md'
|
: 'bg-gray-100 text-gray-800 rounded-bl-md'
|
||||||
)}>
|
)} style={msg.role === 'user' ? { background: primaryColor } : undefined}>
|
||||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||||
|
|
||||||
{/* Sources */}
|
{/* Sources */}
|
||||||
@@ -145,8 +175,7 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
|
|||||||
{msg.sources.map((src, i) => (
|
{msg.sources.map((src, i) => (
|
||||||
<div key={i} className="bg-white/80 rounded-lg p-2 text-xs">
|
<div key={i} className="bg-white/80 rounded-lg p-2 text-xs">
|
||||||
<p className="font-medium text-gray-700">{src.document_name}</p>
|
<p className="font-medium text-gray-700">{src.document_name}</p>
|
||||||
<p className="text-gray-500 mt-0.5 line-clamp-2">{src.chunk_text}</p>
|
<p className="text-gray-500 mt-1 line-clamp-3">{src.chunk_text}</p>
|
||||||
<p className="text-gray-400 mt-0.5">Relevance: {(src.score * 100).toFixed(0)}%</p>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -154,21 +183,12 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{msg.role === 'user' && (
|
|
||||||
<div className="w-7 h-7 bg-gray-200 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
|
|
||||||
<User className="w-3.5 h-3.5 text-gray-600" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Typing indicator */}
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<div className="w-7 h-7 rounded-lg flex items-center justify-center flex-shrink-0"
|
<BotAvatar />
|
||||||
style={{ background: primaryColor }}>
|
|
||||||
<Bot className="w-3.5 h-3.5 text-white" />
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-100 rounded-2xl rounded-bl-md px-4 py-3">
|
<div className="bg-gray-100 rounded-2xl rounded-bl-md px-4 py-3">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||||
@@ -178,7 +198,6 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -188,17 +207,17 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
|
|||||||
<textarea
|
<textarea
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={e => setInput(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Type your message..."
|
placeholder="Type a message..."
|
||||||
rows={1}
|
rows={1}
|
||||||
className="flex-1 resize-none border border-gray-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent max-h-32"
|
className="flex-1 resize-none border border-gray-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-400 transition-all"
|
||||||
style={{ minHeight: '38px' }}
|
style={{ minHeight: '42px', maxHeight: '120px' }}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={send}
|
onClick={send}
|
||||||
disabled={!input.trim() || loading}
|
disabled={loading || !input.trim()}
|
||||||
className="p-2 rounded-xl text-white transition-all disabled:opacity-50 disabled:cursor-not-allowed hover:opacity-90"
|
className="p-2.5 rounded-xl text-white transition-colors disabled:opacity-50"
|
||||||
style={{ background: primaryColor }}
|
style={{ background: primaryColor }}
|
||||||
>
|
>
|
||||||
<Send className="w-4 h-4" />
|
<Send className="w-4 h-4" />
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
import { type ClassValue, clsx } from 'clsx'
|
import { type ClassValue, clsx } from 'clsx'
|
||||||
import { twMerge } from 'tailwind-merge'
|
import { twMerge } from 'tailwind-merge'
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import {useEffect, useState} from "react";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatDate(date: string | Date, opts?: Intl.DateTimeFormatOptions): string {
|
||||||
|
return new Intl.DateTimeFormat('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
...opts,
|
||||||
|
}).format(new Date(date))
|
||||||
|
}
|
||||||
|
|
||||||
export function formatBytes(bytes: number): string {
|
export function formatBytes(bytes: number): string {
|
||||||
if (bytes === 0) return '0 B'
|
if (bytes === 0) return '0 B'
|
||||||
const k = 1024
|
const k = 1024
|
||||||
@@ -14,24 +23,6 @@ export function formatBytes(bytes: number): string {
|
|||||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDate(date: string | Date): string {
|
|
||||||
return new Intl.DateTimeFormat('en-US', {
|
|
||||||
month: 'short', day: 'numeric', year: 'numeric'
|
|
||||||
}).format(new Date(date))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatRelativeTime(date: string | Date): string {
|
|
||||||
const now = new Date()
|
|
||||||
const d = new Date(date)
|
|
||||||
const diff = now.getTime() - d.getTime()
|
|
||||||
const days = Math.floor(diff / 86400000)
|
|
||||||
if (days === 0) return 'Today'
|
|
||||||
if (days === 1) return 'Yesterday'
|
|
||||||
if (days < 7) return `${days}d ago`
|
|
||||||
if (days < 30) return `${Math.floor(days / 7)}w ago`
|
|
||||||
return formatDate(date)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function truncate(str: string, length: number): string {
|
export function truncate(str: string, length: number): string {
|
||||||
if (str.length <= length) return str
|
if (str.length <= length) return str
|
||||||
return str.slice(0, length) + '...'
|
return str.slice(0, length) + '...'
|
||||||
@@ -59,7 +50,6 @@ export function getPlanColor(plan: string): string {
|
|||||||
return colors[plan] || colors.free
|
return colors[plan] || colors.free
|
||||||
}
|
}
|
||||||
|
|
||||||
// IMP-07: Debounce hook for search inputs (300ms default)
|
|
||||||
export function useDebounce<T>(value: T, delay: number = 300): T {
|
export function useDebounce<T>(value: T, delay: number = 300): T {
|
||||||
const [debouncedValue, setDebouncedValue] = useState<T>(value)
|
const [debouncedValue, setDebouncedValue] = useState<T>(value)
|
||||||
|
|
||||||
@@ -70,65 +60,10 @@ export function useDebounce<T>(value: T, delay: number = 300): T {
|
|||||||
|
|
||||||
return debouncedValue
|
return debouncedValue
|
||||||
}
|
}
|
||||||
|
// ─── REMOVED: AVAILABLE_MODELS ────────────────────────────────────────────────
|
||||||
export const AVAILABLE_MODELS = [
|
// Models are now loaded dynamically from the backend via GET /api/v1/models/available
|
||||||
{
|
// This ensures the frontend always reflects the backend's model configuration
|
||||||
id: 'accounts/fireworks/models/kimi-k2-instruct-0905',
|
// and changes only need to be made in one place (backend MODEL_CATALOG + PLAN_LIMITS).
|
||||||
name: 'Kimi K2 Instruct 0905',
|
|
||||||
provider: 'Fireworks AI',
|
|
||||||
plans: ['starter', 'pro', 'enterprise'],
|
|
||||||
badge: 'Fast',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'accounts/fireworks/models/deepseek-v3p2',
|
|
||||||
name: 'Deepseek V3.2',
|
|
||||||
provider: 'Fireworks AI',
|
|
||||||
plans: ['starter', 'pro', 'enterprise'],
|
|
||||||
badge: 'Balanced',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'gpt-4o',
|
|
||||||
name: 'GPT-4o',
|
|
||||||
provider: 'OpenAI',
|
|
||||||
plans: ['pro', 'enterprise'],
|
|
||||||
badge: 'Powerful',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'gpt-4-turbo',
|
|
||||||
name: 'GPT-4 Turbo',
|
|
||||||
provider: 'OpenAI',
|
|
||||||
plans: ['pro', 'enterprise'],
|
|
||||||
badge: 'Smart',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'gpt-3.5-turbo',
|
|
||||||
name: 'GPT-3.5 Turbo',
|
|
||||||
provider: 'OpenAI',
|
|
||||||
plans: ['pro', 'enterprise'],
|
|
||||||
badge: 'Efficient',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'claude-3-5-sonnet-20241022',
|
|
||||||
name: 'Claude 3.5 Sonnet',
|
|
||||||
provider: 'Anthropic',
|
|
||||||
plans: ['pro', 'enterprise'],
|
|
||||||
badge: 'Reasoning',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'claude-3-opus-20240229',
|
|
||||||
name: 'Claude 3 Opus',
|
|
||||||
provider: 'Anthropic',
|
|
||||||
plans: ['pro', 'enterprise'],
|
|
||||||
badge: 'Advanced',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'gemini-1.5-pro',
|
|
||||||
name: 'Gemini 1.5 Pro',
|
|
||||||
provider: 'Google',
|
|
||||||
plans: ['pro', 'enterprise'],
|
|
||||||
badge: 'Long Context',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const CATEGORIES = [
|
export const CATEGORIES = [
|
||||||
'Customer Support', 'Sales', 'FAQ', 'E-commerce',
|
'Customer Support', 'Sales', 'FAQ', 'E-commerce',
|
||||||
|
|||||||
@@ -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 { useParams, useNavigate, Link } from 'react-router-dom'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { chatbotsAPI, documentsAPI } from '@/services/api'
|
import { chatbotsAPI, documentsAPI, modelsAPI } from '@/services/api'
|
||||||
import { useAuthStore } from '@/store/authStore'
|
import { useAuthStore } from '@/store/authStore'
|
||||||
import { Button, Input, Textarea, Select, Card, Badge, StatusDot, Spinner } from '@/components/ui'
|
import { Button, Input, Textarea, Select, Card, Badge, StatusDot, Spinner } from '@/components/ui'
|
||||||
import { 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 { ChatInterface } from '@/components/ChatInterface'
|
||||||
import { useDropzone } from 'react-dropzone'
|
import { useDropzone } from 'react-dropzone'
|
||||||
import type { ChatbotFormData } from '@/types'
|
import type { ChatbotFormData, AvailableModel } from '@/types'
|
||||||
import {
|
import {
|
||||||
ArrowLeft, Save, Eye, Upload, Trash2, FileText,
|
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'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const DEFAULT_FORM: ChatbotFormData = {
|
const DEFAULT_FORM: ChatbotFormData = {
|
||||||
@@ -22,6 +23,7 @@ const DEFAULT_FORM: ChatbotFormData = {
|
|||||||
max_tokens: 1000,
|
max_tokens: 1000,
|
||||||
primary_color: '#6366f1',
|
primary_color: '#6366f1',
|
||||||
welcome_message: 'Hello! How can I help you today?',
|
welcome_message: 'Hello! How can I help you today?',
|
||||||
|
logo_url: '',
|
||||||
category: '',
|
category: '',
|
||||||
industry: '',
|
industry: '',
|
||||||
languages: ['en'],
|
languages: ['en'],
|
||||||
@@ -58,6 +60,7 @@ export const ChatbotBuilderPage: React.FC = () => {
|
|||||||
max_tokens: existingChatbot.max_tokens,
|
max_tokens: existingChatbot.max_tokens,
|
||||||
primary_color: existingChatbot.primary_color,
|
primary_color: existingChatbot.primary_color,
|
||||||
welcome_message: existingChatbot.welcome_message,
|
welcome_message: existingChatbot.welcome_message,
|
||||||
|
logo_url: existingChatbot.logo_url || '',
|
||||||
category: existingChatbot.category || '',
|
category: existingChatbot.category || '',
|
||||||
industry: existingChatbot.industry || '',
|
industry: existingChatbot.industry || '',
|
||||||
languages: existingChatbot.languages,
|
languages: existingChatbot.languages,
|
||||||
@@ -101,10 +104,6 @@ export const ChatbotBuilderPage: React.FC = () => {
|
|||||||
setTimeout(() => setToast(null), 3000)
|
setTimeout(() => setToast(null), 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
const availableModels = AVAILABLE_MODELS.filter(m =>
|
|
||||||
m.plans.includes(user?.plan || 'free') || m.plans.includes('starter')
|
|
||||||
)
|
|
||||||
|
|
||||||
if (loadingChatbot) {
|
if (loadingChatbot) {
|
||||||
return <div className="flex items-center justify-center h-full"><Spinner className="text-primary-600" /></div>
|
return <div className="flex items-center justify-center h-full"><Spinner className="text-primary-600" /></div>
|
||||||
}
|
}
|
||||||
@@ -117,76 +116,82 @@ export const ChatbotBuilderPage: React.FC = () => {
|
|||||||
<ArrowLeft className="w-4 h-4 text-gray-600" />
|
<ArrowLeft className="w-4 h-4 text-gray-600" />
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="font-semibold text-gray-900 flex-1">
|
<h1 className="font-semibold text-gray-900 flex-1">
|
||||||
{isNew ? 'Create Chatbot' : form.name || 'Edit Chatbot'}
|
{isNew ? 'Create Chatbot' : `Edit: ${form.name || 'Untitled'}`}
|
||||||
</h1>
|
</h1>
|
||||||
{existingChatbot && (
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-1.5">
|
<Button
|
||||||
<StatusDot status={existingChatbot.is_published ? 'published' : 'preview'} />
|
variant="primary"
|
||||||
<span className="text-xs text-gray-500">
|
size="sm"
|
||||||
{existingChatbot.is_published ? 'Published' : 'Preview only'}
|
onClick={handleSave}
|
||||||
</span>
|
loading={createMutation.isPending || updateMutation.isPending}
|
||||||
</div>
|
>
|
||||||
)}
|
<Save className="w-3.5 h-3.5" />
|
||||||
<Button
|
Save
|
||||||
onClick={handleSave}
|
</Button>
|
||||||
loading={createMutation.isPending || updateMutation.isPending}
|
</div>
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<Save className="w-3.5 h-3.5" />
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* 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 },
|
{ key: 'settings' as Tab, label: 'Settings', icon: Sliders },
|
||||||
{ id: 'documents', label: 'Documents', icon: FileText, disabled: !chatbotId },
|
{ key: 'documents' as Tab, label: 'Documents', icon: FileText },
|
||||||
{ id: 'preview', label: 'Preview', icon: Eye, disabled: !chatbotId },
|
{ key: 'preview' as Tab, label: 'Preview', icon: Eye },
|
||||||
] as const).map(({ id: tid, label, icon: Icon, disabled }) => (
|
]).map(t => (
|
||||||
<button
|
<button
|
||||||
key={tid}
|
key={t.key}
|
||||||
onClick={() => !disabled && setTab(tid as Tab)}
|
onClick={() => setTab(t.key)}
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
|
||||||
tab === tid
|
tab === t.key
|
||||||
? 'bg-primary-50 text-primary-700'
|
? 'bg-white text-primary-700 shadow-sm'
|
||||||
: disabled
|
: 'text-gray-500 hover:text-gray-700 hover:bg-white/50'
|
||||||
? 'text-gray-300 cursor-not-allowed'
|
|
||||||
: 'text-gray-600 hover:bg-gray-100'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon className="w-3.5 h-3.5" />
|
<t.icon className="w-4 h-4" />
|
||||||
{label}
|
{t.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
{tab === 'settings' && (
|
{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 && (
|
{tab === 'documents' && chatbotId && <DocumentsTab chatbotId={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 && (
|
{tab === 'preview' && chatbotId && (
|
||||||
<div className="p-6 max-w-2xl mx-auto">
|
<div className="h-[calc(100vh-280px)] min-h-[400px] max-h-[700px]">
|
||||||
<div className="h-[600px]">
|
<ChatInterface
|
||||||
<ChatInterface
|
chatbotId={chatbotId}
|
||||||
chatbotId={chatbotId}
|
chatbotName={form.name || 'Preview'}
|
||||||
chatbotName={existingChatbot.name}
|
welcomeMessage={form.welcome_message}
|
||||||
welcomeMessage={existingChatbot.welcome_message}
|
primaryColor={form.primary_color}
|
||||||
primaryColor={existingChatbot.primary_color}
|
logoUrl={form.logo_url}
|
||||||
isPreview
|
isPreview
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Toast */}
|
{/* Toast */}
|
||||||
{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' ? 'bg-green-600' : 'bg-red-600'
|
||||||
}`}>
|
}`}>
|
||||||
{toast.type === 'success' ? <CheckCircle className="w-4 h-4" /> : <AlertCircle className="w-4 h-4" />}
|
{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
|
form: ChatbotFormData
|
||||||
setForm: React.Dispatch<React.SetStateAction<ChatbotFormData>>
|
setForm: React.Dispatch<React.SetStateAction<ChatbotFormData>>
|
||||||
availableModels: typeof AVAILABLE_MODELS
|
|
||||||
userPlan: string
|
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 (
|
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 */}
|
{/* Basic Info */}
|
||||||
<Card className="p-6 space-y-4">
|
<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>
|
<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="My Support Bot" />
|
<Input
|
||||||
<Textarea label="Description" value={form.description} onChange={e => set('description')(e.target.value)} placeholder="What does this chatbot help with?" rows={2} />
|
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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Welcome Message</label>
|
<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>
|
</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>
|
</Card>
|
||||||
|
|
||||||
{/* Appearance */}
|
{/* ── IMPROVEMENT #2: Appearance with logo upload ──────────────────────── */}
|
||||||
<Card className="p-6 space-y-4">
|
<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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Brand Color</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">Brand Color</label>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<input type="color" value={form.primary_color} onChange={e => set('primary_color')(e.target.value)}
|
<input
|
||||||
className="w-10 h-10 rounded-lg border border-gray-200 cursor-pointer" />
|
type="color"
|
||||||
<Input value={form.primary_color} onChange={e => set('primary_color')(e.target.value)} className="w-32" />
|
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">
|
<div className="flex gap-2">
|
||||||
{['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'].map(c => (
|
{['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'].map(c => (
|
||||||
<button key={c} onClick={() => set('primary_color')(c)}
|
<button
|
||||||
className="w-6 h-6 rounded-full border-2 border-white shadow-sm"
|
key={c}
|
||||||
style={{ background: 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* AI Model */}
|
{/* ── IMPROVEMENT #1: Advanced Settings (collapsible) ──────────────────── */}
|
||||||
<Card className="p-6 space-y-4">
|
<Card className="overflow-hidden">
|
||||||
<h2 className="font-semibold text-gray-900 text-sm uppercase tracking-wide text-gray-500">AI Model</h2>
|
<button
|
||||||
<div className="space-y-2">
|
type="button"
|
||||||
{availableModels.map(m => (
|
onClick={() => setAdvancedOpen(!advancedOpen)}
|
||||||
<label key={m.id} className={`flex items-center gap-3 p-3 rounded-xl border cursor-pointer transition-colors ${
|
className="w-full flex items-center justify-between p-6 text-left hover:bg-gray-50 transition-colors"
|
||||||
form.model === m.id ? 'border-primary-400 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
|
>
|
||||||
}`}>
|
<div className="flex items-center gap-2">
|
||||||
<input type="radio" name="model" value={m.id} checked={form.model === m.id} onChange={() => set('model')(m.id)} className="sr-only" />
|
<Settings2 className="w-4 h-4 text-gray-500" />
|
||||||
<div className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center text-xs font-bold text-gray-600">
|
<h2 className="font-semibold text-sm uppercase tracking-wide text-gray-500">
|
||||||
{m.provider[0]}
|
Advanced Settings
|
||||||
</div>
|
</h2>
|
||||||
<div className="flex-1">
|
</div>
|
||||||
<p className="text-sm font-medium text-gray-900">{m.name}</p>
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-xs text-gray-500">{m.provider}</p>
|
<span className="text-xs text-gray-400">AI Model, Temperature, Max Tokens</span>
|
||||||
</div>
|
{advancedOpen
|
||||||
<Badge variant={m.badge === 'Powerful' ? 'purple' : 'default'}>{m.badge}</Badge>
|
? <ChevronDown className="w-4 h-4 text-gray-400" />
|
||||||
</label>
|
: <ChevronRight className="w-4 h-4 text-gray-400" />
|
||||||
))}
|
}
|
||||||
{userPlan === 'free' || userPlan === 'starter' ? (
|
</div>
|
||||||
<div className="p-3 bg-gray-50 rounded-xl border border-dashed border-gray-300 text-center">
|
</button>
|
||||||
<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">
|
{advancedOpen && (
|
||||||
<div>
|
<div className="px-6 pb-6 space-y-4 border-t border-gray-100 pt-4">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Temperature</label>
|
{/* AI Model Selection - loaded from backend */}
|
||||||
<input type="range" min="0" max="1" step="0.1" value={form.temperature}
|
<div>
|
||||||
onChange={e => set('temperature')(parseFloat(e.target.value))}
|
<label className="block text-sm font-medium text-gray-700 mb-2">AI Model</label>
|
||||||
className="w-full accent-primary-600" />
|
|
||||||
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
{modelsLoading ? (
|
||||||
<span>Precise ({form.temperature})</span>
|
<div className="flex items-center gap-2 p-4 bg-gray-50 rounded-xl">
|
||||||
<span>Creative</span>
|
<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>
|
)}
|
||||||
<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>
|
</Card>
|
||||||
|
|
||||||
{/* Classification */}
|
{/* Classification */}
|
||||||
<Card className="p-6 space-y-4">
|
<Card className="p-6 space-y-4">
|
||||||
<h2 className="font-semibold text-gray-900 text-sm uppercase tracking-wide text-gray-500">Classification</h2>
|
<h2 className="font-semibold text-sm uppercase tracking-wide text-gray-500">Classification</h2>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<Select
|
<Select
|
||||||
label="Category"
|
label="Category"
|
||||||
value={form.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 DocumentsTab: React.FC<{ chatbotId: string }> = ({ chatbotId }) => {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
@@ -322,9 +611,10 @@ const DocumentsTab: React.FC<{ chatbotId: string }> = ({ chatbotId }) => {
|
|||||||
const { data: documents = [], isLoading } = useQuery({
|
const { data: documents = [], isLoading } = useQuery({
|
||||||
queryKey: ['documents', chatbotId],
|
queryKey: ['documents', chatbotId],
|
||||||
queryFn: () => documentsAPI.list(chatbotId),
|
queryFn: () => documentsAPI.list(chatbotId),
|
||||||
refetchInterval: (data) => {
|
refetchInterval: (query) => {
|
||||||
|
const data = query.state.data
|
||||||
if (data && Array.isArray(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 hasProcessing ? 3000 : false
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -333,112 +623,117 @@ const DocumentsTab: React.FC<{ chatbotId: string }> = ({ chatbotId }) => {
|
|||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (docId: string) => documentsAPI.delete(chatbotId, docId),
|
mutationFn: (docId: string) => documentsAPI.delete(chatbotId, docId),
|
||||||
onSuccess: () => {
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['documents', chatbotId] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ['documents', chatbotId] })
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['chatbots'] })
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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({
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
|
onDrop: handleUpload,
|
||||||
accept: {
|
accept: {
|
||||||
'application/pdf': ['.pdf'],
|
'application/pdf': ['.pdf'],
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
|
||||||
'text/csv': ['.csv'],
|
'text/csv': ['.csv'],
|
||||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
|
||||||
'text/plain': ['.txt'],
|
'text/plain': ['.txt'],
|
||||||
},
|
'text/markdown': ['.md'],
|
||||||
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 (
|
return (
|
||||||
<div className="p-6 max-w-2xl mx-auto space-y-4">
|
<div className="max-w-2xl mx-auto space-y-6">
|
||||||
{/* Dropzone */}
|
{/* Dropzone */}
|
||||||
<div
|
<Card className="p-6">
|
||||||
{...getRootProps()}
|
<div
|
||||||
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors ${
|
{...getRootProps()}
|
||||||
isDragActive ? 'border-primary-400 bg-primary-50' : 'border-gray-300 hover:border-gray-400'
|
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 ? (
|
<input {...getInputProps()} />
|
||||||
<div className="space-y-3">
|
<Upload className="w-10 h-10 text-gray-400 mx-auto mb-3" />
|
||||||
<Spinner className="mx-auto text-primary-600" />
|
<p className="text-sm text-gray-600 font-medium">
|
||||||
<p className="text-sm text-gray-600">Uploading & processing... {uploadProgress}%</p>
|
{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="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>
|
||||||
</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 ? (
|
{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 ? (
|
) : documents.length === 0 ? (
|
||||||
<Card className="p-8 text-center">
|
<Card className="p-8 text-center">
|
||||||
<FileText className="w-8 h-8 text-gray-300 mx-auto mb-3" />
|
<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-gray-500 text-sm">No documents yet. Upload files to build your chatbot's knowledge base.</p>
|
||||||
<p className="text-xs text-gray-400 mt-1">Upload files to give your chatbot knowledge</p>
|
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<Card className="divide-y divide-gray-100">
|
||||||
{documents.map((doc) => (
|
{documents.map((doc: any) => (
|
||||||
<Card key={doc.id} className="p-4">
|
<div key={doc.id} className="flex items-center gap-3 p-4">
|
||||||
<div className="flex items-center gap-3">
|
<span className="text-xl">{getFileIcon(doc.file_type)}</span>
|
||||||
<span className="text-xl">{getFileIcon(doc.file_type)}</span>
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex-1 min-w-0">
|
<p className="text-sm font-medium text-gray-900 truncate">{doc.file_name}</p>
|
||||||
<p className="text-sm font-medium text-gray-900 truncate">{doc.file_name}</p>
|
<p className="text-xs text-gray-400">
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
{formatBytes(doc.file_size)}
|
||||||
<span className={`w-1.5 h-1.5 rounded-full ${
|
{doc.chunk_count > 0 && ` · ${doc.chunk_count} chunks`}
|
||||||
doc.status === 'completed' ? 'bg-green-500' :
|
</p>
|
||||||
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>
|
</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 && (
|
{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}
|
{toast}
|
||||||
<button onClick={() => setToast('')} className="ml-3 opacity-60 hover:opacity-100">×</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -220,12 +220,20 @@ const ChatbotCard: React.FC<{
|
|||||||
<Card className="p-5">
|
<Card className="p-5">
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div
|
{chatbot.logo_url ? (
|
||||||
className="w-10 h-10 rounded-xl flex items-center justify-center text-white text-lg"
|
<img
|
||||||
style={{ background: chatbot.primary_color }}
|
src={chatbot.logo_url}
|
||||||
>
|
alt={chatbot.name}
|
||||||
<Bot className="w-5 h-5" />
|
className="w-10 h-10 rounded-xl object-cover flex-shrink-0"
|
||||||
</div>
|
/>
|
||||||
|
) : (
|
||||||
|
<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>
|
<div>
|
||||||
<h3 className="font-semibold text-gray-900 text-sm">{chatbot.name}</h3>
|
<h3 className="font-semibold text-gray-900 text-sm">{chatbot.name}</h3>
|
||||||
<div className="flex items-center gap-1.5 mt-0.5">
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
|||||||
@@ -1,230 +1,264 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate, useParams, Link } from 'react-router-dom'
|
import { marketplaceAPI } from '@/services/api'
|
||||||
import { marketplaceAPI, chatAPI } from '@/services/api'
|
import { Card, Spinner, EmptyState, Button, Badge } from '@/components/ui'
|
||||||
import { Card, Spinner, EmptyState, Button } from '@/components/ui'
|
|
||||||
import { ChatInterface } from '@/components/ChatInterface'
|
import { ChatInterface } from '@/components/ChatInterface'
|
||||||
import { CATEGORIES, INDUSTRIES, useDebounce } from '@/lib/utils'
|
import { CATEGORIES, INDUSTRIES } from '@/lib/utils'
|
||||||
import { Search, Bot, Star, MessageSquare, ArrowLeft } from 'lucide-react'
|
import { Search, Bot, Star, MessageSquare, ArrowLeft } from 'lucide-react'
|
||||||
import type { ChatbotPublic } from '@/types'
|
import type { ChatbotPublic } from '@/types'
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// MARKETPLACE LISTING PAGE
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
export const MarketplacePage: React.FC = () => {
|
export const MarketplacePage: React.FC = () => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [category, setCategory] = useState('')
|
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||||
const [industry, setIndustry] = useState('')
|
const [category, setCategory] = useState('')
|
||||||
const [page, setPage] = useState(1)
|
const [industry, setIndustry] = useState('')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
// IMP-07 FIX: Debounce search input by 300ms
|
// Debounce search
|
||||||
const debouncedSearch = useDebounce(search, 300)
|
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({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['marketplace', debouncedSearch, category, industry, page],
|
queryKey: ['marketplace', debouncedSearch, category, industry, page],
|
||||||
queryFn: () => marketplaceAPI.list({ search: debouncedSearch, category, industry, page, limit: 20 }),
|
queryFn: () => marketplaceAPI.list({ search: debouncedSearch, category, industry, page, limit: 20 }),
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 sm:p-6 max-w-6xl mx-auto">
|
<div className="p-6 max-w-6xl mx-auto">
|
||||||
<div className="mb-6 sm:mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-xl sm:text-2xl font-bold text-gray-900">AI Chatbot Marketplace</h1>
|
<h1 className="text-2xl font-bold text-gray-900">AI Chatbot Marketplace</h1>
|
||||||
<p className="text-gray-500 mt-1 text-sm">Discover and interact with AI chatbots from companies worldwide</p>
|
<p className="text-sm text-gray-500 mt-1">Discover and interact with AI chatbots built by businesses</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search & Filters */}
|
{/* Filters */}
|
||||||
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => { setSearch(e.target.value); setPage(1) }}
|
onChange={e => handleSearch(e.target.value)}
|
||||||
placeholder="Search chatbots..."
|
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"
|
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>
|
|
||||||
<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}`)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
</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 */}
|
{/* Results */}
|
||||||
{data.has_more && (
|
{isLoading ? (
|
||||||
<div className="flex justify-center mt-8 gap-2">
|
<div className="flex justify-center py-20"><Spinner className="text-primary-600" /></div>
|
||||||
<Button
|
) : !data?.chatbots?.length ? (
|
||||||
variant="outline"
|
<EmptyState
|
||||||
size="sm"
|
icon={<Bot className="w-8 h-8" />}
|
||||||
disabled={page === 1}
|
title="No chatbots found"
|
||||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
description="Be the first to publish your AI chatbot to the marketplace!"
|
||||||
>
|
action={<Button onClick={() => navigate('/chatbots/new')}>Create Chatbot</Button>}
|
||||||
Previous
|
/>
|
||||||
</Button>
|
) : (
|
||||||
<span className="px-3 py-1.5 text-sm text-gray-500">Page {page}</span>
|
<>
|
||||||
<Button
|
<div className="mb-4 text-sm text-gray-500">{data.total} chatbot{data.total !== 1 ? 's' : ''} available</div>
|
||||||
variant="outline"
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
|
||||||
size="sm"
|
{data.chatbots.map(chatbot => (
|
||||||
disabled={!data.has_more}
|
<ChatbotMarketplaceCard
|
||||||
onClick={() => setPage(p => p + 1)}
|
key={chatbot.id}
|
||||||
>
|
chatbot={chatbot}
|
||||||
Next
|
onClick={() => navigate(`/marketplace/${chatbot.id}`)}
|
||||||
</Button>
|
/>
|
||||||
</div>
|
))}
|
||||||
)}
|
</div>
|
||||||
</>
|
|
||||||
)}
|
{data.total > 20 && (
|
||||||
</div>
|
<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 }) => (
|
const ChatbotMarketplaceCard: React.FC<{ chatbot: ChatbotPublic; onClick: () => void }> = ({ chatbot, onClick }) => (
|
||||||
<Card
|
<Card
|
||||||
className="p-5 cursor-pointer hover:shadow-md hover:border-gray-300 transition-all"
|
className="p-5 cursor-pointer hover:shadow-md hover:border-gray-300 transition-all"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<div
|
{/* Logo or fallback icon */}
|
||||||
className="w-10 h-10 rounded-xl flex items-center justify-center text-white"
|
{chatbot.logo_url ? (
|
||||||
style={{ background: chatbot.primary_color }}
|
<img
|
||||||
>
|
src={chatbot.logo_url}
|
||||||
<Bot className="w-5 h-5" />
|
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>
|
||||||
<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 && (
|
{chatbot.description && (
|
||||||
<p className="text-xs text-gray-500 mb-3 line-clamp-2">{chatbot.description}</p>
|
<p className="text-xs text-gray-500 mb-3 line-clamp-2">{chatbot.description}</p>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 text-xs text-gray-400">
|
|
||||||
{chatbot.average_rating && chatbot.average_rating > 0 && (
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Star className="w-3 h-3 text-yellow-400 fill-yellow-400" />
|
|
||||||
{chatbot.average_rating.toFixed(1)}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
|
<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" />
|
<MessageSquare className="w-3 h-3" />
|
||||||
{chatbot.total_conversations} chats
|
{chatbot.total_conversations} chats
|
||||||
</span>
|
</span>
|
||||||
{chatbot.category && <span className="truncate">🏷 {chatbot.category}</span>}
|
{chatbot.category && <span className="truncate">🏷 {chatbot.category}</span>}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// CHATBOT DETAIL PAGE — shows logo in header + passes to ChatInterface
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
export const ChatbotDetailPage: React.FC = () => {
|
export const ChatbotDetailPage: React.FC = () => {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const { data: chatbot, isLoading, error } = useQuery({
|
const { data: chatbot, isLoading, error } = useQuery({
|
||||||
queryKey: ['marketplace-chatbot', id],
|
queryKey: ['marketplace-chatbot', id],
|
||||||
queryFn: () => marketplaceAPI.get(id!),
|
queryFn: () => marketplaceAPI.get(id!),
|
||||||
enabled: !!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 (
|
return (
|
||||||
<div className="flex justify-center py-20">
|
<div className="p-4 sm:p-6 max-w-4xl mx-auto">
|
||||||
<Spinner className="text-primary-600" />
|
{/* Back link */}
|
||||||
</div>
|
<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) {
|
{/* Chatbot info — logo or fallback */}
|
||||||
return (
|
<div className="flex items-center gap-4 mb-6">
|
||||||
<div className="p-6 max-w-2xl mx-auto text-center">
|
{chatbot.logo_url ? (
|
||||||
<EmptyState
|
<img
|
||||||
icon={<Bot className="w-8 h-8" />}
|
src={chatbot.logo_url}
|
||||||
title="Chatbot not found"
|
alt={chatbot.name}
|
||||||
description="This chatbot may have been unpublished or removed."
|
className="w-14 h-14 rounded-2xl object-cover"
|
||||||
action={
|
/>
|
||||||
<Button onClick={() => navigate('/marketplace')} variant="outline">
|
) : (
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<div
|
||||||
Back to Marketplace
|
className="w-14 h-14 rounded-2xl flex items-center justify-center text-white"
|
||||||
</Button>
|
style={{ background: chatbot.primary_color }}
|
||||||
}
|
>
|
||||||
/>
|
<Bot className="w-7 h-7" />
|
||||||
</div>
|
</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 (
|
{chatbot.description && (
|
||||||
<div className="p-4 sm:p-6 max-w-4xl mx-auto">
|
<p className="text-gray-600 text-sm mb-6">{chatbot.description}</p>
|
||||||
{/* 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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{chatbot.description && (
|
{/* Chat — passes logoUrl so header and bot avatars show the logo */}
|
||||||
<p className="text-gray-600 text-sm mb-6">{chatbot.description}</p>
|
<div className="h-[calc(100vh-280px)] min-h-[400px] max-h-[700px]">
|
||||||
)}
|
<ChatInterface
|
||||||
|
chatbotId={chatbot.id}
|
||||||
{/* R-05 FIX: Use viewport-relative height instead of fixed h-[600px] */}
|
chatbotName={chatbot.name}
|
||||||
<div className="h-[calc(100vh-280px)] min-h-[400px] max-h-[700px]">
|
welcomeMessage={chatbot.welcome_message}
|
||||||
<ChatInterface
|
primaryColor={chatbot.primary_color}
|
||||||
chatbotId={chatbot.id}
|
logoUrl={chatbot.logo_url}
|
||||||
chatbotName={chatbot.name}
|
/>
|
||||||
welcomeMessage={chatbot.welcome_message}
|
</div>
|
||||||
primaryColor={chatbot.primary_color}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import type {
|
import type {
|
||||||
AuthResponse, User, Chatbot, ChatbotFormData, Document,
|
AuthResponse, User, Chatbot, ChatbotFormData, Document,
|
||||||
ChatResponse, Subscription, MarketplaceResponse, Analytics, ChatbotPublic
|
ChatResponse, Subscription, MarketplaceResponse, Analytics, ChatbotPublic,
|
||||||
|
ModelsResponse,
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
import { useAuthStore } from '@/store/authStore'
|
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||||
|
|
||||||
@@ -12,48 +12,49 @@ export const api = axios.create({
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
})
|
})
|
||||||
|
|
||||||
// BUG-01 FIX: Read token from Zustand store (single source of truth)
|
// Request interceptor - attach token
|
||||||
// instead of manual localStorage.getItem('access_token')
|
|
||||||
api.interceptors.request.use((config) => {
|
api.interceptors.request.use((config) => {
|
||||||
const token = useAuthStore.getState().token
|
// Read from Zustand persisted state (single source of truth)
|
||||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
try {
|
||||||
|
const stored = localStorage.getItem('contexta-auth')
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored)
|
||||||
|
const token = parsed?.state?.token
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
return config
|
return config
|
||||||
})
|
})
|
||||||
|
|
||||||
// BUG-02 FIX: Prevent infinite redirect loop on 401
|
// Response interceptor - handle 401
|
||||||
// - Use a flag to prevent multiple redirects
|
|
||||||
// - Call Zustand logout() to clear state
|
|
||||||
// - Use window.location.replace() to avoid back-button loop
|
|
||||||
// - Skip redirect if already on login page
|
|
||||||
let isRedirecting = false
|
let isRedirecting = false
|
||||||
|
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(res) => res,
|
(res) => res,
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error.response?.status === 401 && !isRedirecting) {
|
if (error.response?.status === 401 && !isRedirecting) {
|
||||||
// Don't redirect if already on login/signup page
|
isRedirecting = true
|
||||||
const currentPath = window.location.pathname
|
localStorage.removeItem('contexta-auth')
|
||||||
if (currentPath !== '/login' && currentPath !== '/signup') {
|
window.location.replace('/login')
|
||||||
isRedirecting = true
|
setTimeout(() => { isRedirecting = false }, 2000)
|
||||||
useAuthStore.getState().logout()
|
|
||||||
window.location.replace('/login')
|
|
||||||
// Reset flag after a delay to allow the redirect to complete
|
|
||||||
setTimeout(() => { isRedirecting = false }, 2000)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// ─── Auth ──────────────────────────────────────────────────────────────────────
|
// ─── Auth ─────────────────────────────────────────────────────────────────────
|
||||||
export const authAPI = {
|
export const authAPI = {
|
||||||
signup: (data: { email: string; password: string; company_name: string }) =>
|
signup: (data: { email: string; password: string; full_name: string; company_name?: string }) =>
|
||||||
api.post<AuthResponse>('/auth/signup', data).then(r => r.data),
|
api.post<AuthResponse>('/auth/signup', data).then(r => r.data),
|
||||||
|
|
||||||
login: (data: { email: string; password: string }) =>
|
login: (data: { email: string; password: string }) =>
|
||||||
api.post<AuthResponse>('/auth/login', data).then(r => r.data),
|
api.post<AuthResponse>('/auth/login', data).then(r => r.data),
|
||||||
|
|
||||||
logout: () => api.post('/auth/logout').then(r => r.data),
|
logout: () =>
|
||||||
|
api.post('/auth/logout').then(r => r.data),
|
||||||
|
|
||||||
me: () => api.get<User>('/auth/me').then(r => r.data),
|
me: () => api.get<User>('/auth/me').then(r => r.data),
|
||||||
}
|
}
|
||||||
@@ -122,18 +123,26 @@ export const marketplaceAPI = {
|
|||||||
|
|
||||||
rate: (chatbotId: string, data: { rating: number; comment?: string }) =>
|
rate: (chatbotId: string, data: { rating: number; comment?: string }) =>
|
||||||
api.post(`/marketplace/chatbots/${chatbotId}/rate`, data).then(r => r.data),
|
api.post(`/marketplace/chatbots/${chatbotId}/rate`, data).then(r => r.data),
|
||||||
|
|
||||||
|
categories: () =>
|
||||||
|
api.get<string[]>('/marketplace/categories').then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Billing ──────────────────────────────────────────────────────────────────
|
// ─── Billing ──────────────────────────────────────────────────────────────────
|
||||||
export const billingAPI = {
|
export const billingAPI = {
|
||||||
createCheckout: (plan: string, successUrl: string, cancelUrl: string) =>
|
checkout: (plan: string) =>
|
||||||
api.post<{ checkout_url: string; session_id: string }>('/billing/checkout', {
|
api.post<{ url: string }>('/billing/checkout', { plan }).then(r => r.data),
|
||||||
plan, success_url: successUrl, cancel_url: cancelUrl,
|
|
||||||
}).then(r => r.data),
|
|
||||||
|
|
||||||
getSubscription: () =>
|
portal: () =>
|
||||||
|
api.post<{ url: string }>('/billing/portal').then(r => r.data),
|
||||||
|
|
||||||
|
subscription: () =>
|
||||||
api.get<Subscription>('/billing/subscription').then(r => r.data),
|
api.get<Subscription>('/billing/subscription').then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
createPortal: (returnUrl: string) =>
|
// ─── Models (NEW - loaded from backend) ───────────────────────────────────────
|
||||||
api.post<{ url: string }>('/billing/portal', { return_url: returnUrl }).then(r => r.data),
|
export const modelsAPI = {
|
||||||
|
/** Fetch available models for the current user based on their plan */
|
||||||
|
available: () =>
|
||||||
|
api.get<ModelsResponse>('/models/available').then(r => r.data),
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
// ─── Auth ──────────────────────────────────────────────────────────────────────
|
// ─── User ─────────────────────────────────────────────────────────────────────
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string
|
id: string
|
||||||
email: string
|
email: string
|
||||||
|
full_name: string
|
||||||
company_name?: string
|
company_name?: string
|
||||||
plan: 'free' | 'starter' | 'pro' | 'enterprise'
|
plan: 'free' | 'starter' | 'pro' | 'enterprise'
|
||||||
created_at?: string
|
created_at?: string
|
||||||
@@ -25,6 +26,7 @@ export interface Chatbot {
|
|||||||
max_tokens: number
|
max_tokens: number
|
||||||
primary_color: string
|
primary_color: string
|
||||||
welcome_message: string
|
welcome_message: string
|
||||||
|
logo_url?: string
|
||||||
category?: string
|
category?: string
|
||||||
industry?: string
|
industry?: string
|
||||||
languages: string[]
|
languages: string[]
|
||||||
@@ -47,6 +49,7 @@ export interface ChatbotPublic {
|
|||||||
languages: string[]
|
languages: string[]
|
||||||
primary_color: string
|
primary_color: string
|
||||||
welcome_message: string
|
welcome_message: string
|
||||||
|
logo_url?: string
|
||||||
average_rating?: number
|
average_rating?: number
|
||||||
total_conversations: number
|
total_conversations: number
|
||||||
company_name?: string
|
company_name?: string
|
||||||
@@ -64,6 +67,7 @@ export interface ChatbotFormData {
|
|||||||
max_tokens: number
|
max_tokens: number
|
||||||
primary_color: string
|
primary_color: string
|
||||||
welcome_message: string
|
welcome_message: string
|
||||||
|
logo_url: string
|
||||||
category: string
|
category: string
|
||||||
industry: string
|
industry: string
|
||||||
languages: string[]
|
languages: string[]
|
||||||
@@ -154,3 +158,21 @@ export interface Analytics {
|
|||||||
average_rating: number
|
average_rating: number
|
||||||
conversations_last_30_days: number
|
conversations_last_30_days: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Models (loaded from backend) ─────────────────────────────────────────────
|
||||||
|
export interface AvailableModel {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
provider: string
|
||||||
|
badge: string
|
||||||
|
description?: string
|
||||||
|
is_default: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelsResponse {
|
||||||
|
models: AvailableModel[]
|
||||||
|
plan: string
|
||||||
|
default_model: string | null
|
||||||
|
has_premium_access: boolean
|
||||||
|
upgrade_label: string | null
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user