- {plan.badge && (
-
+ const isCurrentPlan = (planId: string) => user && planId === currentPlan
+
+ return (
+
+
+
Simple, transparent pricing
+
+ Start free and build as many chatbots as you want. Upgrade when you're ready to publish and go live.
+
+
+
+ {/* R-06 FIX: Better grid breakpoints - md:grid-cols-2 instead of jumping to 4 */}
+
+ {PLANS.map((plan) => (
+
+ {/* BUG-10 FIX: Show "Current Plan" badge */}
+ {isCurrentPlan(plan.id) && (
+
+
+ Current Plan
+
+
+ )}
+ {plan.badge && !isCurrentPlan(plan.id) && (
+
{plan.badge}
-
- )}
-
-
-
{plan.icon}
-
{plan.name}
-
{plan.description}
-
- {plan.price !== null ? (
-
- ${plan.price}
- /month
-
- ) : (
-
Custom
+
)}
-
-
-
- {plan.features.map(({ text, included }) => (
-
-
- {included ?
:
– }
+
+
{plan.icon}
+
{plan.name}
+
{plan.description}
+
+ {plan.price !== null ? (
+
+ ${plan.price}
+ {plan.price > 0 && /month }
+
+ ) : (
+
Custom
+ )}
-
{text}
-
- ))}
-
+
-
handleSubscribe(plan.id)}
- disabled={user?.plan === plan.id}
- >
- {user?.plan === plan.id
- ? 'Current Plan'
- : plan.price === null
- ? 'Contact Sales'
- : plan.price === 0
- ? 'Get Started Free'
- : `Subscribe – $${plan.price}/mo`}
-
-
- ))}
-
+
+
+ {plan.features.map((feature) => (
+
+
+
+
+
+ {feature.text}
+
+
+ ))}
+
+
- {/* FAQ */}
-
-
Frequently Asked Questions
-
- {[
- {
- q: 'What is preview mode?',
- a: 'Preview mode lets you build and test your chatbot for free with unlimited conversations. Only you (and people you share the link with) can access it until you publish.'
- },
- {
- q: 'Can I cancel anytime?',
- a: 'Yes, you can cancel anytime. Your chatbots will remain in preview mode but will be removed from the marketplace.'
- },
- {
- q: 'What is code export?',
- a: 'Pro 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! Your API keys are handled by Contexta. However, if you export the code, you\'ll need your own API keys for self-hosted deployment.'
- },
- ].map(({ q, a }) => (
-
+
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"
+ >
+ {getCtaText(plan.id)}
+
+
))}
+
+ {/* FAQ */}
+
+
Frequently Asked Questions
+
+ {[
+ {
+ 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.'
+ },
+ {
+ q: 'What is code export?',
+ a: 'Pro 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! Your API keys are handled by Contexta. However, if you export the code, you\'ll need your own API 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.'
+ },
+ ].map(({ q, a }) => (
+
+ ))}
+
+
-
)
-}
+}
\ No newline at end of file
diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx
index c2dc0ea..258711b 100644
--- a/src/pages/SettingsPage.tsx
+++ b/src/pages/SettingsPage.tsx
@@ -1,6 +1,6 @@
-import React, { useState } from 'react'
-import { useQuery, useMutation } from '@tanstack/react-query'
-import { useNavigate, Link } from 'react-router-dom'
+import React, { useState, useEffect, useCallback } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { useNavigate, useLocation, Link } from 'react-router-dom'
import { billingAPI } from '@/services/api'
import { useAuthStore } from '@/store/authStore'
import { Button, Card, Input, Badge } from '@/components/ui'
@@ -8,48 +8,74 @@ import { getPlanColor, formatDate } from '@/lib/utils'
import { CreditCard, User, Shield, Download, ExternalLink } from 'lucide-react'
export const SettingsPage: React.FC = () => {
- const { user, updateUser } = useAuthStore()
+ // BUG-04 FIX: Removed unused 'updateUser' from destructuring
+ const { user } = useAuthStore()
const navigate = useNavigate()
- const [tab, setTab] = useState<'profile' | 'billing' | 'export'>('profile')
+ const location = useLocation()
+
+ // BUG-06 FIX: Sync tab with URL path on mount and when path changes
+ const getTabFromPath = (pathname: string): 'profile' | 'billing' => {
+ if (pathname === '/settings/billing') return 'billing'
+ return 'profile'
+ }
+
+ const [tab, setTab] = useState<'profile' | 'billing'>(getTabFromPath(location.pathname))
const [toast, setToast] = useState('')
+ // Keep tab in sync if URL changes externally
+ useEffect(() => {
+ setTab(getTabFromPath(location.pathname))
+ }, [location.pathname])
+
+ // Update URL when tab changes
+ const handleTabChange = useCallback((newTab: 'profile' | 'billing') => {
+ setTab(newTab)
+ const newPath = newTab === 'billing' ? '/settings/billing' : '/settings'
+ if (location.pathname !== newPath) {
+ navigate(newPath, { replace: true })
+ }
+ }, [navigate, location.pathname])
+
const showToast = (msg: string) => {
setToast(msg)
setTimeout(() => setToast(''), 3000)
}
return (
-
-
Settings
+
+
Settings
- {/* Tabs */}
-
- {[
- { id: 'profile', label: 'Profile', icon: User },
- { id: 'billing', label: 'Billing', icon: CreditCard },
- ].map(({ id, label, icon: Icon }) => (
- setTab(id as any)}
- className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
- tab === id ? 'bg-white shadow-sm text-gray-900' : 'text-gray-500 hover:text-gray-700'
- }`}
- >
-
- {label}
-
- ))}
-
-
- {tab === 'profile' &&
}
- {tab === 'billing' &&
}
-
- {toast && (
-
- {toast}
+ {/* Tabs */}
+
+ {[
+ { id: 'profile' as const, label: 'Profile', icon: User },
+ { id: 'billing' as const, label: 'Billing', icon: CreditCard },
+ ].map(({ id, label, icon: Icon }) => (
+ handleTabChange(id)}
+ className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
+ tab === id
+ ? 'bg-white shadow-sm text-gray-900'
+ : 'text-gray-500 hover:text-gray-700'
+ }`}
+ >
+
+ {label}
+
+ ))}
- )}
-
+
+ {tab === 'profile' &&
}
+ {tab === 'billing' &&
}
+
+ {toast && (
+
+ {toast}
+ setToast('')} className="ml-3 opacity-60 hover:opacity-100">×
+
+ )}
+
)
}
@@ -57,22 +83,22 @@ const ProfileSettings: React.FC<{ onToast: (msg: string) => void }> = ({ onToast
const { user } = useAuthStore()
return (
-
- Profile Information
-
-
-
-
Plan
-
-
+
)
}
@@ -101,59 +127,59 @@ const BillingSettings: React.FC<{ onToast: (msg: string) => void }> = ({ onToast
const isPaid = subscription?.plan && subscription.plan !== 'free'
return (
-
-
- Current Plan
-
-
+
+
+ Current Plan
+
+
{subscription?.plan || 'free'}
-
- Status:
+
+ Status:
{subscription?.status || 'active'}
-
+
+
+ {isPaid && subscription?.current_period_end && (
+
+
Renews on
+
{formatDate(subscription.current_period_end)}
+
+ )}
- {isPaid && subscription?.current_period_end && (
-
-
Renews on
-
{formatDate(subscription.current_period_end)}
-
- )}
-
-
- {!isPaid ? (
- navigate('/pricing')} className="flex-1">
- Upgrade Plan
-
- ) : (
-
-
- Manage Billing
-
- )}
-
-
+
+ {!isPaid ? (
+ navigate('/pricing')} className="flex-1">
+ Upgrade Plan
+
+ ) : (
+
+
+ Manage Billing
+
+ )}
+
+
- {/* Plan features */}
-
- Plan Features
-
- {[
- { label: 'Chatbots created', value: 'Unlimited', always: true },
- { label: 'Chatbots published', value: subscription?.plan === 'pro' ? '3' : subscription?.plan === 'starter' ? '1' : '0 (upgrade to publish)', always: true },
- { label: 'Conversations/month', value: subscription?.plan === 'pro' ? '20,000' : subscription?.plan === 'starter' ? '5,000' : 'Unlimited (preview)', always: true },
- { label: 'Code export', value: subscription?.plan === 'pro' || subscription?.plan === 'enterprise' ? '✅ Included' : '❌ Pro+ only', always: true },
- ].map(({ label, value }) => (
-
- {label}
- {value}
-
- ))}
-
-
-
+ {/* Plan features */}
+
+ Plan Features
+
+ {[
+ { label: 'Chatbots created', value: 'Unlimited' },
+ { label: 'Chatbots published', value: subscription?.plan === 'pro' ? '3' : subscription?.plan === 'starter' ? '1' : '0 (upgrade to publish)' },
+ { label: 'Conversations/month', value: subscription?.plan === 'pro' ? '20,000' : subscription?.plan === 'starter' ? '5,000' : 'Unlimited (preview)' },
+ { label: 'Code export', value: subscription?.plan === 'pro' || subscription?.plan === 'enterprise' ? '✅ Included' : '❌ Pro+ only' },
+ ].map(({ label, value }) => (
+
+ {label}
+ {value}
+
+ ))}
+
+
+
)
-}
+}
\ No newline at end of file
diff --git a/src/services/api.ts b/src/services/api.ts
index f4acb5c..d46bb95 100644
--- a/src/services/api.ts
+++ b/src/services/api.ts
@@ -3,6 +3,7 @@ import type {
AuthResponse, User, Chatbot, ChatbotFormData, Document,
ChatResponse, Subscription, MarketplaceResponse, Analytics, ChatbotPublic
} from '@/types'
+import { useAuthStore } from '@/store/authStore'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
@@ -11,33 +12,46 @@ export const api = axios.create({
headers: { 'Content-Type': 'application/json' },
})
-// Request interceptor - attach token
+// BUG-01 FIX: Read token from Zustand store (single source of truth)
+// instead of manual localStorage.getItem('access_token')
api.interceptors.request.use((config) => {
- const token = localStorage.getItem('access_token')
+ const token = useAuthStore.getState().token
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
-// Response interceptor - handle 401
+// BUG-02 FIX: Prevent infinite redirect loop on 401
+// - Use a flag to prevent multiple redirects
+// - Call Zustand logout() to clear state
+// - Use window.location.replace() to avoid back-button loop
+// - Skip redirect if already on login page
+let isRedirecting = false
+
api.interceptors.response.use(
- (res) => res,
- (error) => {
- if (error.response?.status === 401) {
- localStorage.removeItem('access_token')
- localStorage.removeItem('user')
- window.location.href = '/login'
+ (res) => res,
+ (error) => {
+ if (error.response?.status === 401 && !isRedirecting) {
+ // Don't redirect if already on login/signup page
+ const currentPath = window.location.pathname
+ if (currentPath !== '/login' && currentPath !== '/signup') {
+ isRedirecting = true
+ useAuthStore.getState().logout()
+ window.location.replace('/login')
+ // Reset flag after a delay to allow the redirect to complete
+ setTimeout(() => { isRedirecting = false }, 2000)
+ }
+ }
+ return Promise.reject(error)
}
- return Promise.reject(error)
- }
)
// ─── Auth ──────────────────────────────────────────────────────────────────────
export const authAPI = {
signup: (data: { email: string; password: string; company_name: string }) =>
- api.post('/auth/signup', data).then(r => r.data),
+ api.post('/auth/signup', data).then(r => r.data),
login: (data: { email: string; password: string }) =>
- api.post('/auth/login', data).then(r => r.data),
+ api.post('/auth/login', data).then(r => r.data),
logout: () => api.post('/auth/logout').then(r => r.data),
@@ -51,87 +65,75 @@ export const chatbotsAPI = {
get: (id: string) => api.get(`/chatbots/${id}`).then(r => r.data),
create: (data: ChatbotFormData) =>
- api.post('/chatbots', data).then(r => r.data),
+ api.post('/chatbots', data).then(r => r.data),
update: (id: string, data: Partial) =>
- api.put(`/chatbots/${id}`, data).then(r => r.data),
+ api.put(`/chatbots/${id}`, data).then(r => r.data),
delete: (id: string) =>
- api.delete(`/chatbots/${id}`).then(r => r.data),
+ api.delete(`/chatbots/${id}`).then(r => r.data),
publish: (id: string) =>
- api.post(`/chatbots/${id}/publish`).then(r => r.data),
+ api.post(`/chatbots/${id}/publish`).then(r => r.data),
unpublish: (id: string) =>
- api.post(`/chatbots/${id}/unpublish`).then(r => r.data),
+ api.post(`/chatbots/${id}/unpublish`).then(r => r.data),
export: (id: string) =>
- api.post(`/chatbots/${id}/export`, {}, { responseType: 'blob' }).then(r => r.data),
+ api.post(`/chatbots/${id}/export`, {}, { responseType: 'blob' }).then(r => r.data),
}
// ─── Documents ────────────────────────────────────────────────────────────────
export const documentsAPI = {
list: (chatbotId: string) =>
- api.get(`/chatbots/${chatbotId}/documents`).then(r => r.data),
+ api.get(`/chatbots/${chatbotId}/documents`).then(r => r.data),
- upload: (chatbotId: string, file: File, onProgress?: (p: number) => void) => {
- const form = new FormData()
- form.append('file', file)
- return api.post(
- `/chatbots/${chatbotId}/documents`,
- form,
- {
- headers: { 'Content-Type': 'multipart/form-data' },
- onUploadProgress: (e) => {
- if (onProgress && e.total) onProgress(Math.round((e.loaded / e.total) * 100))
- },
- }
- ).then(r => r.data)
+ upload: (chatbotId: string, file: File, onProgress?: (pct: number) => void) => {
+ const formData = new FormData()
+ formData.append('file', file)
+ return api.post(`/chatbots/${chatbotId}/documents`, formData, {
+ headers: { 'Content-Type': 'multipart/form-data' },
+ onUploadProgress: (e) => {
+ if (onProgress && e.total) onProgress(Math.round((e.loaded * 100) / e.total))
+ },
+ }).then(r => r.data)
},
delete: (chatbotId: string, docId: string) =>
- api.delete(`/chatbots/${chatbotId}/documents/${docId}`).then(r => r.data),
+ api.delete(`/chatbots/${chatbotId}/documents/${docId}`).then(r => r.data),
}
// ─── Chat ─────────────────────────────────────────────────────────────────────
export const chatAPI = {
- send: (chatbotId: string, data: { message: string; session_id?: string; language?: string }) =>
- api.post(`/chat/${chatbotId}`, data).then(r => r.data),
+ send: (chatbotId: string, data: { message: string; session_id: string; language?: string }) =>
+ api.post(`/chat/${chatbotId}`, data).then(r => r.data),
- getHistory: (chatbotId: string, sessionId: string) =>
- api.get(`/chat/${chatbotId}/history/${sessionId}`).then(r => r.data),
-
- getAnalytics: (chatbotId: string) =>
- api.get(`/analytics/${chatbotId}`).then(r => r.data),
+ history: (chatbotId: string, sessionId: string) =>
+ api.get(`/chat/${chatbotId}/history/${sessionId}`).then(r => r.data),
}
// ─── Marketplace ──────────────────────────────────────────────────────────────
export const marketplaceAPI = {
- list: (params?: {
- category?: string; industry?: string; language?: string;
- search?: string; page?: number; limit?: number
- }) => api.get('/marketplace/chatbots', { params }).then(r => r.data),
+ list: (params?: { search?: string; category?: string; industry?: string; page?: number; limit?: number }) =>
+ api.get('/marketplace/chatbots', { params }).then(r => r.data),
get: (id: string) =>
- api.get(`/marketplace/chatbots/${id}`).then(r => r.data),
+ api.get(`/marketplace/chatbots/${id}`).then(r => r.data),
- categories: () =>
- api.get<{ categories: string[]; industries: string[] }>('/marketplace/categories').then(r => r.data),
-
- rate: (id: string, rating: number, feedback?: string) =>
- api.post(`/marketplace/chatbots/${id}/rate`, { rating, feedback }).then(r => r.data),
+ rate: (chatbotId: string, data: { rating: number; comment?: string }) =>
+ api.post(`/marketplace/chatbots/${chatbotId}/rate`, data).then(r => r.data),
}
// ─── Billing ──────────────────────────────────────────────────────────────────
export const billingAPI = {
+ 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),
+
getSubscription: () =>
- api.get('/billing/subscription').then(r => r.data),
+ api.get('/billing/subscription').then(r => r.data),
- createCheckout: (plan: string, success_url: string, cancel_url: string) =>
- api.post<{ checkout_url: string; session_id: string }>(
- '/billing/checkout', { plan, success_url, cancel_url }
- ).then(r => r.data),
-
- createPortal: (return_url: string) =>
- api.post<{ url: string }>('/billing/portal', { return_url }).then(r => r.data),
-}
+ createPortal: (returnUrl: string) =>
+ api.post<{ url: string }>('/billing/portal', { return_url: returnUrl }).then(r => r.data),
+}
\ No newline at end of file
diff --git a/src/store/authStore.ts b/src/store/authStore.ts
index 1baa181..715756b 100644
--- a/src/store/authStore.ts
+++ b/src/store/authStore.ts
@@ -3,44 +3,44 @@ import { persist } from 'zustand/middleware'
import type { User } from '@/types'
interface AuthState {
- user: User | null
- token: string | null
- isAuthenticated: boolean
- setAuth: (user: User, token: string) => void
- logout: () => void
- updateUser: (user: Partial) => void
+ user: User | null
+ token: string | null
+ isAuthenticated: boolean
+ setAuth: (user: User, token: string) => void
+ logout: () => void
+ updateUser: (user: Partial) => void
}
export const useAuthStore = create()(
- persist(
- (set) => ({
- user: null,
- token: null,
- isAuthenticated: false,
+ persist(
+ (set) => ({
+ user: null,
+ token: null,
+ isAuthenticated: false,
- setAuth: (user, token) => {
- localStorage.setItem('access_token', token)
- set({ user, token, isAuthenticated: true })
- },
+ // BUG-01 FIX: Removed manual localStorage.setItem('access_token', token)
+ // Zustand persist middleware is the single source of truth.
+ // The API interceptor now reads from Zustand store directly.
+ setAuth: (user, token) => {
+ set({ user, token, isAuthenticated: true })
+ },
- logout: () => {
- localStorage.removeItem('access_token')
- localStorage.removeItem('user')
- set({ user: null, token: null, isAuthenticated: false })
- },
+ logout: () => {
+ set({ user: null, token: null, isAuthenticated: false })
+ },
- updateUser: (updates) =>
- set((state) => ({
- user: state.user ? { ...state.user, ...updates } : null,
- })),
- }),
- {
- name: 'contexta-auth',
- partialize: (state) => ({
- user: state.user,
- token: state.token,
- isAuthenticated: state.isAuthenticated,
- }),
- }
- )
-)
+ updateUser: (updates) =>
+ set((state) => ({
+ user: state.user ? { ...state.user, ...updates } : null,
+ })),
+ }),
+ {
+ name: 'contexta-auth',
+ partialize: (state) => ({
+ user: state.user,
+ token: state.token,
+ isAuthenticated: state.isAuthenticated,
+ }),
+ }
+ )
+)
\ No newline at end of file