ADDED ANALYTICS

This commit is contained in:
belviskhoremk
2026-02-23 17:24:27 +00:00
parent cec36ee298
commit f2a0fd1260
6 changed files with 597 additions and 173 deletions

View File

@@ -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>} />

View File

@@ -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 },
]
@@ -74,24 +75,18 @@ export const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children })
<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'}
{user?.email?.charAt(0).toUpperCase() || '?'}
</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'}
<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>
<Link to="/settings/billing" className="text-xs text-primary-600 hover:underline ml-auto">
Upgrade
</Link>
</div>
</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"
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
@@ -99,21 +94,25 @@ export const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children })
</div>
</aside>
{/* Main content */}
<div className="flex-1 flex flex-col min-w-0">
{/* Main */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* 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" />
<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 flex items-center justify-center">
<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">Contexta</span>
<span className="font-bold text-gray-900 text-sm">Contexta</span>
</div>
</header>
{/* Page content */}
<main className="flex-1 overflow-y-auto">
{children}
</main>

View File

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

View File

@@ -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">

View File

@@ -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)
}
return Promise.reject(error)
(err) => {
if (err.response?.status === 401) {
useAuthStore.getState().logout()
window.location.href = '/login'
}
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),
}