mirror of
http://88.130.71.182:3000/BlitTech/contexta_fe.git
synced 2026-06-13 08:21:22 +00:00
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:
93
src/App.tsx
93
src/App.tsx
@@ -2,7 +2,9 @@ import React, { lazy, Suspense } from 'react'
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { AppLayout } from '@/components/Layout'
|
||||
import { AdminLayout } from '@/components/AdminLayout'
|
||||
import { PublicLayout } from '@/components/PublicLayout'
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary'
|
||||
import { Spinner } from '@/components/ui'
|
||||
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 InboxPage = lazy(() => import('@/pages/InboxPage').then(m => ({ default: m.InboxPage })))
|
||||
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 = () => (
|
||||
<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>
|
||||
}
|
||||
|
||||
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 { isAuthenticated } = useAuthStore()
|
||||
if (isAuthenticated) return <Navigate to="/dashboard" replace />
|
||||
@@ -50,38 +69,52 @@ const SmartPublicRoute: React.FC<{ children: React.ReactNode }> = ({ children })
|
||||
}
|
||||
|
||||
export const App: React.FC = () => (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Routes>
|
||||
{/* Public - Landing has its own nav */}
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Routes>
|
||||
{/* Public - Landing has its own nav */}
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
|
||||
{/* Public pages - wrapped in SmartPublicRoute for proper nav */}
|
||||
<Route path="/pricing" element={<SmartPublicRoute><PricingPage /></SmartPublicRoute>} />
|
||||
<Route path="/marketplace" element={<SmartPublicRoute><MarketplacePage /></SmartPublicRoute>} />
|
||||
<Route path="/marketplace/:id" element={<SmartPublicRoute><ChatbotDetailPage /></SmartPublicRoute>} />
|
||||
{/* Public pages - wrapped in SmartPublicRoute for proper nav */}
|
||||
<Route path="/pricing" element={<SmartPublicRoute><PricingPage /></SmartPublicRoute>} />
|
||||
<Route path="/marketplace" element={<SmartPublicRoute><MarketplacePage /></SmartPublicRoute>} />
|
||||
<Route path="/marketplace/:id" element={<SmartPublicRoute><ChatbotDetailPage /></SmartPublicRoute>} />
|
||||
|
||||
{/* Public chat - no auth, no layout */}
|
||||
<Route path="/chat/:id" element={<PublicChatPage />} />
|
||||
{/* Public chat - no auth, no layout */}
|
||||
<Route path="/chat/:id" element={<PublicChatPage />} />
|
||||
|
||||
{/* Auth */}
|
||||
<Route path="/login" element={<PublicOnlyRoute><LoginPage /></PublicOnlyRoute>} />
|
||||
<Route path="/signup" element={<PublicOnlyRoute><SignupPage /></PublicOnlyRoute>} />
|
||||
<Route path="/forgot-password" element={<PublicOnlyRoute><ForgotPasswordPage /></PublicOnlyRoute>} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
{/* Public booking - no auth, no layout */}
|
||||
<Route path="/book/:chatbotId" element={<PublicBookingPage />} />
|
||||
|
||||
{/* Protected */}
|
||||
<Route path="/dashboard" element={<PrivateRoute><DashboardPage /></PrivateRoute>} />
|
||||
<Route path="/analytics" element={<PrivateRoute><AnalyticsPage /></PrivateRoute>} />
|
||||
<Route path="/chatbots/new" element={<PrivateRoute><ChatbotBuilderPage /></PrivateRoute>} />
|
||||
<Route path="/chatbots/:id/edit" element={<PrivateRoute><ChatbotBuilderPage /></PrivateRoute>} />
|
||||
<Route path="/chatbots/:id/preview" element={<PrivateRoute><ChatbotBuilderPage /></PrivateRoute>} />
|
||||
<Route path="/inbox" element={<PrivateRoute><InboxPage /></PrivateRoute>} />
|
||||
<Route path="/leads" element={<PrivateRoute><LeadsPage /></PrivateRoute>} />
|
||||
<Route path="/settings" element={<PrivateRoute><SettingsPage /></PrivateRoute>} />
|
||||
<Route path="/settings/billing" element={<PrivateRoute><SettingsPage /></PrivateRoute>} />
|
||||
{/* Auth */}
|
||||
<Route path="/login" element={<PublicOnlyRoute><LoginPage /></PublicOnlyRoute>} />
|
||||
<Route path="/signup" element={<PublicOnlyRoute><SignupPage /></PublicOnlyRoute>} />
|
||||
<Route path="/forgot-password" element={<PublicOnlyRoute><ForgotPasswordPage /></PublicOnlyRoute>} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
|
||||
{/* Fallback */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
)
|
||||
{/* Protected */}
|
||||
<Route path="/dashboard" element={<PrivateRoute><DashboardPage /></PrivateRoute>} />
|
||||
<Route path="/analytics" element={<PrivateRoute><AnalyticsPage /></PrivateRoute>} />
|
||||
<Route path="/chatbots/new" element={<PrivateRoute><ChatbotBuilderPage /></PrivateRoute>} />
|
||||
<Route path="/chatbots/:id/edit" element={<PrivateRoute><ChatbotBuilderPage /></PrivateRoute>} />
|
||||
<Route path="/chatbots/:id/preview" element={<PrivateRoute><ChatbotBuilderPage /></PrivateRoute>} />
|
||||
<Route path="/inbox" element={<PrivateRoute><InboxPage /></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/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 */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
139
src/components/AdminLayout.tsx
Normal file
139
src/components/AdminLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
45
src/components/ErrorBoundary.tsx
Normal file
45
src/components/ErrorBoundary.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,16 @@ import { authAPI } from '@/services/api'
|
||||
import { getPlanColor } from '@/lib/utils'
|
||||
import {
|
||||
LayoutDashboard, ShoppingBag, Settings,
|
||||
LogOut, Menu, Sparkles, BarChart3, Mail, Users
|
||||
LogOut, Menu, Sparkles, BarChart3, Mail, Users,
|
||||
Shield, X, CalendarDays, Megaphone,
|
||||
} from 'lucide-react'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||
{ label: 'Inbox', href: '/inbox', icon: Mail },
|
||||
{ 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: 'Marketplace', href: '/marketplace', icon: ShoppingBag },
|
||||
{ label: 'Settings', href: '/settings', icon: Settings },
|
||||
@@ -25,101 +28,131 @@ export const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children })
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
|
||||
const handleLogout = async () => {
|
||||
try { await authAPI.logout() } catch { /* intentionally ignored */ }
|
||||
try { await authAPI.logout() } catch { /* ignore */ }
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-50 overflow-hidden">
|
||||
{/* Mobile backdrop */}
|
||||
{sidebarOpen && (
|
||||
<div className="fixed inset-0 z-20 bg-black/40 lg:hidden" onClick={() => setSidebarOpen(false)} />
|
||||
)}
|
||||
const initial = user?.email?.charAt(0).toUpperCase() || '?'
|
||||
|
||||
{/* Sidebar */}
|
||||
<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',
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||
)}>
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-2 px-6 py-5 border-b border-gray-100">
|
||||
<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">
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-50 overflow-hidden">
|
||||
{/* Mobile backdrop */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-20 bg-black/30 backdrop-blur-[2px] lg:hidden transition-opacity"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className={cn(
|
||||
'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'
|
||||
)}>
|
||||
{/* Logo */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-50">
|
||||
<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" />
|
||||
</div>
|
||||
<span className="font-bold text-gray-900 text-lg tracking-tight">Contexta</span>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
|
||||
{NAV_ITEMS.map(({ label, href, icon: Icon }) => {
|
||||
const active = location.pathname.startsWith(href)
|
||||
return (
|
||||
<Link
|
||||
key={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)}
|
||||
>
|
||||
<Icon className={cn('w-4 h-4 transition-transform duration-150', active && 'scale-110')} />
|
||||
{label}
|
||||
{active && <span className="ml-auto w-1.5 h-1.5 rounded-full bg-primary-500" />}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User profile */}
|
||||
<div className="px-4 py-4 border-t border-gray-100 bg-gray-50/50">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<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">
|
||||
{user?.email?.charAt(0).toUpperCase() || '?'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm 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'))}>
|
||||
{(user?.plan || 'free').charAt(0).toUpperCase() + (user?.plan || 'free').slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<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-white border-b border-gray-200">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="p-1.5 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<Menu className="w-5 h-5 text-gray-600" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 bg-primary-600 rounded-md flex items-center justify-center">
|
||||
<Sparkles className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
<span className="font-bold text-gray-900 text-sm">Contexta</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
{children}
|
||||
</main>
|
||||
<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 className="flex-1 px-3 py-3 space-y-0.5 overflow-y-auto">
|
||||
{NAV_ITEMS.map(({ label, href, icon: Icon }) => {
|
||||
const active = location.pathname.startsWith(href)
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
to={href}
|
||||
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 shrink-0 transition-transform duration-150',
|
||||
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>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User section */}
|
||||
<div className="px-3 py-3 border-t border-gray-50 space-y-1">
|
||||
{user?.is_admin && (
|
||||
<Link
|
||||
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 className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-gray-900 truncate">{user?.email}</p>
|
||||
<span className={cn('text-xs px-1.5 py-0.5 rounded-full font-medium capitalize', getPlanColor(user?.plan || 'free'))}>
|
||||
{user?.plan || 'free'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
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" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||
{/* Mobile header */}
|
||||
<header className="lg:hidden flex items-center gap-3 px-4 py-3 bg-white border-b border-gray-100 shrink-0">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
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" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<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" />
|
||||
</div>
|
||||
<span className="font-bold text-gray-900 text-sm">Contexta</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
65
src/components/Skeletons.tsx
Normal file
65
src/components/Skeletons.tsx
Normal 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>
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react'
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
// ─── Button ────────────────────────────────────────────────────────────────────
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
@@ -11,24 +12,48 @@ interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
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 = {
|
||||
primary: 'bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500',
|
||||
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-400',
|
||||
outline: 'border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 focus:ring-primary-500',
|
||||
ghost: 'text-gray-600 hover:bg-gray-100 focus:ring-gray-400',
|
||||
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
|
||||
primary: cn(
|
||||
'bg-gradient-to-b from-primary-500 to-primary-600 text-white',
|
||||
'hover:from-primary-600 hover:to-primary-700',
|
||||
'focus-visible:ring-primary-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 (
|
||||
<button
|
||||
className={cn(base, variants[variant], sizes[size], className)}
|
||||
disabled={disabled || loading}
|
||||
aria-busy={loading}
|
||||
{...props}
|
||||
>
|
||||
{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" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>
|
||||
@@ -43,9 +68,10 @@ interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string
|
||||
error?: 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, '-')
|
||||
return (
|
||||
<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>
|
||||
)}
|
||||
<input
|
||||
id={inputId}
|
||||
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',
|
||||
error ? 'border-red-400 bg-red-50' : 'border-gray-300 bg-white hover:border-gray-400',
|
||||
className
|
||||
<div className="relative">
|
||||
{icon && (
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||
{hint && !error && <p className="text-xs text-gray-500">{hint}</p>}
|
||||
<input
|
||||
id={inputId}
|
||||
className={cn(
|
||||
'w-full py-2.5 border rounded-xl text-sm',
|
||||
'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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -80,18 +120,27 @@ export const Textarea: React.FC<TextareaProps> = ({ label, error, hint, classNam
|
||||
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
|
||||
return (
|
||||
<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
|
||||
id={inputId}
|
||||
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',
|
||||
error ? 'border-red-400 bg-red-50' : 'border-gray-300 bg-white hover:border-gray-400',
|
||||
'w-full px-3.5 py-2.5 border rounded-xl text-sm resize-none',
|
||||
'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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||
{hint && !error && <p className="text-xs text-gray-500">{hint}</p>}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
{hint && !error && <p className="text-xs text-gray-400">{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -107,12 +156,18 @@ export const Select: React.FC<SelectProps> = ({ label, error, options, className
|
||||
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
|
||||
return (
|
||||
<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
|
||||
id={inputId}
|
||||
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',
|
||||
error ? 'border-red-400' : 'border-gray-300',
|
||||
'w-full px-3.5 py-2.5 border rounded-xl text-sm bg-white',
|
||||
'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
|
||||
)}
|
||||
{...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>
|
||||
))}
|
||||
</select>
|
||||
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Card ──────────────────────────────────────────────────────────────────────
|
||||
export const Card: React.FC<{ children: React.ReactNode; className?: string; onClick?: () => void }> = ({
|
||||
children, className, onClick
|
||||
}) => (
|
||||
export const Card: React.FC<{
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
}> = ({ children, className, onClick }) => (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-white rounded-xl border border-gray-200 shadow-sm transition-all duration-200',
|
||||
onClick && 'cursor-pointer hover:-translate-y-1 hover:shadow-lg hover:border-gray-300',
|
||||
'bg-white rounded-2xl border border-gray-100 shadow-sm transition-all duration-200',
|
||||
onClick && 'cursor-pointer hover:-translate-y-0.5 hover:shadow-md hover:border-gray-200',
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
@@ -151,38 +208,50 @@ interface BadgeProps {
|
||||
|
||||
export const Badge: React.FC<BadgeProps> = ({ children, variant = 'default', className }) => {
|
||||
const variants = {
|
||||
default: 'bg-gray-100 text-gray-700',
|
||||
success: 'bg-green-100 text-green-700',
|
||||
warning: 'bg-yellow-100 text-yellow-700',
|
||||
error: 'bg-red-100 text-red-700',
|
||||
info: 'bg-blue-100 text-blue-700',
|
||||
purple: 'bg-purple-100 text-purple-700',
|
||||
default: 'bg-gray-100 text-gray-600 border-gray-200',
|
||||
success: 'bg-green-50 text-green-700 border-green-200',
|
||||
warning: 'bg-amber-50 text-amber-700 border-amber-200',
|
||||
error: 'bg-red-50 text-red-600 border-red-200',
|
||||
info: 'bg-blue-50 text-blue-700 border-blue-200',
|
||||
purple: 'bg-purple-50 text-purple-700 border-purple-200',
|
||||
}
|
||||
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}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Spinner ───────────────────────────────────────────────────────────────────
|
||||
export const Spinner: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg className={cn('animate-spin h-5 w-5', className)} viewBox="0 0 24 24" fill="none">
|
||||
<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" />
|
||||
</svg>
|
||||
)
|
||||
export const Spinner: React.FC<{ className?: string; size?: 'sm' | 'md' | 'lg' }> = ({
|
||||
className, size = 'md'
|
||||
}) => {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Empty State ───────────────────────────────────────────────────────────────
|
||||
export const EmptyState: React.FC<{
|
||||
icon: React.ReactNode; title: string; description: string; action?: React.ReactNode
|
||||
}> = ({ icon, title, description, action }) => (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center animate-fade-in-up">
|
||||
<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">
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
description: string
|
||||
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}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
|
||||
<p className="text-sm text-gray-500 max-w-sm mb-6">{description}</p>
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-1.5">{title}</h3>
|
||||
<p className="text-sm text-gray-500 max-w-xs mb-6 leading-relaxed">{description}</p>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
@@ -194,21 +263,43 @@ interface ModalProps {
|
||||
title?: string
|
||||
children: React.ReactNode
|
||||
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
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 animate-fade-in">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className={cn('relative bg-white rounded-2xl shadow-2xl w-full max-w-lg animate-scale-in', className)}>
|
||||
<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/40 backdrop-blur-[2px]" onClick={onClose} />
|
||||
<div className={cn(
|
||||
'relative bg-white w-full sm:rounded-2xl shadow-2xl animate-scale-in',
|
||||
'rounded-t-2xl',
|
||||
sizes[size],
|
||||
className
|
||||
)}>
|
||||
{title && (
|
||||
<div className="flex items-center justify-between p-6 border-b">
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded-lg text-gray-500">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||||
<h2 className="text-base font-semibold text-gray-900">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
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"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</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 }
|
||||
|
||||
export const Toast: React.FC<ToastProps> = ({ message, type = 'info', onClose }) => {
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
const t = setTimeout(onClose, 4000)
|
||||
return () => clearTimeout(t)
|
||||
}, [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',
|
||||
}
|
||||
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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -244,12 +341,42 @@ export const Toast: React.FC<ToastProps> = ({ message, type = 'info', onClose })
|
||||
export const StatusDot: React.FC<{ status: string }> = ({ status }) => {
|
||||
const colors: Record<string, string> = {
|
||||
completed: 'bg-green-500',
|
||||
processing: 'bg-yellow-500 animate-pulse',
|
||||
pending: 'bg-gray-400',
|
||||
processing: 'bg-amber-400 animate-pulse',
|
||||
pending: 'bg-gray-300',
|
||||
failed: 'bg-red-500',
|
||||
active: '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>
|
||||
)
|
||||
|
||||
92
src/contexts/ToastContext.tsx
Normal file
92
src/contexts/ToastContext.tsx
Normal 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
|
||||
}
|
||||
142
src/index.css
142
src/index.css
@@ -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 components;
|
||||
@tailwind utilities;
|
||||
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -15,38 +14,51 @@
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
scroll-behavior: smooth;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900;
|
||||
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
/* Selection */
|
||||
::selection {
|
||||
@apply bg-primary-100 text-primary-900;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-transparent;
|
||||
/* Focus visible - keyboard nav only */
|
||||
:focus-visible {
|
||||
outline: 2px solid theme('colors.primary.500');
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-300 rounded-full;
|
||||
}
|
||||
/* Scrollbar */
|
||||
::-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 {
|
||||
@apply bg-gray-400;
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-feature-settings: 'ss01';
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Line clamp */
|
||||
.line-clamp-1 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
@@ -54,17 +66,17 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Animation utilities */
|
||||
.animate-in {
|
||||
animation-fill-mode: both;
|
||||
animation-duration: 200ms;
|
||||
}
|
||||
.slide-in-from-bottom-2 { animation-name: slideInFromBottom; }
|
||||
|
||||
.slide-in-from-bottom-2 {
|
||||
animation-name: slideInFromBottom;
|
||||
}
|
||||
|
||||
/* Staggered animation delay utilities */
|
||||
/* Staggered delays */
|
||||
.delay-75 { animation-delay: 75ms; }
|
||||
.delay-100 { animation-delay: 100ms; }
|
||||
.delay-150 { animation-delay: 150ms; }
|
||||
.delay-200 { animation-delay: 200ms; }
|
||||
.delay-300 { animation-delay: 300ms; }
|
||||
.delay-400 { animation-delay: 400ms; }
|
||||
@@ -73,70 +85,96 @@
|
||||
.delay-700 { animation-delay: 700ms; }
|
||||
.delay-800 { animation-delay: 800ms; }
|
||||
|
||||
/* Gradient text utility */
|
||||
/* Gradient text */
|
||||
.text-gradient {
|
||||
@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%;
|
||||
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 {
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(16px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.glass-dark {
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(16px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||
background: rgba(17, 24, 39, 0.8);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Glow effects */
|
||||
.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 {
|
||||
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 {
|
||||
content: '';
|
||||
position: absolute;
|
||||
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;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Grid background pattern */
|
||||
.bg-grid {
|
||||
background-image:
|
||||
linear-gradient(rgba(79, 70, 229, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(79, 70, 229, 0.03) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
/* Gradient borders */
|
||||
.border-gradient {
|
||||
position: relative;
|
||||
background: white;
|
||||
background-clip: padding-box;
|
||||
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 */
|
||||
.bg-dots {
|
||||
background-image: radial-gradient(circle, rgba(79, 70, 229, 0.08) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
/* Smooth number transition */
|
||||
.tabular-nums {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInFromBottom {
|
||||
from {
|
||||
transform: translateY(8px);
|
||||
opacity: 0;
|
||||
from { transform: translateY(10px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
13
src/lib/apiError.ts
Normal file
13
src/lib/apiError.ts
Normal 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'
|
||||
}
|
||||
10
src/main.tsx
10
src/main.tsx
@@ -3,8 +3,12 @@ import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { App } from './App'
|
||||
import { ToastProvider } from '@/contexts/ToastContext'
|
||||
import { initTheme } from '@/store/themeStore'
|
||||
import './index.css'
|
||||
|
||||
initTheme()
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
@@ -18,8 +22,10 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2,12 +2,14 @@ import React, { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { analyticsAPI } from '@/services/api'
|
||||
import { Card, Spinner, Button, Badge } from '@/components/ui'
|
||||
import { Card, Button, Badge } from '@/components/ui'
|
||||
import {
|
||||
BarChart3, Users, MessageSquare, Star,
|
||||
Clock, Globe, Lock, Bot,
|
||||
ChevronDown, ChevronUp
|
||||
ChevronDown, ChevronUp, TrendingUp, AlertCircle, ThumbsUp, ThumbsDown
|
||||
} from 'lucide-react'
|
||||
import { SkeletonStatCard } from '@/components/Skeletons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// ANALYTICS PAGE — Available for Starter and Pro plans
|
||||
@@ -63,8 +65,14 @@ interface OverviewData {
|
||||
|
||||
// ─── Mini bar chart component ─────────────────────────────────────────────────
|
||||
const MiniBarChart: React.FC<{ data: DailyConversation[] }> = ({ data }) => {
|
||||
const [tooltip, setTooltip] = useState<{ date: string; count: number; idx: number } | null>(null)
|
||||
|
||||
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)
|
||||
@@ -81,15 +89,50 @@ const MiniBarChart: React.FC<{ data: DailyConversation[] }> = ({ data }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-[2px] h-16">
|
||||
{days.map((d) => (
|
||||
<div className="relative">
|
||||
{/* 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
|
||||
key={d.date}
|
||||
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)}%` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tooltip */}
|
||||
{tooltip && (
|
||||
<div
|
||||
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"
|
||||
style={{ height: `${Math.max((d.count / max) * 100, d.count > 0 ? 8 : 2)}%` }}
|
||||
title={`${d.date}: ${d.count} conversations`}
|
||||
/>
|
||||
))}
|
||||
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
|
||||
subtitle?: string
|
||||
color?: string
|
||||
}> = ({ label, value, icon, subtitle, color = 'primary' }) => (
|
||||
<Card className="p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">{label}</span>
|
||||
<div className={`w-8 h-8 rounded-lg bg-${color}-50 flex items-center justify-center text-${color}-600`}>
|
||||
trend?: number | null
|
||||
}> = ({ label, value, icon, subtitle, color = 'primary', trend }) => (
|
||||
<Card className="p-5 hover:shadow-md transition-all duration-200">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className={`w-9 h-9 rounded-xl bg-${color}-50 flex items-center justify-center text-${color}-600`}>
|
||||
{icon}
|
||||
</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 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>
|
||||
)
|
||||
|
||||
@@ -122,20 +177,31 @@ const UsageBar: React.FC<{ used: number; limit: number }> = ({ used, limit }) =>
|
||||
|
||||
return (
|
||||
<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={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()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<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'
|
||||
}`}
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -144,139 +210,194 @@ const UsageBar: React.FC<{ used: number; limit: number }> = ({ used, limit }) =>
|
||||
const ChatbotRow: React.FC<{ chatbot: ChatbotAnalytics }> = ({ chatbot }) => {
|
||||
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 (
|
||||
<Card className="overflow-hidden">
|
||||
<Card className="overflow-hidden hover:shadow-md transition-all duration-200">
|
||||
<button
|
||||
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="w-9 h-9 rounded-lg bg-primary-50 flex items-center justify-center">
|
||||
<Bot className="w-4 h-4 text-primary-600" />
|
||||
<div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center flex-shrink-0">
|
||||
<Bot className="w-5 h-5 text-primary-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 text-sm">{chatbot.chatbot_name}</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
{chatbot.total_conversations} conversations · {chatbot.unique_sessions} users
|
||||
<h3 className="font-semibold text-gray-900">{chatbot.chatbot_name}</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
{chatbot.total_conversations.toLocaleString()} conversations · {chatbot.unique_sessions.toLocaleString()} users
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-3 sm:gap-4">
|
||||
{chatbot.average_rating && (
|
||||
<div className="flex items-center gap-1 text-xs text-amber-600">
|
||||
<Star className="w-3 h-3 fill-amber-400" />
|
||||
{chatbot.average_rating.toFixed(1)}
|
||||
<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 text-amber-400" />
|
||||
<span className="font-semibold">{chatbot.average_rating.toFixed(1)}</span>
|
||||
</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
|
||||
</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>
|
||||
</button>
|
||||
|
||||
{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 */}
|
||||
<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>
|
||||
<p className="text-lg font-bold text-gray-900">{chatbot.conversations_today}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-3 border border-gray-100">
|
||||
<p className="text-xs text-gray-500">This week</p>
|
||||
<p className="text-lg font-bold text-gray-900">{chatbot.conversations_this_week}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-3 border border-gray-100">
|
||||
<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>
|
||||
{[
|
||||
{ label: 'Today', value: chatbot.conversations_today },
|
||||
{ label: 'This week', value: chatbot.conversations_this_week },
|
||||
{ label: 'This month', value: chatbot.conversations_this_month },
|
||||
{ label: 'Avg msgs/convo', value: chatbot.average_messages_per_conversation },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="bg-white rounded-xl p-4 border border-gray-100 shadow-sm text-center">
|
||||
<p className="text-xs text-gray-500 mb-1">{label}</p>
|
||||
<p className="text-xl font-bold text-gray-900">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Daily chart */}
|
||||
<div className="bg-white rounded-lg p-3 border border-gray-100">
|
||||
<p className="text-xs font-medium text-gray-500 mb-2">Last 30 days</p>
|
||||
<div className="bg-white rounded-xl p-4 border border-gray-100 shadow-sm">
|
||||
<p className="text-xs font-semibold text-gray-600 mb-3 uppercase tracking-wide">Last 30 days</p>
|
||||
<MiniBarChart data={chatbot.daily_conversations} />
|
||||
</div>
|
||||
|
||||
{/* Top queries & Languages side by side */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{/* Top queries & Languages */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
|
||||
{chatbot.top_queries.length > 0 && (
|
||||
<div className="bg-white rounded-lg p-3 border border-gray-100">
|
||||
<p className="text-xs font-medium text-gray-500 mb-2">Top questions</p>
|
||||
<ul className="space-y-1.5">
|
||||
<div className="bg-white rounded-xl p-4 border border-gray-100 shadow-sm">
|
||||
<p className="text-xs font-semibold text-gray-600 mb-3 uppercase tracking-wide">Top questions</p>
|
||||
<div className="space-y-2">
|
||||
{chatbot.top_queries.slice(0, 5).map((q, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-xs">
|
||||
<span className="text-gray-400 font-mono">{i + 1}.</span>
|
||||
<span className="text-gray-700 flex-1 truncate">{q.query}</span>
|
||||
<span className="text-gray-400">{q.count}×</span>
|
||||
</li>
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<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">
|
||||
{i + 1}
|
||||
</span>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{Object.keys(chatbot.languages_used).length > 0 && (
|
||||
<div className="bg-white rounded-lg p-3 border border-gray-100">
|
||||
<p className="text-xs font-medium text-gray-500 mb-2">Languages</p>
|
||||
<div className="space-y-1.5">
|
||||
<div className="bg-white rounded-xl p-4 border border-gray-100 shadow-sm">
|
||||
<p className="text-xs font-semibold text-gray-600 mb-3 uppercase tracking-wide">Languages</p>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(chatbot.languages_used)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 5)
|
||||
.map(([lang, count]) => (
|
||||
<div key={lang} className="flex items-center gap-2 text-xs">
|
||||
<Globe className="w-3 h-3 text-gray-400" />
|
||||
<span className="text-gray-700 uppercase">{lang}</span>
|
||||
<span className="text-gray-400 ml-auto">{count} convos</span>
|
||||
</div>
|
||||
))}
|
||||
.map(([lang, count]) => {
|
||||
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" />
|
||||
<span className="text-gray-700 font-medium uppercase">{lang}</span>
|
||||
</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>
|
||||
|
||||
{/* Knowledge Gaps */}
|
||||
{/* Knowledge Gaps — Phase 3: actionable suggestions */}
|
||||
{chatbot.unanswered_queries && chatbot.unanswered_queries.length > 0 && (
|
||||
<div className="bg-amber-50 rounded-lg p-3 border border-amber-100">
|
||||
<p className="text-xs font-medium text-amber-700 mb-2">
|
||||
Knowledge Gaps — questions your bot couldn't answer well:
|
||||
<div className="bg-amber-50 rounded-xl p-4 border border-amber-100 space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate(`/chatbots/${chatbot.chatbot_id}/edit`)}
|
||||
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"
|
||||
>
|
||||
+ Add content →
|
||||
</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>
|
||||
<ul className="space-y-1.5">
|
||||
{chatbot.unanswered_queries.slice(0, 5).map((q, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-xs">
|
||||
<span className="text-amber-400 font-mono">{i + 1}.</span>
|
||||
<span className="text-amber-800 flex-1 truncate">{q.query}</span>
|
||||
<span className="text-amber-500">{q.count}×</span>
|
||||
</li>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Feedback */}
|
||||
{(chatbot.feedback_positive > 0 || chatbot.feedback_negative > 0) && (
|
||||
<div className="text-xs text-gray-500 flex items-center gap-3">
|
||||
<span className="font-medium text-gray-600">Feedback:</span>
|
||||
<span className="text-green-600">👍 {chatbot.feedback_positive}</span>
|
||||
<span className="text-red-500">👎 {chatbot.feedback_negative}</span>
|
||||
{(chatbot.feedback_positive + chatbot.feedback_negative) > 0 && (
|
||||
<span className="text-gray-400">
|
||||
({Math.round((chatbot.feedback_positive / (chatbot.feedback_positive + chatbot.feedback_negative)) * 100)}% helpful)
|
||||
</span>
|
||||
</div>
|
||||
{chatbot.unanswered_queries.length > 6 && (
|
||||
<p className="text-xs text-amber-500 text-center">
|
||||
+{chatbot.unanswered_queries.length - 6} more gaps
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chatbot.peak_hour !== null && (
|
||||
<div className="text-xs text-gray-500 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
Peak hour: {chatbot.peak_hour}:00 - {chatbot.peak_hour + 1}:00
|
||||
</div>
|
||||
)}
|
||||
{/* Feedback & Peak hour */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{feedbackTotal > 0 && (
|
||||
<div className="flex items-center gap-3 bg-white rounded-xl px-4 py-3 border border-gray-100 shadow-sm">
|
||||
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Feedback</span>
|
||||
<div className="flex items-center gap-1 text-emerald-600">
|
||||
<ThumbsUp className="w-3.5 h-3.5" />
|
||||
<span className="text-sm font-bold">{chatbot.feedback_positive}</span>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chatbot.peak_hour !== null && (
|
||||
<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.5 h-3.5 text-gray-400" />
|
||||
<span>Peak: <span className="font-semibold text-gray-900">{chatbot.peak_hour}:00 – {chatbot.peak_hour + 1}:00</span></span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
@@ -301,12 +422,12 @@ export const AnalyticsPage: React.FC = () => {
|
||||
if (error && (error as { response?: { status?: number } })?.response?.status === 402) {
|
||||
return (
|
||||
<div className="p-6 max-w-2xl mx-auto">
|
||||
<Card className="p-8 text-center">
|
||||
<div className="w-14 h-14 rounded-2xl bg-primary-50 flex items-center justify-center mx-auto mb-4">
|
||||
<Lock className="w-6 h-6 text-primary-600" />
|
||||
<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">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.
|
||||
</p>
|
||||
<Button onClick={() => navigate('/pricing')}>
|
||||
@@ -320,8 +441,35 @@ export const AnalyticsPage: React.FC = () => {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<Spinner className="text-primary-600" />
|
||||
<div className="p-4 sm:p-6 max-w-5xl mx-auto space-y-6">
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
@@ -329,73 +477,91 @@ export const AnalyticsPage: React.FC = () => {
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<Card className="p-8 text-center">
|
||||
<BarChart3 className="w-8 h-8 text-gray-400 mx-auto mb-3" />
|
||||
<p className="text-gray-600">Unable to load analytics. Please try again.</p>
|
||||
<Card className="p-10 text-center">
|
||||
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mx-auto mb-4">
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="p-4 sm:p-6 max-w-5xl mx-auto space-y-6">
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Analytics</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
Track how your chatbots are performing
|
||||
</p>
|
||||
<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>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Analytics</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">Track how your chatbots are performing</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge className="text-xs capitalize">{data.plan} plan</Badge>
|
||||
</div>
|
||||
|
||||
{/* Usage bar */}
|
||||
<Card className="p-4">
|
||||
{/* ── Usage bar ── */}
|
||||
<Card className="p-5">
|
||||
<UsageBar used={data.conversations_used} limit={data.conversations_limit} />
|
||||
</Card>
|
||||
|
||||
{/* Overview stat cards */}
|
||||
{/* ── Overview stat cards ── */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="Conversations"
|
||||
value={data.total_conversations}
|
||||
icon={<MessageSquare className="w-4 h-4" />}
|
||||
subtitle={`${data.conversations_this_month} this month`}
|
||||
color="primary"
|
||||
/>
|
||||
<StatCard
|
||||
label="Unique users"
|
||||
value={data.unique_sessions}
|
||||
icon={<Users className="w-4 h-4" />}
|
||||
subtitle="Across all chatbots"
|
||||
color="sky"
|
||||
/>
|
||||
<StatCard
|
||||
label="Messages"
|
||||
value={data.total_messages}
|
||||
icon={<BarChart3 className="w-4 h-4" />}
|
||||
subtitle="Total messages exchanged"
|
||||
subtitle="Total exchanged"
|
||||
color="violet"
|
||||
/>
|
||||
<StatCard
|
||||
label="Avg rating"
|
||||
value={data.average_rating ? data.average_rating.toFixed(1) : '—'}
|
||||
icon={<Star className="w-4 h-4" />}
|
||||
subtitle={data.average_rating ? 'Across rated chatbots' : 'No ratings yet'}
|
||||
color="amber"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chatbot breakdown header */}
|
||||
{/* ── Chatbot breakdown header ── */}
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{/* Per-chatbot expandable rows */}
|
||||
{/* ── Per-chatbot expandable rows ── */}
|
||||
{data.chatbots.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<Bot className="w-8 h-8 text-gray-400 mx-auto mb-3" />
|
||||
<p className="text-sm text-gray-600 mb-4">No chatbots yet. Create your first chatbot to see analytics.</p>
|
||||
<Card className="p-12 text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-gray-50 flex items-center justify-center mx-auto mb-5">
|
||||
<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')}>
|
||||
Create chatbot
|
||||
</Button>
|
||||
|
||||
426
src/pages/AppointmentsPage.tsx
Normal file
426
src/pages/AppointmentsPage.tsx
Normal 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, // Mon–Fri 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>
|
||||
)
|
||||
}
|
||||
@@ -3,8 +3,117 @@ import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { authAPI } from '@/services/api'
|
||||
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 = () => {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
@@ -31,73 +140,84 @@ export const LoginPage: React.FC = () => {
|
||||
}
|
||||
|
||||
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="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex w-12 h-12 bg-primary-600 rounded-2xl items-center justify-center mb-4">
|
||||
<Sparkles className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
placeholder="you@company.com"
|
||||
required
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
label="Password"
|
||||
type={showPass ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPass(!showPass)}
|
||||
className="absolute right-3 top-8 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showPass ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<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">
|
||||
Sign in
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-right">
|
||||
<Link to="/forgot-password" className="text-sm text-primary-600 hover:underline">
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<AuthLayout>
|
||||
{/* Mobile logo */}
|
||||
<div className="flex lg:hidden items-center gap-2 mb-8">
|
||||
<div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
|
||||
<Sparkles className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span className="font-bold text-gray-900">Contexta</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-7">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<IconInput
|
||||
label="Email"
|
||||
icon={<Mail className="w-4 h-4" />}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
placeholder="you@company.com"
|
||||
required
|
||||
/>
|
||||
|
||||
<IconInput
|
||||
label="Password"
|
||||
icon={<Lock className="w-4 h-4" />}
|
||||
type={showPass ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
rightElement={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPass(!showPass)}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
{showPass ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && <ErrorAlert message={error} onDismiss={() => setError('')} />}
|
||||
|
||||
<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
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between text-sm">
|
||||
<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?
|
||||
</Link>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── SignupPage ────────────────────────────────────────────────────────────────
|
||||
export const SignupPage: React.FC = () => {
|
||||
const [form, setForm] = useState({ email: '', password: '', company_name: '' })
|
||||
const [showPass, setShowPass] = useState(false)
|
||||
@@ -135,19 +255,19 @@ export const SignupPage: React.FC = () => {
|
||||
|
||||
if (emailSent) {
|
||||
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="w-full max-w-md text-center">
|
||||
<div className="inline-flex w-16 h-16 bg-green-100 rounded-2xl items-center justify-center mb-4">
|
||||
<Sparkles className="w-8 h-8 text-green-600" />
|
||||
<div className="min-h-screen bg-dots bg-gray-50 flex items-center justify-center p-4">
|
||||
<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="w-16 h-16 bg-green-100 rounded-2xl flex items-center justify-center mx-auto mb-5">
|
||||
<Check className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Check your email</h1>
|
||||
<p className="text-gray-500 mb-4">
|
||||
We sent a confirmation link to <strong>{form.email}</strong>.<br />
|
||||
Click the link to activate your account.
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Check your inbox</h1>
|
||||
<p className="text-gray-500 text-sm mb-1">
|
||||
A confirmation link was sent to
|
||||
</p>
|
||||
<p className="text-gray-900 font-medium text-sm mb-6">{form.email}</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -155,89 +275,88 @@ export const SignupPage: React.FC = () => {
|
||||
}
|
||||
|
||||
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="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex w-12 h-12 bg-primary-600 rounded-2xl items-center justify-center mb-4">
|
||||
<Sparkles className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<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 for free</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Company Name"
|
||||
type="text"
|
||||
value={form.company_name}
|
||||
onChange={e => setForm(f => ({ ...f, company_name: e.target.value }))}
|
||||
placeholder="Acme Corp"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
|
||||
placeholder="you@company.com"
|
||||
required
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
label="Password"
|
||||
type={showPass ? 'text' : 'password'}
|
||||
value={form.password}
|
||||
onChange={e => setForm(f => ({ ...f, password: e.target.value }))}
|
||||
placeholder="Min 8 characters"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPass(!showPass)}
|
||||
className="absolute right-3 top-8 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showPass ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<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">
|
||||
Create free account
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-center text-gray-400">
|
||||
By signing up, you agree to our Terms of Service and Privacy Policy
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-sm text-gray-500">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="text-primary-600 font-medium hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
))}
|
||||
<AuthLayout>
|
||||
{/* Mobile logo */}
|
||||
<div className="flex lg:hidden items-center gap-2 mb-8">
|
||||
<div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
|
||||
<Sparkles className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span className="font-bold text-gray-900">Contexta</span>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<IconInput
|
||||
label="Company Name"
|
||||
icon={<Building2 className="w-4 h-4" />}
|
||||
type="text"
|
||||
value={form.company_name}
|
||||
onChange={e => setForm(f => ({ ...f, company_name: e.target.value }))}
|
||||
placeholder="Acme Corp"
|
||||
required
|
||||
/>
|
||||
|
||||
<IconInput
|
||||
label="Email"
|
||||
icon={<Mail className="w-4 h-4" />}
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
|
||||
placeholder="you@company.com"
|
||||
required
|
||||
/>
|
||||
|
||||
<IconInput
|
||||
label="Password"
|
||||
icon={<Lock className="w-4 h-4" />}
|
||||
type={showPass ? 'text' : 'password'}
|
||||
value={form.password}
|
||||
onChange={e => setForm(f => ({ ...f, password: e.target.value }))}
|
||||
placeholder="Min 8 characters"
|
||||
required
|
||||
rightElement={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPass(!showPass)}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
{showPass ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && <ErrorAlert message={error} onDismiss={() => setError('')} />}
|
||||
|
||||
<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
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-center text-gray-400 leading-relaxed">
|
||||
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>
|
||||
</form>
|
||||
|
||||
<div className="mt-5 text-center text-sm text-gray-500">
|
||||
Already have an account?{' '}
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-primary-600 font-medium hover:text-primary-700 transition-colors underline-offset-2 hover:underline"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
|
||||
383
src/pages/CampaignsPage.tsx
Normal file
383
src/pages/CampaignsPage.tsx
Normal 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
@@ -1,38 +1,23 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
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 {
|
||||
Bot, Plus, MoreHorizontal, Globe, Lock, Trash2,
|
||||
Settings, Eye, BarChart2
|
||||
Settings, Eye, BarChart2, FileText, MessageSquare,
|
||||
} from 'lucide-react'
|
||||
|
||||
// BUG-05 FIX: Toast queue system using array + auto-dismiss
|
||||
interface ToastItem {
|
||||
id: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export const DashboardPage: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||
|
||||
// BUG-05 FIX: Queue-based toast - no overwrites
|
||||
const showToast = useCallback((message: string) => {
|
||||
const id = crypto.randomUUID()
|
||||
setToasts(prev => [...prev, { id, message }])
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id))
|
||||
}, 3000)
|
||||
}, [])
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id))
|
||||
}, [])
|
||||
const [confirmAction, setConfirmAction] = useState<{ type: 'publish' | 'unpublish'; id: string } | null>(null)
|
||||
const { success: showToast } = useToast()
|
||||
|
||||
const { data: chatbots = [], isLoading } = useQuery({
|
||||
queryKey: ['chatbots'],
|
||||
@@ -64,144 +49,130 @@ export const DashboardPage: React.FC = () => {
|
||||
},
|
||||
})
|
||||
|
||||
// IMP-11: Confirmation before publish/unpublish
|
||||
const [confirmAction, setConfirmAction] = useState<{ type: 'publish' | 'unpublish'; id: string } | null>(null)
|
||||
|
||||
const handleConfirmAction = () => {
|
||||
if (!confirmAction) return
|
||||
if (confirmAction.type === 'publish') {
|
||||
publishMutation.mutate(confirmAction.id)
|
||||
} else {
|
||||
unpublishMutation.mutate(confirmAction.id)
|
||||
}
|
||||
if (confirmAction.type === 'publish') publishMutation.mutate(confirmAction.id)
|
||||
else unpublishMutation.mutate(confirmAction.id)
|
||||
setConfirmAction(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-6 gap-3 animate-fade-in">
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">Manage your AI chatbots</p>
|
||||
</div>
|
||||
<Button onClick={() => navigate('/chatbots/new')}>
|
||||
<Plus className="w-4 h-4" />
|
||||
New Chatbot
|
||||
<div className="p-4 sm:p-6 max-w-7xl">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-6 gap-3">
|
||||
<div>
|
||||
<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">
|
||||
{chatbots.length > 0
|
||||
? `${chatbots.length} chatbot${chatbots.length !== 1 ? 's' : ''}`
|
||||
: 'Manage your AI chatbots'}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => navigate('/chatbots/new')} size="md">
|
||||
<Plus className="w-4 h-4" />
|
||||
New Chatbot
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => <SkeletonCard key={i} />)}
|
||||
</div>
|
||||
) : chatbots.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Bot className="w-8 h-8" />}
|
||||
title="No chatbots yet"
|
||||
description="Create your first AI chatbot powered by your documents. Free to build and test."
|
||||
action={
|
||||
<Button onClick={() => navigate('/chatbots/new')} size="lg">
|
||||
<Plus className="w-4 h-4" />
|
||||
Create your first chatbot
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{chatbots.map((chatbot, i) => (
|
||||
<ChatbotCard
|
||||
key={chatbot.id}
|
||||
chatbot={chatbot}
|
||||
index={i}
|
||||
onEdit={() => navigate(`/chatbots/${chatbot.id}/edit`)}
|
||||
onPreview={() => navigate(`/chatbots/${chatbot.id}/preview`)}
|
||||
onPublish={() => setConfirmAction({ type: 'publish', id: chatbot.id })}
|
||||
onUnpublish={() => setConfirmAction({ type: 'unpublish', id: chatbot.id })}
|
||||
onDelete={() => setDeleteId(chatbot.id)}
|
||||
onAnalytics={() => navigate(`/analytics`)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* New chatbot card */}
|
||||
<button
|
||||
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="w-12 h-12 rounded-2xl bg-gray-100 group-hover:bg-primary-100 flex items-center justify-center transition-colors duration-200">
|
||||
<Plus className="w-5 h-5 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>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete modal */}
|
||||
<Modal isOpen={!!deleteId} onClose={() => setDeleteId(null)} title="Delete Chatbot" size="sm">
|
||||
<p className="text-sm text-gray-500 mb-5 leading-relaxed">
|
||||
All documents, conversation history, and settings will be permanently removed. This cannot be undone.
|
||||
</p>
|
||||
<div className="flex gap-2.5">
|
||||
<Button variant="outline" onClick={() => setDeleteId(null)} className="flex-1">Cancel</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => deleteId && deleteMutation.mutate(deleteId)}
|
||||
loading={deleteMutation.isPending}
|
||||
className="flex-1"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Spinner className="text-primary-600" />
|
||||
</div>
|
||||
) : chatbots.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Bot className="w-8 h-8" />}
|
||||
title="No chatbots yet"
|
||||
description="Create your first AI chatbot powered by your documents. It's free to build and test."
|
||||
action={
|
||||
<Button onClick={() => navigate('/chatbots/new')} size="lg">
|
||||
<Plus className="w-4 h-4" />
|
||||
Create your first chatbot
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{chatbots.map((chatbot, i) => (
|
||||
<ChatbotCard
|
||||
key={chatbot.id}
|
||||
chatbot={chatbot}
|
||||
index={i}
|
||||
onEdit={() => navigate(`/chatbots/${chatbot.id}/edit`)}
|
||||
onPreview={() => navigate(`/chatbots/${chatbot.id}/preview`)}
|
||||
onPublish={() => setConfirmAction({ type: 'publish', id: chatbot.id })}
|
||||
onUnpublish={() => setConfirmAction({ type: 'unpublish', id: chatbot.id })}
|
||||
onDelete={() => setDeleteId(chatbot.id)}
|
||||
onAnalytics={() => navigate(`/chatbots/${chatbot.id}/analytics`)}
|
||||
/>
|
||||
))}
|
||||
{/* Add new card */}
|
||||
<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')}
|
||||
>
|
||||
<div className="text-center">
|
||||
<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-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>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<Modal
|
||||
isOpen={!!deleteId}
|
||||
onClose={() => setDeleteId(null)}
|
||||
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>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" onClick={() => setDeleteId(null)} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => deleteId && deleteMutation.mutate(deleteId)}
|
||||
loading={deleteMutation.isPending}
|
||||
className="flex-1"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* IMP-11: Publish/Unpublish confirmation */}
|
||||
<Modal
|
||||
isOpen={!!confirmAction}
|
||||
onClose={() => setConfirmAction(null)}
|
||||
title={confirmAction?.type === 'publish' ? 'Publish Chatbot' : 'Unpublish Chatbot'}
|
||||
>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{confirmAction?.type === 'publish'
|
||||
? 'This will make your chatbot publicly visible on the marketplace. Are you sure?'
|
||||
: 'This will remove your chatbot from the marketplace. Users will no longer be able to access it.'}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" onClick={() => setConfirmAction(null)} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmAction}
|
||||
loading={publishMutation.isPending || unpublishMutation.isPending}
|
||||
className="flex-1"
|
||||
>
|
||||
{confirmAction?.type === 'publish' ? 'Publish' : 'Unpublish'}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* BUG-05 FIX: Toast queue - renders all active toasts */}
|
||||
<div className="fixed bottom-4 right-4 z-50 space-y-2">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className="bg-gray-900 text-white px-4 py-2 rounded-lg text-sm shadow-lg animate-fade-in-up flex items-center gap-2"
|
||||
>
|
||||
{toast.message}
|
||||
<button onClick={() => removeToast(toast.id)} className="opacity-60 hover:opacity-100">×</button>
|
||||
</div>
|
||||
))}
|
||||
{/* Publish/unpublish modal */}
|
||||
<Modal
|
||||
isOpen={!!confirmAction}
|
||||
onClose={() => setConfirmAction(null)}
|
||||
title={confirmAction?.type === 'publish' ? 'Publish to Marketplace' : 'Unpublish Chatbot'}
|
||||
size="sm"
|
||||
>
|
||||
<p className="text-sm text-gray-500 mb-5 leading-relaxed">
|
||||
{confirmAction?.type === 'publish'
|
||||
? 'Your chatbot will be publicly visible on the marketplace.'
|
||||
: 'Your chatbot will be removed from the marketplace.'}
|
||||
</p>
|
||||
<div className="flex gap-2.5">
|
||||
<Button variant="outline" onClick={() => setConfirmAction(null)} className="flex-1">Cancel</Button>
|
||||
<Button
|
||||
onClick={handleConfirmAction}
|
||||
loading={publishMutation.isPending || unpublishMutation.isPending}
|
||||
className="flex-1"
|
||||
>
|
||||
{confirmAction?.type === 'publish' ? 'Publish' : 'Unpublish'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// ─── Chatbot Card ──────────────────────────────────────────────────────────────
|
||||
const ChatbotCard: React.FC<{
|
||||
chatbot: Chatbot
|
||||
index: number
|
||||
@@ -215,119 +186,146 @@ const ChatbotCard: React.FC<{
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<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"
|
||||
style={{ animationDelay: `${index * 80}ms`, animationFillMode: 'both' }}
|
||||
>
|
||||
{/* Colored top accent */}
|
||||
<div className="h-1 w-full" style={{ background: chatbot.primary_color }} />
|
||||
<div
|
||||
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 * 60}ms`, animationFillMode: 'both' }}
|
||||
>
|
||||
{/* Color accent bar */}
|
||||
<div className="h-1 w-full" style={{ background: chatbot.primary_color }} />
|
||||
|
||||
<div className="p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{chatbot.logo_url ? (
|
||||
<img
|
||||
src={chatbot.logo_url}
|
||||
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"
|
||||
/>
|
||||
) : (
|
||||
<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"
|
||||
style={{ background: chatbot.primary_color }}
|
||||
>
|
||||
<Bot className="w-5 h-5" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 text-sm group-hover:text-primary-700 transition-colors">{chatbot.name}</h3>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<StatusDot status={chatbot.is_published ? 'published' : 'preview'} />
|
||||
<span className={`text-xs font-medium ${chatbot.is_published ? 'text-green-600' : 'text-gray-400'}`}>
|
||||
{chatbot.is_published ? 'Published' : 'Preview'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{/* Top row */}
|
||||
<div className="flex items-start justify-between gap-2 mb-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{chatbot.logo_url ? (
|
||||
<img
|
||||
src={chatbot.logo_url}
|
||||
alt={chatbot.name}
|
||||
className="w-10 h-10 rounded-xl object-cover shrink-0 shadow-sm group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
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 }}
|
||||
>
|
||||
<Bot className="w-4.5 h-4.5" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 text-sm truncate">{chatbot.name}</h3>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<StatusDot status={chatbot.is_published ? 'published' : 'preview'} />
|
||||
<span className={cn(
|
||||
'text-xs font-medium',
|
||||
chatbot.is_published ? 'text-green-600' : 'text-gray-400'
|
||||
)}>
|
||||
{chatbot.is_published ? 'Published' : 'Draft'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
className="p-1.5 hover:bg-gray-100 rounded-lg text-gray-300 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} />
|
||||
<div className="absolute right-0 mt-1 w-44 bg-white border border-gray-200 rounded-xl shadow-xl z-20 overflow-hidden text-sm 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
|
||||
</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
|
||||
</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 ? (
|
||||
<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">
|
||||
<Lock className="w-3.5 h-3.5" /> Unpublish
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={() => { onPublish(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2.5 hover:bg-green-50 text-green-600 text-left transition-colors">
|
||||
<Globe className="w-3.5 h-3.5" /> Publish
|
||||
</button>
|
||||
)}
|
||||
<div className="h-px bg-gray-100 mx-2" />
|
||||
<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">
|
||||
<Trash2 className="w-3.5 h-3.5" /> Delete
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{chatbot.description && (
|
||||
<p className="text-xs text-gray-500 mb-3 line-clamp-2 leading-relaxed">{chatbot.description}</p>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex flex-wrap 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="text-gray-400">📄</span> {chatbot.document_count} docs
|
||||
</span>
|
||||
<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
|
||||
</span>
|
||||
{chatbot.category && (
|
||||
<span className="text-xs text-primary-700 bg-primary-50 px-2 py-1 rounded-lg font-medium">
|
||||
{chatbot.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onPreview} className="flex-1">
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
Preview
|
||||
</Button>
|
||||
{chatbot.is_published ? (
|
||||
<Button variant="outline" size="sm" onClick={onUnpublish} className="flex-1 text-orange-600 border-orange-200 hover:bg-orange-50">
|
||||
<Lock className="w-3.5 h-3.5" />
|
||||
Unpublish
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" onClick={onPublish} className="flex-1">
|
||||
<Globe className="w-3.5 h-3.5" />
|
||||
Publish
|
||||
</Button>
|
||||
{/* Context menu */}
|
||||
<div className="relative shrink-0">
|
||||
<button
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
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" />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} />
|
||||
<div className="absolute right-0 mt-1 w-44 bg-white border border-gray-100 rounded-xl shadow-xl z-20 overflow-hidden py-1 animate-scale-in">
|
||||
{[
|
||||
{ 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>
|
||||
))}
|
||||
<div className="h-px bg-gray-50 my-1" />
|
||||
{chatbot.is_published ? (
|
||||
<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
|
||||
</button>
|
||||
) : (
|
||||
<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
|
||||
</button>
|
||||
)}
|
||||
<div className="h-px bg-gray-50 my-1" />
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{chatbot.description && (
|
||||
<p className="text-xs text-gray-500 mb-3 line-clamp-2 leading-relaxed">{chatbot.description}</p>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<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">
|
||||
<FileText className="w-3 h-3 text-gray-400" />
|
||||
{chatbot.document_count}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-xs text-gray-500 bg-gray-50 px-2 py-1 rounded-lg">
|
||||
<MessageSquare className="w-3 h-3 text-gray-400" />
|
||||
{chatbot.conversation_count.toLocaleString()}
|
||||
</span>
|
||||
{chatbot.category && (
|
||||
<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}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onPreview} className="flex-1 text-xs">
|
||||
<Eye className="w-3 h-3" />
|
||||
Preview
|
||||
</Button>
|
||||
{chatbot.is_published ? (
|
||||
<Button
|
||||
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
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" onClick={onPublish} className="flex-1 text-xs">
|
||||
<Globe className="w-3 h-3" />
|
||||
Publish
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { authAPI } from '@/services/api'
|
||||
import { Button, Input } from '@/components/ui'
|
||||
import { Sparkles, ArrowLeft } from 'lucide-react'
|
||||
import { Button } from '@/components/ui'
|
||||
import { Sparkles, ArrowLeft, Mail, X, Check } from 'lucide-react'
|
||||
|
||||
export const ForgotPasswordPage: React.FC = () => {
|
||||
const [email, setEmail] = useState('')
|
||||
@@ -25,58 +25,96 @@ export const ForgotPasswordPage: React.FC = () => {
|
||||
}
|
||||
|
||||
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="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex w-12 h-12 bg-primary-600 rounded-2xl items-center justify-center mb-4">
|
||||
<Sparkles className="w-6 h-6 text-white" />
|
||||
<div className="min-h-screen bg-dots bg-gray-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md animate-scale-in">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center justify-center gap-2 mb-8">
|
||||
<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>
|
||||
<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</p>
|
||||
<span className="font-bold text-gray-900 text-lg">Contexta</span>
|
||||
</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 ? (
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-green-600 text-xl">✓</span>
|
||||
<div className="text-center py-2 animate-fade-in-up">
|
||||
<div className="w-14 h-14 bg-green-100 rounded-2xl flex items-center justify-center mx-auto mb-5">
|
||||
<Check className="w-7 h-7 text-green-600" />
|
||||
</div>
|
||||
<h2 className="font-semibold text-gray-900 mb-2">Check your email</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
If <strong>{email}</strong> is registered, a reset link has been sent.
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-2">Check your inbox</h2>
|
||||
<p className="text-sm text-gray-500 mb-1">
|
||||
If <strong className="text-gray-700">{email}</strong> is registered,
|
||||
</p>
|
||||
<Link to="/login" className="text-sm text-primary-600 hover:underline">
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
placeholder="you@company.com"
|
||||
required
|
||||
/>
|
||||
{error && (
|
||||
<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">
|
||||
Send reset link
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{!sent && (
|
||||
<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">
|
||||
<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
|
||||
</Link>
|
||||
</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">
|
||||
{/* Email input with icon */}
|
||||
<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"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
placeholder="you@company.com"
|
||||
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 && (
|
||||
<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>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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
|
||||
</Button>
|
||||
</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>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { inboxAPI } from '@/services/api'
|
||||
import { Card, Spinner } from '@/components/ui'
|
||||
import { Mail, MessageSquare, Bot, AlertTriangle, ArrowRight, Trash2 } from 'lucide-react'
|
||||
import type { InboxConversation, InboxMessage } from '@/types'
|
||||
import {
|
||||
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 { SkeletonList } from '@/components/Skeletons'
|
||||
|
||||
interface ConversationDetail {
|
||||
conversation_id: string
|
||||
@@ -15,16 +19,94 @@ interface ConversationDetail {
|
||||
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 = () => {
|
||||
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 [replyText, setReplyText] = useState('')
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: conversations = [], isLoading, error } = useQuery<InboxConversation[]>({
|
||||
queryKey: ['inbox-conversations', chatbotFilter],
|
||||
queryFn: () => inboxAPI.conversations(chatbotFilter ? { chatbot_id: chatbotFilter } : undefined),
|
||||
queryKey: ['inbox-conversations', statusFilter],
|
||||
queryFn: () => inboxAPI.conversations(statusFilter !== 'all' ? { status: statusFilter } : undefined),
|
||||
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) => {
|
||||
@@ -42,23 +124,23 @@ export const InboxPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const { data: detail, isLoading: detailLoading } = useQuery<ConversationDetail>({
|
||||
queryKey: ['inbox-conversation', selectedId],
|
||||
queryFn: () => inboxAPI.conversation(selectedId!),
|
||||
enabled: !!selectedId,
|
||||
})
|
||||
const handleSendReply = () => {
|
||||
if (!replyText.trim() || !selectedId) return
|
||||
sendReply.mutate({ id: selectedId, message: replyText.trim() })
|
||||
}
|
||||
|
||||
const selectedConv = conversations.find(c => c.id === selectedId)
|
||||
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-8 text-center">
|
||||
<div className="w-14 h-14 rounded-2xl bg-primary-50 flex items-center justify-center mx-auto mb-4">
|
||||
<Mail className="w-6 h-6 text-primary-600" />
|
||||
<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">
|
||||
<Mail className="w-7 h-7 text-primary-600" />
|
||||
</div>
|
||||
<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.
|
||||
</p>
|
||||
</Card>
|
||||
@@ -67,136 +149,278 @@ export const InboxPage: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
{/* Left panel - conversation list */}
|
||||
<div className="w-80 flex-shrink-0 border-r border-gray-200 bg-white flex flex-col">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h1 className="text-lg font-bold text-gray-900 flex items-center gap-2">
|
||||
<Mail className="w-5 h-5 text-primary-600" />
|
||||
Inbox
|
||||
</h1>
|
||||
<p className="text-xs text-gray-500 mt-0.5">All chatbot conversations</p>
|
||||
</div>
|
||||
<div className="flex h-full overflow-hidden">
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Spinner className="text-primary-600" />
|
||||
{/* ── Left panel ── */}
|
||||
<div className={cn(
|
||||
'flex-shrink-0 border-r border-gray-200 bg-white flex flex-col transition-all duration-200',
|
||||
'w-full lg:w-80',
|
||||
selectedId ? 'hidden lg:flex' : 'flex'
|
||||
)}>
|
||||
{/* 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>
|
||||
) : conversations.length === 0 ? (
|
||||
<div className="p-6 text-center">
|
||||
<MessageSquare className="w-8 h-8 text-gray-300 mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-500">No conversations yet</p>
|
||||
<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>
|
||||
) : (
|
||||
conversations.map((conv) => (
|
||||
<div
|
||||
key={conv.id}
|
||||
</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(
|
||||
'w-full text-left border-b border-gray-100 hover:bg-gray-50 transition-colors group relative',
|
||||
selectedId === conv.id && 'bg-primary-50 border-l-2 border-l-primary-500'
|
||||
'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'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={() => setSelectedId(conv.id)}
|
||||
className="w-full text-left p-4 pr-10"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<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}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-gray-400 flex-shrink-0">
|
||||
{conv.created_at ? new Date(conv.created_at).toLocaleDateString() : ''}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 truncate">
|
||||
{conv.first_message || '(No messages)'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
{conv.message_count} messages · {conv.language.toUpperCase()}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleDelete(e, 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"
|
||||
title="Delete conversation"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conversation list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="p-4"><SkeletonList rows={6} /></div>
|
||||
) : conversations.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full py-16 px-6 text-center">
|
||||
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-4">
|
||||
<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>
|
||||
) : (
|
||||
conversations.map((conv) => {
|
||||
const sc = STATUS_CONFIG[conv.status] || STATUS_CONFIG.open
|
||||
return (
|
||||
<div
|
||||
key={conv.id}
|
||||
className={cn(
|
||||
'relative border-b border-gray-100 group transition-all duration-150',
|
||||
selectedId === conv.id
|
||||
? 'bg-primary-50 border-l-[3px] border-l-primary-500'
|
||||
: 'hover:bg-gray-50 border-l-[3px] border-l-transparent'
|
||||
)}
|
||||
>
|
||||
<button onClick={() => setSelectedId(conv.id)} className="w-full text-left p-4 pr-10">
|
||||
<div className="flex items-start gap-3">
|
||||
<AvatarInitial name={conv.chatbot_name} size="sm" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-1 mb-0.5">
|
||||
<span className={cn('text-xs font-semibold truncate', selectedId === conv.id ? 'text-primary-700' : 'text-gray-700')}>
|
||||
{conv.chatbot_name}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400 flex-shrink-0">{timeAgo(conv.created_at)}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 truncate leading-snug">
|
||||
{conv.first_message || '(No messages)'}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 mt-1.5">
|
||||
<span className={cn('text-[10px] px-1.5 py-0.5 rounded-full font-medium', sc.color)}>
|
||||
{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
|
||||
onClick={(e) => handleDelete(e, conv.id)}
|
||||
disabled={deletingId === conv.id}
|
||||
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"
|
||||
>
|
||||
{deletingId === conv.id ? <Spinner className="w-3.5 h-3.5 text-red-400" /> : <Trash2 className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel - conversation detail */}
|
||||
<div className="flex-1 flex flex-col bg-gray-50 overflow-hidden">
|
||||
{/* ── Right panel ── */}
|
||||
<div className={cn(
|
||||
'flex-1 flex flex-col bg-gray-50/50 overflow-hidden',
|
||||
selectedId ? 'flex' : 'hidden lg:flex'
|
||||
)}>
|
||||
{!selectedId ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<ArrowRight className="w-8 h-8 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-sm text-gray-500">Select a conversation to view</p>
|
||||
<div className="text-center px-6">
|
||||
<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">
|
||||
<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>
|
||||
) : detailLoading ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Spinner className="text-primary-600" />
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center"><Spinner className="text-primary-600" /></div>
|
||||
) : detail ? (
|
||||
<>
|
||||
<div className="p-4 bg-white border-b border-gray-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="w-4 h-4 text-primary-600" />
|
||||
<h2 className="font-semibold text-gray-900 text-sm">{detail.chatbot_name}</h2>
|
||||
<span className="text-xs text-gray-400">·</span>
|
||||
<span className="text-xs text-gray-500 uppercase">{detail.language}</span>
|
||||
{/* Detail header */}
|
||||
<div className="p-3 bg-white border-b border-gray-200 flex items-center gap-3 shadow-sm">
|
||||
<button
|
||||
onClick={() => setSelectedId(null)}
|
||||
className="lg:hidden p-1.5 rounded-lg hover:bg-gray-100 text-gray-500"
|
||||
>
|
||||
<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 && (
|
||||
<>
|
||||
<span className="text-xs text-gray-400">·</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(detail.created_at).toLocaleString()}
|
||||
</span>
|
||||
</>
|
||||
<p className="text-xs text-gray-400">{new Date(detail.created_at).toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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 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) => (
|
||||
<div
|
||||
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' && (
|
||||
<div className="w-6 h-6 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Bot className="w-3 h-3 text-primary-600" />
|
||||
{(msg.role === 'assistant' || msg.role === 'agent') && (
|
||||
<div className={cn(
|
||||
'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 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'
|
||||
? 'bg-primary-600 text-white rounded-br-sm'
|
||||
: 'bg-white border border-gray-200 text-gray-800 rounded-bl-sm'
|
||||
? 'bg-primary-600 text-white rounded-2xl rounded-br-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>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{msg.is_handoff && (
|
||||
<span className="text-[10px] bg-orange-100 text-orange-700 px-1.5 py-0.5 rounded">
|
||||
Handoff
|
||||
</span>
|
||||
)}
|
||||
{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">
|
||||
<AlertTriangle className="w-2.5 h-2.5" /> Low confidence
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(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 && (
|
||||
<span className="text-[10px] bg-orange-100 text-orange-700 px-2 py-0.5 rounded-full font-medium">
|
||||
Handoff requested
|
||||
</span>
|
||||
)}
|
||||
{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 bg-amber-50 px-2 py-0.5 rounded-full">
|
||||
<AlertTriangle className="w-2.5 h-2.5" /> Low confidence
|
||||
</span>
|
||||
)}
|
||||
</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 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>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,69 @@
|
||||
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 { Card, Spinner, Button } from '@/components/ui'
|
||||
import { Users, Download, Mail, Lock } from 'lucide-react'
|
||||
import type { Lead, Chatbot } from '@/types'
|
||||
import { Card, Button } from '@/components/ui'
|
||||
import { Users, Download, Mail, Lock, TrendingUp, Filter, UserCheck, StickyNote, X, Check } from 'lucide-react'
|
||||
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 = () => {
|
||||
const [chatbotFilter, setChatbotFilter] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<LeadStatus | ''>('')
|
||||
const [notesLead, setNotesLead] = useState<Lead | null>(null)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: chatbots = [] } = useQuery<Chatbot[]>({
|
||||
queryKey: ['chatbots'],
|
||||
@@ -14,11 +71,17 @@ export const LeadsPage: React.FC = () => {
|
||||
})
|
||||
|
||||
const { data: leads = [], isLoading, error } = useQuery<Lead[]>({
|
||||
queryKey: ['leads', chatbotFilter],
|
||||
queryKey: ['leads', chatbotFilter, statusFilter],
|
||||
queryFn: () => leadsAPI.list(chatbotFilter ? { chatbot_id: chatbotFilter } : undefined),
|
||||
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 handleExport = async () => {
|
||||
@@ -32,20 +95,18 @@ export const LeadsPage: React.FC = () => {
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
alert('Export failed')
|
||||
}
|
||||
} catch { alert('Export failed') }
|
||||
}
|
||||
|
||||
if (isPlanError) {
|
||||
return (
|
||||
<div className="p-6 max-w-2xl mx-auto">
|
||||
<Card className="p-8 text-center">
|
||||
<div className="w-14 h-14 rounded-2xl bg-primary-50 flex items-center justify-center mx-auto mb-4">
|
||||
<Lock className="w-6 h-6 text-primary-600" />
|
||||
<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">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.
|
||||
</p>
|
||||
</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 (
|
||||
<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-6 max-w-5xl mx-auto space-y-6">
|
||||
<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 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<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>
|
||||
<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>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Leads</h1>
|
||||
<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">
|
||||
<Button onClick={handleExport} variant="secondary" size="sm" className="self-start sm:self-auto gap-2">
|
||||
<Download className="w-4 h-4" />
|
||||
Export CSV
|
||||
</Button>
|
||||
</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 */}
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm font-medium text-gray-700 flex-shrink-0">Filter by chatbot:</label>
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3">
|
||||
<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
|
||||
value={chatbotFilter}
|
||||
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>
|
||||
{chatbots.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
{chatbots.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</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>
|
||||
</Card>
|
||||
|
||||
{/* Table */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Spinner className="text-primary-600" />
|
||||
</div>
|
||||
) : leads.length === 0 ? (
|
||||
<Card className="p-12 text-center">
|
||||
<Mail className="w-10 h-10 text-gray-300 mx-auto mb-3" />
|
||||
<h3 className="font-semibold text-gray-700 mb-1">No leads yet</h3>
|
||||
<p className="text-sm text-gray-500 max-w-sm mx-auto">
|
||||
Enable lead capture on your chatbots to start collecting contact information from visitors.
|
||||
<Card className="p-6"><SkeletonTable rows={6} /></Card>
|
||||
) : 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>
|
||||
<h3 className="font-semibold text-gray-700 mb-2">No leads {statusFilter ? `with status "${statusFilter}"` : 'yet'}</h3>
|
||||
<p className="text-sm text-gray-500 max-w-sm mx-auto leading-relaxed">
|
||||
{statusFilter
|
||||
? 'Try a different filter or clear the current one.'
|
||||
: 'Enable lead capture on your chatbots to start collecting contact information.'}
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 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-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">Name</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">Phone</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">Company</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{leads.map((lead) => (
|
||||
<tr key={lead.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-4 py-3 text-gray-900">{lead.email || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-700">{lead.name || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-700">{lead.phone || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-700">{lead.company || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-500">
|
||||
{lead.created_at ? new Date(lead.created_at).toLocaleDateString() : '—'}
|
||||
</td>
|
||||
<>
|
||||
{/* Desktop table */}
|
||||
<Card className="overflow-hidden hidden sm:block">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50/80 border-b border-gray-200">
|
||||
<th className="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Contact</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-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">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-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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{filtered.map((lead, idx) => {
|
||||
const sc = statusConfig(lead.status || 'new')
|
||||
return (
|
||||
<tr key={lead.id} className={`hover:bg-primary-50/30 transition-colors ${idx % 2 === 0 ? 'bg-white' : 'bg-gray-50/40'}`}>
|
||||
<td className="px-5 py-3.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<LeadAvatar name={lead.name} email={lead.email} />
|
||||
<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>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</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>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,10 +2,11 @@ import React, { useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
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 { 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'
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -19,6 +20,7 @@ export const MarketplacePage: React.FC = () => {
|
||||
const [category, setCategory] = useState('')
|
||||
const [industry, setIndustry] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
|
||||
// Debounce search
|
||||
const searchTimeout = React.useRef<ReturnType<typeof setTimeout>>()
|
||||
@@ -36,88 +38,233 @@ export const MarketplacePage: React.FC = () => {
|
||||
queryFn: () => marketplaceAPI.list({ search: debouncedSearch, category, industry, page, limit: 20 }),
|
||||
})
|
||||
|
||||
const totalPages = data ? Math.ceil(data.total / 20) : 0
|
||||
const hasActiveFilters = category !== '' || industry !== ''
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8 animate-fade-in">
|
||||
<h1 className="text-2xl font-bold text-gray-900">AI Chatbot Marketplace</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Discover and interact with AI chatbots built by businesses</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-6 animate-fade-in-down">
|
||||
<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" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => handleSearch(e.target.value)}
|
||||
placeholder="Search chatbots..."
|
||||
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>
|
||||
<select
|
||||
value={category}
|
||||
onChange={e => { setCategory(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"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
<select
|
||||
value={industry}
|
||||
onChange={e => { setIndustry(e.target.value); setPage(1) }}
|
||||
className="px-3 py-2.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 Industries</option>
|
||||
{INDUSTRIES.map(i => <option key={i} value={i}>{i}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 gap-3">
|
||||
<Spinner className="text-primary-600 w-7 h-7" />
|
||||
<p className="text-sm text-gray-400">Loading chatbots…</p>
|
||||
</div>
|
||||
) : !data?.chatbots?.length ? (
|
||||
<EmptyState
|
||||
icon={<Bot className="w-8 h-8" />}
|
||||
title="No chatbots found"
|
||||
description="Be the first to publish your AI chatbot to the marketplace!"
|
||||
action={<Button onClick={() => navigate('/chatbots/new')}>Create Chatbot</Button>}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4 text-xs text-gray-400 font-medium uppercase tracking-wide">
|
||||
{data.total} chatbot{data.total !== 1 ? 's' : ''} available
|
||||
<div className="min-h-full bg-gray-50/50">
|
||||
{/* Page Header */}
|
||||
<div className="bg-white border-b border-gray-200">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8 sm:py-10">
|
||||
<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 className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
|
||||
{data.chatbots.map((chatbot, i) => (
|
||||
<ChatbotMarketplaceCard
|
||||
key={chatbot.id}
|
||||
chatbot={chatbot}
|
||||
index={i}
|
||||
onClick={() => navigate(`/marketplace/${chatbot.id}`)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-6">
|
||||
{/* Search & Filter Bar */}
|
||||
<div className="mb-6 animate-fade-in-down space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => handleSearch(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
))}
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => handleSearch('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</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>
|
||||
|
||||
{data.total > 20 && (
|
||||
<div className="flex justify-center items-center gap-3">
|
||||
<Button variant="outline" size="sm" onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-gray-500 bg-white border border-gray-200 px-4 py-1.5 rounded-lg shadow-sm">
|
||||
Page {page} of {Math.ceil(data.total / 20)}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={() => setPage(p => p + 1)} disabled={!data.has_more}>
|
||||
Next
|
||||
</Button>
|
||||
{/* 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
|
||||
value={industry}
|
||||
onChange={e => { setIndustry(e.target.value); setPage(1) }}
|
||||
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>
|
||||
{INDUSTRIES.map(i => <option key={i} value={i}>{i}</option>)}
|
||||
</select>
|
||||
</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 */}
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<SkeletonCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : !data?.chatbots?.length ? (
|
||||
<EmptyState
|
||||
icon={<Bot className="w-8 h-8" />}
|
||||
title="No chatbots found"
|
||||
description={
|
||||
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 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
|
||||
</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 className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
|
||||
{data.chatbots.map((chatbot, i) => (
|
||||
<ChatbotMarketplaceCard
|
||||
key={chatbot.id}
|
||||
chatbot={chatbot}
|
||||
index={i}
|
||||
onClick={() => navigate(`/marketplace/${chatbot.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{data.total > 20 && (
|
||||
<div className="flex justify-center items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -129,59 +276,79 @@ export const MarketplacePage: React.FC = () => {
|
||||
|
||||
const ChatbotMarketplaceCard: React.FC<{ chatbot: ChatbotPublic; onClick: () => void; index: number }> = ({ chatbot, onClick, index }) => (
|
||||
<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' }}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* Colored accent bar */}
|
||||
<div className="h-1 w-full" style={{ background: chatbot.primary_color }} />
|
||||
{/* Colored accent top bar — thicker and with gradient */}
|
||||
<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="flex items-center gap-3 mb-3">
|
||||
{/* Header row */}
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
{chatbot.logo_url ? (
|
||||
<img
|
||||
src={chatbot.logo_url}
|
||||
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
|
||||
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 }}
|
||||
>
|
||||
<Bot className="w-5 h-5" />
|
||||
<Bot className="w-6 h-6" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 text-sm truncate group-hover:text-primary-700 transition-colors">{chatbot.name}</h3>
|
||||
<div className="flex-1 min-w-0 pt-0.5">
|
||||
<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 && (
|
||||
<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>
|
||||
|
||||
{chatbot.description && (
|
||||
{/* Description */}
|
||||
{chatbot.description ? (
|
||||
<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">
|
||||
{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">
|
||||
<Star className="w-3 h-3 fill-yellow-400 text-yellow-400" />
|
||||
<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-amber-400 text-amber-400" />
|
||||
{chatbot.average_rating.toFixed(1)}
|
||||
</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" />
|
||||
{chatbot.total_conversations.toLocaleString()}
|
||||
</span>
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
|
||||
@@ -231,54 +398,68 @@ export const ChatbotDetailPage: React.FC = () => {
|
||||
{/* Back link */}
|
||||
<button
|
||||
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" />
|
||||
Back to Marketplace
|
||||
</button>
|
||||
|
||||
{/* Chatbot info */}
|
||||
<div className="flex items-center gap-4 mb-5">
|
||||
{chatbot.logo_url ? (
|
||||
<img
|
||||
src={chatbot.logo_url}
|
||||
alt={chatbot.name}
|
||||
className="w-16 h-16 rounded-2xl object-cover shadow-md"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-16 h-16 rounded-2xl flex items-center justify-center text-white shadow-md"
|
||||
style={{ background: chatbot.primary_color }}
|
||||
>
|
||||
<Bot className="w-8 h-8" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-gray-900">{chatbot.name}</h1>
|
||||
{chatbot.company_name && (
|
||||
<p className="text-sm text-gray-500">by {chatbot.company_name}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
{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">
|
||||
<Star className="w-3 h-3 fill-yellow-400 text-yellow-400" />
|
||||
{chatbot.average_rating.toFixed(1)}
|
||||
</span>
|
||||
{/* Chatbot info card */}
|
||||
<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 ? (
|
||||
<img
|
||||
src={chatbot.logo_url}
|
||||
alt={chatbot.name}
|
||||
className="w-16 h-16 rounded-2xl object-cover shadow-md flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-16 h-16 rounded-2xl flex items-center justify-center text-white shadow-md flex-shrink-0"
|
||||
style={{ background: chatbot.primary_color }}
|
||||
>
|
||||
<Bot className="w-8 h-8" />
|
||||
</div>
|
||||
)}
|
||||
<span className="flex items-center gap-1 text-xs text-gray-500 bg-gray-50 px-2 py-0.5 rounded-full">
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
{chatbot.total_conversations.toLocaleString()} conversations
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-gray-900">{chatbot.name}</h1>
|
||||
{chatbot.company_name && (
|
||||
<p className="text-sm text-gray-500">by {chatbot.company_name}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
|
||||
{chatbot.average_rating && chatbot.average_rating > 0 && (
|
||||
<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-amber-400 text-amber-400" />
|
||||
{chatbot.average_rating.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
<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" />
|
||||
{chatbot.total_conversations.toLocaleString()} conversations
|
||||
</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>
|
||||
|
||||
{chatbot.description && (
|
||||
<p className="text-gray-500 text-sm leading-relaxed">{chatbot.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{chatbot.description && (
|
||||
<p className="text-gray-500 text-sm mb-5 leading-relaxed">{chatbot.description}</p>
|
||||
)}
|
||||
|
||||
{/* 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
|
||||
chatbotId={chatbot.id}
|
||||
chatbotName={chatbot.name}
|
||||
@@ -289,4 +470,4 @@ export const ChatbotDetailPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,23 +4,28 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { billingAPI } from '@/services/api'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { Button } from '@/components/ui'
|
||||
import { Check } from 'lucide-react'
|
||||
import { Check, Minus, Zap, Building2, Rocket, Star } from 'lucide-react'
|
||||
|
||||
const PLANS = [
|
||||
{
|
||||
id: 'free',
|
||||
name: 'Free',
|
||||
price: 0,
|
||||
yearlyPrice: 0,
|
||||
description: 'Build, test and launch your first chatbot — no card needed',
|
||||
icon: '🆓',
|
||||
icon: Star,
|
||||
iconColor: 'text-gray-500',
|
||||
iconBg: 'bg-gray-100',
|
||||
features: [
|
||||
{ text: '1 published chatbot', included: true },
|
||||
{ text: '100 conversations/month', included: true },
|
||||
{ text: '3 documents per chatbot', included: true },
|
||||
{ text: 'Public chat link + website embed', 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: 'Lead capture', included: false },
|
||||
{ text: 'Appointments & campaigns', included: false },
|
||||
{ text: 'Messaging channels', included: false },
|
||||
{ text: 'Remove "Powered by Contexta"', included: false },
|
||||
],
|
||||
@@ -28,52 +33,67 @@ const PLANS = [
|
||||
{
|
||||
id: 'starter',
|
||||
name: 'Starter',
|
||||
price: 12,
|
||||
description: 'For individuals and solo businesses going live',
|
||||
icon: '🚀',
|
||||
price: 19,
|
||||
yearlyPrice: 15,
|
||||
description: 'For solo operators: live chat, leads, booking, and campaigns',
|
||||
icon: Rocket,
|
||||
iconColor: 'text-blue-600',
|
||||
iconBg: 'bg-blue-50',
|
||||
features: [
|
||||
{ text: 'Everything in Free', included: true },
|
||||
{ text: '3 published chatbots', included: true },
|
||||
{ text: '1,500 conversations/month', included: true },
|
||||
{ text: '10 documents per chatbot', included: true },
|
||||
{ text: '4 Fireworks AI models (Qwen3, DeepSeek, Kimi, Llama)', included: true },
|
||||
{ text: 'Lead capture + inbox', included: true },
|
||||
{ text: 'Analytics + knowledge gaps', included: true },
|
||||
{ text: 'Telegram channel', included: true },
|
||||
{ text: 'WhatsApp channel', included: false },
|
||||
{ text: 'Live chat inbox + agent replies', included: true },
|
||||
{ text: 'Full lead CRM (status + notes)', included: true },
|
||||
{ text: 'Appointment booking (1 chatbot)', included: true },
|
||||
{ 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: 'Remove "Powered by Contexta"', included: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'business',
|
||||
name: 'Business',
|
||||
price: 29,
|
||||
description: 'For growing businesses that need more reach and power',
|
||||
icon: '⚡',
|
||||
price: 49,
|
||||
yearlyPrice: 39,
|
||||
description: 'For growing businesses: premium AI, unlimited booking, full analytics',
|
||||
icon: Zap,
|
||||
iconColor: 'text-primary-600',
|
||||
iconBg: 'bg-primary-50',
|
||||
highlighted: true,
|
||||
badge: 'Most Popular',
|
||||
features: [
|
||||
{ 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: '50 documents per chatbot', included: true },
|
||||
{ text: 'WhatsApp + Telegram channels', included: true },
|
||||
{ text: 'GPT-4o, Claude Haiku, Gemini 2.5', included: true },
|
||||
{ text: 'GPT-4o, Claude Haiku 4.5, 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: 'Unlimited URL sources', included: true },
|
||||
{ text: 'Priority support', included: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'agency',
|
||||
name: 'Agency',
|
||||
price: 79,
|
||||
description: 'For agencies and large businesses managing many chatbots',
|
||||
icon: '🏗️',
|
||||
price: 99,
|
||||
yearlyPrice: 79,
|
||||
description: 'For agencies: unlimited everything, white-label ready',
|
||||
icon: Building2,
|
||||
iconColor: 'text-purple-600',
|
||||
iconBg: 'bg-purple-50',
|
||||
features: [
|
||||
{ text: 'Everything in Business', included: true },
|
||||
{ text: 'Unlimited published chatbots', included: true },
|
||||
{ text: '20,000 conversations/month', included: true },
|
||||
{ text: 'Unlimited documents', included: true },
|
||||
{ text: 'Unlimited campaign recipients', included: true },
|
||||
{ text: 'Code export (FastAPI + React)', included: true },
|
||||
{ text: 'Dedicated support', included: true },
|
||||
],
|
||||
@@ -82,8 +102,11 @@ const PLANS = [
|
||||
id: 'enterprise',
|
||||
name: 'Enterprise',
|
||||
price: null,
|
||||
yearlyPrice: null,
|
||||
description: 'For large organizations with custom needs and SLAs',
|
||||
icon: '🏢',
|
||||
icon: Building2,
|
||||
iconColor: 'text-gray-700',
|
||||
iconBg: 'bg-gray-100',
|
||||
features: [
|
||||
{ text: 'Everything in Agency', included: true },
|
||||
{ text: 'Unlimited conversations', included: true },
|
||||
@@ -100,6 +123,7 @@ export const PricingPage: React.FC = () => {
|
||||
const { user } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
const [loading, setLoading] = useState<string | null>(null)
|
||||
const [yearly, setYearly] = useState(false)
|
||||
|
||||
const { data: subscription } = useQuery({
|
||||
queryKey: ['subscription'],
|
||||
@@ -138,135 +162,200 @@ export const PricingPage: React.FC = () => {
|
||||
}
|
||||
|
||||
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 === 'enterprise') return 'Contact Sales'
|
||||
if (planId === 'free') return 'Downgrade'
|
||||
return 'Upgrade'
|
||||
return 'Upgrade Now'
|
||||
}
|
||||
|
||||
const isCurrentPlan = (planId: string) => user && planId === currentPlan
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-6xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-3">Simple, affordable pricing</h1>
|
||||
<p className="text-gray-500 max-w-xl mx-auto">
|
||||
Start free and go live for $12/month. Built for individuals, small businesses, agencies, and enterprises alike.
|
||||
</p>
|
||||
<div className="p-6 max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-10 animate-fade-in-up">
|
||||
<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.
|
||||
</p>
|
||||
|
||||
{/* Billing toggle */}
|
||||
<div className="inline-flex items-center gap-3 mt-6 bg-gray-100 rounded-xl p-1">
|
||||
<button
|
||||
onClick={() => setYearly(false)}
|
||||
className={`px-4 py-1.5 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
!yearly ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Monthly
|
||||
</button>
|
||||
<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>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-5">
|
||||
{PLANS.map((plan) => (
|
||||
<div
|
||||
key={plan.id}
|
||||
className={`relative rounded-2xl border p-6 flex flex-col ${
|
||||
plan.highlighted
|
||||
? 'border-primary-400 bg-gradient-to-b from-primary-50 to-white shadow-lg shadow-primary-100'
|
||||
: isCurrentPlan(plan.id)
|
||||
? 'border-primary-300 bg-primary-50/30 shadow-sm'
|
||||
: 'border-gray-200 bg-white'
|
||||
}`}
|
||||
>
|
||||
{isCurrentPlan(plan.id) && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||
<span className="bg-green-600 text-white text-xs font-semibold px-3 py-1 rounded-full">
|
||||
Current Plan
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{plan.badge && !isCurrentPlan(plan.id) && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||
<span className="bg-primary-600 text-white text-xs font-semibold px-3 py-1 rounded-full">
|
||||
{plan.badge}
|
||||
</span>
|
||||
</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
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{plan.badge && !isCurrent && (
|
||||
<div className="absolute -top-3.5 left-1/2 -translate-x-1/2 z-10">
|
||||
<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}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-5 flex flex-col flex-1">
|
||||
{/* Plan header */}
|
||||
<div className="mb-5">
|
||||
<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="mb-6">
|
||||
<div className="text-3xl mb-2">{plan.icon}</div>
|
||||
<h2 className="text-xl font-bold text-gray-900">{plan.name}</h2>
|
||||
<p className="text-sm text-gray-500 mt-1 min-h-[40px]">{plan.description}</p>
|
||||
<div className="mt-4">
|
||||
{plan.price !== null ? (
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-4xl font-extrabold text-gray-900">${plan.price}</span>
|
||||
{plan.price > 0 && <span className="text-gray-500 text-sm">/month</span>}
|
||||
</div>
|
||||
{displayPrice !== null ? (
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-3xl font-extrabold text-gray-900">${displayPrice}</span>
|
||||
{(displayPrice as number) > 0 && (
|
||||
<span className="text-gray-400 text-xs">/mo</span>
|
||||
)}
|
||||
</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 className="flex-1">
|
||||
<ul className="space-y-3 mb-8">
|
||||
{plan.features.map((feature) => (
|
||||
<li key={feature.text} className="flex items-start gap-2 text-sm">
|
||||
<div className={`w-4 h-4 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5 ${
|
||||
feature.included
|
||||
? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-400'
|
||||
}`}>
|
||||
<Check className="w-2.5 h-2.5" />
|
||||
</div>
|
||||
<span className={feature.included ? 'text-gray-700' : 'text-gray-400'}>
|
||||
{feature.text}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{/* Features */}
|
||||
<ul className="space-y-2.5 mb-6 flex-1">
|
||||
{plan.features.map((feature) => (
|
||||
<li key={feature.text} className="flex items-start gap-2 text-xs">
|
||||
{feature.included ? (
|
||||
<span className="w-4 h-4 rounded-full bg-green-100 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Check className="w-2.5 h-2.5 text-green-600" />
|
||||
</span>
|
||||
) : (
|
||||
<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'}>
|
||||
{feature.text}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* CTA */}
|
||||
<Button
|
||||
onClick={() => handleSubscribe(plan.id)}
|
||||
loading={loading === plan.id}
|
||||
disabled={isCurrentPlan(plan.id) || loading === plan.id}
|
||||
variant={plan.highlighted ? 'default' : 'outline'}
|
||||
className="w-full"
|
||||
onClick={() => handleSubscribe(plan.id)}
|
||||
loading={loading === plan.id}
|
||||
disabled={isCurrent || loading === plan.id}
|
||||
variant={plan.highlighted ? 'primary' : isCurrent ? 'secondary' : 'outline'}
|
||||
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)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* FAQ */}
|
||||
<div className="mt-16 max-w-2xl mx-auto animate-fade-in-up">
|
||||
<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">
|
||||
{[
|
||||
{
|
||||
q: 'Can I use the free tier forever?',
|
||||
a: 'Yes! Build and test unlimited chatbots for free. Your chatbots will remain in preview mode until you subscribe.'
|
||||
},
|
||||
{
|
||||
q: 'What is code export?',
|
||||
a: 'Agency plan users can export their chatbot as a complete, production-ready package including a FastAPI backend and React TypeScript widget — giving you full control to self-host.'
|
||||
},
|
||||
{
|
||||
q: 'Do I need my own API keys?',
|
||||
a: 'No — API keys are handled by Contexta. If you export the code on the Agency plan, you\'ll need your own keys for self-hosted deployment.'
|
||||
},
|
||||
{
|
||||
q: 'Can I cancel anytime?',
|
||||
a: 'Yes, cancel anytime. Your chatbots will revert to the free tier at the end of your billing period.'
|
||||
},
|
||||
{
|
||||
q: 'What happens if I hit my conversation limit?',
|
||||
a: 'Your chatbot will show a friendly message to try again later. Upgrade your plan for more conversations.'
|
||||
},
|
||||
{
|
||||
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 for premium AI models and more capacity.'
|
||||
},
|
||||
].map(({ q, a }) => (
|
||||
<div
|
||||
key={q}
|
||||
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>
|
||||
|
||||
{/* FAQ */}
|
||||
<div className="mt-16 max-w-2xl mx-auto">
|
||||
<h2 className="text-xl font-bold text-gray-900 text-center mb-8">Frequently Asked Questions</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{[
|
||||
{
|
||||
q: 'Can I use the free tier forever?',
|
||||
a: 'Yes! Build and test unlimited chatbots for free. Your chatbots will remain in preview mode until you subscribe.'
|
||||
},
|
||||
{
|
||||
q: 'What is code export?',
|
||||
a: 'Agency plan users can export their chatbot as a complete, production-ready package including a FastAPI backend and React TypeScript widget — giving you full control to self-host.'
|
||||
},
|
||||
{
|
||||
q: 'Do I need my own API keys?',
|
||||
a: 'No — API keys are handled by Contexta. If you export the code on the Agency plan, you\'ll need your own keys for self-hosted deployment.'
|
||||
},
|
||||
{
|
||||
q: 'Can I cancel anytime?',
|
||||
a: 'Yes, cancel anytime. Your chatbots will revert to the free tier at the end of your billing period.'
|
||||
},
|
||||
{
|
||||
q: 'What happens if I hit my conversation limit?',
|
||||
a: 'Your chatbot will show a friendly message to try again later. Upgrade your plan for more conversations.'
|
||||
},
|
||||
{
|
||||
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.'
|
||||
},
|
||||
].map(({ q, a }) => (
|
||||
<div key={q} className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<h3 className="font-semibold text-gray-900 text-sm mb-1">{q}</h3>
|
||||
<p className="text-sm text-gray-500">{a}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
413
src/pages/PublicBookingPage.tsx
Normal file
413
src/pages/PublicBookingPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { authAPI } from '@/services/api'
|
||||
import { Button, Input } from '@/components/ui'
|
||||
import { Sparkles, Eye, EyeOff } from 'lucide-react'
|
||||
import { Button } from '@/components/ui'
|
||||
import { Sparkles, Eye, EyeOff, Lock, X, ArrowLeft } from 'lucide-react'
|
||||
|
||||
export const ResetPasswordPage: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
@@ -54,62 +54,117 @@ export const ResetPasswordPage: React.FC = () => {
|
||||
}
|
||||
|
||||
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="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex w-12 h-12 bg-primary-600 rounded-2xl items-center justify-center mb-4">
|
||||
<Sparkles className="w-6 h-6 text-white" />
|
||||
<div className="min-h-screen bg-dots bg-gray-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md animate-scale-in">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center justify-center gap-2 mb-8">
|
||||
<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>
|
||||
<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>
|
||||
<span className="font-bold text-gray-900 text-lg">Contexta</span>
|
||||
</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 ? (
|
||||
<div className="text-center">
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700 mb-4">
|
||||
{error}
|
||||
<div className="text-center py-2 animate-fade-in-up">
|
||||
<div className="w-14 h-14 bg-red-50 rounded-2xl flex items-center justify-center mx-auto mb-5">
|
||||
<X className="w-7 h-7 text-red-500" />
|
||||
</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
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="relative">
|
||||
<Input
|
||||
label="New Password"
|
||||
type={showPass ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="Min 8 characters"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPass(!showPass)}
|
||||
className="absolute right-3 top-8 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showPass ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
<>
|
||||
<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>
|
||||
<Input
|
||||
label="Confirm Password"
|
||||
type={showPass ? 'text' : 'password'}
|
||||
value={confirm}
|
||||
onChange={e => setConfirm(e.target.value)}
|
||||
placeholder="Repeat password"
|
||||
required
|
||||
/>
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{error}
|
||||
|
||||
<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">
|
||||
<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'}
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="Min 8 characters"
|
||||
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
|
||||
type="button"
|
||||
onClick={() => setShowPass(!showPass)}
|
||||
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" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Button type="submit" loading={loading} className="w-full" size="lg">
|
||||
Set new password
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* 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'}
|
||||
value={confirm}
|
||||
onChange={e => setConfirm(e.target.value)}
|
||||
placeholder="Repeat password"
|
||||
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 && (
|
||||
<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>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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
|
||||
</Button>
|
||||
</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>
|
||||
|
||||
@@ -4,13 +4,16 @@ import { useNavigate, useLocation, Link } from 'react-router-dom'
|
||||
import { billingAPI, authAPI } from '@/services/api'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { Button, Card, Input } from '@/components/ui'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
import { useThemeStore } from '@/store/themeStore'
|
||||
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 = () => {
|
||||
const navigate = useNavigate()
|
||||
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'
|
||||
|
||||
@@ -21,14 +24,19 @@ export const SettingsPage: React.FC = () => {
|
||||
}
|
||||
}, [navigate, location.pathname])
|
||||
|
||||
const showToast = (msg: string) => {
|
||||
setToast(msg)
|
||||
setTimeout(() => setToast(''), 3500)
|
||||
}
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<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>
|
||||
|
||||
{tab === 'profile' && <ProfileSettings onToast={showToast} />}
|
||||
{tab === 'billing' && <BillingSettings onToast={showToast} />}
|
||||
|
||||
{toast && (
|
||||
<div className="fixed bottom-4 right-4 bg-gray-900 text-white px-4 py-2 rounded-lg text-sm shadow-lg z-50 animate-fade-in-up">
|
||||
{toast}
|
||||
<button onClick={() => setToast('')} className="ml-3 opacity-60 hover:opacity-100">×</button>
|
||||
</div>
|
||||
)}
|
||||
{tab === 'profile' && <ProfileSettings onToast={showToast} onError={showError} />}
|
||||
{tab === 'billing' && <BillingSettings onToast={showToast} onError={showError} />}
|
||||
</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 navigate = useNavigate()
|
||||
const [companyName, setCompanyName] = useState(user?.company_name || '')
|
||||
@@ -95,7 +96,7 @@ const ProfileSettings: React.FC<{ onToast: (msg: string) => void }> = ({ onToast
|
||||
onToast('Profile updated successfully')
|
||||
} catch (err) {
|
||||
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 {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -110,7 +111,7 @@ const ProfileSettings: React.FC<{ onToast: (msg: string) => void }> = ({ onToast
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 [loading, setLoading] = useState(false)
|
||||
|
||||
@@ -227,7 +228,7 @@ const BillingSettings: React.FC<{ onToast: (msg: string) => void }> = ({ onToast
|
||||
window.location.href = url
|
||||
} catch (err) {
|
||||
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 {
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
130
src/pages/admin/AdminChatbotsPage.tsx
Normal file
130
src/pages/admin/AdminChatbotsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
88
src/pages/admin/AdminConversationsPage.tsx
Normal file
88
src/pages/admin/AdminConversationsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
116
src/pages/admin/AdminDashboardPage.tsx
Normal file
116
src/pages/admin/AdminDashboardPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
106
src/pages/admin/AdminSystemPage.tsx
Normal file
106
src/pages/admin/AdminSystemPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
219
src/pages/admin/AdminUsersPage.tsx
Normal file
219
src/pages/admin/AdminUsersPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,9 @@ import { useAuthStore } from '@/store/authStore'
|
||||
import type {
|
||||
AuthResponse, Chatbot, ChatbotPublic, ChatResponse,
|
||||
Document, MarketplaceResponse, Subscription,
|
||||
ModelsResponse, Lead, UrlSource, InboxConversation, ChannelConnection
|
||||
ModelsResponse, Lead, UrlSource, InboxConversation, ChannelConnection,
|
||||
AdminStats, AdminUser, AdminChatbot, AdminConversation, AdminSystemHealth,
|
||||
Appointment, BusinessHoursEntry, TimeSlot, Campaign,
|
||||
} from '@/types'
|
||||
|
||||
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 }) =>
|
||||
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 }) =>
|
||||
api.post<Lead>(`/chatbots/${chatbotId}/leads`, data).then(r => r.data),
|
||||
|
||||
@@ -208,16 +213,104 @@ export const leadsAPI = {
|
||||
|
||||
// ─── Inbox ────────────────────────────────────────────────────────────────────
|
||||
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),
|
||||
|
||||
conversation: (id: string) =>
|
||||
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) =>
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
export const channelsAPI = {
|
||||
list: (chatbotId: string) =>
|
||||
@@ -226,9 +319,6 @@ export const channelsAPI = {
|
||||
connectTelegram: (chatbotId: string, botToken: string) =>
|
||||
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) =>
|
||||
api.delete(`/channels/${connectionId}`).then(r => r.data),
|
||||
}
|
||||
32
src/store/themeStore.ts
Normal file
32
src/store/themeStore.ts
Normal 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 */ }
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export interface User {
|
||||
company_name?: string
|
||||
plan: 'free' | 'starter' | 'business' | 'agency' | 'enterprise'
|
||||
created_at?: string
|
||||
is_admin?: boolean
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
@@ -46,6 +47,7 @@ export interface Chatbot {
|
||||
handoff_message: string
|
||||
handoff_email?: string
|
||||
handoff_keywords: string[]
|
||||
booking_enabled: boolean
|
||||
}
|
||||
|
||||
export interface ChatbotPublic {
|
||||
@@ -87,6 +89,7 @@ export interface ChatbotFormData {
|
||||
handoff_message: string
|
||||
handoff_email: string
|
||||
handoff_keywords: string[]
|
||||
booking_enabled: boolean
|
||||
}
|
||||
|
||||
// ─── Document ─────────────────────────────────────────────────────────────────
|
||||
@@ -196,6 +199,8 @@ export interface ModelsResponse {
|
||||
}
|
||||
|
||||
// ─── Leads ────────────────────────────────────────────────────────────────────
|
||||
export type LeadStatus = 'new' | 'contacted' | 'qualified' | 'closed' | 'lost'
|
||||
|
||||
export interface Lead {
|
||||
id: string
|
||||
chatbot_id: string
|
||||
@@ -204,6 +209,8 @@ export interface Lead {
|
||||
name?: string
|
||||
phone?: string
|
||||
company?: string
|
||||
status: LeadStatus
|
||||
notes?: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
@@ -220,6 +227,8 @@ export interface UrlSource {
|
||||
}
|
||||
|
||||
// ─── Inbox ────────────────────────────────────────────────────────────────────
|
||||
export type ConversationStatus = 'open' | 'agent_handling' | 'resolved'
|
||||
|
||||
export interface InboxConversation {
|
||||
id: string
|
||||
chatbot_id: string
|
||||
@@ -228,12 +237,14 @@ export interface InboxConversation {
|
||||
language: string
|
||||
message_count: number
|
||||
first_message?: string
|
||||
status: ConversationStatus
|
||||
last_agent_reply_at?: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface InboxMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
role: 'user' | 'assistant' | 'agent'
|
||||
content: string
|
||||
sources?: SourceDocument[]
|
||||
confidence_score?: number
|
||||
@@ -241,17 +252,107 @@ export interface InboxMessage {
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
export interface ChannelConnection {
|
||||
id: string
|
||||
channel: 'telegram' | 'whatsapp'
|
||||
channel: 'telegram'
|
||||
bot_username?: string
|
||||
wa_keyword?: string
|
||||
wa_link?: string
|
||||
is_active: boolean
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
export interface ChatbotTemplate {
|
||||
id: string
|
||||
|
||||
Reference in New Issue
Block a user