fixed bugs

This commit is contained in:
belviskhoremk
2026-02-22 23:25:10 +00:00
parent 53279e8fe1
commit f5d1bfb49d
10 changed files with 1073 additions and 834 deletions

View File

@@ -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,27 +35,42 @@ 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 = () => (
<Routes> <Suspense fallback={<PageLoader />}>
{/* Public */} <Routes>
<Route path="/" element={<LandingPage />} /> {/* Public - Landing has its own nav */}
<Route path="/pricing" element={<PricingPage />} /> <Route path="/" element={<LandingPage />} />
<Route path="/marketplace" element={<MarketplacePage />} />
<Route path="/marketplace/:id" element={<ChatbotDetailPage />} />
{/* Auth */} {/* Public pages - wrapped in SmartPublicRoute for proper nav */}
<Route path="/login" element={<PublicOnlyRoute><LoginPage /></PublicOnlyRoute>} /> <Route path="/pricing" element={<SmartPublicRoute><PricingPage /></SmartPublicRoute>} />
<Route path="/signup" element={<PublicOnlyRoute><SignupPage /></PublicOnlyRoute>} /> <Route path="/marketplace" element={<SmartPublicRoute><MarketplacePage /></SmartPublicRoute>} />
<Route path="/marketplace/:id" element={<SmartPublicRoute><ChatbotDetailPage /></SmartPublicRoute>} />
{/* Protected */} {/* Auth */}
<Route path="/dashboard" element={<PrivateRoute><DashboardPage /></PrivateRoute>} /> <Route path="/login" element={<PublicOnlyRoute><LoginPage /></PublicOnlyRoute>} />
<Route path="/chatbots/new" element={<PrivateRoute><ChatbotBuilderPage /></PrivateRoute>} /> <Route path="/signup" element={<PublicOnlyRoute><SignupPage /></PublicOnlyRoute>} />
<Route path="/chatbots/:id/edit" element={<PrivateRoute><ChatbotBuilderPage /></PrivateRoute>} />
<Route path="/chatbots/:id/preview" element={<PrivateRoute><ChatbotBuilderPage /></PrivateRoute>} />
<Route path="/settings" element={<PrivateRoute><SettingsPage /></PrivateRoute>} />
<Route path="/settings/billing" element={<PrivateRoute><SettingsPage /></PrivateRoute>} />
{/* Fallback */} {/* Protected */}
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="/dashboard" element={<PrivateRoute><DashboardPage /></PrivateRoute>} />
</Routes> <Route path="/chatbots/new" element={<PrivateRoute><ChatbotBuilderPage /></PrivateRoute>} />
<Route path="/chatbots/:id/edit" element={<PrivateRoute><ChatbotBuilderPage /></PrivateRoute>} />
<Route path="/chatbots/:id/preview" element={<PrivateRoute><ChatbotBuilderPage /></PrivateRoute>} />
<Route path="/settings" element={<PrivateRoute><SettingsPage /></PrivateRoute>} />
<Route path="/settings/billing" element={<PrivateRoute><SettingsPage /></PrivateRoute>} />
{/* Fallback */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
) )

View File

@@ -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,120 +99,112 @@ 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>
<div className="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center"> <div>
<Bot className="w-4 h-4" /> <h3 className="text-sm font-semibold text-white">{chatbotName}</h3>
{isPreview && <span className="text-xs text-white/70">Preview mode</span>}
</div>
</div> </div>
<div>
<p className="font-semibold text-sm">{chatbotName}</p>
<p className="text-xs opacity-80">Online</p>
</div>
{isPreview && (
<span className="ml-auto bg-white/20 text-white text-xs px-2 py-0.5 rounded-full">
Preview
</span>
)}
</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 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 } : {}}
>
{msg.content}
</div>
{/* Sources */}
{msg.role === 'assistant' && msg.sources && msg.sources.length > 0 && (
<div className="mt-1">
<button
onClick={() => toggleSources(msg.id)}
className="flex items-center gap-1 text-xs text-gray-400 hover:text-gray-600"
>
<FileText className="w-3 h-3" />
{msg.sources.length} source{msg.sources.length > 1 ? 's' : ''}
{expandedSources.has(msg.id) ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button>
{expandedSources.has(msg.id) && (
<div className="mt-2 space-y-1.5">
{msg.sources.map((s, i) => (
<div key={i} className="bg-gray-50 border border-gray-200 rounded-lg p-2.5 text-xs">
<p className="font-medium text-gray-700 mb-1">📄 {s.document_name}</p>
<p className="text-gray-500 line-clamp-2">{s.chunk_text}</p>
</div>
))}
</div> </div>
)}
<div className={cn(
'max-w-[80%] rounded-2xl px-4 py-2.5 text-sm',
msg.role === 'user'
? '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 */}
{msg.sources && msg.sources.length > 0 && (
<div className="mt-2 pt-2 border-t border-gray-200/50">
<button
onClick={() => toggleSources(msg.id)}
className="flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700"
>
<FileText className="w-3 h-3" />
{msg.sources.length} source{msg.sources.length > 1 ? 's' : ''}
{expandedSources.has(msg.id) ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button>
{expandedSources.has(msg.id) && (
<div className="mt-2 space-y-2">
{msg.sources.map((src, i) => (
<div key={i} className="bg-white/80 rounded-lg p-2 text-xs">
<p className="font-medium text-gray-700">{src.document_name}</p>
<p className="text-gray-500 mt-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> </div>
)} {msg.role === 'user' && (
</div> <div className="w-7 h-7 bg-gray-200 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
</div> <User className="w-3.5 h-3.5 text-gray-600" />
))} </div>
)}
{loading && (
<div className="flex gap-3">
<div className="w-7 h-7 rounded-full flex-shrink-0 flex items-center justify-center text-white"
style={{ background: primaryColor }}>
<Bot className="w-3.5 h-3.5" />
</div>
<div className="bg-gray-100 rounded-2xl rounded-tl-sm px-4 py-3">
<div className="flex gap-1">
<span className="w-1.5 h-1.5 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' }} />
<span className="w-1.5 h-1.5 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div> </div>
</div> ))}
</div>
)}
<div ref={bottomRef} />
</div>
{/* Input */} {/* Typing indicator */}
<div className="px-4 py-3 border-t border-gray-100"> {loading && (
<div className="flex gap-2"> <div className="flex gap-3">
<input <div className="w-7 h-7 rounded-lg flex items-center justify-center flex-shrink-0"
ref={inputRef} style={{ background: primaryColor }}>
type="text" <Bot className="w-3.5 h-3.5 text-white" />
value={input} </div>
onChange={(e) => setInput(e.target.value)} <div className="bg-gray-100 rounded-2xl rounded-bl-md px-4 py-3">
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && send()} <div className="flex gap-1">
placeholder="Type a message..." <div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
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" <div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
/> <div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
<button </div>
onClick={send} </div>
disabled={!input.trim() || loading} </div>
className="p-2 rounded-xl text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed" )}
style={{ background: primaryColor }}
> <div ref={bottomRef} />
<Send className="w-4 h-4" /> </div>
</button>
{/* Input */}
<div className="border-t border-gray-100 p-3">
<div className="flex gap-2 items-end">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your message..."
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
onClick={send}
disabled={!input.trim() || loading}
className="p-2 rounded-xl text-white transition-all disabled:opacity-50 disabled:cursor-not-allowed hover:opacity-90"
style={{ background: primaryColor }}
>
<Send className="w-4 h-4" />
</button>
</div>
</div> </div>
<p className="text-center text-xs text-gray-400 mt-2">Powered by Contexta</p>
</div> </div>
</div>
) )
} }

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

View File

@@ -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',

View File

@@ -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,148 +55,152 @@ 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>
<h1 className="text-2xl font-bold text-gray-900">
Good {getGreeting()}, {user?.company_name || 'there'} 👋
</h1>
<p className="text-gray-500 text-sm mt-1">Manage your AI chatbots</p>
</div>
<Button onClick={() => navigate('/chatbots/new')} size="lg">
<Plus className="w-4 h-4" />
New Chatbot
</Button>
</div>
{/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
{[
{ label: 'Total Chatbots', value: chatbots.length, icon: '🤖' },
{ label: 'Published', value: chatbots.filter(c => c.is_published).length, icon: '🌐' },
{ label: 'Documents', value: chatbots.reduce((sum, c) => sum + c.document_count, 0), icon: '📄' },
{ label: 'Conversations', value: chatbots.reduce((sum, c) => sum + c.conversation_count, 0), icon: '💬' },
].map(({ label, value, icon }) => (
<Card key={label} className="p-4">
<div className="flex items-center gap-3">
<span className="text-2xl">{icon}</span>
<div>
<p className="text-2xl font-bold text-gray-900">{value}</p>
<p className="text-xs text-gray-500">{label}</p>
</div>
</div>
</Card>
))}
</div>
{/* Plan notice */}
{user?.plan === 'free' && (
<div className="mb-6 p-4 bg-gradient-to-r from-primary-50 to-purple-50 border border-primary-200 rounded-xl flex items-center justify-between">
<div> <div>
<p className="text-sm font-semibold text-primary-900">You're on the Free plan</p> <h1 className="text-xl sm:text-2xl font-bold text-gray-900">Dashboard</h1>
<p className="text-xs text-primary-700 mt-0.5"> <p className="text-sm text-gray-500 mt-0.5">Manage your AI chatbots</p>
Upgrade to publish chatbots to the marketplace and unlock premium AI models
</p>
</div> </div>
<Link to="/pricing"> <Button onClick={() => navigate('/chatbots/new')}>
<Button size="sm">Upgrade</Button> <Plus className="w-4 h-4" />
</Link> New Chatbot
</Button>
</div> </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" />
</div>
) : chatbots.length === 0 ? (
<EmptyState
icon={<Bot className="w-8 h-8" />}
title="No chatbots yet"
description="Create your first AI chatbot powered by your documents. It's free to build and test."
action={
<Button onClick={() => navigate('/chatbots/new')} size="lg">
<Plus className="w-4 h-4" />
Create your first chatbot
</Button>
}
/>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
{chatbots.map((chatbot) => (
<ChatbotCard
key={chatbot.id}
chatbot={chatbot}
onEdit={() => navigate(`/chatbots/${chatbot.id}/edit`)}
onPreview={() => navigate(`/chatbots/${chatbot.id}/preview`)}
onPublish={() => publishMutation.mutate(chatbot.id)}
onUnpublish={() => unpublishMutation.mutate(chatbot.id)}
onDelete={() => setDeleteId(chatbot.id)}
onAnalytics={() => navigate(`/chatbots/${chatbot.id}/analytics`)}
/>
))}
{/* Add new card */}
<Card
className="border-dashed hover:border-primary-400 hover:bg-primary-50 flex items-center justify-center min-h-[200px] cursor-pointer transition-colors"
onClick={() => navigate('/chatbots/new')}
>
<div className="text-center">
<div className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-3">
<Plus className="w-6 h-6 text-gray-400" />
</div>
<p className="text-sm font-medium text-gray-500">New Chatbot</p>
</div> </div>
</Card> ) : chatbots.length === 0 ? (
</div> <EmptyState
)} icon={<Bot className="w-8 h-8" />}
title="No chatbots yet"
description="Create your first AI chatbot powered by your documents. It's free to build and test."
action={
<Button onClick={() => navigate('/chatbots/new')} size="lg">
<Plus className="w-4 h-4" />
Create your first chatbot
</Button>
}
/>
) : (
// 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) => (
<ChatbotCard
key={chatbot.id}
chatbot={chatbot}
onEdit={() => navigate(`/chatbots/${chatbot.id}/edit`)}
onPreview={() => navigate(`/chatbots/${chatbot.id}/preview`)}
onPublish={() => setConfirmAction({ type: 'publish', id: chatbot.id })}
onUnpublish={() => setConfirmAction({ type: 'unpublish', id: chatbot.id })}
onDelete={() => setDeleteId(chatbot.id)}
onAnalytics={() => navigate(`/chatbots/${chatbot.id}/analytics`)}
/>
))}
{/* Add new card */}
<Card
className="border-dashed hover:border-primary-400 hover:bg-primary-50 flex items-center justify-center min-h-[200px] cursor-pointer transition-colors"
onClick={() => navigate('/chatbots/new')}
>
<div className="text-center">
<div className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-3">
<Plus className="w-6 h-6 text-gray-400" />
</div>
<p className="text-sm font-medium text-gray-500">New Chatbot</p>
</div>
</Card>
</div>
)}
{/* Delete confirmation */} {/* Delete confirmation */}
<Modal <Modal
isOpen={!!deleteId} isOpen={!!deleteId}
onClose={() => setDeleteId(null)} onClose={() => setDeleteId(null)}
title="Delete Chatbot" title="Delete Chatbot"
> >
<p className="text-gray-600 mb-6"> <p className="text-gray-600 mb-6">
Are you sure you want to delete this chatbot? All documents and conversation history will be permanently removed. Are you sure you want to delete this chatbot? All documents and conversation history will be permanently removed.
</p> </p>
<div className="flex gap-3"> <div className="flex gap-3">
<Button variant="outline" onClick={() => setDeleteId(null)} className="flex-1"> <Button variant="outline" onClick={() => setDeleteId(null)} className="flex-1">
Cancel Cancel
</Button> </Button>
<Button <Button
variant="danger" variant="danger"
onClick={() => deleteId && deleteMutation.mutate(deleteId)} onClick={() => deleteId && deleteMutation.mutate(deleteId)}
loading={deleteMutation.isPending} loading={deleteMutation.isPending}
className="flex-1" className="flex-1"
> >
Delete Delete
</Button> </Button>
</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">&times;</button>
</div>
))}
</div> </div>
)} </div>
</div>
) )
} }
@@ -194,106 +217,100 @@ const ChatbotCard: React.FC<{
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
return ( return (
<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 <div
className="w-10 h-10 rounded-xl flex items-center justify-center text-white text-lg" className="w-10 h-10 rounded-xl flex items-center justify-center text-white text-lg"
style={{ background: chatbot.primary_color }} style={{ background: chatbot.primary_color }}
> >
<Bot className="w-5 h-5" /> <Bot className="w-5 h-5" />
</div> </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">
<StatusDot status={chatbot.is_published ? 'published' : 'preview'} /> <StatusDot status={chatbot.is_published ? 'published' : 'preview'} />
<span className="text-xs text-gray-500"> <span className="text-xs text-gray-500">
{chatbot.is_published ? 'Published' : 'Preview'} {chatbot.is_published ? 'Published' : 'Preview'}
</span> </span>
</div>
</div> </div>
</div> </div>
{/* R-02 FIX: Menu dropdown with viewport-aware positioning */}
<div className="relative">
<button
onClick={() => setMenuOpen(!menuOpen)}
className="p-1.5 hover:bg-gray-100 rounded-lg text-gray-400"
>
<MoreHorizontal className="w-4 h-4" />
</button>
{menuOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} />
<div className="absolute right-0 mt-1 w-44 bg-white border border-gray-200 rounded-xl shadow-lg z-20 overflow-hidden text-sm max-h-64 overflow-y-auto">
<button onClick={() => { onEdit(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2 hover:bg-gray-50 text-left">
<Settings className="w-3.5 h-3.5" /> Edit Settings
</button>
<button onClick={() => { onPreview(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2 hover:bg-gray-50 text-left">
<Eye className="w-3.5 h-3.5" /> Preview
</button>
<button onClick={() => { onAnalytics(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2 hover:bg-gray-50 text-left">
<BarChart2 className="w-3.5 h-3.5" /> Analytics
</button>
<div className="h-px bg-gray-100" />
{chatbot.is_published ? (
<button onClick={() => { onUnpublish(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2 hover:bg-orange-50 text-orange-600 text-left">
<Lock className="w-3.5 h-3.5" /> Unpublish
</button>
) : (
<button onClick={() => { onPublish(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2 hover:bg-green-50 text-green-600 text-left">
<Globe className="w-3.5 h-3.5" /> Publish
</button>
)}
<div className="h-px bg-gray-100" />
<button onClick={() => { onDelete(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2 hover:bg-red-50 text-red-600 text-left">
<Trash2 className="w-3.5 h-3.5" /> Delete
</button>
</div>
</>
)}
</div>
</div> </div>
<div className="relative">
<button {chatbot.description && (
onClick={() => setMenuOpen(!menuOpen)} <p className="text-xs text-gray-500 mb-3 line-clamp-2">{chatbot.description}</p>
className="p-1.5 hover:bg-gray-100 rounded-lg text-gray-400" )}
{/* Stats */}
<div className="flex flex-wrap gap-3 mb-4 text-xs text-gray-500">
<span>📄 {chatbot.document_count} docs</span>
<span>💬 {chatbot.conversation_count} chats</span>
{chatbot.category && <span>🏷 {chatbot.category}</span>}
</div>
{/* Actions */}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={onPreview}
className="flex-1"
> >
<MoreHorizontal className="w-4 h-4" /> <Eye className="w-3.5 h-3.5" />
</button> Preview
{menuOpen && ( </Button>
<> {chatbot.is_published ? (
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} /> <Button variant="outline" size="sm" onClick={onUnpublish} className="flex-1">
<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"> <Lock className="w-3.5 h-3.5" />
<button onClick={() => { onEdit(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2 hover:bg-gray-50 text-left"> Unpublish
<Settings className="w-3.5 h-3.5" /> Edit Settings </Button>
</button> ) : (
<button onClick={() => { onPreview(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2 hover:bg-gray-50 text-left"> <Button size="sm" onClick={onPublish} className="flex-1">
<Eye className="w-3.5 h-3.5" /> Preview <Globe className="w-3.5 h-3.5" />
</button> Publish
<button onClick={() => { onAnalytics(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2 hover:bg-gray-50 text-left"> </Button>
<BarChart2 className="w-3.5 h-3.5" /> Analytics
</button>
<div className="h-px bg-gray-100" />
<button onClick={() => { onDelete(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2 hover:bg-red-50 text-red-600 text-left">
<Trash2 className="w-3.5 h-3.5" /> Delete
</button>
</div>
</>
)} )}
</div> </div>
</div> </Card>
{chatbot.description && (
<p className="text-xs text-gray-500 mb-3 line-clamp-2">{chatbot.description}</p>
)}
{/* Stats */}
<div className="flex gap-3 mb-4 text-xs text-gray-500">
<span>📄 {chatbot.document_count} docs</span>
<span>💬 {chatbot.conversation_count} chats</span>
{chatbot.category && <span>🏷 {chatbot.category}</span>}
</div>
{/* Actions */}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={onPreview}
className="flex-1"
>
<Eye className="w-3.5 h-3.5" />
Preview
</Button>
{chatbot.is_published ? (
<Button
variant="ghost"
size="sm"
onClick={onUnpublish}
className="flex-1 text-orange-600 hover:bg-orange-50"
>
<Lock className="w-3.5 h-3.5" />
Unpublish
</Button>
) : (
<Button
variant="primary"
size="sm"
onClick={onPublish}
className="flex-1"
>
<Globe className="w-3.5 h-3.5" />
Publish
</Button>
)}
</div>
</Card>
) )
} }
function getGreeting() {
const h = new Date().getHours()
if (h < 12) return 'morning'
if (h < 17) return 'afternoon'
return 'evening'
}

View File

@@ -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,224 +15,216 @@ 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>
{/* Search & Filters */}
<div className="flex flex-col sm:flex-row gap-3 mb-6">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
value={search}
onChange={e => { setSearch(e.target.value); setPage(1) }}
placeholder="Search chatbots..."
className="w-full pl-9 pr-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div> </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 ? ( {/* Search & Filters */}
<div className="flex justify-center py-20"><Spinner className="text-primary-600" /></div> <div className="flex flex-col sm:flex-row gap-3 mb-6">
) : !data?.chatbots?.length ? ( <div className="relative flex-1">
<EmptyState <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
icon={<Bot className="w-8 h-8" />} <input
title="No chatbots found" type="text"
description="Be the first to publish your AI chatbot to the marketplace!" value={search}
action={<Button onClick={() => navigate('/chatbots/new')}>Create Chatbot</Button>} onChange={e => { setSearch(e.target.value); setPage(1) }}
/> placeholder="Search chatbots..."
) : ( className="w-full pl-9 pr-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
<> />
<div className="mb-4 text-sm text-gray-500">{data.total} chatbot{data.total !== 1 ? 's' : ''} available</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
{data.chatbots.map(chatbot => (
<ChatbotMarketplaceCard
key={chatbot.id}
chatbot={chatbot}
onClick={() => navigate(`/marketplace/${chatbot.id}`)}
/>
))}
</div> </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>
{/* Pagination */} {isLoading ? (
{data.total > 20 && ( <div className="flex justify-center py-20"><Spinner className="text-primary-600" /></div>
<div className="flex justify-center gap-2"> ) : !data?.chatbots?.length ? (
<Button variant="outline" size="sm" onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}> <EmptyState
Previous icon={<Bot className="w-8 h-8" />}
</Button> title="No chatbots found"
<span className="flex items-center px-3 text-sm text-gray-600"> description="Be the first to publish your AI chatbot to the marketplace!"
Page {page} of {Math.ceil(data.total / 20)} />
</span> ) : (
<Button variant="outline" size="sm" onClick={() => setPage(p => p + 1)} disabled={!data.has_more}> <>
Next <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
</Button> {data.chatbots.map((chatbot) => (
</div> <ChatbotMarketplaceCard
)} key={chatbot.id}
</> chatbot={chatbot}
)} onClick={() => navigate(`/marketplace/${chatbot.id}`)}
</div> />
))}
</div>
{/* Pagination */}
{data.has_more && (
<div className="flex justify-center mt-8 gap-2">
<Button
variant="outline"
size="sm"
disabled={page === 1}
onClick={() => setPage(p => Math.max(1, p - 1))}
>
Previous
</Button>
<span className="px-3 py-1.5 text-sm text-gray-500">Page {page}</span>
<Button
variant="outline"
size="sm"
disabled={!data.has_more}
onClick={() => setPage(p => p + 1)}
>
Next
</Button>
</div>
)}
</>
)}
</div>
) )
} }
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"
<div onClick={onClick}
className="w-10 h-10 rounded-xl flex items-center justify-center text-white flex-shrink-0" >
style={{ background: chatbot.primary_color }} <div className="flex items-center gap-3 mb-3">
> <div
<Bot className="w-5 h-5" /> className="w-10 h-10 rounded-xl flex items-center justify-center text-white"
style={{ background: chatbot.primary_color }}
>
<Bot className="w-5 h-5" />
</div>
<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-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">
{chatbot.category && (
<span className="text-xs bg-primary-50 text-primary-700 px-2 py-0.5 rounded-full">
{chatbot.category}
</span>
)} )}
{chatbot.industry && (
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">
{chatbot.industry}
</span>
)}
</div>
<div className="flex items-center justify-between text-xs text-gray-500"> <div className="flex items-center gap-3 text-xs text-gray-400">
<div className="flex items-center gap-3"> {chatbot.average_rating && chatbot.average_rating > 0 && (
{chatbot.average_rating && ( <span className="flex items-center gap-1">
<span className="flex items-center gap-0.5"> <Star className="w-3 h-3 text-yellow-400 fill-yellow-400" />
<Star className="w-3 h-3 fill-yellow-400 text-yellow-400" /> {chatbot.average_rating.toFixed(1)}
{chatbot.average_rating.toFixed(1)}
</span>
)}
<span className="flex items-center gap-0.5">
<MessageSquare className="w-3 h-3" />
{chatbot.total_conversations}
</span> </span>
)}
<span className="flex items-center gap-1">
<MessageSquare className="w-3 h-3" />
{chatbot.total_conversations} chats
</span>
{chatbot.category && <span className="truncate">🏷 {chatbot.category}</span>}
</div> </div>
<span className="text-primary-600 font-medium">Chat </span> </Card>
</div>
</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">
return ( <Spinner className="text-primary-600" />
<div className="p-6 max-w-4xl mx-auto">
<div className="flex items-center gap-4 mb-6">
<button onClick={() => navigate('/marketplace')} className="p-1.5 hover:bg-gray-100 rounded-lg">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<div>
<h1 className="text-xl font-bold text-gray-900">{chatbot.name}</h1>
{chatbot.company_name && <p className="text-sm text-gray-500">by {chatbot.company_name}</p>}
</div> </div>
</div> )
}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> if (error || !chatbot) {
{/* Chat */} return (
<div className="lg:col-span-2 h-[600px]"> <div className="p-6 max-w-2xl mx-auto text-center">
<ChatInterface <EmptyState
chatbotId={chatbot.id} icon={<Bot className="w-8 h-8" />}
chatbotName={chatbot.name} title="Chatbot not found"
welcomeMessage={chatbot.welcome_message} description="This chatbot may have been unpublished or removed."
primaryColor={chatbot.primary_color} action={
<Button onClick={() => navigate('/marketplace')} variant="outline">
<ArrowLeft className="w-4 h-4" />
Back to Marketplace
</Button>
}
/> />
</div> </div>
)
}
{/* Info sidebar */} return (
<div className="space-y-4"> <div className="p-4 sm:p-6 max-w-4xl mx-auto">
<Card className="p-4"> {/* Back link */}
<h3 className="font-semibold text-gray-900 text-sm mb-3">About</h3> <button
{chatbot.description && <p className="text-sm text-gray-600 mb-3">{chatbot.description}</p>} onClick={() => navigate('/marketplace')}
<div className="space-y-2 text-sm"> className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 mb-4 transition-colors"
{chatbot.category && ( >
<div className="flex justify-between"> <ArrowLeft className="w-4 h-4" />
<span className="text-gray-500">Category</span> Back to Marketplace
<span className="font-medium">{chatbot.category}</span> </button>
</div>
)} {/* Chatbot info */}
{chatbot.industry && ( <div className="flex items-center gap-4 mb-6">
<div className="flex justify-between"> <div
<span className="text-gray-500">Industry</span> className="w-14 h-14 rounded-2xl flex items-center justify-center text-white"
<span className="font-medium">{chatbot.industry}</span> style={{ background: chatbot.primary_color }}
</div> >
)} <Bot className="w-7 h-7" />
<div className="flex justify-between"> </div>
<span className="text-gray-500">Languages</span> <div>
<span className="font-medium uppercase">{chatbot.languages?.join(', ')}</span> <h1 className="text-xl sm:text-2xl font-bold text-gray-900">{chatbot.name}</h1>
</div> {chatbot.company_name && (
<div className="flex justify-between"> <p className="text-sm text-gray-500">by {chatbot.company_name}</p>
<span className="text-gray-500">Conversations</span> )}
<span className="font-medium">{chatbot.total_conversations}</span> </div>
</div> </div>
{chatbot.average_rating && (
<div className="flex justify-between"> {chatbot.description && (
<span className="text-gray-500">Rating</span> <p className="text-gray-600 text-sm mb-6">{chatbot.description}</p>
<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)} {/* R-05 FIX: Use viewport-relative height instead of fixed h-[600px] */}
</span> <div className="h-[calc(100vh-280px)] min-h-[400px] max-h-[700px]">
</div> <ChatInterface
)} chatbotId={chatbot.id}
</div> chatbotName={chatbot.name}
</Card> welcomeMessage={chatbot.welcome_message}
primaryColor={chatbot.primary_color}
/>
</div> </div>
</div> </div>
</div>
) )
} }
// Import needed for ChatbotDetailPage
import { useParams } from 'react-router-dom'
import { ChatInterface } from '@/components/ChatInterface'

View File

@@ -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,13 +110,15 @@ 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 {
const { checkout_url } = await billingAPI.createCheckout( const { checkout_url } = await billingAPI.createCheckout(
planId, planId,
`${window.location.origin}/settings/billing?success=true`, `${window.location.origin}/settings/billing?success=true`,
`${window.location.origin}/pricing` `${window.location.origin}/pricing`
) )
window.location.href = checkout_url window.location.href = checkout_url
} catch (err: any) { } catch (err: any) {
@@ -117,110 +128,131 @@ export const PricingPage: React.FC = () => {
} }
} }
return ( // Helper to determine CTA text
<div className="p-6 max-w-6xl mx-auto"> const getCtaText = (planId: string): string => {
<div className="text-center mb-12"> if (!user) return planId === 'enterprise' ? 'Contact Sales' : 'Get Started'
<h1 className="text-3xl font-bold text-gray-900 mb-3">Simple, transparent pricing</h1> if (planId === currentPlan) return 'Current Plan'
<p className="text-gray-500 max-w-xl mx-auto"> if (planId === 'enterprise') return 'Contact Sales'
Start free and build as many chatbots as you want. Upgrade when you're ready to publish and go live. if (planId === 'free') return 'Downgrade'
</p> return 'Upgrade'
</div> }
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6"> const isCurrentPlan = (planId: string) => user && planId === currentPlan
{PLANS.map((plan) => (
<div return (
key={plan.id} <div className="p-6 max-w-6xl mx-auto">
className={`relative rounded-2xl border p-6 flex flex-col ${ <div className="text-center mb-12">
plan.highlighted <h1 className="text-3xl font-bold text-gray-900 mb-3">Simple, transparent pricing</h1>
? 'border-primary-400 bg-gradient-to-b from-primary-50 to-white shadow-lg shadow-primary-100' <p className="text-gray-500 max-w-xl mx-auto">
: 'border-gray-200 bg-white' Start free and build as many chatbots as you want. Upgrade when you're ready to publish and go live.
}`} </p>
> </div>
{plan.badge && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2"> {/* 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">
{PLANS.map((plan) => (
<div
key={plan.id}
className={`relative rounded-2xl border p-6 flex flex-col ${
plan.highlighted
? 'border-primary-400 bg-gradient-to-b from-primary-50 to-white shadow-lg shadow-primary-100'
: isCurrentPlan(plan.id)
? 'border-primary-300 bg-primary-50/30 shadow-sm'
: 'border-gray-200 bg-white'
}`}
>
{/* 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">
<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}
</span> </span>
</div> </div>
)}
<div className="mb-6">
<div className="text-3xl mb-2">{plan.icon}</div>
<h2 className="text-xl font-bold text-gray-900">{plan.name}</h2>
<p className="text-sm text-gray-500 mt-1 min-h-[40px]">{plan.description}</p>
<div className="mt-4">
{plan.price !== null ? (
<div className="flex items-baseline gap-1">
<span className="text-3xl font-bold text-gray-900">${plan.price}</span>
<span className="text-gray-500 text-sm">/month</span>
</div>
) : (
<span className="text-2xl font-bold text-gray-900">Custom</span>
)} )}
</div>
</div>
<ul className="space-y-2.5 flex-1 mb-6"> <div className="mb-6">
{plan.features.map(({ text, included }) => ( <div className="text-3xl mb-2">{plan.icon}</div>
<li key={text} className="flex items-start gap-2.5"> <h2 className="text-xl font-bold text-gray-900">{plan.name}</h2>
<div className={`w-4 h-4 rounded-full flex-shrink-0 mt-0.5 flex items-center justify-center ${ <p className="text-sm text-gray-500 mt-1 min-h-[40px]">{plan.description}</p>
included ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-400' <div className="mt-4">
}`}> {plan.price !== null ? (
{included ? <Check className="w-2.5 h-2.5" /> : <span className="text-xs"></span>} <div className="flex items-baseline gap-1">
<span className="text-4xl font-extrabold text-gray-900">${plan.price}</span>
{plan.price > 0 && <span className="text-gray-500 text-sm">/month</span>}
</div>
) : (
<div className="text-2xl font-bold text-gray-900">Custom</div>
)}
</div> </div>
<span className={`text-sm ${included ? 'text-gray-700' : 'text-gray-400'}`}>{text}</span> </div>
</li>
))}
</ul>
<Button <div className="flex-1">
variant={plan.highlighted ? 'primary' : 'outline'} <ul className="space-y-3 mb-8">
className="w-full" {plan.features.map((feature) => (
loading={loading === plan.id} <li key={feature.text} className="flex items-start gap-2 text-sm">
onClick={() => handleSubscribe(plan.id)} <div className={`w-4 h-4 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5 ${
disabled={user?.plan === plan.id} feature.included ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-400'
> }`}>
{user?.plan === plan.id <Check className="w-2.5 h-2.5" />
? 'Current Plan' </div>
: plan.price === null <span className={feature.included ? 'text-gray-700' : 'text-gray-400'}>
? 'Contact Sales' {feature.text}
: plan.price === 0 </span>
? 'Get Started Free' </li>
: `Subscribe $${plan.price}/mo`} ))}
</Button> </ul>
</div> </div>
))}
</div>
{/* FAQ */} <Button
<div className="mt-16 max-w-2xl mx-auto"> onClick={() => handleSubscribe(plan.id)}
<h2 className="text-xl font-bold text-gray-900 mb-6 text-center">Frequently Asked Questions</h2> loading={loading === plan.id}
<div className="space-y-4"> // BUG-10 FIX: Disable CTA for current plan
{[ disabled={isCurrentPlan(plan.id) || loading === plan.id}
{ variant={plan.highlighted ? 'default' : 'outline'}
q: 'What is preview mode?', className="w-full"
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.' >
}, {getCtaText(plan.id)}
{ </Button>
q: 'Can I cancel anytime?', </div>
a: 'Yes, you can cancel anytime. Your chatbots will remain in preview mode but will be removed from the marketplace.'
},
{
q: 'What is code export?',
a: 'Pro plan users can export their chatbot as a complete, production-ready package including a FastAPI backend and React TypeScript widget giving you full control to self-host.'
},
{
q: 'Do I need my own API keys?',
a: 'No! Your API keys are handled by Contexta. However, if you export the code, you\'ll need your own API keys for self-hosted deployment.'
},
].map(({ q, a }) => (
<div key={q} className="bg-white border border-gray-200 rounded-xl p-4">
<h3 className="font-semibold text-gray-900 text-sm mb-1">{q}</h3>
<p className="text-sm text-gray-500">{a}</p>
</div>
))} ))}
</div> </div>
{/* FAQ */}
<div className="mt-16 max-w-2xl mx-auto">
<h2 className="text-xl font-bold text-gray-900 text-center mb-8">Frequently Asked Questions</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{[
{
q: 'Can I use the free tier forever?',
a: 'Yes! Build and test unlimited chatbots for free. Your chatbots will remain in preview mode but will be removed from the marketplace.'
},
{
q: 'What is code export?',
a: 'Pro plan users can export their chatbot as a complete, production-ready package including a FastAPI backend and React TypeScript widget giving you full control to self-host.'
},
{
q: 'Do I need my own API keys?',
a: 'No! Your API keys are handled by Contexta. However, if you export the code, you\'ll need your own API keys for self-hosted deployment.'
},
{
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 }) => (
<div key={q} className="bg-white border border-gray-200 rounded-xl p-4">
<h3 className="font-semibold text-gray-900 text-sm mb-1">{q}</h3>
<p className="text-sm text-gray-500">{a}</p>
</div>
))}
</div>
</div>
</div> </div>
</div>
) )
} }

View File

@@ -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,48 +8,74 @@ 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)
} }
return ( return (
<div className="p-6 max-w-3xl mx-auto"> <div className="p-6 max-w-3xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-6">Settings</h1> <h1 className="text-2xl font-bold text-gray-900 mb-6">Settings</h1>
{/* 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" /> }`}
{label} >
</button> <Icon className="w-3.5 h-3.5" />
))} {label}
</div> </button>
))}
{tab === 'profile' && <ProfileSettings onToast={showToast} />}
{tab === 'billing' && <BillingSettings onToast={showToast} />}
{toast && (
<div className="fixed bottom-4 right-4 bg-gray-900 text-white px-4 py-2 rounded-lg text-sm shadow-lg z-50">
{toast}
</div> </div>
)}
</div> {tab === 'profile' && <ProfileSettings onToast={showToast} />}
{tab === 'billing' && <BillingSettings onToast={showToast} />}
{toast && (
<div className="fixed bottom-4 right-4 bg-gray-900 text-white px-4 py-2 rounded-lg text-sm shadow-lg z-50 animate-fade-in-up">
{toast}
<button onClick={() => setToast('')} className="ml-3 opacity-60 hover:opacity-100">&times;</button>
</div>
)}
</div>
) )
} }
@@ -57,22 +83,22 @@ const ProfileSettings: React.FC<{ onToast: (msg: string) => void }> = ({ onToast
const { user } = useAuthStore() const { user } = useAuthStore()
return ( return (
<Card className="p-6 space-y-4"> <Card className="p-6 space-y-4">
<h2 className="font-semibold text-gray-900">Profile Information</h2> <h2 className="font-semibold text-gray-900">Profile Information</h2>
<Input label="Email" value={user?.email || ''} disabled /> <Input label="Email" value={user?.email || ''} disabled />
<Input label="Company Name" value={user?.company_name || ''} disabled hint="Contact support to change company name" /> <Input label="Company Name" value={user?.company_name || ''} disabled hint="Contact support to change company name" />
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Plan</label> <label className="block text-sm font-medium text-gray-700 mb-1.5">Plan</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`px-3 py-1 text-sm font-medium rounded-full capitalize ${getPlanColor(user?.plan || 'free')}`}> <span className={`px-3 py-1 text-sm font-medium rounded-full capitalize ${getPlanColor(user?.plan || 'free')}`}>
{user?.plan || 'free'} {user?.plan || 'free'}
</span> </span>
<Link to="/pricing" className="text-sm text-primary-600 hover:underline"> <Link to="/pricing" className="text-sm text-primary-600 hover:underline">
Manage plan Manage plan
</Link> </Link>
</div>
</div> </div>
</div> </Card>
</Card>
) )
} }
@@ -101,59 +127,59 @@ const BillingSettings: React.FC<{ onToast: (msg: string) => void }> = ({ onToast
const isPaid = subscription?.plan && subscription.plan !== 'free' const isPaid = subscription?.plan && subscription.plan !== 'free'
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<Card className="p-6"> <Card className="p-6">
<h2 className="font-semibold text-gray-900 mb-4">Current Plan</h2> <h2 className="font-semibold text-gray-900 mb-4">Current Plan</h2>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div> <div>
<span className={`px-3 py-1 text-sm font-semibold rounded-full capitalize ${getPlanColor(subscription?.plan || 'free')}`}> <span className={`px-3 py-1 text-sm font-semibold rounded-full capitalize ${getPlanColor(subscription?.plan || 'free')}`}>
{subscription?.plan || 'free'} {subscription?.plan || 'free'}
</span> </span>
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-gray-500 mt-1">
Status: <span className={subscription?.status === 'active' ? 'text-green-600' : 'text-red-600'}> Status: <span className={subscription?.status === 'active' ? 'text-green-600' : 'text-red-600'}>
{subscription?.status || 'active'} {subscription?.status || 'active'}
</span> </span>
</p> </p>
</div>
{isPaid && subscription?.current_period_end && (
<div className="text-right">
<p className="text-xs text-gray-500">Renews on</p>
<p className="text-sm font-medium">{formatDate(subscription.current_period_end)}</p>
</div>
)}
</div> </div>
{isPaid && subscription?.current_period_end && (
<div className="text-right">
<p className="text-xs text-gray-500">Renews on</p>
<p className="text-sm font-medium">{formatDate(subscription.current_period_end)}</p>
</div>
)}
</div>
<div className="flex gap-3"> <div className="flex gap-3">
{!isPaid ? ( {!isPaid ? (
<Button onClick={() => navigate('/pricing')} className="flex-1"> <Button onClick={() => navigate('/pricing')} className="flex-1">
Upgrade Plan Upgrade Plan
</Button> </Button>
) : ( ) : (
<Button variant="outline" onClick={handlePortal} loading={loading} className="flex-1"> <Button variant="outline" onClick={handlePortal} loading={loading} className="flex-1">
<ExternalLink className="w-3.5 h-3.5" /> <ExternalLink className="w-3.5 h-3.5" />
Manage Billing Manage Billing
</Button> </Button>
)} )}
</div> </div>
</Card> </Card>
{/* Plan features */} {/* Plan features */}
<Card className="p-6"> <Card className="p-6">
<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>
<span className="text-sm font-medium text-gray-900">{value}</span> <span className="text-sm font-medium text-gray-900">{value}</span>
</div> </div>
))} ))}
</div> </div>
</Card> </Card>
</div> </div>
) )
} }

View File

@@ -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,33 +12,46 @@ 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)
}
) )
// ─── Auth ────────────────────────────────────────────────────────────────────── // ─── Auth ──────────────────────────────────────────────────────────────────────
export const authAPI = { export const authAPI = {
signup: (data: { email: string; password: string; company_name: string }) => signup: (data: { email: string; password: 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),
@@ -51,87 +65,75 @@ export const chatbotsAPI = {
get: (id: string) => api.get<Chatbot>(`/chatbots/${id}`).then(r => r.data), get: (id: string) => api.get<Chatbot>(`/chatbots/${id}`).then(r => r.data),
create: (data: ChatbotFormData) => create: (data: ChatbotFormData) =>
api.post<Chatbot>('/chatbots', data).then(r => r.data), api.post<Chatbot>('/chatbots', data).then(r => r.data),
update: (id: string, data: Partial<ChatbotFormData>) => update: (id: string, data: Partial<ChatbotFormData>) =>
api.put<Chatbot>(`/chatbots/${id}`, data).then(r => r.data), api.put<Chatbot>(`/chatbots/${id}`, data).then(r => r.data),
delete: (id: string) => delete: (id: string) =>
api.delete(`/chatbots/${id}`).then(r => r.data), api.delete(`/chatbots/${id}`).then(r => r.data),
publish: (id: string) => publish: (id: string) =>
api.post<Chatbot>(`/chatbots/${id}/publish`).then(r => r.data), api.post<Chatbot>(`/chatbots/${id}/publish`).then(r => r.data),
unpublish: (id: string) => unpublish: (id: string) =>
api.post<Chatbot>(`/chatbots/${id}/unpublish`).then(r => r.data), api.post<Chatbot>(`/chatbots/${id}/unpublish`).then(r => r.data),
export: (id: string) => export: (id: string) =>
api.post(`/chatbots/${id}/export`, {}, { responseType: 'blob' }).then(r => r.data), api.post(`/chatbots/${id}/export`, {}, { responseType: 'blob' }).then(r => r.data),
} }
// ─── Documents ──────────────────────────────────────────────────────────────── // ─── Documents ────────────────────────────────────────────────────────────────
export const documentsAPI = { 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`, headers: { 'Content-Type': 'multipart/form-data' },
form, onUploadProgress: (e) => {
{ if (onProgress && e.total) onProgress(Math.round((e.loaded * 100) / e.total))
headers: { 'Content-Type': 'multipart/form-data' }, },
onUploadProgress: (e) => { }).then(r => r.data)
if (onProgress && e.total) onProgress(Math.round((e.loaded / e.total) * 100))
},
}
).then(r => r.data)
}, },
delete: (chatbotId: string, docId: string) => delete: (chatbotId: string, docId: string) =>
api.delete(`/chatbots/${chatbotId}/documents/${docId}`).then(r => r.data), api.delete(`/chatbots/${chatbotId}/documents/${docId}`).then(r => r.data),
} }
// ─── 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),
} }

View File

@@ -3,44 +3,44 @@ import { persist } from 'zustand/middleware'
import type { User } from '@/types' import type { User } from '@/types'
interface AuthState { interface AuthState {
user: User | null user: User | null
token: string | null token: string | null
isAuthenticated: boolean isAuthenticated: boolean
setAuth: (user: User, token: string) => void setAuth: (user: User, token: string) => void
logout: () => void logout: () => void
updateUser: (user: Partial<User>) => void updateUser: (user: Partial<User>) => void
} }
export const useAuthStore = create<AuthState>()( export const useAuthStore = create<AuthState>()(
persist( persist(
(set) => ({ (set) => ({
user: null, user: null,
token: null, token: null,
isAuthenticated: false, isAuthenticated: false,
setAuth: (user, token) => { // BUG-01 FIX: Removed manual localStorage.setItem('access_token', token)
localStorage.setItem('access_token', token) // Zustand persist middleware is the single source of truth.
set({ user, token, isAuthenticated: true }) // The API interceptor now reads from Zustand store directly.
}, setAuth: (user, token) => {
set({ user, token, isAuthenticated: true })
},
logout: () => { logout: () => {
localStorage.removeItem('access_token') set({ user: null, token: null, isAuthenticated: false })
localStorage.removeItem('user') },
set({ user: null, token: null, isAuthenticated: false })
},
updateUser: (updates) => updateUser: (updates) =>
set((state) => ({ set((state) => ({
user: state.user ? { ...state.user, ...updates } : null, user: state.user ? { ...state.user, ...updates } : null,
})), })),
}), }),
{ {
name: 'contexta-auth', name: 'contexta-auth',
partialize: (state) => ({ partialize: (state) => ({
user: state.user, user: state.user,
token: state.token, token: state.token,
isAuthenticated: state.isAuthenticated, isAuthenticated: state.isAuthenticated,
}), }),
} }
) )
) )