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:
51
src/App.tsx
51
src/App.tsx
@@ -1,16 +1,28 @@
|
|||||||
import React from 'react'
|
import React, { lazy, Suspense } from 'react'
|
||||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { useAuthStore } from '@/store/authStore'
|
import { useAuthStore } from '@/store/authStore'
|
||||||
import { AppLayout } from '@/components/Layout'
|
import { AppLayout } from '@/components/Layout'
|
||||||
import { LandingPage } from '@/pages/LandingPage'
|
import { PublicLayout } from '@/components/PublicLayout'
|
||||||
import { LoginPage, SignupPage } from '@/pages/AuthPages'
|
import { Spinner } from '@/components/ui'
|
||||||
import { DashboardPage } from '@/pages/DashboardPage'
|
|
||||||
import { ChatbotBuilderPage } from '@/pages/ChatbotBuilderPage'
|
|
||||||
import { MarketplacePage, ChatbotDetailPage } from '@/pages/MarketplacePage'
|
|
||||||
import { PricingPage } from '@/pages/PricingPage'
|
|
||||||
import { SettingsPage } from '@/pages/SettingsPage'
|
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
|
// IMP-02 FIX: Route code splitting with lazy imports
|
||||||
|
const LandingPage = lazy(() => import('@/pages/LandingPage').then(m => ({ default: m.LandingPage })))
|
||||||
|
const LoginPage = lazy(() => import('@/pages/AuthPages').then(m => ({ default: m.LoginPage })))
|
||||||
|
const SignupPage = lazy(() => import('@/pages/AuthPages').then(m => ({ default: m.SignupPage })))
|
||||||
|
const DashboardPage = lazy(() => import('@/pages/DashboardPage').then(m => ({ default: m.DashboardPage })))
|
||||||
|
const ChatbotBuilderPage = lazy(() => import('@/pages/ChatbotBuilderPage').then(m => ({ default: m.ChatbotBuilderPage })))
|
||||||
|
const MarketplacePage = lazy(() => import('@/pages/MarketplacePage').then(m => ({ default: m.MarketplacePage })))
|
||||||
|
const ChatbotDetailPage = lazy(() => import('@/pages/MarketplacePage').then(m => ({ default: m.ChatbotDetailPage })))
|
||||||
|
const PricingPage = lazy(() => import('@/pages/PricingPage').then(m => ({ default: m.PricingPage })))
|
||||||
|
const SettingsPage = lazy(() => import('@/pages/SettingsPage').then(m => ({ default: m.SettingsPage })))
|
||||||
|
|
||||||
|
const PageLoader = () => (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<Spinner className="text-primary-600" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
const { isAuthenticated } = useAuthStore()
|
const { isAuthenticated } = useAuthStore()
|
||||||
if (!isAuthenticated) return <Navigate to="/login" replace />
|
if (!isAuthenticated) return <Navigate to="/login" replace />
|
||||||
@@ -23,13 +35,27 @@ const PublicOnlyRoute: React.FC<{ children: React.ReactNode }> = ({ children })
|
|||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BUG-07/08 FIX: Smart wrapper that uses AppLayout for authenticated users
|
||||||
|
// and PublicLayout for unauthenticated users, solving the "lost sidebar" issue
|
||||||
|
const SmartPublicRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const { isAuthenticated } = useAuthStore()
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return <AppLayout>{children}</AppLayout>
|
||||||
|
}
|
||||||
|
// R-07 FIX: PublicLayout adds navigation header for unauthenticated users
|
||||||
|
return <PublicLayout>{children}</PublicLayout>
|
||||||
|
}
|
||||||
|
|
||||||
export const App: React.FC = () => (
|
export const App: React.FC = () => (
|
||||||
|
<Suspense fallback={<PageLoader />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Public */}
|
{/* Public - Landing has its own nav */}
|
||||||
<Route path="/" element={<LandingPage />} />
|
<Route path="/" element={<LandingPage />} />
|
||||||
<Route path="/pricing" element={<PricingPage />} />
|
|
||||||
<Route path="/marketplace" element={<MarketplacePage />} />
|
{/* Public pages - wrapped in SmartPublicRoute for proper nav */}
|
||||||
<Route path="/marketplace/:id" element={<ChatbotDetailPage />} />
|
<Route path="/pricing" element={<SmartPublicRoute><PricingPage /></SmartPublicRoute>} />
|
||||||
|
<Route path="/marketplace" element={<SmartPublicRoute><MarketplacePage /></SmartPublicRoute>} />
|
||||||
|
<Route path="/marketplace/:id" element={<SmartPublicRoute><ChatbotDetailPage /></SmartPublicRoute>} />
|
||||||
|
|
||||||
{/* Auth */}
|
{/* Auth */}
|
||||||
<Route path="/login" element={<PublicOnlyRoute><LoginPage /></PublicOnlyRoute>} />
|
<Route path="/login" element={<PublicOnlyRoute><LoginPage /></PublicOnlyRoute>} />
|
||||||
@@ -46,4 +72,5 @@ export const App: React.FC = () => (
|
|||||||
{/* Fallback */}
|
{/* Fallback */}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
)
|
)
|
||||||
@@ -10,20 +10,33 @@ interface ChatInterfaceProps {
|
|||||||
welcomeMessage: string
|
welcomeMessage: string
|
||||||
primaryColor: string
|
primaryColor: string
|
||||||
isPreview?: boolean
|
isPreview?: boolean
|
||||||
|
// BUG-09 FIX: Accept optional sessionId to persist conversations across navigation
|
||||||
|
sessionId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChatInterface: React.FC<ChatInterfaceProps> = ({
|
export const ChatInterface: React.FC<ChatInterfaceProps> = ({
|
||||||
chatbotId, chatbotName, welcomeMessage, primaryColor, isPreview = false
|
chatbotId, chatbotName, welcomeMessage, primaryColor, 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 }
|
||||||
])
|
])
|
||||||
const [input, setInput] = useState('')
|
const [input, setInput] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [sessionId] = useState(() => crypto.randomUUID())
|
|
||||||
|
// BUG-09 FIX: Use provided sessionId or persist in sessionStorage
|
||||||
|
const [sessionId] = useState(() => {
|
||||||
|
if (externalSessionId) return externalSessionId
|
||||||
|
const storageKey = `chat-session-${chatbotId}`
|
||||||
|
const stored = sessionStorage.getItem(storageKey)
|
||||||
|
if (stored) return stored
|
||||||
|
const newId = crypto.randomUUID()
|
||||||
|
sessionStorage.setItem(storageKey, newId)
|
||||||
|
return newId
|
||||||
|
})
|
||||||
|
|
||||||
const [expandedSources, setExpandedSources] = useState<Set<string>>(new Set())
|
const [expandedSources, setExpandedSources] = useState<Set<string>>(new Set())
|
||||||
const bottomRef = useRef<HTMLDivElement>(null)
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
@@ -69,6 +82,14 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IMP-08: Shift+Enter for newline, Enter to send
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const toggleSources = (msgId: string) => {
|
const toggleSources = (msgId: string) => {
|
||||||
setExpandedSources(prev => {
|
setExpandedSources(prev => {
|
||||||
const n = new Set(prev)
|
const n = new Set(prev)
|
||||||
@@ -78,69 +99,54 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-white rounded-xl overflow-hidden border border-gray-200">
|
<div className="flex flex-col h-full bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div
|
<div className="flex items-center gap-3 px-4 py-3 border-b border-gray-100" style={{ background: primaryColor }}>
|
||||||
className="flex items-center gap-3 px-4 py-3 text-white"
|
<div className="w-8 h-8 bg-white/20 rounded-lg flex items-center justify-center">
|
||||||
style={{ background: primaryColor }}
|
<Bot className="w-4 h-4 text-white" />
|
||||||
>
|
|
||||||
<div className="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center">
|
|
||||||
<Bot className="w-4 h-4" />
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-sm">{chatbotName}</p>
|
<h3 className="text-sm font-semibold text-white">{chatbotName}</h3>
|
||||||
<p className="text-xs opacity-80">Online</p>
|
{isPreview && <span className="text-xs text-white/70">Preview mode</span>}
|
||||||
</div>
|
</div>
|
||||||
{isPreview && (
|
|
||||||
<span className="ml-auto bg-white/20 text-white text-xs px-2 py-0.5 rounded-full">
|
|
||||||
Preview
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
{messages.map((msg) => (
|
{messages.map((msg) => (
|
||||||
<div key={msg.id} className={cn('flex gap-3', msg.role === 'user' && 'flex-row-reverse')}>
|
<div key={msg.id} className={cn('flex gap-3', msg.role === 'user' ? 'justify-end' : '')}>
|
||||||
{/* Avatar */}
|
{msg.role === 'assistant' && (
|
||||||
<div className={cn(
|
<div className="w-7 h-7 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5"
|
||||||
'w-7 h-7 rounded-full flex-shrink-0 flex items-center justify-center text-white text-xs',
|
style={{ background: primaryColor }}>
|
||||||
msg.role === 'assistant' ? 'bg-primary-600' : 'bg-gray-500'
|
<Bot className="w-3.5 h-3.5 text-white" />
|
||||||
)}
|
|
||||||
style={msg.role === 'assistant' ? { background: primaryColor } : {}}
|
|
||||||
>
|
|
||||||
{msg.role === 'assistant' ? <Bot className="w-3.5 h-3.5" /> : <User className="w-3.5 h-3.5" />}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={cn('max-w-[80%] space-y-1', msg.role === 'user' && 'items-end')}>
|
|
||||||
<div className={cn(
|
|
||||||
'px-4 py-2.5 rounded-2xl text-sm leading-relaxed',
|
|
||||||
msg.role === 'assistant'
|
|
||||||
? 'bg-gray-100 text-gray-800 rounded-tl-sm'
|
|
||||||
: 'text-white rounded-tr-sm'
|
|
||||||
)}
|
)}
|
||||||
style={msg.role === 'user' ? { background: primaryColor } : {}}
|
<div className={cn(
|
||||||
>
|
'max-w-[80%] rounded-2xl px-4 py-2.5 text-sm',
|
||||||
{msg.content}
|
msg.role === 'user'
|
||||||
</div>
|
? 'bg-primary-600 text-white rounded-br-md'
|
||||||
|
: 'bg-gray-100 text-gray-800 rounded-bl-md'
|
||||||
|
)}>
|
||||||
|
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||||
|
|
||||||
{/* Sources */}
|
{/* Sources */}
|
||||||
{msg.role === 'assistant' && msg.sources && msg.sources.length > 0 && (
|
{msg.sources && msg.sources.length > 0 && (
|
||||||
<div className="mt-1">
|
<div className="mt-2 pt-2 border-t border-gray-200/50">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleSources(msg.id)}
|
onClick={() => toggleSources(msg.id)}
|
||||||
className="flex items-center gap-1 text-xs text-gray-400 hover:text-gray-600"
|
className="flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700"
|
||||||
>
|
>
|
||||||
<FileText className="w-3 h-3" />
|
<FileText className="w-3 h-3" />
|
||||||
{msg.sources.length} source{msg.sources.length > 1 ? 's' : ''}
|
{msg.sources.length} source{msg.sources.length > 1 ? 's' : ''}
|
||||||
{expandedSources.has(msg.id) ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
{expandedSources.has(msg.id) ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
||||||
</button>
|
</button>
|
||||||
{expandedSources.has(msg.id) && (
|
{expandedSources.has(msg.id) && (
|
||||||
<div className="mt-2 space-y-1.5">
|
<div className="mt-2 space-y-2">
|
||||||
{msg.sources.map((s, i) => (
|
{msg.sources.map((src, i) => (
|
||||||
<div key={i} className="bg-gray-50 border border-gray-200 rounded-lg p-2.5 text-xs">
|
<div key={i} className="bg-white/80 rounded-lg p-2 text-xs">
|
||||||
<p className="font-medium text-gray-700 mb-1">📄 {s.document_name}</p>
|
<p className="font-medium text-gray-700">{src.document_name}</p>
|
||||||
<p className="text-gray-500 line-clamp-2">{s.chunk_text}</p>
|
<p className="text-gray-500 mt-0.5 line-clamp-2">{src.chunk_text}</p>
|
||||||
|
<p className="text-gray-400 mt-0.5">Relevance: {(src.score * 100).toFixed(0)}%</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -148,49 +154,56 @@ 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-full flex-shrink-0 flex items-center justify-center text-white"
|
<div className="w-7 h-7 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||||
style={{ background: primaryColor }}>
|
style={{ background: primaryColor }}>
|
||||||
<Bot className="w-3.5 h-3.5" />
|
<Bot className="w-3.5 h-3.5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-100 rounded-2xl rounded-tl-sm 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">
|
||||||
<span className="w-1.5 h-1.5 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' }} />
|
||||||
<span className="w-1.5 h-1.5 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||||
<span className="w-1.5 h-1.5 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Input */}
|
{/* Input */}
|
||||||
<div className="px-4 py-3 border-t border-gray-100">
|
<div className="border-t border-gray-100 p-3">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 items-end">
|
||||||
<input
|
<textarea
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && send()}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Type a message..."
|
placeholder="Type your message..."
|
||||||
className="flex-1 px-3 py-2 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
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"
|
||||||
|
style={{ minHeight: '38px' }}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={send}
|
onClick={send}
|
||||||
disabled={!input.trim() || loading}
|
disabled={!input.trim() || loading}
|
||||||
className="p-2 rounded-xl text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="p-2 rounded-xl text-white transition-all disabled:opacity-50 disabled:cursor-not-allowed hover:opacity-90"
|
||||||
style={{ background: primaryColor }}
|
style={{ background: primaryColor }}
|
||||||
>
|
>
|
||||||
<Send className="w-4 h-4" />
|
<Send className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-center text-xs text-gray-400 mt-2">Powered by Contexta</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
116
src/components/PublicLayout.tsx
Normal file
116
src/components/PublicLayout.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
|
import { Sparkles, Menu, X } from 'lucide-react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* R-07 FIX: PublicLayout provides navigation for unauthenticated users
|
||||||
|
* on public pages (Marketplace, Pricing, ChatbotDetail).
|
||||||
|
* Previously these pages had NO navigation header, making it impossible
|
||||||
|
* for unauthenticated users to navigate between public pages.
|
||||||
|
*/
|
||||||
|
export const PublicLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const location = useLocation()
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||||
|
|
||||||
|
const isActive = (path: string) => location.pathname.startsWith(path)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
{/* Navigation Header */}
|
||||||
|
<nav className="bg-white border-b border-gray-200 sticky top-0 z-50">
|
||||||
|
<div className="max-w-6xl mx-auto px-4 sm:px-6">
|
||||||
|
<div className="flex items-center justify-between h-14">
|
||||||
|
{/* Logo */}
|
||||||
|
<Link to="/" className="flex items-center gap-2 group">
|
||||||
|
<div className="w-8 h-8 bg-gradient-to-br from-primary-500 to-primary-700 rounded-lg flex items-center justify-center
|
||||||
|
shadow-sm group-hover:shadow-md transition-shadow">
|
||||||
|
<Sparkles className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-gray-900 text-lg tracking-tight">Contexta</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Desktop nav links */}
|
||||||
|
<div className="hidden md:flex items-center gap-6">
|
||||||
|
<Link
|
||||||
|
to="/marketplace"
|
||||||
|
className={`text-sm font-medium transition-colors ${
|
||||||
|
isActive('/marketplace') ? 'text-primary-600' : 'text-gray-600 hover:text-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Marketplace
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/pricing"
|
||||||
|
className={`text-sm font-medium transition-colors ${
|
||||||
|
isActive('/pricing') ? 'text-primary-600' : 'text-gray-600 hover:text-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Pricing
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auth buttons (desktop) */}
|
||||||
|
<div className="hidden md:flex items-center gap-3">
|
||||||
|
<Link to="/login" className="text-sm text-gray-600 hover:text-gray-900 font-medium px-3 py-1.5 transition-colors">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/signup"
|
||||||
|
className="bg-primary-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-primary-700 font-semibold transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
Get started free
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile hamburger */}
|
||||||
|
<button
|
||||||
|
className="md:hidden p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||||
|
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||||
|
aria-label="Toggle menu"
|
||||||
|
>
|
||||||
|
{mobileMenuOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile menu */}
|
||||||
|
{mobileMenuOpen && (
|
||||||
|
<div className="md:hidden border-t border-gray-100 py-3 space-y-1 animate-fade-in-down">
|
||||||
|
<Link
|
||||||
|
to="/marketplace"
|
||||||
|
className="block px-3 py-2 text-sm text-gray-700 font-medium rounded-lg hover:bg-gray-50"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
Marketplace
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/pricing"
|
||||||
|
className="block px-3 py-2 text-sm text-gray-700 font-medium rounded-lg hover:bg-gray-50"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
Pricing
|
||||||
|
</Link>
|
||||||
|
<hr className="border-gray-100 my-2" />
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="block px-3 py-2 text-sm text-gray-700 font-medium rounded-lg hover:bg-gray-50"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/signup"
|
||||||
|
className="block mx-3 bg-primary-600 text-white text-sm px-4 py-2.5 rounded-lg font-semibold text-center"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
Get started free
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Page content */}
|
||||||
|
<main>{children}</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
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'
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
@@ -58,6 +59,18 @@ 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 {
|
||||||
|
const [debouncedValue, setDebouncedValue] = useState<T>(value)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setDebouncedValue(value), delay)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [value, delay])
|
||||||
|
|
||||||
|
return debouncedValue
|
||||||
|
}
|
||||||
|
|
||||||
export const AVAILABLE_MODELS = [
|
export const AVAILABLE_MODELS = [
|
||||||
{
|
{
|
||||||
id: 'accounts/fireworks/models/llama-v3p1-70b-instruct',
|
id: 'accounts/fireworks/models/llama-v3p1-70b-instruct',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState, useCallback } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { chatbotsAPI } from '@/services/api'
|
import { chatbotsAPI } from '@/services/api'
|
||||||
@@ -11,12 +11,31 @@ import {
|
|||||||
Settings, Upload, Eye, ExternalLink, Download, BarChart2
|
Settings, Upload, Eye, ExternalLink, Download, BarChart2
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
// BUG-05 FIX: Toast queue system using array + auto-dismiss
|
||||||
|
interface ToastItem {
|
||||||
|
id: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
export const DashboardPage: React.FC = () => {
|
export const DashboardPage: React.FC = () => {
|
||||||
const { user } = useAuthStore()
|
const { user } = useAuthStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||||
const [toast, setToast] = useState<string>('')
|
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||||
|
|
||||||
|
// BUG-05 FIX: Queue-based toast - no overwrites
|
||||||
|
const showToast = useCallback((message: string) => {
|
||||||
|
const id = crypto.randomUUID()
|
||||||
|
setToasts(prev => [...prev, { id, message }])
|
||||||
|
setTimeout(() => {
|
||||||
|
setToasts(prev => prev.filter(t => t.id !== id))
|
||||||
|
}, 3000)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const removeToast = useCallback((id: string) => {
|
||||||
|
setToasts(prev => prev.filter(t => t.id !== id))
|
||||||
|
}, [])
|
||||||
|
|
||||||
const { data: chatbots = [], isLoading } = useQuery({
|
const { data: chatbots = [], isLoading } = useQuery({
|
||||||
queryKey: ['chatbots'],
|
queryKey: ['chatbots'],
|
||||||
@@ -28,7 +47,7 @@ export const DashboardPage: React.FC = () => {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['chatbots'] })
|
queryClient.invalidateQueries({ queryKey: ['chatbots'] })
|
||||||
setDeleteId(null)
|
setDeleteId(null)
|
||||||
setToast('Chatbot deleted')
|
showToast('Chatbot deleted')
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -36,71 +55,44 @@ export const DashboardPage: React.FC = () => {
|
|||||||
mutationFn: chatbotsAPI.publish,
|
mutationFn: chatbotsAPI.publish,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['chatbots'] })
|
queryClient.invalidateQueries({ queryKey: ['chatbots'] })
|
||||||
setToast('Chatbot published to marketplace!')
|
showToast('Chatbot published to marketplace!')
|
||||||
},
|
},
|
||||||
onError: (err: any) => setToast(err.response?.data?.detail || 'Failed to publish'),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const unpublishMutation = useMutation({
|
const unpublishMutation = useMutation({
|
||||||
mutationFn: chatbotsAPI.unpublish,
|
mutationFn: chatbotsAPI.unpublish,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['chatbots'] })
|
queryClient.invalidateQueries({ queryKey: ['chatbots'] })
|
||||||
setToast('Chatbot unpublished')
|
showToast('Chatbot unpublished')
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// IMP-11: Confirmation before publish/unpublish
|
||||||
|
const [confirmAction, setConfirmAction] = useState<{ type: 'publish' | 'unpublish'; id: string } | null>(null)
|
||||||
|
|
||||||
|
const handleConfirmAction = () => {
|
||||||
|
if (!confirmAction) return
|
||||||
|
if (confirmAction.type === 'publish') {
|
||||||
|
publishMutation.mutate(confirmAction.id)
|
||||||
|
} else {
|
||||||
|
unpublishMutation.mutate(confirmAction.id)
|
||||||
|
}
|
||||||
|
setConfirmAction(null)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-6xl mx-auto">
|
<div className="p-4 sm:p-6">
|
||||||
{/* Header */}
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-6 gap-3">
|
||||||
<div className="flex items-center justify-between mb-8">
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">
|
<h1 className="text-xl sm:text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||||
Good {getGreeting()}, {user?.company_name || 'there'} 👋
|
<p className="text-sm text-gray-500 mt-0.5">Manage your AI chatbots</p>
|
||||||
</h1>
|
|
||||||
<p className="text-gray-500 text-sm mt-1">Manage your AI chatbots</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => navigate('/chatbots/new')} size="lg">
|
<Button onClick={() => navigate('/chatbots/new')}>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
New Chatbot
|
New Chatbot
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
|
||||||
{[
|
|
||||||
{ label: 'Total Chatbots', value: chatbots.length, icon: '🤖' },
|
|
||||||
{ label: 'Published', value: chatbots.filter(c => c.is_published).length, icon: '🌐' },
|
|
||||||
{ label: 'Documents', value: chatbots.reduce((sum, c) => sum + c.document_count, 0), icon: '📄' },
|
|
||||||
{ label: 'Conversations', value: chatbots.reduce((sum, c) => sum + c.conversation_count, 0), icon: '💬' },
|
|
||||||
].map(({ label, value, icon }) => (
|
|
||||||
<Card key={label} className="p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-2xl">{icon}</span>
|
|
||||||
<div>
|
|
||||||
<p className="text-2xl font-bold text-gray-900">{value}</p>
|
|
||||||
<p className="text-xs text-gray-500">{label}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Plan notice */}
|
|
||||||
{user?.plan === 'free' && (
|
|
||||||
<div className="mb-6 p-4 bg-gradient-to-r from-primary-50 to-purple-50 border border-primary-200 rounded-xl flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-semibold text-primary-900">You're on the Free plan</p>
|
|
||||||
<p className="text-xs text-primary-700 mt-0.5">
|
|
||||||
Upgrade to publish chatbots to the marketplace and unlock premium AI models
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Link to="/pricing">
|
|
||||||
<Button size="sm">Upgrade</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Chatbots Grid */}
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<Spinner className="text-primary-600" />
|
<Spinner className="text-primary-600" />
|
||||||
@@ -118,15 +110,16 @@ export const DashboardPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
// R-02 FIX: Better responsive grid breakpoints
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{chatbots.map((chatbot) => (
|
{chatbots.map((chatbot) => (
|
||||||
<ChatbotCard
|
<ChatbotCard
|
||||||
key={chatbot.id}
|
key={chatbot.id}
|
||||||
chatbot={chatbot}
|
chatbot={chatbot}
|
||||||
onEdit={() => navigate(`/chatbots/${chatbot.id}/edit`)}
|
onEdit={() => navigate(`/chatbots/${chatbot.id}/edit`)}
|
||||||
onPreview={() => navigate(`/chatbots/${chatbot.id}/preview`)}
|
onPreview={() => navigate(`/chatbots/${chatbot.id}/preview`)}
|
||||||
onPublish={() => publishMutation.mutate(chatbot.id)}
|
onPublish={() => setConfirmAction({ type: 'publish', id: chatbot.id })}
|
||||||
onUnpublish={() => unpublishMutation.mutate(chatbot.id)}
|
onUnpublish={() => setConfirmAction({ type: 'unpublish', id: chatbot.id })}
|
||||||
onDelete={() => setDeleteId(chatbot.id)}
|
onDelete={() => setDeleteId(chatbot.id)}
|
||||||
onAnalytics={() => navigate(`/chatbots/${chatbot.id}/analytics`)}
|
onAnalytics={() => navigate(`/chatbots/${chatbot.id}/analytics`)}
|
||||||
/>
|
/>
|
||||||
@@ -170,13 +163,43 @@ export const DashboardPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Toast */}
|
{/* IMP-11: Publish/Unpublish confirmation */}
|
||||||
{toast && (
|
<Modal
|
||||||
<div className="fixed bottom-4 right-4 bg-gray-900 text-white px-4 py-2 rounded-lg text-sm shadow-lg z-50">
|
isOpen={!!confirmAction}
|
||||||
{toast}
|
onClose={() => setConfirmAction(null)}
|
||||||
<button onClick={() => setToast('')} className="ml-3 opacity-60 hover:opacity-100">×</button>
|
title={confirmAction?.type === 'publish' ? 'Publish Chatbot' : 'Unpublish Chatbot'}
|
||||||
|
>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
{confirmAction?.type === 'publish'
|
||||||
|
? 'This will make your chatbot publicly visible on the marketplace. Are you sure?'
|
||||||
|
: 'This will remove your chatbot from the marketplace. Users will no longer be able to access it.'}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button variant="outline" onClick={() => setConfirmAction(null)} className="flex-1">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirmAction}
|
||||||
|
loading={publishMutation.isPending || unpublishMutation.isPending}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{confirmAction?.type === 'publish' ? 'Publish' : 'Unpublish'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* BUG-05 FIX: Toast queue - renders all active toasts */}
|
||||||
|
<div className="fixed bottom-4 right-4 z-50 space-y-2">
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<div
|
||||||
|
key={toast.id}
|
||||||
|
className="bg-gray-900 text-white px-4 py-2 rounded-lg text-sm shadow-lg animate-fade-in-up flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{toast.message}
|
||||||
|
<button onClick={() => removeToast(toast.id)} className="opacity-60 hover:opacity-100">×</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -213,6 +236,7 @@ const ChatbotCard: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* R-02 FIX: Menu dropdown with viewport-aware positioning */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setMenuOpen(!menuOpen)}
|
onClick={() => setMenuOpen(!menuOpen)}
|
||||||
@@ -223,7 +247,7 @@ const ChatbotCard: React.FC<{
|
|||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} />
|
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} />
|
||||||
<div className="absolute right-0 mt-1 w-44 bg-white border border-gray-200 rounded-xl shadow-lg z-20 overflow-hidden text-sm">
|
<div className="absolute right-0 mt-1 w-44 bg-white border border-gray-200 rounded-xl shadow-lg z-20 overflow-hidden text-sm max-h-64 overflow-y-auto">
|
||||||
<button onClick={() => { onEdit(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2 hover:bg-gray-50 text-left">
|
<button onClick={() => { onEdit(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2 hover:bg-gray-50 text-left">
|
||||||
<Settings className="w-3.5 h-3.5" /> Edit Settings
|
<Settings className="w-3.5 h-3.5" /> Edit Settings
|
||||||
</button>
|
</button>
|
||||||
@@ -234,6 +258,16 @@ const ChatbotCard: React.FC<{
|
|||||||
<BarChart2 className="w-3.5 h-3.5" /> Analytics
|
<BarChart2 className="w-3.5 h-3.5" /> Analytics
|
||||||
</button>
|
</button>
|
||||||
<div className="h-px bg-gray-100" />
|
<div className="h-px bg-gray-100" />
|
||||||
|
{chatbot.is_published ? (
|
||||||
|
<button onClick={() => { onUnpublish(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2 hover:bg-orange-50 text-orange-600 text-left">
|
||||||
|
<Lock className="w-3.5 h-3.5" /> Unpublish
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => { onPublish(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2 hover:bg-green-50 text-green-600 text-left">
|
||||||
|
<Globe className="w-3.5 h-3.5" /> Publish
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="h-px bg-gray-100" />
|
||||||
<button onClick={() => { onDelete(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2 hover:bg-red-50 text-red-600 text-left">
|
<button onClick={() => { onDelete(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2 hover:bg-red-50 text-red-600 text-left">
|
||||||
<Trash2 className="w-3.5 h-3.5" /> Delete
|
<Trash2 className="w-3.5 h-3.5" /> Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -248,7 +282,7 @@ const ChatbotCard: React.FC<{
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="flex gap-3 mb-4 text-xs text-gray-500">
|
<div className="flex flex-wrap gap-3 mb-4 text-xs text-gray-500">
|
||||||
<span>📄 {chatbot.document_count} docs</span>
|
<span>📄 {chatbot.document_count} docs</span>
|
||||||
<span>💬 {chatbot.conversation_count} chats</span>
|
<span>💬 {chatbot.conversation_count} chats</span>
|
||||||
{chatbot.category && <span>🏷 {chatbot.category}</span>}
|
{chatbot.category && <span>🏷 {chatbot.category}</span>}
|
||||||
@@ -266,22 +300,12 @@ const ChatbotCard: React.FC<{
|
|||||||
Preview
|
Preview
|
||||||
</Button>
|
</Button>
|
||||||
{chatbot.is_published ? (
|
{chatbot.is_published ? (
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={onUnpublish} className="flex-1">
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={onUnpublish}
|
|
||||||
className="flex-1 text-orange-600 hover:bg-orange-50"
|
|
||||||
>
|
|
||||||
<Lock className="w-3.5 h-3.5" />
|
<Lock className="w-3.5 h-3.5" />
|
||||||
Unpublish
|
Unpublish
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button size="sm" onClick={onPublish} className="flex-1">
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
onClick={onPublish}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
<Globe className="w-3.5 h-3.5" />
|
<Globe className="w-3.5 h-3.5" />
|
||||||
Publish
|
Publish
|
||||||
</Button>
|
</Button>
|
||||||
@@ -290,10 +314,3 @@ const ChatbotCard: React.FC<{
|
|||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGreeting() {
|
|
||||||
const h = new Date().getHours()
|
|
||||||
if (h < 12) return 'morning'
|
|
||||||
if (h < 17) return 'afternoon'
|
|
||||||
return 'evening'
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate, useParams, Link } from 'react-router-dom'
|
||||||
import { marketplaceAPI } from '@/services/api'
|
import { marketplaceAPI, chatAPI } from '@/services/api'
|
||||||
import { Card, Spinner, EmptyState, Button } from '@/components/ui'
|
import { Card, Spinner, EmptyState, Button } from '@/components/ui'
|
||||||
import { CATEGORIES, INDUSTRIES } from '@/lib/utils'
|
import { ChatInterface } from '@/components/ChatInterface'
|
||||||
import { Search, Bot, Star, MessageSquare } from 'lucide-react'
|
import { CATEGORIES, INDUSTRIES, useDebounce } from '@/lib/utils'
|
||||||
|
import { Search, Bot, Star, MessageSquare, ArrowLeft } from 'lucide-react'
|
||||||
import type { ChatbotPublic } from '@/types'
|
import type { ChatbotPublic } from '@/types'
|
||||||
|
|
||||||
export const MarketplacePage: React.FC = () => {
|
export const MarketplacePage: React.FC = () => {
|
||||||
@@ -14,15 +15,18 @@ export const MarketplacePage: React.FC = () => {
|
|||||||
const [industry, setIndustry] = useState('')
|
const [industry, setIndustry] = useState('')
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
|
// IMP-07 FIX: Debounce search input by 300ms
|
||||||
|
const debouncedSearch = useDebounce(search, 300)
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['marketplace', search, category, industry, page],
|
queryKey: ['marketplace', debouncedSearch, category, industry, page],
|
||||||
queryFn: () => marketplaceAPI.list({ search, category, industry, page, limit: 20 }),
|
queryFn: () => marketplaceAPI.list({ search: debouncedSearch, category, industry, page, limit: 20 }),
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-6xl mx-auto">
|
<div className="p-4 sm:p-6 max-w-6xl mx-auto">
|
||||||
<div className="mb-8">
|
<div className="mb-6 sm:mb-8">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">AI Chatbot Marketplace</h1>
|
<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>
|
<p className="text-gray-500 mt-1 text-sm">Discover and interact with AI chatbots from companies worldwide</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -63,13 +67,11 @@ export const MarketplacePage: React.FC = () => {
|
|||||||
icon={<Bot className="w-8 h-8" />}
|
icon={<Bot className="w-8 h-8" />}
|
||||||
title="No chatbots found"
|
title="No chatbots found"
|
||||||
description="Be the first to publish your AI chatbot to the marketplace!"
|
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">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
|
{data.chatbots.map((chatbot) => (
|
||||||
{data.chatbots.map(chatbot => (
|
|
||||||
<ChatbotMarketplaceCard
|
<ChatbotMarketplaceCard
|
||||||
key={chatbot.id}
|
key={chatbot.id}
|
||||||
chatbot={chatbot}
|
chatbot={chatbot}
|
||||||
@@ -79,15 +81,23 @@ export const MarketplacePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{data.total > 20 && (
|
{data.has_more && (
|
||||||
<div className="flex justify-center gap-2">
|
<div className="flex justify-center mt-8 gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page === 1}
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
Previous
|
Previous
|
||||||
</Button>
|
</Button>
|
||||||
<span className="flex items-center px-3 text-sm text-gray-600">
|
<span className="px-3 py-1.5 text-sm text-gray-500">Page {page}</span>
|
||||||
Page {page} of {Math.ceil(data.total / 20)}
|
<Button
|
||||||
</span>
|
variant="outline"
|
||||||
<Button variant="outline" size="sm" onClick={() => setPage(p => p + 1)} disabled={!data.has_more}>
|
size="sm"
|
||||||
|
disabled={!data.has_more}
|
||||||
|
onClick={() => setPage(p => p + 1)}
|
||||||
|
>
|
||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,10 +109,13 @@ export const MarketplacePage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ChatbotMarketplaceCard: React.FC<{ chatbot: ChatbotPublic; onClick: () => void }> = ({ chatbot, onClick }) => (
|
const ChatbotMarketplaceCard: React.FC<{ chatbot: ChatbotPublic; onClick: () => void }> = ({ chatbot, onClick }) => (
|
||||||
<Card className="p-5 cursor-pointer hover:shadow-md transition-shadow" onClick={onClick}>
|
<Card
|
||||||
<div className="flex items-start gap-3 mb-3">
|
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
|
<div
|
||||||
className="w-10 h-10 rounded-xl flex items-center justify-center text-white flex-shrink-0"
|
className="w-10 h-10 rounded-xl flex items-center justify-center text-white"
|
||||||
style={{ background: chatbot.primary_color }}
|
style={{ background: chatbot.primary_color }}
|
||||||
>
|
>
|
||||||
<Bot className="w-5 h-5" />
|
<Bot className="w-5 h-5" />
|
||||||
@@ -116,71 +129,95 @@ const ChatbotMarketplaceCard: React.FC<{ chatbot: ChatbotPublic; onClick: () =>
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{chatbot.description && (
|
{chatbot.description && (
|
||||||
<p className="text-xs text-gray-600 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 flex-wrap gap-1.5 mb-3">
|
<div className="flex items-center gap-3 text-xs text-gray-400">
|
||||||
{chatbot.category && (
|
{chatbot.average_rating && chatbot.average_rating > 0 && (
|
||||||
<span className="text-xs bg-primary-50 text-primary-700 px-2 py-0.5 rounded-full">
|
<span className="flex items-center gap-1">
|
||||||
{chatbot.category}
|
<Star className="w-3 h-3 text-yellow-400 fill-yellow-400" />
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{chatbot.industry && (
|
|
||||||
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">
|
|
||||||
{chatbot.industry}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-xs text-gray-500">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{chatbot.average_rating && (
|
|
||||||
<span className="flex items-center gap-0.5">
|
|
||||||
<Star className="w-3 h-3 fill-yellow-400 text-yellow-400" />
|
|
||||||
{chatbot.average_rating.toFixed(1)}
|
{chatbot.average_rating.toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="flex items-center gap-0.5">
|
<span className="flex items-center gap-1">
|
||||||
<MessageSquare className="w-3 h-3" />
|
<MessageSquare className="w-3 h-3" />
|
||||||
{chatbot.total_conversations}
|
{chatbot.total_conversations} chats
|
||||||
</span>
|
</span>
|
||||||
</div>
|
{chatbot.category && <span className="truncate">🏷 {chatbot.category}</span>}
|
||||||
<span className="text-primary-600 font-medium">Chat →</span>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|
||||||
// ─── Chatbot Detail / Chat Page ────────────────────────────────────────────────
|
|
||||||
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 } = 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 (isLoading) {
|
||||||
if (!chatbot) return <div className="text-center py-20 text-gray-500">Chatbot not found</div>
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-4xl mx-auto">
|
<div className="p-4 sm:p-6 max-w-4xl mx-auto">
|
||||||
<div className="flex items-center gap-4 mb-6">
|
{/* Back link */}
|
||||||
<button onClick={() => navigate('/marketplace')} className="p-1.5 hover:bg-gray-100 rounded-lg">
|
<button
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
onClick={() => navigate('/marketplace')}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 mb-4 transition-colors"
|
||||||
</svg>
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Back to Marketplace
|
||||||
</button>
|
</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>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-gray-900">{chatbot.name}</h1>
|
<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.company_name && (
|
||||||
|
<p className="text-sm text-gray-500">by {chatbot.company_name}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
{chatbot.description && (
|
||||||
{/* Chat */}
|
<p className="text-gray-600 text-sm mb-6">{chatbot.description}</p>
|
||||||
<div className="lg:col-span-2 h-[600px]">
|
)}
|
||||||
|
|
||||||
|
{/* 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
|
<ChatInterface
|
||||||
chatbotId={chatbot.id}
|
chatbotId={chatbot.id}
|
||||||
chatbotName={chatbot.name}
|
chatbotName={chatbot.name}
|
||||||
@@ -188,50 +225,6 @@ export const ChatbotDetailPage: React.FC = () => {
|
|||||||
primaryColor={chatbot.primary_color}
|
primaryColor={chatbot.primary_color}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info sidebar */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Card className="p-4">
|
|
||||||
<h3 className="font-semibold text-gray-900 text-sm mb-3">About</h3>
|
|
||||||
{chatbot.description && <p className="text-sm text-gray-600 mb-3">{chatbot.description}</p>}
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
{chatbot.category && (
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-500">Category</span>
|
|
||||||
<span className="font-medium">{chatbot.category}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{chatbot.industry && (
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-500">Industry</span>
|
|
||||||
<span className="font-medium">{chatbot.industry}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-500">Languages</span>
|
|
||||||
<span className="font-medium uppercase">{chatbot.languages?.join(', ')}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-500">Conversations</span>
|
|
||||||
<span className="font-medium">{chatbot.total_conversations}</span>
|
|
||||||
</div>
|
|
||||||
{chatbot.average_rating && (
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-500">Rating</span>
|
|
||||||
<span className="font-medium flex items-center gap-1">
|
|
||||||
<Star className="w-3.5 h-3.5 fill-yellow-400 text-yellow-400" />
|
|
||||||
{chatbot.average_rating.toFixed(1)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import needed for ChatbotDetailPage
|
|
||||||
import { useParams } from 'react-router-dom'
|
|
||||||
import { ChatInterface } from '@/components/ChatInterface'
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate, Link } from 'react-router-dom'
|
||||||
import { useMutation } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { billingAPI } from '@/services/api'
|
import { billingAPI } from '@/services/api'
|
||||||
import { useAuthStore } from '@/store/authStore'
|
import { useAuthStore } from '@/store/authStore'
|
||||||
import { Button, Card } from '@/components/ui'
|
import { Button, Card } from '@/components/ui'
|
||||||
@@ -91,6 +91,15 @@ export const PricingPage: React.FC = () => {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [loading, setLoading] = useState<string | null>(null)
|
const [loading, setLoading] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// BUG-10 FIX: Fetch current subscription to show "Current Plan" badge
|
||||||
|
const { data: subscription } = useQuery({
|
||||||
|
queryKey: ['subscription'],
|
||||||
|
queryFn: billingAPI.getSubscription,
|
||||||
|
enabled: !!user, // Only fetch if logged in
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentPlan = subscription?.plan || user?.plan || 'free'
|
||||||
|
|
||||||
const handleSubscribe = async (planId: string) => {
|
const handleSubscribe = async (planId: string) => {
|
||||||
if (!user) { navigate('/login'); return }
|
if (!user) { navigate('/login'); return }
|
||||||
if (planId === 'enterprise') {
|
if (planId === 'enterprise') {
|
||||||
@@ -101,6 +110,8 @@ export const PricingPage: React.FC = () => {
|
|||||||
navigate('/dashboard')
|
navigate('/dashboard')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// BUG-10 FIX: Don't allow re-subscribing to current plan
|
||||||
|
if (planId === currentPlan) return
|
||||||
|
|
||||||
setLoading(planId)
|
setLoading(planId)
|
||||||
try {
|
try {
|
||||||
@@ -117,6 +128,17 @@ export const PricingPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to determine CTA text
|
||||||
|
const getCtaText = (planId: string): string => {
|
||||||
|
if (!user) return planId === 'enterprise' ? 'Contact Sales' : 'Get Started'
|
||||||
|
if (planId === currentPlan) return 'Current Plan'
|
||||||
|
if (planId === 'enterprise') return 'Contact Sales'
|
||||||
|
if (planId === 'free') return 'Downgrade'
|
||||||
|
return 'Upgrade'
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCurrentPlan = (planId: string) => user && planId === currentPlan
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-6xl mx-auto">
|
<div className="p-6 max-w-6xl mx-auto">
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
@@ -126,6 +148,7 @@ export const PricingPage: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* R-06 FIX: Better grid breakpoints - md:grid-cols-2 instead of jumping to 4 */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6">
|
||||||
{PLANS.map((plan) => (
|
{PLANS.map((plan) => (
|
||||||
<div
|
<div
|
||||||
@@ -133,10 +156,20 @@ export const PricingPage: React.FC = () => {
|
|||||||
className={`relative rounded-2xl border p-6 flex flex-col ${
|
className={`relative rounded-2xl border p-6 flex flex-col ${
|
||||||
plan.highlighted
|
plan.highlighted
|
||||||
? 'border-primary-400 bg-gradient-to-b from-primary-50 to-white shadow-lg shadow-primary-100'
|
? 'border-primary-400 bg-gradient-to-b from-primary-50 to-white shadow-lg shadow-primary-100'
|
||||||
|
: isCurrentPlan(plan.id)
|
||||||
|
? 'border-primary-300 bg-primary-50/30 shadow-sm'
|
||||||
: 'border-gray-200 bg-white'
|
: 'border-gray-200 bg-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{plan.badge && (
|
{/* BUG-10 FIX: Show "Current Plan" badge */}
|
||||||
|
{isCurrentPlan(plan.id) && (
|
||||||
|
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||||
|
<span className="bg-green-600 text-white text-xs font-semibold px-3 py-1 rounded-full">
|
||||||
|
Current Plan
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{plan.badge && !isCurrentPlan(plan.id) && (
|
||||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||||
<span className="bg-primary-600 text-white text-xs font-semibold px-3 py-1 rounded-full">
|
<span className="bg-primary-600 text-white text-xs font-semibold px-3 py-1 rounded-full">
|
||||||
{plan.badge}
|
{plan.badge}
|
||||||
@@ -151,42 +184,41 @@ export const PricingPage: React.FC = () => {
|
|||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
{plan.price !== null ? (
|
{plan.price !== null ? (
|
||||||
<div className="flex items-baseline gap-1">
|
<div className="flex items-baseline gap-1">
|
||||||
<span className="text-3xl font-bold text-gray-900">${plan.price}</span>
|
<span className="text-4xl font-extrabold text-gray-900">${plan.price}</span>
|
||||||
<span className="text-gray-500 text-sm">/month</span>
|
{plan.price > 0 && <span className="text-gray-500 text-sm">/month</span>}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-2xl font-bold text-gray-900">Custom</span>
|
<div className="text-2xl font-bold text-gray-900">Custom</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="space-y-2.5 flex-1 mb-6">
|
<div className="flex-1">
|
||||||
{plan.features.map(({ text, included }) => (
|
<ul className="space-y-3 mb-8">
|
||||||
<li key={text} className="flex items-start gap-2.5">
|
{plan.features.map((feature) => (
|
||||||
<div className={`w-4 h-4 rounded-full flex-shrink-0 mt-0.5 flex items-center justify-center ${
|
<li key={feature.text} className="flex items-start gap-2 text-sm">
|
||||||
included ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-400'
|
<div className={`w-4 h-4 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5 ${
|
||||||
|
feature.included ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-400'
|
||||||
}`}>
|
}`}>
|
||||||
{included ? <Check className="w-2.5 h-2.5" /> : <span className="text-xs">–</span>}
|
<Check className="w-2.5 h-2.5" />
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-sm ${included ? 'text-gray-700' : 'text-gray-400'}`}>{text}</span>
|
<span className={feature.included ? 'text-gray-700' : 'text-gray-400'}>
|
||||||
|
{feature.text}
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={plan.highlighted ? 'primary' : 'outline'}
|
|
||||||
className="w-full"
|
|
||||||
loading={loading === plan.id}
|
|
||||||
onClick={() => handleSubscribe(plan.id)}
|
onClick={() => handleSubscribe(plan.id)}
|
||||||
disabled={user?.plan === plan.id}
|
loading={loading === plan.id}
|
||||||
|
// BUG-10 FIX: Disable CTA for current plan
|
||||||
|
disabled={isCurrentPlan(plan.id) || loading === plan.id}
|
||||||
|
variant={plan.highlighted ? 'default' : 'outline'}
|
||||||
|
className="w-full"
|
||||||
>
|
>
|
||||||
{user?.plan === plan.id
|
{getCtaText(plan.id)}
|
||||||
? 'Current Plan'
|
|
||||||
: plan.price === null
|
|
||||||
? 'Contact Sales'
|
|
||||||
: plan.price === 0
|
|
||||||
? 'Get Started Free'
|
|
||||||
: `Subscribe – $${plan.price}/mo`}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -194,16 +226,12 @@ export const PricingPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* FAQ */}
|
{/* FAQ */}
|
||||||
<div className="mt-16 max-w-2xl mx-auto">
|
<div className="mt-16 max-w-2xl mx-auto">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-6 text-center">Frequently Asked Questions</h2>
|
<h2 className="text-xl font-bold text-gray-900 text-center mb-8">Frequently Asked Questions</h2>
|
||||||
<div className="space-y-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
q: 'What is preview mode?',
|
q: 'Can I use the free tier forever?',
|
||||||
a: 'Preview mode lets you build and test your chatbot for free with unlimited conversations. Only you (and people you share the link with) can access it until you publish.'
|
a: 'Yes! Build and test unlimited chatbots for free. Your chatbots will remain in preview mode but will be removed from the marketplace.'
|
||||||
},
|
|
||||||
{
|
|
||||||
q: 'Can I cancel anytime?',
|
|
||||||
a: 'Yes, you can cancel anytime. Your chatbots will remain in preview mode but will be removed from the marketplace.'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: 'What is code export?',
|
q: 'What is code export?',
|
||||||
@@ -213,6 +241,10 @@ export const PricingPage: React.FC = () => {
|
|||||||
q: 'Do I need my own API keys?',
|
q: 'Do I need my own API keys?',
|
||||||
a: 'No! Your API keys are handled by Contexta. However, if you export the code, you\'ll need your own API keys for self-hosted deployment.'
|
a: 'No! Your API keys are handled by Contexta. However, if you export the code, you\'ll need your own API keys for self-hosted deployment.'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
q: 'Can I cancel anytime?',
|
||||||
|
a: 'Yes, you can cancel your subscription anytime. Your chatbots will revert to preview mode at the end of your billing period.'
|
||||||
|
},
|
||||||
].map(({ q, a }) => (
|
].map(({ q, a }) => (
|
||||||
<div key={q} className="bg-white border border-gray-200 rounded-xl p-4">
|
<div key={q} className="bg-white border border-gray-200 rounded-xl p-4">
|
||||||
<h3 className="font-semibold text-gray-900 text-sm mb-1">{q}</h3>
|
<h3 className="font-semibold text-gray-900 text-sm mb-1">{q}</h3>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
import { useQuery, useMutation } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate, Link } from 'react-router-dom'
|
import { useNavigate, useLocation, Link } from 'react-router-dom'
|
||||||
import { billingAPI } from '@/services/api'
|
import { billingAPI } from '@/services/api'
|
||||||
import { useAuthStore } from '@/store/authStore'
|
import { useAuthStore } from '@/store/authStore'
|
||||||
import { Button, Card, Input, Badge } from '@/components/ui'
|
import { Button, Card, Input, Badge } from '@/components/ui'
|
||||||
@@ -8,11 +8,34 @@ import { getPlanColor, formatDate } from '@/lib/utils'
|
|||||||
import { CreditCard, User, Shield, Download, ExternalLink } from 'lucide-react'
|
import { CreditCard, User, Shield, Download, ExternalLink } from 'lucide-react'
|
||||||
|
|
||||||
export const SettingsPage: React.FC = () => {
|
export const SettingsPage: React.FC = () => {
|
||||||
const { user, updateUser } = useAuthStore()
|
// BUG-04 FIX: Removed unused 'updateUser' from destructuring
|
||||||
|
const { user } = useAuthStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [tab, setTab] = useState<'profile' | 'billing' | 'export'>('profile')
|
const location = useLocation()
|
||||||
|
|
||||||
|
// BUG-06 FIX: Sync tab with URL path on mount and when path changes
|
||||||
|
const getTabFromPath = (pathname: string): 'profile' | 'billing' => {
|
||||||
|
if (pathname === '/settings/billing') return 'billing'
|
||||||
|
return 'profile'
|
||||||
|
}
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<'profile' | 'billing'>(getTabFromPath(location.pathname))
|
||||||
const [toast, setToast] = useState('')
|
const [toast, setToast] = useState('')
|
||||||
|
|
||||||
|
// Keep tab in sync if URL changes externally
|
||||||
|
useEffect(() => {
|
||||||
|
setTab(getTabFromPath(location.pathname))
|
||||||
|
}, [location.pathname])
|
||||||
|
|
||||||
|
// Update URL when tab changes
|
||||||
|
const handleTabChange = useCallback((newTab: 'profile' | 'billing') => {
|
||||||
|
setTab(newTab)
|
||||||
|
const newPath = newTab === 'billing' ? '/settings/billing' : '/settings'
|
||||||
|
if (location.pathname !== newPath) {
|
||||||
|
navigate(newPath, { replace: true })
|
||||||
|
}
|
||||||
|
}, [navigate, location.pathname])
|
||||||
|
|
||||||
const showToast = (msg: string) => {
|
const showToast = (msg: string) => {
|
||||||
setToast(msg)
|
setToast(msg)
|
||||||
setTimeout(() => setToast(''), 3000)
|
setTimeout(() => setToast(''), 3000)
|
||||||
@@ -25,14 +48,16 @@ export const SettingsPage: React.FC = () => {
|
|||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-1 mb-6 bg-gray-100 p-1 rounded-xl w-fit">
|
<div className="flex gap-1 mb-6 bg-gray-100 p-1 rounded-xl w-fit">
|
||||||
{[
|
{[
|
||||||
{ id: 'profile', label: 'Profile', icon: User },
|
{ id: 'profile' as const, label: 'Profile', icon: User },
|
||||||
{ id: 'billing', label: 'Billing', icon: CreditCard },
|
{ id: 'billing' as const, label: 'Billing', icon: CreditCard },
|
||||||
].map(({ id, label, icon: Icon }) => (
|
].map(({ id, label, icon: Icon }) => (
|
||||||
<button
|
<button
|
||||||
key={id}
|
key={id}
|
||||||
onClick={() => setTab(id as any)}
|
onClick={() => handleTabChange(id)}
|
||||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
tab === id ? 'bg-white shadow-sm text-gray-900' : 'text-gray-500 hover:text-gray-700'
|
tab === id
|
||||||
|
? 'bg-white shadow-sm text-gray-900'
|
||||||
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon className="w-3.5 h-3.5" />
|
<Icon className="w-3.5 h-3.5" />
|
||||||
@@ -45,8 +70,9 @@ export const SettingsPage: React.FC = () => {
|
|||||||
{tab === 'billing' && <BillingSettings onToast={showToast} />}
|
{tab === 'billing' && <BillingSettings onToast={showToast} />}
|
||||||
|
|
||||||
{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-4 right-4 bg-gray-900 text-white px-4 py-2 rounded-lg text-sm shadow-lg z-50 animate-fade-in-up">
|
||||||
{toast}
|
{toast}
|
||||||
|
<button onClick={() => setToast('')} className="ml-3 opacity-60 hover:opacity-100">×</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -142,10 +168,10 @@ const BillingSettings: React.FC<{ onToast: (msg: string) => void }> = ({ onToast
|
|||||||
<h3 className="font-semibold text-gray-900 mb-4">Plan Features</h3>
|
<h3 className="font-semibold text-gray-900 mb-4">Plan Features</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{[
|
{[
|
||||||
{ label: 'Chatbots created', value: 'Unlimited', always: true },
|
{ label: 'Chatbots created', value: 'Unlimited' },
|
||||||
{ label: 'Chatbots published', value: subscription?.plan === 'pro' ? '3' : subscription?.plan === 'starter' ? '1' : '0 (upgrade to publish)', always: true },
|
{ label: 'Chatbots published', value: subscription?.plan === 'pro' ? '3' : subscription?.plan === 'starter' ? '1' : '0 (upgrade to publish)' },
|
||||||
{ label: 'Conversations/month', value: subscription?.plan === 'pro' ? '20,000' : subscription?.plan === 'starter' ? '5,000' : 'Unlimited (preview)', always: true },
|
{ label: 'Conversations/month', value: subscription?.plan === 'pro' ? '20,000' : subscription?.plan === 'starter' ? '5,000' : 'Unlimited (preview)' },
|
||||||
{ label: 'Code export', value: subscription?.plan === 'pro' || subscription?.plan === 'enterprise' ? '✅ Included' : '❌ Pro+ only', always: true },
|
{ label: 'Code export', value: subscription?.plan === 'pro' || subscription?.plan === 'enterprise' ? '✅ Included' : '❌ Pro+ only' },
|
||||||
].map(({ label, value }) => (
|
].map(({ label, value }) => (
|
||||||
<div key={label} className="flex justify-between py-2 border-b border-gray-100 last:border-0">
|
<div key={label} className="flex justify-between py-2 border-b border-gray-100 last:border-0">
|
||||||
<span className="text-sm text-gray-600">{label}</span>
|
<span className="text-sm text-gray-600">{label}</span>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
AuthResponse, User, Chatbot, ChatbotFormData, Document,
|
AuthResponse, User, Chatbot, ChatbotFormData, Document,
|
||||||
ChatResponse, Subscription, MarketplaceResponse, Analytics, ChatbotPublic
|
ChatResponse, Subscription, MarketplaceResponse, Analytics, ChatbotPublic
|
||||||
} 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'
|
||||||
|
|
||||||
@@ -11,21 +12,34 @@ export const api = axios.create({
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Request interceptor - attach token
|
// BUG-01 FIX: Read token from Zustand store (single source of truth)
|
||||||
|
// instead of manual localStorage.getItem('access_token')
|
||||||
api.interceptors.request.use((config) => {
|
api.interceptors.request.use((config) => {
|
||||||
const token = localStorage.getItem('access_token')
|
const token = useAuthStore.getState().token
|
||||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||||
return config
|
return config
|
||||||
})
|
})
|
||||||
|
|
||||||
// Response interceptor - handle 401
|
// BUG-02 FIX: Prevent infinite redirect loop on 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
|
||||||
|
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(res) => res,
|
(res) => res,
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401 && !isRedirecting) {
|
||||||
localStorage.removeItem('access_token')
|
// Don't redirect if already on login/signup page
|
||||||
localStorage.removeItem('user')
|
const currentPath = window.location.pathname
|
||||||
window.location.href = '/login'
|
if (currentPath !== '/login' && currentPath !== '/signup') {
|
||||||
|
isRedirecting = true
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
@@ -74,19 +88,15 @@ export const documentsAPI = {
|
|||||||
list: (chatbotId: string) =>
|
list: (chatbotId: string) =>
|
||||||
api.get<Document[]>(`/chatbots/${chatbotId}/documents`).then(r => r.data),
|
api.get<Document[]>(`/chatbots/${chatbotId}/documents`).then(r => r.data),
|
||||||
|
|
||||||
upload: (chatbotId: string, file: File, onProgress?: (p: number) => void) => {
|
upload: (chatbotId: string, file: File, onProgress?: (pct: number) => void) => {
|
||||||
const form = new FormData()
|
const formData = new FormData()
|
||||||
form.append('file', file)
|
formData.append('file', file)
|
||||||
return api.post<Document>(
|
return api.post<Document>(`/chatbots/${chatbotId}/documents`, formData, {
|
||||||
`/chatbots/${chatbotId}/documents`,
|
|
||||||
form,
|
|
||||||
{
|
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
onUploadProgress: (e) => {
|
onUploadProgress: (e) => {
|
||||||
if (onProgress && e.total) onProgress(Math.round((e.loaded / e.total) * 100))
|
if (onProgress && e.total) onProgress(Math.round((e.loaded * 100) / e.total))
|
||||||
},
|
},
|
||||||
}
|
}).then(r => r.data)
|
||||||
).then(r => r.data)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
delete: (chatbotId: string, docId: string) =>
|
delete: (chatbotId: string, docId: string) =>
|
||||||
@@ -95,43 +105,35 @@ export const documentsAPI = {
|
|||||||
|
|
||||||
// ─── Chat ─────────────────────────────────────────────────────────────────────
|
// ─── Chat ─────────────────────────────────────────────────────────────────────
|
||||||
export const chatAPI = {
|
export const chatAPI = {
|
||||||
send: (chatbotId: string, data: { message: string; session_id?: string; language?: string }) =>
|
send: (chatbotId: string, data: { message: string; session_id: string; language?: string }) =>
|
||||||
api.post<ChatResponse>(`/chat/${chatbotId}`, data).then(r => r.data),
|
api.post<ChatResponse>(`/chat/${chatbotId}`, data).then(r => r.data),
|
||||||
|
|
||||||
getHistory: (chatbotId: string, sessionId: string) =>
|
history: (chatbotId: string, sessionId: string) =>
|
||||||
api.get(`/chat/${chatbotId}/history/${sessionId}`).then(r => r.data),
|
api.get(`/chat/${chatbotId}/history/${sessionId}`).then(r => r.data),
|
||||||
|
|
||||||
getAnalytics: (chatbotId: string) =>
|
|
||||||
api.get<Analytics>(`/analytics/${chatbotId}`).then(r => r.data),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Marketplace ──────────────────────────────────────────────────────────────
|
// ─── Marketplace ──────────────────────────────────────────────────────────────
|
||||||
export const marketplaceAPI = {
|
export const marketplaceAPI = {
|
||||||
list: (params?: {
|
list: (params?: { search?: string; category?: string; industry?: string; page?: number; limit?: number }) =>
|
||||||
category?: string; industry?: string; language?: string;
|
api.get<MarketplaceResponse>('/marketplace/chatbots', { params }).then(r => r.data),
|
||||||
search?: string; page?: number; limit?: number
|
|
||||||
}) => api.get<MarketplaceResponse>('/marketplace/chatbots', { params }).then(r => r.data),
|
|
||||||
|
|
||||||
get: (id: string) =>
|
get: (id: string) =>
|
||||||
api.get<ChatbotPublic>(`/marketplace/chatbots/${id}`).then(r => r.data),
|
api.get<ChatbotPublic>(`/marketplace/chatbots/${id}`).then(r => r.data),
|
||||||
|
|
||||||
categories: () =>
|
rate: (chatbotId: string, data: { rating: number; comment?: string }) =>
|
||||||
api.get<{ categories: string[]; industries: string[] }>('/marketplace/categories').then(r => r.data),
|
api.post(`/marketplace/chatbots/${chatbotId}/rate`, data).then(r => r.data),
|
||||||
|
|
||||||
rate: (id: string, rating: number, feedback?: string) =>
|
|
||||||
api.post(`/marketplace/chatbots/${id}/rate`, { rating, feedback }).then(r => r.data),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Billing ──────────────────────────────────────────────────────────────────
|
// ─── Billing ──────────────────────────────────────────────────────────────────
|
||||||
export const billingAPI = {
|
export const billingAPI = {
|
||||||
|
createCheckout: (plan: string, successUrl: string, cancelUrl: string) =>
|
||||||
|
api.post<{ checkout_url: string; session_id: string }>('/billing/checkout', {
|
||||||
|
plan, success_url: successUrl, cancel_url: cancelUrl,
|
||||||
|
}).then(r => r.data),
|
||||||
|
|
||||||
getSubscription: () =>
|
getSubscription: () =>
|
||||||
api.get<Subscription>('/billing/subscription').then(r => r.data),
|
api.get<Subscription>('/billing/subscription').then(r => r.data),
|
||||||
|
|
||||||
createCheckout: (plan: string, success_url: string, cancel_url: string) =>
|
createPortal: (returnUrl: string) =>
|
||||||
api.post<{ checkout_url: string; session_id: string }>(
|
api.post<{ url: string }>('/billing/portal', { return_url: returnUrl }).then(r => r.data),
|
||||||
'/billing/checkout', { plan, success_url, cancel_url }
|
|
||||||
).then(r => r.data),
|
|
||||||
|
|
||||||
createPortal: (return_url: string) =>
|
|
||||||
api.post<{ url: string }>('/billing/portal', { return_url }).then(r => r.data),
|
|
||||||
}
|
}
|
||||||
@@ -18,14 +18,14 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
token: null,
|
token: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
|
||||||
|
// BUG-01 FIX: Removed manual localStorage.setItem('access_token', token)
|
||||||
|
// Zustand persist middleware is the single source of truth.
|
||||||
|
// The API interceptor now reads from Zustand store directly.
|
||||||
setAuth: (user, token) => {
|
setAuth: (user, token) => {
|
||||||
localStorage.setItem('access_token', token)
|
|
||||||
set({ user, token, isAuthenticated: true })
|
set({ user, token, isAuthenticated: true })
|
||||||
},
|
},
|
||||||
|
|
||||||
logout: () => {
|
logout: () => {
|
||||||
localStorage.removeItem('access_token')
|
|
||||||
localStorage.removeItem('user')
|
|
||||||
set({ user: null, token: null, isAuthenticated: false })
|
set({ user: null, token: null, isAuthenticated: false })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user