mirror of
http://88.130.71.182:3000/BlitTech/contexta_fe.git
synced 2026-06-12 23:23:22 +00:00
306 lines
12 KiB
TypeScript
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>
|
|
)
|
|
}
|