mirror of
http://88.130.71.182:3000/BlitTech/contexta_fe.git
synced 2026-06-12 23:23:22 +00:00
ADDED ANALYTICS
This commit is contained in:
@@ -6,7 +6,7 @@ import { PublicLayout } from '@/components/PublicLayout'
|
||||
import { Spinner } from '@/components/ui'
|
||||
import './App.css'
|
||||
|
||||
// IMP-02 FIX: Route code splitting with lazy imports
|
||||
// Route code splitting with lazy imports
|
||||
const LandingPage = lazy(() => import('@/pages/LandingPage').then(m => ({ default: m.LandingPage })))
|
||||
const LoginPage = lazy(() => import('@/pages/AuthPages').then(m => ({ default: m.LoginPage })))
|
||||
const SignupPage = lazy(() => import('@/pages/AuthPages').then(m => ({ default: m.SignupPage })))
|
||||
@@ -16,6 +16,7 @@ const MarketplacePage = lazy(() => import('@/pages/MarketplacePage').then(m => (
|
||||
const ChatbotDetailPage = lazy(() => import('@/pages/MarketplacePage').then(m => ({ default: m.ChatbotDetailPage })))
|
||||
const PricingPage = lazy(() => import('@/pages/PricingPage').then(m => ({ default: m.PricingPage })))
|
||||
const SettingsPage = lazy(() => import('@/pages/SettingsPage').then(m => ({ default: m.SettingsPage })))
|
||||
const AnalyticsPage = lazy(() => import('@/pages/AnalyticsPage').then(m => ({ default: m.AnalyticsPage })))
|
||||
|
||||
const PageLoader = () => (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
@@ -35,14 +36,11 @@ const PublicOnlyRoute: React.FC<{ children: React.ReactNode }> = ({ children })
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
// BUG-07/08 FIX: Smart wrapper that uses AppLayout for authenticated users
|
||||
// and PublicLayout for unauthenticated users, solving the "lost sidebar" issue
|
||||
const SmartPublicRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { isAuthenticated } = useAuthStore()
|
||||
if (isAuthenticated) {
|
||||
return <AppLayout>{children}</AppLayout>
|
||||
}
|
||||
// R-07 FIX: PublicLayout adds navigation header for unauthenticated users
|
||||
return <PublicLayout>{children}</PublicLayout>
|
||||
}
|
||||
|
||||
@@ -63,6 +61,7 @@ export const App: React.FC = () => (
|
||||
|
||||
{/* Protected */}
|
||||
<Route path="/dashboard" element={<PrivateRoute><DashboardPage /></PrivateRoute>} />
|
||||
<Route path="/analytics" element={<PrivateRoute><AnalyticsPage /></PrivateRoute>} />
|
||||
<Route path="/chatbots/new" element={<PrivateRoute><ChatbotBuilderPage /></PrivateRoute>} />
|
||||
<Route path="/chatbots/:id/edit" element={<PrivateRoute><ChatbotBuilderPage /></PrivateRoute>} />
|
||||
<Route path="/chatbots/:id/preview" element={<PrivateRoute><ChatbotBuilderPage /></PrivateRoute>} />
|
||||
|
||||
@@ -6,11 +6,12 @@ import { authAPI } from '@/services/api'
|
||||
import { getPlanColor } from '@/lib/utils'
|
||||
import {
|
||||
Bot, LayoutDashboard, ShoppingBag, Settings,
|
||||
LogOut, Menu, X, Sparkles, ChevronDown
|
||||
LogOut, Menu, X, Sparkles, ChevronDown, BarChart3
|
||||
} from 'lucide-react'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||
{ label: 'Analytics', href: '/analytics', icon: BarChart3 },
|
||||
{ label: 'Marketplace', href: '/marketplace', icon: ShoppingBag },
|
||||
{ label: 'Settings', href: '/settings', icon: Settings },
|
||||
]
|
||||
@@ -28,96 +29,94 @@ export const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-50 overflow-hidden">
|
||||
{/* Mobile backdrop */}
|
||||
{sidebarOpen && (
|
||||
<div className="fixed inset-0 z-20 bg-black/40 lg:hidden" onClick={() => setSidebarOpen(false)} />
|
||||
)}
|
||||
<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 */}
|
||||
<aside className={cn(
|
||||
'fixed lg:relative z-30 flex flex-col w-64 h-full bg-white border-r border-gray-200 transition-transform duration-200',
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||
)}>
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-2 px-6 py-5 border-b border-gray-100">
|
||||
<div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
|
||||
<Sparkles className="w-4 h-4 text-white" />
|
||||
{/* Sidebar */}
|
||||
<aside className={cn(
|
||||
'fixed lg:relative z-30 flex flex-col w-64 h-full bg-white border-r border-gray-200 transition-transform duration-200',
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||
)}>
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-2 px-6 py-5 border-b border-gray-100">
|
||||
<div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
|
||||
<Sparkles className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span className="font-bold text-gray-900 text-lg">Contexta</span>
|
||||
</div>
|
||||
<span className="font-bold text-gray-900 text-lg">Contexta</span>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
|
||||
{NAV_ITEMS.map(({ label, href, icon: Icon }) => {
|
||||
const active = location.pathname.startsWith(href)
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
to={href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors',
|
||||
active
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
|
||||
)}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User profile */}
|
||||
<div className="px-4 py-4 border-t border-gray-100">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-9 h-9 rounded-full bg-primary-100 flex items-center justify-center text-primary-700 font-semibold text-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>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
|
||||
{NAV_ITEMS.map(({ label, href, icon: Icon }) => {
|
||||
const active = location.pathname.startsWith(href)
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
to={href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors',
|
||||
active
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
|
||||
)}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User profile */}
|
||||
<div className="px-4 py-4 border-t border-gray-100">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-9 h-9 rounded-full bg-primary-100 flex items-center justify-center text-primary-700 font-semibold text-sm">
|
||||
{user?.email?.[0]?.toUpperCase() || 'U'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">{user?.company_name || 'My Company'}</p>
|
||||
<p className="text-xs text-gray-500 truncate">{user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('px-2 py-0.5 text-xs font-medium rounded-full capitalize', getPlanColor(user?.plan || 'free'))}>
|
||||
{user?.plan || 'free'}
|
||||
</span>
|
||||
<Link to="/settings/billing" className="text-xs text-primary-600 hover:underline ml-auto">
|
||||
Upgrade
|
||||
</Link>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 mt-3 w-full px-3 py-2 text-sm text-gray-500 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Mobile header */}
|
||||
<header className="flex items-center gap-4 px-4 py-3 bg-white border-b border-gray-200 lg:hidden">
|
||||
<button onClick={() => setSidebarOpen(true)} className="p-1 rounded-lg hover:bg-gray-100">
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 bg-primary-600 rounded flex items-center justify-center">
|
||||
<Sparkles className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
<span className="font-bold text-gray-900">Contexta</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -60,19 +60,64 @@ export function useDebounce<T>(value: T, delay: number = 300): T {
|
||||
|
||||
return debouncedValue
|
||||
}
|
||||
|
||||
// ─── REMOVED: AVAILABLE_MODELS ────────────────────────────────────────────────
|
||||
// Models are now loaded dynamically from the backend via GET /api/v1/models/available
|
||||
// This ensures the frontend always reflects the backend's model configuration
|
||||
// and changes only need to be made in one place (backend MODEL_CATALOG + PLAN_LIMITS).
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// CATEGORIES & INDUSTRIES — Expanded for all business types
|
||||
// Synced with backend: app/routers/marketplace.py
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export const CATEGORIES = [
|
||||
'Customer Support', 'Sales', 'FAQ', 'E-commerce',
|
||||
'Healthcare', 'Finance', 'Education', 'HR', 'Legal', 'Other'
|
||||
'Customer Support',
|
||||
'Sales Assistant',
|
||||
'FAQ & Knowledge Base',
|
||||
'Appointment Booking',
|
||||
'Order & Delivery Tracking',
|
||||
'Product Recommendations',
|
||||
'Lead Generation',
|
||||
'Onboarding & Training',
|
||||
'Feedback & Surveys',
|
||||
'Personal Assistant',
|
||||
'Consultation',
|
||||
'Other',
|
||||
]
|
||||
|
||||
export const INDUSTRIES = [
|
||||
'Technology', 'E-commerce', 'Healthcare', 'Finance',
|
||||
'Education', 'Legal', 'Real Estate', 'Hospitality', 'Retail', 'Other'
|
||||
// Small businesses / Local services
|
||||
'Restaurant & Food',
|
||||
'Beauty & Barbershop',
|
||||
'Retail & Shopping',
|
||||
'Phone & Electronics',
|
||||
'Automotive & Repair',
|
||||
'Fitness & Wellness',
|
||||
'Cleaning & Home Services',
|
||||
'Photography & Events',
|
||||
// Professional services
|
||||
'Healthcare & Medical',
|
||||
'Legal & Law',
|
||||
'Finance & Insurance',
|
||||
'Real Estate',
|
||||
'Accounting & Tax',
|
||||
// Tech & Digital
|
||||
'Technology & SaaS',
|
||||
'E-commerce',
|
||||
'Agency & Marketing',
|
||||
// Education & Non-profit
|
||||
'Education & Training',
|
||||
'Non-profit & NGO',
|
||||
// Large scale
|
||||
'Hospitality & Hotels',
|
||||
'Travel & Tourism',
|
||||
'Manufacturing',
|
||||
'Logistics & Transport',
|
||||
'Agriculture',
|
||||
'Government',
|
||||
// Personal
|
||||
'Personal Brand',
|
||||
'Freelancer & Consultant',
|
||||
'Other',
|
||||
]
|
||||
|
||||
export const LANGUAGES = [
|
||||
|
||||
379
src/pages/AnalyticsPage.tsx
Normal file
379
src/pages/AnalyticsPage.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { analyticsAPI } from '@/services/api'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { Card, Spinner, Button, Badge } from '@/components/ui'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import {
|
||||
BarChart3, Users, MessageSquare, Star, TrendingUp,
|
||||
Clock, Globe, ArrowRight, Lock, Bot, Calendar,
|
||||
ArrowUp, ArrowDown, Minus, ChevronDown, ChevronUp
|
||||
} from 'lucide-react'
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// ANALYTICS PAGE — Available for Starter and Pro plans
|
||||
// Shows: conversations, unique users, ratings, top queries, daily trends
|
||||
// Does NOT show: LLM costs, token usage costs, API spending
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
interface DailyConversation {
|
||||
date: string
|
||||
count: number
|
||||
}
|
||||
|
||||
interface TopQuery {
|
||||
query: string
|
||||
count: number
|
||||
}
|
||||
|
||||
interface ChatbotAnalytics {
|
||||
chatbot_id: string
|
||||
chatbot_name: string
|
||||
total_conversations: number
|
||||
unique_sessions: number
|
||||
total_messages: number
|
||||
average_messages_per_conversation: number
|
||||
average_rating: number | null
|
||||
total_ratings: number
|
||||
conversations_today: number
|
||||
conversations_this_week: number
|
||||
conversations_this_month: number
|
||||
daily_conversations: DailyConversation[]
|
||||
top_queries: TopQuery[]
|
||||
languages_used: Record<string, number>
|
||||
peak_hour: number | null
|
||||
}
|
||||
|
||||
interface OverviewData {
|
||||
total_chatbots: number
|
||||
published_chatbots: number
|
||||
total_conversations: number
|
||||
total_messages: number
|
||||
unique_sessions: number
|
||||
conversations_this_month: number
|
||||
average_rating: number | null
|
||||
chatbots: ChatbotAnalytics[]
|
||||
plan: string
|
||||
conversations_limit: number
|
||||
conversations_used: number
|
||||
}
|
||||
|
||||
// ─── Mini bar chart component ─────────────────────────────────────────────────
|
||||
const MiniBarChart: React.FC<{ data: DailyConversation[] }> = ({ data }) => {
|
||||
if (!data.length) {
|
||||
return <div className="text-xs text-gray-400 italic py-4">No data yet</div>
|
||||
}
|
||||
|
||||
const max = Math.max(...data.map(d => d.count), 1)
|
||||
|
||||
// Fill last 30 days
|
||||
const today = new Date()
|
||||
const days: { date: string; count: number }[] = []
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const d = new Date(today)
|
||||
d.setDate(d.getDate() - i)
|
||||
const dateStr = d.toISOString().slice(0, 10)
|
||||
const found = data.find(x => x.date === dateStr)
|
||||
days.push({ date: dateStr, count: found?.count || 0 })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-[2px] h-16">
|
||||
{days.map((d, i) => (
|
||||
<div
|
||||
key={d.date}
|
||||
className="flex-1 min-w-[3px] rounded-t-sm bg-primary-400 hover:bg-primary-600 transition-colors cursor-default group relative"
|
||||
style={{ height: `${Math.max((d.count / max) * 100, d.count > 0 ? 8 : 2)}%` }}
|
||||
title={`${d.date}: ${d.count} conversations`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Stat card ────────────────────────────────────────────────────────────────
|
||||
const StatCard: React.FC<{
|
||||
label: string
|
||||
value: string | number
|
||||
icon: React.ReactNode
|
||||
subtitle?: string
|
||||
color?: string
|
||||
}> = ({ label, value, icon, subtitle, color = 'primary' }) => (
|
||||
<Card className="p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">{label}</span>
|
||||
<div className={`w-8 h-8 rounded-lg bg-${color}-50 flex items-center justify-center text-${color}-600`}>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{typeof value === 'number' ? value.toLocaleString() : value}</div>
|
||||
{subtitle && <p className="text-xs text-gray-500 mt-1">{subtitle}</p>}
|
||||
</Card>
|
||||
)
|
||||
|
||||
// ─── Usage bar ────────────────────────────────────────────────────────────────
|
||||
const UsageBar: React.FC<{ used: number; limit: number }> = ({ used, limit }) => {
|
||||
const pct = limit > 0 ? Math.min((used / limit) * 100, 100) : 0
|
||||
const isHigh = pct > 80
|
||||
const isFull = pct >= 100
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs mb-1.5">
|
||||
<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'}>
|
||||
{used.toLocaleString()} / {limit.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${
|
||||
isFull ? 'bg-red-500' : isHigh ? 'bg-amber-400' : 'bg-primary-500'
|
||||
}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Chatbot detail row ───────────────────────────────────────────────────────
|
||||
const ChatbotRow: React.FC<{ chatbot: ChatbotAnalytics }> = ({ chatbot }) => {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="w-full p-4 flex items-center justify-between hover:bg-gray-50 transition-colors text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-primary-50 flex items-center justify-center">
|
||||
<Bot className="w-4 h-4 text-primary-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 text-sm">{chatbot.chatbot_name}</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
{chatbot.total_conversations} conversations · {chatbot.unique_sessions} users
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{chatbot.average_rating && (
|
||||
<div className="flex items-center gap-1 text-xs text-amber-600">
|
||||
<Star className="w-3 h-3 fill-amber-400" />
|
||||
{chatbot.average_rating.toFixed(1)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-500">
|
||||
{chatbot.conversations_today} today
|
||||
</div>
|
||||
{expanded ? <ChevronUp className="w-4 h-4 text-gray-400" /> : <ChevronDown className="w-4 h-4 text-gray-400" />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-gray-100 p-4 space-y-4 bg-gray-50/50">
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div className="bg-white rounded-lg p-3 border border-gray-100">
|
||||
<p className="text-xs text-gray-500">Today</p>
|
||||
<p className="text-lg font-bold text-gray-900">{chatbot.conversations_today}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-3 border border-gray-100">
|
||||
<p className="text-xs text-gray-500">This week</p>
|
||||
<p className="text-lg font-bold text-gray-900">{chatbot.conversations_this_week}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-3 border border-gray-100">
|
||||
<p className="text-xs text-gray-500">This month</p>
|
||||
<p className="text-lg font-bold text-gray-900">{chatbot.conversations_this_month}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-3 border border-gray-100">
|
||||
<p className="text-xs text-gray-500">Avg messages/convo</p>
|
||||
<p className="text-lg font-bold text-gray-900">{chatbot.average_messages_per_conversation}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Daily chart */}
|
||||
<div className="bg-white rounded-lg p-3 border border-gray-100">
|
||||
<p className="text-xs font-medium text-gray-500 mb-2">Last 30 days</p>
|
||||
<MiniBarChart data={chatbot.daily_conversations} />
|
||||
</div>
|
||||
|
||||
{/* Top queries & Languages side by side */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{chatbot.top_queries.length > 0 && (
|
||||
<div className="bg-white rounded-lg p-3 border border-gray-100">
|
||||
<p className="text-xs font-medium text-gray-500 mb-2">Top questions</p>
|
||||
<ul className="space-y-1.5">
|
||||
{chatbot.top_queries.slice(0, 5).map((q, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-xs">
|
||||
<span className="text-gray-400 font-mono">{i + 1}.</span>
|
||||
<span className="text-gray-700 flex-1 truncate">{q.query}</span>
|
||||
<span className="text-gray-400">{q.count}×</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.keys(chatbot.languages_used).length > 0 && (
|
||||
<div className="bg-white rounded-lg p-3 border border-gray-100">
|
||||
<p className="text-xs font-medium text-gray-500 mb-2">Languages</p>
|
||||
<div className="space-y-1.5">
|
||||
{Object.entries(chatbot.languages_used)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 5)
|
||||
.map(([lang, count]) => (
|
||||
<div key={lang} className="flex items-center gap-2 text-xs">
|
||||
<Globe className="w-3 h-3 text-gray-400" />
|
||||
<span className="text-gray-700 uppercase">{lang}</span>
|
||||
<span className="text-gray-400 ml-auto">{count} convos</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{chatbot.peak_hour !== null && (
|
||||
<div className="text-xs text-gray-500 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
Peak hour: {chatbot.peak_hour}:00 - {chatbot.peak_hour + 1}:00
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// MAIN ANALYTICS PAGE
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export const AnalyticsPage: React.FC = () => {
|
||||
const { user } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { data, isLoading, error } = useQuery<OverviewData>({
|
||||
queryKey: ['analytics-overview'],
|
||||
queryFn: analyticsAPI.overview,
|
||||
staleTime: 60_000, // 1 min cache
|
||||
retry: false,
|
||||
})
|
||||
|
||||
// Handle plan gate (402 response)
|
||||
if (error && (error as any)?.response?.status === 402) {
|
||||
return (
|
||||
<div className="p-6 max-w-2xl mx-auto">
|
||||
<Card className="p-8 text-center">
|
||||
<div className="w-14 h-14 rounded-2xl bg-primary-50 flex items-center justify-center mx-auto mb-4">
|
||||
<Lock className="w-6 h-6 text-primary-600" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-2">Analytics Dashboard</h2>
|
||||
<p className="text-gray-500 text-sm mb-6">
|
||||
Unlock analytics to see how your chatbots are performing — conversations, user engagement, top questions, and more.
|
||||
</p>
|
||||
<Button onClick={() => navigate('/pricing')}>
|
||||
Upgrade to Starter — $3/mo
|
||||
</Button>
|
||||
<p className="text-xs text-gray-400 mt-3">Available on Starter and Pro plans</p>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<Spinner className="text-primary-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<Card className="p-8 text-center">
|
||||
<BarChart3 className="w-8 h-8 text-gray-400 mx-auto mb-3" />
|
||||
<p className="text-gray-600">Unable to load analytics. Please try again.</p>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Analytics</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
Track how your chatbots are performing
|
||||
</p>
|
||||
</div>
|
||||
<Badge className="text-xs capitalize">{data.plan} plan</Badge>
|
||||
</div>
|
||||
|
||||
{/* Usage bar */}
|
||||
<Card className="p-4">
|
||||
<UsageBar used={data.conversations_used} limit={data.conversations_limit} />
|
||||
</Card>
|
||||
|
||||
{/* Overview stat cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="Conversations"
|
||||
value={data.total_conversations}
|
||||
icon={<MessageSquare className="w-4 h-4" />}
|
||||
subtitle={`${data.conversations_this_month} this month`}
|
||||
/>
|
||||
<StatCard
|
||||
label="Unique users"
|
||||
value={data.unique_sessions}
|
||||
icon={<Users className="w-4 h-4" />}
|
||||
subtitle="Across all chatbots"
|
||||
/>
|
||||
<StatCard
|
||||
label="Messages"
|
||||
value={data.total_messages}
|
||||
icon={<BarChart3 className="w-4 h-4" />}
|
||||
subtitle="Total messages exchanged"
|
||||
/>
|
||||
<StatCard
|
||||
label="Avg rating"
|
||||
value={data.average_rating ? data.average_rating.toFixed(1) : '—'}
|
||||
icon={<Star className="w-4 h-4" />}
|
||||
subtitle={data.average_rating ? 'Across rated chatbots' : 'No ratings yet'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chatbot breakdown header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
Your chatbots ({data.total_chatbots})
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500">{data.published_chatbots} published</p>
|
||||
</div>
|
||||
|
||||
{/* Per-chatbot expandable rows */}
|
||||
{data.chatbots.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<Bot className="w-8 h-8 text-gray-400 mx-auto mb-3" />
|
||||
<p className="text-sm text-gray-600 mb-4">No chatbots yet. Create your first chatbot to see analytics.</p>
|
||||
<Button size="sm" onClick={() => navigate('/chatbots/new')}>
|
||||
Create chatbot
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{data.chatbots.map(cb => (
|
||||
<ChatbotRow key={cb.chatbot_id} chatbot={cb} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -11,7 +11,7 @@ const PLANS = [
|
||||
id: 'free',
|
||||
name: 'Free',
|
||||
price: 0,
|
||||
description: 'Perfect for testing and development',
|
||||
description: 'Build and test chatbots, no credit card needed',
|
||||
icon: '🆓',
|
||||
color: 'gray',
|
||||
features: [
|
||||
@@ -19,50 +19,54 @@ const PLANS = [
|
||||
{ text: 'Upload PDF, DOCX, CSV, XLSX', included: true },
|
||||
{ text: 'Unlimited preview testing', included: true },
|
||||
{ text: 'Shareable preview links', included: true },
|
||||
{ text: '50 preview conversations/month', included: true },
|
||||
{ text: '3 documents per chatbot', included: true },
|
||||
{ text: 'Llama 3.3 70B model', included: true },
|
||||
{ text: 'Publish to marketplace', included: false },
|
||||
{ text: 'Premium AI models', included: false },
|
||||
{ text: 'Code export', included: false },
|
||||
{ text: 'Analytics dashboard', included: false },
|
||||
{ text: 'Code export', included: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'starter',
|
||||
name: 'Starter',
|
||||
price: 39,
|
||||
description: 'For small businesses launching their first chatbot',
|
||||
price: 3,
|
||||
description: 'Go live with your first chatbot',
|
||||
icon: '🚀',
|
||||
color: 'blue',
|
||||
badge: 'Popular',
|
||||
badge: 'Most Popular',
|
||||
features: [
|
||||
{ text: 'Everything in Free', included: true },
|
||||
{ text: 'Publish 1 chatbot to marketplace', included: true },
|
||||
{ text: 'Fireworks AI models (Llama, Mixtral)', included: true },
|
||||
{ text: '5,000 conversations/month', included: true },
|
||||
{ text: '500 conversations/month', included: true },
|
||||
{ text: '10 documents per chatbot', included: true },
|
||||
{ text: '4 Fireworks AI models (Qwen3, DeepSeek, Kimi, Llama)', included: true },
|
||||
{ text: 'Analytics dashboard', included: true },
|
||||
{ text: 'Custom branding', included: true },
|
||||
{ text: 'Email support', included: true },
|
||||
{ text: 'Premium AI models (GPT-4, Claude)', included: false },
|
||||
{ text: 'Premium models (GPT-4o, Claude, Gemini)', included: false },
|
||||
{ text: 'Code export', included: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pro',
|
||||
name: 'Pro',
|
||||
price: 119,
|
||||
description: 'For growing businesses with multiple products',
|
||||
price: 20,
|
||||
description: 'For growing businesses with multiple chatbots',
|
||||
icon: '⚡',
|
||||
color: 'purple',
|
||||
highlighted: true,
|
||||
features: [
|
||||
{ text: 'Everything in Starter', included: true },
|
||||
{ text: 'Build & publish 3 chatbots', included: true },
|
||||
{ text: 'GPT-4o, Claude 3.5, Gemini 1.5', included: true },
|
||||
{ text: '20,000 conversations/month', included: true },
|
||||
{ text: 'Build & publish up to 5 chatbots', included: true },
|
||||
{ text: '2,000 conversations/month', included: true },
|
||||
{ text: '50 documents per chatbot', included: true },
|
||||
{ text: 'GPT-4o, GPT-4o Mini', included: true },
|
||||
{ text: 'Claude Haiku 4.5', included: true },
|
||||
{ text: 'Gemini 2.5 Flash, Lite & Pro', included: true },
|
||||
{ text: 'Code export (FastAPI + React widget)', included: true },
|
||||
{ text: 'Advanced analytics', included: true },
|
||||
{ text: 'Remove "Powered by" badge', included: true },
|
||||
{ text: 'Priority support', included: true },
|
||||
{ text: 'Custom domain', included: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -91,11 +95,10 @@ export const PricingPage: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const [loading, setLoading] = useState<string | null>(null)
|
||||
|
||||
// BUG-10 FIX: Fetch current subscription to show "Current Plan" badge
|
||||
const { data: subscription } = useQuery({
|
||||
queryKey: ['subscription'],
|
||||
queryFn: billingAPI.getSubscription,
|
||||
enabled: !!user, // Only fetch if logged in
|
||||
enabled: !!user,
|
||||
})
|
||||
|
||||
const currentPlan = subscription?.plan || user?.plan || 'free'
|
||||
@@ -110,7 +113,6 @@ export const PricingPage: React.FC = () => {
|
||||
navigate('/dashboard')
|
||||
return
|
||||
}
|
||||
// BUG-10 FIX: Don't allow re-subscribing to current plan
|
||||
if (planId === currentPlan) return
|
||||
|
||||
setLoading(planId)
|
||||
@@ -128,7 +130,6 @@ export const PricingPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to determine CTA text
|
||||
const getCtaText = (planId: string): string => {
|
||||
if (!user) return planId === 'enterprise' ? 'Contact Sales' : 'Get Started'
|
||||
if (planId === currentPlan) return 'Current Plan'
|
||||
@@ -142,13 +143,12 @@ export const PricingPage: React.FC = () => {
|
||||
return (
|
||||
<div className="p-6 max-w-6xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-3">Simple, transparent pricing</h1>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-3">Simple, affordable pricing</h1>
|
||||
<p className="text-gray-500 max-w-xl mx-auto">
|
||||
Start free and build as many chatbots as you want. Upgrade when you're ready to publish and go live.
|
||||
Start free, go live for just $3/month. Built for individuals, small businesses, and enterprises alike.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* R-06 FIX: Better grid breakpoints - md:grid-cols-2 instead of jumping to 4 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6">
|
||||
{PLANS.map((plan) => (
|
||||
<div
|
||||
@@ -161,7 +161,6 @@ export const PricingPage: React.FC = () => {
|
||||
: 'border-gray-200 bg-white'
|
||||
}`}
|
||||
>
|
||||
{/* BUG-10 FIX: Show "Current Plan" badge */}
|
||||
{isCurrentPlan(plan.id) && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||
<span className="bg-green-600 text-white text-xs font-semibold px-3 py-1 rounded-full">
|
||||
@@ -198,7 +197,8 @@ export const PricingPage: React.FC = () => {
|
||||
{plan.features.map((feature) => (
|
||||
<li key={feature.text} className="flex items-start gap-2 text-sm">
|
||||
<div className={`w-4 h-4 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5 ${
|
||||
feature.included ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-400'
|
||||
feature.included
|
||||
? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-400'
|
||||
}`}>
|
||||
<Check className="w-2.5 h-2.5" />
|
||||
</div>
|
||||
@@ -213,7 +213,6 @@ export const PricingPage: React.FC = () => {
|
||||
<Button
|
||||
onClick={() => handleSubscribe(plan.id)}
|
||||
loading={loading === plan.id}
|
||||
// BUG-10 FIX: Disable CTA for current plan
|
||||
disabled={isCurrentPlan(plan.id) || loading === plan.id}
|
||||
variant={plan.highlighted ? 'default' : 'outline'}
|
||||
className="w-full"
|
||||
@@ -231,7 +230,7 @@ export const PricingPage: React.FC = () => {
|
||||
{[
|
||||
{
|
||||
q: 'Can I use the free tier forever?',
|
||||
a: 'Yes! Build and test unlimited chatbots for free. Your chatbots will remain in preview mode but will be removed from the marketplace.'
|
||||
a: 'Yes! Build and test unlimited chatbots for free. Your chatbots will remain in preview mode until you subscribe.'
|
||||
},
|
||||
{
|
||||
q: 'What is code export?',
|
||||
@@ -239,11 +238,19 @@ export const PricingPage: React.FC = () => {
|
||||
},
|
||||
{
|
||||
q: 'Do I need my own API keys?',
|
||||
a: 'No! Your API keys are handled by Contexta. However, if you export the code, you\'ll need your own API keys for self-hosted deployment.'
|
||||
a: 'No! API keys are handled by Contexta. If you export the code, you\'ll need your own keys for self-hosted deployment.'
|
||||
},
|
||||
{
|
||||
q: 'Can I cancel anytime?',
|
||||
a: 'Yes, you can cancel your subscription anytime. Your chatbots will revert to preview mode at the end of your billing period.'
|
||||
a: 'Yes, cancel anytime. Your chatbots will revert to preview mode 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 just $3/month — it gives you 1 published chatbot, 500 conversations, and analytics. Perfect for restaurants, barbershops, shops, and more.'
|
||||
},
|
||||
].map(({ q, a }) => (
|
||||
<div key={q} className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import axios from 'axios'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import type {
|
||||
AuthResponse, User, Chatbot, ChatbotFormData, Document,
|
||||
ChatResponse, Subscription, MarketplaceResponse, Analytics, ChatbotPublic,
|
||||
ModelsResponse,
|
||||
AuthResponse, Chatbot, ChatbotPublic, ChatResponse,
|
||||
Document, MarketplaceResponse, Subscription,
|
||||
ModelsResponse
|
||||
} from '@/types'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||
@@ -12,51 +13,36 @@ export const api = axios.create({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
// Request interceptor - attach token
|
||||
// ── Interceptor: attach auth token ───────────────────────────────────────────
|
||||
api.interceptors.request.use((config) => {
|
||||
// Read from Zustand persisted state (single source of truth)
|
||||
try {
|
||||
const stored = localStorage.getItem('contexta-auth')
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored)
|
||||
const token = parsed?.state?.token
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
const token = useAuthStore.getState().token
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
// Response interceptor - handle 401
|
||||
let isRedirecting = false
|
||||
// ── Interceptor: handle 401 (expired token) ──────────────────────────────────
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
(error) => {
|
||||
if (error.response?.status === 401 && !isRedirecting) {
|
||||
isRedirecting = true
|
||||
localStorage.removeItem('contexta-auth')
|
||||
window.location.replace('/login')
|
||||
setTimeout(() => { isRedirecting = false }, 2000)
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
useAuthStore.getState().logout()
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
return Promise.reject(err)
|
||||
},
|
||||
)
|
||||
|
||||
// ─── Auth ─────────────────────────────────────────────────────────────────────
|
||||
export const authAPI = {
|
||||
signup: (data: { email: string; password: string; full_name: string; company_name?: string }) =>
|
||||
signup: (data: { email: string; password: string; company_name: string }) =>
|
||||
api.post<AuthResponse>('/auth/signup', data).then(r => r.data),
|
||||
|
||||
login: (data: { email: string; password: string }) =>
|
||||
api.post<AuthResponse>('/auth/login', data).then(r => r.data),
|
||||
|
||||
logout: () =>
|
||||
api.post('/auth/logout').then(r => r.data),
|
||||
me: () => api.get('/auth/me').then(r => r.data),
|
||||
|
||||
me: () => api.get<User>('/auth/me').then(r => r.data),
|
||||
logout: () => api.post('/auth/logout').then(r => r.data),
|
||||
}
|
||||
|
||||
// ─── Chatbots ─────────────────────────────────────────────────────────────────
|
||||
@@ -65,20 +51,17 @@ export const chatbotsAPI = {
|
||||
|
||||
get: (id: string) => api.get<Chatbot>(`/chatbots/${id}`).then(r => r.data),
|
||||
|
||||
create: (data: ChatbotFormData) =>
|
||||
create: (data: Partial<Chatbot>) =>
|
||||
api.post<Chatbot>('/chatbots', data).then(r => r.data),
|
||||
|
||||
update: (id: string, data: Partial<ChatbotFormData>) =>
|
||||
update: (id: string, data: Partial<Chatbot>) =>
|
||||
api.put<Chatbot>(`/chatbots/${id}`, data).then(r => r.data),
|
||||
|
||||
delete: (id: string) =>
|
||||
api.delete(`/chatbots/${id}`).then(r => r.data),
|
||||
delete: (id: string) => api.delete(`/chatbots/${id}`).then(r => r.data),
|
||||
|
||||
publish: (id: string) =>
|
||||
api.post<Chatbot>(`/chatbots/${id}/publish`).then(r => r.data),
|
||||
publish: (id: string) => api.post(`/chatbots/${id}/publish`).then(r => r.data),
|
||||
|
||||
unpublish: (id: string) =>
|
||||
api.post<Chatbot>(`/chatbots/${id}/unpublish`).then(r => r.data),
|
||||
unpublish: (id: string) => api.post(`/chatbots/${id}/unpublish`).then(r => r.data),
|
||||
|
||||
export: (id: string) =>
|
||||
api.post(`/chatbots/${id}/export`, {}, { responseType: 'blob' }).then(r => r.data),
|
||||
@@ -130,19 +113,31 @@ export const marketplaceAPI = {
|
||||
|
||||
// ─── Billing ──────────────────────────────────────────────────────────────────
|
||||
export const billingAPI = {
|
||||
checkout: (plan: string) =>
|
||||
api.post<{ url: string }>('/billing/checkout', { plan }).then(r => r.data),
|
||||
createCheckout: (plan: string, successUrl: string, cancelUrl: string) =>
|
||||
api.post<{ checkout_url: string; session_id: string }>('/billing/checkout', {
|
||||
plan,
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
}).then(r => r.data),
|
||||
|
||||
portal: () =>
|
||||
getPortalUrl: () =>
|
||||
api.post<{ url: string }>('/billing/portal').then(r => r.data),
|
||||
|
||||
subscription: () =>
|
||||
getSubscription: () =>
|
||||
api.get<Subscription>('/billing/subscription').then(r => r.data),
|
||||
}
|
||||
|
||||
// ─── Models (NEW - loaded from backend) ───────────────────────────────────────
|
||||
// ─── Models ───────────────────────────────────────────────────────────────────
|
||||
export const modelsAPI = {
|
||||
/** Fetch available models for the current user based on their plan */
|
||||
available: () =>
|
||||
api.get<ModelsResponse>('/models/available').then(r => r.data),
|
||||
}
|
||||
|
||||
// ─── Analytics (Starter+ only) ────────────────────────────────────────────────
|
||||
export const analyticsAPI = {
|
||||
overview: () =>
|
||||
api.get('/analytics/overview').then(r => r.data),
|
||||
|
||||
chatbot: (chatbotId: string) =>
|
||||
api.get(`/analytics/chatbot/${chatbotId}`).then(r => r.data),
|
||||
}
|
||||
Reference in New Issue
Block a user