Files
contexta_fe/src/pages/SettingsPage.tsx
2026-04-03 09:15:25 +00:00

306 lines
12 KiB
TypeScript

import React, { useState, useCallback } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate, useLocation, Link } from 'react-router-dom'
import { billingAPI, authAPI } from '@/services/api'
import { useAuthStore } from '@/store/authStore'
import { Button, Card, Input } from '@/components/ui'
import { useToast } from '@/contexts/ToastContext'
import { useThemeStore } from '@/store/themeStore'
import { getPlanColor, formatDate } from '@/lib/utils'
import { CreditCard, User, ExternalLink, AlertTriangle, Moon, Sun } from 'lucide-react'
export const SettingsPage: React.FC = () => {
const navigate = useNavigate()
const location = useLocation()
const { success: showToast, error: showError } = useToast()
const { isDark, toggle: toggleTheme } = useThemeStore()
const tab: 'profile' | 'billing' = location.pathname === '/settings/billing' ? 'billing' : 'profile'
const handleTabChange = useCallback((newTab: 'profile' | 'billing') => {
const newPath = newTab === 'billing' ? '/settings/billing' : '/settings'
if (location.pathname !== newPath) {
navigate(newPath, { replace: true })
}
}, [navigate, location.pathname])
return (
<div className="p-6 max-w-3xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
<button
onClick={toggleTheme}
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-200 hover:bg-gray-100 text-sm text-gray-600 transition-colors"
aria-label="Toggle dark mode"
>
{isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
{isDark ? 'Light mode' : 'Dark mode'}
</button>
</div>
{/* Tabs */}
<div className="flex gap-1 mb-6 bg-gray-100 p-1 rounded-xl w-fit">
{[
{ id: 'profile' as const, label: 'Profile', icon: User },
{ id: 'billing' as const, label: 'Billing', icon: CreditCard },
].map(({ id, label, icon: Icon }) => (
<button
key={id}
onClick={() => 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'
}`}
>
<Icon className="w-3.5 h-3.5" />
{label}
</button>
))}
</div>
{tab === 'profile' && <ProfileSettings onToast={showToast} onError={showError} />}
{tab === 'billing' && <BillingSettings onToast={showToast} onError={showError} />}
</div>
)
}
const ProfileSettings: React.FC<{ onToast: (msg: string) => void; onError: (msg: string) => void }> = ({ onToast, onError }) => {
const { user, setAuth, token, logout } = useAuthStore()
const navigate = useNavigate()
const [companyName, setCompanyName] = useState(user?.company_name || '')
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [saving, setSaving] = useState(false)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [deleteConfirm, setDeleteConfirm] = useState('')
const [deleting, setDeleting] = useState(false)
const handleSave = async () => {
setSaving(true)
try {
const payload: { company_name?: string; current_password?: string; new_password?: string } = {}
if (companyName !== user?.company_name) payload.company_name = companyName
if (newPassword) {
payload.current_password = currentPassword
payload.new_password = newPassword
}
if (Object.keys(payload).length === 0) {
onToast('No changes to save')
return
}
const updated = await authAPI.updateProfile(payload)
setAuth(updated, token || '')
setCurrentPassword('')
setNewPassword('')
onToast('Profile updated successfully')
} catch (err) {
const e = err as { response?: { data?: { detail?: string } } }
onError(e.response?.data?.detail || 'Failed to update profile')
} finally {
setSaving(false)
}
}
const handleDeleteAccount = async () => {
if (deleteConfirm !== 'DELETE') return
setDeleting(true)
try {
await authAPI.deleteAccount()
logout()
navigate('/')
} catch (err) {
const e = err as { response?: { data?: { detail?: string } } }
onError(e.response?.data?.detail || 'Failed to delete account')
setDeleting(false)
}
}
return (
<div className="space-y-4">
<Card className="p-6 space-y-4">
<h2 className="font-semibold text-gray-900">Profile Information</h2>
<Input label="Email" value={user?.email || ''} disabled hint="Email cannot be changed" />
<Input
label="Company Name"
value={companyName}
onChange={e => setCompanyName(e.target.value)}
placeholder="Your company name"
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Plan</label>
<div className="flex items-center gap-2">
<span className={`px-3 py-1 text-sm font-medium rounded-full capitalize ${getPlanColor(user?.plan || 'free')}`}>
{user?.plan || 'free'}
</span>
<Link to="/pricing" className="text-sm text-primary-600 hover:underline">
Manage plan
</Link>
</div>
</div>
</Card>
<Card className="p-6 space-y-4">
<h2 className="font-semibold text-gray-900">Change Password</h2>
<Input
label="Current Password"
type="password"
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
placeholder="Enter current password"
/>
<Input
label="New Password"
type="password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
placeholder="Min 8 characters"
hint="Leave blank to keep current password"
/>
</Card>
<Button onClick={handleSave} loading={saving}>
Save Changes
</Button>
{/* Danger Zone */}
<Card className="p-6 border-red-200 bg-red-50/30">
<h2 className="font-semibold text-red-800 mb-2 flex items-center gap-1.5">
<AlertTriangle className="w-4 h-4" />
Danger Zone
</h2>
<p className="text-sm text-red-700 mb-4">
Permanently delete your account, all chatbots, documents, and data. This cannot be undone.
</p>
<Button variant="outline" className="border-red-300 text-red-700 hover:bg-red-50" onClick={() => setShowDeleteModal(true)}>
Delete Account
</Button>
</Card>
{/* Delete Account Modal */}
{showDeleteModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl p-6 w-full max-w-md shadow-xl">
<h3 className="text-lg font-bold text-gray-900 mb-2">Delete Account</h3>
<p className="text-sm text-gray-500 mb-4">
This will permanently delete your account and all associated data including chatbots, documents, conversations, and leads.
<strong className="text-red-600"> This action cannot be undone.</strong>
</p>
<p className="text-sm text-gray-700 mb-2">Type <strong>DELETE</strong> to confirm:</p>
<Input
value={deleteConfirm}
onChange={e => setDeleteConfirm(e.target.value)}
placeholder="DELETE"
/>
<div className="flex gap-3 mt-4">
<Button variant="outline" className="flex-1" onClick={() => { setShowDeleteModal(false); setDeleteConfirm('') }}>
Cancel
</Button>
<Button
className="flex-1 bg-red-600 hover:bg-red-700"
disabled={deleteConfirm !== 'DELETE'}
loading={deleting}
onClick={handleDeleteAccount}
>
Delete Account
</Button>
</div>
</div>
</div>
)}
</div>
)
}
const BillingSettings: React.FC<{ onToast: (msg: string) => void; onError: (msg: string) => void }> = ({ onError }) => {
const navigate = useNavigate()
const [loading, setLoading] = useState(false)
const { data: subscription } = useQuery({
queryKey: ['subscription'],
queryFn: billingAPI.getSubscription,
})
const handlePortal = async () => {
setLoading(true)
try {
const { url } = await billingAPI.createPortal(window.location.href)
window.location.href = url
} catch (err) {
const e = err as { response?: { data?: { detail?: string } } }
onError(e.response?.data?.detail || 'Failed to open billing portal')
} finally {
setLoading(false)
}
}
const plan = subscription?.plan || 'free'
const isPaid = plan !== 'free'
const planFeatures: Record<string, { published: string; conversations: string; codeExport: string }> = {
free: { published: '1', conversations: '100/month', codeExport: '❌ Agency+ only' },
starter: { published: '1', conversations: '1,500/month', codeExport: '❌ Agency+ only' },
business: { published: '3', conversations: '5,000/month', codeExport: '❌ Agency+ only' },
agency: { published: 'Unlimited', conversations: '20,000/month', codeExport: '✅ Included' },
enterprise: { published: 'Unlimited', conversations: 'Unlimited', codeExport: '✅ Included' },
}
const features = planFeatures[plan] || planFeatures.free
return (
<div className="space-y-4">
<Card className="p-6">
<h2 className="font-semibold text-gray-900 mb-4">Current Plan</h2>
<div className="flex items-center justify-between mb-4">
<div>
<span className={`px-3 py-1 text-sm font-semibold rounded-full capitalize ${getPlanColor(plan)}`}>
{plan}
</span>
<p className="text-xs text-gray-500 mt-1">
Status:{' '}
<span className={subscription?.status === 'active' ? 'text-green-600' : 'text-red-600'}>
{subscription?.status || 'active'}
</span>
</p>
</div>
{isPaid && subscription?.current_period_end && (
<div className="text-right">
<p className="text-xs text-gray-500">Renews on</p>
<p className="text-sm font-medium">{formatDate(subscription.current_period_end)}</p>
</div>
)}
</div>
<div className="flex gap-3">
{!isPaid ? (
<Button onClick={() => navigate('/pricing')} className="flex-1">
Upgrade Plan
</Button>
) : (
<Button variant="outline" onClick={handlePortal} loading={loading} className="flex-1">
<ExternalLink className="w-3.5 h-3.5" />
Manage Billing
</Button>
)}
</div>
</Card>
{/* Plan features */}
<Card className="p-6">
<h3 className="font-semibold text-gray-900 mb-4">Plan Features</h3>
<div className="space-y-3">
{[
{ label: 'Chatbots published', value: features.published },
{ label: 'Conversations/month', value: features.conversations },
{ label: 'Code export', value: features.codeExport },
].map(({ label, value }) => (
<div key={label} className="flex justify-between py-2 border-b border-gray-100 last:border-0">
<span className="text-sm text-gray-600">{label}</span>
<span className="text-sm font-medium text-gray-900">{value}</span>
</div>
))}
</div>
</Card>
</div>
)
}