Updates Apr3: new pages, components, and widespread UI/API improvements

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
belviskhoremk
2026-04-03 09:15:25 +00:00
parent d07111a4f2
commit 56ce9717aa
34 changed files with 6810 additions and 2291 deletions

View File

@@ -2,7 +2,9 @@ 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 { AdminLayout } from '@/components/AdminLayout'
import { PublicLayout } from '@/components/PublicLayout' import { PublicLayout } from '@/components/PublicLayout'
import { ErrorBoundary } from '@/components/ErrorBoundary'
import { Spinner } from '@/components/ui' import { Spinner } from '@/components/ui'
import './App.css' import './App.css'
@@ -22,6 +24,16 @@ const AnalyticsPage = lazy(() => import('@/pages/AnalyticsPage').then(m => ({ de
const PublicChatPage = lazy(() => import('@/pages/PublicChatPage').then(m => ({ default: m.PublicChatPage }))) const PublicChatPage = lazy(() => import('@/pages/PublicChatPage').then(m => ({ default: m.PublicChatPage })))
const InboxPage = lazy(() => import('@/pages/InboxPage').then(m => ({ default: m.InboxPage }))) const InboxPage = lazy(() => import('@/pages/InboxPage').then(m => ({ default: m.InboxPage })))
const LeadsPage = lazy(() => import('@/pages/LeadsPage').then(m => ({ default: m.LeadsPage }))) const LeadsPage = lazy(() => import('@/pages/LeadsPage').then(m => ({ default: m.LeadsPage })))
const AppointmentsPage = lazy(() => import('@/pages/AppointmentsPage').then(m => ({ default: m.AppointmentsPage })))
const CampaignsPage = lazy(() => import('@/pages/CampaignsPage').then(m => ({ default: m.CampaignsPage })))
const PublicBookingPage = lazy(() => import('@/pages/PublicBookingPage').then(m => ({ default: m.PublicBookingPage })))
// Admin pages
const AdminDashboardPage = lazy(() => import('@/pages/admin/AdminDashboardPage').then(m => ({ default: m.AdminDashboardPage })))
const AdminUsersPage = lazy(() => import('@/pages/admin/AdminUsersPage').then(m => ({ default: m.AdminUsersPage })))
const AdminChatbotsPage = lazy(() => import('@/pages/admin/AdminChatbotsPage').then(m => ({ default: m.AdminChatbotsPage })))
const AdminConversationsPage = lazy(() => import('@/pages/admin/AdminConversationsPage').then(m => ({ default: m.AdminConversationsPage })))
const AdminSystemPage = lazy(() => import('@/pages/admin/AdminSystemPage').then(m => ({ default: m.AdminSystemPage })))
const PageLoader = () => ( const PageLoader = () => (
<div className="flex items-center justify-center min-h-screen"> <div className="flex items-center justify-center min-h-screen">
@@ -35,6 +47,13 @@ const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) =>
return <AppLayout>{children}</AppLayout> return <AppLayout>{children}</AppLayout>
} }
const AdminRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isAuthenticated, user } = useAuthStore()
if (!isAuthenticated) return <Navigate to="/login" replace />
if (!user?.is_admin) return <Navigate to="/dashboard" replace />
return <AdminLayout>{children}</AdminLayout>
}
const PublicOnlyRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => { const PublicOnlyRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isAuthenticated } = useAuthStore() const { isAuthenticated } = useAuthStore()
if (isAuthenticated) return <Navigate to="/dashboard" replace /> if (isAuthenticated) return <Navigate to="/dashboard" replace />
@@ -50,6 +69,7 @@ const SmartPublicRoute: React.FC<{ children: React.ReactNode }> = ({ children })
} }
export const App: React.FC = () => ( export const App: React.FC = () => (
<ErrorBoundary>
<Suspense fallback={<PageLoader />}> <Suspense fallback={<PageLoader />}>
<Routes> <Routes>
{/* Public - Landing has its own nav */} {/* Public - Landing has its own nav */}
@@ -63,6 +83,9 @@ export const App: React.FC = () => (
{/* Public chat - no auth, no layout */} {/* Public chat - no auth, no layout */}
<Route path="/chat/:id" element={<PublicChatPage />} /> <Route path="/chat/:id" element={<PublicChatPage />} />
{/* Public booking - no auth, no layout */}
<Route path="/book/:chatbotId" element={<PublicBookingPage />} />
{/* Auth */} {/* Auth */}
<Route path="/login" element={<PublicOnlyRoute><LoginPage /></PublicOnlyRoute>} /> <Route path="/login" element={<PublicOnlyRoute><LoginPage /></PublicOnlyRoute>} />
<Route path="/signup" element={<PublicOnlyRoute><SignupPage /></PublicOnlyRoute>} /> <Route path="/signup" element={<PublicOnlyRoute><SignupPage /></PublicOnlyRoute>} />
@@ -77,11 +100,21 @@ export const App: React.FC = () => (
<Route path="/chatbots/:id/preview" element={<PrivateRoute><ChatbotBuilderPage /></PrivateRoute>} /> <Route path="/chatbots/:id/preview" element={<PrivateRoute><ChatbotBuilderPage /></PrivateRoute>} />
<Route path="/inbox" element={<PrivateRoute><InboxPage /></PrivateRoute>} /> <Route path="/inbox" element={<PrivateRoute><InboxPage /></PrivateRoute>} />
<Route path="/leads" element={<PrivateRoute><LeadsPage /></PrivateRoute>} /> <Route path="/leads" element={<PrivateRoute><LeadsPage /></PrivateRoute>} />
<Route path="/appointments" element={<PrivateRoute><AppointmentsPage /></PrivateRoute>} />
<Route path="/campaigns" element={<PrivateRoute><CampaignsPage /></PrivateRoute>} />
<Route path="/settings" element={<PrivateRoute><SettingsPage /></PrivateRoute>} /> <Route path="/settings" element={<PrivateRoute><SettingsPage /></PrivateRoute>} />
<Route path="/settings/billing" element={<PrivateRoute><SettingsPage /></PrivateRoute>} /> <Route path="/settings/billing" element={<PrivateRoute><SettingsPage /></PrivateRoute>} />
{/* Admin */}
<Route path="/admin" element={<AdminRoute><AdminDashboardPage /></AdminRoute>} />
<Route path="/admin/users" element={<AdminRoute><AdminUsersPage /></AdminRoute>} />
<Route path="/admin/chatbots" element={<AdminRoute><AdminChatbotsPage /></AdminRoute>} />
<Route path="/admin/conversations" element={<AdminRoute><AdminConversationsPage /></AdminRoute>} />
<Route path="/admin/system" element={<AdminRoute><AdminSystemPage /></AdminRoute>} />
{/* Fallback */} {/* Fallback */}
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</Suspense> </Suspense>
</ErrorBoundary>
) )

View File

@@ -0,0 +1,139 @@
import React, { useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/store/authStore'
import { authAPI } from '@/services/api'
import {
LayoutDashboard, Users, Bot, MessageSquare, Activity,
LogOut, Menu, Shield, X, ChevronRight,
} from 'lucide-react'
const NAV_ITEMS = [
{ label: 'Overview', href: '/admin', icon: LayoutDashboard, exact: true },
{ label: 'Users', href: '/admin/users', icon: Users },
{ label: 'Chatbots', href: '/admin/chatbots', icon: Bot },
{ label: 'Conversations', href: '/admin/conversations', icon: MessageSquare },
{ label: 'System Health', href: '/admin/system', icon: Activity },
]
export const AdminLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { user, logout } = useAuthStore()
const location = useLocation()
const navigate = useNavigate()
const [sidebarOpen, setSidebarOpen] = useState(false)
const handleLogout = async () => {
try { await authAPI.logout() } catch { /* ignore */ }
logout()
navigate('/login')
}
const isActive = (item: typeof NAV_ITEMS[0]) =>
item.exact ? location.pathname === item.href : location.pathname.startsWith(item.href)
return (
<div className="flex h-screen bg-gray-950 overflow-hidden">
{/* Mobile backdrop */}
{sidebarOpen && (
<div
className="fixed inset-0 z-20 bg-black/60 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<aside className={cn(
'fixed lg:relative z-30 flex flex-col w-64 h-full bg-gray-900 border-r border-gray-800 transition-transform duration-200',
sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
)}>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-800">
<Link to="/dashboard" className="flex items-center gap-2 group">
<div className="w-8 h-8 bg-red-600 rounded-lg flex items-center justify-center shadow-sm">
<Shield className="w-4 h-4 text-white" />
</div>
<div>
<span className="font-bold text-white text-sm tracking-tight">Admin Panel</span>
<p className="text-gray-500 text-xs">Contexta</p>
</div>
</Link>
<button
className="lg:hidden text-gray-400 hover:text-white"
onClick={() => setSidebarOpen(false)}
>
<X className="w-4 h-4" />
</button>
</div>
{/* Nav */}
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
{NAV_ITEMS.map(({ label, href, icon: Icon, exact }) => {
const active = exact ? location.pathname === href : location.pathname.startsWith(href)
return (
<Link
key={href}
to={href}
onClick={() => setSidebarOpen(false)}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-150',
active
? 'bg-red-600/20 text-red-400 border border-red-600/30'
: 'text-gray-400 hover:bg-gray-800 hover:text-gray-200'
)}
>
<Icon className="w-4 h-4 shrink-0" />
<span>{label}</span>
{active && <ChevronRight className="ml-auto w-3 h-3" />}
</Link>
)
})}
</nav>
{/* Back to app + user */}
<div className="px-4 py-4 border-t border-gray-800 space-y-2">
<Link
to="/dashboard"
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-400 hover:text-gray-200 hover:bg-gray-800 rounded-lg transition-colors"
>
<ChevronRight className="w-4 h-4 rotate-180" />
Back to App
</Link>
<div className="flex items-center gap-2 px-2 py-1">
<div className="w-7 h-7 rounded-full bg-red-700 flex items-center justify-center text-white font-semibold text-xs">
{user?.email?.charAt(0).toUpperCase() || 'A'}
</div>
<p className="text-xs text-gray-400 truncate flex-1">{user?.email}</p>
</div>
<button
onClick={handleLogout}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-400 hover:text-red-400 hover:bg-red-900/20 rounded-lg transition-colors"
>
<LogOut className="w-4 h-4" />
Sign out
</button>
</div>
</aside>
{/* Main */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Mobile header */}
<header className="lg:hidden flex items-center gap-3 px-4 py-3 bg-gray-900 border-b border-gray-800">
<button
onClick={() => setSidebarOpen(true)}
className="p-1.5 rounded-lg hover:bg-gray-800 transition-colors text-gray-400"
>
<Menu className="w-5 h-5" />
</button>
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-red-500" />
<span className="font-bold text-white text-sm">Admin Panel</span>
</div>
</header>
<main className="flex-1 overflow-y-auto bg-gray-950">
{children}
</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,45 @@
import React from 'react'
interface Props {
children: React.ReactNode
}
interface State {
hasError: boolean
error?: Error
}
export class ErrorBoundary extends React.Component<Props, State> {
state: State = { hasError: false }
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error('[ErrorBoundary]', error, info)
}
render() {
if (this.state.hasError) {
return (
<div className="flex flex-col items-center justify-center min-h-screen gap-4 px-4 text-center">
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center text-3xl">
</div>
<h1 className="text-xl font-semibold text-gray-900">Something went wrong</h1>
<p className="text-gray-500 text-sm max-w-md">
{this.state.error?.message ?? 'An unexpected error occurred.'}
</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700 transition-colors"
>
Reload page
</button>
</div>
)
}
return this.props.children
}
}

View File

@@ -6,13 +6,16 @@ import { authAPI } from '@/services/api'
import { getPlanColor } from '@/lib/utils' import { getPlanColor } from '@/lib/utils'
import { import {
LayoutDashboard, ShoppingBag, Settings, LayoutDashboard, ShoppingBag, Settings,
LogOut, Menu, Sparkles, BarChart3, Mail, Users LogOut, Menu, Sparkles, BarChart3, Mail, Users,
Shield, X, CalendarDays, Megaphone,
} from 'lucide-react' } from 'lucide-react'
const NAV_ITEMS = [ const NAV_ITEMS = [
{ label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard }, { label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
{ label: 'Inbox', href: '/inbox', icon: Mail }, { label: 'Inbox', href: '/inbox', icon: Mail },
{ label: 'Leads', href: '/leads', icon: Users }, { label: 'Leads', href: '/leads', icon: Users },
{ label: 'Appointments', href: '/appointments', icon: CalendarDays },
{ label: 'Campaigns', href: '/campaigns', icon: Megaphone },
{ label: 'Analytics', href: '/analytics', icon: BarChart3 }, { label: 'Analytics', href: '/analytics', icon: BarChart3 },
{ label: 'Marketplace', href: '/marketplace', icon: ShoppingBag }, { label: 'Marketplace', href: '/marketplace', icon: ShoppingBag },
{ label: 'Settings', href: '/settings', icon: Settings }, { label: 'Settings', href: '/settings', icon: Settings },
@@ -25,71 +28,101 @@ export const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children })
const [sidebarOpen, setSidebarOpen] = useState(false) const [sidebarOpen, setSidebarOpen] = useState(false)
const handleLogout = async () => { const handleLogout = async () => {
try { await authAPI.logout() } catch { /* intentionally ignored */ } try { await authAPI.logout() } catch { /* ignore */ }
logout() logout()
navigate('/login') navigate('/login')
} }
const initial = user?.email?.charAt(0).toUpperCase() || '?'
return ( return (
<div className="flex h-screen bg-gray-50 overflow-hidden"> <div className="flex h-screen bg-gray-50 overflow-hidden">
{/* Mobile backdrop */} {/* Mobile backdrop */}
{sidebarOpen && ( {sidebarOpen && (
<div className="fixed inset-0 z-20 bg-black/40 lg:hidden" onClick={() => setSidebarOpen(false)} /> <div
className="fixed inset-0 z-20 bg-black/30 backdrop-blur-[2px] lg:hidden transition-opacity"
onClick={() => setSidebarOpen(false)}
/>
)} )}
{/* Sidebar */} {/* Sidebar */}
<aside className={cn( <aside className={cn(
'fixed lg:relative z-30 flex flex-col w-64 h-full bg-white border-r border-gray-200 transition-transform duration-200', 'fixed lg:relative z-30 flex flex-col w-64 h-full',
'bg-white border-r border-gray-100',
'transition-transform duration-300 ease-out',
sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0' sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
)}> )}>
{/* Logo */} {/* Logo */}
<div className="flex items-center gap-2 px-6 py-5 border-b border-gray-100"> <div className="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<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"> <div className="flex items-center gap-2.5">
<div className="w-8 h-8 bg-gradient-to-br from-primary-500 to-primary-700 rounded-xl flex items-center justify-center shadow-sm shadow-primary-200">
<Sparkles className="w-4 h-4 text-white" /> <Sparkles className="w-4 h-4 text-white" />
</div> </div>
<span className="font-bold text-gray-900 text-lg tracking-tight">Contexta</span> <span className="font-bold text-gray-900 text-lg tracking-tight">Contexta</span>
</div> </div>
<button
className="lg:hidden w-7 h-7 flex items-center justify-center rounded-lg hover:bg-gray-100 text-gray-400 transition-colors"
onClick={() => setSidebarOpen(false)}
>
<X className="w-4 h-4" />
</button>
</div>
{/* Nav */} {/* Nav */}
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto"> <nav className="flex-1 px-3 py-3 space-y-0.5 overflow-y-auto">
{NAV_ITEMS.map(({ label, href, icon: Icon }) => { {NAV_ITEMS.map(({ label, href, icon: Icon }) => {
const active = location.pathname.startsWith(href) const active = location.pathname.startsWith(href)
return ( return (
<Link <Link
key={href} key={href}
to={href} to={href}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-150',
active
? 'bg-primary-50 text-primary-700 shadow-sm'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 hover:translate-x-0.5'
)}
onClick={() => setSidebarOpen(false)} onClick={() => setSidebarOpen(false)}
className={cn(
'group flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium',
'transition-all duration-150',
active
? 'bg-primary-50 text-primary-700'
: 'text-gray-500 hover:bg-gray-50 hover:text-gray-900'
)}
> >
<Icon className={cn('w-4 h-4 transition-transform duration-150', active && 'scale-110')} /> <Icon className={cn(
{label} 'w-4 h-4 shrink-0 transition-transform duration-150',
{active && <span className="ml-auto w-1.5 h-1.5 rounded-full bg-primary-500" />} active ? 'text-primary-600' : 'text-gray-400 group-hover:text-gray-600',
)} />
<span className="flex-1">{label}</span>
{active && (
<span className="w-1.5 h-1.5 rounded-full bg-primary-500" />
)}
</Link> </Link>
) )
})} })}
</nav> </nav>
{/* User profile */} {/* User section */}
<div className="px-4 py-4 border-t border-gray-100 bg-gray-50/50"> <div className="px-3 py-3 border-t border-gray-50 space-y-1">
<div className="flex items-center gap-3 mb-3"> {user?.is_admin && (
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center text-white font-semibold text-sm shadow-sm"> <Link
{user?.email?.charAt(0).toUpperCase() || '?'} to="/admin"
className="flex items-center gap-2.5 px-3 py-2 rounded-xl text-sm font-medium text-red-600 hover:bg-red-50 transition-colors"
>
<Shield className="w-4 h-4" />
Admin Panel
</Link>
)}
<div className="flex items-center gap-2.5 px-3 py-2">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center text-white font-semibold text-sm shrink-0">
{initial}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{user?.email}</p> <p className="text-xs font-medium text-gray-900 truncate">{user?.email}</p>
<span className={cn('text-xs px-2 py-0.5 rounded-full font-medium', getPlanColor(user?.plan || 'free'))}> <span className={cn('text-xs px-1.5 py-0.5 rounded-full font-medium capitalize', getPlanColor(user?.plan || 'free'))}>
{(user?.plan || 'free').charAt(0).toUpperCase() + (user?.plan || 'free').slice(1)} {user?.plan || 'free'}
</span> </span>
</div> </div>
</div> </div>
<button <button
onClick={handleLogout} onClick={handleLogout}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-600 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-xl transition-all duration-150"
> >
<LogOut className="w-4 h-4" /> <LogOut className="w-4 h-4" />
Sign out Sign out
@@ -98,24 +131,24 @@ export const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children })
</aside> </aside>
{/* Main */} {/* Main */}
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden min-w-0">
{/* Mobile header */} {/* Mobile header */}
<header className="lg:hidden flex items-center gap-3 px-4 py-3 bg-white border-b border-gray-200"> <header className="lg:hidden flex items-center gap-3 px-4 py-3 bg-white border-b border-gray-100 shrink-0">
<button <button
onClick={() => setSidebarOpen(true)} onClick={() => setSidebarOpen(true)}
className="p-1.5 rounded-lg hover:bg-gray-100 transition-colors" className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100 transition-colors"
aria-label="Open navigation"
> >
<Menu className="w-5 h-5 text-gray-600" /> <Menu className="w-5 h-5 text-gray-600" />
</button> </button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-6 h-6 bg-primary-600 rounded-md flex items-center justify-center"> <div className="w-6 h-6 bg-gradient-to-br from-primary-500 to-primary-700 rounded-lg flex items-center justify-center">
<Sparkles className="w-3 h-3 text-white" /> <Sparkles className="w-3 h-3 text-white" />
</div> </div>
<span className="font-bold text-gray-900 text-sm">Contexta</span> <span className="font-bold text-gray-900 text-sm">Contexta</span>
</div> </div>
</header> </header>
{/* Page content */}
<main className="flex-1 overflow-y-auto"> <main className="flex-1 overflow-y-auto">
{children} {children}
</main> </main>

View File

@@ -0,0 +1,65 @@
import React from 'react'
import { cn } from '@/lib/utils'
const Pulse: React.FC<{ className?: string }> = ({ className }) => (
<div className={cn('animate-pulse bg-gray-200 rounded', className)} />
)
export const SkeletonCard: React.FC<{ className?: string }> = ({ className }) => (
<div className={cn('bg-white rounded-xl border border-gray-200 p-5 space-y-3', className)}>
<div className="flex items-center gap-3">
<Pulse className="w-10 h-10 rounded-lg" />
<div className="flex-1 space-y-2">
<Pulse className="h-4 w-2/3" />
<Pulse className="h-3 w-1/3" />
</div>
</div>
<Pulse className="h-3 w-full" />
<Pulse className="h-3 w-4/5" />
<div className="flex gap-2 pt-1">
<Pulse className="h-6 w-16 rounded-full" />
<Pulse className="h-6 w-16 rounded-full" />
</div>
</div>
)
export const SkeletonTable: React.FC<{ rows?: number }> = ({ rows = 5 }) => (
<div className="space-y-2">
<Pulse className="h-10 w-full rounded-lg mb-3" />
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex gap-4 px-4 py-3 bg-white rounded-lg border border-gray-100">
<Pulse className="h-4 w-8" />
<Pulse className="h-4 flex-1" />
<Pulse className="h-4 w-24" />
<Pulse className="h-4 w-20" />
<Pulse className="h-4 w-16" />
</div>
))}
</div>
)
export const SkeletonList: React.FC<{ rows?: number }> = ({ rows = 6 }) => (
<div className="space-y-3">
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex items-center gap-3 p-4 bg-white rounded-xl border border-gray-100">
<Pulse className="w-9 h-9 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<Pulse className="h-4 w-1/2" />
<Pulse className="h-3 w-1/3" />
</div>
<Pulse className="h-4 w-20" />
</div>
))}
</div>
)
export const SkeletonStatCard: React.FC = () => (
<div className="bg-white rounded-xl border border-gray-200 p-5 space-y-3">
<div className="flex items-center justify-between">
<Pulse className="h-4 w-28" />
<Pulse className="w-8 h-8 rounded-lg" />
</div>
<Pulse className="h-8 w-20" />
<Pulse className="h-3 w-24" />
</div>
)

View File

@@ -1,5 +1,6 @@
import React from 'react' import React, { useEffect, useRef } from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { X } from 'lucide-react'
// ─── Button ──────────────────────────────────────────────────────────────────── // ─── Button ────────────────────────────────────────────────────────────────────
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
@@ -11,24 +12,48 @@ interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
export const Button: React.FC<ButtonProps> = ({ export const Button: React.FC<ButtonProps> = ({
variant = 'primary', size = 'md', loading, disabled, children, className, ...props variant = 'primary', size = 'md', loading, disabled, children, className, ...props
}) => { }) => {
const base = 'inline-flex items-center justify-center gap-2 font-medium rounded-lg transition-all focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed active:scale-95' const base = cn(
'inline-flex items-center justify-center gap-2 font-medium rounded-xl',
'transition-all duration-200',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
'disabled:opacity-50 disabled:cursor-not-allowed',
'active:scale-[0.97] select-none',
)
const variants = { const variants = {
primary: 'bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500', primary: cn(
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-400', 'bg-gradient-to-b from-primary-500 to-primary-600 text-white',
outline: 'border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 focus:ring-primary-500', 'hover:from-primary-600 hover:to-primary-700',
ghost: 'text-gray-600 hover:bg-gray-100 focus:ring-gray-400', 'focus-visible:ring-primary-500',
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500', 'shadow-sm shadow-primary-200/60 hover:shadow-md hover:shadow-primary-300/40',
),
secondary: 'bg-gray-100 text-gray-800 hover:bg-gray-200 focus-visible:ring-gray-400',
outline: cn(
'border border-gray-200 bg-white text-gray-700',
'hover:bg-gray-50 hover:border-gray-300',
'focus-visible:ring-primary-500 shadow-sm',
),
ghost: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 focus-visible:ring-gray-400',
danger: cn(
'bg-gradient-to-b from-red-500 to-red-600 text-white',
'hover:from-red-600 hover:to-red-700',
'focus-visible:ring-red-500 shadow-sm',
),
}
const sizes = {
sm: 'px-3 py-1.5 text-xs gap-1.5',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-2.5 text-base',
} }
const sizes = { sm: 'px-3 py-1.5 text-sm', md: 'px-4 py-2 text-sm', lg: 'px-6 py-3 text-base' }
return ( return (
<button <button
className={cn(base, variants[variant], sizes[size], className)} className={cn(base, variants[variant], sizes[size], className)}
disabled={disabled || loading} disabled={disabled || loading}
aria-busy={loading}
{...props} {...props}
> >
{loading && ( {loading && (
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none"> <svg className="w-3.5 h-3.5 animate-spin shrink-0" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" /> <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg> </svg>
@@ -43,9 +68,10 @@ interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string label?: string
error?: string error?: string
hint?: string hint?: string
icon?: React.ReactNode
} }
export const Input: React.FC<InputProps> = ({ label, error, hint, className, id, ...props }) => { export const Input: React.FC<InputProps> = ({ label, error, hint, icon, className, id, ...props }) => {
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-') const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
return ( return (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
@@ -54,17 +80,31 @@ export const Input: React.FC<InputProps> = ({ label, error, hint, className, id,
{label} {label}
</label> </label>
)} )}
<div className="relative">
{icon && (
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
{icon}
</div>
)}
<input <input
id={inputId} id={inputId}
className={cn( className={cn(
'w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors', 'w-full py-2.5 border rounded-xl text-sm',
error ? 'border-red-400 bg-red-50' : 'border-gray-300 bg-white hover:border-gray-400', 'transition-all duration-150',
'focus:outline-none focus:ring-2 focus:ring-primary-500/30 focus:border-primary-400',
'placeholder:text-gray-400',
icon ? 'pl-10 pr-3' : 'px-3.5',
error
? 'border-red-300 bg-red-50/50 focus:ring-red-300/30 focus:border-red-400'
: 'border-gray-200 bg-white hover:border-gray-300',
props.disabled && 'bg-gray-50 text-gray-500 cursor-not-allowed',
className className
)} )}
{...props} {...props}
/> />
{error && <p className="text-xs text-red-600">{error}</p>} </div>
{hint && !error && <p className="text-xs text-gray-500">{hint}</p>} {error && <p className="text-xs text-red-500 flex items-center gap-1">{error}</p>}
{hint && !error && <p className="text-xs text-gray-400">{hint}</p>}
</div> </div>
) )
} }
@@ -80,18 +120,27 @@ export const Textarea: React.FC<TextareaProps> = ({ label, error, hint, classNam
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-') const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
return ( return (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
{label && <label htmlFor={inputId} className="text-sm font-medium text-gray-700">{label}</label>} {label && (
<label htmlFor={inputId} className="text-sm font-medium text-gray-700">
{label}
</label>
)}
<textarea <textarea
id={inputId} id={inputId}
className={cn( className={cn(
'w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none transition-colors', 'w-full px-3.5 py-2.5 border rounded-xl text-sm resize-none',
error ? 'border-red-400 bg-red-50' : 'border-gray-300 bg-white hover:border-gray-400', 'transition-all duration-150',
'focus:outline-none focus:ring-2 focus:ring-primary-500/30 focus:border-primary-400',
'placeholder:text-gray-400',
error
? 'border-red-300 bg-red-50/50'
: 'border-gray-200 bg-white hover:border-gray-300',
className className
)} )}
{...props} {...props}
/> />
{error && <p className="text-xs text-red-600">{error}</p>} {error && <p className="text-xs text-red-500">{error}</p>}
{hint && !error && <p className="text-xs text-gray-500">{hint}</p>} {hint && !error && <p className="text-xs text-gray-400">{hint}</p>}
</div> </div>
) )
} }
@@ -107,12 +156,18 @@ export const Select: React.FC<SelectProps> = ({ label, error, options, className
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-') const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
return ( return (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
{label && <label htmlFor={inputId} className="text-sm font-medium text-gray-700">{label}</label>} {label && (
<label htmlFor={inputId} className="text-sm font-medium text-gray-700">
{label}
</label>
)}
<select <select
id={inputId} id={inputId}
className={cn( className={cn(
'w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white', 'w-full px-3.5 py-2.5 border rounded-xl text-sm bg-white',
error ? 'border-red-400' : 'border-gray-300', 'transition-all duration-150',
'focus:outline-none focus:ring-2 focus:ring-primary-500/30 focus:border-primary-400',
error ? 'border-red-300' : 'border-gray-200 hover:border-gray-300',
className className
)} )}
{...props} {...props}
@@ -121,19 +176,21 @@ export const Select: React.FC<SelectProps> = ({ label, error, options, className
<option key={o.value} value={o.value} disabled={o.disabled}>{o.label}</option> <option key={o.value} value={o.value} disabled={o.disabled}>{o.label}</option>
))} ))}
</select> </select>
{error && <p className="text-xs text-red-600">{error}</p>} {error && <p className="text-xs text-red-500">{error}</p>}
</div> </div>
) )
} }
// ─── Card ────────────────────────────────────────────────────────────────────── // ─── Card ──────────────────────────────────────────────────────────────────────
export const Card: React.FC<{ children: React.ReactNode; className?: string; onClick?: () => void }> = ({ export const Card: React.FC<{
children, className, onClick children: React.ReactNode
}) => ( className?: string
onClick?: () => void
}> = ({ children, className, onClick }) => (
<div <div
className={cn( className={cn(
'bg-white rounded-xl border border-gray-200 shadow-sm transition-all duration-200', 'bg-white rounded-2xl border border-gray-100 shadow-sm transition-all duration-200',
onClick && 'cursor-pointer hover:-translate-y-1 hover:shadow-lg hover:border-gray-300', onClick && 'cursor-pointer hover:-translate-y-0.5 hover:shadow-md hover:border-gray-200',
className className
)} )}
onClick={onClick} onClick={onClick}
@@ -151,38 +208,50 @@ interface BadgeProps {
export const Badge: React.FC<BadgeProps> = ({ children, variant = 'default', className }) => { export const Badge: React.FC<BadgeProps> = ({ children, variant = 'default', className }) => {
const variants = { const variants = {
default: 'bg-gray-100 text-gray-700', default: 'bg-gray-100 text-gray-600 border-gray-200',
success: 'bg-green-100 text-green-700', success: 'bg-green-50 text-green-700 border-green-200',
warning: 'bg-yellow-100 text-yellow-700', warning: 'bg-amber-50 text-amber-700 border-amber-200',
error: 'bg-red-100 text-red-700', error: 'bg-red-50 text-red-600 border-red-200',
info: 'bg-blue-100 text-blue-700', info: 'bg-blue-50 text-blue-700 border-blue-200',
purple: 'bg-purple-100 text-purple-700', purple: 'bg-purple-50 text-purple-700 border-purple-200',
} }
return ( return (
<span className={cn('inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full', variants[variant], className)}> <span className={cn(
'inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full border',
variants[variant], className
)}>
{children} {children}
</span> </span>
) )
} }
// ─── Spinner ─────────────────────────────────────────────────────────────────── // ─── Spinner ───────────────────────────────────────────────────────────────────
export const Spinner: React.FC<{ className?: string }> = ({ className }) => ( export const Spinner: React.FC<{ className?: string; size?: 'sm' | 'md' | 'lg' }> = ({
<svg className={cn('animate-spin h-5 w-5', className)} viewBox="0 0 24 24" fill="none"> className, size = 'md'
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> }) => {
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" /> const sizes = { sm: 'h-4 w-4', md: 'h-5 w-5', lg: 'h-7 w-7' }
return (
<svg className={cn('animate-spin', sizes[size], className)} viewBox="0 0 24 24" fill="none">
<circle className="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-80" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg> </svg>
) )
}
// ─── Empty State ─────────────────────────────────────────────────────────────── // ─── Empty State ───────────────────────────────────────────────────────────────
export const EmptyState: React.FC<{ export const EmptyState: React.FC<{
icon: React.ReactNode; title: string; description: string; action?: React.ReactNode icon: React.ReactNode
}> = ({ icon, title, description, action }) => ( title: string
<div className="flex flex-col items-center justify-center py-16 text-center animate-fade-in-up"> description: string
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-primary-50 to-primary-100 flex items-center justify-center mb-4 text-primary-400 shadow-sm"> action?: React.ReactNode
className?: string
}> = ({ icon, title, description, action, className }) => (
<div className={cn('flex flex-col items-center justify-center py-16 text-center animate-fade-in-up px-4', className)}>
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-primary-50 to-primary-100 flex items-center justify-center mb-5 text-primary-500 shadow-sm ring-1 ring-primary-100">
{icon} {icon}
</div> </div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3> <h3 className="text-base font-semibold text-gray-900 mb-1.5">{title}</h3>
<p className="text-sm text-gray-500 max-w-sm mb-6">{description}</p> <p className="text-sm text-gray-500 max-w-xs mb-6 leading-relaxed">{description}</p>
{action} {action}
</div> </div>
) )
@@ -194,21 +263,43 @@ interface ModalProps {
title?: string title?: string
children: React.ReactNode children: React.ReactNode
className?: string className?: string
size?: 'sm' | 'md' | 'lg'
} }
export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, className }) => { export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, className, size = 'md' }) => {
const sizes = { sm: 'max-w-sm', md: 'max-w-lg', lg: 'max-w-2xl' }
useEffect(() => {
if (!isOpen) return
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
document.addEventListener('keydown', onKey)
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', onKey)
document.body.style.overflow = ''
}
}, [isOpen, onClose])
if (!isOpen) return null if (!isOpen) return null
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 animate-fade-in"> <div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4 animate-fade-in">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} /> <div className="absolute inset-0 bg-black/40 backdrop-blur-[2px]" onClick={onClose} />
<div className={cn('relative bg-white rounded-2xl shadow-2xl w-full max-w-lg animate-scale-in', className)}> <div className={cn(
'relative bg-white w-full sm:rounded-2xl shadow-2xl animate-scale-in',
'rounded-t-2xl',
sizes[size],
className
)}>
{title && ( {title && (
<div className="flex items-center justify-between p-6 border-b"> <div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<h2 className="text-lg font-semibold">{title}</h2> <h2 className="text-base font-semibold text-gray-900">{title}</h2>
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded-lg text-gray-500"> <button
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> onClick={onClose}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> className="w-8 h-8 flex items-center justify-center hover:bg-gray-100 rounded-lg text-gray-400 hover:text-gray-600 transition-colors"
</svg> aria-label="Close"
>
<X className="w-4 h-4" />
</button> </button>
</div> </div>
)} )}
@@ -218,11 +309,11 @@ export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children,
) )
} }
// ─── Toast ───────────────────────────────────────────────────────────────────── // ─── Toast (kept for legacy usage) ────────────────────────────────────────────
interface ToastProps { message: string; type?: 'success' | 'error' | 'info'; onClose: () => void } interface ToastProps { message: string; type?: 'success' | 'error' | 'info'; onClose: () => void }
export const Toast: React.FC<ToastProps> = ({ message, type = 'info', onClose }) => { export const Toast: React.FC<ToastProps> = ({ message, type = 'info', onClose }) => {
React.useEffect(() => { useEffect(() => {
const t = setTimeout(onClose, 4000) const t = setTimeout(onClose, 4000)
return () => clearTimeout(t) return () => clearTimeout(t)
}, [onClose]) }, [onClose])
@@ -233,9 +324,15 @@ export const Toast: React.FC<ToastProps> = ({ message, type = 'info', onClose })
info: 'bg-blue-50 border-blue-200 text-blue-800', info: 'bg-blue-50 border-blue-200 text-blue-800',
} }
return ( return (
<div className={cn('fixed bottom-4 right-4 z-50 max-w-sm p-4 rounded-xl border shadow-lg flex items-start gap-3 animate-in slide-in-from-bottom-2', styles[type])}> <div className={cn(
'fixed bottom-4 right-4 z-50 max-w-sm px-4 py-3 rounded-xl border shadow-lg',
'flex items-start gap-3 animate-fade-in-up',
styles[type]
)}>
<span className="flex-1 text-sm font-medium">{message}</span> <span className="flex-1 text-sm font-medium">{message}</span>
<button onClick={onClose} className="text-current opacity-60 hover:opacity-100">×</button> <button onClick={onClose} className="text-current opacity-50 hover:opacity-100 transition-opacity">
<X className="w-4 h-4" />
</button>
</div> </div>
) )
} }
@@ -244,12 +341,42 @@ export const Toast: React.FC<ToastProps> = ({ message, type = 'info', onClose })
export const StatusDot: React.FC<{ status: string }> = ({ status }) => { export const StatusDot: React.FC<{ status: string }> = ({ status }) => {
const colors: Record<string, string> = { const colors: Record<string, string> = {
completed: 'bg-green-500', completed: 'bg-green-500',
processing: 'bg-yellow-500 animate-pulse', processing: 'bg-amber-400 animate-pulse',
pending: 'bg-gray-400', pending: 'bg-gray-300',
failed: 'bg-red-500', failed: 'bg-red-500',
active: 'bg-green-500', active: 'bg-green-500',
published: 'bg-green-500', published: 'bg-green-500',
preview: 'bg-gray-400', preview: 'bg-gray-300',
} }
return <span className={cn('w-2 h-2 rounded-full inline-block', colors[status] || 'bg-gray-400')} /> return (
<span className={cn(
'w-1.5 h-1.5 rounded-full inline-block shrink-0',
colors[status] || 'bg-gray-300'
)} />
)
} }
// ─── Divider ───────────────────────────────────────────────────────────────────
export const Divider: React.FC<{ label?: string; className?: string }> = ({ label, className }) => (
<div className={cn('flex items-center gap-3 my-2', className)}>
<div className="flex-1 h-px bg-gray-100" />
{label && <span className="text-xs text-gray-400 font-medium">{label}</span>}
<div className="flex-1 h-px bg-gray-100" />
</div>
)
// ─── Section Header ────────────────────────────────────────────────────────────
export const SectionHeader: React.FC<{
title: string
description?: string
action?: React.ReactNode
className?: string
}> = ({ title, description, action, className }) => (
<div className={cn('flex items-start justify-between gap-4', className)}>
<div>
<h2 className="text-sm font-semibold text-gray-900">{title}</h2>
{description && <p className="text-xs text-gray-500 mt-0.5">{description}</p>}
</div>
{action}
</div>
)

View File

@@ -0,0 +1,92 @@
import React, { createContext, useCallback, useContext, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { X, CheckCircle, AlertCircle, Info } from 'lucide-react'
import { cn } from '@/lib/utils'
type ToastType = 'success' | 'error' | 'info'
interface Toast {
id: number
message: string
type: ToastType
}
interface ToastContextValue {
toast: (message: string, type?: ToastType) => void
success: (message: string) => void
error: (message: string) => void
info: (message: string) => void
}
const ToastContext = createContext<ToastContextValue | null>(null)
const ICONS = {
success: <CheckCircle className="w-4 h-4 text-green-500 shrink-0" />,
error: <AlertCircle className="w-4 h-4 text-red-500 shrink-0" />,
info: <Info className="w-4 h-4 text-blue-500 shrink-0" />,
}
const STYLES: Record<ToastType, string> = {
success: 'border-green-200 bg-green-50 text-green-900',
error: 'border-red-200 bg-red-50 text-red-900',
info: 'border-blue-200 bg-blue-50 text-blue-900',
}
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [toasts, setToasts] = useState<Toast[]>([])
const counter = useRef(0)
const remove = useCallback((id: number) => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, [])
const add = useCallback((message: string, type: ToastType = 'info') => {
const id = ++counter.current
setToasts((prev) => [...prev, { id, message, type }])
setTimeout(() => remove(id), 4000)
}, [remove])
const value: ToastContextValue = {
toast: add,
success: (msg) => add(msg, 'success'),
error: (msg) => add(msg, 'error'),
info: (msg) => add(msg, 'info'),
}
return (
<ToastContext.Provider value={value}>
{children}
{createPortal(
<div className="fixed bottom-4 right-4 z-[9999] flex flex-col gap-2 max-w-sm w-full pointer-events-none">
{toasts.map((t) => (
<div
key={t.id}
role="alert"
className={cn(
'flex items-start gap-2 px-4 py-3 rounded-xl border shadow-lg text-sm pointer-events-auto animate-fade-in-up',
STYLES[t.type]
)}
>
{ICONS[t.type]}
<span className="flex-1">{t.message}</span>
<button
onClick={() => remove(t.id)}
className="shrink-0 opacity-60 hover:opacity-100 transition-opacity"
aria-label="Dismiss"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>,
document.body
)}
</ToastContext.Provider>
)
}
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext)
if (!ctx) throw new Error('useToast must be used inside <ToastProvider>')
return ctx
}

View File

@@ -1,12 +1,11 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,300..900;1,14..32,300..900&display=swap');
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base { @layer base {
* { *, *::before, *::after {
box-sizing: border-box; box-sizing: border-box;
} }
@@ -15,38 +14,51 @@
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
scroll-behavior: smooth; scroll-behavior: smooth;
text-rendering: optimizeLegibility;
} }
body { body {
@apply bg-gray-50 text-gray-900; @apply bg-gray-50 text-gray-900;
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
} }
::-webkit-scrollbar { /* Selection */
width: 6px; ::selection {
height: 6px; @apply bg-primary-100 text-primary-900;
} }
::-webkit-scrollbar-track { /* Focus visible - keyboard nav only */
@apply bg-transparent; :focus-visible {
outline: 2px solid theme('colors.primary.500');
outline-offset: 2px;
} }
::-webkit-scrollbar-thumb { /* Scrollbar */
@apply bg-gray-300 rounded-full; ::-webkit-scrollbar { width: 5px; height: 5px; }
} ::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { @apply bg-gray-200 rounded-full; }
::-webkit-scrollbar-thumb:hover { @apply bg-gray-300; }
::-webkit-scrollbar-thumb:hover { h1, h2, h3, h4, h5, h6 {
@apply bg-gray-400; font-feature-settings: 'ss01';
letter-spacing: -0.01em;
} }
} }
@layer utilities { @layer utilities {
/* Line clamp */
.line-clamp-1 {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-2 { .line-clamp-2 {
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
} }
.line-clamp-3 { .line-clamp-3 {
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 3; -webkit-line-clamp: 3;
@@ -54,17 +66,17 @@
overflow: hidden; overflow: hidden;
} }
/* Animation utilities */
.animate-in { .animate-in {
animation-fill-mode: both; animation-fill-mode: both;
animation-duration: 200ms; animation-duration: 200ms;
} }
.slide-in-from-bottom-2 { animation-name: slideInFromBottom; }
.slide-in-from-bottom-2 { /* Staggered delays */
animation-name: slideInFromBottom; .delay-75 { animation-delay: 75ms; }
}
/* Staggered animation delay utilities */
.delay-100 { animation-delay: 100ms; } .delay-100 { animation-delay: 100ms; }
.delay-150 { animation-delay: 150ms; }
.delay-200 { animation-delay: 200ms; } .delay-200 { animation-delay: 200ms; }
.delay-300 { animation-delay: 300ms; } .delay-300 { animation-delay: 300ms; }
.delay-400 { animation-delay: 400ms; } .delay-400 { animation-delay: 400ms; }
@@ -73,70 +85,96 @@
.delay-700 { animation-delay: 700ms; } .delay-700 { animation-delay: 700ms; }
.delay-800 { animation-delay: 800ms; } .delay-800 { animation-delay: 800ms; }
/* Gradient text utility */ /* Gradient text */
.text-gradient { .text-gradient {
@apply bg-clip-text text-transparent; @apply bg-clip-text text-transparent;
background-image: linear-gradient(135deg, #4f46e5, #7c3aed, #2563eb); background-image: linear-gradient(135deg, #4f46e5 0%, #7c3aed 50%, #2563eb 100%);
background-size: 200% 200%; background-size: 200% 200%;
animation: gradientX 8s ease infinite; animation: gradientX 8s ease infinite;
} }
.text-gradient-warm {
@apply bg-clip-text text-transparent;
background-image: linear-gradient(135deg, #f59e0b 0%, #ef4444 50%, #ec4899 100%);
}
/* Glass morphism */ /* Glass morphism */
.glass { .glass {
backdrop-filter: blur(12px); backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(16px) saturate(180%);
background: rgba(255, 255, 255, 0.7); background: rgba(255, 255, 255, 0.75);
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid rgba(255, 255, 255, 0.3);
} }
.glass-dark { .glass-dark {
backdrop-filter: blur(12px); backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(16px) saturate(180%);
background: rgba(0, 0, 0, 0.3); background: rgba(17, 24, 39, 0.8);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.08);
} }
/* Glow effects */ /* Glow effects */
.glow-primary { .glow-primary {
box-shadow: 0 0 20px rgba(79, 70, 229, 0.3), 0 0 60px rgba(79, 70, 229, 0.1); box-shadow: 0 0 24px rgba(79, 70, 229, 0.25), 0 0 60px rgba(79, 70, 229, 0.08);
} }
.glow-primary-lg { .glow-primary-lg {
box-shadow: 0 0 40px rgba(79, 70, 229, 0.4), 0 0 100px rgba(79, 70, 229, 0.15); box-shadow: 0 0 48px rgba(79, 70, 229, 0.35), 0 0 100px rgba(79, 70, 229, 0.12);
} }
/* Noise texture overlay */ /* Background patterns */
.bg-grid {
background-image:
linear-gradient(rgba(79, 70, 229, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(79, 70, 229, 0.04) 1px, transparent 1px);
background-size: 40px 40px;
}
.bg-dots {
background-image: radial-gradient(circle, rgba(79, 70, 229, 0.07) 1px, transparent 1px);
background-size: 24px 24px;
}
/* Noise texture */
.noise-overlay::after { .noise-overlay::after {
content: ''; content: '';
position: absolute; position: absolute;
inset: 0; inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.03'/%3E%3C/svg%3E");
pointer-events: none; pointer-events: none;
z-index: 1; z-index: 1;
} }
/* Grid background pattern */ /* Gradient borders */
.bg-grid { .border-gradient {
background-image: position: relative;
linear-gradient(rgba(79, 70, 229, 0.03) 1px, transparent 1px), background: white;
linear-gradient(90deg, rgba(79, 70, 229, 0.03) 1px, transparent 1px); background-clip: padding-box;
background-size: 40px 40px; border: 1.5px solid transparent;
}
.border-gradient::before {
content: '';
position: absolute;
inset: -1.5px;
border-radius: inherit;
background: linear-gradient(135deg, #a5b4fc, #818cf8, #c4b5fd);
z-index: -1;
} }
/* Dot grid pattern */ /* Smooth number transition */
.bg-dots { .tabular-nums {
background-image: radial-gradient(circle, rgba(79, 70, 229, 0.08) 1px, transparent 1px); font-variant-numeric: tabular-nums;
background-size: 24px 24px;
} }
} }
@keyframes slideInFromBottom { @keyframes slideInFromBottom {
from { from { transform: translateY(10px); opacity: 0; }
transform: translateY(8px); to { transform: translateY(0); opacity: 1; }
opacity: 0;
} }
to {
transform: translateY(0); /* Reduced motion */
opacity: 1; @media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
} }
} }

13
src/lib/apiError.ts Normal file
View File

@@ -0,0 +1,13 @@
export function getApiError(err: unknown): string {
if (err && typeof err === 'object' && 'response' in err) {
const res = (err as { response?: { data?: { detail?: unknown } } }).response
const detail = res?.data?.detail
if (typeof detail === 'string') return detail
if (Array.isArray(detail) && detail.length > 0) {
const first = detail[0]
if (typeof first === 'object' && first !== null && 'msg' in first) return String(first.msg)
}
}
if (err instanceof Error) return err.message
return 'An unexpected error occurred'
}

View File

@@ -3,8 +3,12 @@ import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { App } from './App' import { App } from './App'
import { ToastProvider } from '@/contexts/ToastContext'
import { initTheme } from '@/store/themeStore'
import './index.css' import './index.css'
initTheme()
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
@@ -18,7 +22,9 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<BrowserRouter> <BrowserRouter>
<ToastProvider>
<App /> <App />
</ToastProvider>
</BrowserRouter> </BrowserRouter>
</QueryClientProvider> </QueryClientProvider>
</React.StrictMode>, </React.StrictMode>,

View File

@@ -2,12 +2,14 @@ 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 } from 'react-router-dom'
import { analyticsAPI } from '@/services/api' import { analyticsAPI } from '@/services/api'
import { Card, Spinner, Button, Badge } from '@/components/ui' import { Card, Button, Badge } from '@/components/ui'
import { import {
BarChart3, Users, MessageSquare, Star, BarChart3, Users, MessageSquare, Star,
Clock, Globe, Lock, Bot, Clock, Globe, Lock, Bot,
ChevronDown, ChevronUp ChevronDown, ChevronUp, TrendingUp, AlertCircle, ThumbsUp, ThumbsDown
} from 'lucide-react' } from 'lucide-react'
import { SkeletonStatCard } from '@/components/Skeletons'
import { cn } from '@/lib/utils'
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
// ANALYTICS PAGE — Available for Starter and Pro plans // ANALYTICS PAGE — Available for Starter and Pro plans
@@ -63,8 +65,14 @@ interface OverviewData {
// ─── Mini bar chart component ───────────────────────────────────────────────── // ─── Mini bar chart component ─────────────────────────────────────────────────
const MiniBarChart: React.FC<{ data: DailyConversation[] }> = ({ data }) => { const MiniBarChart: React.FC<{ data: DailyConversation[] }> = ({ data }) => {
const [tooltip, setTooltip] = useState<{ date: string; count: number; idx: number } | null>(null)
if (!data.length) { if (!data.length) {
return <div className="text-xs text-gray-400 italic py-4">No data yet</div> return (
<div className="flex items-center justify-center h-16 text-xs text-gray-400 italic">
No data yet
</div>
)
} }
const max = Math.max(...data.map(d => d.count), 1) const max = Math.max(...data.map(d => d.count), 1)
@@ -81,16 +89,51 @@ const MiniBarChart: React.FC<{ data: DailyConversation[] }> = ({ data }) => {
} }
return ( return (
<div className="flex items-end gap-[2px] h-16"> <div className="relative">
{days.map((d) => ( {/* Grid lines */}
<div className="absolute inset-0 flex flex-col justify-between pointer-events-none" style={{ height: '64px' }}>
{[0, 1, 2, 3].map(i => (
<div key={i} className="w-full border-t border-gray-100/80" />
))}
</div>
<div className="flex items-end gap-[2px] h-16 relative z-10">
{days.map((d, idx) => (
<div <div
key={d.date} key={d.date}
className="flex-1 min-w-[3px] rounded-t-sm bg-primary-400 hover:bg-primary-600 transition-colors cursor-default group relative" className="flex-1 min-w-[3px] relative group cursor-default"
style={{ height: '100%' }}
onMouseEnter={() => setTooltip({ date: d.date, count: d.count, idx })}
onMouseLeave={() => setTooltip(null)}
>
<div
className={cn(
'absolute bottom-0 w-full rounded-t transition-colors duration-150',
d.count > 0 ? 'bg-primary-400 group-hover:bg-primary-600' : 'bg-gray-100 group-hover:bg-gray-200'
)}
style={{ height: `${Math.max((d.count / max) * 100, d.count > 0 ? 8 : 2)}%` }} style={{ height: `${Math.max((d.count / max) * 100, d.count > 0 ? 8 : 2)}%` }}
title={`${d.date}: ${d.count} conversations`}
/> />
</div>
))} ))}
</div> </div>
{/* Tooltip */}
{tooltip && (
<div
className="absolute bottom-full mb-2 bg-gray-900 text-white text-xs rounded-lg px-2.5 py-1.5 shadow-lg pointer-events-none whitespace-nowrap z-20"
style={{
left: `${(tooltip.idx / days.length) * 100}%`,
transform: 'translateX(-50%)',
}}
>
<span className="font-semibold">{tooltip.count}</span>
<span className="text-gray-300 ml-1">
{new Date(tooltip.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
</span>
<div className="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-gray-900" />
</div>
)}
</div>
) )
} }
@@ -101,16 +144,28 @@ const StatCard: React.FC<{
icon: React.ReactNode icon: React.ReactNode
subtitle?: string subtitle?: string
color?: string color?: string
}> = ({ label, value, icon, subtitle, color = 'primary' }) => ( trend?: number | null
<Card className="p-4"> }> = ({ label, value, icon, subtitle, color = 'primary', trend }) => (
<div className="flex items-start justify-between mb-2"> <Card className="p-5 hover:shadow-md transition-all duration-200">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">{label}</span> <div className="flex items-start justify-between mb-3">
<div className={`w-8 h-8 rounded-lg bg-${color}-50 flex items-center justify-center text-${color}-600`}> <div className={`w-9 h-9 rounded-xl bg-${color}-50 flex items-center justify-center text-${color}-600`}>
{icon} {icon}
</div> </div>
{trend != null && (
<div className={cn(
'flex items-center gap-0.5 text-xs font-medium px-1.5 py-0.5 rounded-full',
trend >= 0 ? 'bg-emerald-50 text-emerald-600' : 'bg-red-50 text-red-500'
)}>
<TrendingUp className={cn('w-3 h-3', trend < 0 && 'rotate-180')} />
{Math.abs(trend)}%
</div> </div>
<div className="text-2xl font-bold text-gray-900">{typeof value === 'number' ? value.toLocaleString() : value}</div> )}
{subtitle && <p className="text-xs text-gray-500 mt-1">{subtitle}</p>} </div>
<div className="text-2xl font-bold text-gray-900 mb-1">
{typeof value === 'number' ? value.toLocaleString() : value}
</div>
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">{label}</p>
{subtitle && <p className="text-xs text-gray-400 mt-0.5">{subtitle}</p>}
</Card> </Card>
) )
@@ -122,20 +177,31 @@ const UsageBar: React.FC<{ used: number; limit: number }> = ({ used, limit }) =>
return ( return (
<div> <div>
<div className="flex items-center justify-between text-xs mb-1.5"> <div className="flex items-center justify-between text-xs mb-2">
<span className="text-gray-600 font-medium">Monthly conversations</span> <span className="text-gray-600 font-medium">Monthly conversations</span>
<span className={isFull ? 'text-red-600 font-semibold' : isHigh ? 'text-amber-600 font-medium' : 'text-gray-500'}> <span className={cn(
'font-semibold',
isFull ? 'text-red-600' : isHigh ? 'text-amber-600' : 'text-gray-600'
)}>
{used.toLocaleString()} / {limit.toLocaleString()} {used.toLocaleString()} / {limit.toLocaleString()}
</span> </span>
</div> </div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden"> <div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div <div
className={`h-full rounded-full transition-all duration-500 ${ className={cn(
'h-full rounded-full transition-all duration-700',
isFull ? 'bg-red-500' : isHigh ? 'bg-amber-400' : 'bg-primary-500' isFull ? 'bg-red-500' : isHigh ? 'bg-amber-400' : 'bg-primary-500'
}`} )}
style={{ width: `${pct}%` }} style={{ width: `${pct}%` }}
/> />
</div> </div>
<div className="flex justify-between mt-1.5">
<span className="text-[10px] text-gray-400">0</span>
<span className={cn('text-[10px] font-medium', isFull ? 'text-red-500' : isHigh ? 'text-amber-500' : 'text-gray-400')}>
{Math.round(pct)}% used
</span>
<span className="text-[10px] text-gray-400">{limit.toLocaleString()}</span>
</div>
</div> </div>
) )
} }
@@ -144,140 +210,195 @@ const UsageBar: React.FC<{ used: number; limit: number }> = ({ used, limit }) =>
const ChatbotRow: React.FC<{ chatbot: ChatbotAnalytics }> = ({ chatbot }) => { const ChatbotRow: React.FC<{ chatbot: ChatbotAnalytics }> = ({ chatbot }) => {
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const feedbackTotal = chatbot.feedback_positive + chatbot.feedback_negative
const helpfulPct = feedbackTotal > 0
? Math.round((chatbot.feedback_positive / feedbackTotal) * 100)
: null
return ( return (
<Card className="overflow-hidden"> <Card className="overflow-hidden hover:shadow-md transition-all duration-200">
<button <button
onClick={() => setExpanded(!expanded)} onClick={() => setExpanded(!expanded)}
className="w-full p-4 flex items-center justify-between hover:bg-gray-50 transition-colors text-left" className="w-full p-5 flex items-center justify-between hover:bg-gray-50/80 transition-colors text-left"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-primary-50 flex items-center justify-center"> <div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center flex-shrink-0">
<Bot className="w-4 h-4 text-primary-600" /> <Bot className="w-5 h-5 text-primary-600" />
</div> </div>
<div> <div>
<h3 className="font-semibold text-gray-900 text-sm">{chatbot.chatbot_name}</h3> <h3 className="font-semibold text-gray-900">{chatbot.chatbot_name}</h3>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500 mt-0.5">
{chatbot.total_conversations} conversations · {chatbot.unique_sessions} users {chatbot.total_conversations.toLocaleString()} conversations · {chatbot.unique_sessions.toLocaleString()} users
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-3 sm:gap-4">
{chatbot.average_rating && ( {chatbot.average_rating && (
<div className="flex items-center gap-1 text-xs text-amber-600"> <div className="hidden sm:flex items-center gap-1 text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded-lg">
<Star className="w-3 h-3 fill-amber-400" /> <Star className="w-3 h-3 fill-amber-400 text-amber-400" />
{chatbot.average_rating.toFixed(1)} <span className="font-semibold">{chatbot.average_rating.toFixed(1)}</span>
</div> </div>
)} )}
<div className="text-xs text-gray-500"> <div className="text-xs text-gray-500 bg-gray-100 px-2.5 py-1 rounded-lg font-medium">
{chatbot.conversations_today} today {chatbot.conversations_today} today
</div> </div>
{expanded ? <ChevronUp className="w-4 h-4 text-gray-400" /> : <ChevronDown className="w-4 h-4 text-gray-400" />} <div className="p-1 rounded-lg text-gray-400">
{expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</div>
</div> </div>
</button> </button>
{expanded && ( {expanded && (
<div className="border-t border-gray-100 p-4 space-y-4 bg-gray-50/50"> <div className="border-t border-gray-100 p-5 space-y-5 bg-gray-50/40">
{/* Stats row */} {/* Stats row */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="bg-white rounded-lg p-3 border border-gray-100"> {[
<p className="text-xs text-gray-500">Today</p> { label: 'Today', value: chatbot.conversations_today },
<p className="text-lg font-bold text-gray-900">{chatbot.conversations_today}</p> { label: 'This week', value: chatbot.conversations_this_week },
</div> { label: 'This month', value: chatbot.conversations_this_month },
<div className="bg-white rounded-lg p-3 border border-gray-100"> { label: 'Avg msgs/convo', value: chatbot.average_messages_per_conversation },
<p className="text-xs text-gray-500">This week</p> ].map(({ label, value }) => (
<p className="text-lg font-bold text-gray-900">{chatbot.conversations_this_week}</p> <div key={label} className="bg-white rounded-xl p-4 border border-gray-100 shadow-sm text-center">
</div> <p className="text-xs text-gray-500 mb-1">{label}</p>
<div className="bg-white rounded-lg p-3 border border-gray-100"> <p className="text-xl font-bold text-gray-900">{value}</p>
<p className="text-xs text-gray-500">This month</p>
<p className="text-lg font-bold text-gray-900">{chatbot.conversations_this_month}</p>
</div>
<div className="bg-white rounded-lg p-3 border border-gray-100">
<p className="text-xs text-gray-500">Avg messages/convo</p>
<p className="text-lg font-bold text-gray-900">{chatbot.average_messages_per_conversation}</p>
</div> </div>
))}
</div> </div>
{/* Daily chart */} {/* Daily chart */}
<div className="bg-white rounded-lg p-3 border border-gray-100"> <div className="bg-white rounded-xl p-4 border border-gray-100 shadow-sm">
<p className="text-xs font-medium text-gray-500 mb-2">Last 30 days</p> <p className="text-xs font-semibold text-gray-600 mb-3 uppercase tracking-wide">Last 30 days</p>
<MiniBarChart data={chatbot.daily_conversations} /> <MiniBarChart data={chatbot.daily_conversations} />
</div> </div>
{/* Top queries & Languages side by side */} {/* Top queries & Languages */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{chatbot.top_queries.length > 0 && ( {chatbot.top_queries.length > 0 && (
<div className="bg-white rounded-lg p-3 border border-gray-100"> <div className="bg-white rounded-xl p-4 border border-gray-100 shadow-sm">
<p className="text-xs font-medium text-gray-500 mb-2">Top questions</p> <p className="text-xs font-semibold text-gray-600 mb-3 uppercase tracking-wide">Top questions</p>
<ul className="space-y-1.5"> <div className="space-y-2">
{chatbot.top_queries.slice(0, 5).map((q, i) => ( {chatbot.top_queries.slice(0, 5).map((q, i) => (
<li key={i} className="flex items-start gap-2 text-xs"> <div key={i} className="flex items-start gap-2">
<span className="text-gray-400 font-mono">{i + 1}.</span> <span className="w-5 h-5 rounded-full bg-primary-50 text-primary-600 text-[10px] font-bold flex items-center justify-center flex-shrink-0 mt-0.5">
<span className="text-gray-700 flex-1 truncate">{q.query}</span> {i + 1}
<span className="text-gray-400">{q.count}×</span> </span>
</li> <span className="text-sm text-gray-700 flex-1 leading-snug">{q.query}</span>
<span className="text-xs text-gray-400 bg-gray-100 px-1.5 py-0.5 rounded-full flex-shrink-0 font-medium">
{q.count}×
</span>
</div>
))} ))}
</ul> </div>
</div> </div>
)} )}
{Object.keys(chatbot.languages_used).length > 0 && ( {Object.keys(chatbot.languages_used).length > 0 && (
<div className="bg-white rounded-lg p-3 border border-gray-100"> <div className="bg-white rounded-xl p-4 border border-gray-100 shadow-sm">
<p className="text-xs font-medium text-gray-500 mb-2">Languages</p> <p className="text-xs font-semibold text-gray-600 mb-3 uppercase tracking-wide">Languages</p>
<div className="space-y-1.5"> <div className="space-y-2">
{Object.entries(chatbot.languages_used) {Object.entries(chatbot.languages_used)
.sort(([, a], [, b]) => b - a) .sort(([, a], [, b]) => b - a)
.slice(0, 5) .slice(0, 5)
.map(([lang, count]) => ( .map(([lang, count]) => {
<div key={lang} className="flex items-center gap-2 text-xs"> const total = Object.values(chatbot.languages_used).reduce((a, b) => a + b, 0)
const pct = total > 0 ? Math.round((count / total) * 100) : 0
return (
<div key={lang}>
<div className="flex items-center justify-between text-xs mb-1">
<div className="flex items-center gap-1.5">
<Globe className="w-3 h-3 text-gray-400" /> <Globe className="w-3 h-3 text-gray-400" />
<span className="text-gray-700 uppercase">{lang}</span> <span className="text-gray-700 font-medium uppercase">{lang}</span>
<span className="text-gray-400 ml-auto">{count} convos</span>
</div> </div>
))} <span className="text-gray-400">{count} · {pct}%</span>
</div>
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-primary-400 rounded-full transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div>
</div>
)
})}
</div> </div>
</div> </div>
)} )}
</div> </div>
{/* Knowledge Gaps */} {/* Knowledge Gaps — Phase 3: actionable suggestions */}
{chatbot.unanswered_queries && chatbot.unanswered_queries.length > 0 && ( {chatbot.unanswered_queries && chatbot.unanswered_queries.length > 0 && (
<div className="bg-amber-50 rounded-lg p-3 border border-amber-100"> <div className="bg-amber-50 rounded-xl p-4 border border-amber-100 space-y-3">
<p className="text-xs font-medium text-amber-700 mb-2"> <div className="flex items-center justify-between gap-2">
Knowledge Gaps questions your bot couldn't answer well: <div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-amber-600 flex-shrink-0" />
<p className="text-xs font-semibold text-amber-700 uppercase tracking-wide">
Knowledge gaps {chatbot.unanswered_count} unanswered
</p> </p>
<ul className="space-y-1.5"> </div>
{chatbot.unanswered_queries.slice(0, 5).map((q, i) => ( <button
<li key={i} className="flex items-start gap-2 text-xs"> onClick={() => navigate(`/chatbots/${chatbot.chatbot_id}/edit`)}
<span className="text-amber-400 font-mono">{i + 1}.</span> className="text-xs font-semibold text-amber-700 hover:text-amber-900 bg-amber-100 hover:bg-amber-200 px-3 py-1.5 rounded-lg transition-colors border border-amber-200 flex-shrink-0"
<span className="text-amber-800 flex-1 truncate">{q.query}</span> >
<span className="text-amber-500">{q.count}×</span> + Add content
</li> </button>
</div>
<p className="text-xs text-amber-600">
Customers asked these questions but your bot couldn't answer well. Add documents or URL sources covering these topics.
</p>
<div className="space-y-2">
{chatbot.unanswered_queries.slice(0, 6).map((q, i) => (
<div
key={i}
className="flex items-center justify-between gap-3 bg-white/70 border border-amber-200/60 rounded-lg px-3 py-2"
>
<span className="text-xs text-amber-800 truncate flex-1">"{q.query}"</span>
<div className="flex items-center gap-2 flex-shrink-0">
<span className="bg-amber-200 text-amber-700 text-[10px] px-2 py-0.5 rounded-full font-bold">
{q.count}× asked
</span>
</div>
</div>
))} ))}
</ul> </div>
{chatbot.unanswered_queries.length > 6 && (
<p className="text-xs text-amber-500 text-center">
+{chatbot.unanswered_queries.length - 6} more gaps
</p>
)}
</div> </div>
)} )}
{/* Feedback */} {/* Feedback & Peak hour */}
{(chatbot.feedback_positive > 0 || chatbot.feedback_negative > 0) && ( <div className="flex flex-wrap items-center gap-4">
<div className="text-xs text-gray-500 flex items-center gap-3"> {feedbackTotal > 0 && (
<span className="font-medium text-gray-600">Feedback:</span> <div className="flex items-center gap-3 bg-white rounded-xl px-4 py-3 border border-gray-100 shadow-sm">
<span className="text-green-600">👍 {chatbot.feedback_positive}</span> <span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Feedback</span>
<span className="text-red-500">👎 {chatbot.feedback_negative}</span> <div className="flex items-center gap-1 text-emerald-600">
{(chatbot.feedback_positive + chatbot.feedback_negative) > 0 && ( <ThumbsUp className="w-3.5 h-3.5" />
<span className="text-gray-400"> <span className="text-sm font-bold">{chatbot.feedback_positive}</span>
({Math.round((chatbot.feedback_positive / (chatbot.feedback_positive + chatbot.feedback_negative)) * 100)}% helpful) </div>
<div className="flex items-center gap-1 text-red-500">
<ThumbsDown className="w-3.5 h-3.5" />
<span className="text-sm font-bold">{chatbot.feedback_negative}</span>
</div>
{helpfulPct !== null && (
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full">
{helpfulPct}% helpful
</span> </span>
)} )}
</div> </div>
)} )}
{chatbot.peak_hour !== null && ( {chatbot.peak_hour !== null && (
<div className="text-xs text-gray-500 flex items-center gap-1"> <div className="flex items-center gap-2 bg-white rounded-xl px-4 py-3 border border-gray-100 shadow-sm text-xs text-gray-600">
<Clock className="w-3 h-3" /> <Clock className="w-3.5 h-3.5 text-gray-400" />
Peak hour: {chatbot.peak_hour}:00 - {chatbot.peak_hour + 1}:00 <span>Peak: <span className="font-semibold text-gray-900">{chatbot.peak_hour}:00 {chatbot.peak_hour + 1}:00</span></span>
</div> </div>
)} )}
</div> </div>
</div>
)} )}
</Card> </Card>
) )
@@ -301,12 +422,12 @@ export const AnalyticsPage: React.FC = () => {
if (error && (error as { response?: { status?: number } })?.response?.status === 402) { if (error && (error as { response?: { status?: number } })?.response?.status === 402) {
return ( return (
<div className="p-6 max-w-2xl mx-auto"> <div className="p-6 max-w-2xl mx-auto">
<Card className="p-8 text-center"> <Card className="p-10 text-center">
<div className="w-14 h-14 rounded-2xl bg-primary-50 flex items-center justify-center mx-auto mb-4"> <div className="w-16 h-16 rounded-2xl bg-primary-50 flex items-center justify-center mx-auto mb-5">
<Lock className="w-6 h-6 text-primary-600" /> <Lock className="w-7 h-7 text-primary-600" />
</div> </div>
<h2 className="text-xl font-bold text-gray-900 mb-2">Analytics Dashboard</h2> <h2 className="text-xl font-bold text-gray-900 mb-2">Analytics Dashboard</h2>
<p className="text-gray-500 text-sm mb-6"> <p className="text-gray-500 text-sm mb-6 max-w-sm mx-auto leading-relaxed">
Unlock analytics to see how your chatbots are performing — conversations, user engagement, top questions, and more. Unlock analytics to see how your chatbots are performing — conversations, user engagement, top questions, and more.
</p> </p>
<Button onClick={() => navigate('/pricing')}> <Button onClick={() => navigate('/pricing')}>
@@ -320,8 +441,35 @@ export const AnalyticsPage: React.FC = () => {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center min-h-[60vh]"> <div className="p-4 sm:p-6 max-w-5xl mx-auto space-y-6">
<Spinner className="text-primary-600" /> {/* Header skeleton */}
<div className="flex items-center justify-between">
<div className="space-y-2">
<div className="h-7 w-40 bg-gray-200 rounded animate-pulse" />
<div className="h-4 w-56 bg-gray-100 rounded animate-pulse" />
</div>
<div className="h-6 w-20 bg-gray-100 rounded-full animate-pulse" />
</div>
{/* Usage bar skeleton */}
<div className="h-16 bg-gray-100 rounded-xl animate-pulse" />
{/* Stat cards skeleton */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[0, 1, 2, 3].map(i => <SkeletonStatCard key={i} />)}
</div>
{/* Chatbot rows skeleton */}
<div className="space-y-3">
{[0, 1].map(i => (
<div key={i} className="bg-white rounded-xl border border-gray-200 p-5 animate-pulse">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gray-200 rounded-xl" />
<div className="flex-1 space-y-2">
<div className="h-4 w-1/3 bg-gray-200 rounded" />
<div className="h-3 w-1/4 bg-gray-100 rounded" />
</div>
</div>
</div>
))}
</div>
</div> </div>
) )
} }
@@ -329,73 +477,91 @@ export const AnalyticsPage: React.FC = () => {
if (!data) { if (!data) {
return ( return (
<div className="p-6 max-w-4xl mx-auto"> <div className="p-6 max-w-4xl mx-auto">
<Card className="p-8 text-center"> <Card className="p-10 text-center">
<BarChart3 className="w-8 h-8 text-gray-400 mx-auto mb-3" /> <div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mx-auto mb-4">
<p className="text-gray-600">Unable to load analytics. Please try again.</p> <BarChart3 className="w-6 h-6 text-gray-400" />
</div>
<p className="text-gray-600 font-medium">Unable to load analytics</p>
<p className="text-sm text-gray-400 mt-1">Please try refreshing the page.</p>
</Card> </Card>
</div> </div>
) )
} }
return ( return (
<div className="p-6 max-w-5xl mx-auto space-y-6"> <div className="p-4 sm:p-6 max-w-5xl mx-auto space-y-6">
{/* Header */} {/* ── Header ── */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center flex-shrink-0">
<BarChart3 className="w-5 h-5 text-primary-600" />
</div>
<div> <div>
<h1 className="text-2xl font-bold text-gray-900">Analytics</h1> <h1 className="text-2xl font-bold text-gray-900">Analytics</h1>
<p className="text-sm text-gray-500 mt-0.5"> <p className="text-sm text-gray-500 mt-0.5">Track how your chatbots are performing</p>
Track how your chatbots are performing </div>
</p>
</div> </div>
<Badge className="text-xs capitalize">{data.plan} plan</Badge> <Badge className="text-xs capitalize">{data.plan} plan</Badge>
</div> </div>
{/* Usage bar */} {/* ── Usage bar ── */}
<Card className="p-4"> <Card className="p-5">
<UsageBar used={data.conversations_used} limit={data.conversations_limit} /> <UsageBar used={data.conversations_used} limit={data.conversations_limit} />
</Card> </Card>
{/* Overview stat cards */} {/* ── Overview stat cards ── */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard <StatCard
label="Conversations" label="Conversations"
value={data.total_conversations} value={data.total_conversations}
icon={<MessageSquare className="w-4 h-4" />} icon={<MessageSquare className="w-4 h-4" />}
subtitle={`${data.conversations_this_month} this month`} subtitle={`${data.conversations_this_month} this month`}
color="primary"
/> />
<StatCard <StatCard
label="Unique users" label="Unique users"
value={data.unique_sessions} value={data.unique_sessions}
icon={<Users className="w-4 h-4" />} icon={<Users className="w-4 h-4" />}
subtitle="Across all chatbots" subtitle="Across all chatbots"
color="sky"
/> />
<StatCard <StatCard
label="Messages" label="Messages"
value={data.total_messages} value={data.total_messages}
icon={<BarChart3 className="w-4 h-4" />} icon={<BarChart3 className="w-4 h-4" />}
subtitle="Total messages exchanged" subtitle="Total exchanged"
color="violet"
/> />
<StatCard <StatCard
label="Avg rating" label="Avg rating"
value={data.average_rating ? data.average_rating.toFixed(1) : ''} value={data.average_rating ? data.average_rating.toFixed(1) : ''}
icon={<Star className="w-4 h-4" />} icon={<Star className="w-4 h-4" />}
subtitle={data.average_rating ? 'Across rated chatbots' : 'No ratings yet'} subtitle={data.average_rating ? 'Across rated chatbots' : 'No ratings yet'}
color="amber"
/> />
</div> </div>
{/* Chatbot breakdown header */} {/* ── Chatbot breakdown header ── */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900"> <h2 className="text-lg font-semibold text-gray-900">
Your chatbots ({data.total_chatbots}) Your chatbots
<span className="ml-2 text-sm font-medium text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full">
{data.total_chatbots}
</span>
</h2> </h2>
<p className="text-xs text-gray-500">{data.published_chatbots} published</p> <p className="text-xs text-gray-500 bg-emerald-50 text-emerald-600 px-2.5 py-1 rounded-full font-medium">
{data.published_chatbots} published
</p>
</div> </div>
{/* Per-chatbot expandable rows */} {/* ── Per-chatbot expandable rows ── */}
{data.chatbots.length === 0 ? ( {data.chatbots.length === 0 ? (
<Card className="p-8 text-center"> <Card className="p-12 text-center">
<Bot className="w-8 h-8 text-gray-400 mx-auto mb-3" /> <div className="w-16 h-16 rounded-2xl bg-gray-50 flex items-center justify-center mx-auto mb-5">
<p className="text-sm text-gray-600 mb-4">No chatbots yet. Create your first chatbot to see analytics.</p> <Bot className="w-7 h-7 text-gray-300" />
</div>
<p className="text-sm font-medium text-gray-600 mb-1">No chatbots yet</p>
<p className="text-xs text-gray-400 mb-5">Create your first chatbot to start seeing analytics.</p>
<Button size="sm" onClick={() => navigate('/chatbots/new')}> <Button size="sm" onClick={() => navigate('/chatbots/new')}>
Create chatbot Create chatbot
</Button> </Button>

View File

@@ -0,0 +1,426 @@
import React, { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { appointmentsAPI, chatbotsAPI } from '@/services/api'
import { Card, Button, Spinner } from '@/components/ui'
import {
Calendar, Clock, User, Phone, Filter, Lock, CheckCircle2,
XCircle, RotateCcw, ChevronDown, Settings, CalendarDays,
} from 'lucide-react'
import type { Appointment, Chatbot, BusinessHoursEntry } from '@/types'
import { cn } from '@/lib/utils'
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: React.ElementType }> = {
pending: { label: 'Pending', color: 'bg-yellow-100 text-yellow-700', icon: Clock },
confirmed: { label: 'Confirmed', color: 'bg-green-100 text-green-700', icon: CheckCircle2 },
cancelled: { label: 'Cancelled', color: 'bg-red-100 text-red-600', icon: XCircle },
completed: { label: 'Completed', color: 'bg-gray-100 text-gray-600', icon: CheckCircle2 },
}
const DAY_LABELS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
const DEFAULT_HOURS: BusinessHoursEntry[] = DAY_LABELS.map((_, i) => ({
day_of_week: i,
is_open: i < 5, // MonFri open by default
open_time: '09:00',
close_time: '17:00',
slot_duration_minutes: 60,
}))
// ── Business Hours Settings Panel ─────────────────────────────────────────────
const HoursSettings: React.FC<{ chatbotId: string; onClose: () => void }> = ({ chatbotId, onClose }) => {
const queryClient = useQueryClient()
const [hours, setHours] = useState<BusinessHoursEntry[]>(DEFAULT_HOURS)
const [saved, setSaved] = useState(false)
const { isLoading } = useQuery<BusinessHoursEntry[]>({
queryKey: ['business-hours', chatbotId],
queryFn: () => appointmentsAPI.getHours(chatbotId),
onSuccess: (data) => {
if (data && data.length > 0) {
// Merge fetched data with defaults
const merged = DEFAULT_HOURS.map(d => {
const found = data.find(h => h.day_of_week === d.day_of_week)
return found ? { ...d, ...found } : d
})
setHours(merged)
}
},
} as any)
const save = useMutation({
mutationFn: () => appointmentsAPI.saveHours(chatbotId, hours),
onSuccess: () => {
setSaved(true)
setTimeout(() => setSaved(false), 2000)
queryClient.invalidateQueries({ queryKey: ['business-hours', chatbotId] })
},
})
const update = (idx: number, field: keyof BusinessHoursEntry, value: any) => {
setHours(prev => prev.map((h, i) => i === idx ? { ...h, [field]: value } : h))
}
if (isLoading) return <div className="p-8 text-center"><Spinner className="text-primary-600 mx-auto" /></div>
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-gray-900">Business Hours</h3>
<button onClick={onClose} className="text-xs text-gray-500 hover:text-gray-700"> Back</button>
</div>
<p className="text-xs text-gray-500">Configure when customers can book appointments.</p>
<div className="space-y-2">
{hours.map((h, i) => (
<div key={i} className="flex items-center gap-3 p-3 bg-gray-50 rounded-xl">
<div className="w-24 flex-shrink-0">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={h.is_open}
onChange={e => update(i, 'is_open', e.target.checked)}
className="w-3.5 h-3.5 accent-primary-600"
/>
<span className={cn('text-xs font-medium', h.is_open ? 'text-gray-900' : 'text-gray-400')}>
{DAY_LABELS[i].slice(0, 3)}
</span>
</label>
</div>
{h.is_open ? (
<>
<input
type="time"
value={h.open_time}
onChange={e => update(i, 'open_time', e.target.value)}
className="text-xs border border-gray-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-1 focus:ring-primary-400"
/>
<span className="text-xs text-gray-400">to</span>
<input
type="time"
value={h.close_time}
onChange={e => update(i, 'close_time', e.target.value)}
className="text-xs border border-gray-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-1 focus:ring-primary-400"
/>
<select
value={h.slot_duration_minutes}
onChange={e => update(i, 'slot_duration_minutes', Number(e.target.value))}
className="text-xs border border-gray-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-1 focus:ring-primary-400 ml-auto"
>
<option value={15}>15 min</option>
<option value={30}>30 min</option>
<option value={60}>1 hr</option>
<option value={90}>1.5 hr</option>
<option value={120}>2 hr</option>
</select>
</>
) : (
<span className="text-xs text-gray-400 italic">Closed</span>
)}
</div>
))}
</div>
<Button
onClick={() => save.mutate()}
disabled={save.isPending}
className="w-full gap-2"
size="sm"
>
{save.isPending ? <Spinner className="w-4 h-4 text-white" /> : saved ? '✓ Saved!' : 'Save Hours'}
</Button>
</div>
)
}
// ── Main Page ─────────────────────────────────────────────────────────────────
export const AppointmentsPage: React.FC = () => {
const navigate = useNavigate()
const queryClient = useQueryClient()
const [chatbotFilter, setChatbotFilter] = useState('')
const [statusFilter, setStatusFilter] = useState('')
const [settingsChatbotId, setSettingsChatbotId] = useState<string | null>(null)
const { data: chatbots = [] } = useQuery<Chatbot[]>({
queryKey: ['chatbots'],
queryFn: chatbotsAPI.list,
})
const { data: appointments = [], isLoading, error } = useQuery<Appointment[]>({
queryKey: ['appointments', chatbotFilter, statusFilter],
queryFn: () => appointmentsAPI.list({
chatbot_id: chatbotFilter || undefined,
status: statusFilter || undefined,
}),
retry: false,
})
const updateStatus = useMutation({
mutationFn: ({ id, status }: { id: string; status: string }) =>
appointmentsAPI.updateStatus(id, status),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['appointments'] }),
})
const isPlanError = (error as { response?: { status?: number } })?.response?.status === 402
if (isPlanError) {
return (
<div className="p-6 max-w-2xl mx-auto">
<Card className="p-10 text-center">
<div className="w-16 h-16 rounded-2xl bg-primary-50 flex items-center justify-center mx-auto mb-5">
<Lock className="w-7 h-7 text-primary-600" />
</div>
<h2 className="text-xl font-bold text-gray-900 mb-2">Appointment Booking</h2>
<p className="text-gray-500 text-sm mb-6 max-w-sm mx-auto">
Upgrade to Starter to enable appointment booking for your chatbots.
</p>
</Card>
</div>
)
}
const upcoming = appointments.filter(a => new Date(a.slot_start) >= new Date() && a.status !== 'cancelled')
const today = appointments.filter(a => {
const d = new Date(a.slot_start)
const now = new Date()
return d.toDateString() === now.toDateString()
})
const bookingEnabledChatbots = chatbots.filter(c => c.booking_enabled)
return (
<div className="p-4 sm:p-6 max-w-5xl mx-auto space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center flex-shrink-0">
<CalendarDays className="w-5 h-5 text-primary-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">Appointments</h1>
<p className="text-sm text-gray-500 mt-0.5">Bookings made through your chatbots</p>
</div>
</div>
</div>
{/* Business hours settings panel */}
{settingsChatbotId && (
<Card className="p-5">
<HoursSettings
chatbotId={settingsChatbotId}
onClose={() => setSettingsChatbotId(null)}
/>
</Card>
)}
{/* Setup prompt when no chatbots have booking enabled */}
{!isLoading && bookingEnabledChatbots.length === 0 && (
<Card className="p-6 border-dashed border-2 border-gray-200">
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-amber-50 flex items-center justify-center flex-shrink-0">
<Calendar className="w-5 h-5 text-amber-600" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-900 mb-1">Enable booking on a chatbot</h3>
<p className="text-sm text-gray-500 mb-3">
Go to a chatbot's Deploy tab and enable "Appointment Booking" to start accepting bookings.
</p>
{chatbots.length > 0 && (
<Button
size="sm"
variant="secondary"
onClick={() => navigate(`/chatbots/${chatbots[0].id}/edit`)}
>
Configure chatbot →
</Button>
)}
</div>
</div>
</Card>
)}
{/* Stats cards */}
{appointments.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[
{ label: 'Today', count: today.length, color: 'text-blue-600', bg: 'bg-blue-50' },
{ label: 'Upcoming', count: upcoming.length, color: 'text-primary-600', bg: 'bg-primary-50' },
{ label: 'Confirmed', count: appointments.filter(a => a.status === 'confirmed').length, color: 'text-green-600', bg: 'bg-green-50' },
{ label: 'Pending', count: appointments.filter(a => a.status === 'pending').length, color: 'text-yellow-600', bg: 'bg-yellow-50' },
].map(stat => (
<Card key={stat.label} className="p-4 flex items-center gap-3">
<div className={cn('w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0', stat.bg)}>
<Calendar className={cn('w-4 h-4', stat.color)} />
</div>
<div>
<p className="text-xs text-gray-500 font-medium">{stat.label}</p>
<p className="text-xl font-bold text-gray-900">{stat.count}</p>
</div>
</Card>
))}
</div>
)}
{/* Filters */}
<Card className="p-4">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 flex-wrap">
<div className="flex items-center gap-2 text-sm font-medium text-gray-600 flex-shrink-0">
<Filter className="w-4 h-4 text-gray-400" />
Filter
</div>
<select
value={chatbotFilter}
onChange={e => setChatbotFilter(e.target.value)}
className="border border-gray-200 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary-500/30 appearance-none cursor-pointer"
>
<option value="">All chatbots</option>
{bookingEnabledChatbots.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<select
value={statusFilter}
onChange={e => setStatusFilter(e.target.value)}
className="border border-gray-200 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary-500/30 appearance-none cursor-pointer"
>
<option value="">All statuses</option>
{Object.entries(STATUS_CONFIG).map(([v, c]) => <option key={v} value={v}>{c.label}</option>)}
</select>
{/* Per-chatbot hours settings */}
{bookingEnabledChatbots.length > 0 && (
<div className="ml-auto flex items-center gap-2">
<span className="text-xs text-gray-500">Hours:</span>
<select
value={settingsChatbotId || ''}
onChange={e => setSettingsChatbotId(e.target.value || null)}
className="border border-gray-200 rounded-lg px-2 py-1.5 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-primary-400 appearance-none cursor-pointer"
>
<option value="">Configure chatbot...</option>
{bookingEnabledChatbots.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<Settings className="w-3.5 h-3.5 text-gray-400" />
</div>
)}
</div>
</Card>
{/* Appointments list */}
{isLoading ? (
<div className="flex justify-center py-12"><Spinner className="text-primary-600" /></div>
) : appointments.length === 0 ? (
<Card className="p-14 text-center">
<div className="w-16 h-16 rounded-2xl bg-gray-50 flex items-center justify-center mx-auto mb-5">
<Calendar className="w-7 h-7 text-gray-300" />
</div>
<h3 className="font-semibold text-gray-700 mb-2">No appointments yet</h3>
<p className="text-sm text-gray-500 max-w-sm mx-auto">
Once customers book through your chatbot, appointments will appear here.
</p>
</Card>
) : (
<div className="space-y-3">
{appointments.map(appt => {
const sc = STATUS_CONFIG[appt.status] || STATUS_CONFIG.pending
const Icon = sc.icon
const slotDate = new Date(appt.slot_start)
const slotEnd = new Date(appt.slot_end)
const isToday = slotDate.toDateString() === new Date().toDateString()
return (
<Card key={appt.id} className="p-4">
<div className="flex items-start gap-4">
{/* Date block */}
<div className="flex-shrink-0 text-center bg-primary-50 rounded-xl p-2.5 w-14">
<p className="text-xs font-semibold text-primary-500 uppercase">
{slotDate.toLocaleDateString(undefined, { month: 'short' })}
</p>
<p className="text-2xl font-bold text-primary-700 leading-none">{slotDate.getDate()}</p>
{isToday && <p className="text-[10px] text-primary-500 font-medium mt-0.5">Today</p>}
</div>
{/* Details */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 flex-wrap">
<div>
<p className="font-semibold text-gray-900 text-sm">{appt.customer_name}</p>
{appt.service && <p className="text-xs text-gray-500 mt-0.5">{appt.service}</p>}
</div>
<span className={cn('flex items-center gap-1 text-xs font-medium px-2.5 py-1 rounded-full flex-shrink-0', sc.color)}>
<Icon className="w-3 h-3" />
{sc.label}
</span>
</div>
<div className="flex flex-wrap gap-3 mt-2 text-xs text-gray-500">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{slotDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })} {slotEnd.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
</span>
<span className="flex items-center gap-1">
<Phone className="w-3 h-3" />
{appt.customer_contact}
</span>
</div>
{appt.notes && (
<p className="text-xs text-gray-400 mt-1.5 italic">"{appt.notes}"</p>
)}
{/* Actions */}
{appt.status === 'pending' && (
<div className="flex gap-2 mt-3">
<button
onClick={() => updateStatus.mutate({ id: appt.id, status: 'confirmed' })}
disabled={updateStatus.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-green-700 bg-green-50 hover:bg-green-100 border border-green-200 rounded-lg transition-colors disabled:opacity-50"
>
<CheckCircle2 className="w-3.5 h-3.5" /> Confirm
</button>
<button
onClick={() => updateStatus.mutate({ id: appt.id, status: 'cancelled' })}
disabled={updateStatus.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-red-600 bg-red-50 hover:bg-red-100 border border-red-200 rounded-lg transition-colors disabled:opacity-50"
>
<XCircle className="w-3.5 h-3.5" /> Decline
</button>
</div>
)}
{appt.status === 'confirmed' && (
<div className="flex gap-2 mt-3">
<button
onClick={() => updateStatus.mutate({ id: appt.id, status: 'completed' })}
disabled={updateStatus.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 border border-gray-200 rounded-lg transition-colors disabled:opacity-50"
>
<CheckCircle2 className="w-3.5 h-3.5" /> Mark Complete
</button>
<button
onClick={() => updateStatus.mutate({ id: appt.id, status: 'cancelled' })}
disabled={updateStatus.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-red-600 bg-red-50 hover:bg-red-100 border border-red-200 rounded-lg transition-colors disabled:opacity-50"
>
<XCircle className="w-3.5 h-3.5" /> Cancel
</button>
</div>
)}
{appt.status === 'cancelled' && (
<button
onClick={() => updateStatus.mutate({ id: appt.id, status: 'pending' })}
disabled={updateStatus.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 mt-3 text-xs font-medium text-gray-600 bg-gray-100 hover:bg-gray-200 border border-gray-200 rounded-lg transition-colors disabled:opacity-50"
>
<RotateCcw className="w-3.5 h-3.5" /> Restore
</button>
)}
</div>
</div>
</Card>
)
})}
</div>
)}
</div>
)
}

View File

@@ -3,8 +3,117 @@ import { Link, useNavigate } from 'react-router-dom'
import { useAuthStore } from '@/store/authStore' import { useAuthStore } from '@/store/authStore'
import { authAPI } from '@/services/api' import { authAPI } from '@/services/api'
import { Button, Input } from '@/components/ui' import { Button, Input } from '@/components/ui'
import { Sparkles, Eye, EyeOff } from 'lucide-react' import { Sparkles, Eye, EyeOff, Mail, Lock, Building2, Check, X, MessageSquare, FileText, Globe } from 'lucide-react'
// ─── Shared branding panel ────────────────────────────────────────────────────
const BrandingPanel: React.FC = () => (
<div className="hidden lg:flex flex-col justify-between h-full p-10 bg-gradient-to-br from-primary-600 via-primary-700 to-purple-800 text-white relative overflow-hidden">
{/* decorative circles */}
<div className="absolute -top-16 -right-16 w-64 h-64 rounded-full bg-white/5" />
<div className="absolute bottom-10 -left-16 w-48 h-48 rounded-full bg-white/5" />
<div className="absolute top-1/2 right-4 w-32 h-32 rounded-full bg-white/5" />
{/* Logo */}
<div className="relative z-10 flex items-center gap-3">
<div className="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center backdrop-blur-sm">
<Sparkles className="w-5 h-5 text-white" />
</div>
<span className="text-xl font-bold tracking-tight">Contexta</span>
</div>
{/* Center content */}
<div className="relative z-10">
<h2 className="text-3xl font-bold leading-snug mb-3">
Build AI chatbots<br />that actually work.
</h2>
<p className="text-primary-200 text-sm leading-relaxed mb-8">
Upload your docs, train your bot, and publish it anywhere in minutes.
</p>
<ul className="space-y-3">
{[
{ icon: MessageSquare, text: 'Custom chatbots trained on your content' },
{ icon: FileText, text: 'PDF, DOCX, CSV, and URL sources' },
{ icon: Globe, text: 'Embed on any website or channel' },
].map(({ icon: Icon, text }) => (
<li key={text} className="flex items-center gap-3 text-sm text-primary-100">
<span className="w-6 h-6 rounded-full bg-white/20 flex items-center justify-center flex-shrink-0">
<Check className="w-3.5 h-3.5 text-white" />
</span>
{text}
</li>
))}
</ul>
</div>
{/* Footer quote */}
<p className="relative z-10 text-xs text-primary-300">
Trusted by businesses building smarter customer experiences.
</p>
</div>
)
// ─── Shared page wrapper ──────────────────────────────────────────────────────
const AuthLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<div className="min-h-screen bg-dots bg-gray-50 flex items-center justify-center p-4">
<div className="w-full max-w-4xl bg-white rounded-2xl shadow-xl border border-gray-100 overflow-hidden animate-scale-in">
<div className="grid grid-cols-1 lg:grid-cols-2 min-h-[580px]">
<BrandingPanel />
<div className="flex flex-col justify-center p-8 sm:p-10">
{children}
</div>
</div>
</div>
</div>
)
// ─── Error alert ──────────────────────────────────────────────────────────────
const ErrorAlert: React.FC<{ message: string; onDismiss?: () => void }> = ({ message, onDismiss }) => (
<div className="flex items-start gap-3 p-3.5 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700 animate-fade-in">
<span className="w-5 h-5 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0 mt-0.5">
<X className="w-3 h-3 text-red-600" />
</span>
<span className="flex-1">{message}</span>
{onDismiss && (
<button onClick={onDismiss} className="text-red-400 hover:text-red-600 transition-colors">
<X className="w-4 h-4" />
</button>
)}
</div>
)
// ─── Input wrapper with icon ──────────────────────────────────────────────────
const IconInput: React.FC<{
label: string
icon: React.ReactNode
type: string
value: string
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
placeholder: string
required?: boolean
rightElement?: React.ReactNode
}> = ({ label, icon, rightElement, ...inputProps }) => (
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">{label}</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
{icon}
</span>
<input
{...inputProps}
className="w-full pl-10 pr-10 py-2.5 border border-gray-300 rounded-lg text-sm bg-white
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent
hover:border-gray-400 transition-all duration-200 placeholder:text-gray-400"
/>
{rightElement && (
<span className="absolute right-3 top-1/2 -translate-y-1/2">
{rightElement}
</span>
)}
</div>
</div>
)
// ─── LoginPage ─────────────────────────────────────────────────────────────────
export const LoginPage: React.FC = () => { export const LoginPage: React.FC = () => {
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
@@ -31,73 +140,84 @@ export const LoginPage: React.FC = () => {
} }
return ( return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 via-white to-purple-50 flex items-center justify-center p-4"> <AuthLayout>
<div className="w-full max-w-md"> {/* Mobile logo */}
<div className="text-center mb-8"> <div className="flex lg:hidden items-center gap-2 mb-8">
<div className="inline-flex w-12 h-12 bg-primary-600 rounded-2xl items-center justify-center mb-4"> <div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
<Sparkles className="w-6 h-6 text-white" /> <Sparkles className="w-4 h-4 text-white" />
</div> </div>
<span className="font-bold text-gray-900">Contexta</span>
</div>
<div className="mb-7">
<h1 className="text-2xl font-bold text-gray-900">Welcome back</h1> <h1 className="text-2xl font-bold text-gray-900">Welcome back</h1>
<p className="text-gray-500 mt-1 text-sm">Sign in to your Contexta account</p> <p className="text-gray-500 mt-1 text-sm">Sign in to your Contexta account</p>
</div> </div>
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-8">
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<Input <IconInput
label="Email" label="Email"
icon={<Mail className="w-4 h-4" />}
type="email" type="email"
value={email} value={email}
onChange={e => setEmail(e.target.value)} onChange={e => setEmail(e.target.value)}
placeholder="you@company.com" placeholder="you@company.com"
required required
/> />
<div className="relative">
<Input <IconInput
label="Password" label="Password"
icon={<Lock className="w-4 h-4" />}
type={showPass ? 'text' : 'password'} type={showPass ? 'text' : 'password'}
value={password} value={password}
onChange={e => setPassword(e.target.value)} onChange={e => setPassword(e.target.value)}
placeholder="••••••••" placeholder="••••••••"
required required
/> rightElement={
<button <button
type="button" type="button"
onClick={() => setShowPass(!showPass)} onClick={() => setShowPass(!showPass)}
className="absolute right-3 top-8 text-gray-400 hover:text-gray-600" className="text-gray-400 hover:text-gray-600 transition-colors"
> >
{showPass ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />} {showPass ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button> </button>
</div> }
/>
{error && ( {error && <ErrorAlert message={error} onDismiss={() => setError('')} />}
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{error}
</div>
)}
<Button type="submit" loading={loading} className="w-full" size="lg"> <Button
type="submit"
loading={loading}
className="w-full mt-2 bg-primary-600 hover:bg-primary-700 shadow-sm hover:shadow-md transition-all duration-200"
size="lg"
>
Sign in Sign in
</Button> </Button>
</form> </form>
<div className="mt-4 text-right"> <div className="mt-4 flex items-center justify-between text-sm">
<Link to="/forgot-password" className="text-sm text-primary-600 hover:underline"> <span className="text-gray-500">
No account?{' '}
<Link
to="/signup"
className="text-primary-600 font-medium hover:text-primary-700 transition-colors underline-offset-2 hover:underline"
>
Sign up free
</Link>
</span>
<Link
to="/forgot-password"
className="text-gray-400 hover:text-primary-600 transition-colors text-xs"
>
Forgot password? Forgot password?
</Link> </Link>
</div> </div>
</AuthLayout>
<div className="mt-4 text-center text-sm text-gray-500">
Don't have an account?{' '}
<Link to="/signup" className="text-primary-600 font-medium hover:underline">
Create one free
</Link>
</div>
</div>
</div>
</div>
) )
} }
// ─── SignupPage ────────────────────────────────────────────────────────────────
export const SignupPage: React.FC = () => { export const SignupPage: React.FC = () => {
const [form, setForm] = useState({ email: '', password: '', company_name: '' }) const [form, setForm] = useState({ email: '', password: '', company_name: '' })
const [showPass, setShowPass] = useState(false) const [showPass, setShowPass] = useState(false)
@@ -135,19 +255,19 @@ export const SignupPage: React.FC = () => {
if (emailSent) { if (emailSent) {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 via-white to-purple-50 flex items-center justify-center p-4"> <div className="min-h-screen bg-dots bg-gray-50 flex items-center justify-center p-4">
<div className="w-full max-w-md text-center"> <div className="w-full max-w-md bg-white rounded-2xl shadow-xl border border-gray-100 p-10 text-center animate-scale-in">
<div className="inline-flex w-16 h-16 bg-green-100 rounded-2xl items-center justify-center mb-4"> <div className="w-16 h-16 bg-green-100 rounded-2xl flex items-center justify-center mx-auto mb-5">
<Sparkles className="w-8 h-8 text-green-600" /> <Check className="w-8 h-8 text-green-600" />
</div> </div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">Check your email</h1> <h1 className="text-2xl font-bold text-gray-900 mb-2">Check your inbox</h1>
<p className="text-gray-500 mb-4"> <p className="text-gray-500 text-sm mb-1">
We sent a confirmation link to <strong>{form.email}</strong>.<br /> A confirmation link was sent to
Click the link to activate your account.
</p> </p>
<p className="text-gray-900 font-medium text-sm mb-6">{form.email}</p>
<p className="text-sm text-gray-400"> <p className="text-sm text-gray-400">
Already confirmed?{' '} Already confirmed?{' '}
<Link to="/login" className="text-primary-600 hover:underline">Sign in</Link> <Link to="/login" className="text-primary-600 font-medium hover:underline">Sign in</Link>
</p> </p>
</div> </div>
</div> </div>
@@ -155,89 +275,88 @@ export const SignupPage: React.FC = () => {
} }
return ( return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 via-white to-purple-50 flex items-center justify-center p-4"> <AuthLayout>
<div className="w-full max-w-md"> {/* Mobile logo */}
<div className="text-center mb-8"> <div className="flex lg:hidden items-center gap-2 mb-8">
<div className="inline-flex w-12 h-12 bg-primary-600 rounded-2xl items-center justify-center mb-4"> <div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
<Sparkles className="w-6 h-6 text-white" /> <Sparkles className="w-4 h-4 text-white" />
</div> </div>
<h1 className="text-2xl font-bold text-gray-900">Create your account</h1> <span className="font-bold text-gray-900">Contexta</span>
<p className="text-gray-500 mt-1 text-sm">Start building AI chatbots for free</p> </div>
<div className="mb-7">
<h1 className="text-2xl font-bold text-gray-900">Create your account</h1>
<p className="text-gray-500 mt-1 text-sm">Start building AI chatbots free forever</p>
</div> </div>
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-8">
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<Input <IconInput
label="Company Name" label="Company Name"
icon={<Building2 className="w-4 h-4" />}
type="text" type="text"
value={form.company_name} value={form.company_name}
onChange={e => setForm(f => ({ ...f, company_name: e.target.value }))} onChange={e => setForm(f => ({ ...f, company_name: e.target.value }))}
placeholder="Acme Corp" placeholder="Acme Corp"
required required
/> />
<Input
<IconInput
label="Email" label="Email"
icon={<Mail className="w-4 h-4" />}
type="email" type="email"
value={form.email} value={form.email}
onChange={e => setForm(f => ({ ...f, email: e.target.value }))} onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
placeholder="you@company.com" placeholder="you@company.com"
required required
/> />
<div className="relative">
<Input <IconInput
label="Password" label="Password"
icon={<Lock className="w-4 h-4" />}
type={showPass ? 'text' : 'password'} type={showPass ? 'text' : 'password'}
value={form.password} value={form.password}
onChange={e => setForm(f => ({ ...f, password: e.target.value }))} onChange={e => setForm(f => ({ ...f, password: e.target.value }))}
placeholder="Min 8 characters" placeholder="Min 8 characters"
required required
/> rightElement={
<button <button
type="button" type="button"
onClick={() => setShowPass(!showPass)} onClick={() => setShowPass(!showPass)}
className="absolute right-3 top-8 text-gray-400 hover:text-gray-600" className="text-gray-400 hover:text-gray-600 transition-colors"
> >
{showPass ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />} {showPass ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button> </button>
</div> }
/>
{error && ( {error && <ErrorAlert message={error} onDismiss={() => setError('')} />}
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{error}
</div>
)}
<Button type="submit" loading={loading} className="w-full" size="lg"> <Button
type="submit"
loading={loading}
className="w-full mt-2 bg-primary-600 hover:bg-primary-700 shadow-sm hover:shadow-md transition-all duration-200"
size="lg"
>
Create free account Create free account
</Button> </Button>
<p className="text-xs text-center text-gray-400"> <p className="text-xs text-center text-gray-400 leading-relaxed">
By signing up, you agree to our Terms of Service and Privacy Policy By signing up you agree to our{' '}
<span className="text-gray-500 underline cursor-pointer">Terms of Service</span>{' '}
and{' '}
<span className="text-gray-500 underline cursor-pointer">Privacy Policy</span>
</p> </p>
</form> </form>
<div className="mt-6 text-center text-sm text-gray-500"> <div className="mt-5 text-center text-sm text-gray-500">
Already have an account?{' '} Already have an account?{' '}
<Link to="/login" className="text-primary-600 font-medium hover:underline"> <Link
to="/login"
className="text-primary-600 font-medium hover:text-primary-700 transition-colors underline-offset-2 hover:underline"
>
Sign in Sign in
</Link> </Link>
</div> </div>
</div> </AuthLayout>
{/* Features */}
<div className="mt-8 grid grid-cols-3 gap-4">
{[
{ emoji: '🤖', text: 'Build unlimited chatbots free' },
{ emoji: '📄', text: 'Upload PDF, DOCX, CSV files' },
{ emoji: '🏪', text: 'Publish to marketplace' },
].map(({ emoji, text }) => (
<div key={text} className="text-center">
<div className="text-2xl mb-1">{emoji}</div>
<p className="text-xs text-gray-500">{text}</p>
</div>
))}
</div>
</div>
</div>
) )
} }

383
src/pages/CampaignsPage.tsx Normal file
View File

@@ -0,0 +1,383 @@
import React, { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { campaignsAPI, chatbotsAPI } from '@/services/api'
import { Card, Button, Spinner } from '@/components/ui'
import {
Megaphone, Send, Trash2, Lock, Users, CheckCircle2, Clock,
AlertCircle, Plus, X, ChevronDown,
} from 'lucide-react'
import type { Campaign, Chatbot } from '@/types'
import { cn } from '@/lib/utils'
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: React.ElementType }> = {
draft: { label: 'Draft', color: 'bg-gray-100 text-gray-600', icon: Clock },
sending: { label: 'Sending...', color: 'bg-blue-100 text-blue-700', icon: Clock },
sent: { label: 'Sent', color: 'bg-green-100 text-green-700', icon: CheckCircle2 },
failed: { label: 'Failed', color: 'bg-red-100 text-red-600', icon: AlertCircle },
}
function timeAgo(dateStr?: string): string {
if (!dateStr) return ''
const diff = Date.now() - new Date(dateStr).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins}m ago`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return `${hrs}h ago`
return new Date(dateStr).toLocaleDateString()
}
// ── New Campaign Form ─────────────────────────────────────────────────────────
const NewCampaignForm: React.FC<{
chatbots: Chatbot[]
onClose: () => void
onCreate: (data: { chatbot_id: string; title: string; message: string }) => void
creating: boolean
}> = ({ chatbots, onClose, onCreate, creating }) => {
const [chatbotId, setChatbotId] = useState(chatbots[0]?.id || '')
const [title, setTitle] = useState('')
const [message, setMessage] = useState('')
const telegramChatbots = chatbots // All chatbots can have Telegram connected
const canSubmit = chatbotId && title.trim() && message.trim()
return (
<Card className="p-5 border-primary-200 bg-primary-50/30">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-gray-900 text-sm flex items-center gap-2">
<Megaphone className="w-4 h-4 text-primary-600" />
New Campaign
</h3>
<button onClick={onClose} className="p-1 rounded-lg hover:bg-gray-100 text-gray-400">
<X className="w-4 h-4" />
</button>
</div>
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-gray-700 block mb-1">Chatbot</label>
<select
value={chatbotId}
onChange={e => setChatbotId(e.target.value)}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-400 appearance-none"
>
{telegramChatbots.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<p className="text-[11px] text-gray-400 mt-1">
Will broadcast to all Telegram subscribers of this chatbot.
</p>
</div>
<div>
<label className="text-xs font-medium text-gray-700 block mb-1">Campaign name</label>
<input
type="text"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="e.g. Summer promotion, New menu announcement..."
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-400 transition-colors"
/>
</div>
<div>
<label className="text-xs font-medium text-gray-700 block mb-1">Message</label>
<textarea
value={message}
onChange={e => setMessage(e.target.value)}
placeholder="Write your broadcast message here..."
rows={4}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-400 transition-colors"
/>
<p className="text-[11px] text-gray-400 mt-1">{message.length}/4000 characters</p>
</div>
<div className="flex gap-2 pt-1">
<Button variant="secondary" size="sm" onClick={onClose} className="flex-1">Cancel</Button>
<Button
size="sm"
onClick={() => onCreate({ chatbot_id: chatbotId, title: title.trim(), message: message.trim() })}
disabled={!canSubmit || creating}
className="flex-1 gap-2"
>
{creating ? <Spinner className="w-4 h-4 text-white" /> : <Plus className="w-4 h-4" />}
Create Campaign
</Button>
</div>
</div>
</Card>
)
}
// ── Main Page ─────────────────────────────────────────────────────────────────
export const CampaignsPage: React.FC = () => {
const queryClient = useQueryClient()
const [showForm, setShowForm] = useState(false)
const [chatbotFilter, setChatbotFilter] = useState('')
const [confirmSendId, setConfirmSendId] = useState<string | null>(null)
const { data: chatbots = [] } = useQuery<Chatbot[]>({
queryKey: ['chatbots'],
queryFn: chatbotsAPI.list,
})
const { data: campaigns = [], isLoading, error } = useQuery<Campaign[]>({
queryKey: ['campaigns', chatbotFilter],
queryFn: () => campaignsAPI.list(chatbotFilter ? { chatbot_id: chatbotFilter } : undefined),
retry: false,
refetchInterval: 5000, // Poll while a campaign may be sending
})
const createCampaign = useMutation({
mutationFn: campaignsAPI.create,
onSuccess: () => {
setShowForm(false)
queryClient.invalidateQueries({ queryKey: ['campaigns'] })
},
})
const sendCampaign = useMutation({
mutationFn: (id: string) => campaignsAPI.send(id),
onSuccess: () => {
setConfirmSendId(null)
queryClient.invalidateQueries({ queryKey: ['campaigns'] })
},
})
const deleteCampaign = useMutation({
mutationFn: (id: string) => campaignsAPI.delete(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['campaigns'] }),
})
const isPlanError = (error as { response?: { status?: number } })?.response?.status === 402
if (isPlanError) {
return (
<div className="p-6 max-w-2xl mx-auto">
<Card className="p-10 text-center">
<div className="w-16 h-16 rounded-2xl bg-primary-50 flex items-center justify-center mx-auto mb-5">
<Lock className="w-7 h-7 text-primary-600" />
</div>
<h2 className="text-xl font-bold text-gray-900 mb-2">Telegram Campaigns</h2>
<p className="text-gray-500 text-sm mb-6 max-w-sm mx-auto">
Upgrade to Starter to broadcast messages to your Telegram subscribers.
</p>
</Card>
</div>
)
}
const chatbotMap = Object.fromEntries(chatbots.map(c => [c.id, c.name]))
const sentTotal = campaigns.filter(c => c.status === 'sent').reduce((sum, c) => sum + c.sent_count, 0)
return (
<div className="p-4 sm:p-6 max-w-3xl mx-auto space-y-6">
{/* Confirm send modal */}
{confirmSendId && (() => {
const c = campaigns.find(x => x.id === confirmSendId)
if (!c) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6">
<h3 className="font-bold text-gray-900 mb-2">Send this campaign?</h3>
<p className="text-sm text-gray-600 mb-1">
<strong>"{c.title}"</strong> will be sent to{' '}
<strong>{c.recipients_count} subscriber{c.recipients_count !== 1 ? 's' : ''}</strong>{' '}
via Telegram.
</p>
<p className="text-xs text-amber-600 bg-amber-50 rounded-lg p-2.5 mt-3">
This action cannot be undone. The message will be delivered immediately.
</p>
<div className="flex gap-2 mt-5">
<Button variant="secondary" size="sm" className="flex-1" onClick={() => setConfirmSendId(null)}>
Cancel
</Button>
<Button
size="sm"
className="flex-1 gap-2 bg-green-600 hover:bg-green-700"
onClick={() => sendCampaign.mutate(confirmSendId)}
disabled={sendCampaign.isPending}
>
{sendCampaign.isPending ? <Spinner className="w-4 h-4 text-white" /> : <Send className="w-4 h-4" />}
Send Now
</Button>
</div>
</div>
</div>
)
})()}
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center flex-shrink-0">
<Megaphone className="w-5 h-5 text-primary-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">Campaigns</h1>
<p className="text-sm text-gray-500 mt-0.5">Broadcast messages to Telegram subscribers</p>
</div>
</div>
{!showForm && (
<Button size="sm" onClick={() => setShowForm(true)} className="self-start sm:self-auto gap-2">
<Plus className="w-4 h-4" />
New Campaign
</Button>
)}
</div>
{/* Stats */}
{campaigns.length > 0 && (
<div className="grid grid-cols-3 gap-4">
{[
{ label: 'Campaigns', value: campaigns.length },
{ label: 'Sent', value: campaigns.filter(c => c.status === 'sent').length },
{ label: 'Messages delivered', value: sentTotal.toLocaleString() },
].map(s => (
<Card key={s.label} className="p-4 text-center">
<p className="text-2xl font-bold text-gray-900">{s.value}</p>
<p className="text-xs text-gray-500 mt-0.5">{s.label}</p>
</Card>
))}
</div>
)}
{/* New campaign form */}
{showForm && chatbots.length > 0 && (
<NewCampaignForm
chatbots={chatbots}
onClose={() => setShowForm(false)}
onCreate={(data) => createCampaign.mutate(data)}
creating={createCampaign.isPending}
/>
)}
{showForm && chatbots.length === 0 && (
<Card className="p-6 text-center">
<p className="text-sm text-gray-500">You need at least one chatbot to create a campaign.</p>
<button onClick={() => setShowForm(false)} className="text-xs text-primary-600 mt-2 hover:underline">Close</button>
</Card>
)}
{/* Filter */}
{chatbots.length > 0 && (
<div className="flex items-center gap-3">
<select
value={chatbotFilter}
onChange={e => setChatbotFilter(e.target.value)}
className="border border-gray-200 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary-500/30 appearance-none cursor-pointer"
>
<option value="">All chatbots</option>
{chatbots.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
)}
{/* Campaigns list */}
{isLoading ? (
<div className="flex justify-center py-12"><Spinner className="text-primary-600" /></div>
) : campaigns.length === 0 ? (
<Card className="p-14 text-center">
<div className="w-16 h-16 rounded-2xl bg-gray-50 flex items-center justify-center mx-auto mb-5">
<Megaphone className="w-7 h-7 text-gray-300" />
</div>
<h3 className="font-semibold text-gray-700 mb-2">No campaigns yet</h3>
<p className="text-sm text-gray-500 max-w-sm mx-auto">
Create a campaign to broadcast a message to all your Telegram subscribers at once.
</p>
{!showForm && (
<Button size="sm" className="mt-4 gap-2" onClick={() => setShowForm(true)}>
<Plus className="w-4 h-4" />
New Campaign
</Button>
)}
</Card>
) : (
<div className="space-y-3">
{campaigns.map(campaign => {
const sc = STATUS_CONFIG[campaign.status] || STATUS_CONFIG.draft
const Icon = sc.icon
const chatbotName = chatbotMap[campaign.chatbot_id] || 'Unknown chatbot'
return (
<Card key={campaign.id} className="p-5">
<div className="flex items-start gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 flex-wrap mb-1">
<h3 className="font-semibold text-gray-900 text-sm">{campaign.title}</h3>
<span className={cn('flex items-center gap-1 text-xs font-medium px-2.5 py-1 rounded-full flex-shrink-0', sc.color)}>
<Icon className="w-3 h-3" />
{sc.label}
</span>
</div>
<p className="text-xs text-gray-500 mb-2">
{chatbotName} · {timeAgo(campaign.created_at)}
</p>
<p className="text-sm text-gray-700 bg-gray-50 rounded-lg px-3 py-2 mb-3 line-clamp-2">
{campaign.message}
</p>
<div className="flex items-center gap-4 text-xs text-gray-500">
<span className="flex items-center gap-1">
<Users className="w-3 h-3" />
{campaign.recipients_count} subscriber{campaign.recipients_count !== 1 ? 's' : ''}
</span>
{campaign.status === 'sent' && (
<span className="flex items-center gap-1 text-green-600">
<CheckCircle2 className="w-3 h-3" />
{campaign.sent_count} delivered · {timeAgo(campaign.sent_at)}
</span>
)}
</div>
{/* Actions */}
{campaign.status === 'draft' && (
<div className="flex gap-2 mt-3">
<button
onClick={() => setConfirmSendId(campaign.id)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-green-600 hover:bg-green-700 rounded-lg transition-colors"
>
<Send className="w-3.5 h-3.5" />
Send Campaign
</button>
<button
onClick={() => {
if (confirm('Delete this campaign?'))
deleteCampaign.mutate(campaign.id)
}}
disabled={deleteCampaign.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-red-600 bg-red-50 hover:bg-red-100 border border-red-200 rounded-lg transition-colors disabled:opacity-50"
>
<Trash2 className="w-3.5 h-3.5" />
Delete
</button>
</div>
)}
{campaign.status === 'sent' && (
<button
onClick={() => {
if (confirm('Delete this campaign record?'))
deleteCampaign.mutate(campaign.id)
}}
disabled={deleteCampaign.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 mt-3 text-xs font-medium text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 className="w-3 h-3" />
Delete record
</button>
)}
</div>
</div>
</Card>
)
})}
</div>
)}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,38 +1,23 @@
import React, { useState, useCallback } from 'react' import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { 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'
import { Button, Card, StatusDot, EmptyState, Modal, Spinner } from '@/components/ui' import { Button, StatusDot, EmptyState, Modal } from '@/components/ui'
import { SkeletonCard } from '@/components/Skeletons'
import { useToast } from '@/contexts/ToastContext'
import { cn } from '@/lib/utils'
import type { Chatbot } from '@/types' import type { Chatbot } from '@/types'
import { import {
Bot, Plus, MoreHorizontal, Globe, Lock, Trash2, Bot, Plus, MoreHorizontal, Globe, Lock, Trash2,
Settings, Eye, BarChart2 Settings, Eye, BarChart2, FileText, MessageSquare,
} 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 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 [toasts, setToasts] = useState<ToastItem[]>([]) const [confirmAction, setConfirmAction] = useState<{ type: 'publish' | 'unpublish'; id: string } | null>(null)
const { success: showToast } = useToast()
// 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'],
@@ -64,41 +49,41 @@ export const DashboardPage: React.FC = () => {
}, },
}) })
// IMP-11: Confirmation before publish/unpublish
const [confirmAction, setConfirmAction] = useState<{ type: 'publish' | 'unpublish'; id: string } | null>(null)
const handleConfirmAction = () => { const handleConfirmAction = () => {
if (!confirmAction) return if (!confirmAction) return
if (confirmAction.type === 'publish') { if (confirmAction.type === 'publish') publishMutation.mutate(confirmAction.id)
publishMutation.mutate(confirmAction.id) else unpublishMutation.mutate(confirmAction.id)
} else {
unpublishMutation.mutate(confirmAction.id)
}
setConfirmAction(null) setConfirmAction(null)
} }
return ( return (
<div className="p-4 sm:p-6"> <div className="p-4 sm:p-6 max-w-7xl">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-6 gap-3 animate-fade-in"> {/* Header */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-6 gap-3">
<div> <div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900">Dashboard</h1> <h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight">Dashboard</h1>
<p className="text-sm text-gray-500 mt-0.5">Manage your AI chatbots</p> <p className="text-sm text-gray-500 mt-0.5">
{chatbots.length > 0
? `${chatbots.length} chatbot${chatbots.length !== 1 ? 's' : ''}`
: 'Manage your AI chatbots'}
</p>
</div> </div>
<Button onClick={() => navigate('/chatbots/new')}> <Button onClick={() => navigate('/chatbots/new')} size="md">
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
New Chatbot New Chatbot
</Button> </Button>
</div> </div>
{/* Grid */}
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-20"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<Spinner className="text-primary-600" /> {Array.from({ length: 3 }).map((_, i) => <SkeletonCard key={i} />)}
</div> </div>
) : chatbots.length === 0 ? ( ) : chatbots.length === 0 ? (
<EmptyState <EmptyState
icon={<Bot className="w-8 h-8" />} icon={<Bot className="w-8 h-8" />}
title="No chatbots yet" title="No chatbots yet"
description="Create your first AI chatbot powered by your documents. It's free to build and test." description="Create your first AI chatbot powered by your documents. Free to build and test."
action={ action={
<Button onClick={() => navigate('/chatbots/new')} size="lg"> <Button onClick={() => navigate('/chatbots/new')} size="lg">
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
@@ -118,37 +103,37 @@ export const DashboardPage: React.FC = () => {
onPublish={() => setConfirmAction({ type: 'publish', id: chatbot.id })} onPublish={() => setConfirmAction({ type: 'publish', id: chatbot.id })}
onUnpublish={() => setConfirmAction({ type: 'unpublish', id: 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(`/analytics`)}
/> />
))} ))}
{/* Add new card */}
{/* New chatbot card */}
<button <button
className="group border-2 border-dashed border-gray-200 hover:border-primary-400 hover:bg-primary-50/50 flex items-center justify-center min-h-[220px] rounded-xl cursor-pointer transition-all duration-200 hover:-translate-y-1 hover:shadow-md"
onClick={() => navigate('/chatbots/new')} onClick={() => navigate('/chatbots/new')}
className={cn(
'group flex flex-col items-center justify-center gap-3 min-h-[200px]',
'border-2 border-dashed border-gray-200 rounded-2xl',
'hover:border-primary-300 hover:bg-primary-50/30',
'transition-all duration-200 hover:-translate-y-0.5',
)}
> >
<div className="text-center"> <div className="w-12 h-12 rounded-2xl bg-gray-100 group-hover:bg-primary-100 flex items-center justify-center transition-colors duration-200">
<div className="w-12 h-12 bg-gray-100 group-hover:bg-primary-100 rounded-full flex items-center justify-center mx-auto mb-3 transition-colors duration-200"> <Plus className="w-5 h-5 text-gray-400 group-hover:text-primary-500 transition-colors duration-200" />
<Plus className="w-6 h-6 text-gray-400 group-hover:text-primary-500 transition-colors duration-200" />
</div>
<p className="text-sm font-medium text-gray-400 group-hover:text-primary-600 transition-colors duration-200">New Chatbot</p>
</div> </div>
<p className="text-sm font-medium text-gray-400 group-hover:text-primary-600 transition-colors duration-200">
New Chatbot
</p>
</button> </button>
</div> </div>
)} )}
{/* Delete confirmation */} {/* Delete modal */}
<Modal <Modal isOpen={!!deleteId} onClose={() => setDeleteId(null)} title="Delete Chatbot" size="sm">
isOpen={!!deleteId} <p className="text-sm text-gray-500 mb-5 leading-relaxed">
onClose={() => setDeleteId(null)} All documents, conversation history, and settings will be permanently removed. This cannot be undone.
title="Delete Chatbot"
>
<p className="text-gray-600 mb-6">
Are you sure you want to delete this chatbot? All documents and conversation history will be permanently removed.
</p> </p>
<div className="flex gap-3"> <div className="flex gap-2.5">
<Button variant="outline" onClick={() => setDeleteId(null)} className="flex-1"> <Button variant="outline" onClick={() => setDeleteId(null)} className="flex-1">Cancel</Button>
Cancel
</Button>
<Button <Button
variant="danger" variant="danger"
onClick={() => deleteId && deleteMutation.mutate(deleteId)} onClick={() => deleteId && deleteMutation.mutate(deleteId)}
@@ -160,21 +145,20 @@ export const DashboardPage: React.FC = () => {
</div> </div>
</Modal> </Modal>
{/* IMP-11: Publish/Unpublish confirmation */} {/* Publish/unpublish modal */}
<Modal <Modal
isOpen={!!confirmAction} isOpen={!!confirmAction}
onClose={() => setConfirmAction(null)} onClose={() => setConfirmAction(null)}
title={confirmAction?.type === 'publish' ? 'Publish Chatbot' : 'Unpublish Chatbot'} title={confirmAction?.type === 'publish' ? 'Publish to Marketplace' : 'Unpublish Chatbot'}
size="sm"
> >
<p className="text-gray-600 mb-6"> <p className="text-sm text-gray-500 mb-5 leading-relaxed">
{confirmAction?.type === 'publish' {confirmAction?.type === 'publish'
? 'This will make your chatbot publicly visible on the marketplace. Are you sure?' ? 'Your chatbot will be publicly visible on the marketplace.'
: 'This will remove your chatbot from the marketplace. Users will no longer be able to access it.'} : 'Your chatbot will be removed from the marketplace.'}
</p> </p>
<div className="flex gap-3"> <div className="flex gap-2.5">
<Button variant="outline" onClick={() => setConfirmAction(null)} className="flex-1"> <Button variant="outline" onClick={() => setConfirmAction(null)} className="flex-1">Cancel</Button>
Cancel
</Button>
<Button <Button
onClick={handleConfirmAction} onClick={handleConfirmAction}
loading={publishMutation.isPending || unpublishMutation.isPending} loading={publishMutation.isPending || unpublishMutation.isPending}
@@ -184,24 +168,11 @@ export const DashboardPage: React.FC = () => {
</Button> </Button>
</div> </div>
</Modal> </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>
) )
} }
// ─── Chatbot Card ──────────────────────────────────────────────────────────────
const ChatbotCard: React.FC<{ const ChatbotCard: React.FC<{
chatbot: Chatbot chatbot: Chatbot
index: number index: number
@@ -216,72 +187,91 @@ const ChatbotCard: React.FC<{
return ( return (
<div <div
className="group bg-white rounded-xl border border-gray-200 shadow-sm hover:-translate-y-1 hover:shadow-lg hover:border-gray-300 transition-all duration-200 overflow-hidden animate-fade-in-up" className="group bg-white rounded-2xl border border-gray-100 shadow-sm hover:shadow-md hover:-translate-y-0.5 hover:border-gray-200 transition-all duration-200 overflow-hidden animate-fade-in-up"
style={{ animationDelay: `${index * 80}ms`, animationFillMode: 'both' }} style={{ animationDelay: `${index * 60}ms`, animationFillMode: 'both' }}
> >
{/* Colored top accent */} {/* Color accent bar */}
<div className="h-1 w-full" style={{ background: chatbot.primary_color }} /> <div className="h-1 w-full" style={{ background: chatbot.primary_color }} />
<div className="p-5"> <div className="p-5">
<div className="flex items-start justify-between mb-3"> {/* Top row */}
<div className="flex items-center gap-3"> <div className="flex items-start justify-between gap-2 mb-3">
<div className="flex items-center gap-3 min-w-0">
{chatbot.logo_url ? ( {chatbot.logo_url ? (
<img <img
src={chatbot.logo_url} src={chatbot.logo_url}
alt={chatbot.name} alt={chatbot.name}
className="w-11 h-11 rounded-xl object-cover flex-shrink-0 shadow-sm group-hover:scale-105 transition-transform duration-200" className="w-10 h-10 rounded-xl object-cover shrink-0 shadow-sm group-hover:scale-105 transition-transform duration-200"
/> />
) : ( ) : (
<div <div
className="w-11 h-11 rounded-xl flex items-center justify-center text-white flex-shrink-0 shadow-sm group-hover:scale-105 transition-transform duration-200" className="w-10 h-10 rounded-xl flex items-center justify-center text-white shrink-0 shadow-sm group-hover:scale-105 transition-transform duration-200"
style={{ background: chatbot.primary_color }} style={{ background: chatbot.primary_color }}
> >
<Bot className="w-5 h-5" /> <Bot className="w-4.5 h-4.5" />
</div> </div>
)} )}
<div> <div className="min-w-0">
<h3 className="font-semibold text-gray-900 text-sm group-hover:text-primary-700 transition-colors">{chatbot.name}</h3> <h3 className="font-semibold text-gray-900 text-sm truncate">{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 font-medium ${chatbot.is_published ? 'text-green-600' : 'text-gray-400'}`}> <span className={cn(
{chatbot.is_published ? 'Published' : 'Preview'} 'text-xs font-medium',
chatbot.is_published ? 'text-green-600' : 'text-gray-400'
)}>
{chatbot.is_published ? 'Published' : 'Draft'}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
<div className="relative"> {/* Context menu */}
<div className="relative shrink-0">
<button <button
onClick={() => setMenuOpen(!menuOpen)} onClick={() => setMenuOpen(!menuOpen)}
className="p-1.5 hover:bg-gray-100 rounded-lg text-gray-300 hover:text-gray-600 transition-colors" className="w-7 h-7 flex items-center justify-center hover:bg-gray-100 rounded-lg text-gray-300 hover:text-gray-600 transition-colors"
> >
<MoreHorizontal className="w-4 h-4" /> <MoreHorizontal className="w-4 h-4" />
</button> </button>
{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-xl z-20 overflow-hidden text-sm animate-scale-in"> <div className="absolute right-0 mt-1 w-44 bg-white border border-gray-100 rounded-xl shadow-xl z-20 overflow-hidden py-1 animate-scale-in">
<button onClick={() => { onEdit(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2.5 hover:bg-gray-50 text-left text-gray-700 transition-colors"> {[
<Settings className="w-3.5 h-3.5 text-gray-400" /> Edit Settings { label: 'Edit Settings', icon: Settings, action: onEdit },
{ label: 'Preview', icon: Eye, action: onPreview },
{ label: 'Analytics', icon: BarChart2, action: onAnalytics },
].map(({ label, icon: Icon, action }) => (
<button
key={label}
onClick={() => { action(); setMenuOpen(false) }}
className="flex items-center gap-2.5 w-full px-3 py-2 hover:bg-gray-50 text-left text-sm text-gray-700 transition-colors"
>
<Icon className="w-3.5 h-3.5 text-gray-400" />
{label}
</button> </button>
<button onClick={() => { onPreview(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2.5 hover:bg-gray-50 text-left text-gray-700 transition-colors"> ))}
<Eye className="w-3.5 h-3.5 text-gray-400" /> Preview <div className="h-px bg-gray-50 my-1" />
</button>
<button onClick={() => { onAnalytics(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2.5 hover:bg-gray-50 text-left text-gray-700 transition-colors">
<BarChart2 className="w-3.5 h-3.5 text-gray-400" /> Analytics
</button>
<div className="h-px bg-gray-100 mx-2" />
{chatbot.is_published ? ( {chatbot.is_published ? (
<button onClick={() => { onUnpublish(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2.5 hover:bg-orange-50 text-orange-600 text-left transition-colors"> <button
onClick={() => { onUnpublish(); setMenuOpen(false) }}
className="flex items-center gap-2.5 w-full px-3 py-2 hover:bg-amber-50 text-amber-600 text-left text-sm transition-colors"
>
<Lock className="w-3.5 h-3.5" /> Unpublish <Lock className="w-3.5 h-3.5" /> Unpublish
</button> </button>
) : ( ) : (
<button onClick={() => { onPublish(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2.5 hover:bg-green-50 text-green-600 text-left transition-colors"> <button
onClick={() => { onPublish(); setMenuOpen(false) }}
className="flex items-center gap-2.5 w-full px-3 py-2 hover:bg-green-50 text-green-600 text-left text-sm transition-colors"
>
<Globe className="w-3.5 h-3.5" /> Publish <Globe className="w-3.5 h-3.5" /> Publish
</button> </button>
)} )}
<div className="h-px bg-gray-100 mx-2" /> <div className="h-px bg-gray-50 my-1" />
<button onClick={() => { onDelete(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2.5 hover:bg-red-50 text-red-600 text-left transition-colors"> <button
onClick={() => { onDelete(); setMenuOpen(false) }}
className="flex items-center gap-2.5 w-full px-3 py-2 hover:bg-red-50 text-red-500 text-left text-sm transition-colors"
>
<Trash2 className="w-3.5 h-3.5" /> Delete <Trash2 className="w-3.5 h-3.5" /> Delete
</button> </button>
</div> </div>
@@ -290,20 +280,23 @@ const ChatbotCard: React.FC<{
</div> </div>
</div> </div>
{/* Description */}
{chatbot.description && ( {chatbot.description && (
<p className="text-xs text-gray-500 mb-3 line-clamp-2 leading-relaxed">{chatbot.description}</p> <p className="text-xs text-gray-500 mb-3 line-clamp-2 leading-relaxed">{chatbot.description}</p>
)} )}
{/* Stats */} {/* Stats */}
<div className="flex flex-wrap gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<span className="flex items-center gap-1 text-xs text-gray-500 bg-gray-50 px-2 py-1 rounded-lg"> <span className="flex items-center gap-1 text-xs text-gray-500 bg-gray-50 px-2 py-1 rounded-lg">
<span className="text-gray-400">📄</span> {chatbot.document_count} docs <FileText className="w-3 h-3 text-gray-400" />
{chatbot.document_count}
</span> </span>
<span className="flex items-center gap-1 text-xs text-gray-500 bg-gray-50 px-2 py-1 rounded-lg"> <span className="flex items-center gap-1 text-xs text-gray-500 bg-gray-50 px-2 py-1 rounded-lg">
<span className="text-gray-400">💬</span> {chatbot.conversation_count.toLocaleString()} chats <MessageSquare className="w-3 h-3 text-gray-400" />
{chatbot.conversation_count.toLocaleString()}
</span> </span>
{chatbot.category && ( {chatbot.category && (
<span className="text-xs text-primary-700 bg-primary-50 px-2 py-1 rounded-lg font-medium"> <span className="text-xs text-primary-600 bg-primary-50 px-2 py-1 rounded-lg font-medium border border-primary-100 ml-auto">
{chatbot.category} {chatbot.category}
</span> </span>
)} )}
@@ -311,18 +304,23 @@ const ChatbotCard: React.FC<{
{/* Actions */} {/* Actions */}
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="outline" size="sm" onClick={onPreview} className="flex-1"> <Button variant="outline" size="sm" onClick={onPreview} className="flex-1 text-xs">
<Eye className="w-3.5 h-3.5" /> <Eye className="w-3 h-3" />
Preview Preview
</Button> </Button>
{chatbot.is_published ? ( {chatbot.is_published ? (
<Button variant="outline" size="sm" onClick={onUnpublish} className="flex-1 text-orange-600 border-orange-200 hover:bg-orange-50"> <Button
<Lock className="w-3.5 h-3.5" /> variant="outline"
size="sm"
onClick={onUnpublish}
className="flex-1 text-xs text-amber-600 border-amber-200 hover:bg-amber-50"
>
<Lock className="w-3 h-3" />
Unpublish Unpublish
</Button> </Button>
) : ( ) : (
<Button size="sm" onClick={onPublish} className="flex-1"> <Button size="sm" onClick={onPublish} className="flex-1 text-xs">
<Globe className="w-3.5 h-3.5" /> <Globe className="w-3 h-3" />
Publish Publish
</Button> </Button>
)} )}

View File

@@ -1,8 +1,8 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { authAPI } from '@/services/api' import { authAPI } from '@/services/api'
import { Button, Input } from '@/components/ui' import { Button } from '@/components/ui'
import { Sparkles, ArrowLeft } from 'lucide-react' import { Sparkles, ArrowLeft, Mail, X, Check } from 'lucide-react'
export const ForgotPasswordPage: React.FC = () => { export const ForgotPasswordPage: React.FC = () => {
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
@@ -25,58 +25,96 @@ export const ForgotPasswordPage: React.FC = () => {
} }
return ( return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 via-white to-purple-50 flex items-center justify-center p-4"> <div className="min-h-screen bg-dots bg-gray-50 flex items-center justify-center p-4">
<div className="w-full max-w-md"> <div className="w-full max-w-md animate-scale-in">
<div className="text-center mb-8"> {/* Logo */}
<div className="inline-flex w-12 h-12 bg-primary-600 rounded-2xl items-center justify-center mb-4"> <div className="flex items-center justify-center gap-2 mb-8">
<Sparkles className="w-6 h-6 text-white" /> <div className="w-9 h-9 bg-primary-600 rounded-xl flex items-center justify-center shadow-sm">
<Sparkles className="w-5 h-5 text-white" />
</div> </div>
<h1 className="text-2xl font-bold text-gray-900">Reset your password</h1> <span className="font-bold text-gray-900 text-lg">Contexta</span>
<p className="text-gray-500 mt-1 text-sm">We'll send a reset link to your email</p>
</div> </div>
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-8"> <div className="bg-white rounded-2xl shadow-xl border border-gray-100 p-8">
{sent ? ( {sent ? (
<div className="text-center"> <div className="text-center py-2 animate-fade-in-up">
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4"> <div className="w-14 h-14 bg-green-100 rounded-2xl flex items-center justify-center mx-auto mb-5">
<span className="text-green-600 text-xl"></span> <Check className="w-7 h-7 text-green-600" />
</div> </div>
<h2 className="font-semibold text-gray-900 mb-2">Check your email</h2> <h2 className="text-xl font-bold text-gray-900 mb-2">Check your inbox</h2>
<p className="text-sm text-gray-500 mb-4"> <p className="text-sm text-gray-500 mb-1">
If <strong>{email}</strong> is registered, a reset link has been sent. If <strong className="text-gray-700">{email}</strong> is registered,
</p> </p>
<Link to="/login" className="text-sm text-primary-600 hover:underline"> <p className="text-sm text-gray-500 mb-6">
a password reset link has been sent.
</p>
<Link
to="/login"
className="inline-flex items-center gap-1.5 text-sm text-primary-600 font-medium hover:text-primary-700 transition-colors"
>
<ArrowLeft className="w-3.5 h-3.5" />
Back to sign in Back to sign in
</Link> </Link>
</div> </div>
) : ( ) : (
<>
<div className="mb-7">
<h1 className="text-2xl font-bold text-gray-900">Reset your password</h1>
<p className="text-gray-500 mt-1 text-sm">
We'll send a reset link to your email address.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<Input {/* Email input with icon */}
label="Email" <div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Email address</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
<Mail className="w-4 h-4" />
</span>
<input
type="email" type="email"
value={email} value={email}
onChange={e => setEmail(e.target.value)} onChange={e => setEmail(e.target.value)}
placeholder="you@company.com" placeholder="you@company.com"
required required
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-lg text-sm bg-white
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent
hover:border-gray-400 transition-all duration-200 placeholder:text-gray-400"
/> />
</div>
</div>
{error && ( {error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700"> <div className="flex items-start gap-3 p-3.5 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700 animate-fade-in">
{error} <span className="w-5 h-5 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0 mt-0.5">
<X className="w-3 h-3 text-red-600" />
</span>
<span>{error}</span>
</div> </div>
)} )}
<Button type="submit" loading={loading} className="w-full" size="lg">
<Button
type="submit"
loading={loading}
className="w-full bg-primary-600 hover:bg-primary-700 shadow-sm hover:shadow-md transition-all duration-200"
size="lg"
>
Send reset link Send reset link
</Button> </Button>
</form> </form>
)}
{!sent && (
<div className="mt-6 text-center"> <div className="mt-6 text-center">
<Link to="/login" className="text-sm text-gray-500 hover:text-gray-700 flex items-center justify-center gap-1"> <Link
to="/login"
className="inline-flex items-center gap-1.5 text-sm text-gray-400 hover:text-gray-600 transition-colors"
>
<ArrowLeft className="w-3.5 h-3.5" /> <ArrowLeft className="w-3.5 h-3.5" />
Back to sign in Back to sign in
</Link> </Link>
</div> </div>
</>
)} )}
</div> </div>
</div> </div>

View File

@@ -1,10 +1,14 @@
import React, { useState } from 'react' import React, { useState, useRef, useEffect } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { inboxAPI } from '@/services/api' import { inboxAPI } from '@/services/api'
import { Card, Spinner } from '@/components/ui' import { Card, Spinner } from '@/components/ui'
import { Mail, MessageSquare, Bot, AlertTriangle, ArrowRight, Trash2 } from 'lucide-react' import {
import type { InboxConversation, InboxMessage } from '@/types' Mail, MessageSquare, Bot, AlertTriangle, ArrowLeft, Trash2, Inbox,
UserCheck, Send, CheckCircle2, RotateCcw, User,
} from 'lucide-react'
import type { InboxConversation, InboxMessage, ConversationStatus } from '@/types'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { SkeletonList } from '@/components/Skeletons'
interface ConversationDetail { interface ConversationDetail {
conversation_id: string conversation_id: string
@@ -15,16 +19,94 @@ interface ConversationDetail {
messages: InboxMessage[] messages: InboxMessage[]
} }
function timeAgo(dateStr?: string): string {
if (!dateStr) return ''
const diff = Date.now() - new Date(dateStr).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins}m ago`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return `${hrs}h ago`
const days = Math.floor(hrs / 24)
if (days < 7) return `${days}d ago`
return new Date(dateStr).toLocaleDateString()
}
const AvatarInitial: React.FC<{ name: string; size?: 'sm' | 'md' }> = ({ name, size = 'md' }) => {
const initial = name ? name[0].toUpperCase() : '?'
const colors = [
'bg-indigo-100 text-indigo-700', 'bg-violet-100 text-violet-700',
'bg-sky-100 text-sky-700', 'bg-emerald-100 text-emerald-700',
'bg-rose-100 text-rose-700', 'bg-amber-100 text-amber-700',
]
const colorIdx = name.charCodeAt(0) % colors.length
return (
<div className={cn(
'flex items-center justify-center rounded-full font-semibold flex-shrink-0',
colors[colorIdx],
size === 'sm' ? 'w-8 h-8 text-xs' : 'w-10 h-10 text-sm'
)}>
{initial}
</div>
)
}
const STATUS_CONFIG: Record<ConversationStatus, { label: string; color: string }> = {
open: { label: 'Open', color: 'bg-blue-100 text-blue-700' },
agent_handling: { label: 'Agent', color: 'bg-orange-100 text-orange-700' },
resolved: { label: 'Resolved', color: 'bg-green-100 text-green-700' },
}
const STATUS_TABS: { key: ConversationStatus | 'all'; label: string }[] = [
{ key: 'all', label: 'All' },
{ key: 'open', label: 'Open' },
{ key: 'agent_handling', label: 'Agent' },
{ key: 'resolved', label: 'Resolved' },
]
export const InboxPage: React.FC = () => { export const InboxPage: React.FC = () => {
const [selectedId, setSelectedId] = useState<string | null>(null) const [selectedId, setSelectedId] = useState<string | null>(null)
const [chatbotFilter, setChatbotFilter] = useState('') const [statusFilter, setStatusFilter] = useState<ConversationStatus | 'all'>('all')
const [deletingId, setDeletingId] = useState<string | null>(null) const [deletingId, setDeletingId] = useState<string | null>(null)
const [replyText, setReplyText] = useState('')
const messagesEndRef = useRef<HTMLDivElement>(null)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { data: conversations = [], isLoading, error } = useQuery<InboxConversation[]>({ const { data: conversations = [], isLoading, error } = useQuery<InboxConversation[]>({
queryKey: ['inbox-conversations', chatbotFilter], queryKey: ['inbox-conversations', statusFilter],
queryFn: () => inboxAPI.conversations(chatbotFilter ? { chatbot_id: chatbotFilter } : undefined), queryFn: () => inboxAPI.conversations(statusFilter !== 'all' ? { status: statusFilter } : undefined),
retry: false, retry: false,
refetchInterval: 15000, // poll every 15s
})
const { data: detail, isLoading: detailLoading } = useQuery<ConversationDetail>({
queryKey: ['inbox-conversation', selectedId],
queryFn: () => inboxAPI.conversation(selectedId!),
enabled: !!selectedId,
refetchInterval: 8000,
})
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [detail?.messages])
const updateStatus = useMutation({
mutationFn: ({ id, status }: { id: string; status: string }) =>
inboxAPI.updateStatus(id, status),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['inbox-conversations'] })
queryClient.invalidateQueries({ queryKey: ['inbox-conversation', selectedId] })
},
})
const sendReply = useMutation({
mutationFn: ({ id, message }: { id: string; message: string }) =>
inboxAPI.reply(id, message),
onSuccess: () => {
setReplyText('')
queryClient.invalidateQueries({ queryKey: ['inbox-conversations'] })
queryClient.invalidateQueries({ queryKey: ['inbox-conversation', selectedId] })
},
}) })
const handleDelete = async (e: React.MouseEvent, convId: string) => { const handleDelete = async (e: React.MouseEvent, convId: string) => {
@@ -42,23 +124,23 @@ export const InboxPage: React.FC = () => {
} }
} }
const { data: detail, isLoading: detailLoading } = useQuery<ConversationDetail>({ const handleSendReply = () => {
queryKey: ['inbox-conversation', selectedId], if (!replyText.trim() || !selectedId) return
queryFn: () => inboxAPI.conversation(selectedId!), sendReply.mutate({ id: selectedId, message: replyText.trim() })
enabled: !!selectedId, }
})
const selectedConv = conversations.find(c => c.id === selectedId)
const isPlanError = (error as { response?: { status?: number } })?.response?.status === 402 const isPlanError = (error as { response?: { status?: number } })?.response?.status === 402
if (isPlanError) { if (isPlanError) {
return ( return (
<div className="p-6 max-w-2xl mx-auto"> <div className="p-6 max-w-2xl mx-auto">
<Card className="p-8 text-center"> <Card className="p-10 text-center">
<div className="w-14 h-14 rounded-2xl bg-primary-50 flex items-center justify-center mx-auto mb-4"> <div className="w-16 h-16 rounded-2xl bg-primary-50 flex items-center justify-center mx-auto mb-5">
<Mail className="w-6 h-6 text-primary-600" /> <Mail className="w-7 h-7 text-primary-600" />
</div> </div>
<h2 className="text-xl font-bold text-gray-900 mb-2">Conversation Inbox</h2> <h2 className="text-xl font-bold text-gray-900 mb-2">Conversation Inbox</h2>
<p className="text-gray-500 text-sm mb-6"> <p className="text-gray-500 text-sm mb-6 max-w-sm mx-auto">
Upgrade to Starter to read all your chatbot conversations in one place. Upgrade to Starter to read all your chatbot conversations in one place.
</p> </p>
</Card> </Card>
@@ -67,136 +149,278 @@ export const InboxPage: React.FC = () => {
} }
return ( return (
<div className="flex h-full"> <div className="flex h-full overflow-hidden">
{/* Left panel - conversation list */}
<div className="w-80 flex-shrink-0 border-r border-gray-200 bg-white flex flex-col"> {/* ── Left panel ── */}
<div className="p-4 border-b border-gray-200"> <div className={cn(
<h1 className="text-lg font-bold text-gray-900 flex items-center gap-2"> 'flex-shrink-0 border-r border-gray-200 bg-white flex flex-col transition-all duration-200',
<Mail className="w-5 h-5 text-primary-600" /> 'w-full lg:w-80',
Inbox selectedId ? 'hidden lg:flex' : 'flex'
</h1> )}>
<p className="text-xs text-gray-500 mt-0.5">All chatbot conversations</p> {/* Header */}
<div className="p-4 border-b border-gray-100">
<div className="flex items-center gap-2.5 mb-3">
<div className="w-8 h-8 rounded-xl bg-primary-50 flex items-center justify-center">
<Inbox className="w-4 h-4 text-primary-600" />
</div>
<div>
<h1 className="text-sm font-bold text-gray-900">Inbox</h1>
<p className="text-xs text-gray-500">{conversations.length} conversation{conversations.length !== 1 ? 's' : ''}</p>
</div>
</div>
{/* Status tabs */}
<div className="flex gap-1 bg-gray-100 rounded-lg p-0.5">
{STATUS_TABS.map(t => (
<button
key={t.key}
onClick={() => setStatusFilter(t.key)}
className={cn(
'flex-1 text-xs font-medium py-1.5 rounded-md transition-all',
statusFilter === t.key
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-500 hover:text-gray-700'
)}
>
{t.label}
</button>
))}
</div>
</div> </div>
{/* Conversation list */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center p-8"> <div className="p-4"><SkeletonList rows={6} /></div>
<Spinner className="text-primary-600" />
</div>
) : conversations.length === 0 ? ( ) : conversations.length === 0 ? (
<div className="p-6 text-center"> <div className="flex flex-col items-center justify-center h-full py-16 px-6 text-center">
<MessageSquare className="w-8 h-8 text-gray-300 mx-auto mb-2" /> <div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-4">
<p className="text-sm text-gray-500">No conversations yet</p> <MessageSquare className="w-6 h-6 text-gray-300" />
</div>
<p className="text-sm font-medium text-gray-600 mb-1">No conversations</p>
<p className="text-xs text-gray-400">Try a different filter</p>
</div> </div>
) : ( ) : (
conversations.map((conv) => ( conversations.map((conv) => {
const sc = STATUS_CONFIG[conv.status] || STATUS_CONFIG.open
return (
<div <div
key={conv.id} key={conv.id}
className={cn( className={cn(
'w-full text-left border-b border-gray-100 hover:bg-gray-50 transition-colors group relative', 'relative border-b border-gray-100 group transition-all duration-150',
selectedId === conv.id && 'bg-primary-50 border-l-2 border-l-primary-500' selectedId === conv.id
? 'bg-primary-50 border-l-[3px] border-l-primary-500'
: 'hover:bg-gray-50 border-l-[3px] border-l-transparent'
)} )}
> >
<button <button onClick={() => setSelectedId(conv.id)} className="w-full text-left p-4 pr-10">
onClick={() => setSelectedId(conv.id)} <div className="flex items-start gap-3">
className="w-full text-left p-4 pr-10" <AvatarInitial name={conv.chatbot_name} size="sm" />
> <div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-1"> <div className="flex items-center justify-between gap-1 mb-0.5">
<div className="flex items-center gap-1.5"> <span className={cn('text-xs font-semibold truncate', selectedId === conv.id ? 'text-primary-700' : 'text-gray-700')}>
<Bot className="w-3.5 h-3.5 text-primary-500 flex-shrink-0" />
<span className="text-xs font-medium text-primary-700 truncate max-w-[120px]">
{conv.chatbot_name} {conv.chatbot_name}
</span> </span>
<span className="text-[10px] text-gray-400 flex-shrink-0">{timeAgo(conv.created_at)}</span>
</div> </div>
<span className="text-[10px] text-gray-400 flex-shrink-0"> <p className="text-sm text-gray-600 truncate leading-snug">
{conv.created_at ? new Date(conv.created_at).toLocaleDateString() : ''}
</span>
</div>
<p className="text-sm text-gray-700 truncate">
{conv.first_message || '(No messages)'} {conv.first_message || '(No messages)'}
</p> </p>
<p className="text-xs text-gray-400 mt-0.5"> <div className="flex items-center gap-1.5 mt-1.5">
{conv.message_count} messages · {conv.language.toUpperCase()} <span className={cn('text-[10px] px-1.5 py-0.5 rounded-full font-medium', sc.color)}>
</p> {sc.label}
</span>
<span className="inline-flex items-center gap-0.5 text-[10px] bg-gray-100 text-gray-500 px-1.5 py-0.5 rounded-full">
<MessageSquare className="w-2.5 h-2.5" />
{conv.message_count}
</span>
</div>
</div>
</div>
</button> </button>
<button <button
onClick={(e) => handleDelete(e, conv.id)} onClick={(e) => handleDelete(e, conv.id)}
disabled={deletingId === conv.id} disabled={deletingId === conv.id}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded-lg text-gray-300 hover:text-red-500 hover:bg-red-50 opacity-0 group-hover:opacity-100 transition-all" className="absolute right-2.5 top-1/2 -translate-y-1/2 p-1.5 rounded-lg text-gray-300 hover:text-red-500 hover:bg-red-50 opacity-0 group-hover:opacity-100 transition-all"
title="Delete conversation" title="Delete"
> >
<Trash2 className="w-3.5 h-3.5" /> {deletingId === conv.id ? <Spinner className="w-3.5 h-3.5 text-red-400" /> : <Trash2 className="w-3.5 h-3.5" />}
</button> </button>
</div> </div>
)) )
})
)} )}
</div> </div>
</div> </div>
{/* Right panel - conversation detail */} {/* ── Right panel ── */}
<div className="flex-1 flex flex-col bg-gray-50 overflow-hidden"> <div className={cn(
'flex-1 flex flex-col bg-gray-50/50 overflow-hidden',
selectedId ? 'flex' : 'hidden lg:flex'
)}>
{!selectedId ? ( {!selectedId ? (
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center">
<div className="text-center"> <div className="text-center px-6">
<ArrowRight className="w-8 h-8 text-gray-300 mx-auto mb-3" /> <div className="w-16 h-16 rounded-2xl bg-white border border-gray-200 shadow-sm flex items-center justify-center mx-auto mb-4">
<p className="text-sm text-gray-500">Select a conversation to view</p> <Mail className="w-7 h-7 text-gray-300" />
</div>
<p className="text-sm font-medium text-gray-500 mb-1">Select a conversation</p>
<p className="text-xs text-gray-400">Choose one from the list to view the full exchange</p>
</div> </div>
</div> </div>
) : detailLoading ? ( ) : detailLoading ? (
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center"><Spinner className="text-primary-600" /></div>
<Spinner className="text-primary-600" />
</div>
) : detail ? ( ) : detail ? (
<> <>
<div className="p-4 bg-white border-b border-gray-200"> {/* Detail header */}
<div className="flex items-center gap-2"> <div className="p-3 bg-white border-b border-gray-200 flex items-center gap-3 shadow-sm">
<Bot className="w-4 h-4 text-primary-600" /> <button
<h2 className="font-semibold text-gray-900 text-sm">{detail.chatbot_name}</h2> onClick={() => setSelectedId(null)}
<span className="text-xs text-gray-400">·</span> className="lg:hidden p-1.5 rounded-lg hover:bg-gray-100 text-gray-500"
<span className="text-xs text-gray-500 uppercase">{detail.language}</span> >
<ArrowLeft className="w-5 h-5" />
</button>
<AvatarInitial name={detail.chatbot_name} size="sm" />
<div className="flex-1 min-w-0">
<h2 className="font-semibold text-gray-900 text-sm truncate">{detail.chatbot_name}</h2>
{detail.created_at && ( {detail.created_at && (
<> <p className="text-xs text-gray-400">{new Date(detail.created_at).toLocaleString()}</p>
<span className="text-xs text-gray-400">·</span> )}
<span className="text-xs text-gray-500"> </div>
{new Date(detail.created_at).toLocaleString()}
</span> {/* Status action buttons */}
</> <div className="flex items-center gap-1.5 flex-shrink-0">
{selectedConv?.status !== 'agent_handling' && (
<button
onClick={() => updateStatus.mutate({ id: selectedId!, status: 'agent_handling' })}
disabled={updateStatus.isPending}
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-orange-700 bg-orange-50 hover:bg-orange-100 border border-orange-200 rounded-lg transition-colors disabled:opacity-50"
title="Take over this conversation"
>
<UserCheck className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Take Over</span>
</button>
)}
{selectedConv?.status !== 'resolved' && (
<button
onClick={() => updateStatus.mutate({ id: selectedId!, status: 'resolved' })}
disabled={updateStatus.isPending}
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-green-700 bg-green-50 hover:bg-green-100 border border-green-200 rounded-lg transition-colors disabled:opacity-50"
title="Mark as resolved"
>
<CheckCircle2 className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Resolve</span>
</button>
)}
{selectedConv?.status !== 'open' && (
<button
onClick={() => updateStatus.mutate({ id: selectedId!, status: 'open' })}
disabled={updateStatus.isPending}
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 border border-blue-200 rounded-lg transition-colors disabled:opacity-50"
title="Reopen"
>
<RotateCcw className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Reopen</span>
</button>
)} )}
</div> </div>
</div> </div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-4">
{detail.messages.map((msg) => ( {detail.messages.map((msg) => (
<div <div
key={msg.id} key={msg.id}
className={cn('flex gap-2', msg.role === 'user' ? 'justify-end' : '')} className={cn(
'flex gap-2.5 items-end',
msg.role === 'user' ? 'justify-end' : 'justify-start'
)}
> >
{msg.role === 'assistant' && ( {(msg.role === 'assistant' || msg.role === 'agent') && (
<div className="w-6 h-6 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0 mt-0.5"> <div className={cn(
<Bot className="w-3 h-3 text-primary-600" /> 'w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0 mb-0.5',
msg.role === 'agent' ? 'bg-orange-100' : 'bg-primary-100'
)}>
{msg.role === 'agent'
? <User className="w-3.5 h-3.5 text-orange-600" />
: <Bot className="w-3.5 h-3.5 text-primary-600" />
}
</div> </div>
)} )}
<div className={cn( <div className={cn(
'max-w-[75%] rounded-2xl px-3 py-2 text-sm', 'max-w-[75%] sm:max-w-[65%] px-4 py-2.5 text-sm leading-relaxed shadow-sm',
msg.role === 'user' msg.role === 'user'
? 'bg-primary-600 text-white rounded-br-sm' ? 'bg-primary-600 text-white rounded-2xl rounded-br-sm'
: 'bg-white border border-gray-200 text-gray-800 rounded-bl-sm' : msg.role === 'agent'
? 'bg-orange-50 border border-orange-200 text-gray-800 rounded-2xl rounded-bl-sm'
: 'bg-white border border-gray-200 text-gray-800 rounded-2xl rounded-bl-sm'
)}> )}>
{msg.role === 'agent' && (
<p className="text-[10px] font-semibold text-orange-600 mb-1 uppercase tracking-wide">You (agent)</p>
)}
<p className="whitespace-pre-wrap">{msg.content}</p> <p className="whitespace-pre-wrap">{msg.content}</p>
<div className="flex items-center gap-2 mt-1"> {(msg.is_handoff || (msg.confidence_score !== undefined && msg.confidence_score !== null && msg.confidence_score < 0.2)) && (
<div className={cn(
'flex items-center gap-2 mt-2 pt-2 flex-wrap',
msg.role === 'user' ? 'border-t border-primary-500/30' : 'border-t border-gray-100'
)}>
{msg.is_handoff && ( {msg.is_handoff && (
<span className="text-[10px] bg-orange-100 text-orange-700 px-1.5 py-0.5 rounded"> <span className="text-[10px] bg-orange-100 text-orange-700 px-2 py-0.5 rounded-full font-medium">
Handoff Handoff requested
</span> </span>
)} )}
{msg.confidence_score !== undefined && msg.confidence_score !== null && msg.confidence_score < 0.2 && ( {msg.confidence_score !== undefined && msg.confidence_score !== null && msg.confidence_score < 0.2 && (
<span className="text-[10px] flex items-center gap-0.5 text-amber-600"> <span className="text-[10px] flex items-center gap-0.5 text-amber-600 bg-amber-50 px-2 py-0.5 rounded-full">
<AlertTriangle className="w-2.5 h-2.5" /> Low confidence <AlertTriangle className="w-2.5 h-2.5" /> Low confidence
</span> </span>
)} )}
</div> </div>
)}
</div> </div>
{msg.role === 'user' && (
<div className="w-7 h-7 rounded-full bg-gray-200 flex items-center justify-center flex-shrink-0 mb-0.5 text-[10px] font-bold text-gray-500">
U
</div>
)}
</div> </div>
))} ))}
<div ref={messagesEndRef} />
</div>
{/* Agent reply input */}
<div className="p-3 bg-white border-t border-gray-200">
{selectedConv?.status === 'resolved' ? (
<p className="text-xs text-center text-gray-400 py-1">
Conversation resolved {' '}
<button
onClick={() => updateStatus.mutate({ id: selectedId!, status: 'open' })}
className="text-primary-600 hover:underline font-medium"
>
reopen
</button>
{' '}to reply
</p>
) : (
<div className="flex gap-2">
<input
type="text"
value={replyText}
onChange={e => setReplyText(e.target.value)}
onKeyDown={e => e.key === 'Enter' && !e.shiftKey && handleSendReply()}
placeholder="Type a reply as agent..."
className="flex-1 border border-gray-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-400 transition-colors"
/>
<button
onClick={handleSendReply}
disabled={!replyText.trim() || sendReply.isPending}
className="p-2.5 bg-primary-600 text-white rounded-xl hover:bg-primary-700 disabled:opacity-40 transition-colors flex-shrink-0"
title="Send reply"
>
{sendReply.isPending ? <Spinner className="w-4 h-4 text-white" /> : <Send className="w-4 h-4" />}
</button>
</div>
)}
</div> </div>
</> </>
) : null} ) : null}

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,69 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { leadsAPI, chatbotsAPI } from '@/services/api' import { leadsAPI, chatbotsAPI } from '@/services/api'
import { Card, Spinner, Button } from '@/components/ui' import { Card, Button } from '@/components/ui'
import { Users, Download, Mail, Lock } from 'lucide-react' import { Users, Download, Mail, Lock, TrendingUp, Filter, UserCheck, StickyNote, X, Check } from 'lucide-react'
import type { Lead, Chatbot } from '@/types' import type { Lead, LeadStatus, Chatbot } from '@/types'
import { SkeletonTable } from '@/components/Skeletons'
import { cn } from '@/lib/utils'
const STATUS_OPTIONS: { value: LeadStatus; label: string; color: string }[] = [
{ value: 'new', label: 'New', color: 'bg-blue-100 text-blue-700' },
{ value: 'contacted', label: 'Contacted', color: 'bg-yellow-100 text-yellow-700' },
{ value: 'qualified', label: 'Qualified', color: 'bg-purple-100 text-purple-700' },
{ value: 'closed', label: 'Closed', color: 'bg-green-100 text-green-700' },
{ value: 'lost', label: 'Lost', color: 'bg-gray-100 text-gray-500' },
]
const statusConfig = (status: LeadStatus) =>
STATUS_OPTIONS.find(s => s.value === status) || STATUS_OPTIONS[0]
interface NotesModalProps {
lead: Lead
onClose: () => void
onSave: (notes: string) => void
saving: boolean
}
const NotesModal: React.FC<NotesModalProps> = ({ lead, onClose, onSave, saving }) => {
const [text, setText] = useState(lead.notes || '')
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md">
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
<h3 className="font-semibold text-gray-900 text-sm">
Notes {lead.name || lead.email || 'Lead'}
</h3>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400">
<X className="w-4 h-4" />
</button>
</div>
<div className="p-5">
<textarea
value={text}
onChange={e => setText(e.target.value)}
placeholder="Add notes about this lead..."
rows={5}
className="w-full border border-gray-200 rounded-xl px-3 py-2.5 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-400 transition-colors"
/>
</div>
<div className="flex gap-2 px-5 pb-4">
<Button variant="secondary" size="sm" onClick={onClose} className="flex-1">Cancel</Button>
<Button size="sm" onClick={() => onSave(text)} disabled={saving} className="flex-1 gap-1.5">
<Check className="w-3.5 h-3.5" />
Save
</Button>
</div>
</div>
</div>
)
}
export const LeadsPage: React.FC = () => { export const LeadsPage: React.FC = () => {
const [chatbotFilter, setChatbotFilter] = useState('') const [chatbotFilter, setChatbotFilter] = useState('')
const [statusFilter, setStatusFilter] = useState<LeadStatus | ''>('')
const [notesLead, setNotesLead] = useState<Lead | null>(null)
const queryClient = useQueryClient()
const { data: chatbots = [] } = useQuery<Chatbot[]>({ const { data: chatbots = [] } = useQuery<Chatbot[]>({
queryKey: ['chatbots'], queryKey: ['chatbots'],
@@ -14,11 +71,17 @@ export const LeadsPage: React.FC = () => {
}) })
const { data: leads = [], isLoading, error } = useQuery<Lead[]>({ const { data: leads = [], isLoading, error } = useQuery<Lead[]>({
queryKey: ['leads', chatbotFilter], queryKey: ['leads', chatbotFilter, statusFilter],
queryFn: () => leadsAPI.list(chatbotFilter ? { chatbot_id: chatbotFilter } : undefined), queryFn: () => leadsAPI.list(chatbotFilter ? { chatbot_id: chatbotFilter } : undefined),
retry: false, retry: false,
}) })
const updateLead = useMutation({
mutationFn: ({ id, data }: { id: string; data: { status?: string; notes?: string } }) =>
leadsAPI.update(id, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['leads'] }),
})
const isPlanError = (error as { response?: { status?: number } })?.response?.status === 402 const isPlanError = (error as { response?: { status?: number } })?.response?.status === 402
const handleExport = async () => { const handleExport = async () => {
@@ -32,20 +95,18 @@ export const LeadsPage: React.FC = () => {
a.click() a.click()
document.body.removeChild(a) document.body.removeChild(a)
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} catch { } catch { alert('Export failed') }
alert('Export failed')
}
} }
if (isPlanError) { if (isPlanError) {
return ( return (
<div className="p-6 max-w-2xl mx-auto"> <div className="p-6 max-w-2xl mx-auto">
<Card className="p-8 text-center"> <Card className="p-10 text-center">
<div className="w-14 h-14 rounded-2xl bg-primary-50 flex items-center justify-center mx-auto mb-4"> <div className="w-16 h-16 rounded-2xl bg-primary-50 flex items-center justify-center mx-auto mb-5">
<Lock className="w-6 h-6 text-primary-600" /> <Lock className="w-7 h-7 text-primary-600" />
</div> </div>
<h2 className="text-xl font-bold text-gray-900 mb-2">Lead Capture</h2> <h2 className="text-xl font-bold text-gray-900 mb-2">Lead Capture</h2>
<p className="text-gray-500 text-sm mb-6"> <p className="text-gray-500 text-sm mb-6 max-w-sm mx-auto">
Upgrade to Starter to capture and manage leads from your chatbots. Upgrade to Starter to capture and manage leads from your chatbots.
</p> </p>
</Card> </Card>
@@ -53,82 +114,258 @@ export const LeadsPage: React.FC = () => {
) )
} }
const now = new Date()
const thisMonthLeads = leads.filter((l) => {
if (!l.created_at) return false
const d = new Date(l.created_at)
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth()
})
// Apply client-side status filter
const filtered = statusFilter ? leads.filter(l => l.status === statusFilter) : leads
// CRM stats
const byStatus = (s: LeadStatus) => leads.filter(l => l.status === s).length
const LeadAvatar: React.FC<{ name?: string | null; email?: string | null }> = ({ name, email }) => {
const display = name || email || '?'
return ( return (
<div className="p-6 max-w-5xl mx-auto space-y-6"> <div className="w-8 h-8 rounded-full bg-primary-100 text-primary-700 flex items-center justify-center text-xs font-bold flex-shrink-0">
{display[0].toUpperCase()}
</div>
)
}
return (
<div className="p-4 sm:p-6 max-w-5xl mx-auto space-y-6">
{notesLead && (
<NotesModal
lead={notesLead}
onClose={() => setNotesLead(null)}
saving={updateLead.isPending}
onSave={(notes) => {
updateLead.mutate(
{ id: notesLead.id, data: { notes } },
{ onSuccess: () => setNotesLead(null) }
)
}}
/>
)}
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center flex-shrink-0">
<Users className="w-5 h-5 text-primary-600" />
</div>
<div> <div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2"> <h1 className="text-2xl font-bold text-gray-900">Leads</h1>
<Users className="w-6 h-6 text-primary-600" />
Leads
</h1>
<p className="text-sm text-gray-500 mt-0.5">Contacts collected by your chatbots</p> <p className="text-sm text-gray-500 mt-0.5">Contacts collected by your chatbots</p>
</div> </div>
<Button onClick={handleExport} variant="secondary" size="sm"> </div>
<Button onClick={handleExport} variant="secondary" size="sm" className="self-start sm:self-auto gap-2">
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
Export CSV Export CSV
</Button> </Button>
</div> </div>
{/* CRM pipeline stats */}
{!isLoading && leads.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3">
{STATUS_OPTIONS.map(s => (
<button
key={s.value}
onClick={() => setStatusFilter(prev => prev === s.value ? '' : s.value)}
className={cn(
'p-3 rounded-xl border text-left transition-all',
statusFilter === s.value
? 'border-primary-300 bg-primary-50 shadow-sm'
: 'border-gray-200 bg-white hover:border-gray-300'
)}
>
<p className={cn('text-xs font-semibold px-2 py-0.5 rounded-full inline-block mb-2', s.color)}>
{s.label}
</p>
<p className="text-xl font-bold text-gray-900">{byStatus(s.value)}</p>
</button>
))}
</div>
)}
{/* Stats summary */}
{!isLoading && leads.length > 0 && (
<div className="grid grid-cols-2 gap-4">
<Card className="p-5 flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center flex-shrink-0">
<UserCheck className="w-5 h-5 text-primary-600" />
</div>
<div>
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">Total leads</p>
<p className="text-2xl font-bold text-gray-900">{leads.length.toLocaleString()}</p>
</div>
</Card>
<Card className="p-5 flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-emerald-50 flex items-center justify-center flex-shrink-0">
<TrendingUp className="w-5 h-5 text-emerald-600" />
</div>
<div>
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">This month</p>
<p className="text-2xl font-bold text-gray-900">{thisMonthLeads.length.toLocaleString()}</p>
</div>
</Card>
</div>
)}
{/* Filter */} {/* Filter */}
<Card className="p-4"> <Card className="p-4">
<div className="flex items-center gap-3"> <div className="flex flex-col sm:flex-row items-start sm:items-center gap-3">
<label className="text-sm font-medium text-gray-700 flex-shrink-0">Filter by chatbot:</label> <div className="flex items-center gap-2 text-sm font-medium text-gray-600 flex-shrink-0">
<Filter className="w-4 h-4 text-gray-400" />
Filter by chatbot
</div>
<select <select
value={chatbotFilter} value={chatbotFilter}
onChange={e => setChatbotFilter(e.target.value)} onChange={e => setChatbotFilter(e.target.value)}
className="flex-1 max-w-xs border border-gray-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500/20" className="w-full sm:max-w-xs border border-gray-200 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary-500/30 focus:border-primary-400 transition-all appearance-none cursor-pointer"
> >
<option value="">All chatbots</option> <option value="">All chatbots</option>
{chatbots.map((c) => ( {chatbots.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select> </select>
{statusFilter && (
<button
onClick={() => setStatusFilter('')}
className="text-xs text-primary-600 hover:underline flex items-center gap-1"
>
<X className="w-3 h-3" /> Clear status filter
</button>
)}
</div> </div>
</Card> </Card>
{/* Table */} {/* Table */}
{isLoading ? ( {isLoading ? (
<div className="flex justify-center py-12"> <Card className="p-6"><SkeletonTable rows={6} /></Card>
<Spinner className="text-primary-600" /> ) : filtered.length === 0 ? (
<Card className="p-14 text-center">
<div className="w-16 h-16 rounded-2xl bg-gray-50 flex items-center justify-center mx-auto mb-5">
<Mail className="w-7 h-7 text-gray-300" />
</div> </div>
) : leads.length === 0 ? ( <h3 className="font-semibold text-gray-700 mb-2">No leads {statusFilter ? `with status "${statusFilter}"` : 'yet'}</h3>
<Card className="p-12 text-center"> <p className="text-sm text-gray-500 max-w-sm mx-auto leading-relaxed">
<Mail className="w-10 h-10 text-gray-300 mx-auto mb-3" /> {statusFilter
<h3 className="font-semibold text-gray-700 mb-1">No leads yet</h3> ? 'Try a different filter or clear the current one.'
<p className="text-sm text-gray-500 max-w-sm mx-auto"> : 'Enable lead capture on your chatbots to start collecting contact information.'}
Enable lead capture on your chatbots to start collecting contact information from visitors.
</p> </p>
</Card> </Card>
) : ( ) : (
<Card className="overflow-hidden"> <>
{/* Desktop table */}
<Card className="overflow-hidden hidden sm:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="bg-gray-50 border-b border-gray-200"> <tr className="bg-gray-50/80 border-b border-gray-200">
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">Email</th> <th className="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Contact</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">Name</th> <th className="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Phone</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">Phone</th> <th className="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Company</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">Company</th> <th className="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">Date</th> <th className="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Notes</th>
<th className="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Date</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-100"> <tbody className="divide-y divide-gray-100">
{leads.map((lead) => ( {filtered.map((lead, idx) => {
<tr key={lead.id} className="hover:bg-gray-50 transition-colors"> const sc = statusConfig(lead.status || 'new')
<td className="px-4 py-3 text-gray-900">{lead.email || '—'}</td> return (
<td className="px-4 py-3 text-gray-700">{lead.name || ''}</td> <tr key={lead.id} className={`hover:bg-primary-50/30 transition-colors ${idx % 2 === 0 ? 'bg-white' : 'bg-gray-50/40'}`}>
<td className="px-4 py-3 text-gray-700">{lead.phone || '—'}</td> <td className="px-5 py-3.5">
<td className="px-4 py-3 text-gray-700">{lead.company || '—'}</td> <div className="flex items-center gap-3">
<td className="px-4 py-3 text-gray-500"> <LeadAvatar name={lead.name} email={lead.email} />
{lead.created_at ? new Date(lead.created_at).toLocaleDateString() : '—'} <div>
{lead.name && <p className="font-medium text-gray-900 text-sm">{lead.name}</p>}
{lead.email
? <p className={`text-sm ${lead.name ? 'text-gray-500' : 'font-medium text-gray-900'}`}>{lead.email}</p>
: <p className="text-sm text-gray-400"></p>
}
</div>
</div>
</td>
<td className="px-5 py-3.5 text-gray-600">{lead.phone || <span className="text-gray-300"></span>}</td>
<td className="px-5 py-3.5 text-gray-600">{lead.company || <span className="text-gray-300"></span>}</td>
<td className="px-5 py-3.5">
<select
value={lead.status || 'new'}
onChange={e => updateLead.mutate({ id: lead.id, data: { status: e.target.value } })}
className={cn(
'text-xs font-medium px-2 py-1 rounded-full border-0 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-500/30',
sc.color
)}
>
{STATUS_OPTIONS.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
</td>
<td className="px-5 py-3.5">
<button
onClick={() => setNotesLead(lead)}
className="flex items-center gap-1.5 text-xs text-gray-500 hover:text-primary-600 transition-colors group"
title={lead.notes || 'Add notes'}
>
<StickyNote className="w-3.5 h-3.5 group-hover:text-primary-500" />
<span className="max-w-[100px] truncate">
{lead.notes || <span className="text-gray-300">Add note</span>}
</span>
</button>
</td>
<td className="px-5 py-3.5 text-gray-500 text-xs whitespace-nowrap">
{lead.created_at
? new Date(lead.created_at).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })
: <span className="text-gray-300"></span>}
</td> </td>
</tr> </tr>
))} )
})}
</tbody> </tbody>
</table> </table>
</div> </div>
</Card> </Card>
{/* Mobile cards */}
<div className="sm:hidden space-y-3">
{filtered.map((lead) => {
const sc = statusConfig(lead.status || 'new')
return (
<Card key={lead.id} className="p-4">
<div className="flex items-start gap-3">
<LeadAvatar name={lead.name} email={lead.email} />
<div className="flex-1 min-w-0 space-y-1">
{lead.name && <p className="font-semibold text-gray-900 text-sm truncate">{lead.name}</p>}
{lead.email && <p className="text-sm text-gray-600 truncate">{lead.email}</p>}
<div className="flex flex-wrap gap-2 pt-1">
<select
value={lead.status || 'new'}
onChange={e => updateLead.mutate({ id: lead.id, data: { status: e.target.value } })}
className={cn('text-xs font-medium px-2 py-0.5 rounded-full border-0 cursor-pointer', sc.color)}
>
{STATUS_OPTIONS.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
{lead.phone && <span className="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded-full">{lead.phone}</span>}
{lead.company && <span className="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded-full">{lead.company}</span>}
</div>
<button
onClick={() => setNotesLead(lead)}
className="text-xs text-gray-400 hover:text-primary-600 flex items-center gap-1 mt-1 transition-colors"
>
<StickyNote className="w-3 h-3" />
{lead.notes || 'Add note'}
</button>
</div>
</div>
</Card>
)
})}
</div>
</>
)} )}
</div> </div>
) )

View File

@@ -2,10 +2,11 @@ import React, { useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { marketplaceAPI } from '@/services/api' import { marketplaceAPI } from '@/services/api'
import { Card, Spinner, EmptyState, Button } from '@/components/ui' import { Spinner, EmptyState, Button } from '@/components/ui'
import { SkeletonCard } from '@/components/Skeletons'
import { ChatInterface } from '@/components/ChatInterface' import { ChatInterface } from '@/components/ChatInterface'
import { CATEGORIES, INDUSTRIES } from '@/lib/utils' import { CATEGORIES, INDUSTRIES } from '@/lib/utils'
import { Search, Bot, Star, MessageSquare, ArrowLeft } from 'lucide-react' import { Search, Bot, Star, MessageSquare, ArrowLeft, SlidersHorizontal, ChevronLeft, ChevronRight } from 'lucide-react'
import type { ChatbotPublic } from '@/types' import type { ChatbotPublic } from '@/types'
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
@@ -19,6 +20,7 @@ export const MarketplacePage: React.FC = () => {
const [category, setCategory] = useState('') const [category, setCategory] = useState('')
const [industry, setIndustry] = useState('') const [industry, setIndustry] = useState('')
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [showFilters, setShowFilters] = useState(false)
// Debounce search // Debounce search
const searchTimeout = React.useRef<ReturnType<typeof setTimeout>>() const searchTimeout = React.useRef<ReturnType<typeof setTimeout>>()
@@ -36,62 +38,172 @@ export const MarketplacePage: React.FC = () => {
queryFn: () => marketplaceAPI.list({ search: debouncedSearch, category, industry, page, limit: 20 }), queryFn: () => marketplaceAPI.list({ search: debouncedSearch, category, industry, page, limit: 20 }),
}) })
const totalPages = data ? Math.ceil(data.total / 20) : 0
const hasActiveFilters = category !== '' || industry !== ''
return ( return (
<div className="p-6 max-w-6xl mx-auto"> <div className="min-h-full bg-gray-50/50">
{/* Header */} {/* Page Header */}
<div className="mb-8 animate-fade-in"> <div className="bg-white border-b border-gray-200">
<h1 className="text-2xl font-bold text-gray-900">AI Chatbot Marketplace</h1> <div className="max-w-6xl mx-auto px-4 sm:px-6 py-8 sm:py-10">
<p className="text-sm text-gray-500 mt-1">Discover and interact with AI chatbots built by businesses</p> <div className="animate-fade-in">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center shadow-sm">
<Bot className="w-5 h-5 text-white" />
</div>
<h1 className="text-2xl sm:text-3xl font-bold bg-gradient-to-r from-gray-900 via-primary-800 to-primary-600 bg-clip-text text-transparent">
AI Chatbot Marketplace
</h1>
</div>
<p className="text-gray-500 text-sm sm:text-base max-w-xl">
Discover and interact with AI-powered chatbots built by businesses ready to answer your questions instantly.
</p>
</div>
</div>
</div> </div>
{/* Filters */} <div className="max-w-6xl mx-auto px-4 sm:px-6 py-6">
<div className="flex flex-col sm:flex-row gap-3 mb-6 animate-fade-in-down"> {/* Search & Filter Bar */}
<div className="mb-6 animate-fade-in-down space-y-3">
<div className="flex gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" /> <Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
<input <input
type="text" type="text"
value={search} value={search}
onChange={e => handleSearch(e.target.value)} onChange={e => handleSearch(e.target.value)}
placeholder="Search chatbots..." placeholder="Search chatbots by name or description..."
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-400 bg-white shadow-sm transition-all placeholder-gray-400" className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-400 bg-white shadow-sm transition-all placeholder-gray-400"
/> />
</div> {search && (
<select <button
value={category} onClick={() => handleSearch('')}
onChange={e => { setCategory(e.target.value); setPage(1) }} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
className="px-3 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white shadow-sm transition-all text-gray-700"
> >
<option value="">All Categories</option> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)} <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</select> </svg>
</button>
)}
</div>
<button
onClick={() => setShowFilters(!showFilters)}
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl border text-sm font-medium transition-all shadow-sm ${
showFilters || hasActiveFilters
? 'bg-primary-50 border-primary-300 text-primary-700'
: 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'
}`}
>
<SlidersHorizontal className="w-4 h-4" />
<span className="hidden sm:inline">Filters</span>
{hasActiveFilters && (
<span className="w-5 h-5 rounded-full bg-primary-600 text-white text-xs flex items-center justify-center">
{(category ? 1 : 0) + (industry ? 1 : 0)}
</span>
)}
</button>
</div>
{/* Expandable filter section */}
{showFilters && (
<div className="bg-white border border-gray-200 rounded-xl p-4 shadow-sm space-y-4 animate-fade-in-down">
{/* Category filter — pill buttons since list is manageable */}
<div>
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2.5">Category</p>
<div className="flex flex-wrap gap-2">
<button
onClick={() => { setCategory(''); setPage(1) }}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-all border ${
category === ''
? 'bg-primary-600 text-white border-primary-600 shadow-sm'
: 'bg-white text-gray-600 border-gray-200 hover:border-primary-300 hover:text-primary-600'
}`}
>
All
</button>
{CATEGORIES.map(c => (
<button
key={c}
onClick={() => { setCategory(category === c ? '' : c); setPage(1) }}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-all border ${
category === c
? 'bg-primary-600 text-white border-primary-600 shadow-sm'
: 'bg-white text-gray-600 border-gray-200 hover:border-primary-300 hover:text-primary-600'
}`}
>
{c}
</button>
))}
</div>
</div>
{/* Industry filter — select dropdown since list is long */}
<div>
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2.5">Industry</p>
<select <select
value={industry} value={industry}
onChange={e => { setIndustry(e.target.value); setPage(1) }} onChange={e => { setIndustry(e.target.value); setPage(1) }}
className="px-3 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white shadow-sm transition-all text-gray-700" className="w-full sm:w-72 px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white transition-all text-gray-700"
> >
<option value="">All Industries</option> <option value="">All Industries</option>
{INDUSTRIES.map(i => <option key={i} value={i}>{i}</option>)} {INDUSTRIES.map(i => <option key={i} value={i}>{i}</option>)}
</select> </select>
</div> </div>
{hasActiveFilters && (
<button
onClick={() => { setCategory(''); setIndustry(''); setPage(1) }}
className="text-xs text-red-500 hover:text-red-700 transition-colors font-medium"
>
Clear all filters
</button>
)}
</div>
)}
</div>
{/* Results */} {/* Results */}
{isLoading ? ( {isLoading ? (
<div className="flex flex-col items-center justify-center py-24 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
<Spinner className="text-primary-600 w-7 h-7" /> {Array.from({ length: 6 }).map((_, i) => (
<p className="text-sm text-gray-400">Loading chatbots</p> <SkeletonCard key={i} />
))}
</div> </div>
) : !data?.chatbots?.length ? ( ) : !data?.chatbots?.length ? (
<EmptyState <EmptyState
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={
action={<Button onClick={() => navigate('/chatbots/new')}>Create Chatbot</Button>} hasActiveFilters || debouncedSearch
? "Try adjusting your filters or search query."
: "Be the first to publish your AI chatbot to the marketplace!"
}
action={
hasActiveFilters || debouncedSearch ? (
<Button variant="outline" onClick={() => { setCategory(''); setIndustry(''); handleSearch('') }}>
Clear filters
</Button>
) : (
<Button onClick={() => navigate('/chatbots/new')}>Create Chatbot</Button>
)
}
/> />
) : ( ) : (
<> <>
<div className="mb-4 text-xs text-gray-400 font-medium uppercase tracking-wide"> <div className="mb-4 flex items-center justify-between">
<p className="text-xs text-gray-400 font-medium uppercase tracking-wide">
{data.total} chatbot{data.total !== 1 ? 's' : ''} available {data.total} chatbot{data.total !== 1 ? 's' : ''} available
</p>
{hasActiveFilters && (
<button
onClick={() => { setCategory(''); setIndustry(''); setPage(1) }}
className="text-xs text-primary-600 hover:text-primary-800 transition-colors"
>
Clear filters
</button>
)}
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-8"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
{data.chatbots.map((chatbot, i) => ( {data.chatbots.map((chatbot, i) => (
<ChatbotMarketplaceCard <ChatbotMarketplaceCard
@@ -103,22 +215,57 @@ export const MarketplacePage: React.FC = () => {
))} ))}
</div> </div>
{/* Pagination */}
{data.total > 20 && ( {data.total > 20 && (
<div className="flex justify-center items-center gap-3"> <div className="flex justify-center items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}> <button
Previous onClick={() => setPage(p => Math.max(1, p - 1))}
</Button> disabled={page === 1}
<span className="text-sm text-gray-500 bg-white border border-gray-200 px-4 py-1.5 rounded-lg shadow-sm"> className="w-9 h-9 flex items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-600 hover:bg-gray-50 hover:border-gray-300 disabled:opacity-40 disabled:cursor-not-allowed transition-all shadow-sm"
Page {page} of {Math.ceil(data.total / 20)} >
<ChevronLeft className="w-4 h-4" />
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter(p => p === 1 || p === totalPages || Math.abs(p - page) <= 1)
.reduce<(number | 'ellipsis')[]>((acc, p, idx, arr) => {
if (idx > 0 && p - (arr[idx - 1] as number) > 1) acc.push('ellipsis')
acc.push(p)
return acc
}, [])
.map((p, idx) =>
p === 'ellipsis' ? (
<span key={`ellipsis-${idx}`} className="w-9 h-9 flex items-center justify-center text-gray-400 text-sm">
</span> </span>
<Button variant="outline" size="sm" onClick={() => setPage(p => p + 1)} disabled={!data.has_more}> ) : (
Next <button
</Button> key={p}
onClick={() => setPage(p as number)}
className={`w-9 h-9 flex items-center justify-center rounded-lg text-sm font-medium transition-all ${
page === p
? 'bg-primary-600 text-white shadow-sm shadow-primary-200'
: 'border border-gray-200 bg-white text-gray-600 hover:bg-gray-50 hover:border-gray-300'
}`}
>
{p}
</button>
)
)}
<button
onClick={() => setPage(p => p + 1)}
disabled={!data.has_more}
className="w-9 h-9 flex items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-600 hover:bg-gray-50 hover:border-gray-300 disabled:opacity-40 disabled:cursor-not-allowed transition-all shadow-sm"
>
<ChevronRight className="w-4 h-4" />
</button>
</div> </div>
)} )}
</> </>
)} )}
</div> </div>
</div>
) )
} }
@@ -129,59 +276,79 @@ export const MarketplacePage: React.FC = () => {
const ChatbotMarketplaceCard: React.FC<{ chatbot: ChatbotPublic; onClick: () => void; index: number }> = ({ chatbot, onClick, index }) => ( const ChatbotMarketplaceCard: React.FC<{ chatbot: ChatbotPublic; onClick: () => void; index: number }> = ({ chatbot, onClick, index }) => (
<div <div
className="group bg-white rounded-xl border border-gray-200 shadow-sm hover:-translate-y-1.5 hover:shadow-xl hover:border-gray-300 transition-all duration-200 cursor-pointer overflow-hidden" className="group relative bg-white rounded-xl border border-gray-200 shadow-sm hover:shadow-lg hover:border-gray-300 hover:-translate-y-1 transition-all duration-200 cursor-pointer overflow-hidden"
style={{ animationDelay: `${index * 60}ms`, animationFillMode: 'both' }} style={{ animationDelay: `${index * 60}ms`, animationFillMode: 'both' }}
onClick={onClick} onClick={onClick}
> >
{/* Colored accent bar */} {/* Colored accent top bar — thicker and with gradient */}
<div className="h-1 w-full" style={{ background: chatbot.primary_color }} /> <div
className="h-1.5 w-full"
style={{ background: `linear-gradient(90deg, ${chatbot.primary_color}, ${chatbot.primary_color}aa)` }}
/>
<div className="p-5"> <div className="p-5">
<div className="flex items-center gap-3 mb-3"> {/* Header row */}
<div className="flex items-start gap-3 mb-3">
{chatbot.logo_url ? ( {chatbot.logo_url ? (
<img <img
src={chatbot.logo_url} src={chatbot.logo_url}
alt={chatbot.name} alt={chatbot.name}
className="w-11 h-11 rounded-xl object-cover flex-shrink-0 shadow-sm group-hover:scale-105 transition-transform duration-200" className="w-12 h-12 rounded-xl object-cover flex-shrink-0 shadow-sm group-hover:scale-105 transition-transform duration-200"
/> />
) : ( ) : (
<div <div
className="w-11 h-11 rounded-xl flex items-center justify-center text-white flex-shrink-0 shadow-sm group-hover:scale-105 transition-transform duration-200" className="w-12 h-12 rounded-xl flex items-center justify-center text-white flex-shrink-0 shadow-sm group-hover:scale-105 transition-transform duration-200"
style={{ background: chatbot.primary_color }} style={{ background: chatbot.primary_color }}
> >
<Bot className="w-5 h-5" /> <Bot className="w-6 h-6" />
</div> </div>
)} )}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0 pt-0.5">
<h3 className="font-semibold text-gray-900 text-sm truncate group-hover:text-primary-700 transition-colors">{chatbot.name}</h3> <h3 className="font-semibold text-gray-900 text-sm leading-tight truncate group-hover:text-primary-700 transition-colors">
{chatbot.name}
</h3>
{chatbot.company_name && ( {chatbot.company_name && (
<p className="text-xs text-gray-400 truncate">by {chatbot.company_name}</p> <p className="text-xs text-gray-400 truncate mt-0.5">by {chatbot.company_name}</p>
)} )}
</div> </div>
</div> </div>
{chatbot.description && ( {/* Description */}
{chatbot.description ? (
<p className="text-xs text-gray-500 mb-4 line-clamp-2 leading-relaxed">{chatbot.description}</p> <p className="text-xs text-gray-500 mb-4 line-clamp-2 leading-relaxed">{chatbot.description}</p>
) : (
<div className="mb-4" />
)} )}
{/* Stats row */}
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
{chatbot.average_rating && chatbot.average_rating > 0 && ( {chatbot.average_rating && chatbot.average_rating > 0 && (
<span className="flex items-center gap-1 bg-yellow-50 text-yellow-700 px-2 py-0.5 rounded-full text-xs font-medium"> <span className="flex items-center gap-1 bg-amber-50 text-amber-700 px-2.5 py-1 rounded-full text-xs font-medium border border-amber-100">
<Star className="w-3 h-3 fill-yellow-400 text-yellow-400" /> <Star className="w-3 h-3 fill-amber-400 text-amber-400" />
{chatbot.average_rating.toFixed(1)} {chatbot.average_rating.toFixed(1)}
</span> </span>
)} )}
<span className="flex items-center gap-1 bg-gray-50 text-gray-500 px-2 py-0.5 rounded-full text-xs"> <span className="flex items-center gap-1 bg-gray-50 text-gray-500 px-2.5 py-1 rounded-full text-xs border border-gray-100">
<MessageSquare className="w-3 h-3" /> <MessageSquare className="w-3 h-3" />
{chatbot.total_conversations.toLocaleString()} {chatbot.total_conversations.toLocaleString()}
</span> </span>
{chatbot.category && ( {chatbot.category && (
<span className="bg-primary-50 text-primary-700 px-2 py-0.5 rounded-full text-xs font-medium truncate"> <span className="bg-primary-50 text-primary-700 px-2.5 py-1 rounded-full text-xs font-medium border border-primary-100 truncate max-w-[120px]">
{chatbot.category} {chatbot.category}
</span> </span>
)} )}
</div> </div>
</div> </div>
{/* Hover overlay: "Chat now" CTA */}
<div className="absolute inset-0 flex items-end justify-center pb-5 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none">
<div
className="px-5 py-2 rounded-full text-white text-xs font-semibold shadow-lg pointer-events-none"
style={{ background: chatbot.primary_color }}
>
Chat now
</div>
</div>
</div> </div>
) )
@@ -231,23 +398,30 @@ export const ChatbotDetailPage: React.FC = () => {
{/* Back link */} {/* Back link */}
<button <button
onClick={() => navigate('/marketplace')} onClick={() => navigate('/marketplace')}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-primary-600 mb-5 transition-colors group" className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-primary-600 mb-6 transition-colors group"
> >
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-0.5 transition-transform" /> <ArrowLeft className="w-4 h-4 group-hover:-translate-x-0.5 transition-transform" />
Back to Marketplace Back to Marketplace
</button> </button>
{/* Chatbot info */} {/* Chatbot info card */}
<div className="flex items-center gap-4 mb-5"> <div className="bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden mb-5">
{/* Accent bar */}
<div
className="h-1.5 w-full"
style={{ background: `linear-gradient(90deg, ${chatbot.primary_color}, ${chatbot.primary_color}88)` }}
/>
<div className="p-5 sm:p-6">
<div className="flex items-center gap-4 mb-3">
{chatbot.logo_url ? ( {chatbot.logo_url ? (
<img <img
src={chatbot.logo_url} src={chatbot.logo_url}
alt={chatbot.name} alt={chatbot.name}
className="w-16 h-16 rounded-2xl object-cover shadow-md" className="w-16 h-16 rounded-2xl object-cover shadow-md flex-shrink-0"
/> />
) : ( ) : (
<div <div
className="w-16 h-16 rounded-2xl flex items-center justify-center text-white shadow-md" className="w-16 h-16 rounded-2xl flex items-center justify-center text-white shadow-md flex-shrink-0"
style={{ background: chatbot.primary_color }} style={{ background: chatbot.primary_color }}
> >
<Bot className="w-8 h-8" /> <Bot className="w-8 h-8" />
@@ -258,27 +432,34 @@ export const ChatbotDetailPage: React.FC = () => {
{chatbot.company_name && ( {chatbot.company_name && (
<p className="text-sm text-gray-500">by {chatbot.company_name}</p> <p className="text-sm text-gray-500">by {chatbot.company_name}</p>
)} )}
<div className="flex items-center gap-2 mt-1 flex-wrap"> <div className="flex items-center gap-2 mt-1.5 flex-wrap">
{chatbot.average_rating && chatbot.average_rating > 0 && ( {chatbot.average_rating && chatbot.average_rating > 0 && (
<span className="flex items-center gap-1 text-xs text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full font-medium"> <span className="flex items-center gap-1 text-xs text-amber-700 bg-amber-50 px-2 py-0.5 rounded-full font-medium border border-amber-100">
<Star className="w-3 h-3 fill-yellow-400 text-yellow-400" /> <Star className="w-3 h-3 fill-amber-400 text-amber-400" />
{chatbot.average_rating.toFixed(1)} {chatbot.average_rating.toFixed(1)}
</span> </span>
)} )}
<span className="flex items-center gap-1 text-xs text-gray-500 bg-gray-50 px-2 py-0.5 rounded-full"> <span className="flex items-center gap-1 text-xs text-gray-500 bg-gray-50 px-2 py-0.5 rounded-full border border-gray-100">
<MessageSquare className="w-3 h-3" /> <MessageSquare className="w-3 h-3" />
{chatbot.total_conversations.toLocaleString()} conversations {chatbot.total_conversations.toLocaleString()} conversations
</span> </span>
{chatbot.category && (
<span className="text-xs text-primary-700 bg-primary-50 px-2 py-0.5 rounded-full font-medium border border-primary-100">
{chatbot.category}
</span>
)}
</div> </div>
</div> </div>
</div> </div>
{chatbot.description && ( {chatbot.description && (
<p className="text-gray-500 text-sm mb-5 leading-relaxed">{chatbot.description}</p> <p className="text-gray-500 text-sm leading-relaxed">{chatbot.description}</p>
)} )}
</div>
</div>
{/* Chat */} {/* Chat */}
<div className="h-[calc(100vh-300px)] min-h-[400px] max-h-[680px] rounded-2xl overflow-hidden border border-gray-200 shadow-sm"> <div className="h-[calc(100vh-340px)] min-h-[400px] max-h-[680px] rounded-2xl overflow-hidden border border-gray-200 shadow-sm">
<ChatInterface <ChatInterface
chatbotId={chatbot.id} chatbotId={chatbot.id}
chatbotName={chatbot.name} chatbotName={chatbot.name}

View File

@@ -4,23 +4,28 @@ 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 } from '@/components/ui' import { Button } from '@/components/ui'
import { Check } from 'lucide-react' import { Check, Minus, Zap, Building2, Rocket, Star } from 'lucide-react'
const PLANS = [ const PLANS = [
{ {
id: 'free', id: 'free',
name: 'Free', name: 'Free',
price: 0, price: 0,
yearlyPrice: 0,
description: 'Build, test and launch your first chatbot — no card needed', description: 'Build, test and launch your first chatbot — no card needed',
icon: '🆓', icon: Star,
iconColor: 'text-gray-500',
iconBg: 'bg-gray-100',
features: [ features: [
{ text: '1 published chatbot', included: true }, { text: '1 published chatbot', included: true },
{ text: '100 conversations/month', included: true }, { text: '100 conversations/month', included: true },
{ text: '3 documents per chatbot', included: true }, { text: '3 documents per chatbot', included: true },
{ text: 'Public chat link + website embed', included: true }, { text: 'Public chat link + website embed', included: true },
{ text: 'Llama 3.3 70B model', included: true }, { text: 'Llama 3.3 70B model', included: true },
{ text: 'Read-only inbox (no agent replies)', included: true },
{ text: 'View-only leads (no editing)', included: true },
{ text: 'Analytics dashboard', included: false }, { text: 'Analytics dashboard', included: false },
{ text: 'Lead capture', included: false }, { text: 'Appointments & campaigns', included: false },
{ text: 'Messaging channels', included: false }, { text: 'Messaging channels', included: false },
{ text: 'Remove "Powered by Contexta"', included: false }, { text: 'Remove "Powered by Contexta"', included: false },
], ],
@@ -28,52 +33,67 @@ const PLANS = [
{ {
id: 'starter', id: 'starter',
name: 'Starter', name: 'Starter',
price: 12, price: 19,
description: 'For individuals and solo businesses going live', yearlyPrice: 15,
icon: '🚀', description: 'For solo operators: live chat, leads, booking, and campaigns',
icon: Rocket,
iconColor: 'text-blue-600',
iconBg: 'bg-blue-50',
features: [ features: [
{ text: 'Everything in Free', included: true }, { text: 'Everything in Free', included: true },
{ text: '3 published chatbots', included: true },
{ text: '1,500 conversations/month', included: true }, { text: '1,500 conversations/month', included: true },
{ text: '10 documents per chatbot', included: true }, { text: '10 documents per chatbot', included: true },
{ text: '4 Fireworks AI models (Qwen3, DeepSeek, Kimi, Llama)', included: true }, { text: '4 Fireworks AI models (Qwen3, DeepSeek, Kimi, Llama)', included: true },
{ text: 'Lead capture + inbox', included: true }, { text: 'Live chat inbox + agent replies', included: true },
{ text: 'Analytics + knowledge gaps', included: true }, { text: 'Full lead CRM (status + notes)', included: true },
{ text: 'Telegram channel', included: true }, { text: 'Appointment booking (1 chatbot)', included: true },
{ text: 'WhatsApp channel', included: false }, { text: 'Telegram campaigns (3/mo · 500 recipients)', included: true },
{ text: 'Analytics dashboard', included: true },
{ text: 'Knowledge gap suggestions', included: false },
{ text: 'Premium models (GPT-4o, Claude, Gemini)', included: false }, { text: 'Premium models (GPT-4o, Claude, Gemini)', included: false },
{ text: 'Remove "Powered by Contexta"', included: false },
], ],
}, },
{ {
id: 'business', id: 'business',
name: 'Business', name: 'Business',
price: 29, price: 49,
description: 'For growing businesses that need more reach and power', yearlyPrice: 39,
icon: '⚡', description: 'For growing businesses: premium AI, unlimited booking, full analytics',
icon: Zap,
iconColor: 'text-primary-600',
iconBg: 'bg-primary-50',
highlighted: true, highlighted: true,
badge: 'Most Popular', badge: 'Most Popular',
features: [ features: [
{ text: 'Everything in Starter', included: true }, { text: 'Everything in Starter', included: true },
{ text: 'Up to 3 published chatbots', included: true }, { text: '10 published chatbots', included: true },
{ text: '5,000 conversations/month', included: true }, { text: '5,000 conversations/month', included: true },
{ text: '50 documents per chatbot', included: true }, { text: '50 documents per chatbot', included: true },
{ text: 'WhatsApp + Telegram channels', included: true }, { text: 'GPT-4o, Claude Haiku 4.5, Gemini 2.5', included: true },
{ text: 'GPT-4o, Claude Haiku, Gemini 2.5', included: true }, { text: 'Appointment booking (all chatbots)', included: true },
{ text: 'Unlimited campaigns · 5,000 recipients', included: true },
{ text: 'Knowledge gap suggestions', included: true },
{ text: 'Remove "Powered by Contexta"', included: true }, { text: 'Remove "Powered by Contexta"', included: true },
{ text: 'Unlimited URL sources', included: true }, { text: 'Unlimited URL sources', included: true },
{ text: 'Priority support', included: true },
], ],
}, },
{ {
id: 'agency', id: 'agency',
name: 'Agency', name: 'Agency',
price: 79, price: 99,
description: 'For agencies and large businesses managing many chatbots', yearlyPrice: 79,
icon: '🏗️', description: 'For agencies: unlimited everything, white-label ready',
icon: Building2,
iconColor: 'text-purple-600',
iconBg: 'bg-purple-50',
features: [ features: [
{ text: 'Everything in Business', included: true }, { text: 'Everything in Business', included: true },
{ text: 'Unlimited published chatbots', included: true }, { text: 'Unlimited published chatbots', included: true },
{ text: '20,000 conversations/month', included: true }, { text: '20,000 conversations/month', included: true },
{ text: 'Unlimited documents', included: true }, { text: 'Unlimited documents', included: true },
{ text: 'Unlimited campaign recipients', included: true },
{ text: 'Code export (FastAPI + React)', included: true }, { text: 'Code export (FastAPI + React)', included: true },
{ text: 'Dedicated support', included: true }, { text: 'Dedicated support', included: true },
], ],
@@ -82,8 +102,11 @@ const PLANS = [
id: 'enterprise', id: 'enterprise',
name: 'Enterprise', name: 'Enterprise',
price: null, price: null,
yearlyPrice: null,
description: 'For large organizations with custom needs and SLAs', description: 'For large organizations with custom needs and SLAs',
icon: '🏢', icon: Building2,
iconColor: 'text-gray-700',
iconBg: 'bg-gray-100',
features: [ features: [
{ text: 'Everything in Agency', included: true }, { text: 'Everything in Agency', included: true },
{ text: 'Unlimited conversations', included: true }, { text: 'Unlimited conversations', included: true },
@@ -100,6 +123,7 @@ export const PricingPage: React.FC = () => {
const { user } = useAuthStore() const { user } = useAuthStore()
const navigate = useNavigate() const navigate = useNavigate()
const [loading, setLoading] = useState<string | null>(null) const [loading, setLoading] = useState<string | null>(null)
const [yearly, setYearly] = useState(false)
const { data: subscription } = useQuery({ const { data: subscription } = useQuery({
queryKey: ['subscription'], queryKey: ['subscription'],
@@ -138,101 +162,163 @@ export const PricingPage: React.FC = () => {
} }
const getCtaText = (planId: string): string => { const getCtaText = (planId: string): string => {
if (!user) return planId === 'enterprise' ? 'Contact Sales' : 'Get Started' if (!user) return planId === 'enterprise' ? 'Contact Sales' : 'Get Started Free'
if (planId === currentPlan) return 'Current Plan' if (planId === currentPlan) return 'Current Plan'
if (planId === 'enterprise') return 'Contact Sales' if (planId === 'enterprise') return 'Contact Sales'
if (planId === 'free') return 'Downgrade' if (planId === 'free') return 'Downgrade'
return 'Upgrade' return 'Upgrade Now'
} }
const isCurrentPlan = (planId: string) => user && planId === currentPlan 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"> {/* Header */}
<h1 className="text-3xl font-bold text-gray-900 mb-3">Simple, affordable pricing</h1> <div className="text-center mb-10 animate-fade-in-up">
<p className="text-gray-500 max-w-xl mx-auto"> <span className="inline-block px-3 py-1 text-xs font-semibold bg-primary-50 text-primary-600 rounded-full mb-4 border border-primary-100">
Pricing
</span>
<h1 className="text-4xl font-bold mb-3">
<span className="text-gradient">Simple, transparent pricing</span>
</h1>
<p className="text-gray-500 max-w-xl mx-auto text-sm leading-relaxed">
Start free and go live for $12/month. Built for individuals, small businesses, agencies, and enterprises alike. Start free and go live for $12/month. Built for individuals, small businesses, agencies, and enterprises alike.
</p> </p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-5"> {/* Billing toggle */}
{PLANS.map((plan) => ( <div className="inline-flex items-center gap-3 mt-6 bg-gray-100 rounded-xl p-1">
<div <button
key={plan.id} onClick={() => setYearly(false)}
className={`relative rounded-2xl border p-6 flex flex-col ${ className={`px-4 py-1.5 rounded-lg text-sm font-medium transition-all duration-200 ${
plan.highlighted !yearly ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'
? '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'
}`} }`}
> >
{isCurrentPlan(plan.id) && ( Monthly
<div className="absolute -top-3 left-1/2 -translate-x-1/2"> </button>
<span className="bg-green-600 text-white text-xs font-semibold px-3 py-1 rounded-full"> <button
onClick={() => setYearly(true)}
className={`px-4 py-1.5 rounded-lg text-sm font-medium transition-all duration-200 flex items-center gap-2 ${
yearly ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'
}`}
>
Yearly
<span className="text-xs font-semibold text-green-600 bg-green-50 px-1.5 py-0.5 rounded-md">
-20%
</span>
</button>
</div>
</div>
{/* Plan cards */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-4">
{PLANS.map((plan, i) => {
const PlanIcon = plan.icon
const displayPrice = yearly ? plan.yearlyPrice : plan.price
const isCurrent = isCurrentPlan(plan.id)
return (
<div
key={plan.id}
className={`relative rounded-2xl border flex flex-col transition-all duration-200 animate-fade-in-up ${
plan.highlighted
? 'border-primary-300 bg-gradient-to-b from-primary-50 via-white to-white shadow-lg shadow-primary-100/60 scale-[1.02]'
: isCurrent
? 'border-green-200 bg-green-50/30 shadow-sm'
: 'border-gray-200 bg-white hover:border-gray-300 hover:shadow-md'
}`}
style={{ animationDelay: `${i * 60}ms` }}
>
{/* Badge */}
{isCurrent && (
<div className="absolute -top-3.5 left-1/2 -translate-x-1/2 z-10">
<span className="bg-green-600 text-white text-xs font-semibold px-3 py-1 rounded-full shadow-sm">
Current Plan Current Plan
</span> </span>
</div> </div>
)} )}
{plan.badge && !isCurrentPlan(plan.id) && ( {plan.badge && !isCurrent && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2"> <div className="absolute -top-3.5 left-1/2 -translate-x-1/2 z-10">
<span className="bg-primary-600 text-white text-xs font-semibold px-3 py-1 rounded-full"> <span className="bg-gradient-to-r from-primary-600 to-purple-600 text-white text-xs font-semibold px-3 py-1 rounded-full shadow-sm shadow-primary-200">
{plan.badge} {plan.badge}
</span> </span>
</div> </div>
)} )}
<div className="mb-6"> <div className="p-5 flex flex-col flex-1">
<div className="text-3xl mb-2">{plan.icon}</div> {/* Plan header */}
<h2 className="text-xl font-bold text-gray-900">{plan.name}</h2> <div className="mb-5">
<p className="text-sm text-gray-500 mt-1 min-h-[40px]">{plan.description}</p> <div className={`w-9 h-9 rounded-xl ${plan.iconBg} flex items-center justify-center mb-3`}>
<PlanIcon className={`w-4.5 h-4.5 ${plan.iconColor}`} />
</div>
<h2 className="text-lg font-bold text-gray-900">{plan.name}</h2>
<p className="text-xs text-gray-500 mt-1 leading-relaxed min-h-[32px]">{plan.description}</p>
<div className="mt-4"> <div className="mt-4">
{plan.price !== null ? ( {displayPrice !== null ? (
<div className="flex items-baseline gap-1"> <div className="flex items-baseline gap-1">
<span className="text-4xl font-extrabold text-gray-900">${plan.price}</span> <span className="text-3xl font-extrabold text-gray-900">${displayPrice}</span>
{plan.price > 0 && <span className="text-gray-500 text-sm">/month</span>} {(displayPrice as number) > 0 && (
<span className="text-gray-400 text-xs">/mo</span>
)}
</div> </div>
) : ( ) : (
<div className="text-2xl font-bold text-gray-900">Custom</div> <div className="text-2xl font-bold text-gray-900">Custom</div>
)} )}
{yearly && plan.price !== null && (plan.price as number) > 0 && (
<p className="text-xs text-green-600 mt-0.5 font-medium">
Save ${(((plan.price as number) - (plan.yearlyPrice as number)) * 12).toFixed(0)}/yr
</p>
)}
</div> </div>
</div> </div>
<div className="flex-1"> {/* Features */}
<ul className="space-y-3 mb-8"> <ul className="space-y-2.5 mb-6 flex-1">
{plan.features.map((feature) => ( {plan.features.map((feature) => (
<li key={feature.text} className="flex items-start gap-2 text-sm"> <li key={feature.text} className="flex items-start gap-2 text-xs">
<div className={`w-4 h-4 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5 ${ {feature.included ? (
feature.included <span className="w-4 h-4 rounded-full bg-green-100 flex items-center justify-center flex-shrink-0 mt-0.5">
? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-400' <Check className="w-2.5 h-2.5 text-green-600" />
}`}> </span>
<Check className="w-2.5 h-2.5" /> ) : (
</div> <span className="w-4 h-4 rounded-full bg-gray-100 flex items-center justify-center flex-shrink-0 mt-0.5">
<Minus className="w-2.5 h-2.5 text-gray-400" />
</span>
)}
<span className={feature.included ? 'text-gray-700' : 'text-gray-400'}> <span className={feature.included ? 'text-gray-700' : 'text-gray-400'}>
{feature.text} {feature.text}
</span> </span>
</li> </li>
))} ))}
</ul> </ul>
</div>
{/* CTA */}
<Button <Button
onClick={() => handleSubscribe(plan.id)} onClick={() => handleSubscribe(plan.id)}
loading={loading === plan.id} loading={loading === plan.id}
disabled={isCurrentPlan(plan.id) || loading === plan.id} disabled={isCurrent || loading === plan.id}
variant={plan.highlighted ? 'default' : 'outline'} variant={plan.highlighted ? 'primary' : isCurrent ? 'secondary' : 'outline'}
className="w-full" className={`w-full transition-all duration-200 ${
plan.highlighted
? 'bg-gradient-to-r from-primary-600 to-primary-700 hover:from-primary-700 hover:to-primary-800 shadow-sm hover:shadow-md'
: ''
}`}
size="md"
> >
{getCtaText(plan.id)} {getCtaText(plan.id)}
</Button> </Button>
</div> </div>
))} </div>
)
})}
</div> </div>
{/* FAQ */} {/* FAQ */}
<div className="mt-16 max-w-2xl mx-auto"> <div className="mt-16 max-w-2xl mx-auto animate-fade-in-up">
<h2 className="text-xl font-bold text-gray-900 text-center mb-8">Frequently Asked Questions</h2> <h2 className="text-2xl font-bold text-gray-900 text-center mb-2">
Frequently Asked Questions
</h2>
<p className="text-gray-500 text-sm text-center mb-8">Everything you need to know about Contexta's plans.</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{[ {[
{ {
@@ -257,12 +343,15 @@ export const PricingPage: React.FC = () => {
}, },
{ {
q: 'I\'m a small business. Which plan is right for me?', q: 'I\'m a small business. Which plan is right for me?',
a: 'Start with Starter at $12/month — 1 published chatbot, 1,500 conversations, analytics, lead capture and Telegram. Perfect for restaurants, shops, salons, and more. Upgrade to Business when you want WhatsApp.' a: 'Start with Starter at $12/month — 1 published chatbot, 1,500 conversations, analytics, lead capture and Telegram. Perfect for restaurants, shops, salons, and more. Upgrade to Business for premium AI models and more capacity.'
}, },
].map(({ q, a }) => ( ].map(({ q, a }) => (
<div key={q} className="bg-white border border-gray-200 rounded-xl p-4"> <div
<h3 className="font-semibold text-gray-900 text-sm mb-1">{q}</h3> key={q}
<p className="text-sm text-gray-500">{a}</p> className="bg-white border border-gray-100 rounded-xl p-5 hover:border-gray-200 hover:shadow-sm transition-all duration-200"
>
<h3 className="font-semibold text-gray-900 text-sm mb-1.5">{q}</h3>
<p className="text-sm text-gray-500 leading-relaxed">{a}</p>
</div> </div>
))} ))}
</div> </div>

View File

@@ -0,0 +1,413 @@
import React, { useState } from 'react'
import { useParams } from 'react-router-dom'
import { useQuery, useMutation } from '@tanstack/react-query'
import { appointmentsAPI } from '@/services/api'
import { Sparkles, Calendar, Clock, ChevronLeft, ChevronRight, CheckCircle, Loader2, AlertCircle } from 'lucide-react'
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December']
function toDateString(d: Date) {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function formatSlotTime(iso: string) {
const d = new Date(iso)
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
function formatDisplayDate(dateStr: string) {
const [y, m, d] = dateStr.split('-').map(Number)
const dt = new Date(y, m - 1, d)
return dt.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
}
export const PublicBookingPage: React.FC = () => {
const { chatbotId } = useParams<{ chatbotId: string }>()
const today = new Date()
today.setHours(0, 0, 0, 0)
const [calYear, setCalYear] = useState(today.getFullYear())
const [calMonth, setCalMonth] = useState(today.getMonth())
const [selectedDate, setSelectedDate] = useState<string | null>(null)
const [selectedSlot, setSelectedSlot] = useState<string | null>(null)
const [step, setStep] = useState<'date' | 'slot' | 'form' | 'done'>('date')
const [form, setForm] = useState({ name: '', contact: '', service: '', notes: '' })
const [formErrors, setFormErrors] = useState<{ name?: string; contact?: string }>({})
// ── Data fetching ────────────────────────────────────────────────────────
const { data: info, isLoading: infoLoading, isError: infoError } = useQuery({
queryKey: ['booking-info', chatbotId],
queryFn: () => appointmentsAPI.getBookingInfo(chatbotId!),
enabled: !!chatbotId,
retry: false,
})
const { data: slotsData, isLoading: slotsLoading } = useQuery({
queryKey: ['slots', chatbotId, selectedDate],
queryFn: () => appointmentsAPI.getAvailableSlots(chatbotId!, selectedDate!),
enabled: !!chatbotId && !!selectedDate,
})
const bookMutation = useMutation({
mutationFn: () => appointmentsAPI.book(chatbotId!, {
customer_name: form.name.trim(),
customer_contact: form.contact.trim(),
service: form.service.trim() || undefined,
notes: form.notes.trim() || undefined,
slot_start: selectedSlot!,
}),
onSuccess: () => setStep('done'),
})
// ── Calendar helpers ─────────────────────────────────────────────────────
const firstDayOfMonth = new Date(calYear, calMonth, 1).getDay()
const daysInMonth = new Date(calYear, calMonth + 1, 0).getDate()
const prevMonth = () => {
if (calMonth === 0) { setCalYear(y => y - 1); setCalMonth(11) }
else setCalMonth(m => m - 1)
}
const nextMonth = () => {
if (calMonth === 11) { setCalYear(y => y + 1); setCalMonth(0) }
else setCalMonth(m => m + 1)
}
const isPastDay = (day: number) => {
const d = new Date(calYear, calMonth, day)
return d < today
}
const handleDayClick = (day: number) => {
if (isPastDay(day)) return
const ds = toDateString(new Date(calYear, calMonth, day))
setSelectedDate(ds)
setSelectedSlot(null)
setStep('slot')
}
// ── Form validation ──────────────────────────────────────────────────────
const handleBook = () => {
const errors: typeof formErrors = {}
if (!form.name.trim()) errors.name = 'Name is required'
if (!form.contact.trim()) errors.contact = 'Email or phone is required'
if (Object.keys(errors).length) { setFormErrors(errors); return }
setFormErrors({})
bookMutation.mutate()
}
// ── Loading / error states ───────────────────────────────────────────────
if (infoLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
</div>
)
}
if (infoError || !info) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div className="text-center max-w-sm">
<AlertCircle className="w-12 h-12 text-gray-300 mx-auto mb-4" />
<h2 className="text-lg font-semibold text-gray-700 mb-1">Booking unavailable</h2>
<p className="text-sm text-gray-400">This booking link is no longer active.</p>
</div>
</div>
)
}
// ── Done state ───────────────────────────────────────────────────────────
if (step === 'done') {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-8 max-w-sm w-full text-center">
<div className="w-16 h-16 rounded-full bg-green-50 flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-8 h-8 text-green-500" />
</div>
<h2 className="text-xl font-bold text-gray-900 mb-2">Booking confirmed!</h2>
<p className="text-sm text-gray-500 mb-1">
<span className="font-medium text-gray-700">{formatDisplayDate(selectedDate!)}</span>
</p>
<p className="text-sm text-gray-500 mb-6">
at <span className="font-medium text-gray-700">{selectedSlot ? formatSlotTime(selectedSlot) : ''}</span>
</p>
<p className="text-xs text-gray-400">
{info.company_name || info.chatbot_name} will follow up to confirm your appointment.
</p>
</div>
</div>
)
}
// ── Main layout ──────────────────────────────────────────────────────────
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white border-b border-gray-100 px-4 py-4">
<div className="max-w-lg mx-auto flex items-center gap-2.5">
<div className="w-8 h-8 bg-gradient-to-br from-primary-500 to-primary-700 rounded-xl flex items-center justify-center shadow-sm shadow-primary-200">
<Sparkles className="w-4 h-4 text-white" />
</div>
<div>
<p className="font-semibold text-gray-900 text-sm leading-tight">
{info.company_name || info.chatbot_name}
</p>
{info.company_name && (
<p className="text-xs text-gray-400">{info.chatbot_name}</p>
)}
</div>
</div>
</header>
<div className="max-w-lg mx-auto px-4 py-6 space-y-4">
{/* Step breadcrumb */}
<div className="flex items-center gap-2 text-xs text-gray-400">
<button
onClick={() => { setStep('date'); setSelectedDate(null); setSelectedSlot(null) }}
className={step === 'date' ? 'text-primary-600 font-medium' : 'hover:text-gray-600 cursor-pointer'}
>
Select date
</button>
<ChevronRight className="w-3 h-3" />
<button
onClick={() => { if (selectedDate) setStep('slot') }}
className={step === 'slot' ? 'text-primary-600 font-medium' : selectedDate ? 'hover:text-gray-600 cursor-pointer' : 'opacity-40 cursor-default'}
>
Choose time
</button>
<ChevronRight className="w-3 h-3" />
<span className={step === 'form' ? 'text-primary-600 font-medium' : 'opacity-40'}>
Your details
</span>
</div>
{/* ── Step 1: Date picker ── */}
{step === 'date' && (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5">
<div className="flex items-center justify-between mb-4">
<button onClick={prevMonth} className="w-8 h-8 rounded-lg hover:bg-gray-100 flex items-center justify-center transition-colors">
<ChevronLeft className="w-4 h-4 text-gray-500" />
</button>
<span className="font-semibold text-gray-900 text-sm">
{MONTHS[calMonth]} {calYear}
</span>
<button onClick={nextMonth} className="w-8 h-8 rounded-lg hover:bg-gray-100 flex items-center justify-center transition-colors">
<ChevronRight className="w-4 h-4 text-gray-500" />
</button>
</div>
<div className="grid grid-cols-7 gap-1 mb-2">
{DAYS.map(d => (
<div key={d} className="text-center text-xs text-gray-400 font-medium py-1">{d}</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{Array.from({ length: firstDayOfMonth }).map((_, i) => (
<div key={`empty-${i}`} />
))}
{Array.from({ length: daysInMonth }).map((_, i) => {
const day = i + 1
const past = isPastDay(day)
const ds = toDateString(new Date(calYear, calMonth, day))
const selected = ds === selectedDate
return (
<button
key={day}
onClick={() => handleDayClick(day)}
disabled={past}
className={[
'w-full aspect-square rounded-xl text-sm font-medium transition-all',
past ? 'text-gray-200 cursor-not-allowed' :
selected ? 'bg-primary-600 text-white shadow-sm' :
'text-gray-700 hover:bg-primary-50 hover:text-primary-700',
].join(' ')}
>
{day}
</button>
)
})}
</div>
<div className="mt-4 flex items-center gap-2 text-xs text-gray-400">
<Calendar className="w-3.5 h-3.5" />
<span>Select a date to see available times</span>
</div>
</div>
)}
{/* ── Step 2: Time slots ── */}
{step === 'slot' && selectedDate && (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5">
<div className="flex items-center gap-2 mb-4">
<Calendar className="w-4 h-4 text-primary-500" />
<span className="font-semibold text-gray-900 text-sm">{formatDisplayDate(selectedDate)}</span>
</div>
{slotsLoading ? (
<div className="flex items-center justify-center py-8 gap-2 text-gray-400 text-sm">
<Loader2 className="w-4 h-4 animate-spin" />
Loading available times...
</div>
) : !slotsData?.slots.length ? (
<div className="text-center py-8">
<Clock className="w-8 h-8 text-gray-200 mx-auto mb-2" />
<p className="text-sm text-gray-500">No available slots on this day.</p>
<button
onClick={() => setStep('date')}
className="mt-3 text-xs text-primary-600 hover:text-primary-700 font-medium"
>
Pick another date
</button>
</div>
) : (
<div className="grid grid-cols-3 gap-2">
{slotsData.slots.map(slot => {
const isSelected = slot.slot_start === selectedSlot
return (
<button
key={slot.slot_start}
onClick={() => setSelectedSlot(slot.slot_start)}
className={[
'py-2 px-3 rounded-xl text-sm font-medium border transition-all',
isSelected
? 'bg-primary-600 text-white border-primary-600 shadow-sm'
: 'text-gray-700 border-gray-200 hover:border-primary-300 hover:text-primary-700 hover:bg-primary-50',
].join(' ')}
>
{formatSlotTime(slot.slot_start)}
</button>
)
})}
</div>
)}
{selectedSlot && (
<button
onClick={() => setStep('form')}
className="mt-4 w-full bg-primary-600 hover:bg-primary-700 text-white text-sm font-semibold py-2.5 rounded-xl transition-colors"
>
Continue
</button>
)}
</div>
)}
{/* ── Step 3: Contact form ── */}
{step === 'form' && selectedDate && selectedSlot && (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5">
{/* Summary */}
<div className="flex items-center gap-3 p-3 bg-primary-50 rounded-xl mb-5">
<div className="w-8 h-8 bg-primary-100 rounded-lg flex items-center justify-center shrink-0">
<Clock className="w-4 h-4 text-primary-600" />
</div>
<div>
<p className="text-sm font-medium text-primary-900">{formatDisplayDate(selectedDate)}</p>
<p className="text-xs text-primary-600">{formatSlotTime(selectedSlot)}</p>
</div>
<button
onClick={() => setStep('slot')}
className="ml-auto text-xs text-primary-500 hover:text-primary-700 font-medium"
>
Change
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1.5">
Full name <span className="text-red-400">*</span>
</label>
<input
type="text"
value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
placeholder="Jane Doe"
className={[
'w-full px-3 py-2.5 text-sm rounded-xl border outline-none transition-colors',
formErrors.name
? 'border-red-300 bg-red-50 focus:border-red-400'
: 'border-gray-200 bg-white focus:border-primary-400',
].join(' ')}
/>
{formErrors.name && <p className="text-xs text-red-500 mt-1">{formErrors.name}</p>}
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1.5">
Email or phone <span className="text-red-400">*</span>
</label>
<input
type="text"
value={form.contact}
onChange={e => setForm(f => ({ ...f, contact: e.target.value }))}
placeholder="jane@example.com or +1 555 000 0000"
className={[
'w-full px-3 py-2.5 text-sm rounded-xl border outline-none transition-colors',
formErrors.contact
? 'border-red-300 bg-red-50 focus:border-red-400'
: 'border-gray-200 bg-white focus:border-primary-400',
].join(' ')}
/>
{formErrors.contact && <p className="text-xs text-red-500 mt-1">{formErrors.contact}</p>}
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1.5">
Service / reason <span className="text-gray-400 font-normal">(optional)</span>
</label>
<input
type="text"
value={form.service}
onChange={e => setForm(f => ({ ...f, service: e.target.value }))}
placeholder="e.g. Consultation, Haircut, Follow-up…"
className="w-full px-3 py-2.5 text-sm rounded-xl border border-gray-200 bg-white outline-none focus:border-primary-400 transition-colors"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1.5">
Notes <span className="text-gray-400 font-normal">(optional)</span>
</label>
<textarea
value={form.notes}
onChange={e => setForm(f => ({ ...f, notes: e.target.value }))}
rows={3}
placeholder="Anything we should know beforehand…"
className="w-full px-3 py-2.5 text-sm rounded-xl border border-gray-200 bg-white outline-none focus:border-primary-400 transition-colors resize-none"
/>
</div>
</div>
{bookMutation.isError && (
<div className="mt-3 flex items-center gap-2 text-xs text-red-600 bg-red-50 px-3 py-2 rounded-xl">
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
{(bookMutation.error as any)?.response?.data?.detail || 'Something went wrong. Please try again.'}
</div>
)}
<button
onClick={handleBook}
disabled={bookMutation.isPending}
className="mt-5 w-full bg-primary-600 hover:bg-primary-700 disabled:opacity-60 text-white text-sm font-semibold py-2.5 rounded-xl transition-colors flex items-center justify-center gap-2"
>
{bookMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Confirm booking
</button>
</div>
)}
{/* Powered by */}
<p className="text-center text-xs text-gray-300">
Powered by Contexta
</p>
</div>
</div>
)
}

View File

@@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { useNavigate, Link } from 'react-router-dom' import { useNavigate, Link } from 'react-router-dom'
import { authAPI } from '@/services/api' import { authAPI } from '@/services/api'
import { Button, Input } from '@/components/ui' import { Button } from '@/components/ui'
import { Sparkles, Eye, EyeOff } from 'lucide-react' import { Sparkles, Eye, EyeOff, Lock, X, ArrowLeft } from 'lucide-react'
export const ResetPasswordPage: React.FC = () => { export const ResetPasswordPage: React.FC = () => {
const navigate = useNavigate() const navigate = useNavigate()
@@ -54,62 +54,117 @@ export const ResetPasswordPage: React.FC = () => {
} }
return ( return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 via-white to-purple-50 flex items-center justify-center p-4"> <div className="min-h-screen bg-dots bg-gray-50 flex items-center justify-center p-4">
<div className="w-full max-w-md"> <div className="w-full max-w-md animate-scale-in">
<div className="text-center mb-8"> {/* Logo */}
<div className="inline-flex w-12 h-12 bg-primary-600 rounded-2xl items-center justify-center mb-4"> <div className="flex items-center justify-center gap-2 mb-8">
<Sparkles className="w-6 h-6 text-white" /> <div className="w-9 h-9 bg-primary-600 rounded-xl flex items-center justify-center shadow-sm">
<Sparkles className="w-5 h-5 text-white" />
</div> </div>
<h1 className="text-2xl font-bold text-gray-900">Set new password</h1> <span className="font-bold text-gray-900 text-lg">Contexta</span>
<p className="text-gray-500 mt-1 text-sm">Choose a strong password for your account</p>
</div> </div>
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-8"> <div className="bg-white rounded-2xl shadow-xl border border-gray-100 p-8">
{!accessToken && error ? ( {!accessToken && error ? (
<div className="text-center"> <div className="text-center py-2 animate-fade-in-up">
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700 mb-4"> <div className="w-14 h-14 bg-red-50 rounded-2xl flex items-center justify-center mx-auto mb-5">
{error} <X className="w-7 h-7 text-red-500" />
</div> </div>
<Link to="/forgot-password" className="text-primary-600 hover:underline text-sm"> <h2 className="text-xl font-bold text-gray-900 mb-2">Link expired</h2>
<p className="text-sm text-gray-500 mb-6">{error}</p>
<Link
to="/forgot-password"
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700 transition-colors"
>
Request a new reset link Request a new reset link
</Link> </Link>
</div> </div>
) : ( ) : (
<>
<div className="mb-7">
<h1 className="text-2xl font-bold text-gray-900">Set new password</h1>
<p className="text-gray-500 mt-1 text-sm">
Choose a strong password for your account.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{/* New password */}
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">New Password</label>
<div className="relative"> <div className="relative">
<Input <span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
label="New Password" <Lock className="w-4 h-4" />
</span>
<input
type={showPass ? 'text' : 'password'} type={showPass ? 'text' : 'password'}
value={password} value={password}
onChange={e => setPassword(e.target.value)} onChange={e => setPassword(e.target.value)}
placeholder="Min 8 characters" placeholder="Min 8 characters"
required required
className="w-full pl-10 pr-10 py-2.5 border border-gray-300 rounded-lg text-sm bg-white
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent
hover:border-gray-400 transition-all duration-200 placeholder:text-gray-400"
/> />
<button <button
type="button" type="button"
onClick={() => setShowPass(!showPass)} onClick={() => setShowPass(!showPass)}
className="absolute right-3 top-8 text-gray-400 hover:text-gray-600" className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
> >
{showPass ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />} {showPass ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button> </button>
</div> </div>
<Input </div>
label="Confirm Password"
{/* Confirm password */}
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Confirm Password</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
<Lock className="w-4 h-4" />
</span>
<input
type={showPass ? 'text' : 'password'} type={showPass ? 'text' : 'password'}
value={confirm} value={confirm}
onChange={e => setConfirm(e.target.value)} onChange={e => setConfirm(e.target.value)}
placeholder="Repeat password" placeholder="Repeat password"
required required
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-lg text-sm bg-white
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent
hover:border-gray-400 transition-all duration-200 placeholder:text-gray-400"
/> />
</div>
</div>
{error && ( {error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700"> <div className="flex items-start gap-3 p-3.5 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700 animate-fade-in">
{error} <span className="w-5 h-5 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0 mt-0.5">
<X className="w-3 h-3 text-red-600" />
</span>
<span>{error}</span>
</div> </div>
)} )}
<Button type="submit" loading={loading} className="w-full" size="lg">
<Button
type="submit"
loading={loading}
className="w-full bg-primary-600 hover:bg-primary-700 shadow-sm hover:shadow-md transition-all duration-200"
size="lg"
>
Set new password Set new password
</Button> </Button>
</form> </form>
<div className="mt-6 text-center">
<Link
to="/login"
className="inline-flex items-center gap-1.5 text-sm text-gray-400 hover:text-gray-600 transition-colors"
>
<ArrowLeft className="w-3.5 h-3.5" />
Back to sign in
</Link>
</div>
</>
)} )}
</div> </div>
</div> </div>

View File

@@ -4,13 +4,16 @@ import { useNavigate, useLocation, Link } from 'react-router-dom'
import { billingAPI, authAPI } from '@/services/api' import { billingAPI, authAPI } from '@/services/api'
import { useAuthStore } from '@/store/authStore' import { useAuthStore } from '@/store/authStore'
import { Button, Card, Input } from '@/components/ui' import { Button, Card, Input } from '@/components/ui'
import { useToast } from '@/contexts/ToastContext'
import { useThemeStore } from '@/store/themeStore'
import { getPlanColor, formatDate } from '@/lib/utils' import { getPlanColor, formatDate } from '@/lib/utils'
import { CreditCard, User, ExternalLink, AlertTriangle } from 'lucide-react' import { CreditCard, User, ExternalLink, AlertTriangle, Moon, Sun } from 'lucide-react'
export const SettingsPage: React.FC = () => { export const SettingsPage: React.FC = () => {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const [toast, setToast] = useState('') const { success: showToast, error: showError } = useToast()
const { isDark, toggle: toggleTheme } = useThemeStore()
const tab: 'profile' | 'billing' = location.pathname === '/settings/billing' ? 'billing' : 'profile' const tab: 'profile' | 'billing' = location.pathname === '/settings/billing' ? 'billing' : 'profile'
@@ -21,14 +24,19 @@ export const SettingsPage: React.FC = () => {
} }
}, [navigate, location.pathname]) }, [navigate, location.pathname])
const showToast = (msg: string) => {
setToast(msg)
setTimeout(() => setToast(''), 3500)
}
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> <div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
<button
onClick={toggleTheme}
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-200 hover:bg-gray-100 text-sm text-gray-600 transition-colors"
aria-label="Toggle dark mode"
>
{isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
{isDark ? 'Light mode' : 'Dark mode'}
</button>
</div>
{/* 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">
@@ -51,20 +59,13 @@ export const SettingsPage: React.FC = () => {
))} ))}
</div> </div>
{tab === 'profile' && <ProfileSettings onToast={showToast} />} {tab === 'profile' && <ProfileSettings onToast={showToast} onError={showError} />}
{tab === 'billing' && <BillingSettings onToast={showToast} />} {tab === 'billing' && <BillingSettings onToast={showToast} onError={showError} />}
{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> </div>
) )
} }
const ProfileSettings: React.FC<{ onToast: (msg: string) => void }> = ({ onToast }) => { const ProfileSettings: React.FC<{ onToast: (msg: string) => void; onError: (msg: string) => void }> = ({ onToast, onError }) => {
const { user, setAuth, token, logout } = useAuthStore() const { user, setAuth, token, logout } = useAuthStore()
const navigate = useNavigate() const navigate = useNavigate()
const [companyName, setCompanyName] = useState(user?.company_name || '') const [companyName, setCompanyName] = useState(user?.company_name || '')
@@ -95,7 +96,7 @@ const ProfileSettings: React.FC<{ onToast: (msg: string) => void }> = ({ onToast
onToast('Profile updated successfully') onToast('Profile updated successfully')
} catch (err) { } catch (err) {
const e = err as { response?: { data?: { detail?: string } } } const e = err as { response?: { data?: { detail?: string } } }
onToast(e.response?.data?.detail || 'Failed to update profile') onError(e.response?.data?.detail || 'Failed to update profile')
} finally { } finally {
setSaving(false) setSaving(false)
} }
@@ -110,7 +111,7 @@ const ProfileSettings: React.FC<{ onToast: (msg: string) => void }> = ({ onToast
navigate('/') navigate('/')
} catch (err) { } catch (err) {
const e = err as { response?: { data?: { detail?: string } } } const e = err as { response?: { data?: { detail?: string } } }
onToast(e.response?.data?.detail || 'Failed to delete account') onError(e.response?.data?.detail || 'Failed to delete account')
setDeleting(false) setDeleting(false)
} }
} }
@@ -211,7 +212,7 @@ const ProfileSettings: React.FC<{ onToast: (msg: string) => void }> = ({ onToast
) )
} }
const BillingSettings: React.FC<{ onToast: (msg: string) => void }> = ({ onToast }) => { const BillingSettings: React.FC<{ onToast: (msg: string) => void; onError: (msg: string) => void }> = ({ onError }) => {
const navigate = useNavigate() const navigate = useNavigate()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -227,7 +228,7 @@ const BillingSettings: React.FC<{ onToast: (msg: string) => void }> = ({ onToast
window.location.href = url window.location.href = url
} catch (err) { } catch (err) {
const e = err as { response?: { data?: { detail?: string } } } const e = err as { response?: { data?: { detail?: string } } }
onToast(e.response?.data?.detail || 'Failed to open billing portal') onError(e.response?.data?.detail || 'Failed to open billing portal')
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -0,0 +1,130 @@
import React, { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { adminAPI } from '@/services/api'
import { useToast } from '@/contexts/ToastContext'
import { getApiError } from '@/lib/apiError'
import { SkeletonTable } from '@/components/Skeletons'
import { Search, Trash2, Globe, EyeOff, ChevronLeft, ChevronRight } from 'lucide-react'
export const AdminChatbotsPage: React.FC = () => {
const qc = useQueryClient()
const toast = useToast()
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const LIMIT = 20
const { data, isLoading } = useQuery({
queryKey: ['admin', 'chatbots', search, page],
queryFn: () => adminAPI.chatbots({ search: search || undefined, page, limit: LIMIT }),
placeholderData: (prev) => prev,
})
const deleteChatbot = useMutation({
mutationFn: (id: string) => adminAPI.deleteChatbot(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'chatbots'] })
toast.success('Chatbot deleted')
},
onError: (err) => toast.error(getApiError(err)),
})
const chatbots = data?.chatbots ?? []
const total = data?.total ?? 0
const totalPages = Math.ceil(total / LIMIT)
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="mb-6 flex items-center justify-between gap-4 flex-wrap">
<div>
<h1 className="text-2xl font-bold text-white">Chatbots</h1>
<p className="text-gray-400 text-sm mt-1">{total} total chatbots</p>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
className="pl-9 pr-4 py-2 bg-gray-900 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:border-gray-500 w-64"
placeholder="Search by name..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
/>
</div>
</div>
{isLoading ? (
<SkeletonTable rows={8} />
) : (
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-800">
<th className="text-left px-4 py-3 text-gray-400 font-medium">Name</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">Owner</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">Status</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">Docs</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">Conversations</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">Created</th>
<th className="text-right px-4 py-3 text-gray-400 font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{chatbots.map((bot) => (
<tr key={bot.id} className="hover:bg-gray-800/50 transition-colors">
<td className="px-4 py-3 text-gray-200 font-medium">{bot.name}</td>
<td className="px-4 py-3 text-gray-400 text-xs">{bot.owner_email}</td>
<td className="px-4 py-3">
{bot.is_published ? (
<span className="flex items-center gap-1 text-xs text-green-400">
<Globe className="w-3 h-3" /> Published
</span>
) : (
<span className="flex items-center gap-1 text-xs text-gray-500">
<EyeOff className="w-3 h-3" /> Draft
</span>
)}
</td>
<td className="px-4 py-3 text-gray-400">{bot.document_count}</td>
<td className="px-4 py-3 text-gray-400">{bot.conversation_count}</td>
<td className="px-4 py-3 text-gray-500 text-xs">
{bot.created_at ? new Date(bot.created_at).toLocaleDateString() : '—'}
</td>
<td className="px-4 py-3 text-right">
<button
title="Delete chatbot"
onClick={() => {
if (confirm(`Delete chatbot "${bot.name}"? This is irreversible.`)) {
deleteChatbot.mutate(bot.id)
}
}}
className="p-1.5 text-gray-400 hover:text-red-400 hover:bg-red-900/20 rounded transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</td>
</tr>
))}
{chatbots.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-gray-500">No chatbots found</td>
</tr>
)}
</tbody>
</table>
</div>
)}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-4">
<button disabled={page === 1} onClick={() => setPage((p) => p - 1)}
className="p-2 text-gray-400 disabled:opacity-30 hover:text-white transition-colors">
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-gray-400 text-sm">{page} / {totalPages}</span>
<button disabled={page === totalPages} onClick={() => setPage((p) => p + 1)}
className="p-2 text-gray-400 disabled:opacity-30 hover:text-white transition-colors">
<ChevronRight className="w-4 h-4" />
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,88 @@
import React, { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { adminAPI } from '@/services/api'
import { SkeletonTable } from '@/components/Skeletons'
import { MessageSquare, ChevronLeft, ChevronRight } from 'lucide-react'
export const AdminConversationsPage: React.FC = () => {
const [page, setPage] = useState(1)
const LIMIT = 25
const { data, isLoading } = useQuery({
queryKey: ['admin', 'conversations', page],
queryFn: () => adminAPI.conversations({ page, limit: LIMIT }),
placeholderData: (prev) => prev,
})
const conversations = data?.conversations ?? []
const total = data?.total ?? 0
const totalPages = Math.ceil(total / LIMIT)
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="mb-6">
<h1 className="text-2xl font-bold text-white">Conversations</h1>
<p className="text-gray-400 text-sm mt-1">{total} total conversations</p>
</div>
{isLoading ? (
<SkeletonTable rows={10} />
) : (
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-800">
<th className="text-left px-4 py-3 text-gray-400 font-medium">Chatbot</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">Owner</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">Messages</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">Language</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">Date</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{conversations.map((conv) => (
<tr key={conv.id} className="hover:bg-gray-800/50 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<MessageSquare className="w-3.5 h-3.5 text-gray-500 shrink-0" />
<span className="text-gray-200">{conv.chatbot_name}</span>
</div>
</td>
<td className="px-4 py-3 text-gray-400 text-xs">{conv.owner_email}</td>
<td className="px-4 py-3 text-gray-400">{conv.message_count}</td>
<td className="px-4 py-3">
<span className="text-xs bg-gray-800 text-gray-300 px-2 py-0.5 rounded">
{conv.language || 'en'}
</span>
</td>
<td className="px-4 py-3 text-gray-500 text-xs">
{conv.created_at ? new Date(conv.created_at).toLocaleString() : '—'}
</td>
</tr>
))}
{conversations.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-gray-500">No conversations found</td>
</tr>
)}
</tbody>
</table>
</div>
)}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-4">
<button disabled={page === 1} onClick={() => setPage((p) => p - 1)}
className="p-2 text-gray-400 disabled:opacity-30 hover:text-white transition-colors">
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-gray-400 text-sm">{page} / {totalPages}</span>
<button disabled={page === totalPages} onClick={() => setPage((p) => p + 1)}
className="p-2 text-gray-400 disabled:opacity-30 hover:text-white transition-colors">
<ChevronRight className="w-4 h-4" />
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,116 @@
import React from 'react'
import { useQuery } from '@tanstack/react-query'
import { adminAPI } from '@/services/api'
import { SkeletonStatCard } from '@/components/Skeletons'
import { Users, Bot, MessageSquare, Activity, CheckCircle, XCircle } from 'lucide-react'
const StatCard: React.FC<{
label: string
value: number | string
icon: React.ReactNode
color: string
}> = ({ label, value, icon, color }) => (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
<div className="flex items-center justify-between mb-3">
<span className="text-gray-400 text-sm">{label}</span>
<div className={`w-9 h-9 rounded-lg flex items-center justify-center ${color}`}>
{icon}
</div>
</div>
<p className="text-3xl font-bold text-white">{value.toLocaleString()}</p>
</div>
)
export const AdminDashboardPage: React.FC = () => {
const { data: stats, isLoading: statsLoading } = useQuery({
queryKey: ['admin', 'stats'],
queryFn: adminAPI.stats,
refetchInterval: 30_000,
})
const { data: health, isLoading: healthLoading } = useQuery({
queryKey: ['admin', 'health'],
queryFn: adminAPI.health,
refetchInterval: 30_000,
})
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="mb-6">
<h1 className="text-2xl font-bold text-white">Admin Overview</h1>
<p className="text-gray-400 text-sm mt-1">Platform-wide statistics and health</p>
</div>
{/* Stats grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 mb-8">
{statsLoading
? Array.from({ length: 6 }).map((_, i) => <SkeletonStatCard key={i} />)
: stats && (
<>
<StatCard label="Total Users" value={stats.total_users} icon={<Users className="w-4 h-4 text-white" />} color="bg-blue-600" />
<StatCard label="Total Chatbots" value={stats.total_chatbots} icon={<Bot className="w-4 h-4 text-white" />} color="bg-purple-600" />
<StatCard label="Published Chatbots" value={stats.total_published_chatbots} icon={<Bot className="w-4 h-4 text-white" />} color="bg-green-600" />
<StatCard label="Total Conversations" value={stats.total_conversations} icon={<MessageSquare className="w-4 h-4 text-white" />} color="bg-yellow-600" />
<StatCard label="Total Messages" value={stats.total_messages} icon={<MessageSquare className="w-4 h-4 text-white" />} color="bg-orange-600" />
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 sm:col-span-2 xl:col-span-1">
<p className="text-gray-400 text-sm mb-3">Plan Distribution</p>
<div className="space-y-2">
{Object.entries(stats.active_subscriptions).map(([plan, count]) => (
<div key={plan} className="flex items-center justify-between">
<span className="text-gray-300 text-sm capitalize">{plan}</span>
<span className="text-white font-semibold text-sm">{count}</span>
</div>
))}
</div>
</div>
</>
)}
</div>
{/* System Health */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
<div className="flex items-center gap-2 mb-4">
<Activity className="w-4 h-4 text-gray-400" />
<h2 className="text-white font-semibold">System Health</h2>
{health && (
<span className="ml-auto text-gray-500 text-xs">
{new Date(health.timestamp).toLocaleTimeString()}
</span>
)}
</div>
{healthLoading ? (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="animate-pulse h-8 bg-gray-800 rounded" />
))}
</div>
) : health && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{[
{ label: 'Database', status: health.db },
{ label: 'Qdrant', status: health.qdrant },
...Object.entries(health.llm_providers).map(([name, ok]) => ({
label: `LLM: ${name}`,
status: ok ? 'healthy' : 'unavailable',
})),
].map(({ label, status }) => {
const ok = status === 'healthy'
return (
<div key={label} className="flex items-center gap-3 px-3 py-2 bg-gray-800 rounded-lg">
{ok
? <CheckCircle className="w-4 h-4 text-green-500 shrink-0" />
: <XCircle className="w-4 h-4 text-red-500 shrink-0" />}
<span className="text-gray-300 text-sm flex-1">{label}</span>
<span className={`text-xs font-medium ${ok ? 'text-green-400' : 'text-red-400'}`}>
{status}
</span>
</div>
)
})}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,106 @@
import React from 'react'
import { useQuery } from '@tanstack/react-query'
import { adminAPI } from '@/services/api'
import { CheckCircle, XCircle, RefreshCw, Activity } from 'lucide-react'
export const AdminSystemPage: React.FC = () => {
const { data: health, isLoading, refetch, isFetching, dataUpdatedAt } = useQuery({
queryKey: ['admin', 'health'],
queryFn: adminAPI.health,
refetchInterval: 60_000,
})
const StatusRow: React.FC<{ label: string; status: string }> = ({ label, status }) => {
const ok = status === 'healthy'
return (
<div className="flex items-center gap-4 px-5 py-4 border-b border-gray-800 last:border-0">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${ok ? 'bg-green-900/40' : 'bg-red-900/40'}`}>
{ok
? <CheckCircle className="w-5 h-5 text-green-400" />
: <XCircle className="w-5 h-5 text-red-400" />}
</div>
<div className="flex-1">
<p className="text-gray-200 font-medium text-sm">{label}</p>
<p className={`text-xs ${ok ? 'text-green-400' : 'text-red-400'}`}>{status}</p>
</div>
<div className={`w-2 h-2 rounded-full ${ok ? 'bg-green-400' : 'bg-red-400'} animate-pulse`} />
</div>
)
}
return (
<div className="p-6 max-w-3xl mx-auto">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">System Health</h1>
{dataUpdatedAt > 0 && (
<p className="text-gray-400 text-sm mt-1">
Last updated: {new Date(dataUpdatedAt).toLocaleTimeString()}
</p>
)}
</div>
<button
onClick={() => refetch()}
disabled={isFetching}
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg text-sm text-gray-300 transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${isFetching ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
{isLoading ? (
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="animate-pulse h-16 border-b border-gray-800 last:border-0 bg-gray-900" />
))}
</div>
) : health ? (
<div className="space-y-4">
{/* Core services */}
<div>
<h2 className="text-gray-400 text-xs font-semibold uppercase tracking-wider mb-2 px-1">
Core Services
</h2>
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<StatusRow label="Database (Supabase)" status={health.db} />
<StatusRow label="Vector Store (Qdrant)" status={health.qdrant} />
</div>
</div>
{/* LLM providers */}
<div>
<h2 className="text-gray-400 text-xs font-semibold uppercase tracking-wider mb-2 px-1">
LLM Providers
</h2>
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
{Object.entries(health.llm_providers).map(([name, ok]) => (
<StatusRow key={name} label={name.charAt(0).toUpperCase() + name.slice(1)} status={ok ? 'healthy' : 'unavailable'} />
))}
</div>
</div>
{/* Overall */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 flex items-center gap-3">
<Activity className="w-4 h-4 text-gray-400" />
<div>
<p className="text-gray-300 text-sm font-medium">Overall Status</p>
<p className="text-gray-500 text-xs">
{[health.db, health.qdrant, ...Object.values(health.llm_providers).map(ok => ok ? 'healthy' : 'down')].every(s => s === 'healthy' || s === true)
? 'All systems operational'
: 'Some services degraded'}
</p>
</div>
<div className="ml-auto">
{health.db === 'healthy' && health.qdrant === 'healthy'
? <span className="text-xs text-green-400 bg-green-900/30 px-2 py-1 rounded-full">Operational</span>
: <span className="text-xs text-red-400 bg-red-900/30 px-2 py-1 rounded-full">Degraded</span>}
</div>
</div>
</div>
) : (
<div className="text-center py-12 text-gray-500">Failed to load health data</div>
)}
</div>
)
}

View File

@@ -0,0 +1,219 @@
import React, { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { adminAPI } from '@/services/api'
import { useToast } from '@/contexts/ToastContext'
import { getApiError } from '@/lib/apiError'
import { SkeletonTable } from '@/components/Skeletons'
import { Search, Shield, Ban, Trash2, CreditCard, ChevronLeft, ChevronRight } from 'lucide-react'
import type { AdminUser } from '@/types'
const PLANS = ['free', 'starter', 'business', 'agency', 'enterprise']
const PlanBadge: React.FC<{ plan: string }> = ({ plan }) => {
const colors: Record<string, string> = {
free: 'bg-gray-700 text-gray-300',
starter: 'bg-blue-900 text-blue-300',
business: 'bg-purple-900 text-purple-300',
agency: 'bg-yellow-900 text-yellow-300',
enterprise: 'bg-red-900 text-red-300',
}
return (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${colors[plan] ?? 'bg-gray-700 text-gray-300'}`}>
{plan}
</span>
)
}
export const AdminUsersPage: React.FC = () => {
const qc = useQueryClient()
const toast = useToast()
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [changePlanUserId, setChangePlanUserId] = useState<string | null>(null)
const [selectedPlan, setSelectedPlan] = useState('free')
const LIMIT = 20
const { data, isLoading } = useQuery({
queryKey: ['admin', 'users', search, page],
queryFn: () => adminAPI.users({ search: search || undefined, page, limit: LIMIT }),
placeholderData: (prev) => prev,
})
const changePlan = useMutation({
mutationFn: ({ id, plan }: { id: string; plan: string }) => adminAPI.changePlan(id, plan),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'users'] })
toast.success('Plan updated')
setChangePlanUserId(null)
},
onError: (err) => toast.error(getApiError(err)),
})
const suspend = useMutation({
mutationFn: (user: AdminUser) =>
user.is_suspended ? adminAPI.unsuspendUser(user.id) : adminAPI.suspendUser(user.id),
onSuccess: (_, user) => {
qc.invalidateQueries({ queryKey: ['admin', 'users'] })
toast.success(user.is_suspended ? 'User unsuspended' : 'User suspended')
},
onError: (err) => toast.error(getApiError(err)),
})
const deleteUser = useMutation({
mutationFn: (id: string) => adminAPI.deleteUser(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'users'] })
toast.success('User deleted')
},
onError: (err) => toast.error(getApiError(err)),
})
const users = data?.users ?? []
const total = data?.total ?? 0
const totalPages = Math.ceil(total / LIMIT)
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="mb-6 flex items-center justify-between gap-4 flex-wrap">
<div>
<h1 className="text-2xl font-bold text-white">Users</h1>
<p className="text-gray-400 text-sm mt-1">{total} total users</p>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
className="pl-9 pr-4 py-2 bg-gray-900 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:border-gray-500 w-64"
placeholder="Search by email..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
/>
</div>
</div>
{isLoading ? (
<SkeletonTable rows={8} />
) : (
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-800">
<th className="text-left px-4 py-3 text-gray-400 font-medium">Email</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">Plan</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">Chatbots</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">Conversations</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">Status</th>
<th className="text-right px-4 py-3 text-gray-400 font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{users.map((user) => (
<tr key={user.id} className="hover:bg-gray-800/50 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<span className="text-gray-200">{user.email}</span>
{user.is_admin && <Shield className="w-3 h-3 text-red-400" title="Admin" />}
</div>
{user.company_name && (
<span className="text-gray-500 text-xs">{user.company_name}</span>
)}
</td>
<td className="px-4 py-3">
{changePlanUserId === user.id ? (
<div className="flex items-center gap-1">
<select
className="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-200 focus:outline-none"
value={selectedPlan}
onChange={(e) => setSelectedPlan(e.target.value)}
>
{PLANS.map((p) => (
<option key={p} value={p}>{p}</option>
))}
</select>
<button
onClick={() => changePlan.mutate({ id: user.id, plan: selectedPlan })}
className="text-xs text-green-400 hover:text-green-300 px-1"
>Save</button>
<button
onClick={() => setChangePlanUserId(null)}
className="text-xs text-gray-500 hover:text-gray-300 px-1"
></button>
</div>
) : (
<PlanBadge plan={user.plan} />
)}
</td>
<td className="px-4 py-3 text-gray-400">{user.chatbot_count}</td>
<td className="px-4 py-3 text-gray-400">{user.conversation_count}</td>
<td className="px-4 py-3">
{user.is_suspended ? (
<span className="text-xs text-red-400 bg-red-900/30 px-2 py-0.5 rounded-full">Suspended</span>
) : (
<span className="text-xs text-green-400 bg-green-900/30 px-2 py-0.5 rounded-full">Active</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1 justify-end">
<button
title="Change plan"
onClick={() => { setChangePlanUserId(user.id); setSelectedPlan(user.plan) }}
className="p-1.5 text-gray-400 hover:text-blue-400 hover:bg-blue-900/20 rounded transition-colors"
>
<CreditCard className="w-3.5 h-3.5" />
</button>
<button
title={user.is_suspended ? 'Unsuspend' : 'Suspend'}
onClick={() => suspend.mutate(user)}
className="p-1.5 text-gray-400 hover:text-yellow-400 hover:bg-yellow-900/20 rounded transition-colors"
>
<Ban className="w-3.5 h-3.5" />
</button>
{!user.is_admin && (
<button
title="Delete user"
onClick={() => {
if (confirm(`Delete ${user.email}? This is irreversible.`)) {
deleteUser.mutate(user.id)
}
}}
className="p-1.5 text-gray-400 hover:text-red-400 hover:bg-red-900/20 rounded transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</td>
</tr>
))}
{users.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-gray-500">No users found</td>
</tr>
)}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-4">
<button
disabled={page === 1}
onClick={() => setPage((p) => p - 1)}
className="p-2 text-gray-400 disabled:opacity-30 hover:text-white transition-colors"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-gray-400 text-sm">{page} / {totalPages}</span>
<button
disabled={page === totalPages}
onClick={() => setPage((p) => p + 1)}
className="p-2 text-gray-400 disabled:opacity-30 hover:text-white transition-colors"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
)}
</div>
)
}

View File

@@ -3,7 +3,9 @@ import { useAuthStore } from '@/store/authStore'
import type { import type {
AuthResponse, Chatbot, ChatbotPublic, ChatResponse, AuthResponse, Chatbot, ChatbotPublic, ChatResponse,
Document, MarketplaceResponse, Subscription, Document, MarketplaceResponse, Subscription,
ModelsResponse, Lead, UrlSource, InboxConversation, ChannelConnection ModelsResponse, Lead, UrlSource, InboxConversation, ChannelConnection,
AdminStats, AdminUser, AdminChatbot, AdminConversation, AdminSystemHealth,
Appointment, BusinessHoursEntry, TimeSlot, Campaign,
} from '@/types' } from '@/types'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000' const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
@@ -196,6 +198,9 @@ export const leadsAPI = {
list: (params?: { chatbot_id?: string; page?: number; limit?: number }) => list: (params?: { chatbot_id?: string; page?: number; limit?: number }) =>
api.get<Lead[]>('/leads', { params }).then(r => r.data), api.get<Lead[]>('/leads', { params }).then(r => r.data),
update: (id: string, data: { status?: string; notes?: string }) =>
api.patch<Lead>(`/leads/${id}`, data).then(r => r.data),
submit: (chatbotId: string, data: { email?: string; name?: string; phone?: string; company?: string; conversation_id?: string }) => submit: (chatbotId: string, data: { email?: string; name?: string; phone?: string; company?: string; conversation_id?: string }) =>
api.post<Lead>(`/chatbots/${chatbotId}/leads`, data).then(r => r.data), api.post<Lead>(`/chatbots/${chatbotId}/leads`, data).then(r => r.data),
@@ -208,16 +213,104 @@ export const leadsAPI = {
// ─── Inbox ──────────────────────────────────────────────────────────────────── // ─── Inbox ────────────────────────────────────────────────────────────────────
export const inboxAPI = { export const inboxAPI = {
conversations: (params?: { chatbot_id?: string; page?: number; limit?: number }) => conversations: (params?: { chatbot_id?: string; status?: string; page?: number; limit?: number }) =>
api.get<InboxConversation[]>('/inbox/conversations', { params }).then(r => r.data), api.get<InboxConversation[]>('/inbox/conversations', { params }).then(r => r.data),
conversation: (id: string) => conversation: (id: string) =>
api.get(`/inbox/conversations/${id}`).then(r => r.data), api.get(`/inbox/conversations/${id}`).then(r => r.data),
updateStatus: (id: string, status: string) =>
api.patch(`/inbox/conversations/${id}/status`, { status }).then(r => r.data),
reply: (id: string, message: string) =>
api.post(`/inbox/conversations/${id}/reply`, { message }).then(r => r.data),
deleteConversation: (id: string) => deleteConversation: (id: string) =>
api.delete(`/inbox/conversations/${id}`).then(r => r.data), api.delete(`/inbox/conversations/${id}`).then(r => r.data),
} }
// ─── Appointments ─────────────────────────────────────────────────────────────
export const appointmentsAPI = {
list: (params?: { chatbot_id?: string; status?: string; page?: number }) =>
api.get<Appointment[]>('/appointments', { params }).then(r => r.data),
updateStatus: (id: string, status: string) =>
api.patch<Appointment>(`/appointments/${id}`, { status }).then(r => r.data),
getHours: (chatbotId: string) =>
api.get<BusinessHoursEntry[]>(`/appointments/chatbot/${chatbotId}/hours`).then(r => r.data),
saveHours: (chatbotId: string, hours: BusinessHoursEntry[]) =>
api.put(`/appointments/chatbot/${chatbotId}/hours`, { hours }).then(r => r.data),
getBookingInfo: (chatbotId: string) =>
api.get<{ chatbot_id: string; chatbot_name: string; company_name: string }>(`/chatbots/${chatbotId}/booking-info`).then(r => r.data),
getAvailableSlots: (chatbotId: string, date: string) =>
api.get<{ date: string; slots: TimeSlot[] }>(`/chatbots/${chatbotId}/available-slots`, { params: { date } }).then(r => r.data),
book: (chatbotId: string, data: {
customer_name: string
customer_contact: string
service?: string
slot_start: string
notes?: string
conversation_id?: string
}) =>
api.post<Appointment>(`/chatbots/${chatbotId}/appointments`, data).then(r => r.data),
}
// ─── Campaigns ────────────────────────────────────────────────────────────────
export const campaignsAPI = {
list: (params?: { chatbot_id?: string; page?: number }) =>
api.get<Campaign[]>('/campaigns', { params }).then(r => r.data),
create: (data: { chatbot_id: string; title: string; message: string }) =>
api.post<Campaign>('/campaigns', data).then(r => r.data),
send: (id: string) =>
api.post<Campaign>(`/campaigns/${id}/send`).then(r => r.data),
delete: (id: string) =>
api.delete(`/campaigns/${id}`).then(r => r.data),
}
// ─── Admin ────────────────────────────────────────────────────────────────────
export const adminAPI = {
stats: () =>
api.get<AdminStats>('/admin/stats').then(r => r.data),
users: (params?: { search?: string; page?: number; limit?: number }) =>
api.get<{ users: AdminUser[]; total: number }>('/admin/users', { params }).then(r => r.data),
user: (id: string) =>
api.get<AdminUser>(`/admin/users/${id}`).then(r => r.data),
changePlan: (id: string, plan: string, reason?: string) =>
api.patch(`/admin/users/${id}/plan`, { plan, reason }).then(r => r.data),
suspendUser: (id: string, reason?: string) =>
api.post(`/admin/users/${id}/suspend`, { reason }).then(r => r.data),
unsuspendUser: (id: string) =>
api.post(`/admin/users/${id}/unsuspend`).then(r => r.data),
deleteUser: (id: string) =>
api.delete(`/admin/users/${id}`).then(r => r.data),
chatbots: (params?: { search?: string; page?: number; limit?: number }) =>
api.get<{ chatbots: AdminChatbot[]; total: number }>('/admin/chatbots', { params }).then(r => r.data),
deleteChatbot: (id: string) =>
api.delete(`/admin/chatbots/${id}`).then(r => r.data),
conversations: (params?: { page?: number; limit?: number }) =>
api.get<{ conversations: AdminConversation[]; total: number }>('/admin/conversations', { params }).then(r => r.data),
health: () =>
api.get<AdminSystemHealth>('/admin/system/health').then(r => r.data),
}
// ─── Channels ───────────────────────────────────────────────────────────────── // ─── Channels ─────────────────────────────────────────────────────────────────
export const channelsAPI = { export const channelsAPI = {
list: (chatbotId: string) => list: (chatbotId: string) =>
@@ -226,9 +319,6 @@ export const channelsAPI = {
connectTelegram: (chatbotId: string, botToken: string) => connectTelegram: (chatbotId: string, botToken: string) =>
api.post('/channels/telegram', { chatbot_id: chatbotId, bot_token: botToken }).then(r => r.data), api.post('/channels/telegram', { chatbot_id: chatbotId, bot_token: botToken }).then(r => r.data),
connectWhatsapp: (chatbotId: string, waKeyword?: string) =>
api.post('/channels/whatsapp', { chatbot_id: chatbotId, wa_keyword: waKeyword || null }).then(r => r.data),
disconnect: (connectionId: string) => disconnect: (connectionId: string) =>
api.delete(`/channels/${connectionId}`).then(r => r.data), api.delete(`/channels/${connectionId}`).then(r => r.data),
} }

32
src/store/themeStore.ts Normal file
View File

@@ -0,0 +1,32 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface ThemeState {
isDark: boolean
toggle: () => void
}
export const useThemeStore = create<ThemeState>()(
persist(
(set) => ({
isDark: false,
toggle: () => set((s) => {
const next = !s.isDark
document.documentElement.classList.toggle('dark', next)
return { isDark: next }
}),
}),
{ name: 'theme' }
)
)
/** Call once at app startup to apply persisted theme class */
export function initTheme() {
const raw = localStorage.getItem('theme')
if (raw) {
try {
const parsed = JSON.parse(raw) as { state?: { isDark?: boolean } }
document.documentElement.classList.toggle('dark', !!parsed.state?.isDark)
} catch { /* ignore */ }
}
}

View File

@@ -6,6 +6,7 @@ export interface User {
company_name?: string company_name?: string
plan: 'free' | 'starter' | 'business' | 'agency' | 'enterprise' plan: 'free' | 'starter' | 'business' | 'agency' | 'enterprise'
created_at?: string created_at?: string
is_admin?: boolean
} }
export interface AuthResponse { export interface AuthResponse {
@@ -46,6 +47,7 @@ export interface Chatbot {
handoff_message: string handoff_message: string
handoff_email?: string handoff_email?: string
handoff_keywords: string[] handoff_keywords: string[]
booking_enabled: boolean
} }
export interface ChatbotPublic { export interface ChatbotPublic {
@@ -87,6 +89,7 @@ export interface ChatbotFormData {
handoff_message: string handoff_message: string
handoff_email: string handoff_email: string
handoff_keywords: string[] handoff_keywords: string[]
booking_enabled: boolean
} }
// ─── Document ───────────────────────────────────────────────────────────────── // ─── Document ─────────────────────────────────────────────────────────────────
@@ -196,6 +199,8 @@ export interface ModelsResponse {
} }
// ─── Leads ──────────────────────────────────────────────────────────────────── // ─── Leads ────────────────────────────────────────────────────────────────────
export type LeadStatus = 'new' | 'contacted' | 'qualified' | 'closed' | 'lost'
export interface Lead { export interface Lead {
id: string id: string
chatbot_id: string chatbot_id: string
@@ -204,6 +209,8 @@ export interface Lead {
name?: string name?: string
phone?: string phone?: string
company?: string company?: string
status: LeadStatus
notes?: string
created_at?: string created_at?: string
} }
@@ -220,6 +227,8 @@ export interface UrlSource {
} }
// ─── Inbox ──────────────────────────────────────────────────────────────────── // ─── Inbox ────────────────────────────────────────────────────────────────────
export type ConversationStatus = 'open' | 'agent_handling' | 'resolved'
export interface InboxConversation { export interface InboxConversation {
id: string id: string
chatbot_id: string chatbot_id: string
@@ -228,12 +237,14 @@ export interface InboxConversation {
language: string language: string
message_count: number message_count: number
first_message?: string first_message?: string
status: ConversationStatus
last_agent_reply_at?: string
created_at?: string created_at?: string
} }
export interface InboxMessage { export interface InboxMessage {
id: string id: string
role: 'user' | 'assistant' role: 'user' | 'assistant' | 'agent'
content: string content: string
sources?: SourceDocument[] sources?: SourceDocument[]
confidence_score?: number confidence_score?: number
@@ -241,17 +252,107 @@ export interface InboxMessage {
created_at?: string created_at?: string
} }
// ─── Appointments ─────────────────────────────────────────────────────────────
export interface BusinessHoursEntry {
day_of_week: number // 0=Mon, 6=Sun
is_open: boolean
open_time: string // HH:MM
close_time: string
slot_duration_minutes: number
}
export interface TimeSlot {
slot_start: string
slot_end: string
}
export interface Appointment {
id: string
chatbot_id: string
conversation_id?: string
customer_name: string
customer_contact: string
service?: string
slot_start: string
slot_end: string
status: 'pending' | 'confirmed' | 'cancelled' | 'completed'
notes?: string
created_at?: string
}
// ─── Campaigns ────────────────────────────────────────────────────────────────
export interface Campaign {
id: string
chatbot_id: string
title: string
message: string
status: 'draft' | 'sending' | 'sent' | 'failed'
recipients_count: number
sent_count: number
created_at?: string
sent_at?: string
}
// ─── Channels ───────────────────────────────────────────────────────────────── // ─── Channels ─────────────────────────────────────────────────────────────────
export interface ChannelConnection { export interface ChannelConnection {
id: string id: string
channel: 'telegram' | 'whatsapp' channel: 'telegram'
bot_username?: string bot_username?: string
wa_keyword?: string
wa_link?: string
is_active: boolean is_active: boolean
created_at?: string created_at?: string
} }
// ─── Admin ────────────────────────────────────────────────────────────────────
export interface AdminStats {
total_users: number
total_chatbots: number
total_published_chatbots: number
total_conversations: number
total_messages: number
active_subscriptions: Record<string, number>
}
export interface AdminUser {
id: string
email: string
company_name?: string
plan: string
is_admin: boolean
is_suspended: boolean
suspended_at?: string
chatbot_count: number
conversation_count: number
created_at?: string
}
export interface AdminChatbot {
id: string
name: string
owner_email: string
owner_id: string
is_published: boolean
document_count: number
conversation_count: number
created_at?: string
}
export interface AdminConversation {
id: string
chatbot_id: string
chatbot_name: string
owner_email: string
message_count: number
language: string
created_at?: string
}
export interface AdminSystemHealth {
db: string
qdrant: string
llm_providers: Record<string, boolean>
timestamp: string
}
// ─── Templates ──────────────────────────────────────────────────────────────── // ─── Templates ────────────────────────────────────────────────────────────────
export interface ChatbotTemplate { export interface ChatbotTemplate {
id: string id: string

View File

@@ -5,6 +5,7 @@ export default {
'./index.html', './index.html',
'./src/**/*.{js,ts,jsx,tsx}', './src/**/*.{js,ts,jsx,tsx}',
], ],
darkMode: 'class',
theme: { theme: {
extend: { extend: {
colors: { colors: {