mirror of
http://88.130.71.182:3000/BlitTech/contexta_fe.git
synced 2026-06-12 23:23: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 { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { useAuthStore } from '@/store/authStore'
|
import { useAuthStore } from '@/store/authStore'
|
||||||
import { AppLayout } from '@/components/Layout'
|
import { AppLayout } from '@/components/Layout'
|
||||||
|
import { AdminLayout } from '@/components/AdminLayout'
|
||||||
import { PublicLayout } from '@/components/PublicLayout'
|
import { PublicLayout } from '@/components/PublicLayout'
|
||||||
|
import { ErrorBoundary } from '@/components/ErrorBoundary'
|
||||||
import { Spinner } from '@/components/ui'
|
import { Spinner } from '@/components/ui'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
@@ -22,6 +24,16 @@ const AnalyticsPage = lazy(() => import('@/pages/AnalyticsPage').then(m => ({ de
|
|||||||
const PublicChatPage = lazy(() => import('@/pages/PublicChatPage').then(m => ({ default: m.PublicChatPage })))
|
const PublicChatPage = lazy(() => import('@/pages/PublicChatPage').then(m => ({ default: m.PublicChatPage })))
|
||||||
const InboxPage = lazy(() => import('@/pages/InboxPage').then(m => ({ default: m.InboxPage })))
|
const InboxPage = lazy(() => import('@/pages/InboxPage').then(m => ({ default: m.InboxPage })))
|
||||||
const LeadsPage = lazy(() => import('@/pages/LeadsPage').then(m => ({ default: m.LeadsPage })))
|
const LeadsPage = lazy(() => import('@/pages/LeadsPage').then(m => ({ default: m.LeadsPage })))
|
||||||
|
const AppointmentsPage = lazy(() => import('@/pages/AppointmentsPage').then(m => ({ default: m.AppointmentsPage })))
|
||||||
|
const CampaignsPage = lazy(() => import('@/pages/CampaignsPage').then(m => ({ default: m.CampaignsPage })))
|
||||||
|
const PublicBookingPage = lazy(() => import('@/pages/PublicBookingPage').then(m => ({ default: m.PublicBookingPage })))
|
||||||
|
|
||||||
|
// Admin pages
|
||||||
|
const AdminDashboardPage = lazy(() => import('@/pages/admin/AdminDashboardPage').then(m => ({ default: m.AdminDashboardPage })))
|
||||||
|
const AdminUsersPage = lazy(() => import('@/pages/admin/AdminUsersPage').then(m => ({ default: m.AdminUsersPage })))
|
||||||
|
const AdminChatbotsPage = lazy(() => import('@/pages/admin/AdminChatbotsPage').then(m => ({ default: m.AdminChatbotsPage })))
|
||||||
|
const AdminConversationsPage = lazy(() => import('@/pages/admin/AdminConversationsPage').then(m => ({ default: m.AdminConversationsPage })))
|
||||||
|
const AdminSystemPage = lazy(() => import('@/pages/admin/AdminSystemPage').then(m => ({ default: m.AdminSystemPage })))
|
||||||
|
|
||||||
const PageLoader = () => (
|
const PageLoader = () => (
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
@@ -35,6 +47,13 @@ const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
|||||||
return <AppLayout>{children}</AppLayout>
|
return <AppLayout>{children}</AppLayout>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AdminRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const { isAuthenticated, user } = useAuthStore()
|
||||||
|
if (!isAuthenticated) return <Navigate to="/login" replace />
|
||||||
|
if (!user?.is_admin) return <Navigate to="/dashboard" replace />
|
||||||
|
return <AdminLayout>{children}</AdminLayout>
|
||||||
|
}
|
||||||
|
|
||||||
const PublicOnlyRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
const PublicOnlyRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
const { isAuthenticated } = useAuthStore()
|
const { isAuthenticated } = useAuthStore()
|
||||||
if (isAuthenticated) return <Navigate to="/dashboard" replace />
|
if (isAuthenticated) return <Navigate to="/dashboard" replace />
|
||||||
@@ -50,38 +69,52 @@ const SmartPublicRoute: React.FC<{ children: React.ReactNode }> = ({ children })
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const App: React.FC = () => (
|
export const App: React.FC = () => (
|
||||||
<Suspense fallback={<PageLoader />}>
|
<ErrorBoundary>
|
||||||
<Routes>
|
<Suspense fallback={<PageLoader />}>
|
||||||
{/* Public - Landing has its own nav */}
|
<Routes>
|
||||||
<Route path="/" element={<LandingPage />} />
|
{/* Public - Landing has its own nav */}
|
||||||
|
<Route path="/" element={<LandingPage />} />
|
||||||
|
|
||||||
{/* Public pages - wrapped in SmartPublicRoute for proper nav */}
|
{/* Public pages - wrapped in SmartPublicRoute for proper nav */}
|
||||||
<Route path="/pricing" element={<SmartPublicRoute><PricingPage /></SmartPublicRoute>} />
|
<Route path="/pricing" element={<SmartPublicRoute><PricingPage /></SmartPublicRoute>} />
|
||||||
<Route path="/marketplace" element={<SmartPublicRoute><MarketplacePage /></SmartPublicRoute>} />
|
<Route path="/marketplace" element={<SmartPublicRoute><MarketplacePage /></SmartPublicRoute>} />
|
||||||
<Route path="/marketplace/:id" element={<SmartPublicRoute><ChatbotDetailPage /></SmartPublicRoute>} />
|
<Route path="/marketplace/:id" element={<SmartPublicRoute><ChatbotDetailPage /></SmartPublicRoute>} />
|
||||||
|
|
||||||
{/* Public chat - no auth, no layout */}
|
{/* Public chat - no auth, no layout */}
|
||||||
<Route path="/chat/:id" element={<PublicChatPage />} />
|
<Route path="/chat/:id" element={<PublicChatPage />} />
|
||||||
|
|
||||||
{/* Auth */}
|
{/* Public booking - no auth, no layout */}
|
||||||
<Route path="/login" element={<PublicOnlyRoute><LoginPage /></PublicOnlyRoute>} />
|
<Route path="/book/:chatbotId" element={<PublicBookingPage />} />
|
||||||
<Route path="/signup" element={<PublicOnlyRoute><SignupPage /></PublicOnlyRoute>} />
|
|
||||||
<Route path="/forgot-password" element={<PublicOnlyRoute><ForgotPasswordPage /></PublicOnlyRoute>} />
|
|
||||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
|
||||||
|
|
||||||
{/* Protected */}
|
{/* Auth */}
|
||||||
<Route path="/dashboard" element={<PrivateRoute><DashboardPage /></PrivateRoute>} />
|
<Route path="/login" element={<PublicOnlyRoute><LoginPage /></PublicOnlyRoute>} />
|
||||||
<Route path="/analytics" element={<PrivateRoute><AnalyticsPage /></PrivateRoute>} />
|
<Route path="/signup" element={<PublicOnlyRoute><SignupPage /></PublicOnlyRoute>} />
|
||||||
<Route path="/chatbots/new" element={<PrivateRoute><ChatbotBuilderPage /></PrivateRoute>} />
|
<Route path="/forgot-password" element={<PublicOnlyRoute><ForgotPasswordPage /></PublicOnlyRoute>} />
|
||||||
<Route path="/chatbots/:id/edit" element={<PrivateRoute><ChatbotBuilderPage /></PrivateRoute>} />
|
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||||
<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>} />
|
|
||||||
|
|
||||||
{/* Fallback */}
|
{/* Protected */}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="/dashboard" element={<PrivateRoute><DashboardPage /></PrivateRoute>} />
|
||||||
</Routes>
|
<Route path="/analytics" element={<PrivateRoute><AnalyticsPage /></PrivateRoute>} />
|
||||||
</Suspense>
|
<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 { getPlanColor } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, ShoppingBag, Settings,
|
LayoutDashboard, ShoppingBag, Settings,
|
||||||
LogOut, Menu, Sparkles, BarChart3, Mail, Users
|
LogOut, Menu, Sparkles, BarChart3, Mail, Users,
|
||||||
|
Shield, X, CalendarDays, Megaphone,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
{ label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||||
{ label: 'Inbox', href: '/inbox', icon: Mail },
|
{ label: 'Inbox', href: '/inbox', icon: Mail },
|
||||||
{ label: 'Leads', href: '/leads', icon: Users },
|
{ label: 'Leads', href: '/leads', icon: Users },
|
||||||
|
{ label: 'Appointments', href: '/appointments', icon: CalendarDays },
|
||||||
|
{ label: 'Campaigns', href: '/campaigns', icon: Megaphone },
|
||||||
{ label: 'Analytics', href: '/analytics', icon: BarChart3 },
|
{ label: 'Analytics', href: '/analytics', icon: BarChart3 },
|
||||||
{ label: 'Marketplace', href: '/marketplace', icon: ShoppingBag },
|
{ label: 'Marketplace', href: '/marketplace', icon: ShoppingBag },
|
||||||
{ label: 'Settings', href: '/settings', icon: Settings },
|
{ label: 'Settings', href: '/settings', icon: Settings },
|
||||||
@@ -25,101 +28,131 @@ export const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children })
|
|||||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try { await authAPI.logout() } catch { /* intentionally ignored */ }
|
try { await authAPI.logout() } catch { /* ignore */ }
|
||||||
logout()
|
logout()
|
||||||
navigate('/login')
|
navigate('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const initial = user?.email?.charAt(0).toUpperCase() || '?'
|
||||||
<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)} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Sidebar */}
|
return (
|
||||||
<aside className={cn(
|
<div className="flex h-screen bg-gray-50 overflow-hidden">
|
||||||
'fixed lg:relative z-30 flex flex-col w-64 h-full bg-white border-r border-gray-200 transition-transform duration-200',
|
{/* Mobile backdrop */}
|
||||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
{sidebarOpen && (
|
||||||
)}>
|
<div
|
||||||
{/* Logo */}
|
className="fixed inset-0 z-20 bg-black/30 backdrop-blur-[2px] lg:hidden transition-opacity"
|
||||||
<div className="flex items-center gap-2 px-6 py-5 border-b border-gray-100">
|
onClick={() => setSidebarOpen(false)}
|
||||||
<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">
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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" />
|
<Sparkles className="w-4 h-4 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-bold text-gray-900 text-lg tracking-tight">Contexta</span>
|
<span className="font-bold text-gray-900 text-lg tracking-tight">Contexta</span>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
{/* Nav */}
|
className="lg:hidden w-7 h-7 flex items-center justify-center rounded-lg hover:bg-gray-100 text-gray-400 transition-colors"
|
||||||
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
|
onClick={() => setSidebarOpen(false)}
|
||||||
{NAV_ITEMS.map(({ label, href, icon: Icon }) => {
|
>
|
||||||
const active = location.pathname.startsWith(href)
|
<X className="w-4 h-4" />
|
||||||
return (
|
</button>
|
||||||
<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>
|
|
||||||
</div>
|
</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>
|
||||||
|
</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 { cn } from '@/lib/utils'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
|
||||||
// ─── Button ────────────────────────────────────────────────────────────────────
|
// ─── Button ────────────────────────────────────────────────────────────────────
|
||||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
@@ -11,24 +12,48 @@ interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|||||||
export const Button: React.FC<ButtonProps> = ({
|
export const Button: React.FC<ButtonProps> = ({
|
||||||
variant = 'primary', size = 'md', loading, disabled, children, className, ...props
|
variant = 'primary', size = 'md', loading, disabled, children, className, ...props
|
||||||
}) => {
|
}) => {
|
||||||
const base = 'inline-flex items-center justify-center gap-2 font-medium rounded-lg transition-all focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed active:scale-95'
|
const base = cn(
|
||||||
|
'inline-flex items-center justify-center gap-2 font-medium rounded-xl',
|
||||||
|
'transition-all duration-200',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
|
||||||
|
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
|
'active:scale-[0.97] select-none',
|
||||||
|
)
|
||||||
const variants = {
|
const variants = {
|
||||||
primary: 'bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500',
|
primary: cn(
|
||||||
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-400',
|
'bg-gradient-to-b from-primary-500 to-primary-600 text-white',
|
||||||
outline: 'border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 focus:ring-primary-500',
|
'hover:from-primary-600 hover:to-primary-700',
|
||||||
ghost: 'text-gray-600 hover:bg-gray-100 focus:ring-gray-400',
|
'focus-visible:ring-primary-500',
|
||||||
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
|
'shadow-sm shadow-primary-200/60 hover:shadow-md hover:shadow-primary-300/40',
|
||||||
|
),
|
||||||
|
secondary: 'bg-gray-100 text-gray-800 hover:bg-gray-200 focus-visible:ring-gray-400',
|
||||||
|
outline: cn(
|
||||||
|
'border border-gray-200 bg-white text-gray-700',
|
||||||
|
'hover:bg-gray-50 hover:border-gray-300',
|
||||||
|
'focus-visible:ring-primary-500 shadow-sm',
|
||||||
|
),
|
||||||
|
ghost: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 focus-visible:ring-gray-400',
|
||||||
|
danger: cn(
|
||||||
|
'bg-gradient-to-b from-red-500 to-red-600 text-white',
|
||||||
|
'hover:from-red-600 hover:to-red-700',
|
||||||
|
'focus-visible:ring-red-500 shadow-sm',
|
||||||
|
),
|
||||||
|
}
|
||||||
|
const sizes = {
|
||||||
|
sm: 'px-3 py-1.5 text-xs gap-1.5',
|
||||||
|
md: 'px-4 py-2 text-sm',
|
||||||
|
lg: 'px-6 py-2.5 text-base',
|
||||||
}
|
}
|
||||||
const sizes = { sm: 'px-3 py-1.5 text-sm', md: 'px-4 py-2 text-sm', lg: 'px-6 py-3 text-base' }
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={cn(base, variants[variant], sizes[size], className)}
|
className={cn(base, variants[variant], sizes[size], className)}
|
||||||
disabled={disabled || loading}
|
disabled={disabled || loading}
|
||||||
|
aria-busy={loading}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{loading && (
|
{loading && (
|
||||||
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
<svg className="w-3.5 h-3.5 animate-spin shrink-0" viewBox="0 0 24 24" fill="none">
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -43,9 +68,10 @@ interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
|||||||
label?: string
|
label?: string
|
||||||
error?: string
|
error?: string
|
||||||
hint?: string
|
hint?: string
|
||||||
|
icon?: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Input: React.FC<InputProps> = ({ label, error, hint, className, id, ...props }) => {
|
export const Input: React.FC<InputProps> = ({ label, error, hint, icon, className, id, ...props }) => {
|
||||||
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
|
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
@@ -54,17 +80,31 @@ export const Input: React.FC<InputProps> = ({ label, error, hint, className, id,
|
|||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
<input
|
<div className="relative">
|
||||||
id={inputId}
|
{icon && (
|
||||||
className={cn(
|
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
|
||||||
'w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors',
|
{icon}
|
||||||
error ? 'border-red-400 bg-red-50' : 'border-gray-300 bg-white hover:border-gray-400',
|
</div>
|
||||||
className
|
|
||||||
)}
|
)}
|
||||||
{...props}
|
<input
|
||||||
/>
|
id={inputId}
|
||||||
{error && <p className="text-xs text-red-600">{error}</p>}
|
className={cn(
|
||||||
{hint && !error && <p className="text-xs text-gray-500">{hint}</p>}
|
'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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -80,18 +120,27 @@ export const Textarea: React.FC<TextareaProps> = ({ label, error, hint, classNam
|
|||||||
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
|
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
{label && <label htmlFor={inputId} className="text-sm font-medium text-gray-700">{label}</label>}
|
{label && (
|
||||||
|
<label htmlFor={inputId} className="text-sm font-medium text-gray-700">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<textarea
|
<textarea
|
||||||
id={inputId}
|
id={inputId}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none transition-colors',
|
'w-full px-3.5 py-2.5 border rounded-xl text-sm resize-none',
|
||||||
error ? 'border-red-400 bg-red-50' : 'border-gray-300 bg-white hover:border-gray-400',
|
'transition-all duration-150',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-primary-500/30 focus:border-primary-400',
|
||||||
|
'placeholder:text-gray-400',
|
||||||
|
error
|
||||||
|
? 'border-red-300 bg-red-50/50'
|
||||||
|
: 'border-gray-200 bg-white hover:border-gray-300',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
{error && <p className="text-xs text-red-600">{error}</p>}
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||||
{hint && !error && <p className="text-xs text-gray-500">{hint}</p>}
|
{hint && !error && <p className="text-xs text-gray-400">{hint}</p>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -107,12 +156,18 @@ export const Select: React.FC<SelectProps> = ({ label, error, options, className
|
|||||||
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
|
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
{label && <label htmlFor={inputId} className="text-sm font-medium text-gray-700">{label}</label>}
|
{label && (
|
||||||
|
<label htmlFor={inputId} className="text-sm font-medium text-gray-700">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<select
|
<select
|
||||||
id={inputId}
|
id={inputId}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white',
|
'w-full px-3.5 py-2.5 border rounded-xl text-sm bg-white',
|
||||||
error ? 'border-red-400' : 'border-gray-300',
|
'transition-all duration-150',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-primary-500/30 focus:border-primary-400',
|
||||||
|
error ? 'border-red-300' : 'border-gray-200 hover:border-gray-300',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -121,19 +176,21 @@ export const Select: React.FC<SelectProps> = ({ label, error, options, className
|
|||||||
<option key={o.value} value={o.value} disabled={o.disabled}>{o.label}</option>
|
<option key={o.value} value={o.value} disabled={o.disabled}>{o.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{error && <p className="text-xs text-red-600">{error}</p>}
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Card ──────────────────────────────────────────────────────────────────────
|
// ─── Card ──────────────────────────────────────────────────────────────────────
|
||||||
export const Card: React.FC<{ children: React.ReactNode; className?: string; onClick?: () => void }> = ({
|
export const Card: React.FC<{
|
||||||
children, className, onClick
|
children: React.ReactNode
|
||||||
}) => (
|
className?: string
|
||||||
|
onClick?: () => void
|
||||||
|
}> = ({ children, className, onClick }) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-white rounded-xl border border-gray-200 shadow-sm transition-all duration-200',
|
'bg-white rounded-2xl border border-gray-100 shadow-sm transition-all duration-200',
|
||||||
onClick && 'cursor-pointer hover:-translate-y-1 hover:shadow-lg hover:border-gray-300',
|
onClick && 'cursor-pointer hover:-translate-y-0.5 hover:shadow-md hover:border-gray-200',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@@ -151,38 +208,50 @@ interface BadgeProps {
|
|||||||
|
|
||||||
export const Badge: React.FC<BadgeProps> = ({ children, variant = 'default', className }) => {
|
export const Badge: React.FC<BadgeProps> = ({ children, variant = 'default', className }) => {
|
||||||
const variants = {
|
const variants = {
|
||||||
default: 'bg-gray-100 text-gray-700',
|
default: 'bg-gray-100 text-gray-600 border-gray-200',
|
||||||
success: 'bg-green-100 text-green-700',
|
success: 'bg-green-50 text-green-700 border-green-200',
|
||||||
warning: 'bg-yellow-100 text-yellow-700',
|
warning: 'bg-amber-50 text-amber-700 border-amber-200',
|
||||||
error: 'bg-red-100 text-red-700',
|
error: 'bg-red-50 text-red-600 border-red-200',
|
||||||
info: 'bg-blue-100 text-blue-700',
|
info: 'bg-blue-50 text-blue-700 border-blue-200',
|
||||||
purple: 'bg-purple-100 text-purple-700',
|
purple: 'bg-purple-50 text-purple-700 border-purple-200',
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span className={cn('inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full', variants[variant], className)}>
|
<span className={cn(
|
||||||
|
'inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full border',
|
||||||
|
variants[variant], className
|
||||||
|
)}>
|
||||||
{children}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Spinner ───────────────────────────────────────────────────────────────────
|
// ─── Spinner ───────────────────────────────────────────────────────────────────
|
||||||
export const Spinner: React.FC<{ className?: string }> = ({ className }) => (
|
export const Spinner: React.FC<{ className?: string; size?: 'sm' | 'md' | 'lg' }> = ({
|
||||||
<svg className={cn('animate-spin h-5 w-5', className)} viewBox="0 0 24 24" fill="none">
|
className, size = 'md'
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
}) => {
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
const sizes = { sm: 'h-4 w-4', md: 'h-5 w-5', lg: 'h-7 w-7' }
|
||||||
</svg>
|
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 ───────────────────────────────────────────────────────────────
|
// ─── Empty State ───────────────────────────────────────────────────────────────
|
||||||
export const EmptyState: React.FC<{
|
export const EmptyState: React.FC<{
|
||||||
icon: React.ReactNode; title: string; description: string; action?: React.ReactNode
|
icon: React.ReactNode
|
||||||
}> = ({ icon, title, description, action }) => (
|
title: string
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center animate-fade-in-up">
|
description: string
|
||||||
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-primary-50 to-primary-100 flex items-center justify-center mb-4 text-primary-400 shadow-sm">
|
action?: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}> = ({ icon, title, description, action, className }) => (
|
||||||
|
<div className={cn('flex flex-col items-center justify-center py-16 text-center animate-fade-in-up px-4', className)}>
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-primary-50 to-primary-100 flex items-center justify-center mb-5 text-primary-500 shadow-sm ring-1 ring-primary-100">
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
|
<h3 className="text-base font-semibold text-gray-900 mb-1.5">{title}</h3>
|
||||||
<p className="text-sm text-gray-500 max-w-sm mb-6">{description}</p>
|
<p className="text-sm text-gray-500 max-w-xs mb-6 leading-relaxed">{description}</p>
|
||||||
{action}
|
{action}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -194,21 +263,43 @@ interface ModalProps {
|
|||||||
title?: string
|
title?: string
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, className }) => {
|
export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, className, size = 'md' }) => {
|
||||||
|
const sizes = { sm: 'max-w-sm', md: 'max-w-lg', lg: 'max-w-2xl' }
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKey)
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
}
|
||||||
|
}, [isOpen, onClose])
|
||||||
|
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 animate-fade-in">
|
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4 animate-fade-in">
|
||||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
|
<div className="absolute inset-0 bg-black/40 backdrop-blur-[2px]" onClick={onClose} />
|
||||||
<div className={cn('relative bg-white rounded-2xl shadow-2xl w-full max-w-lg animate-scale-in', className)}>
|
<div className={cn(
|
||||||
|
'relative bg-white w-full sm:rounded-2xl shadow-2xl animate-scale-in',
|
||||||
|
'rounded-t-2xl',
|
||||||
|
sizes[size],
|
||||||
|
className
|
||||||
|
)}>
|
||||||
{title && (
|
{title && (
|
||||||
<div className="flex items-center justify-between p-6 border-b">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||||||
<h2 className="text-lg font-semibold">{title}</h2>
|
<h2 className="text-base font-semibold text-gray-900">{title}</h2>
|
||||||
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded-lg text-gray-500">
|
<button
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
onClick={onClose}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
className="w-8 h-8 flex items-center justify-center hover:bg-gray-100 rounded-lg text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
</svg>
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -218,11 +309,11 @@ export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children,
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Toast ─────────────────────────────────────────────────────────────────────
|
// ─── Toast (kept for legacy usage) ────────────────────────────────────────────
|
||||||
interface ToastProps { message: string; type?: 'success' | 'error' | 'info'; onClose: () => void }
|
interface ToastProps { message: string; type?: 'success' | 'error' | 'info'; onClose: () => void }
|
||||||
|
|
||||||
export const Toast: React.FC<ToastProps> = ({ message, type = 'info', onClose }) => {
|
export const Toast: React.FC<ToastProps> = ({ message, type = 'info', onClose }) => {
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
const t = setTimeout(onClose, 4000)
|
const t = setTimeout(onClose, 4000)
|
||||||
return () => clearTimeout(t)
|
return () => clearTimeout(t)
|
||||||
}, [onClose])
|
}, [onClose])
|
||||||
@@ -233,9 +324,15 @@ export const Toast: React.FC<ToastProps> = ({ message, type = 'info', onClose })
|
|||||||
info: 'bg-blue-50 border-blue-200 text-blue-800',
|
info: 'bg-blue-50 border-blue-200 text-blue-800',
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className={cn('fixed bottom-4 right-4 z-50 max-w-sm p-4 rounded-xl border shadow-lg flex items-start gap-3 animate-in slide-in-from-bottom-2', styles[type])}>
|
<div className={cn(
|
||||||
|
'fixed bottom-4 right-4 z-50 max-w-sm px-4 py-3 rounded-xl border shadow-lg',
|
||||||
|
'flex items-start gap-3 animate-fade-in-up',
|
||||||
|
styles[type]
|
||||||
|
)}>
|
||||||
<span className="flex-1 text-sm font-medium">{message}</span>
|
<span className="flex-1 text-sm font-medium">{message}</span>
|
||||||
<button onClick={onClose} className="text-current opacity-60 hover:opacity-100">×</button>
|
<button onClick={onClose} className="text-current opacity-50 hover:opacity-100 transition-opacity">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -244,12 +341,42 @@ export const Toast: React.FC<ToastProps> = ({ message, type = 'info', onClose })
|
|||||||
export const StatusDot: React.FC<{ status: string }> = ({ status }) => {
|
export const StatusDot: React.FC<{ status: string }> = ({ status }) => {
|
||||||
const colors: Record<string, string> = {
|
const colors: Record<string, string> = {
|
||||||
completed: 'bg-green-500',
|
completed: 'bg-green-500',
|
||||||
processing: 'bg-yellow-500 animate-pulse',
|
processing: 'bg-amber-400 animate-pulse',
|
||||||
pending: 'bg-gray-400',
|
pending: 'bg-gray-300',
|
||||||
failed: 'bg-red-500',
|
failed: 'bg-red-500',
|
||||||
active: 'bg-green-500',
|
active: 'bg-green-500',
|
||||||
published: 'bg-green-500',
|
published: 'bg-green-500',
|
||||||
preview: 'bg-gray-400',
|
preview: 'bg-gray-300',
|
||||||
}
|
}
|
||||||
return <span className={cn('w-2 h-2 rounded-full inline-block', colors[status] || 'bg-gray-400')} />
|
return (
|
||||||
|
<span className={cn(
|
||||||
|
'w-1.5 h-1.5 rounded-full inline-block shrink-0',
|
||||||
|
colors[status] || 'bg-gray-300'
|
||||||
|
)} />
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Divider ───────────────────────────────────────────────────────────────────
|
||||||
|
export const Divider: React.FC<{ label?: string; className?: string }> = ({ label, className }) => (
|
||||||
|
<div className={cn('flex items-center gap-3 my-2', className)}>
|
||||||
|
<div className="flex-1 h-px bg-gray-100" />
|
||||||
|
{label && <span className="text-xs text-gray-400 font-medium">{label}</span>}
|
||||||
|
<div className="flex-1 h-px bg-gray-100" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Section Header ────────────────────────────────────────────────────────────
|
||||||
|
export const SectionHeader: React.FC<{
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
action?: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}> = ({ title, description, action, className }) => (
|
||||||
|
<div className={cn('flex items-start justify-between gap-4', className)}>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900">{title}</h2>
|
||||||
|
{description && <p className="text-xs text-gray-500 mt-0.5">{description}</p>}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|||||||
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 base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
*, *::before, *::after {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,38 +14,51 @@
|
|||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-gray-50 text-gray-900;
|
@apply bg-gray-50 text-gray-900;
|
||||||
|
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
/* Selection */
|
||||||
width: 6px;
|
::selection {
|
||||||
height: 6px;
|
@apply bg-primary-100 text-primary-900;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
/* Focus visible - keyboard nav only */
|
||||||
@apply bg-transparent;
|
:focus-visible {
|
||||||
|
outline: 2px solid theme('colors.primary.500');
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
/* Scrollbar */
|
||||||
@apply bg-gray-300 rounded-full;
|
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||||
}
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { @apply bg-gray-200 rounded-full; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { @apply bg-gray-300; }
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
h1, h2, h3, h4, h5, h6 {
|
||||||
@apply bg-gray-400;
|
font-feature-settings: 'ss01';
|
||||||
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
/* Line clamp */
|
||||||
|
.line-clamp-1 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
.line-clamp-2 {
|
.line-clamp-2 {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.line-clamp-3 {
|
.line-clamp-3 {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 3;
|
-webkit-line-clamp: 3;
|
||||||
@@ -54,17 +66,17 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Animation utilities */
|
||||||
.animate-in {
|
.animate-in {
|
||||||
animation-fill-mode: both;
|
animation-fill-mode: both;
|
||||||
animation-duration: 200ms;
|
animation-duration: 200ms;
|
||||||
}
|
}
|
||||||
|
.slide-in-from-bottom-2 { animation-name: slideInFromBottom; }
|
||||||
|
|
||||||
.slide-in-from-bottom-2 {
|
/* Staggered delays */
|
||||||
animation-name: slideInFromBottom;
|
.delay-75 { animation-delay: 75ms; }
|
||||||
}
|
|
||||||
|
|
||||||
/* Staggered animation delay utilities */
|
|
||||||
.delay-100 { animation-delay: 100ms; }
|
.delay-100 { animation-delay: 100ms; }
|
||||||
|
.delay-150 { animation-delay: 150ms; }
|
||||||
.delay-200 { animation-delay: 200ms; }
|
.delay-200 { animation-delay: 200ms; }
|
||||||
.delay-300 { animation-delay: 300ms; }
|
.delay-300 { animation-delay: 300ms; }
|
||||||
.delay-400 { animation-delay: 400ms; }
|
.delay-400 { animation-delay: 400ms; }
|
||||||
@@ -73,70 +85,96 @@
|
|||||||
.delay-700 { animation-delay: 700ms; }
|
.delay-700 { animation-delay: 700ms; }
|
||||||
.delay-800 { animation-delay: 800ms; }
|
.delay-800 { animation-delay: 800ms; }
|
||||||
|
|
||||||
/* Gradient text utility */
|
/* Gradient text */
|
||||||
.text-gradient {
|
.text-gradient {
|
||||||
@apply bg-clip-text text-transparent;
|
@apply bg-clip-text text-transparent;
|
||||||
background-image: linear-gradient(135deg, #4f46e5, #7c3aed, #2563eb);
|
background-image: linear-gradient(135deg, #4f46e5 0%, #7c3aed 50%, #2563eb 100%);
|
||||||
background-size: 200% 200%;
|
background-size: 200% 200%;
|
||||||
animation: gradientX 8s ease infinite;
|
animation: gradientX 8s ease infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-gradient-warm {
|
||||||
|
@apply bg-clip-text text-transparent;
|
||||||
|
background-image: linear-gradient(135deg, #f59e0b 0%, #ef4444 50%, #ec4899 100%);
|
||||||
|
}
|
||||||
|
|
||||||
/* Glass morphism */
|
/* Glass morphism */
|
||||||
.glass {
|
.glass {
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(16px) saturate(180%);
|
||||||
-webkit-backdrop-filter: blur(12px);
|
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||||
background: rgba(255, 255, 255, 0.7);
|
background: rgba(255, 255, 255, 0.75);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-dark {
|
.glass-dark {
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(16px) saturate(180%);
|
||||||
-webkit-backdrop-filter: blur(12px);
|
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: rgba(17, 24, 39, 0.8);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Glow effects */
|
/* Glow effects */
|
||||||
.glow-primary {
|
.glow-primary {
|
||||||
box-shadow: 0 0 20px rgba(79, 70, 229, 0.3), 0 0 60px rgba(79, 70, 229, 0.1);
|
box-shadow: 0 0 24px rgba(79, 70, 229, 0.25), 0 0 60px rgba(79, 70, 229, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.glow-primary-lg {
|
.glow-primary-lg {
|
||||||
box-shadow: 0 0 40px rgba(79, 70, 229, 0.4), 0 0 100px rgba(79, 70, 229, 0.15);
|
box-shadow: 0 0 48px rgba(79, 70, 229, 0.35), 0 0 100px rgba(79, 70, 229, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Noise texture overlay */
|
/* Background patterns */
|
||||||
|
.bg-grid {
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(79, 70, 229, 0.04) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(79, 70, 229, 0.04) 1px, transparent 1px);
|
||||||
|
background-size: 40px 40px;
|
||||||
|
}
|
||||||
|
.bg-dots {
|
||||||
|
background-image: radial-gradient(circle, rgba(79, 70, 229, 0.07) 1px, transparent 1px);
|
||||||
|
background-size: 24px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Noise texture */
|
||||||
.noise-overlay::after {
|
.noise-overlay::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E");
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.03'/%3E%3C/svg%3E");
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Grid background pattern */
|
/* Gradient borders */
|
||||||
.bg-grid {
|
.border-gradient {
|
||||||
background-image:
|
position: relative;
|
||||||
linear-gradient(rgba(79, 70, 229, 0.03) 1px, transparent 1px),
|
background: white;
|
||||||
linear-gradient(90deg, rgba(79, 70, 229, 0.03) 1px, transparent 1px);
|
background-clip: padding-box;
|
||||||
background-size: 40px 40px;
|
border: 1.5px solid transparent;
|
||||||
|
}
|
||||||
|
.border-gradient::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -1.5px;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(135deg, #a5b4fc, #818cf8, #c4b5fd);
|
||||||
|
z-index: -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dot grid pattern */
|
/* Smooth number transition */
|
||||||
.bg-dots {
|
.tabular-nums {
|
||||||
background-image: radial-gradient(circle, rgba(79, 70, 229, 0.08) 1px, transparent 1px);
|
font-variant-numeric: tabular-nums;
|
||||||
background-size: 24px 24px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideInFromBottom {
|
@keyframes slideInFromBottom {
|
||||||
from {
|
from { transform: translateY(10px); opacity: 0; }
|
||||||
transform: translateY(8px);
|
to { transform: translateY(0); opacity: 1; }
|
||||||
opacity: 0;
|
}
|
||||||
|
|
||||||
|
/* 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 { BrowserRouter } from 'react-router-dom'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { App } from './App'
|
import { App } from './App'
|
||||||
|
import { ToastProvider } from '@/contexts/ToastContext'
|
||||||
|
import { initTheme } from '@/store/themeStore'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
|
initTheme()
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
@@ -18,8 +22,10 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
|||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<App />
|
<ToastProvider>
|
||||||
|
<App />
|
||||||
|
</ToastProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import React, { useState } from 'react'
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { analyticsAPI } from '@/services/api'
|
import { analyticsAPI } from '@/services/api'
|
||||||
import { Card, Spinner, Button, Badge } from '@/components/ui'
|
import { Card, Button, Badge } from '@/components/ui'
|
||||||
import {
|
import {
|
||||||
BarChart3, Users, MessageSquare, Star,
|
BarChart3, Users, MessageSquare, Star,
|
||||||
Clock, Globe, Lock, Bot,
|
Clock, Globe, Lock, Bot,
|
||||||
ChevronDown, ChevronUp
|
ChevronDown, ChevronUp, TrendingUp, AlertCircle, ThumbsUp, ThumbsDown
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { SkeletonStatCard } from '@/components/Skeletons'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
// ANALYTICS PAGE — Available for Starter and Pro plans
|
// ANALYTICS PAGE — Available for Starter and Pro plans
|
||||||
@@ -63,8 +65,14 @@ interface OverviewData {
|
|||||||
|
|
||||||
// ─── Mini bar chart component ─────────────────────────────────────────────────
|
// ─── Mini bar chart component ─────────────────────────────────────────────────
|
||||||
const MiniBarChart: React.FC<{ data: DailyConversation[] }> = ({ data }) => {
|
const MiniBarChart: React.FC<{ data: DailyConversation[] }> = ({ data }) => {
|
||||||
|
const [tooltip, setTooltip] = useState<{ date: string; count: number; idx: number } | null>(null)
|
||||||
|
|
||||||
if (!data.length) {
|
if (!data.length) {
|
||||||
return <div className="text-xs text-gray-400 italic py-4">No data yet</div>
|
return (
|
||||||
|
<div className="flex items-center justify-center h-16 text-xs text-gray-400 italic">
|
||||||
|
No data yet
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const max = Math.max(...data.map(d => d.count), 1)
|
const max = Math.max(...data.map(d => d.count), 1)
|
||||||
@@ -81,15 +89,50 @@ const MiniBarChart: React.FC<{ data: DailyConversation[] }> = ({ data }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-end gap-[2px] h-16">
|
<div className="relative">
|
||||||
{days.map((d) => (
|
{/* Grid lines */}
|
||||||
|
<div className="absolute inset-0 flex flex-col justify-between pointer-events-none" style={{ height: '64px' }}>
|
||||||
|
{[0, 1, 2, 3].map(i => (
|
||||||
|
<div key={i} className="w-full border-t border-gray-100/80" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-end gap-[2px] h-16 relative z-10">
|
||||||
|
{days.map((d, idx) => (
|
||||||
|
<div
|
||||||
|
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
|
<div
|
||||||
key={d.date}
|
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"
|
||||||
className="flex-1 min-w-[3px] rounded-t-sm bg-primary-400 hover:bg-primary-600 transition-colors cursor-default group relative"
|
style={{
|
||||||
style={{ height: `${Math.max((d.count / max) * 100, d.count > 0 ? 8 : 2)}%` }}
|
left: `${(tooltip.idx / days.length) * 100}%`,
|
||||||
title={`${d.date}: ${d.count} conversations`}
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -101,16 +144,28 @@ const StatCard: React.FC<{
|
|||||||
icon: React.ReactNode
|
icon: React.ReactNode
|
||||||
subtitle?: string
|
subtitle?: string
|
||||||
color?: string
|
color?: string
|
||||||
}> = ({ label, value, icon, subtitle, color = 'primary' }) => (
|
trend?: number | null
|
||||||
<Card className="p-4">
|
}> = ({ label, value, icon, subtitle, color = 'primary', trend }) => (
|
||||||
<div className="flex items-start justify-between mb-2">
|
<Card className="p-5 hover:shadow-md transition-all duration-200">
|
||||||
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">{label}</span>
|
<div className="flex items-start justify-between mb-3">
|
||||||
<div className={`w-8 h-8 rounded-lg bg-${color}-50 flex items-center justify-center text-${color}-600`}>
|
<div className={`w-9 h-9 rounded-xl bg-${color}-50 flex items-center justify-center text-${color}-600`}>
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
|
{trend != null && (
|
||||||
|
<div className={cn(
|
||||||
|
'flex items-center gap-0.5 text-xs font-medium px-1.5 py-0.5 rounded-full',
|
||||||
|
trend >= 0 ? 'bg-emerald-50 text-emerald-600' : 'bg-red-50 text-red-500'
|
||||||
|
)}>
|
||||||
|
<TrendingUp className={cn('w-3 h-3', trend < 0 && 'rotate-180')} />
|
||||||
|
{Math.abs(trend)}%
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-bold text-gray-900">{typeof value === 'number' ? value.toLocaleString() : value}</div>
|
<div className="text-2xl font-bold text-gray-900 mb-1">
|
||||||
{subtitle && <p className="text-xs text-gray-500 mt-1">{subtitle}</p>}
|
{typeof value === 'number' ? value.toLocaleString() : value}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">{label}</p>
|
||||||
|
{subtitle && <p className="text-xs text-gray-400 mt-0.5">{subtitle}</p>}
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -122,20 +177,31 @@ const UsageBar: React.FC<{ used: number; limit: number }> = ({ used, limit }) =>
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between text-xs mb-1.5">
|
<div className="flex items-center justify-between text-xs mb-2">
|
||||||
<span className="text-gray-600 font-medium">Monthly conversations</span>
|
<span className="text-gray-600 font-medium">Monthly conversations</span>
|
||||||
<span className={isFull ? 'text-red-600 font-semibold' : isHigh ? 'text-amber-600 font-medium' : 'text-gray-500'}>
|
<span className={cn(
|
||||||
|
'font-semibold',
|
||||||
|
isFull ? 'text-red-600' : isHigh ? 'text-amber-600' : 'text-gray-600'
|
||||||
|
)}>
|
||||||
{used.toLocaleString()} / {limit.toLocaleString()}
|
{used.toLocaleString()} / {limit.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={`h-full rounded-full transition-all duration-500 ${
|
className={cn(
|
||||||
|
'h-full rounded-full transition-all duration-700',
|
||||||
isFull ? 'bg-red-500' : isHigh ? 'bg-amber-400' : 'bg-primary-500'
|
isFull ? 'bg-red-500' : isHigh ? 'bg-amber-400' : 'bg-primary-500'
|
||||||
}`}
|
)}
|
||||||
style={{ width: `${pct}%` }}
|
style={{ width: `${pct}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex justify-between mt-1.5">
|
||||||
|
<span className="text-[10px] text-gray-400">0</span>
|
||||||
|
<span className={cn('text-[10px] font-medium', isFull ? 'text-red-500' : isHigh ? 'text-amber-500' : 'text-gray-400')}>
|
||||||
|
{Math.round(pct)}% used
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-gray-400">{limit.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -144,139 +210,194 @@ const UsageBar: React.FC<{ used: number; limit: number }> = ({ used, limit }) =>
|
|||||||
const ChatbotRow: React.FC<{ chatbot: ChatbotAnalytics }> = ({ chatbot }) => {
|
const ChatbotRow: React.FC<{ chatbot: ChatbotAnalytics }> = ({ chatbot }) => {
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
|
||||||
|
const feedbackTotal = chatbot.feedback_positive + chatbot.feedback_negative
|
||||||
|
const helpfulPct = feedbackTotal > 0
|
||||||
|
? Math.round((chatbot.feedback_positive / feedbackTotal) * 100)
|
||||||
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden hover:shadow-md transition-all duration-200">
|
||||||
<button
|
<button
|
||||||
onClick={() => setExpanded(!expanded)}
|
onClick={() => setExpanded(!expanded)}
|
||||||
className="w-full p-4 flex items-center justify-between hover:bg-gray-50 transition-colors text-left"
|
className="w-full p-5 flex items-center justify-between hover:bg-gray-50/80 transition-colors text-left"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-9 h-9 rounded-lg bg-primary-50 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center flex-shrink-0">
|
||||||
<Bot className="w-4 h-4 text-primary-600" />
|
<Bot className="w-5 h-5 text-primary-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-gray-900 text-sm">{chatbot.chatbot_name}</h3>
|
<h3 className="font-semibold text-gray-900">{chatbot.chatbot_name}</h3>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
{chatbot.total_conversations} conversations · {chatbot.unique_sessions} users
|
{chatbot.total_conversations.toLocaleString()} conversations · {chatbot.unique_sessions.toLocaleString()} users
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-3 sm:gap-4">
|
||||||
{chatbot.average_rating && (
|
{chatbot.average_rating && (
|
||||||
<div className="flex items-center gap-1 text-xs text-amber-600">
|
<div className="hidden sm:flex items-center gap-1 text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded-lg">
|
||||||
<Star className="w-3 h-3 fill-amber-400" />
|
<Star className="w-3 h-3 fill-amber-400 text-amber-400" />
|
||||||
{chatbot.average_rating.toFixed(1)}
|
<span className="font-semibold">{chatbot.average_rating.toFixed(1)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500 bg-gray-100 px-2.5 py-1 rounded-lg font-medium">
|
||||||
{chatbot.conversations_today} today
|
{chatbot.conversations_today} today
|
||||||
</div>
|
</div>
|
||||||
{expanded ? <ChevronUp className="w-4 h-4 text-gray-400" /> : <ChevronDown className="w-4 h-4 text-gray-400" />}
|
<div className="p-1 rounded-lg text-gray-400">
|
||||||
|
{expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="border-t border-gray-100 p-4 space-y-4 bg-gray-50/50">
|
<div className="border-t border-gray-100 p-5 space-y-5 bg-gray-50/40">
|
||||||
|
|
||||||
{/* Stats row */}
|
{/* Stats row */}
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
<div className="bg-white rounded-lg p-3 border border-gray-100">
|
{[
|
||||||
<p className="text-xs text-gray-500">Today</p>
|
{ label: 'Today', value: chatbot.conversations_today },
|
||||||
<p className="text-lg font-bold text-gray-900">{chatbot.conversations_today}</p>
|
{ label: 'This week', value: chatbot.conversations_this_week },
|
||||||
</div>
|
{ label: 'This month', value: chatbot.conversations_this_month },
|
||||||
<div className="bg-white rounded-lg p-3 border border-gray-100">
|
{ label: 'Avg msgs/convo', value: chatbot.average_messages_per_conversation },
|
||||||
<p className="text-xs text-gray-500">This week</p>
|
].map(({ label, value }) => (
|
||||||
<p className="text-lg font-bold text-gray-900">{chatbot.conversations_this_week}</p>
|
<div key={label} className="bg-white rounded-xl p-4 border border-gray-100 shadow-sm text-center">
|
||||||
</div>
|
<p className="text-xs text-gray-500 mb-1">{label}</p>
|
||||||
<div className="bg-white rounded-lg p-3 border border-gray-100">
|
<p className="text-xl font-bold text-gray-900">{value}</p>
|
||||||
<p className="text-xs text-gray-500">This month</p>
|
</div>
|
||||||
<p className="text-lg font-bold text-gray-900">{chatbot.conversations_this_month}</p>
|
))}
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg p-3 border border-gray-100">
|
|
||||||
<p className="text-xs text-gray-500">Avg messages/convo</p>
|
|
||||||
<p className="text-lg font-bold text-gray-900">{chatbot.average_messages_per_conversation}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Daily chart */}
|
{/* Daily chart */}
|
||||||
<div className="bg-white rounded-lg p-3 border border-gray-100">
|
<div className="bg-white rounded-xl p-4 border border-gray-100 shadow-sm">
|
||||||
<p className="text-xs font-medium text-gray-500 mb-2">Last 30 days</p>
|
<p className="text-xs font-semibold text-gray-600 mb-3 uppercase tracking-wide">Last 30 days</p>
|
||||||
<MiniBarChart data={chatbot.daily_conversations} />
|
<MiniBarChart data={chatbot.daily_conversations} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top queries & Languages side by side */}
|
{/* Top queries & Languages */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
|
||||||
{chatbot.top_queries.length > 0 && (
|
{chatbot.top_queries.length > 0 && (
|
||||||
<div className="bg-white rounded-lg p-3 border border-gray-100">
|
<div className="bg-white rounded-xl p-4 border border-gray-100 shadow-sm">
|
||||||
<p className="text-xs font-medium text-gray-500 mb-2">Top questions</p>
|
<p className="text-xs font-semibold text-gray-600 mb-3 uppercase tracking-wide">Top questions</p>
|
||||||
<ul className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
{chatbot.top_queries.slice(0, 5).map((q, i) => (
|
{chatbot.top_queries.slice(0, 5).map((q, i) => (
|
||||||
<li key={i} className="flex items-start gap-2 text-xs">
|
<div key={i} className="flex items-start gap-2">
|
||||||
<span className="text-gray-400 font-mono">{i + 1}.</span>
|
<span className="w-5 h-5 rounded-full bg-primary-50 text-primary-600 text-[10px] font-bold flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
<span className="text-gray-700 flex-1 truncate">{q.query}</span>
|
{i + 1}
|
||||||
<span className="text-gray-400">{q.count}×</span>
|
</span>
|
||||||
</li>
|
<span className="text-sm text-gray-700 flex-1 leading-snug">{q.query}</span>
|
||||||
|
<span className="text-xs text-gray-400 bg-gray-100 px-1.5 py-0.5 rounded-full flex-shrink-0 font-medium">
|
||||||
|
{q.count}×
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{Object.keys(chatbot.languages_used).length > 0 && (
|
{Object.keys(chatbot.languages_used).length > 0 && (
|
||||||
<div className="bg-white rounded-lg p-3 border border-gray-100">
|
<div className="bg-white rounded-xl p-4 border border-gray-100 shadow-sm">
|
||||||
<p className="text-xs font-medium text-gray-500 mb-2">Languages</p>
|
<p className="text-xs font-semibold text-gray-600 mb-3 uppercase tracking-wide">Languages</p>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
{Object.entries(chatbot.languages_used)
|
{Object.entries(chatbot.languages_used)
|
||||||
.sort(([, a], [, b]) => b - a)
|
.sort(([, a], [, b]) => b - a)
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
.map(([lang, count]) => (
|
.map(([lang, count]) => {
|
||||||
<div key={lang} className="flex items-center gap-2 text-xs">
|
const total = Object.values(chatbot.languages_used).reduce((a, b) => a + b, 0)
|
||||||
<Globe className="w-3 h-3 text-gray-400" />
|
const pct = total > 0 ? Math.round((count / total) * 100) : 0
|
||||||
<span className="text-gray-700 uppercase">{lang}</span>
|
return (
|
||||||
<span className="text-gray-400 ml-auto">{count} convos</span>
|
<div key={lang}>
|
||||||
</div>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Knowledge Gaps */}
|
{/* Knowledge Gaps — Phase 3: actionable suggestions */}
|
||||||
{chatbot.unanswered_queries && chatbot.unanswered_queries.length > 0 && (
|
{chatbot.unanswered_queries && chatbot.unanswered_queries.length > 0 && (
|
||||||
<div className="bg-amber-50 rounded-lg p-3 border border-amber-100">
|
<div className="bg-amber-50 rounded-xl p-4 border border-amber-100 space-y-3">
|
||||||
<p className="text-xs font-medium text-amber-700 mb-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
Knowledge Gaps — questions your bot couldn't answer well:
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4 text-amber-600 flex-shrink-0" />
|
||||||
|
<p className="text-xs font-semibold text-amber-700 uppercase tracking-wide">
|
||||||
|
Knowledge gaps — {chatbot.unanswered_count} unanswered
|
||||||
|
</p>
|
||||||
|
</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>
|
</p>
|
||||||
<ul className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
{chatbot.unanswered_queries.slice(0, 5).map((q, i) => (
|
{chatbot.unanswered_queries.slice(0, 6).map((q, i) => (
|
||||||
<li key={i} className="flex items-start gap-2 text-xs">
|
<div
|
||||||
<span className="text-amber-400 font-mono">{i + 1}.</span>
|
key={i}
|
||||||
<span className="text-amber-800 flex-1 truncate">{q.query}</span>
|
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-amber-500">{q.count}×</span>
|
>
|
||||||
</li>
|
<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>
|
||||||
</div>
|
{chatbot.unanswered_queries.length > 6 && (
|
||||||
)}
|
<p className="text-xs text-amber-500 text-center">
|
||||||
|
+{chatbot.unanswered_queries.length - 6} more gaps
|
||||||
{/* Feedback */}
|
</p>
|
||||||
{(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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{chatbot.peak_hour !== null && (
|
{/* Feedback & Peak hour */}
|
||||||
<div className="text-xs text-gray-500 flex items-center gap-1">
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
<Clock className="w-3 h-3" />
|
{feedbackTotal > 0 && (
|
||||||
Peak hour: {chatbot.peak_hour}:00 - {chatbot.peak_hour + 1}:00
|
<div className="flex items-center gap-3 bg-white rounded-xl px-4 py-3 border border-gray-100 shadow-sm">
|
||||||
</div>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
@@ -301,12 +422,12 @@ export const AnalyticsPage: React.FC = () => {
|
|||||||
if (error && (error as { response?: { status?: number } })?.response?.status === 402) {
|
if (error && (error as { response?: { status?: number } })?.response?.status === 402) {
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-2xl mx-auto">
|
<div className="p-6 max-w-2xl mx-auto">
|
||||||
<Card className="p-8 text-center">
|
<Card className="p-10 text-center">
|
||||||
<div className="w-14 h-14 rounded-2xl bg-primary-50 flex items-center justify-center mx-auto mb-4">
|
<div className="w-16 h-16 rounded-2xl bg-primary-50 flex items-center justify-center mx-auto mb-5">
|
||||||
<Lock className="w-6 h-6 text-primary-600" />
|
<Lock className="w-7 h-7 text-primary-600" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-2">Analytics Dashboard</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-2">Analytics Dashboard</h2>
|
||||||
<p className="text-gray-500 text-sm mb-6">
|
<p className="text-gray-500 text-sm mb-6 max-w-sm mx-auto leading-relaxed">
|
||||||
Unlock analytics to see how your chatbots are performing — conversations, user engagement, top questions, and more.
|
Unlock analytics to see how your chatbots are performing — conversations, user engagement, top questions, and more.
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={() => navigate('/pricing')}>
|
<Button onClick={() => navigate('/pricing')}>
|
||||||
@@ -320,8 +441,35 @@ export const AnalyticsPage: React.FC = () => {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[60vh]">
|
<div className="p-4 sm:p-6 max-w-5xl mx-auto space-y-6">
|
||||||
<Spinner className="text-primary-600" />
|
{/* Header skeleton */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-7 w-40 bg-gray-200 rounded animate-pulse" />
|
||||||
|
<div className="h-4 w-56 bg-gray-100 rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<div className="h-6 w-20 bg-gray-100 rounded-full animate-pulse" />
|
||||||
|
</div>
|
||||||
|
{/* Usage bar skeleton */}
|
||||||
|
<div className="h-16 bg-gray-100 rounded-xl animate-pulse" />
|
||||||
|
{/* Stat cards skeleton */}
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{[0, 1, 2, 3].map(i => <SkeletonStatCard key={i} />)}
|
||||||
|
</div>
|
||||||
|
{/* Chatbot rows skeleton */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[0, 1].map(i => (
|
||||||
|
<div key={i} className="bg-white rounded-xl border border-gray-200 p-5 animate-pulse">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-gray-200 rounded-xl" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="h-4 w-1/3 bg-gray-200 rounded" />
|
||||||
|
<div className="h-3 w-1/4 bg-gray-100 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -329,73 +477,91 @@ export const AnalyticsPage: React.FC = () => {
|
|||||||
if (!data) {
|
if (!data) {
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-4xl mx-auto">
|
<div className="p-6 max-w-4xl mx-auto">
|
||||||
<Card className="p-8 text-center">
|
<Card className="p-10 text-center">
|
||||||
<BarChart3 className="w-8 h-8 text-gray-400 mx-auto mb-3" />
|
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mx-auto mb-4">
|
||||||
<p className="text-gray-600">Unable to load analytics. Please try again.</p>
|
<BarChart3 className="w-6 h-6 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600 font-medium">Unable to load analytics</p>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">Please try refreshing the page.</p>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
<div className="p-4 sm:p-6 max-w-5xl mx-auto space-y-6">
|
||||||
{/* Header */}
|
{/* ── Header ── */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Analytics</h1>
|
<div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center flex-shrink-0">
|
||||||
<p className="text-sm text-gray-500 mt-0.5">
|
<BarChart3 className="w-5 h-5 text-primary-600" />
|
||||||
Track how your chatbots are performing
|
</div>
|
||||||
</p>
|
<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>
|
</div>
|
||||||
<Badge className="text-xs capitalize">{data.plan} plan</Badge>
|
<Badge className="text-xs capitalize">{data.plan} plan</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Usage bar */}
|
{/* ── Usage bar ── */}
|
||||||
<Card className="p-4">
|
<Card className="p-5">
|
||||||
<UsageBar used={data.conversations_used} limit={data.conversations_limit} />
|
<UsageBar used={data.conversations_used} limit={data.conversations_limit} />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Overview stat cards */}
|
{/* ── Overview stat cards ── */}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Conversations"
|
label="Conversations"
|
||||||
value={data.total_conversations}
|
value={data.total_conversations}
|
||||||
icon={<MessageSquare className="w-4 h-4" />}
|
icon={<MessageSquare className="w-4 h-4" />}
|
||||||
subtitle={`${data.conversations_this_month} this month`}
|
subtitle={`${data.conversations_this_month} this month`}
|
||||||
|
color="primary"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Unique users"
|
label="Unique users"
|
||||||
value={data.unique_sessions}
|
value={data.unique_sessions}
|
||||||
icon={<Users className="w-4 h-4" />}
|
icon={<Users className="w-4 h-4" />}
|
||||||
subtitle="Across all chatbots"
|
subtitle="Across all chatbots"
|
||||||
|
color="sky"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Messages"
|
label="Messages"
|
||||||
value={data.total_messages}
|
value={data.total_messages}
|
||||||
icon={<BarChart3 className="w-4 h-4" />}
|
icon={<BarChart3 className="w-4 h-4" />}
|
||||||
subtitle="Total messages exchanged"
|
subtitle="Total exchanged"
|
||||||
|
color="violet"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Avg rating"
|
label="Avg rating"
|
||||||
value={data.average_rating ? data.average_rating.toFixed(1) : '—'}
|
value={data.average_rating ? data.average_rating.toFixed(1) : '—'}
|
||||||
icon={<Star className="w-4 h-4" />}
|
icon={<Star className="w-4 h-4" />}
|
||||||
subtitle={data.average_rating ? 'Across rated chatbots' : 'No ratings yet'}
|
subtitle={data.average_rating ? 'Across rated chatbots' : 'No ratings yet'}
|
||||||
|
color="amber"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chatbot breakdown header */}
|
{/* ── Chatbot breakdown header ── */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">
|
<h2 className="text-lg font-semibold text-gray-900">
|
||||||
Your chatbots ({data.total_chatbots})
|
Your chatbots
|
||||||
|
<span className="ml-2 text-sm font-medium text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full">
|
||||||
|
{data.total_chatbots}
|
||||||
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-gray-500">{data.published_chatbots} published</p>
|
<p className="text-xs text-gray-500 bg-emerald-50 text-emerald-600 px-2.5 py-1 rounded-full font-medium">
|
||||||
|
{data.published_chatbots} published
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Per-chatbot expandable rows */}
|
{/* ── Per-chatbot expandable rows ── */}
|
||||||
{data.chatbots.length === 0 ? (
|
{data.chatbots.length === 0 ? (
|
||||||
<Card className="p-8 text-center">
|
<Card className="p-12 text-center">
|
||||||
<Bot className="w-8 h-8 text-gray-400 mx-auto mb-3" />
|
<div className="w-16 h-16 rounded-2xl bg-gray-50 flex items-center justify-center mx-auto mb-5">
|
||||||
<p className="text-sm text-gray-600 mb-4">No chatbots yet. Create your first chatbot to see analytics.</p>
|
<Bot className="w-7 h-7 text-gray-300" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-gray-600 mb-1">No chatbots yet</p>
|
||||||
|
<p className="text-xs text-gray-400 mb-5">Create your first chatbot to start seeing analytics.</p>
|
||||||
<Button size="sm" onClick={() => navigate('/chatbots/new')}>
|
<Button size="sm" onClick={() => navigate('/chatbots/new')}>
|
||||||
Create chatbot
|
Create chatbot
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
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 { useAuthStore } from '@/store/authStore'
|
||||||
import { authAPI } from '@/services/api'
|
import { authAPI } from '@/services/api'
|
||||||
import { Button, Input } from '@/components/ui'
|
import { Button, Input } from '@/components/ui'
|
||||||
import { Sparkles, Eye, EyeOff } from 'lucide-react'
|
import { Sparkles, Eye, EyeOff, Mail, Lock, Building2, Check, X, MessageSquare, FileText, Globe } from 'lucide-react'
|
||||||
|
|
||||||
|
// ─── Shared branding panel ────────────────────────────────────────────────────
|
||||||
|
const BrandingPanel: React.FC = () => (
|
||||||
|
<div className="hidden lg:flex flex-col justify-between h-full p-10 bg-gradient-to-br from-primary-600 via-primary-700 to-purple-800 text-white relative overflow-hidden">
|
||||||
|
{/* decorative circles */}
|
||||||
|
<div className="absolute -top-16 -right-16 w-64 h-64 rounded-full bg-white/5" />
|
||||||
|
<div className="absolute bottom-10 -left-16 w-48 h-48 rounded-full bg-white/5" />
|
||||||
|
<div className="absolute top-1/2 right-4 w-32 h-32 rounded-full bg-white/5" />
|
||||||
|
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="relative z-10 flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center backdrop-blur-sm">
|
||||||
|
<Sparkles className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-bold tracking-tight">Contexta</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center content */}
|
||||||
|
<div className="relative z-10">
|
||||||
|
<h2 className="text-3xl font-bold leading-snug mb-3">
|
||||||
|
Build AI chatbots<br />that actually work.
|
||||||
|
</h2>
|
||||||
|
<p className="text-primary-200 text-sm leading-relaxed mb-8">
|
||||||
|
Upload your docs, train your bot, and publish it anywhere — in minutes.
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{[
|
||||||
|
{ icon: MessageSquare, text: 'Custom chatbots trained on your content' },
|
||||||
|
{ icon: FileText, text: 'PDF, DOCX, CSV, and URL sources' },
|
||||||
|
{ icon: Globe, text: 'Embed on any website or channel' },
|
||||||
|
].map(({ icon: Icon, text }) => (
|
||||||
|
<li key={text} className="flex items-center gap-3 text-sm text-primary-100">
|
||||||
|
<span className="w-6 h-6 rounded-full bg-white/20 flex items-center justify-center flex-shrink-0">
|
||||||
|
<Check className="w-3.5 h-3.5 text-white" />
|
||||||
|
</span>
|
||||||
|
{text}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer quote */}
|
||||||
|
<p className="relative z-10 text-xs text-primary-300">
|
||||||
|
Trusted by businesses building smarter customer experiences.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Shared page wrapper ──────────────────────────────────────────────────────
|
||||||
|
const AuthLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||||
|
<div className="min-h-screen bg-dots bg-gray-50 flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-4xl bg-white rounded-2xl shadow-xl border border-gray-100 overflow-hidden animate-scale-in">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 min-h-[580px]">
|
||||||
|
<BrandingPanel />
|
||||||
|
<div className="flex flex-col justify-center p-8 sm:p-10">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Error alert ──────────────────────────────────────────────────────────────
|
||||||
|
const ErrorAlert: React.FC<{ message: string; onDismiss?: () => void }> = ({ message, onDismiss }) => (
|
||||||
|
<div className="flex items-start gap-3 p-3.5 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700 animate-fade-in">
|
||||||
|
<span className="w-5 h-5 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
|
<X className="w-3 h-3 text-red-600" />
|
||||||
|
</span>
|
||||||
|
<span className="flex-1">{message}</span>
|
||||||
|
{onDismiss && (
|
||||||
|
<button onClick={onDismiss} className="text-red-400 hover:text-red-600 transition-colors">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Input wrapper with icon ──────────────────────────────────────────────────
|
||||||
|
const IconInput: React.FC<{
|
||||||
|
label: string
|
||||||
|
icon: React.ReactNode
|
||||||
|
type: string
|
||||||
|
value: string
|
||||||
|
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||||
|
placeholder: string
|
||||||
|
required?: boolean
|
||||||
|
rightElement?: React.ReactNode
|
||||||
|
}> = ({ label, icon, rightElement, ...inputProps }) => (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-sm font-medium text-gray-700">{label}</label>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
{...inputProps}
|
||||||
|
className="w-full pl-10 pr-10 py-2.5 border border-gray-300 rounded-lg text-sm bg-white
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent
|
||||||
|
hover:border-gray-400 transition-all duration-200 placeholder:text-gray-400"
|
||||||
|
/>
|
||||||
|
{rightElement && (
|
||||||
|
<span className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
|
{rightElement}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── LoginPage ─────────────────────────────────────────────────────────────────
|
||||||
export const LoginPage: React.FC = () => {
|
export const LoginPage: React.FC = () => {
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
@@ -31,73 +140,84 @@ export const LoginPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 via-white to-purple-50 flex items-center justify-center p-4">
|
<AuthLayout>
|
||||||
<div className="w-full max-w-md">
|
{/* Mobile logo */}
|
||||||
<div className="text-center mb-8">
|
<div className="flex lg:hidden items-center gap-2 mb-8">
|
||||||
<div className="inline-flex w-12 h-12 bg-primary-600 rounded-2xl items-center justify-center mb-4">
|
<div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
|
||||||
<Sparkles className="w-6 h-6 text-white" />
|
<Sparkles className="w-4 h-4 text-white" />
|
||||||
</div>
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span className="font-bold text-gray-900">Contexta</span>
|
||||||
</div>
|
</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 = () => {
|
export const SignupPage: React.FC = () => {
|
||||||
const [form, setForm] = useState({ email: '', password: '', company_name: '' })
|
const [form, setForm] = useState({ email: '', password: '', company_name: '' })
|
||||||
const [showPass, setShowPass] = useState(false)
|
const [showPass, setShowPass] = useState(false)
|
||||||
@@ -135,19 +255,19 @@ export const SignupPage: React.FC = () => {
|
|||||||
|
|
||||||
if (emailSent) {
|
if (emailSent) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 via-white to-purple-50 flex items-center justify-center p-4">
|
<div className="min-h-screen bg-dots bg-gray-50 flex items-center justify-center p-4">
|
||||||
<div className="w-full max-w-md text-center">
|
<div className="w-full max-w-md bg-white rounded-2xl shadow-xl border border-gray-100 p-10 text-center animate-scale-in">
|
||||||
<div className="inline-flex w-16 h-16 bg-green-100 rounded-2xl items-center justify-center mb-4">
|
<div className="w-16 h-16 bg-green-100 rounded-2xl flex items-center justify-center mx-auto mb-5">
|
||||||
<Sparkles className="w-8 h-8 text-green-600" />
|
<Check className="w-8 h-8 text-green-600" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Check your email</h1>
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">Check your inbox</h1>
|
||||||
<p className="text-gray-500 mb-4">
|
<p className="text-gray-500 text-sm mb-1">
|
||||||
We sent a confirmation link to <strong>{form.email}</strong>.<br />
|
A confirmation link was sent to
|
||||||
Click the link to activate your account.
|
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-gray-900 font-medium text-sm mb-6">{form.email}</p>
|
||||||
<p className="text-sm text-gray-400">
|
<p className="text-sm text-gray-400">
|
||||||
Already confirmed?{' '}
|
Already confirmed?{' '}
|
||||||
<Link to="/login" className="text-primary-600 hover:underline">Sign in</Link>
|
<Link to="/login" className="text-primary-600 font-medium hover:underline">Sign in</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,89 +275,88 @@ export const SignupPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 via-white to-purple-50 flex items-center justify-center p-4">
|
<AuthLayout>
|
||||||
<div className="w-full max-w-md">
|
{/* Mobile logo */}
|
||||||
<div className="text-center mb-8">
|
<div className="flex lg:hidden items-center gap-2 mb-8">
|
||||||
<div className="inline-flex w-12 h-12 bg-primary-600 rounded-2xl items-center justify-center mb-4">
|
<div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
|
||||||
<Sparkles className="w-6 h-6 text-white" />
|
<Sparkles className="w-4 h-4 text-white" />
|
||||||
</div>
|
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
<span className="font-bold text-gray-900">Contexta</span>
|
||||||
</div>
|
</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 { useNavigate } from 'react-router-dom'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { chatbotsAPI } from '@/services/api'
|
import { chatbotsAPI } from '@/services/api'
|
||||||
import { Button, Card, StatusDot, EmptyState, Modal, Spinner } from '@/components/ui'
|
import { Button, StatusDot, EmptyState, Modal } from '@/components/ui'
|
||||||
|
import { SkeletonCard } from '@/components/Skeletons'
|
||||||
|
import { useToast } from '@/contexts/ToastContext'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
import type { Chatbot } from '@/types'
|
import type { Chatbot } from '@/types'
|
||||||
import {
|
import {
|
||||||
Bot, Plus, MoreHorizontal, Globe, Lock, Trash2,
|
Bot, Plus, MoreHorizontal, Globe, Lock, Trash2,
|
||||||
Settings, Eye, BarChart2
|
Settings, Eye, BarChart2, FileText, MessageSquare,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
// BUG-05 FIX: Toast queue system using array + auto-dismiss
|
|
||||||
interface ToastItem {
|
|
||||||
id: string
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DashboardPage: React.FC = () => {
|
export const DashboardPage: React.FC = () => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||||
const [toasts, setToasts] = useState<ToastItem[]>([])
|
const [confirmAction, setConfirmAction] = useState<{ type: 'publish' | 'unpublish'; id: string } | null>(null)
|
||||||
|
const { success: showToast } = useToast()
|
||||||
// BUG-05 FIX: Queue-based toast - no overwrites
|
|
||||||
const showToast = useCallback((message: string) => {
|
|
||||||
const id = crypto.randomUUID()
|
|
||||||
setToasts(prev => [...prev, { id, message }])
|
|
||||||
setTimeout(() => {
|
|
||||||
setToasts(prev => prev.filter(t => t.id !== id))
|
|
||||||
}, 3000)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const removeToast = useCallback((id: string) => {
|
|
||||||
setToasts(prev => prev.filter(t => t.id !== id))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const { data: chatbots = [], isLoading } = useQuery({
|
const { data: chatbots = [], isLoading } = useQuery({
|
||||||
queryKey: ['chatbots'],
|
queryKey: ['chatbots'],
|
||||||
@@ -64,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 = () => {
|
const handleConfirmAction = () => {
|
||||||
if (!confirmAction) return
|
if (!confirmAction) return
|
||||||
if (confirmAction.type === 'publish') {
|
if (confirmAction.type === 'publish') publishMutation.mutate(confirmAction.id)
|
||||||
publishMutation.mutate(confirmAction.id)
|
else unpublishMutation.mutate(confirmAction.id)
|
||||||
} else {
|
|
||||||
unpublishMutation.mutate(confirmAction.id)
|
|
||||||
}
|
|
||||||
setConfirmAction(null)
|
setConfirmAction(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 sm:p-6">
|
<div className="p-4 sm:p-6 max-w-7xl">
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-6 gap-3 animate-fade-in">
|
{/* Header */}
|
||||||
<div>
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-6 gap-3">
|
||||||
<h1 className="text-xl sm:text-2xl font-bold text-gray-900">Dashboard</h1>
|
<div>
|
||||||
<p className="text-sm text-gray-500 mt-0.5">Manage your AI chatbots</p>
|
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight">Dashboard</h1>
|
||||||
</div>
|
<p className="text-sm text-gray-500 mt-0.5">
|
||||||
<Button onClick={() => navigate('/chatbots/new')}>
|
{chatbots.length > 0
|
||||||
<Plus className="w-4 h-4" />
|
? `${chatbots.length} chatbot${chatbots.length !== 1 ? 's' : ''}`
|
||||||
New Chatbot
|
: '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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{isLoading ? (
|
{/* Publish/unpublish modal */}
|
||||||
<div className="flex items-center justify-center py-20">
|
<Modal
|
||||||
<Spinner className="text-primary-600" />
|
isOpen={!!confirmAction}
|
||||||
</div>
|
onClose={() => setConfirmAction(null)}
|
||||||
) : chatbots.length === 0 ? (
|
title={confirmAction?.type === 'publish' ? 'Publish to Marketplace' : 'Unpublish Chatbot'}
|
||||||
<EmptyState
|
size="sm"
|
||||||
icon={<Bot className="w-8 h-8" />}
|
>
|
||||||
title="No chatbots yet"
|
<p className="text-sm text-gray-500 mb-5 leading-relaxed">
|
||||||
description="Create your first AI chatbot powered by your documents. It's free to build and test."
|
{confirmAction?.type === 'publish'
|
||||||
action={
|
? 'Your chatbot will be publicly visible on the marketplace.'
|
||||||
<Button onClick={() => navigate('/chatbots/new')} size="lg">
|
: 'Your chatbot will be removed from the marketplace.'}
|
||||||
<Plus className="w-4 h-4" />
|
</p>
|
||||||
Create your first chatbot
|
<div className="flex gap-2.5">
|
||||||
</Button>
|
<Button variant="outline" onClick={() => setConfirmAction(null)} className="flex-1">Cancel</Button>
|
||||||
}
|
<Button
|
||||||
/>
|
onClick={handleConfirmAction}
|
||||||
) : (
|
loading={publishMutation.isPending || unpublishMutation.isPending}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
className="flex-1"
|
||||||
{chatbots.map((chatbot, i) => (
|
>
|
||||||
<ChatbotCard
|
{confirmAction?.type === 'publish' ? 'Publish' : 'Unpublish'}
|
||||||
key={chatbot.id}
|
</Button>
|
||||||
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>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Chatbot Card ──────────────────────────────────────────────────────────────
|
||||||
const ChatbotCard: React.FC<{
|
const ChatbotCard: React.FC<{
|
||||||
chatbot: Chatbot
|
chatbot: Chatbot
|
||||||
index: number
|
index: number
|
||||||
@@ -215,119 +186,146 @@ const ChatbotCard: React.FC<{
|
|||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="group bg-white rounded-xl border border-gray-200 shadow-sm hover:-translate-y-1 hover:shadow-lg hover:border-gray-300 transition-all duration-200 overflow-hidden animate-fade-in-up"
|
className="group bg-white rounded-2xl border border-gray-100 shadow-sm hover:shadow-md hover:-translate-y-0.5 hover:border-gray-200 transition-all duration-200 overflow-hidden animate-fade-in-up"
|
||||||
style={{ animationDelay: `${index * 80}ms`, animationFillMode: 'both' }}
|
style={{ animationDelay: `${index * 60}ms`, animationFillMode: 'both' }}
|
||||||
>
|
>
|
||||||
{/* Colored top accent */}
|
{/* Color accent bar */}
|
||||||
<div className="h-1 w-full" style={{ background: chatbot.primary_color }} />
|
<div className="h-1 w-full" style={{ background: chatbot.primary_color }} />
|
||||||
|
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
<div className="flex items-start justify-between mb-3">
|
{/* Top row */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-start justify-between gap-2 mb-3">
|
||||||
{chatbot.logo_url ? (
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<img
|
{chatbot.logo_url ? (
|
||||||
src={chatbot.logo_url}
|
<img
|
||||||
alt={chatbot.name}
|
src={chatbot.logo_url}
|
||||||
className="w-11 h-11 rounded-xl object-cover flex-shrink-0 shadow-sm group-hover:scale-105 transition-transform duration-200"
|
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-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"
|
<div
|
||||||
style={{ background: chatbot.primary_color }}
|
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-5 h-5" />
|
>
|
||||||
</div>
|
<Bot className="w-4.5 h-4.5" />
|
||||||
)}
|
</div>
|
||||||
<div>
|
)}
|
||||||
<h3 className="font-semibold text-gray-900 text-sm group-hover:text-primary-700 transition-colors">{chatbot.name}</h3>
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-1.5 mt-0.5">
|
<h3 className="font-semibold text-gray-900 text-sm truncate">{chatbot.name}</h3>
|
||||||
<StatusDot status={chatbot.is_published ? 'published' : 'preview'} />
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
<span className={`text-xs font-medium ${chatbot.is_published ? 'text-green-600' : 'text-gray-400'}`}>
|
<StatusDot status={chatbot.is_published ? 'published' : 'preview'} />
|
||||||
{chatbot.is_published ? 'Published' : 'Preview'}
|
<span className={cn(
|
||||||
</span>
|
'text-xs font-medium',
|
||||||
</div>
|
chatbot.is_published ? 'text-green-600' : 'text-gray-400'
|
||||||
|
)}>
|
||||||
|
{chatbot.is_published ? 'Published' : 'Draft'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{chatbot.description && (
|
{/* Context menu */}
|
||||||
<p className="text-xs text-gray-500 mb-3 line-clamp-2 leading-relaxed">{chatbot.description}</p>
|
<div className="relative shrink-0">
|
||||||
)}
|
<button
|
||||||
|
onClick={() => setMenuOpen(!menuOpen)}
|
||||||
{/* Stats */}
|
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"
|
||||||
<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">
|
<MoreHorizontal className="w-4 h-4" />
|
||||||
<span className="text-gray-400">📄</span> {chatbot.document_count} docs
|
</button>
|
||||||
</span>
|
{menuOpen && (
|
||||||
<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
|
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} />
|
||||||
</span>
|
<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">
|
||||||
{chatbot.category && (
|
{[
|
||||||
<span className="text-xs text-primary-700 bg-primary-50 px-2 py-1 rounded-lg font-medium">
|
{ label: 'Edit Settings', icon: Settings, action: onEdit },
|
||||||
{chatbot.category}
|
{ label: 'Preview', icon: Eye, action: onPreview },
|
||||||
</span>
|
{ label: 'Analytics', icon: BarChart2, action: onAnalytics },
|
||||||
)}
|
].map(({ label, icon: Icon, action }) => (
|
||||||
</div>
|
<button
|
||||||
|
key={label}
|
||||||
{/* Actions */}
|
onClick={() => { action(); setMenuOpen(false) }}
|
||||||
<div className="flex gap-2">
|
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"
|
||||||
<Button variant="outline" size="sm" onClick={onPreview} className="flex-1">
|
>
|
||||||
<Eye className="w-3.5 h-3.5" />
|
<Icon className="w-3.5 h-3.5 text-gray-400" />
|
||||||
Preview
|
{label}
|
||||||
</Button>
|
</button>
|
||||||
{chatbot.is_published ? (
|
))}
|
||||||
<Button variant="outline" size="sm" onClick={onUnpublish} className="flex-1 text-orange-600 border-orange-200 hover:bg-orange-50">
|
<div className="h-px bg-gray-50 my-1" />
|
||||||
<Lock className="w-3.5 h-3.5" />
|
{chatbot.is_published ? (
|
||||||
Unpublish
|
<button
|
||||||
</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"
|
||||||
<Button size="sm" onClick={onPublish} className="flex-1">
|
>
|
||||||
<Globe className="w-3.5 h-3.5" />
|
<Lock className="w-3.5 h-3.5" /> Unpublish
|
||||||
Publish
|
</button>
|
||||||
</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>
|
||||||
</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>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { authAPI } from '@/services/api'
|
import { authAPI } from '@/services/api'
|
||||||
import { Button, Input } from '@/components/ui'
|
import { Button } from '@/components/ui'
|
||||||
import { Sparkles, ArrowLeft } from 'lucide-react'
|
import { Sparkles, ArrowLeft, Mail, X, Check } from 'lucide-react'
|
||||||
|
|
||||||
export const ForgotPasswordPage: React.FC = () => {
|
export const ForgotPasswordPage: React.FC = () => {
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
@@ -25,58 +25,96 @@ export const ForgotPasswordPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 via-white to-purple-50 flex items-center justify-center p-4">
|
<div className="min-h-screen bg-dots bg-gray-50 flex items-center justify-center p-4">
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md animate-scale-in">
|
||||||
<div className="text-center mb-8">
|
{/* Logo */}
|
||||||
<div className="inline-flex w-12 h-12 bg-primary-600 rounded-2xl items-center justify-center mb-4">
|
<div className="flex items-center justify-center gap-2 mb-8">
|
||||||
<Sparkles className="w-6 h-6 text-white" />
|
<div className="w-9 h-9 bg-primary-600 rounded-xl flex items-center justify-center shadow-sm">
|
||||||
|
<Sparkles className="w-5 h-5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Reset your password</h1>
|
<span className="font-bold text-gray-900 text-lg">Contexta</span>
|
||||||
<p className="text-gray-500 mt-1 text-sm">We'll send a reset link to your email</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-8">
|
<div className="bg-white rounded-2xl shadow-xl border border-gray-100 p-8">
|
||||||
{sent ? (
|
{sent ? (
|
||||||
<div className="text-center">
|
<div className="text-center py-2 animate-fade-in-up">
|
||||||
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
<div className="w-14 h-14 bg-green-100 rounded-2xl flex items-center justify-center mx-auto mb-5">
|
||||||
<span className="text-green-600 text-xl">✓</span>
|
<Check className="w-7 h-7 text-green-600" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="font-semibold text-gray-900 mb-2">Check your email</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-2">Check your inbox</h2>
|
||||||
<p className="text-sm text-gray-500 mb-4">
|
<p className="text-sm text-gray-500 mb-1">
|
||||||
If <strong>{email}</strong> is registered, a reset link has been sent.
|
If <strong className="text-gray-700">{email}</strong> is registered,
|
||||||
</p>
|
</p>
|
||||||
<Link to="/login" className="text-sm text-primary-600 hover:underline">
|
<p className="text-sm text-gray-500 mb-6">
|
||||||
Back to sign in
|
a password reset link has been sent.
|
||||||
</Link>
|
</p>
|
||||||
</div>
|
<Link
|
||||||
) : (
|
to="/login"
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
className="inline-flex items-center gap-1.5 text-sm text-primary-600 font-medium hover:text-primary-700 transition-colors"
|
||||||
<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">
|
|
||||||
<ArrowLeft className="w-3.5 h-3.5" />
|
<ArrowLeft className="w-3.5 h-3.5" />
|
||||||
Back to sign in
|
Back to sign in
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div 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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState, useRef, useEffect } from 'react'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { inboxAPI } from '@/services/api'
|
import { inboxAPI } from '@/services/api'
|
||||||
import { Card, Spinner } from '@/components/ui'
|
import { Card, Spinner } from '@/components/ui'
|
||||||
import { Mail, MessageSquare, Bot, AlertTriangle, ArrowRight, Trash2 } from 'lucide-react'
|
import {
|
||||||
import type { InboxConversation, InboxMessage } from '@/types'
|
Mail, MessageSquare, Bot, AlertTriangle, ArrowLeft, Trash2, Inbox,
|
||||||
|
UserCheck, Send, CheckCircle2, RotateCcw, User,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { InboxConversation, InboxMessage, ConversationStatus } from '@/types'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { SkeletonList } from '@/components/Skeletons'
|
||||||
|
|
||||||
interface ConversationDetail {
|
interface ConversationDetail {
|
||||||
conversation_id: string
|
conversation_id: string
|
||||||
@@ -15,16 +19,94 @@ interface ConversationDetail {
|
|||||||
messages: InboxMessage[]
|
messages: InboxMessage[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function timeAgo(dateStr?: string): string {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
const diff = Date.now() - new Date(dateStr).getTime()
|
||||||
|
const mins = Math.floor(diff / 60000)
|
||||||
|
if (mins < 1) return 'just now'
|
||||||
|
if (mins < 60) return `${mins}m ago`
|
||||||
|
const hrs = Math.floor(mins / 60)
|
||||||
|
if (hrs < 24) return `${hrs}h ago`
|
||||||
|
const days = Math.floor(hrs / 24)
|
||||||
|
if (days < 7) return `${days}d ago`
|
||||||
|
return new Date(dateStr).toLocaleDateString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const AvatarInitial: React.FC<{ name: string; size?: 'sm' | 'md' }> = ({ name, size = 'md' }) => {
|
||||||
|
const initial = name ? name[0].toUpperCase() : '?'
|
||||||
|
const colors = [
|
||||||
|
'bg-indigo-100 text-indigo-700', 'bg-violet-100 text-violet-700',
|
||||||
|
'bg-sky-100 text-sky-700', 'bg-emerald-100 text-emerald-700',
|
||||||
|
'bg-rose-100 text-rose-700', 'bg-amber-100 text-amber-700',
|
||||||
|
]
|
||||||
|
const colorIdx = name.charCodeAt(0) % colors.length
|
||||||
|
return (
|
||||||
|
<div className={cn(
|
||||||
|
'flex items-center justify-center rounded-full font-semibold flex-shrink-0',
|
||||||
|
colors[colorIdx],
|
||||||
|
size === 'sm' ? 'w-8 h-8 text-xs' : 'w-10 h-10 text-sm'
|
||||||
|
)}>
|
||||||
|
{initial}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<ConversationStatus, { label: string; color: string }> = {
|
||||||
|
open: { label: 'Open', color: 'bg-blue-100 text-blue-700' },
|
||||||
|
agent_handling: { label: 'Agent', color: 'bg-orange-100 text-orange-700' },
|
||||||
|
resolved: { label: 'Resolved', color: 'bg-green-100 text-green-700' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_TABS: { key: ConversationStatus | 'all'; label: string }[] = [
|
||||||
|
{ key: 'all', label: 'All' },
|
||||||
|
{ key: 'open', label: 'Open' },
|
||||||
|
{ key: 'agent_handling', label: 'Agent' },
|
||||||
|
{ key: 'resolved', label: 'Resolved' },
|
||||||
|
]
|
||||||
|
|
||||||
export const InboxPage: React.FC = () => {
|
export const InboxPage: React.FC = () => {
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
const [chatbotFilter, setChatbotFilter] = useState('')
|
const [statusFilter, setStatusFilter] = useState<ConversationStatus | 'all'>('all')
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||||
|
const [replyText, setReplyText] = useState('')
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const { data: conversations = [], isLoading, error } = useQuery<InboxConversation[]>({
|
const { data: conversations = [], isLoading, error } = useQuery<InboxConversation[]>({
|
||||||
queryKey: ['inbox-conversations', chatbotFilter],
|
queryKey: ['inbox-conversations', statusFilter],
|
||||||
queryFn: () => inboxAPI.conversations(chatbotFilter ? { chatbot_id: chatbotFilter } : undefined),
|
queryFn: () => inboxAPI.conversations(statusFilter !== 'all' ? { status: statusFilter } : undefined),
|
||||||
retry: false,
|
retry: false,
|
||||||
|
refetchInterval: 15000, // poll every 15s
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: detail, isLoading: detailLoading } = useQuery<ConversationDetail>({
|
||||||
|
queryKey: ['inbox-conversation', selectedId],
|
||||||
|
queryFn: () => inboxAPI.conversation(selectedId!),
|
||||||
|
enabled: !!selectedId,
|
||||||
|
refetchInterval: 8000,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}, [detail?.messages])
|
||||||
|
|
||||||
|
const updateStatus = useMutation({
|
||||||
|
mutationFn: ({ id, status }: { id: string; status: string }) =>
|
||||||
|
inboxAPI.updateStatus(id, status),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['inbox-conversations'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['inbox-conversation', selectedId] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const sendReply = useMutation({
|
||||||
|
mutationFn: ({ id, message }: { id: string; message: string }) =>
|
||||||
|
inboxAPI.reply(id, message),
|
||||||
|
onSuccess: () => {
|
||||||
|
setReplyText('')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['inbox-conversations'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['inbox-conversation', selectedId] })
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleDelete = async (e: React.MouseEvent, convId: string) => {
|
const handleDelete = async (e: React.MouseEvent, convId: string) => {
|
||||||
@@ -42,23 +124,23 @@ export const InboxPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: detail, isLoading: detailLoading } = useQuery<ConversationDetail>({
|
const handleSendReply = () => {
|
||||||
queryKey: ['inbox-conversation', selectedId],
|
if (!replyText.trim() || !selectedId) return
|
||||||
queryFn: () => inboxAPI.conversation(selectedId!),
|
sendReply.mutate({ id: selectedId, message: replyText.trim() })
|
||||||
enabled: !!selectedId,
|
}
|
||||||
})
|
|
||||||
|
|
||||||
|
const selectedConv = conversations.find(c => c.id === selectedId)
|
||||||
const isPlanError = (error as { response?: { status?: number } })?.response?.status === 402
|
const isPlanError = (error as { response?: { status?: number } })?.response?.status === 402
|
||||||
|
|
||||||
if (isPlanError) {
|
if (isPlanError) {
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-2xl mx-auto">
|
<div className="p-6 max-w-2xl mx-auto">
|
||||||
<Card className="p-8 text-center">
|
<Card className="p-10 text-center">
|
||||||
<div className="w-14 h-14 rounded-2xl bg-primary-50 flex items-center justify-center mx-auto mb-4">
|
<div className="w-16 h-16 rounded-2xl bg-primary-50 flex items-center justify-center mx-auto mb-5">
|
||||||
<Mail className="w-6 h-6 text-primary-600" />
|
<Mail className="w-7 h-7 text-primary-600" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-2">Conversation Inbox</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-2">Conversation Inbox</h2>
|
||||||
<p className="text-gray-500 text-sm mb-6">
|
<p className="text-gray-500 text-sm mb-6 max-w-sm mx-auto">
|
||||||
Upgrade to Starter to read all your chatbot conversations in one place.
|
Upgrade to Starter to read all your chatbot conversations in one place.
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -67,136 +149,278 @@ export const InboxPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full">
|
<div className="flex h-full overflow-hidden">
|
||||||
{/* Left panel - conversation list */}
|
|
||||||
<div className="w-80 flex-shrink-0 border-r border-gray-200 bg-white flex flex-col">
|
|
||||||
<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-1 overflow-y-auto">
|
{/* ── Left panel ── */}
|
||||||
{isLoading ? (
|
<div className={cn(
|
||||||
<div className="flex items-center justify-center p-8">
|
'flex-shrink-0 border-r border-gray-200 bg-white flex flex-col transition-all duration-200',
|
||||||
<Spinner className="text-primary-600" />
|
'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>
|
</div>
|
||||||
) : conversations.length === 0 ? (
|
<div>
|
||||||
<div className="p-6 text-center">
|
<h1 className="text-sm font-bold text-gray-900">Inbox</h1>
|
||||||
<MessageSquare className="w-8 h-8 text-gray-300 mx-auto mb-2" />
|
<p className="text-xs text-gray-500">{conversations.length} conversation{conversations.length !== 1 ? 's' : ''}</p>
|
||||||
<p className="text-sm text-gray-500">No conversations yet</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
conversations.map((conv) => (
|
{/* Status tabs */}
|
||||||
<div
|
<div className="flex gap-1 bg-gray-100 rounded-lg p-0.5">
|
||||||
key={conv.id}
|
{STATUS_TABS.map(t => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
onClick={() => setStatusFilter(t.key)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full text-left border-b border-gray-100 hover:bg-gray-50 transition-colors group relative',
|
'flex-1 text-xs font-medium py-1.5 rounded-md transition-all',
|
||||||
selectedId === conv.id && 'bg-primary-50 border-l-2 border-l-primary-500'
|
statusFilter === t.key
|
||||||
|
? 'bg-white text-gray-900 shadow-sm'
|
||||||
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<button
|
{t.label}
|
||||||
onClick={() => setSelectedId(conv.id)}
|
</button>
|
||||||
className="w-full text-left p-4 pr-10"
|
))}
|
||||||
>
|
</div>
|
||||||
<div className="flex items-start justify-between gap-2 mb-1">
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Bot className="w-3.5 h-3.5 text-primary-500 flex-shrink-0" />
|
{/* Conversation list */}
|
||||||
<span className="text-xs font-medium text-primary-700 truncate max-w-[120px]">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{conv.chatbot_name}
|
{isLoading ? (
|
||||||
</span>
|
<div className="p-4"><SkeletonList rows={6} /></div>
|
||||||
</div>
|
) : conversations.length === 0 ? (
|
||||||
<span className="text-[10px] text-gray-400 flex-shrink-0">
|
<div className="flex flex-col items-center justify-center h-full py-16 px-6 text-center">
|
||||||
{conv.created_at ? new Date(conv.created_at).toLocaleDateString() : ''}
|
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-4">
|
||||||
</span>
|
<MessageSquare className="w-6 h-6 text-gray-300" />
|
||||||
</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>
|
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right panel - conversation detail */}
|
{/* ── Right panel ── */}
|
||||||
<div className="flex-1 flex flex-col bg-gray-50 overflow-hidden">
|
<div className={cn(
|
||||||
|
'flex-1 flex flex-col bg-gray-50/50 overflow-hidden',
|
||||||
|
selectedId ? 'flex' : 'hidden lg:flex'
|
||||||
|
)}>
|
||||||
{!selectedId ? (
|
{!selectedId ? (
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center px-6">
|
||||||
<ArrowRight className="w-8 h-8 text-gray-300 mx-auto mb-3" />
|
<div className="w-16 h-16 rounded-2xl bg-white border border-gray-200 shadow-sm flex items-center justify-center mx-auto mb-4">
|
||||||
<p className="text-sm text-gray-500">Select a conversation to view</p>
|
<Mail className="w-7 h-7 text-gray-300" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-gray-500 mb-1">Select a conversation</p>
|
||||||
|
<p className="text-xs text-gray-400">Choose one from the list to view the full exchange</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : detailLoading ? (
|
) : detailLoading ? (
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center"><Spinner className="text-primary-600" /></div>
|
||||||
<Spinner className="text-primary-600" />
|
|
||||||
</div>
|
|
||||||
) : detail ? (
|
) : detail ? (
|
||||||
<>
|
<>
|
||||||
<div className="p-4 bg-white border-b border-gray-200">
|
{/* Detail header */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="p-3 bg-white border-b border-gray-200 flex items-center gap-3 shadow-sm">
|
||||||
<Bot className="w-4 h-4 text-primary-600" />
|
<button
|
||||||
<h2 className="font-semibold text-gray-900 text-sm">{detail.chatbot_name}</h2>
|
onClick={() => setSelectedId(null)}
|
||||||
<span className="text-xs text-gray-400">·</span>
|
className="lg:hidden p-1.5 rounded-lg hover:bg-gray-100 text-gray-500"
|
||||||
<span className="text-xs text-gray-500 uppercase">{detail.language}</span>
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<AvatarInitial name={detail.chatbot_name} size="sm" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h2 className="font-semibold text-gray-900 text-sm truncate">{detail.chatbot_name}</h2>
|
||||||
{detail.created_at && (
|
{detail.created_at && (
|
||||||
<>
|
<p className="text-xs text-gray-400">{new Date(detail.created_at).toLocaleString()}</p>
|
||||||
<span className="text-xs text-gray-400">·</span>
|
)}
|
||||||
<span className="text-xs text-gray-500">
|
</div>
|
||||||
{new Date(detail.created_at).toLocaleString()}
|
|
||||||
</span>
|
{/* Status action buttons */}
|
||||||
</>
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
{selectedConv?.status !== 'agent_handling' && (
|
||||||
|
<button
|
||||||
|
onClick={() => updateStatus.mutate({ id: selectedId!, status: 'agent_handling' })}
|
||||||
|
disabled={updateStatus.isPending}
|
||||||
|
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-orange-700 bg-orange-50 hover:bg-orange-100 border border-orange-200 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
title="Take over this conversation"
|
||||||
|
>
|
||||||
|
<UserCheck className="w-3.5 h-3.5" />
|
||||||
|
<span className="hidden sm:inline">Take Over</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{selectedConv?.status !== 'resolved' && (
|
||||||
|
<button
|
||||||
|
onClick={() => updateStatus.mutate({ id: selectedId!, status: 'resolved' })}
|
||||||
|
disabled={updateStatus.isPending}
|
||||||
|
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-green-700 bg-green-50 hover:bg-green-100 border border-green-200 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
title="Mark as resolved"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||||
|
<span className="hidden sm:inline">Resolve</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{selectedConv?.status !== 'open' && (
|
||||||
|
<button
|
||||||
|
onClick={() => updateStatus.mutate({ id: selectedId!, status: 'open' })}
|
||||||
|
disabled={updateStatus.isPending}
|
||||||
|
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 border border-blue-200 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
title="Reopen"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
<span className="hidden sm:inline">Reopen</span>
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
|
||||||
|
{/* Messages */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-4">
|
||||||
{detail.messages.map((msg) => (
|
{detail.messages.map((msg) => (
|
||||||
<div
|
<div
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
className={cn('flex gap-2', msg.role === 'user' ? 'justify-end' : '')}
|
className={cn(
|
||||||
|
'flex gap-2.5 items-end',
|
||||||
|
msg.role === 'user' ? 'justify-end' : 'justify-start'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{msg.role === 'assistant' && (
|
{(msg.role === 'assistant' || msg.role === 'agent') && (
|
||||||
<div className="w-6 h-6 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0 mt-0.5">
|
<div className={cn(
|
||||||
<Bot className="w-3 h-3 text-primary-600" />
|
'w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0 mb-0.5',
|
||||||
|
msg.role === 'agent' ? 'bg-orange-100' : 'bg-primary-100'
|
||||||
|
)}>
|
||||||
|
{msg.role === 'agent'
|
||||||
|
? <User className="w-3.5 h-3.5 text-orange-600" />
|
||||||
|
: <Bot className="w-3.5 h-3.5 text-primary-600" />
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'max-w-[75%] rounded-2xl px-3 py-2 text-sm',
|
'max-w-[75%] sm:max-w-[65%] px-4 py-2.5 text-sm leading-relaxed shadow-sm',
|
||||||
msg.role === 'user'
|
msg.role === 'user'
|
||||||
? 'bg-primary-600 text-white rounded-br-sm'
|
? 'bg-primary-600 text-white rounded-2xl rounded-br-sm'
|
||||||
: 'bg-white border border-gray-200 text-gray-800 rounded-bl-sm'
|
: msg.role === 'agent'
|
||||||
|
? 'bg-orange-50 border border-orange-200 text-gray-800 rounded-2xl rounded-bl-sm'
|
||||||
|
: 'bg-white border border-gray-200 text-gray-800 rounded-2xl rounded-bl-sm'
|
||||||
)}>
|
)}>
|
||||||
|
{msg.role === 'agent' && (
|
||||||
|
<p className="text-[10px] font-semibold text-orange-600 mb-1 uppercase tracking-wide">You (agent)</p>
|
||||||
|
)}
|
||||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
{(msg.is_handoff || (msg.confidence_score !== undefined && msg.confidence_score !== null && msg.confidence_score < 0.2)) && (
|
||||||
{msg.is_handoff && (
|
<div className={cn(
|
||||||
<span className="text-[10px] bg-orange-100 text-orange-700 px-1.5 py-0.5 rounded">
|
'flex items-center gap-2 mt-2 pt-2 flex-wrap',
|
||||||
Handoff
|
msg.role === 'user' ? 'border-t border-primary-500/30' : 'border-t border-gray-100'
|
||||||
</span>
|
)}>
|
||||||
)}
|
{msg.is_handoff && (
|
||||||
{msg.confidence_score !== undefined && msg.confidence_score !== null && msg.confidence_score < 0.2 && (
|
<span className="text-[10px] bg-orange-100 text-orange-700 px-2 py-0.5 rounded-full font-medium">
|
||||||
<span className="text-[10px] flex items-center gap-0.5 text-amber-600">
|
Handoff requested
|
||||||
<AlertTriangle className="w-2.5 h-2.5" /> Low confidence
|
</span>
|
||||||
</span>
|
)}
|
||||||
)}
|
{msg.confidence_score !== undefined && msg.confidence_score !== null && msg.confidence_score < 0.2 && (
|
||||||
</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
|
{msg.role === 'user' && (
|
||||||
|
<div className="w-7 h-7 rounded-full bg-gray-200 flex items-center justify-center flex-shrink-0 mb-0.5 text-[10px] font-bold text-gray-500">
|
||||||
|
U
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agent reply input */}
|
||||||
|
<div className="p-3 bg-white border-t border-gray-200">
|
||||||
|
{selectedConv?.status === 'resolved' ? (
|
||||||
|
<p className="text-xs text-center text-gray-400 py-1">
|
||||||
|
Conversation resolved —{' '}
|
||||||
|
<button
|
||||||
|
onClick={() => updateStatus.mutate({ id: selectedId!, status: 'open' })}
|
||||||
|
className="text-primary-600 hover:underline font-medium"
|
||||||
|
>
|
||||||
|
reopen
|
||||||
|
</button>
|
||||||
|
{' '}to reply
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={replyText}
|
||||||
|
onChange={e => setReplyText(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && !e.shiftKey && handleSendReply()}
|
||||||
|
placeholder="Type a reply as agent..."
|
||||||
|
className="flex-1 border border-gray-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-400 transition-colors"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSendReply}
|
||||||
|
disabled={!replyText.trim() || sendReply.isPending}
|
||||||
|
className="p-2.5 bg-primary-600 text-white rounded-xl hover:bg-primary-700 disabled:opacity-40 transition-colors flex-shrink-0"
|
||||||
|
title="Send reply"
|
||||||
|
>
|
||||||
|
{sendReply.isPending ? <Spinner className="w-4 h-4 text-white" /> : <Send className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,69 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { leadsAPI, chatbotsAPI } from '@/services/api'
|
import { leadsAPI, chatbotsAPI } from '@/services/api'
|
||||||
import { Card, Spinner, Button } from '@/components/ui'
|
import { Card, Button } from '@/components/ui'
|
||||||
import { Users, Download, Mail, Lock } from 'lucide-react'
|
import { Users, Download, Mail, Lock, TrendingUp, Filter, UserCheck, StickyNote, X, Check } from 'lucide-react'
|
||||||
import type { Lead, Chatbot } from '@/types'
|
import type { Lead, LeadStatus, Chatbot } from '@/types'
|
||||||
|
import { SkeletonTable } from '@/components/Skeletons'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const STATUS_OPTIONS: { value: LeadStatus; label: string; color: string }[] = [
|
||||||
|
{ value: 'new', label: 'New', color: 'bg-blue-100 text-blue-700' },
|
||||||
|
{ value: 'contacted', label: 'Contacted', color: 'bg-yellow-100 text-yellow-700' },
|
||||||
|
{ value: 'qualified', label: 'Qualified', color: 'bg-purple-100 text-purple-700' },
|
||||||
|
{ value: 'closed', label: 'Closed', color: 'bg-green-100 text-green-700' },
|
||||||
|
{ value: 'lost', label: 'Lost', color: 'bg-gray-100 text-gray-500' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const statusConfig = (status: LeadStatus) =>
|
||||||
|
STATUS_OPTIONS.find(s => s.value === status) || STATUS_OPTIONS[0]
|
||||||
|
|
||||||
|
interface NotesModalProps {
|
||||||
|
lead: Lead
|
||||||
|
onClose: () => void
|
||||||
|
onSave: (notes: string) => void
|
||||||
|
saving: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const NotesModal: React.FC<NotesModalProps> = ({ lead, onClose, onSave, saving }) => {
|
||||||
|
const [text, setText] = useState(lead.notes || '')
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md">
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
|
||||||
|
<h3 className="font-semibold text-gray-900 text-sm">
|
||||||
|
Notes — {lead.name || lead.email || 'Lead'}
|
||||||
|
</h3>
|
||||||
|
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
<textarea
|
||||||
|
value={text}
|
||||||
|
onChange={e => setText(e.target.value)}
|
||||||
|
placeholder="Add notes about this lead..."
|
||||||
|
rows={5}
|
||||||
|
className="w-full border border-gray-200 rounded-xl px-3 py-2.5 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-400 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 px-5 pb-4">
|
||||||
|
<Button variant="secondary" size="sm" onClick={onClose} className="flex-1">Cancel</Button>
|
||||||
|
<Button size="sm" onClick={() => onSave(text)} disabled={saving} className="flex-1 gap-1.5">
|
||||||
|
<Check className="w-3.5 h-3.5" />
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const LeadsPage: React.FC = () => {
|
export const LeadsPage: React.FC = () => {
|
||||||
const [chatbotFilter, setChatbotFilter] = useState('')
|
const [chatbotFilter, setChatbotFilter] = useState('')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<LeadStatus | ''>('')
|
||||||
|
const [notesLead, setNotesLead] = useState<Lead | null>(null)
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const { data: chatbots = [] } = useQuery<Chatbot[]>({
|
const { data: chatbots = [] } = useQuery<Chatbot[]>({
|
||||||
queryKey: ['chatbots'],
|
queryKey: ['chatbots'],
|
||||||
@@ -14,11 +71,17 @@ export const LeadsPage: React.FC = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const { data: leads = [], isLoading, error } = useQuery<Lead[]>({
|
const { data: leads = [], isLoading, error } = useQuery<Lead[]>({
|
||||||
queryKey: ['leads', chatbotFilter],
|
queryKey: ['leads', chatbotFilter, statusFilter],
|
||||||
queryFn: () => leadsAPI.list(chatbotFilter ? { chatbot_id: chatbotFilter } : undefined),
|
queryFn: () => leadsAPI.list(chatbotFilter ? { chatbot_id: chatbotFilter } : undefined),
|
||||||
retry: false,
|
retry: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const updateLead = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: { status?: string; notes?: string } }) =>
|
||||||
|
leadsAPI.update(id, data),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['leads'] }),
|
||||||
|
})
|
||||||
|
|
||||||
const isPlanError = (error as { response?: { status?: number } })?.response?.status === 402
|
const isPlanError = (error as { response?: { status?: number } })?.response?.status === 402
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
@@ -32,20 +95,18 @@ export const LeadsPage: React.FC = () => {
|
|||||||
a.click()
|
a.click()
|
||||||
document.body.removeChild(a)
|
document.body.removeChild(a)
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
} catch {
|
} catch { alert('Export failed') }
|
||||||
alert('Export failed')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPlanError) {
|
if (isPlanError) {
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-2xl mx-auto">
|
<div className="p-6 max-w-2xl mx-auto">
|
||||||
<Card className="p-8 text-center">
|
<Card className="p-10 text-center">
|
||||||
<div className="w-14 h-14 rounded-2xl bg-primary-50 flex items-center justify-center mx-auto mb-4">
|
<div className="w-16 h-16 rounded-2xl bg-primary-50 flex items-center justify-center mx-auto mb-5">
|
||||||
<Lock className="w-6 h-6 text-primary-600" />
|
<Lock className="w-7 h-7 text-primary-600" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-2">Lead Capture</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-2">Lead Capture</h2>
|
||||||
<p className="text-gray-500 text-sm mb-6">
|
<p className="text-gray-500 text-sm mb-6 max-w-sm mx-auto">
|
||||||
Upgrade to Starter to capture and manage leads from your chatbots.
|
Upgrade to Starter to capture and manage leads from your chatbots.
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -53,82 +114,258 @@ export const LeadsPage: React.FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const thisMonthLeads = leads.filter((l) => {
|
||||||
|
if (!l.created_at) return false
|
||||||
|
const d = new Date(l.created_at)
|
||||||
|
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Apply client-side status filter
|
||||||
|
const filtered = statusFilter ? leads.filter(l => l.status === statusFilter) : leads
|
||||||
|
|
||||||
|
// CRM stats
|
||||||
|
const byStatus = (s: LeadStatus) => leads.filter(l => l.status === s).length
|
||||||
|
|
||||||
|
const LeadAvatar: React.FC<{ name?: string | null; email?: string | null }> = ({ name, email }) => {
|
||||||
|
const display = name || email || '?'
|
||||||
|
return (
|
||||||
|
<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 (
|
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 */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
<div>
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
<div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center flex-shrink-0">
|
||||||
<Users className="w-6 h-6 text-primary-600" />
|
<Users className="w-5 h-5 text-primary-600" />
|
||||||
Leads
|
</div>
|
||||||
</h1>
|
<div>
|
||||||
<p className="text-sm text-gray-500 mt-0.5">Contacts collected by your chatbots</p>
|
<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>
|
</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" />
|
<Download className="w-4 h-4" />
|
||||||
Export CSV
|
Export CSV
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* CRM pipeline stats */}
|
||||||
|
{!isLoading && leads.length > 0 && (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3">
|
||||||
|
{STATUS_OPTIONS.map(s => (
|
||||||
|
<button
|
||||||
|
key={s.value}
|
||||||
|
onClick={() => setStatusFilter(prev => prev === s.value ? '' : s.value)}
|
||||||
|
className={cn(
|
||||||
|
'p-3 rounded-xl border text-left transition-all',
|
||||||
|
statusFilter === s.value
|
||||||
|
? 'border-primary-300 bg-primary-50 shadow-sm'
|
||||||
|
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className={cn('text-xs font-semibold px-2 py-0.5 rounded-full inline-block mb-2', s.color)}>
|
||||||
|
{s.label}
|
||||||
|
</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900">{byStatus(s.value)}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats summary */}
|
||||||
|
{!isLoading && leads.length > 0 && (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Card className="p-5 flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center flex-shrink-0">
|
||||||
|
<UserCheck className="w-5 h-5 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">Total leads</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{leads.length.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-5 flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-emerald-50 flex items-center justify-center flex-shrink-0">
|
||||||
|
<TrendingUp className="w-5 h-5 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">This month</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{thisMonthLeads.length.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Filter */}
|
{/* Filter */}
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3">
|
||||||
<label className="text-sm font-medium text-gray-700 flex-shrink-0">Filter by chatbot:</label>
|
<div className="flex items-center gap-2 text-sm font-medium text-gray-600 flex-shrink-0">
|
||||||
|
<Filter className="w-4 h-4 text-gray-400" />
|
||||||
|
Filter by chatbot
|
||||||
|
</div>
|
||||||
<select
|
<select
|
||||||
value={chatbotFilter}
|
value={chatbotFilter}
|
||||||
onChange={e => setChatbotFilter(e.target.value)}
|
onChange={e => setChatbotFilter(e.target.value)}
|
||||||
className="flex-1 max-w-xs border border-gray-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500/20"
|
className="w-full sm:max-w-xs border border-gray-200 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary-500/30 focus:border-primary-400 transition-all appearance-none cursor-pointer"
|
||||||
>
|
>
|
||||||
<option value="">All chatbots</option>
|
<option value="">All chatbots</option>
|
||||||
{chatbots.map((c) => (
|
{chatbots.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
<option key={c.id} value={c.id}>{c.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
|
{statusFilter && (
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter('')}
|
||||||
|
className="text-xs text-primary-600 hover:underline flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" /> Clear status filter
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex justify-center py-12">
|
<Card className="p-6"><SkeletonTable rows={6} /></Card>
|
||||||
<Spinner className="text-primary-600" />
|
) : filtered.length === 0 ? (
|
||||||
</div>
|
<Card className="p-14 text-center">
|
||||||
) : leads.length === 0 ? (
|
<div className="w-16 h-16 rounded-2xl bg-gray-50 flex items-center justify-center mx-auto mb-5">
|
||||||
<Card className="p-12 text-center">
|
<Mail className="w-7 h-7 text-gray-300" />
|
||||||
<Mail className="w-10 h-10 text-gray-300 mx-auto mb-3" />
|
</div>
|
||||||
<h3 className="font-semibold text-gray-700 mb-1">No leads yet</h3>
|
<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">
|
<p className="text-sm text-gray-500 max-w-sm mx-auto leading-relaxed">
|
||||||
Enable lead capture on your chatbots to start collecting contact information from visitors.
|
{statusFilter
|
||||||
|
? 'Try a different filter or clear the current one.'
|
||||||
|
: 'Enable lead capture on your chatbots to start collecting contact information.'}
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card className="overflow-hidden">
|
<>
|
||||||
<div className="overflow-x-auto">
|
{/* Desktop table */}
|
||||||
<table className="w-full text-sm">
|
<Card className="overflow-hidden hidden sm:block">
|
||||||
<thead>
|
<div className="overflow-x-auto">
|
||||||
<tr className="bg-gray-50 border-b border-gray-200">
|
<table className="w-full text-sm">
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">Email</th>
|
<thead>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">Name</th>
|
<tr className="bg-gray-50/80 border-b border-gray-200">
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">Phone</th>
|
<th className="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Contact</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">Company</th>
|
<th className="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Phone</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">Date</th>
|
<th className="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Company</th>
|
||||||
</tr>
|
<th className="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Status</th>
|
||||||
</thead>
|
<th className="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Notes</th>
|
||||||
<tbody className="divide-y divide-gray-100">
|
<th className="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Date</th>
|
||||||
{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>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody className="divide-y divide-gray-100">
|
||||||
</table>
|
{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>
|
</div>
|
||||||
</Card>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import React, { useState } from 'react'
|
|||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { marketplaceAPI } from '@/services/api'
|
import { marketplaceAPI } from '@/services/api'
|
||||||
import { Card, Spinner, EmptyState, Button } from '@/components/ui'
|
import { Spinner, EmptyState, Button } from '@/components/ui'
|
||||||
|
import { SkeletonCard } from '@/components/Skeletons'
|
||||||
import { ChatInterface } from '@/components/ChatInterface'
|
import { ChatInterface } from '@/components/ChatInterface'
|
||||||
import { CATEGORIES, INDUSTRIES } from '@/lib/utils'
|
import { CATEGORIES, INDUSTRIES } from '@/lib/utils'
|
||||||
import { Search, Bot, Star, MessageSquare, ArrowLeft } from 'lucide-react'
|
import { Search, Bot, Star, MessageSquare, ArrowLeft, SlidersHorizontal, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
import type { ChatbotPublic } from '@/types'
|
import type { ChatbotPublic } from '@/types'
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -19,6 +20,7 @@ export const MarketplacePage: React.FC = () => {
|
|||||||
const [category, setCategory] = useState('')
|
const [category, setCategory] = useState('')
|
||||||
const [industry, setIndustry] = useState('')
|
const [industry, setIndustry] = useState('')
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
const [showFilters, setShowFilters] = useState(false)
|
||||||
|
|
||||||
// Debounce search
|
// Debounce search
|
||||||
const searchTimeout = React.useRef<ReturnType<typeof setTimeout>>()
|
const searchTimeout = React.useRef<ReturnType<typeof setTimeout>>()
|
||||||
@@ -36,88 +38,233 @@ export const MarketplacePage: React.FC = () => {
|
|||||||
queryFn: () => marketplaceAPI.list({ search: debouncedSearch, category, industry, page, limit: 20 }),
|
queryFn: () => marketplaceAPI.list({ search: debouncedSearch, category, industry, page, limit: 20 }),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const totalPages = data ? Math.ceil(data.total / 20) : 0
|
||||||
|
const hasActiveFilters = category !== '' || industry !== ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-6xl mx-auto">
|
<div className="min-h-full bg-gray-50/50">
|
||||||
{/* Header */}
|
{/* Page Header */}
|
||||||
<div className="mb-8 animate-fade-in">
|
<div className="bg-white border-b border-gray-200">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">AI Chatbot Marketplace</h1>
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8 sm:py-10">
|
||||||
<p className="text-sm text-gray-500 mt-1">Discover and interact with AI chatbots built by businesses</p>
|
<div className="animate-fade-in">
|
||||||
</div>
|
<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">
|
||||||
{/* Filters */}
|
<Bot className="w-5 h-5 text-white" />
|
||||||
<div className="flex flex-col sm:flex-row gap-3 mb-6 animate-fade-in-down">
|
</div>
|
||||||
<div className="relative flex-1">
|
<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">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
|
AI Chatbot Marketplace
|
||||||
<input
|
</h1>
|
||||||
type="text"
|
</div>
|
||||||
value={search}
|
<p className="text-gray-500 text-sm sm:text-base max-w-xl">
|
||||||
onChange={e => handleSearch(e.target.value)}
|
Discover and interact with AI-powered chatbots built by businesses — ready to answer your questions instantly.
|
||||||
placeholder="Search chatbots..."
|
</p>
|
||||||
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>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
|
</div>
|
||||||
{data.chatbots.map((chatbot, i) => (
|
</div>
|
||||||
<ChatbotMarketplaceCard
|
|
||||||
key={chatbot.id}
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-6">
|
||||||
chatbot={chatbot}
|
{/* Search & Filter Bar */}
|
||||||
index={i}
|
<div className="mb-6 animate-fade-in-down space-y-3">
|
||||||
onClick={() => navigate(`/marketplace/${chatbot.id}`)}
|
<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>
|
</div>
|
||||||
|
|
||||||
{data.total > 20 && (
|
{/* Expandable filter section */}
|
||||||
<div className="flex justify-center items-center gap-3">
|
{showFilters && (
|
||||||
<Button variant="outline" size="sm" onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}>
|
<div className="bg-white border border-gray-200 rounded-xl p-4 shadow-sm space-y-4 animate-fade-in-down">
|
||||||
Previous
|
{/* Category filter — pill buttons since list is manageable */}
|
||||||
</Button>
|
<div>
|
||||||
<span className="text-sm text-gray-500 bg-white border border-gray-200 px-4 py-1.5 rounded-lg shadow-sm">
|
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2.5">Category</p>
|
||||||
Page {page} of {Math.ceil(data.total / 20)}
|
<div className="flex flex-wrap gap-2">
|
||||||
</span>
|
<button
|
||||||
<Button variant="outline" size="sm" onClick={() => setPage(p => p + 1)} disabled={!data.has_more}>
|
onClick={() => { setCategory(''); setPage(1) }}
|
||||||
Next
|
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-all border ${
|
||||||
</Button>
|
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>
|
||||||
)}
|
)}
|
||||||
</>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -129,59 +276,79 @@ export const MarketplacePage: React.FC = () => {
|
|||||||
|
|
||||||
const ChatbotMarketplaceCard: React.FC<{ chatbot: ChatbotPublic; onClick: () => void; index: number }> = ({ chatbot, onClick, index }) => (
|
const ChatbotMarketplaceCard: React.FC<{ chatbot: ChatbotPublic; onClick: () => void; index: number }> = ({ chatbot, onClick, index }) => (
|
||||||
<div
|
<div
|
||||||
className="group bg-white rounded-xl border border-gray-200 shadow-sm hover:-translate-y-1.5 hover:shadow-xl hover:border-gray-300 transition-all duration-200 cursor-pointer overflow-hidden"
|
className="group relative bg-white rounded-xl border border-gray-200 shadow-sm hover:shadow-lg hover:border-gray-300 hover:-translate-y-1 transition-all duration-200 cursor-pointer overflow-hidden"
|
||||||
style={{ animationDelay: `${index * 60}ms`, animationFillMode: 'both' }}
|
style={{ animationDelay: `${index * 60}ms`, animationFillMode: 'both' }}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{/* Colored accent bar */}
|
{/* Colored accent top bar — thicker and with gradient */}
|
||||||
<div className="h-1 w-full" style={{ background: chatbot.primary_color }} />
|
<div
|
||||||
|
className="h-1.5 w-full"
|
||||||
|
style={{ background: `linear-gradient(90deg, ${chatbot.primary_color}, ${chatbot.primary_color}aa)` }}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
<div className="flex items-center gap-3 mb-3">
|
{/* Header row */}
|
||||||
|
<div className="flex items-start gap-3 mb-3">
|
||||||
{chatbot.logo_url ? (
|
{chatbot.logo_url ? (
|
||||||
<img
|
<img
|
||||||
src={chatbot.logo_url}
|
src={chatbot.logo_url}
|
||||||
alt={chatbot.name}
|
alt={chatbot.name}
|
||||||
className="w-11 h-11 rounded-xl object-cover flex-shrink-0 shadow-sm group-hover:scale-105 transition-transform duration-200"
|
className="w-12 h-12 rounded-xl object-cover flex-shrink-0 shadow-sm group-hover:scale-105 transition-transform duration-200"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="w-11 h-11 rounded-xl flex items-center justify-center text-white flex-shrink-0 shadow-sm group-hover:scale-105 transition-transform duration-200"
|
className="w-12 h-12 rounded-xl flex items-center justify-center text-white flex-shrink-0 shadow-sm group-hover:scale-105 transition-transform duration-200"
|
||||||
style={{ background: chatbot.primary_color }}
|
style={{ background: chatbot.primary_color }}
|
||||||
>
|
>
|
||||||
<Bot className="w-5 h-5" />
|
<Bot className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0 pt-0.5">
|
||||||
<h3 className="font-semibold text-gray-900 text-sm truncate group-hover:text-primary-700 transition-colors">{chatbot.name}</h3>
|
<h3 className="font-semibold text-gray-900 text-sm leading-tight truncate group-hover:text-primary-700 transition-colors">
|
||||||
|
{chatbot.name}
|
||||||
|
</h3>
|
||||||
{chatbot.company_name && (
|
{chatbot.company_name && (
|
||||||
<p className="text-xs text-gray-400 truncate">by {chatbot.company_name}</p>
|
<p className="text-xs text-gray-400 truncate mt-0.5">by {chatbot.company_name}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{chatbot.description && (
|
{/* Description */}
|
||||||
|
{chatbot.description ? (
|
||||||
<p className="text-xs text-gray-500 mb-4 line-clamp-2 leading-relaxed">{chatbot.description}</p>
|
<p className="text-xs text-gray-500 mb-4 line-clamp-2 leading-relaxed">{chatbot.description}</p>
|
||||||
|
) : (
|
||||||
|
<div className="mb-4" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Stats row */}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{chatbot.average_rating && chatbot.average_rating > 0 && (
|
{chatbot.average_rating && chatbot.average_rating > 0 && (
|
||||||
<span className="flex items-center gap-1 bg-yellow-50 text-yellow-700 px-2 py-0.5 rounded-full text-xs font-medium">
|
<span className="flex items-center gap-1 bg-amber-50 text-amber-700 px-2.5 py-1 rounded-full text-xs font-medium border border-amber-100">
|
||||||
<Star className="w-3 h-3 fill-yellow-400 text-yellow-400" />
|
<Star className="w-3 h-3 fill-amber-400 text-amber-400" />
|
||||||
{chatbot.average_rating.toFixed(1)}
|
{chatbot.average_rating.toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="flex items-center gap-1 bg-gray-50 text-gray-500 px-2 py-0.5 rounded-full text-xs">
|
<span className="flex items-center gap-1 bg-gray-50 text-gray-500 px-2.5 py-1 rounded-full text-xs border border-gray-100">
|
||||||
<MessageSquare className="w-3 h-3" />
|
<MessageSquare className="w-3 h-3" />
|
||||||
{chatbot.total_conversations.toLocaleString()}
|
{chatbot.total_conversations.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
{chatbot.category && (
|
{chatbot.category && (
|
||||||
<span className="bg-primary-50 text-primary-700 px-2 py-0.5 rounded-full text-xs font-medium truncate">
|
<span className="bg-primary-50 text-primary-700 px-2.5 py-1 rounded-full text-xs font-medium border border-primary-100 truncate max-w-[120px]">
|
||||||
{chatbot.category}
|
{chatbot.category}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Hover overlay: "Chat now" CTA */}
|
||||||
|
<div className="absolute inset-0 flex items-end justify-center pb-5 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none">
|
||||||
|
<div
|
||||||
|
className="px-5 py-2 rounded-full text-white text-xs font-semibold shadow-lg pointer-events-none"
|
||||||
|
style={{ background: chatbot.primary_color }}
|
||||||
|
>
|
||||||
|
Chat now →
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -231,54 +398,68 @@ export const ChatbotDetailPage: React.FC = () => {
|
|||||||
{/* Back link */}
|
{/* Back link */}
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/marketplace')}
|
onClick={() => navigate('/marketplace')}
|
||||||
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-primary-600 mb-5 transition-colors group"
|
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-primary-600 mb-6 transition-colors group"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-0.5 transition-transform" />
|
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-0.5 transition-transform" />
|
||||||
Back to Marketplace
|
Back to Marketplace
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Chatbot info */}
|
{/* Chatbot info card */}
|
||||||
<div className="flex items-center gap-4 mb-5">
|
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden mb-5">
|
||||||
{chatbot.logo_url ? (
|
{/* Accent bar */}
|
||||||
<img
|
<div
|
||||||
src={chatbot.logo_url}
|
className="h-1.5 w-full"
|
||||||
alt={chatbot.name}
|
style={{ background: `linear-gradient(90deg, ${chatbot.primary_color}, ${chatbot.primary_color}88)` }}
|
||||||
className="w-16 h-16 rounded-2xl object-cover shadow-md"
|
/>
|
||||||
/>
|
<div className="p-5 sm:p-6">
|
||||||
) : (
|
<div className="flex items-center gap-4 mb-3">
|
||||||
<div
|
{chatbot.logo_url ? (
|
||||||
className="w-16 h-16 rounded-2xl flex items-center justify-center text-white shadow-md"
|
<img
|
||||||
style={{ background: chatbot.primary_color }}
|
src={chatbot.logo_url}
|
||||||
>
|
alt={chatbot.name}
|
||||||
<Bot className="w-8 h-8" />
|
className="w-16 h-16 rounded-2xl object-cover shadow-md flex-shrink-0"
|
||||||
</div>
|
/>
|
||||||
)}
|
) : (
|
||||||
<div>
|
<div
|
||||||
<h1 className="text-xl sm:text-2xl font-bold text-gray-900">{chatbot.name}</h1>
|
className="w-16 h-16 rounded-2xl flex items-center justify-center text-white shadow-md flex-shrink-0"
|
||||||
{chatbot.company_name && (
|
style={{ background: chatbot.primary_color }}
|
||||||
<p className="text-sm text-gray-500">by {chatbot.company_name}</p>
|
>
|
||||||
)}
|
<Bot className="w-8 h-8" />
|
||||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
</div>
|
||||||
{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>
|
|
||||||
)}
|
)}
|
||||||
<span className="flex items-center gap-1 text-xs text-gray-500 bg-gray-50 px-2 py-0.5 rounded-full">
|
<div>
|
||||||
<MessageSquare className="w-3 h-3" />
|
<h1 className="text-xl sm:text-2xl font-bold text-gray-900">{chatbot.name}</h1>
|
||||||
{chatbot.total_conversations.toLocaleString()} conversations
|
{chatbot.company_name && (
|
||||||
</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
|
{chatbot.description && (
|
||||||
|
<p className="text-gray-500 text-sm leading-relaxed">{chatbot.description}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{chatbot.description && (
|
|
||||||
<p className="text-gray-500 text-sm mb-5 leading-relaxed">{chatbot.description}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Chat */}
|
{/* Chat */}
|
||||||
<div className="h-[calc(100vh-300px)] min-h-[400px] max-h-[680px] rounded-2xl overflow-hidden border border-gray-200 shadow-sm">
|
<div className="h-[calc(100vh-340px)] min-h-[400px] max-h-[680px] rounded-2xl overflow-hidden border border-gray-200 shadow-sm">
|
||||||
<ChatInterface
|
<ChatInterface
|
||||||
chatbotId={chatbot.id}
|
chatbotId={chatbot.id}
|
||||||
chatbotName={chatbot.name}
|
chatbotName={chatbot.name}
|
||||||
@@ -289,4 +470,4 @@ export const ChatbotDetailPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,23 +4,28 @@ import { useQuery } from '@tanstack/react-query'
|
|||||||
import { billingAPI } from '@/services/api'
|
import { billingAPI } from '@/services/api'
|
||||||
import { useAuthStore } from '@/store/authStore'
|
import { useAuthStore } from '@/store/authStore'
|
||||||
import { Button } from '@/components/ui'
|
import { Button } from '@/components/ui'
|
||||||
import { Check } from 'lucide-react'
|
import { Check, Minus, Zap, Building2, Rocket, Star } from 'lucide-react'
|
||||||
|
|
||||||
const PLANS = [
|
const PLANS = [
|
||||||
{
|
{
|
||||||
id: 'free',
|
id: 'free',
|
||||||
name: 'Free',
|
name: 'Free',
|
||||||
price: 0,
|
price: 0,
|
||||||
|
yearlyPrice: 0,
|
||||||
description: 'Build, test and launch your first chatbot — no card needed',
|
description: 'Build, test and launch your first chatbot — no card needed',
|
||||||
icon: '🆓',
|
icon: Star,
|
||||||
|
iconColor: 'text-gray-500',
|
||||||
|
iconBg: 'bg-gray-100',
|
||||||
features: [
|
features: [
|
||||||
{ text: '1 published chatbot', included: true },
|
{ text: '1 published chatbot', included: true },
|
||||||
{ text: '100 conversations/month', included: true },
|
{ text: '100 conversations/month', included: true },
|
||||||
{ text: '3 documents per chatbot', included: true },
|
{ text: '3 documents per chatbot', included: true },
|
||||||
{ text: 'Public chat link + website embed', included: true },
|
{ text: 'Public chat link + website embed', included: true },
|
||||||
{ text: 'Llama 3.3 70B model', included: true },
|
{ text: 'Llama 3.3 70B model', included: true },
|
||||||
|
{ text: 'Read-only inbox (no agent replies)', included: true },
|
||||||
|
{ text: 'View-only leads (no editing)', included: true },
|
||||||
{ text: 'Analytics dashboard', included: false },
|
{ text: 'Analytics dashboard', included: false },
|
||||||
{ text: 'Lead capture', included: false },
|
{ text: 'Appointments & campaigns', included: false },
|
||||||
{ text: 'Messaging channels', included: false },
|
{ text: 'Messaging channels', included: false },
|
||||||
{ text: 'Remove "Powered by Contexta"', included: false },
|
{ text: 'Remove "Powered by Contexta"', included: false },
|
||||||
],
|
],
|
||||||
@@ -28,52 +33,67 @@ const PLANS = [
|
|||||||
{
|
{
|
||||||
id: 'starter',
|
id: 'starter',
|
||||||
name: 'Starter',
|
name: 'Starter',
|
||||||
price: 12,
|
price: 19,
|
||||||
description: 'For individuals and solo businesses going live',
|
yearlyPrice: 15,
|
||||||
icon: '🚀',
|
description: 'For solo operators: live chat, leads, booking, and campaigns',
|
||||||
|
icon: Rocket,
|
||||||
|
iconColor: 'text-blue-600',
|
||||||
|
iconBg: 'bg-blue-50',
|
||||||
features: [
|
features: [
|
||||||
{ text: 'Everything in Free', included: true },
|
{ text: 'Everything in Free', included: true },
|
||||||
|
{ text: '3 published chatbots', included: true },
|
||||||
{ text: '1,500 conversations/month', included: true },
|
{ text: '1,500 conversations/month', included: true },
|
||||||
{ text: '10 documents per chatbot', included: true },
|
{ text: '10 documents per chatbot', included: true },
|
||||||
{ text: '4 Fireworks AI models (Qwen3, DeepSeek, Kimi, Llama)', included: true },
|
{ text: '4 Fireworks AI models (Qwen3, DeepSeek, Kimi, Llama)', included: true },
|
||||||
{ text: 'Lead capture + inbox', included: true },
|
{ text: 'Live chat inbox + agent replies', included: true },
|
||||||
{ text: 'Analytics + knowledge gaps', included: true },
|
{ text: 'Full lead CRM (status + notes)', included: true },
|
||||||
{ text: 'Telegram channel', included: true },
|
{ text: 'Appointment booking (1 chatbot)', included: true },
|
||||||
{ text: 'WhatsApp channel', included: false },
|
{ text: 'Telegram campaigns (3/mo · 500 recipients)', included: true },
|
||||||
|
{ text: 'Analytics dashboard', included: true },
|
||||||
|
{ text: 'Knowledge gap suggestions', included: false },
|
||||||
{ text: 'Premium models (GPT-4o, Claude, Gemini)', included: false },
|
{ text: 'Premium models (GPT-4o, Claude, Gemini)', included: false },
|
||||||
|
{ text: 'Remove "Powered by Contexta"', included: false },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'business',
|
id: 'business',
|
||||||
name: 'Business',
|
name: 'Business',
|
||||||
price: 29,
|
price: 49,
|
||||||
description: 'For growing businesses that need more reach and power',
|
yearlyPrice: 39,
|
||||||
icon: '⚡',
|
description: 'For growing businesses: premium AI, unlimited booking, full analytics',
|
||||||
|
icon: Zap,
|
||||||
|
iconColor: 'text-primary-600',
|
||||||
|
iconBg: 'bg-primary-50',
|
||||||
highlighted: true,
|
highlighted: true,
|
||||||
badge: 'Most Popular',
|
badge: 'Most Popular',
|
||||||
features: [
|
features: [
|
||||||
{ text: 'Everything in Starter', included: true },
|
{ text: 'Everything in Starter', included: true },
|
||||||
{ text: 'Up to 3 published chatbots', included: true },
|
{ text: '10 published chatbots', included: true },
|
||||||
{ text: '5,000 conversations/month', included: true },
|
{ text: '5,000 conversations/month', included: true },
|
||||||
{ text: '50 documents per chatbot', included: true },
|
{ text: '50 documents per chatbot', included: true },
|
||||||
{ text: 'WhatsApp + Telegram channels', included: true },
|
{ text: 'GPT-4o, Claude Haiku 4.5, Gemini 2.5', included: true },
|
||||||
{ text: 'GPT-4o, Claude Haiku, Gemini 2.5', included: true },
|
{ text: 'Appointment booking (all chatbots)', included: true },
|
||||||
|
{ text: 'Unlimited campaigns · 5,000 recipients', included: true },
|
||||||
|
{ text: 'Knowledge gap suggestions', included: true },
|
||||||
{ text: 'Remove "Powered by Contexta"', included: true },
|
{ text: 'Remove "Powered by Contexta"', included: true },
|
||||||
{ text: 'Unlimited URL sources', included: true },
|
{ text: 'Unlimited URL sources', included: true },
|
||||||
{ text: 'Priority support', included: true },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'agency',
|
id: 'agency',
|
||||||
name: 'Agency',
|
name: 'Agency',
|
||||||
price: 79,
|
price: 99,
|
||||||
description: 'For agencies and large businesses managing many chatbots',
|
yearlyPrice: 79,
|
||||||
icon: '🏗️',
|
description: 'For agencies: unlimited everything, white-label ready',
|
||||||
|
icon: Building2,
|
||||||
|
iconColor: 'text-purple-600',
|
||||||
|
iconBg: 'bg-purple-50',
|
||||||
features: [
|
features: [
|
||||||
{ text: 'Everything in Business', included: true },
|
{ text: 'Everything in Business', included: true },
|
||||||
{ text: 'Unlimited published chatbots', included: true },
|
{ text: 'Unlimited published chatbots', included: true },
|
||||||
{ text: '20,000 conversations/month', included: true },
|
{ text: '20,000 conversations/month', included: true },
|
||||||
{ text: 'Unlimited documents', included: true },
|
{ text: 'Unlimited documents', included: true },
|
||||||
|
{ text: 'Unlimited campaign recipients', included: true },
|
||||||
{ text: 'Code export (FastAPI + React)', included: true },
|
{ text: 'Code export (FastAPI + React)', included: true },
|
||||||
{ text: 'Dedicated support', included: true },
|
{ text: 'Dedicated support', included: true },
|
||||||
],
|
],
|
||||||
@@ -82,8 +102,11 @@ const PLANS = [
|
|||||||
id: 'enterprise',
|
id: 'enterprise',
|
||||||
name: 'Enterprise',
|
name: 'Enterprise',
|
||||||
price: null,
|
price: null,
|
||||||
|
yearlyPrice: null,
|
||||||
description: 'For large organizations with custom needs and SLAs',
|
description: 'For large organizations with custom needs and SLAs',
|
||||||
icon: '🏢',
|
icon: Building2,
|
||||||
|
iconColor: 'text-gray-700',
|
||||||
|
iconBg: 'bg-gray-100',
|
||||||
features: [
|
features: [
|
||||||
{ text: 'Everything in Agency', included: true },
|
{ text: 'Everything in Agency', included: true },
|
||||||
{ text: 'Unlimited conversations', included: true },
|
{ text: 'Unlimited conversations', included: true },
|
||||||
@@ -100,6 +123,7 @@ export const PricingPage: React.FC = () => {
|
|||||||
const { user } = useAuthStore()
|
const { user } = useAuthStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [loading, setLoading] = useState<string | null>(null)
|
const [loading, setLoading] = useState<string | null>(null)
|
||||||
|
const [yearly, setYearly] = useState(false)
|
||||||
|
|
||||||
const { data: subscription } = useQuery({
|
const { data: subscription } = useQuery({
|
||||||
queryKey: ['subscription'],
|
queryKey: ['subscription'],
|
||||||
@@ -138,135 +162,200 @@ export const PricingPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getCtaText = (planId: string): string => {
|
const getCtaText = (planId: string): string => {
|
||||||
if (!user) return planId === 'enterprise' ? 'Contact Sales' : 'Get Started'
|
if (!user) return planId === 'enterprise' ? 'Contact Sales' : 'Get Started Free'
|
||||||
if (planId === currentPlan) return 'Current Plan'
|
if (planId === currentPlan) return 'Current Plan'
|
||||||
if (planId === 'enterprise') return 'Contact Sales'
|
if (planId === 'enterprise') return 'Contact Sales'
|
||||||
if (planId === 'free') return 'Downgrade'
|
if (planId === 'free') return 'Downgrade'
|
||||||
return 'Upgrade'
|
return 'Upgrade Now'
|
||||||
}
|
}
|
||||||
|
|
||||||
const isCurrentPlan = (planId: string) => user && planId === currentPlan
|
const isCurrentPlan = (planId: string) => user && planId === currentPlan
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-6xl mx-auto">
|
<div className="p-6 max-w-6xl mx-auto">
|
||||||
<div className="text-center mb-12">
|
{/* Header */}
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-3">Simple, affordable pricing</h1>
|
<div className="text-center mb-10 animate-fade-in-up">
|
||||||
<p className="text-gray-500 max-w-xl mx-auto">
|
<span className="inline-block px-3 py-1 text-xs font-semibold bg-primary-50 text-primary-600 rounded-full mb-4 border border-primary-100">
|
||||||
Start free and go live for $12/month. Built for individuals, small businesses, agencies, and enterprises alike.
|
Pricing
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-5">
|
{/* Plan cards */}
|
||||||
{PLANS.map((plan) => (
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-4">
|
||||||
<div
|
{PLANS.map((plan, i) => {
|
||||||
key={plan.id}
|
const PlanIcon = plan.icon
|
||||||
className={`relative rounded-2xl border p-6 flex flex-col ${
|
const displayPrice = yearly ? plan.yearlyPrice : plan.price
|
||||||
plan.highlighted
|
const isCurrent = isCurrentPlan(plan.id)
|
||||||
? 'border-primary-400 bg-gradient-to-b from-primary-50 to-white shadow-lg shadow-primary-100'
|
|
||||||
: isCurrentPlan(plan.id)
|
return (
|
||||||
? 'border-primary-300 bg-primary-50/30 shadow-sm'
|
<div
|
||||||
: 'border-gray-200 bg-white'
|
key={plan.id}
|
||||||
}`}
|
className={`relative rounded-2xl border flex flex-col transition-all duration-200 animate-fade-in-up ${
|
||||||
>
|
plan.highlighted
|
||||||
{isCurrentPlan(plan.id) && (
|
? 'border-primary-300 bg-gradient-to-b from-primary-50 via-white to-white shadow-lg shadow-primary-100/60 scale-[1.02]'
|
||||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
: isCurrent
|
||||||
<span className="bg-green-600 text-white text-xs font-semibold px-3 py-1 rounded-full">
|
? 'border-green-200 bg-green-50/30 shadow-sm'
|
||||||
Current Plan
|
: 'border-gray-200 bg-white hover:border-gray-300 hover:shadow-md'
|
||||||
</span>
|
}`}
|
||||||
</div>
|
style={{ animationDelay: `${i * 60}ms` }}
|
||||||
)}
|
>
|
||||||
{plan.badge && !isCurrentPlan(plan.id) && (
|
{/* Badge */}
|
||||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
{isCurrent && (
|
||||||
<span className="bg-primary-600 text-white text-xs font-semibold px-3 py-1 rounded-full">
|
<div className="absolute -top-3.5 left-1/2 -translate-x-1/2 z-10">
|
||||||
{plan.badge}
|
<span className="bg-green-600 text-white text-xs font-semibold px-3 py-1 rounded-full shadow-sm">
|
||||||
</span>
|
Current Plan
|
||||||
</div>
|
</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">
|
<div className="mt-4">
|
||||||
{plan.price !== null ? (
|
{displayPrice !== null ? (
|
||||||
<div className="flex items-baseline gap-1">
|
<div className="flex items-baseline gap-1">
|
||||||
<span className="text-4xl font-extrabold text-gray-900">${plan.price}</span>
|
<span className="text-3xl font-extrabold text-gray-900">${displayPrice}</span>
|
||||||
{plan.price > 0 && <span className="text-gray-500 text-sm">/month</span>}
|
{(displayPrice as number) > 0 && (
|
||||||
</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1">
|
{/* Features */}
|
||||||
<ul className="space-y-3 mb-8">
|
<ul className="space-y-2.5 mb-6 flex-1">
|
||||||
{plan.features.map((feature) => (
|
{plan.features.map((feature) => (
|
||||||
<li key={feature.text} className="flex items-start gap-2 text-sm">
|
<li key={feature.text} className="flex items-start gap-2 text-xs">
|
||||||
<div className={`w-4 h-4 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5 ${
|
{feature.included ? (
|
||||||
feature.included
|
<span className="w-4 h-4 rounded-full bg-green-100 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-400'
|
<Check className="w-2.5 h-2.5 text-green-600" />
|
||||||
}`}>
|
</span>
|
||||||
<Check className="w-2.5 h-2.5" />
|
) : (
|
||||||
</div>
|
<span className="w-4 h-4 rounded-full bg-gray-100 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
<span className={feature.included ? 'text-gray-700' : 'text-gray-400'}>
|
<Minus className="w-2.5 h-2.5 text-gray-400" />
|
||||||
{feature.text}
|
</span>
|
||||||
</span>
|
)}
|
||||||
</li>
|
<span className={feature.included ? 'text-gray-700' : 'text-gray-400'}>
|
||||||
))}
|
{feature.text}
|
||||||
</ul>
|
</span>
|
||||||
</div>
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleSubscribe(plan.id)}
|
onClick={() => handleSubscribe(plan.id)}
|
||||||
loading={loading === plan.id}
|
loading={loading === plan.id}
|
||||||
disabled={isCurrentPlan(plan.id) || loading === plan.id}
|
disabled={isCurrent || loading === plan.id}
|
||||||
variant={plan.highlighted ? 'default' : 'outline'}
|
variant={plan.highlighted ? 'primary' : isCurrent ? 'secondary' : 'outline'}
|
||||||
className="w-full"
|
className={`w-full transition-all duration-200 ${
|
||||||
|
plan.highlighted
|
||||||
|
? 'bg-gradient-to-r from-primary-600 to-primary-700 hover:from-primary-700 hover:to-primary-800 shadow-sm hover:shadow-md'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
size="md"
|
||||||
>
|
>
|
||||||
{getCtaText(plan.id)}
|
{getCtaText(plan.id)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
</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>
|
||||||
|
</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 React, { useState, useEffect } from 'react'
|
||||||
import { useNavigate, Link } from 'react-router-dom'
|
import { useNavigate, Link } from 'react-router-dom'
|
||||||
import { authAPI } from '@/services/api'
|
import { authAPI } from '@/services/api'
|
||||||
import { Button, Input } from '@/components/ui'
|
import { Button } from '@/components/ui'
|
||||||
import { Sparkles, Eye, EyeOff } from 'lucide-react'
|
import { Sparkles, Eye, EyeOff, Lock, X, ArrowLeft } from 'lucide-react'
|
||||||
|
|
||||||
export const ResetPasswordPage: React.FC = () => {
|
export const ResetPasswordPage: React.FC = () => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -54,62 +54,117 @@ export const ResetPasswordPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 via-white to-purple-50 flex items-center justify-center p-4">
|
<div className="min-h-screen bg-dots bg-gray-50 flex items-center justify-center p-4">
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md animate-scale-in">
|
||||||
<div className="text-center mb-8">
|
{/* Logo */}
|
||||||
<div className="inline-flex w-12 h-12 bg-primary-600 rounded-2xl items-center justify-center mb-4">
|
<div className="flex items-center justify-center gap-2 mb-8">
|
||||||
<Sparkles className="w-6 h-6 text-white" />
|
<div className="w-9 h-9 bg-primary-600 rounded-xl flex items-center justify-center shadow-sm">
|
||||||
|
<Sparkles className="w-5 h-5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Set new password</h1>
|
<span className="font-bold text-gray-900 text-lg">Contexta</span>
|
||||||
<p className="text-gray-500 mt-1 text-sm">Choose a strong password for your account</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-8">
|
<div className="bg-white rounded-2xl shadow-xl border border-gray-100 p-8">
|
||||||
{!accessToken && error ? (
|
{!accessToken && error ? (
|
||||||
<div className="text-center">
|
<div className="text-center py-2 animate-fade-in-up">
|
||||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700 mb-4">
|
<div className="w-14 h-14 bg-red-50 rounded-2xl flex items-center justify-center mx-auto mb-5">
|
||||||
{error}
|
<X className="w-7 h-7 text-red-500" />
|
||||||
</div>
|
</div>
|
||||||
<Link to="/forgot-password" className="text-primary-600 hover:underline text-sm">
|
<h2 className="text-xl font-bold text-gray-900 mb-2">Link expired</h2>
|
||||||
|
<p className="text-sm text-gray-500 mb-6">{error}</p>
|
||||||
|
<Link
|
||||||
|
to="/forgot-password"
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700 transition-colors"
|
||||||
|
>
|
||||||
Request a new reset link
|
Request a new reset link
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<>
|
||||||
<div className="relative">
|
<div className="mb-7">
|
||||||
<Input
|
<h1 className="text-2xl font-bold text-gray-900">Set new password</h1>
|
||||||
label="New Password"
|
<p className="text-gray-500 mt-1 text-sm">
|
||||||
type={showPass ? 'text' : 'password'}
|
Choose a strong password for your account.
|
||||||
value={password}
|
</p>
|
||||||
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>
|
</div>
|
||||||
<Input
|
|
||||||
label="Confirm Password"
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
type={showPass ? 'text' : 'password'}
|
{/* New password */}
|
||||||
value={confirm}
|
<div className="flex flex-col gap-1.5">
|
||||||
onChange={e => setConfirm(e.target.value)}
|
<label className="text-sm font-medium text-gray-700">New Password</label>
|
||||||
placeholder="Repeat password"
|
<div className="relative">
|
||||||
required
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
|
||||||
/>
|
<Lock className="w-4 h-4" />
|
||||||
{error && (
|
</span>
|
||||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
<input
|
||||||
{error}
|
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>
|
</div>
|
||||||
)}
|
|
||||||
<Button type="submit" loading={loading} className="w-full" size="lg">
|
{/* Confirm password */}
|
||||||
Set new password
|
<div className="flex flex-col gap-1.5">
|
||||||
</Button>
|
<label className="text-sm font-medium text-gray-700">Confirm Password</label>
|
||||||
</form>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ import { useNavigate, useLocation, Link } from 'react-router-dom'
|
|||||||
import { billingAPI, authAPI } from '@/services/api'
|
import { billingAPI, authAPI } from '@/services/api'
|
||||||
import { useAuthStore } from '@/store/authStore'
|
import { useAuthStore } from '@/store/authStore'
|
||||||
import { Button, Card, Input } from '@/components/ui'
|
import { Button, Card, Input } from '@/components/ui'
|
||||||
|
import { useToast } from '@/contexts/ToastContext'
|
||||||
|
import { useThemeStore } from '@/store/themeStore'
|
||||||
import { getPlanColor, formatDate } from '@/lib/utils'
|
import { getPlanColor, formatDate } from '@/lib/utils'
|
||||||
import { CreditCard, User, ExternalLink, AlertTriangle } from 'lucide-react'
|
import { CreditCard, User, ExternalLink, AlertTriangle, Moon, Sun } from 'lucide-react'
|
||||||
|
|
||||||
export const SettingsPage: React.FC = () => {
|
export const SettingsPage: React.FC = () => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [toast, setToast] = useState('')
|
const { success: showToast, error: showError } = useToast()
|
||||||
|
const { isDark, toggle: toggleTheme } = useThemeStore()
|
||||||
|
|
||||||
const tab: 'profile' | 'billing' = location.pathname === '/settings/billing' ? 'billing' : 'profile'
|
const tab: 'profile' | 'billing' = location.pathname === '/settings/billing' ? 'billing' : 'profile'
|
||||||
|
|
||||||
@@ -21,14 +24,19 @@ export const SettingsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [navigate, location.pathname])
|
}, [navigate, location.pathname])
|
||||||
|
|
||||||
const showToast = (msg: string) => {
|
|
||||||
setToast(msg)
|
|
||||||
setTimeout(() => setToast(''), 3500)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-3xl mx-auto">
|
<div className="p-6 max-w-3xl mx-auto">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Settings</h1>
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-200 hover:bg-gray-100 text-sm text-gray-600 transition-colors"
|
||||||
|
aria-label="Toggle dark mode"
|
||||||
|
>
|
||||||
|
{isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||||
|
{isDark ? 'Light mode' : 'Dark mode'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-1 mb-6 bg-gray-100 p-1 rounded-xl w-fit">
|
<div className="flex gap-1 mb-6 bg-gray-100 p-1 rounded-xl w-fit">
|
||||||
@@ -51,20 +59,13 @@ export const SettingsPage: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === 'profile' && <ProfileSettings onToast={showToast} />}
|
{tab === 'profile' && <ProfileSettings onToast={showToast} onError={showError} />}
|
||||||
{tab === 'billing' && <BillingSettings onToast={showToast} />}
|
{tab === 'billing' && <BillingSettings onToast={showToast} onError={showError} />}
|
||||||
|
|
||||||
{toast && (
|
|
||||||
<div className="fixed bottom-4 right-4 bg-gray-900 text-white px-4 py-2 rounded-lg text-sm shadow-lg z-50 animate-fade-in-up">
|
|
||||||
{toast}
|
|
||||||
<button onClick={() => setToast('')} className="ml-3 opacity-60 hover:opacity-100">×</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProfileSettings: React.FC<{ onToast: (msg: string) => void }> = ({ onToast }) => {
|
const ProfileSettings: React.FC<{ onToast: (msg: string) => void; onError: (msg: string) => void }> = ({ onToast, onError }) => {
|
||||||
const { user, setAuth, token, logout } = useAuthStore()
|
const { user, setAuth, token, logout } = useAuthStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [companyName, setCompanyName] = useState(user?.company_name || '')
|
const [companyName, setCompanyName] = useState(user?.company_name || '')
|
||||||
@@ -95,7 +96,7 @@ const ProfileSettings: React.FC<{ onToast: (msg: string) => void }> = ({ onToast
|
|||||||
onToast('Profile updated successfully')
|
onToast('Profile updated successfully')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as { response?: { data?: { detail?: string } } }
|
const e = err as { response?: { data?: { detail?: string } } }
|
||||||
onToast(e.response?.data?.detail || 'Failed to update profile')
|
onError(e.response?.data?.detail || 'Failed to update profile')
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@@ -110,7 +111,7 @@ const ProfileSettings: React.FC<{ onToast: (msg: string) => void }> = ({ onToast
|
|||||||
navigate('/')
|
navigate('/')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as { response?: { data?: { detail?: string } } }
|
const e = err as { response?: { data?: { detail?: string } } }
|
||||||
onToast(e.response?.data?.detail || 'Failed to delete account')
|
onError(e.response?.data?.detail || 'Failed to delete account')
|
||||||
setDeleting(false)
|
setDeleting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,7 +212,7 @@ const ProfileSettings: React.FC<{ onToast: (msg: string) => void }> = ({ onToast
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const BillingSettings: React.FC<{ onToast: (msg: string) => void }> = ({ onToast }) => {
|
const BillingSettings: React.FC<{ onToast: (msg: string) => void; onError: (msg: string) => void }> = ({ onError }) => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
@@ -227,7 +228,7 @@ const BillingSettings: React.FC<{ onToast: (msg: string) => void }> = ({ onToast
|
|||||||
window.location.href = url
|
window.location.href = url
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as { response?: { data?: { detail?: string } } }
|
const e = err as { response?: { data?: { detail?: string } } }
|
||||||
onToast(e.response?.data?.detail || 'Failed to open billing portal')
|
onError(e.response?.data?.detail || 'Failed to open billing portal')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|||||||
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 {
|
import type {
|
||||||
AuthResponse, Chatbot, ChatbotPublic, ChatResponse,
|
AuthResponse, Chatbot, ChatbotPublic, ChatResponse,
|
||||||
Document, MarketplaceResponse, Subscription,
|
Document, MarketplaceResponse, Subscription,
|
||||||
ModelsResponse, Lead, UrlSource, InboxConversation, ChannelConnection
|
ModelsResponse, Lead, UrlSource, InboxConversation, ChannelConnection,
|
||||||
|
AdminStats, AdminUser, AdminChatbot, AdminConversation, AdminSystemHealth,
|
||||||
|
Appointment, BusinessHoursEntry, TimeSlot, Campaign,
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||||
@@ -196,6 +198,9 @@ export const leadsAPI = {
|
|||||||
list: (params?: { chatbot_id?: string; page?: number; limit?: number }) =>
|
list: (params?: { chatbot_id?: string; page?: number; limit?: number }) =>
|
||||||
api.get<Lead[]>('/leads', { params }).then(r => r.data),
|
api.get<Lead[]>('/leads', { params }).then(r => r.data),
|
||||||
|
|
||||||
|
update: (id: string, data: { status?: string; notes?: string }) =>
|
||||||
|
api.patch<Lead>(`/leads/${id}`, data).then(r => r.data),
|
||||||
|
|
||||||
submit: (chatbotId: string, data: { email?: string; name?: string; phone?: string; company?: string; conversation_id?: string }) =>
|
submit: (chatbotId: string, data: { email?: string; name?: string; phone?: string; company?: string; conversation_id?: string }) =>
|
||||||
api.post<Lead>(`/chatbots/${chatbotId}/leads`, data).then(r => r.data),
|
api.post<Lead>(`/chatbots/${chatbotId}/leads`, data).then(r => r.data),
|
||||||
|
|
||||||
@@ -208,16 +213,104 @@ export const leadsAPI = {
|
|||||||
|
|
||||||
// ─── Inbox ────────────────────────────────────────────────────────────────────
|
// ─── Inbox ────────────────────────────────────────────────────────────────────
|
||||||
export const inboxAPI = {
|
export const inboxAPI = {
|
||||||
conversations: (params?: { chatbot_id?: string; page?: number; limit?: number }) =>
|
conversations: (params?: { chatbot_id?: string; status?: string; page?: number; limit?: number }) =>
|
||||||
api.get<InboxConversation[]>('/inbox/conversations', { params }).then(r => r.data),
|
api.get<InboxConversation[]>('/inbox/conversations', { params }).then(r => r.data),
|
||||||
|
|
||||||
conversation: (id: string) =>
|
conversation: (id: string) =>
|
||||||
api.get(`/inbox/conversations/${id}`).then(r => r.data),
|
api.get(`/inbox/conversations/${id}`).then(r => r.data),
|
||||||
|
|
||||||
|
updateStatus: (id: string, status: string) =>
|
||||||
|
api.patch(`/inbox/conversations/${id}/status`, { status }).then(r => r.data),
|
||||||
|
|
||||||
|
reply: (id: string, message: string) =>
|
||||||
|
api.post(`/inbox/conversations/${id}/reply`, { message }).then(r => r.data),
|
||||||
|
|
||||||
deleteConversation: (id: string) =>
|
deleteConversation: (id: string) =>
|
||||||
api.delete(`/inbox/conversations/${id}`).then(r => r.data),
|
api.delete(`/inbox/conversations/${id}`).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Appointments ─────────────────────────────────────────────────────────────
|
||||||
|
export const appointmentsAPI = {
|
||||||
|
list: (params?: { chatbot_id?: string; status?: string; page?: number }) =>
|
||||||
|
api.get<Appointment[]>('/appointments', { params }).then(r => r.data),
|
||||||
|
|
||||||
|
updateStatus: (id: string, status: string) =>
|
||||||
|
api.patch<Appointment>(`/appointments/${id}`, { status }).then(r => r.data),
|
||||||
|
|
||||||
|
getHours: (chatbotId: string) =>
|
||||||
|
api.get<BusinessHoursEntry[]>(`/appointments/chatbot/${chatbotId}/hours`).then(r => r.data),
|
||||||
|
|
||||||
|
saveHours: (chatbotId: string, hours: BusinessHoursEntry[]) =>
|
||||||
|
api.put(`/appointments/chatbot/${chatbotId}/hours`, { hours }).then(r => r.data),
|
||||||
|
|
||||||
|
getBookingInfo: (chatbotId: string) =>
|
||||||
|
api.get<{ chatbot_id: string; chatbot_name: string; company_name: string }>(`/chatbots/${chatbotId}/booking-info`).then(r => r.data),
|
||||||
|
|
||||||
|
getAvailableSlots: (chatbotId: string, date: string) =>
|
||||||
|
api.get<{ date: string; slots: TimeSlot[] }>(`/chatbots/${chatbotId}/available-slots`, { params: { date } }).then(r => r.data),
|
||||||
|
|
||||||
|
book: (chatbotId: string, data: {
|
||||||
|
customer_name: string
|
||||||
|
customer_contact: string
|
||||||
|
service?: string
|
||||||
|
slot_start: string
|
||||||
|
notes?: string
|
||||||
|
conversation_id?: string
|
||||||
|
}) =>
|
||||||
|
api.post<Appointment>(`/chatbots/${chatbotId}/appointments`, data).then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Campaigns ────────────────────────────────────────────────────────────────
|
||||||
|
export const campaignsAPI = {
|
||||||
|
list: (params?: { chatbot_id?: string; page?: number }) =>
|
||||||
|
api.get<Campaign[]>('/campaigns', { params }).then(r => r.data),
|
||||||
|
|
||||||
|
create: (data: { chatbot_id: string; title: string; message: string }) =>
|
||||||
|
api.post<Campaign>('/campaigns', data).then(r => r.data),
|
||||||
|
|
||||||
|
send: (id: string) =>
|
||||||
|
api.post<Campaign>(`/campaigns/${id}/send`).then(r => r.data),
|
||||||
|
|
||||||
|
delete: (id: string) =>
|
||||||
|
api.delete(`/campaigns/${id}`).then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Admin ────────────────────────────────────────────────────────────────────
|
||||||
|
export const adminAPI = {
|
||||||
|
stats: () =>
|
||||||
|
api.get<AdminStats>('/admin/stats').then(r => r.data),
|
||||||
|
|
||||||
|
users: (params?: { search?: string; page?: number; limit?: number }) =>
|
||||||
|
api.get<{ users: AdminUser[]; total: number }>('/admin/users', { params }).then(r => r.data),
|
||||||
|
|
||||||
|
user: (id: string) =>
|
||||||
|
api.get<AdminUser>(`/admin/users/${id}`).then(r => r.data),
|
||||||
|
|
||||||
|
changePlan: (id: string, plan: string, reason?: string) =>
|
||||||
|
api.patch(`/admin/users/${id}/plan`, { plan, reason }).then(r => r.data),
|
||||||
|
|
||||||
|
suspendUser: (id: string, reason?: string) =>
|
||||||
|
api.post(`/admin/users/${id}/suspend`, { reason }).then(r => r.data),
|
||||||
|
|
||||||
|
unsuspendUser: (id: string) =>
|
||||||
|
api.post(`/admin/users/${id}/unsuspend`).then(r => r.data),
|
||||||
|
|
||||||
|
deleteUser: (id: string) =>
|
||||||
|
api.delete(`/admin/users/${id}`).then(r => r.data),
|
||||||
|
|
||||||
|
chatbots: (params?: { search?: string; page?: number; limit?: number }) =>
|
||||||
|
api.get<{ chatbots: AdminChatbot[]; total: number }>('/admin/chatbots', { params }).then(r => r.data),
|
||||||
|
|
||||||
|
deleteChatbot: (id: string) =>
|
||||||
|
api.delete(`/admin/chatbots/${id}`).then(r => r.data),
|
||||||
|
|
||||||
|
conversations: (params?: { page?: number; limit?: number }) =>
|
||||||
|
api.get<{ conversations: AdminConversation[]; total: number }>('/admin/conversations', { params }).then(r => r.data),
|
||||||
|
|
||||||
|
health: () =>
|
||||||
|
api.get<AdminSystemHealth>('/admin/system/health').then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Channels ─────────────────────────────────────────────────────────────────
|
// ─── Channels ─────────────────────────────────────────────────────────────────
|
||||||
export const channelsAPI = {
|
export const channelsAPI = {
|
||||||
list: (chatbotId: string) =>
|
list: (chatbotId: string) =>
|
||||||
@@ -226,9 +319,6 @@ export const channelsAPI = {
|
|||||||
connectTelegram: (chatbotId: string, botToken: string) =>
|
connectTelegram: (chatbotId: string, botToken: string) =>
|
||||||
api.post('/channels/telegram', { chatbot_id: chatbotId, bot_token: botToken }).then(r => r.data),
|
api.post('/channels/telegram', { chatbot_id: chatbotId, bot_token: botToken }).then(r => r.data),
|
||||||
|
|
||||||
connectWhatsapp: (chatbotId: string, waKeyword?: string) =>
|
|
||||||
api.post('/channels/whatsapp', { chatbot_id: chatbotId, wa_keyword: waKeyword || null }).then(r => r.data),
|
|
||||||
|
|
||||||
disconnect: (connectionId: string) =>
|
disconnect: (connectionId: string) =>
|
||||||
api.delete(`/channels/${connectionId}`).then(r => r.data),
|
api.delete(`/channels/${connectionId}`).then(r => r.data),
|
||||||
}
|
}
|
||||||
32
src/store/themeStore.ts
Normal file
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
|
company_name?: string
|
||||||
plan: 'free' | 'starter' | 'business' | 'agency' | 'enterprise'
|
plan: 'free' | 'starter' | 'business' | 'agency' | 'enterprise'
|
||||||
created_at?: string
|
created_at?: string
|
||||||
|
is_admin?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
@@ -46,6 +47,7 @@ export interface Chatbot {
|
|||||||
handoff_message: string
|
handoff_message: string
|
||||||
handoff_email?: string
|
handoff_email?: string
|
||||||
handoff_keywords: string[]
|
handoff_keywords: string[]
|
||||||
|
booking_enabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatbotPublic {
|
export interface ChatbotPublic {
|
||||||
@@ -87,6 +89,7 @@ export interface ChatbotFormData {
|
|||||||
handoff_message: string
|
handoff_message: string
|
||||||
handoff_email: string
|
handoff_email: string
|
||||||
handoff_keywords: string[]
|
handoff_keywords: string[]
|
||||||
|
booking_enabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Document ─────────────────────────────────────────────────────────────────
|
// ─── Document ─────────────────────────────────────────────────────────────────
|
||||||
@@ -196,6 +199,8 @@ export interface ModelsResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Leads ────────────────────────────────────────────────────────────────────
|
// ─── Leads ────────────────────────────────────────────────────────────────────
|
||||||
|
export type LeadStatus = 'new' | 'contacted' | 'qualified' | 'closed' | 'lost'
|
||||||
|
|
||||||
export interface Lead {
|
export interface Lead {
|
||||||
id: string
|
id: string
|
||||||
chatbot_id: string
|
chatbot_id: string
|
||||||
@@ -204,6 +209,8 @@ export interface Lead {
|
|||||||
name?: string
|
name?: string
|
||||||
phone?: string
|
phone?: string
|
||||||
company?: string
|
company?: string
|
||||||
|
status: LeadStatus
|
||||||
|
notes?: string
|
||||||
created_at?: string
|
created_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +227,8 @@ export interface UrlSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Inbox ────────────────────────────────────────────────────────────────────
|
// ─── Inbox ────────────────────────────────────────────────────────────────────
|
||||||
|
export type ConversationStatus = 'open' | 'agent_handling' | 'resolved'
|
||||||
|
|
||||||
export interface InboxConversation {
|
export interface InboxConversation {
|
||||||
id: string
|
id: string
|
||||||
chatbot_id: string
|
chatbot_id: string
|
||||||
@@ -228,12 +237,14 @@ export interface InboxConversation {
|
|||||||
language: string
|
language: string
|
||||||
message_count: number
|
message_count: number
|
||||||
first_message?: string
|
first_message?: string
|
||||||
|
status: ConversationStatus
|
||||||
|
last_agent_reply_at?: string
|
||||||
created_at?: string
|
created_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InboxMessage {
|
export interface InboxMessage {
|
||||||
id: string
|
id: string
|
||||||
role: 'user' | 'assistant'
|
role: 'user' | 'assistant' | 'agent'
|
||||||
content: string
|
content: string
|
||||||
sources?: SourceDocument[]
|
sources?: SourceDocument[]
|
||||||
confidence_score?: number
|
confidence_score?: number
|
||||||
@@ -241,17 +252,107 @@ export interface InboxMessage {
|
|||||||
created_at?: string
|
created_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Appointments ─────────────────────────────────────────────────────────────
|
||||||
|
export interface BusinessHoursEntry {
|
||||||
|
day_of_week: number // 0=Mon, 6=Sun
|
||||||
|
is_open: boolean
|
||||||
|
open_time: string // HH:MM
|
||||||
|
close_time: string
|
||||||
|
slot_duration_minutes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeSlot {
|
||||||
|
slot_start: string
|
||||||
|
slot_end: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Appointment {
|
||||||
|
id: string
|
||||||
|
chatbot_id: string
|
||||||
|
conversation_id?: string
|
||||||
|
customer_name: string
|
||||||
|
customer_contact: string
|
||||||
|
service?: string
|
||||||
|
slot_start: string
|
||||||
|
slot_end: string
|
||||||
|
status: 'pending' | 'confirmed' | 'cancelled' | 'completed'
|
||||||
|
notes?: string
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Campaigns ────────────────────────────────────────────────────────────────
|
||||||
|
export interface Campaign {
|
||||||
|
id: string
|
||||||
|
chatbot_id: string
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
status: 'draft' | 'sending' | 'sent' | 'failed'
|
||||||
|
recipients_count: number
|
||||||
|
sent_count: number
|
||||||
|
created_at?: string
|
||||||
|
sent_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Channels ─────────────────────────────────────────────────────────────────
|
// ─── Channels ─────────────────────────────────────────────────────────────────
|
||||||
export interface ChannelConnection {
|
export interface ChannelConnection {
|
||||||
id: string
|
id: string
|
||||||
channel: 'telegram' | 'whatsapp'
|
channel: 'telegram'
|
||||||
bot_username?: string
|
bot_username?: string
|
||||||
wa_keyword?: string
|
|
||||||
wa_link?: string
|
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
created_at?: string
|
created_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Admin ────────────────────────────────────────────────────────────────────
|
||||||
|
export interface AdminStats {
|
||||||
|
total_users: number
|
||||||
|
total_chatbots: number
|
||||||
|
total_published_chatbots: number
|
||||||
|
total_conversations: number
|
||||||
|
total_messages: number
|
||||||
|
active_subscriptions: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminUser {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
company_name?: string
|
||||||
|
plan: string
|
||||||
|
is_admin: boolean
|
||||||
|
is_suspended: boolean
|
||||||
|
suspended_at?: string
|
||||||
|
chatbot_count: number
|
||||||
|
conversation_count: number
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminChatbot {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
owner_email: string
|
||||||
|
owner_id: string
|
||||||
|
is_published: boolean
|
||||||
|
document_count: number
|
||||||
|
conversation_count: number
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminConversation {
|
||||||
|
id: string
|
||||||
|
chatbot_id: string
|
||||||
|
chatbot_name: string
|
||||||
|
owner_email: string
|
||||||
|
message_count: number
|
||||||
|
language: string
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminSystemHealth {
|
||||||
|
db: string
|
||||||
|
qdrant: string
|
||||||
|
llm_providers: Record<string, boolean>
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Templates ────────────────────────────────────────────────────────────────
|
// ─── Templates ────────────────────────────────────────────────────────────────
|
||||||
export interface ChatbotTemplate {
|
export interface ChatbotTemplate {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export default {
|
|||||||
'./index.html',
|
'./index.html',
|
||||||
'./src/**/*.{js,ts,jsx,tsx}',
|
'./src/**/*.{js,ts,jsx,tsx}',
|
||||||
],
|
],
|
||||||
|
darkMode: 'class',
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
|
|||||||
Reference in New Issue
Block a user