2901 lines
156 KiB
TypeScript
2901 lines
156 KiB
TypeScript
import { useState, useEffect, useRef, useMemo } from 'react'
|
||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||
import { useAuth } from '@/context/AuthContext'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Label } from '@/components/ui/label'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { Slider } from '@/components/ui/slider'
|
||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||
import {
|
||
SidePanel,
|
||
SidePanelContent,
|
||
SidePanelHeader,
|
||
SidePanelTitle,
|
||
SidePanelDescription,
|
||
SidePanelCloseButton,
|
||
SidePanelBody,
|
||
SidePanelFooter,
|
||
} from '@/components/ui/side-panel'
|
||
import { api } from '@/lib/api'
|
||
import {
|
||
Mail,
|
||
User,
|
||
Users,
|
||
CreditCard,
|
||
Shield,
|
||
Settings as SettingsIcon,
|
||
ArrowLeft,
|
||
Plus,
|
||
Trash2,
|
||
Check,
|
||
X,
|
||
ExternalLink,
|
||
Loader2,
|
||
Crown,
|
||
Star,
|
||
Brain,
|
||
Building2,
|
||
Lock,
|
||
Copy,
|
||
AlertTriangle,
|
||
Search,
|
||
Download,
|
||
Upload,
|
||
ChevronDown,
|
||
ChevronUp,
|
||
RotateCcw,
|
||
Play,
|
||
Eye,
|
||
Camera,
|
||
Globe,
|
||
Clock,
|
||
Palette,
|
||
Save,
|
||
Edit2,
|
||
} from 'lucide-react'
|
||
import type { AIControlSettings, CompanyLabel, NameLabel, CategoryInfo, CleanupSettings, CleanupStatus, CategoryAdvanced } from '@/types/settings'
|
||
import { PrivacySecurity } from '@/components/PrivacySecurity'
|
||
|
||
type TabType = 'profile' | 'accounts' | 'vip' | 'ai-control' | 'name-labels' | 'subscription' | 'privacy' | 'referrals'
|
||
|
||
const HEX_COLOR = /^#([0-9A-Fa-f]{6})$/
|
||
|
||
function validateLabelImport(
|
||
imported: unknown,
|
||
existing: CompanyLabel[]
|
||
): { labels: CompanyLabel[]; errors: string[] } {
|
||
const errors: string[] = []
|
||
if (!Array.isArray(imported)) {
|
||
return { labels: [], errors: ['File must contain a JSON array'] }
|
||
}
|
||
const seen = new Set<string>()
|
||
const labels: CompanyLabel[] = []
|
||
imported.forEach((row, i) => {
|
||
const rowNum = i + 1
|
||
if (!row || typeof row !== 'object') {
|
||
errors.push(`Row ${rowNum}: invalid object`)
|
||
return
|
||
}
|
||
const r = row as Record<string, unknown>
|
||
const name = typeof r.name === 'string' ? r.name.trim() : ''
|
||
if (!name) {
|
||
errors.push(`Row ${rowNum}: name is required`)
|
||
return
|
||
}
|
||
if (name.length > 50) {
|
||
errors.push(`Row ${rowNum}: name must be at most 50 characters`)
|
||
return
|
||
}
|
||
if (r.color != null && r.color !== '') {
|
||
if (typeof r.color !== 'string' || !HEX_COLOR.test(r.color)) {
|
||
errors.push(`Row ${rowNum}: color must be a valid #RRGGBB hex`)
|
||
return
|
||
}
|
||
}
|
||
const key = name.toLowerCase()
|
||
if (seen.has(key)) {
|
||
errors.push(`Row ${rowNum}: duplicate name "${name}" in import`)
|
||
return
|
||
}
|
||
seen.add(key)
|
||
if (existing.some((e) => e.name.trim().toLowerCase() === key)) {
|
||
errors.push(`Row ${rowNum}: name "${name}" already exists`)
|
||
return
|
||
}
|
||
labels.push({
|
||
id: typeof r.id === 'string' && r.id ? r.id : `label_import_${Date.now()}_${i}`,
|
||
name,
|
||
condition: typeof r.condition === 'string' ? r.condition : '',
|
||
enabled: r.enabled !== false,
|
||
category: typeof r.category === 'string' ? r.category : 'promotions',
|
||
})
|
||
})
|
||
if (existing.length + labels.length > 100) {
|
||
return {
|
||
labels: [],
|
||
errors: [`Cannot exceed 100 labels total (have ${existing.length}, importing ${labels.length})`],
|
||
}
|
||
}
|
||
return { labels, errors }
|
||
}
|
||
|
||
interface EmailAccount {
|
||
id: string
|
||
email: string
|
||
provider: 'gmail' | 'outlook' | 'imap' | 'demo'
|
||
connected: boolean
|
||
lastSync?: string
|
||
isDemo?: boolean
|
||
}
|
||
|
||
interface VIPSender {
|
||
email: string
|
||
name?: string
|
||
}
|
||
|
||
interface Subscription {
|
||
status: string
|
||
plan: string
|
||
planDisplayName?: string
|
||
isFreeTier?: boolean
|
||
currentPeriodEnd?: string
|
||
cancelAtPeriodEnd?: boolean
|
||
}
|
||
|
||
function subscriptionTitle(sub: Subscription | null): string {
|
||
if (!sub) return ''
|
||
if (sub.planDisplayName) return sub.planDisplayName
|
||
if (sub.plan === 'free' || sub.isFreeTier) return 'Free plan'
|
||
if (sub.plan) return sub.plan.charAt(0).toUpperCase() + sub.plan.slice(1)
|
||
return 'Subscription'
|
||
}
|
||
|
||
function subscriptionBadge(sub: Subscription | null): {
|
||
label: string
|
||
variant: 'success' | 'warning' | 'secondary'
|
||
} {
|
||
if (!sub) return { label: '', variant: 'secondary' }
|
||
if (sub.isFreeTier) return { label: 'Free plan', variant: 'secondary' }
|
||
if (sub.status === 'active') return { label: 'Active', variant: 'success' }
|
||
const s = (sub.status || '').toLowerCase()
|
||
if (s === 'trialing' || s === 'trial') return { label: 'Trial', variant: 'warning' }
|
||
return {
|
||
label: sub.status ? sub.status.charAt(0).toUpperCase() + sub.status.slice(1) : 'Inactive',
|
||
variant: 'warning',
|
||
}
|
||
}
|
||
|
||
export function Settings() {
|
||
const { user } = useAuth()
|
||
const navigate = useNavigate()
|
||
const [searchParams, setSearchParams] = useSearchParams()
|
||
|
||
const activeTab = (searchParams.get('tab') as TabType) || 'profile'
|
||
const [loading, setLoading] = useState(false)
|
||
const [saving, setSaving] = useState(false)
|
||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||
|
||
const [name, setName] = useState(user?.name || '')
|
||
const [email] = useState(user?.email || '')
|
||
const [language, setLanguage] = useState('en')
|
||
const [timezone, setTimezone] = useState(Intl.DateTimeFormat().resolvedOptions().timeZone)
|
||
const [hasProfileChanges, setHasProfileChanges] = useState(false)
|
||
const savedProfileRef = useRef<{ name: string; language: string; timezone: string } | null>(null)
|
||
const [accounts, setAccounts] = useState<EmailAccount[]>([])
|
||
const [connectingProvider, setConnectingProvider] = useState<string | null>(null)
|
||
const [showImapForm, setShowImapForm] = useState(false)
|
||
const [imapForm, setImapForm] = useState({ email: '', password: '', imapHost: 'imap.porkbun.com', imapPort: 993, imapSecure: true })
|
||
const [imapConnecting, setImapConnecting] = useState(false)
|
||
const [recoveringAccountId, setRecoveringAccountId] = useState<string | null>(null)
|
||
const [reSortingAccountId, setReSortingAccountId] = useState<string | null>(null)
|
||
const [vipSenders, setVipSenders] = useState<VIPSender[]>([])
|
||
const [newVipEmail, setNewVipEmail] = useState('')
|
||
const [subscription, setSubscription] = useState<Subscription | null>(null)
|
||
|
||
// AI Control state
|
||
const [aiControlSettings, setAiControlSettings] = useState<AIControlSettings>({
|
||
enabledCategories: [],
|
||
categoryActions: {},
|
||
autoDetectCompanies: true,
|
||
cleanup: {
|
||
enabled: false,
|
||
readItems: {
|
||
enabled: false,
|
||
action: 'archive_read',
|
||
gracePeriodDays: 7,
|
||
},
|
||
promotions: {
|
||
enabled: false,
|
||
matchCategoriesOrLabels: ['promotions', 'newsletters'],
|
||
action: 'archive_read',
|
||
deleteAfterDays: 30,
|
||
},
|
||
safety: {
|
||
requireConfirmForDelete: true,
|
||
},
|
||
},
|
||
})
|
||
const [categories, setCategories] = useState<CategoryInfo[]>([])
|
||
const [companyLabels, setCompanyLabels] = useState<CompanyLabel[]>([])
|
||
const [labelImportErrors, setLabelImportErrors] = useState<string[]>([])
|
||
const [isAdmin, setIsAdmin] = useState(false)
|
||
const [nameLabels, setNameLabels] = useState<NameLabel[]>([])
|
||
const [editingNameLabel, setEditingNameLabel] = useState<NameLabel | null>(null)
|
||
const [showNameLabelPanel, setShowNameLabelPanel] = useState(false)
|
||
const [referralData, setReferralData] = useState<{ referralCode: string; referralCount: number } | null>(null)
|
||
const [loadingReferral, setLoadingReferral] = useState(false)
|
||
const [resettingSort, setResettingSort] = useState(false)
|
||
|
||
// Control Panel Sub-Tabs
|
||
const [controlPanelTab, setControlPanelTab] = useState<'rules' | 'cleanup' | 'labels'>('rules')
|
||
|
||
// Unsaved changes tracking
|
||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
|
||
const savedSettingsRef = useRef<AIControlSettings | null>(null)
|
||
|
||
// Search/Filter state
|
||
const [searchQuery, setSearchQuery] = useState('')
|
||
const [filterEnabled, setFilterEnabled] = useState<'all' | 'enabled' | 'disabled'>('all')
|
||
const [labelSort, setLabelSort] = useState<'name' | 'newest'>('name')
|
||
|
||
// Cleanup status
|
||
const [cleanupStatus, setCleanupStatus] = useState<CleanupStatus | null>(null)
|
||
const [cleanupPreview, setCleanupPreview] = useState<CleanupStatus['preview']>([])
|
||
const [runningCleanup, setRunningCleanup] = useState(false)
|
||
|
||
// Label editor
|
||
const [editingLabel, setEditingLabel] = useState<CompanyLabel | null>(null)
|
||
|
||
// Advanced options expanded state
|
||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
|
||
|
||
// Side Panel state for category details
|
||
const [selectedCategory, setSelectedCategory] = useState<CategoryInfo | null>(null)
|
||
const [showCategoryPanel, setShowCategoryPanel] = useState(false)
|
||
|
||
// Side Panel state for label editor
|
||
const [showLabelPanel, setShowLabelPanel] = useState(false)
|
||
|
||
useEffect(() => {
|
||
loadData()
|
||
if (user?.$id) {
|
||
loadReferralData()
|
||
}
|
||
}, [user])
|
||
|
||
// Refetch subscription when opening this tab (fixes JWT timing vs initial loadData)
|
||
useEffect(() => {
|
||
if (activeTab !== 'subscription' || !user?.$id) return
|
||
let cancelled = false
|
||
;(async () => {
|
||
const res = await api.getSubscriptionStatus()
|
||
if (!cancelled && res.data) setSubscription(res.data)
|
||
})()
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [activeTab, user?.$id])
|
||
|
||
const loadReferralData = async () => {
|
||
if (!user?.$id) return
|
||
setLoadingReferral(true)
|
||
try {
|
||
const res = await api.getReferralCode()
|
||
if (res.data) setReferralData(res.data)
|
||
} catch (err) {
|
||
console.error('Failed to load referral data:', err)
|
||
} finally {
|
||
setLoadingReferral(false)
|
||
}
|
||
}
|
||
|
||
const loadData = async () => {
|
||
|
||
if (!user?.$id) return
|
||
setLoading(true)
|
||
|
||
try {
|
||
const [accountsRes, subsRes, prefsRes, aiControlRes, companyLabelsRes, meRes] = await Promise.all([
|
||
api.getEmailAccounts(),
|
||
api.getSubscriptionStatus(),
|
||
api.getUserPreferences(),
|
||
api.getAIControlSettings(),
|
||
api.getCompanyLabels(),
|
||
user?.$id ? api.getMe() : Promise.resolve({ data: { isAdmin: false } }),
|
||
])
|
||
|
||
if (accountsRes.data) setAccounts(accountsRes.data)
|
||
if (subsRes.data) setSubscription(subsRes.data)
|
||
if (meRes.data?.isAdmin) {
|
||
setIsAdmin(true)
|
||
const nameLabelsRes = await api.getNameLabels()
|
||
if (nameLabelsRes.data) setNameLabels(nameLabelsRes.data)
|
||
} else {
|
||
setIsAdmin(false)
|
||
}
|
||
if (prefsRes.data?.vipSenders) setVipSenders(prefsRes.data.vipSenders)
|
||
const pdata = prefsRes.data as {
|
||
profile?: { displayName?: string; timezone?: string; notificationPrefs?: { language?: string } }
|
||
} | undefined
|
||
if (pdata?.profile) {
|
||
const prof = pdata.profile
|
||
if (prof.displayName != null && prof.displayName !== '') setName(prof.displayName)
|
||
if (prof.timezone) setTimezone(prof.timezone)
|
||
if (prof.notificationPrefs?.language) setLanguage(String(prof.notificationPrefs.language))
|
||
}
|
||
if (aiControlRes.data) {
|
||
// Merge cleanup defaults if not present
|
||
const raw = aiControlRes.data
|
||
const defaultCleanup: CleanupSettings = {
|
||
enabled: false,
|
||
readItems: {
|
||
enabled: false,
|
||
action: 'archive_read',
|
||
gracePeriodDays: 7,
|
||
},
|
||
promotions: {
|
||
enabled: false,
|
||
matchCategoriesOrLabels: ['promotions', 'newsletters'],
|
||
action: 'archive_read',
|
||
deleteAfterDays: 30,
|
||
},
|
||
safety: {
|
||
requireConfirmForDelete: true,
|
||
dryRun: false,
|
||
maxDeletesPerRun: 100,
|
||
},
|
||
}
|
||
const settings: AIControlSettings = {
|
||
...raw,
|
||
cleanup: (raw.cleanup as CleanupSettings | undefined) || defaultCleanup,
|
||
categoryAdvanced: (raw.categoryAdvanced as Record<string, CategoryAdvanced> | undefined) || {},
|
||
version: raw.version ?? 1,
|
||
}
|
||
setAiControlSettings(settings)
|
||
savedSettingsRef.current = JSON.parse(JSON.stringify(settings)) // Deep copy
|
||
setHasUnsavedChanges(false)
|
||
}
|
||
if (companyLabelsRes.data) setCompanyLabels(companyLabelsRes.data)
|
||
|
||
// Load categories from API or use defaults
|
||
const categoryList: CategoryInfo[] = [
|
||
{ key: 'vip', name: 'Important', description: 'Important emails from known contacts', defaultAction: 'star', color: '#ff0000', enabled: true },
|
||
{ key: 'customers', name: 'Clients', description: 'Emails from clients and projects', defaultAction: 'inbox', color: '#4285f4', enabled: true },
|
||
{ key: 'invoices', name: 'Invoices', description: 'Invoices, receipts and financial documents', defaultAction: 'inbox', color: '#0f9d58', enabled: true },
|
||
{ key: 'newsletters', name: 'Newsletter', description: 'Regular newsletters and updates', defaultAction: 'archive_read', color: '#9c27b0', enabled: true },
|
||
{ key: 'promotions', name: 'Promotions', description: 'Marketing emails and promotions', defaultAction: 'archive_read', color: '#ff9800', enabled: true },
|
||
{ key: 'social', name: 'Social', description: 'Social media and platform notifications', defaultAction: 'archive_read', color: '#00bcd4', enabled: true },
|
||
{ key: 'security', name: 'Security', description: 'Security codes and notifications', defaultAction: 'inbox', color: '#f44336', enabled: true },
|
||
{ key: 'calendar', name: 'Calendar', description: 'Calendar invites and events', defaultAction: 'inbox', color: '#673ab7', enabled: true },
|
||
{ key: 'review', name: 'Review', description: 'Emails that need manual review', defaultAction: 'inbox', color: '#607d8b', enabled: true },
|
||
]
|
||
|
||
// Update enabled status from settings
|
||
const enabledCategories = aiControlRes.data?.enabledCategories || categoryList.map(c => c.key)
|
||
const updatedCategories = categoryList.map(cat => ({
|
||
...cat,
|
||
enabled: enabledCategories.includes(cat.key),
|
||
}))
|
||
setCategories(updatedCategories)
|
||
} catch (error) {
|
||
console.error('Failed to load settings data:', error)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const setTab = (tab: TabType) => {
|
||
setSearchParams({ tab })
|
||
setMessage(null)
|
||
}
|
||
|
||
const showMessage = (type: 'success' | 'error', text: string) => {
|
||
setMessage({ type, text })
|
||
setTimeout(() => setMessage(null), 5000)
|
||
}
|
||
|
||
// Track changes for Control Panel
|
||
useEffect(() => {
|
||
if (activeTab === 'ai-control' && savedSettingsRef.current) {
|
||
const current = JSON.stringify(aiControlSettings)
|
||
const saved = JSON.stringify(savedSettingsRef.current)
|
||
setHasUnsavedChanges(current !== saved)
|
||
}
|
||
}, [aiControlSettings, activeTab])
|
||
|
||
// Save Control Panel settings
|
||
const handleSaveControlPanel = async () => {
|
||
if (!user?.$id) return
|
||
setSaving(true)
|
||
try {
|
||
await api.saveAIControlSettings({
|
||
enabledCategories: aiControlSettings.enabledCategories,
|
||
categoryActions: aiControlSettings.categoryActions,
|
||
autoDetectCompanies: aiControlSettings.autoDetectCompanies,
|
||
cleanup: aiControlSettings.cleanup,
|
||
categoryAdvanced: aiControlSettings.categoryAdvanced,
|
||
version: aiControlSettings.version || 1,
|
||
})
|
||
savedSettingsRef.current = JSON.parse(JSON.stringify(aiControlSettings))
|
||
setHasUnsavedChanges(false)
|
||
showMessage('success', 'Control Panel settings saved!')
|
||
} catch {
|
||
showMessage('error', 'Failed to save settings')
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
// Discard changes
|
||
const handleDiscardChanges = () => {
|
||
if (savedSettingsRef.current) {
|
||
setAiControlSettings(JSON.parse(JSON.stringify(savedSettingsRef.current)))
|
||
setHasUnsavedChanges(false)
|
||
showMessage('success', 'Changes discarded')
|
||
}
|
||
}
|
||
|
||
// Load cleanup status
|
||
const loadCleanupStatus = async () => {
|
||
const aid = accounts.find((a) => a.provider !== 'demo')?.id
|
||
if (!aid) return
|
||
try {
|
||
const res = await api.getCleanupStatus(aid)
|
||
if (res.data) setCleanupStatus(res.data)
|
||
} catch {
|
||
console.debug('Cleanup status endpoint not available')
|
||
}
|
||
}
|
||
|
||
// Load cleanup preview
|
||
const loadCleanupPreview = async () => {
|
||
if (!aiControlSettings.cleanup?.enabled) return
|
||
const aid = accounts.find((a) => a.provider !== 'demo')?.id
|
||
if (!aid) return
|
||
try {
|
||
const res = await api.getCleanupPreview(aid)
|
||
if (res.data?.messages) setCleanupPreview(res.data.messages)
|
||
} catch {
|
||
console.debug('Cleanup preview endpoint not available')
|
||
}
|
||
}
|
||
|
||
// Load cleanup status when cleanup tab is active
|
||
useEffect(() => {
|
||
if (activeTab === 'ai-control' && controlPanelTab === 'cleanup' && aiControlSettings.cleanup?.enabled) {
|
||
loadCleanupStatus()
|
||
if (aiControlSettings.cleanup.safety.dryRun) {
|
||
loadCleanupPreview()
|
||
}
|
||
}
|
||
}, [activeTab, controlPanelTab, aiControlSettings.cleanup?.enabled, aiControlSettings.cleanup?.safety.dryRun, accounts])
|
||
|
||
// Run cleanup now
|
||
const handleRunCleanup = async () => {
|
||
if (!user?.$id) return
|
||
setRunningCleanup(true)
|
||
try {
|
||
const res = await api.runCleanup()
|
||
if (res.data) {
|
||
showMessage('success', `Cleanup completed: ${res.data.emailsProcessed.readItems + res.data.emailsProcessed.promotions} emails processed`)
|
||
await loadCleanupStatus()
|
||
}
|
||
} catch {
|
||
showMessage('error', 'Failed to run cleanup')
|
||
} finally {
|
||
setRunningCleanup(false)
|
||
}
|
||
}
|
||
|
||
// Filtered and sorted labels
|
||
const filteredLabels = useMemo(() => {
|
||
let filtered = companyLabels
|
||
|
||
// Search filter
|
||
if (searchQuery) {
|
||
const query = searchQuery.toLowerCase()
|
||
filtered = filtered.filter(l =>
|
||
l.name.toLowerCase().includes(query) ||
|
||
l.condition.toLowerCase().includes(query)
|
||
)
|
||
}
|
||
|
||
// Enabled/Disabled filter
|
||
if (filterEnabled !== 'all') {
|
||
filtered = filtered.filter(l =>
|
||
filterEnabled === 'enabled' ? l.enabled : !l.enabled
|
||
)
|
||
}
|
||
|
||
// Sort
|
||
if (labelSort === 'name') {
|
||
filtered = [...filtered].sort((a, b) => a.name.localeCompare(b.name))
|
||
} else if (labelSort === 'newest') {
|
||
// Reverse order (assuming newer labels are added at the end)
|
||
filtered = [...filtered].reverse()
|
||
}
|
||
|
||
return filtered
|
||
}, [companyLabels, searchQuery, filterEnabled, labelSort])
|
||
|
||
// Filtered categories
|
||
const filteredCategories = useMemo(() => {
|
||
let filtered = categories
|
||
|
||
if (searchQuery) {
|
||
const query = searchQuery.toLowerCase()
|
||
filtered = filtered.filter(c =>
|
||
c.name.toLowerCase().includes(query) ||
|
||
c.description.toLowerCase().includes(query) ||
|
||
c.key.toLowerCase().includes(query)
|
||
)
|
||
}
|
||
|
||
if (filterEnabled !== 'all') {
|
||
filtered = filtered.filter(c =>
|
||
filterEnabled === 'enabled' ? c.enabled : !c.enabled
|
||
)
|
||
}
|
||
|
||
return filtered
|
||
}, [categories, searchQuery, filterEnabled])
|
||
|
||
const handleSaveProfile = async () => {
|
||
if (!user?.$id) return
|
||
setSaving(true)
|
||
try {
|
||
const res = await api.updateProfile({
|
||
displayName: name,
|
||
timezone,
|
||
notificationPrefs: { language },
|
||
})
|
||
if (res.error) {
|
||
showMessage('error', res.error.message || 'Failed to save profile')
|
||
return
|
||
}
|
||
savedProfileRef.current = { name, language, timezone }
|
||
setHasProfileChanges(false)
|
||
showMessage('success', 'Profile saved successfully!')
|
||
} catch {
|
||
showMessage('error', 'Failed to save profile')
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
// Track profile changes
|
||
useEffect(() => {
|
||
if (savedProfileRef.current) {
|
||
const hasChanges =
|
||
name !== savedProfileRef.current.name ||
|
||
language !== savedProfileRef.current.language ||
|
||
timezone !== savedProfileRef.current.timezone
|
||
setHasProfileChanges(hasChanges)
|
||
}
|
||
}, [name, language, timezone])
|
||
|
||
// Initialize saved profile ref
|
||
useEffect(() => {
|
||
if (user && !savedProfileRef.current) {
|
||
savedProfileRef.current = {
|
||
name: user.name || '',
|
||
language: 'en',
|
||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
|
||
}
|
||
setName(user.name || '')
|
||
}
|
||
}, [user])
|
||
|
||
const handleConnectAccount = async (provider: 'gmail' | 'outlook') => {
|
||
if (!user?.$id) return
|
||
setConnectingProvider(provider)
|
||
|
||
try {
|
||
const res = await api.getOAuthUrl(provider)
|
||
if (res.data?.url) {
|
||
window.location.href = res.data.url
|
||
}
|
||
} catch {
|
||
showMessage('error', `Failed to connect ${provider}`)
|
||
setConnectingProvider(null)
|
||
}
|
||
}
|
||
|
||
const handleDisconnectAccount = async (accountId: string) => {
|
||
if (!user?.$id) return
|
||
|
||
try {
|
||
await api.disconnectEmailAccount(accountId)
|
||
setAccounts(accounts.filter(a => a.id !== accountId))
|
||
showMessage('success', 'Account disconnected')
|
||
} catch {
|
||
showMessage('error', 'Failed to disconnect')
|
||
}
|
||
}
|
||
|
||
const handleRecoverEmails = async (accountId: string) => {
|
||
if (!user?.$id) return
|
||
setRecoveringAccountId(accountId)
|
||
try {
|
||
const res = await api.recoverEmails(accountId)
|
||
if (res.error) {
|
||
showMessage('error', res.error.message || 'Recovery failed')
|
||
return
|
||
}
|
||
const data = res.data as { recovered?: number; message?: string } | undefined
|
||
const n = data?.recovered ?? 0
|
||
const text =
|
||
data?.message ||
|
||
(n > 0 ? `${n} emails recovered to inbox` : 'No emails found outside inbox')
|
||
showMessage('success', text)
|
||
} finally {
|
||
setRecoveringAccountId(null)
|
||
}
|
||
}
|
||
|
||
const handleReSortEmails = async (accountId: string) => {
|
||
if (!user?.$id) return
|
||
if (
|
||
!window.confirm(
|
||
'Move messages from Junk, Archive, MailFlow/EmailSorter folders (and similar sort targets) back to INBOX and remove MailFlow category tags? Then run Sort again. Sent/Drafts/Trash are not touched.',
|
||
)
|
||
) {
|
||
return
|
||
}
|
||
setReSortingAccountId(accountId)
|
||
try {
|
||
const res = await api.reSortEmails(accountId)
|
||
if (res.error) {
|
||
showMessage('error', res.error.message || 'Re-sort prep failed')
|
||
return
|
||
}
|
||
const data = res.data as
|
||
| { recovered?: number; mailFlowKeywordsStripped?: number; message?: string }
|
||
| undefined
|
||
showMessage('success', data?.message || 'Re-sort prep completed')
|
||
} finally {
|
||
setReSortingAccountId(null)
|
||
}
|
||
}
|
||
|
||
const handleConnectImap = async (e: React.FormEvent) => {
|
||
e.preventDefault()
|
||
if (!user?.$id || !imapForm.email.trim() || !imapForm.password) return
|
||
setImapConnecting(true)
|
||
const res = await api.connectImapAccount({
|
||
email: imapForm.email.trim(),
|
||
password: imapForm.password,
|
||
imapHost: imapForm.imapHost || undefined,
|
||
imapPort: imapForm.imapPort || 993,
|
||
imapSecure: imapForm.imapSecure,
|
||
})
|
||
if (res.error) {
|
||
const msg = res.error.message || 'Connection failed'
|
||
showMessage('error', msg.includes('credentials') || msg.includes('auth') || msg.includes('password') ? 'Login failed – check email and password' : msg)
|
||
setImapConnecting(false)
|
||
return
|
||
}
|
||
const list = await api.getEmailAccounts()
|
||
setAccounts(list.data ?? [])
|
||
setShowImapForm(false)
|
||
setImapForm({ email: '', password: '', imapHost: 'imap.porkbun.com', imapPort: 993, imapSecure: true })
|
||
showMessage('success', 'IMAP account connected')
|
||
setImapConnecting(false)
|
||
}
|
||
|
||
const handleAddVip = () => {
|
||
if (!newVipEmail.trim() || !newVipEmail.includes('@')) return
|
||
|
||
if (vipSenders.some(v => v.email === newVipEmail)) {
|
||
showMessage('error', 'This email is already in the VIP list')
|
||
return
|
||
}
|
||
|
||
setVipSenders([...vipSenders, { email: newVipEmail }])
|
||
setNewVipEmail('')
|
||
showMessage('success', 'VIP added')
|
||
}
|
||
|
||
const handleRemoveVip = (email: string) => {
|
||
setVipSenders(vipSenders.filter(v => v.email !== email))
|
||
}
|
||
|
||
const handleSaveVips = async () => {
|
||
if (!user?.$id) return
|
||
setSaving(true)
|
||
|
||
try {
|
||
await api.saveUserPreferences({ vipSenders })
|
||
showMessage('success', 'VIP list saved!')
|
||
} catch {
|
||
showMessage('error', 'Failed to save')
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
const handleManageSubscription = async () => {
|
||
if (!user?.$id) return
|
||
|
||
try {
|
||
const res = await api.createPortalSession()
|
||
if (res.data?.url) {
|
||
window.location.href = res.data.url
|
||
}
|
||
} catch {
|
||
showMessage('error', 'Failed to open customer portal')
|
||
}
|
||
}
|
||
|
||
const handleUpgrade = async (plan: string) => {
|
||
if (!user?.$id) return
|
||
|
||
try {
|
||
const res = await api.createSubscriptionCheckout(plan, user.email)
|
||
if (res.data?.url) {
|
||
window.location.href = res.data.url
|
||
}
|
||
} catch {
|
||
showMessage('error', 'Failed to start checkout')
|
||
}
|
||
}
|
||
|
||
const tabs = useMemo(() => {
|
||
const base = [
|
||
{ id: 'profile' as TabType, label: 'Profile', icon: User },
|
||
{ id: 'accounts' as TabType, label: 'Email Accounts', icon: Mail },
|
||
{ id: 'vip' as TabType, label: 'VIP List', icon: Star },
|
||
{ id: 'ai-control' as TabType, label: 'Control Panel', icon: Brain },
|
||
...(isAdmin ? [{ id: 'name-labels' as TabType, label: 'Name Labels (Team)', icon: Users }] : []),
|
||
{ id: 'subscription' as TabType, label: 'Subscription', icon: CreditCard },
|
||
{ id: 'privacy' as TabType, label: 'Privacy & Security', icon: Lock },
|
||
]
|
||
return base
|
||
}, [isAdmin])
|
||
|
||
return (
|
||
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
|
||
<header className="bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700 sticky top-0 z-40">
|
||
<div className="w-full px-4 sm:px-6 lg:px-8">
|
||
<div className="flex items-center h-16">
|
||
<Button variant="ghost" onClick={() => navigate('/dashboard')} className="mr-4">
|
||
<ArrowLeft className="w-5 h-5 mr-2" />
|
||
Back
|
||
</Button>
|
||
<div className="flex items-center gap-2">
|
||
<SettingsIcon className="w-5 h-5 text-slate-500 dark:text-slate-400" />
|
||
<h1 className="text-lg font-semibold text-slate-900 dark:text-slate-100">Settings</h1>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<main className="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||
{message && (
|
||
<div className={`mb-6 p-4 rounded-lg flex items-center gap-2 ${
|
||
message.type === 'success'
|
||
? 'bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 border border-green-200 dark:border-green-800'
|
||
: 'bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800'
|
||
}`}>
|
||
{message.type === 'success' ? <Check className="w-5 h-5 text-green-700 dark:text-green-300" /> : <X className="w-5 h-5 text-red-700 dark:text-red-300" />}
|
||
{message.text}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex flex-col lg:flex-row gap-8">
|
||
<nav className="lg:w-72 flex-shrink-0">
|
||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||
{tabs.map((tab) => (
|
||
<button
|
||
key={tab.id}
|
||
onClick={() => setTab(tab.id)}
|
||
className={`w-full flex items-center gap-3 px-4 py-3 text-left transition-colors ${
|
||
activeTab === tab.id
|
||
? 'bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 border-l-4 border-primary-500 dark:border-primary-400'
|
||
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-700 border-l-4 border-transparent'
|
||
}`}
|
||
>
|
||
<tab.icon className="w-5 h-5" />
|
||
<span className="font-medium">{tab.label}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</nav>
|
||
|
||
<div className="flex-1 min-w-0">
|
||
{loading ? (
|
||
<div className="flex items-center justify-center py-12">
|
||
<Loader2 className="w-8 h-8 animate-spin text-primary-500 dark:text-primary-400" />
|
||
</div>
|
||
) : (
|
||
<>
|
||
{activeTab === 'profile' && (
|
||
<div className="space-y-6">
|
||
{/* Header with Save Button */}
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h2 className="text-2xl font-bold text-slate-900 dark:text-slate-100">General Settings</h2>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">Manage your account and preferences</p>
|
||
</div>
|
||
{hasProfileChanges && (
|
||
<Button onClick={handleSaveProfile} disabled={saving} size="sm">
|
||
{saving ? (
|
||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||
) : (
|
||
<Save className="w-4 h-4 mr-2" />
|
||
)}
|
||
Save Changes
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Profile Section */}
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex items-center gap-2">
|
||
<User className="w-5 h-5 text-primary-500 dark:text-primary-400" />
|
||
<CardTitle>Profile Information</CardTitle>
|
||
</div>
|
||
<CardDescription>Your personal account details</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-6">
|
||
{/* Avatar Section */}
|
||
<div className="flex items-start gap-6">
|
||
<div className="relative group">
|
||
<div className="w-24 h-24 rounded-full bg-gradient-to-br from-primary-400 via-primary-500 to-primary-600 flex items-center justify-center text-white text-3xl font-bold shadow-lg ring-4 ring-white dark:ring-slate-800">
|
||
{name?.charAt(0)?.toUpperCase() || email?.charAt(0)?.toUpperCase() || 'U'}
|
||
</div>
|
||
<button className="absolute bottom-0 right-0 w-8 h-8 rounded-full bg-primary-500 dark:bg-primary-600 text-white flex items-center justify-center shadow-lg hover:bg-primary-600 dark:hover:bg-primary-700 transition-colors opacity-0 group-hover:opacity-100">
|
||
<Camera className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
<div className="flex-1 space-y-1">
|
||
<h3 className="text-xl font-semibold text-slate-900 dark:text-slate-100">
|
||
{name || 'User'}
|
||
</h3>
|
||
<p className="text-slate-500 dark:text-slate-400">{email}</p>
|
||
<p className="text-xs text-slate-400 dark:text-slate-500 mt-2">
|
||
Member since {user?.$createdAt ? new Date(user.$createdAt).toLocaleDateString('en-US', { month: 'long', year: 'numeric' }) : 'recently'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 pt-4 border-t border-slate-200 dark:border-slate-700">
|
||
<div>
|
||
<Label htmlFor="profile-name" className="flex items-center gap-2">
|
||
<Edit2 className="w-4 h-4" />
|
||
Full Name
|
||
</Label>
|
||
<Input
|
||
id="profile-name"
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
placeholder="Enter your name"
|
||
className="mt-2"
|
||
/>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">This is how your name appears to others</p>
|
||
</div>
|
||
<div>
|
||
<Label htmlFor="profile-email" className="flex items-center gap-2">
|
||
<Mail className="w-4 h-4" />
|
||
Email Address
|
||
</Label>
|
||
<Input
|
||
id="profile-email"
|
||
value={email}
|
||
disabled
|
||
className="mt-2 bg-slate-50 dark:bg-slate-800 cursor-not-allowed"
|
||
/>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">Email cannot be changed</p>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Preferences Section */}
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex items-center gap-2">
|
||
<Palette className="w-5 h-5 text-primary-500 dark:text-primary-400" />
|
||
<CardTitle>Preferences</CardTitle>
|
||
</div>
|
||
<CardDescription>Customize your experience</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-6">
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
<div>
|
||
<Label htmlFor="language" className="flex items-center gap-2 mb-2">
|
||
<Globe className="w-4 h-4" />
|
||
Language
|
||
</Label>
|
||
<select
|
||
id="language"
|
||
value={language}
|
||
onChange={(e) => setLanguage(e.target.value)}
|
||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||
>
|
||
<option value="en">English</option>
|
||
<option value="de">Deutsch</option>
|
||
<option value="fr">Français</option>
|
||
<option value="es">Español</option>
|
||
<option value="it">Italiano</option>
|
||
</select>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">Choose your preferred language</p>
|
||
</div>
|
||
<div>
|
||
<Label htmlFor="timezone" className="flex items-center gap-2 mb-2">
|
||
<Clock className="w-4 h-4" />
|
||
Timezone
|
||
</Label>
|
||
<select
|
||
id="timezone"
|
||
value={timezone}
|
||
onChange={(e) => setTimezone(e.target.value)}
|
||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||
>
|
||
<option value="Europe/Berlin">Europe/Berlin (CET/CEST)</option>
|
||
<option value="Europe/London">Europe/London (GMT/BST)</option>
|
||
<option value="Europe/Paris">Europe/Paris (CET/CEST)</option>
|
||
<option value="America/New_York">America/New York (EST/EDT)</option>
|
||
<option value="America/Chicago">America/Chicago (CST/CDT)</option>
|
||
<option value="America/Denver">America/Denver (MST/MDT)</option>
|
||
<option value="America/Los_Angeles">America/Los Angeles (PST/PDT)</option>
|
||
<option value="Asia/Tokyo">Asia/Tokyo (JST)</option>
|
||
<option value="Asia/Shanghai">Asia/Shanghai (CST)</option>
|
||
<option value="Asia/Dubai">Asia/Dubai (GST)</option>
|
||
<option value="Australia/Sydney">Australia/Sydney (AEDT/AEST)</option>
|
||
<option value="UTC">UTC</option>
|
||
</select>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">Set your local timezone</p>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Account Information */}
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex items-center gap-2">
|
||
<Shield className="w-5 h-5 text-primary-500 dark:text-primary-400" />
|
||
<CardTitle>Account Information</CardTitle>
|
||
</div>
|
||
<CardDescription>Your account details and security</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between p-4 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||
<Check className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||
</div>
|
||
<div>
|
||
<p className="font-medium text-slate-900 dark:text-slate-100">Account Status</p>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400">Active and verified</p>
|
||
</div>
|
||
</div>
|
||
<Badge variant="default" className="bg-green-500 dark:bg-green-600">Active</Badge>
|
||
</div>
|
||
<div className="flex items-center justify-between p-4 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||
<Mail className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||
</div>
|
||
<div>
|
||
<p className="font-medium text-slate-900 dark:text-slate-100">Email Verification</p>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400">{email}</p>
|
||
</div>
|
||
</div>
|
||
<Badge variant="default" className="bg-blue-500 dark:bg-blue-600">Verified</Badge>
|
||
</div>
|
||
<div className="flex items-center justify-between p-4 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-10 h-10 rounded-full bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
|
||
<Clock className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||
</div>
|
||
<div>
|
||
<p className="font-medium text-slate-900 dark:text-slate-100">Account Created</p>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||
{user?.$createdAt ? new Date(user.$createdAt).toLocaleDateString('en-US', {
|
||
year: 'numeric',
|
||
month: 'long',
|
||
day: 'numeric'
|
||
}) : 'Unknown'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Sticky Save Bar */}
|
||
{hasProfileChanges && (
|
||
<div className="sticky bottom-0 z-50 bg-primary-600 dark:bg-primary-700 text-white px-6 py-4 rounded-lg flex items-center justify-between shadow-lg">
|
||
<span className="font-medium">You have unsaved changes</span>
|
||
<div className="flex items-center gap-3">
|
||
<Button
|
||
variant="secondary"
|
||
onClick={() => {
|
||
if (savedProfileRef.current) {
|
||
setName(savedProfileRef.current.name)
|
||
setLanguage(savedProfileRef.current.language)
|
||
setTimezone(savedProfileRef.current.timezone)
|
||
setHasProfileChanges(false)
|
||
}
|
||
}}
|
||
disabled={saving}
|
||
size="sm"
|
||
className="bg-white/20 hover:bg-white/30 text-white border-0"
|
||
>
|
||
<RotateCcw className="w-4 h-4 mr-2" />
|
||
Discard
|
||
</Button>
|
||
<Button
|
||
onClick={handleSaveProfile}
|
||
disabled={saving}
|
||
size="sm"
|
||
className="bg-white text-primary-600 hover:bg-white/90"
|
||
>
|
||
{saving ? (
|
||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||
) : (
|
||
<Save className="w-4 h-4 mr-2" />
|
||
)}
|
||
Save Changes
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'accounts' && (
|
||
<div className="space-y-6">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Connected Email Accounts</CardTitle>
|
||
<CardDescription>Connect your email accounts for automatic sorting</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{accounts.length > 0 ? (
|
||
accounts.map((account) => (
|
||
<div key={account.id} className="flex items-center justify-between p-4 bg-slate-50 dark:bg-slate-800/50 rounded-lg">
|
||
<div className="flex items-center gap-4">
|
||
<div className={`w-12 h-12 rounded-lg flex items-center justify-center ${
|
||
account.provider === 'gmail' ? 'bg-red-100 dark:bg-red-900/50' : account.provider === 'outlook' ? 'bg-blue-100 dark:bg-blue-900/50' : 'bg-slate-100 dark:bg-slate-700/50'
|
||
}`}>
|
||
<Mail className={`w-6 h-6 ${account.provider === 'gmail' ? 'text-red-600 dark:text-red-400' : account.provider === 'outlook' ? 'text-blue-600 dark:text-blue-400' : 'text-slate-600 dark:text-slate-400'}`} />
|
||
</div>
|
||
<div>
|
||
<p className="font-medium text-slate-900 dark:text-slate-100">{account.email}</p>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400 capitalize">{account.provider === 'imap' ? 'IMAP' : account.provider}</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-3 flex-wrap justify-end">
|
||
<Badge variant={account.connected ? 'success' : 'secondary'}>
|
||
{account.connected ? 'Connected' : 'Disconnected'}
|
||
</Badge>
|
||
{account.provider === 'imap' && (
|
||
<>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={
|
||
recoveringAccountId === account.id ||
|
||
reSortingAccountId === account.id
|
||
}
|
||
onClick={() => handleRecoverEmails(account.id)}
|
||
>
|
||
{recoveringAccountId === account.id ? (
|
||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||
) : null}
|
||
Recover Emails
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="secondary"
|
||
size="sm"
|
||
disabled={
|
||
recoveringAccountId === account.id ||
|
||
reSortingAccountId === account.id
|
||
}
|
||
onClick={() => handleReSortEmails(account.id)}
|
||
title="Reset wrongly sorted mail: move from Junk/Archive/MailFlow folders to INBOX and clear MailFlow tags"
|
||
>
|
||
{reSortingAccountId === account.id ? (
|
||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||
) : null}
|
||
Re-sort all
|
||
</Button>
|
||
</>
|
||
)}
|
||
<Button variant="ghost" size="icon" onClick={() => handleDisconnectAccount(account.id)}>
|
||
<Trash2 className="w-4 h-4 text-red-500" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
))
|
||
) : (
|
||
<p className="text-center text-slate-500 dark:text-slate-400 py-8">No email accounts connected yet</p>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Add Account</CardTitle>
|
||
<CardDescription>Connect a new email account</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="grid sm:grid-cols-2 gap-4">
|
||
<button
|
||
onClick={() => handleConnectAccount('gmail')}
|
||
disabled={connectingProvider === 'gmail'}
|
||
className="flex items-center gap-4 p-4 border-2 border-slate-200 dark:border-slate-700 rounded-xl hover:border-red-300 dark:hover:border-red-700 hover:bg-red-50 dark:hover:bg-red-900/30 transition-all disabled:opacity-50"
|
||
>
|
||
{connectingProvider === 'gmail' ? (
|
||
<Loader2 className="w-8 h-8 animate-spin text-red-500" />
|
||
) : (
|
||
<div className="w-12 h-12 rounded-lg bg-red-100 flex items-center justify-center">
|
||
<svg className="w-6 h-6" viewBox="0 0 24 24">
|
||
<path fill="#EA4335" d="M12 11.3L1.5 3.5h21z"/>
|
||
<path fill="#34A853" d="M12 12.7L1.5 20.5V3.5z"/>
|
||
<path fill="#FBBC05" d="M1.5 20.5h21v-17z"/>
|
||
<path fill="#4285F4" d="M22.5 3.5v17L12 12.7z"/>
|
||
</svg>
|
||
</div>
|
||
)}
|
||
<div className="text-left">
|
||
<p className="font-semibold text-slate-900 dark:text-slate-100">Gmail</p>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400">Connect Google account</p>
|
||
</div>
|
||
</button>
|
||
|
||
<button
|
||
onClick={() => handleConnectAccount('outlook')}
|
||
disabled={connectingProvider === 'outlook'}
|
||
className="flex items-center gap-4 p-4 border-2 border-slate-200 dark:border-slate-700 rounded-xl hover:border-blue-300 dark:hover:border-blue-700 hover:bg-blue-50 dark:hover:bg-blue-900/30 transition-all disabled:opacity-50"
|
||
>
|
||
{connectingProvider === 'outlook' ? (
|
||
<Loader2 className="w-8 h-8 animate-spin text-blue-500" />
|
||
) : (
|
||
<div className="w-12 h-12 rounded-lg bg-blue-100 flex items-center justify-center">
|
||
<svg className="w-6 h-6" viewBox="0 0 24 24">
|
||
<path fill="#0078D4" d="M2 6.5v11c0 .83.67 1.5 1.5 1.5h9c.83 0 1.5-.67 1.5-1.5v-11c0-.83-.67-1.5-1.5-1.5h-9C2.67 5 2 5.67 2 6.5z"/>
|
||
<path fill="#0078D4" d="M14 6v12l8-6z"/>
|
||
</svg>
|
||
</div>
|
||
)}
|
||
<div className="text-left">
|
||
<p className="font-semibold text-slate-900 dark:text-slate-100">Outlook</p>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400">Connect Microsoft account</p>
|
||
</div>
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowImapForm(!showImapForm)}
|
||
className="flex items-center gap-4 p-4 border-2 border-slate-200 dark:border-slate-700 rounded-xl hover:border-slate-300 dark:hover:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-all"
|
||
>
|
||
<div className="w-12 h-12 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
|
||
<Mail className="w-6 h-6 text-slate-600 dark:text-slate-400" />
|
||
</div>
|
||
<div className="text-left">
|
||
<p className="font-semibold text-slate-900 dark:text-slate-100">IMAP / Other</p>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400">Porkbun, Nextcloud Mail, or any IMAP</p>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
|
||
{showImapForm && (
|
||
<form onSubmit={handleConnectImap} className="mt-6 p-4 border border-slate-200 dark:border-slate-700 rounded-xl bg-slate-50 dark:bg-slate-800/50 space-y-4">
|
||
<div>
|
||
<label htmlFor="imap-email" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Email</label>
|
||
<Input
|
||
id="imap-email"
|
||
type="email"
|
||
placeholder="you@example.com"
|
||
value={imapForm.email}
|
||
onChange={(e) => setImapForm((f) => ({ ...f, email: e.target.value }))}
|
||
required
|
||
autoComplete="email"
|
||
className="bg-white dark:bg-slate-900"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label htmlFor="imap-password" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Password / App password</label>
|
||
<Input
|
||
id="imap-password"
|
||
type="password"
|
||
placeholder="••••••••"
|
||
value={imapForm.password}
|
||
onChange={(e) => setImapForm((f) => ({ ...f, password: e.target.value }))}
|
||
required
|
||
autoComplete="current-password"
|
||
className="bg-white dark:bg-slate-900"
|
||
/>
|
||
</div>
|
||
<details className="text-sm">
|
||
<summary className="cursor-pointer text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200">Advanced (host, port, SSL)</summary>
|
||
<div className="mt-3 grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||
<div>
|
||
<label htmlFor="imap-host" className="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">IMAP host</label>
|
||
<Input
|
||
id="imap-host"
|
||
type="text"
|
||
placeholder="imap.porkbun.com"
|
||
value={imapForm.imapHost}
|
||
onChange={(e) => setImapForm((f) => ({ ...f, imapHost: e.target.value }))}
|
||
className="bg-white dark:bg-slate-900 text-sm"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label htmlFor="imap-port" className="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">Port</label>
|
||
<Input
|
||
id="imap-port"
|
||
type="number"
|
||
min={1}
|
||
max={65535}
|
||
value={imapForm.imapPort}
|
||
onChange={(e) => setImapForm((f) => ({ ...f, imapPort: Number(e.target.value) || 993 }))}
|
||
className="bg-white dark:bg-slate-900 text-sm"
|
||
/>
|
||
</div>
|
||
<div className="flex items-end gap-2 pb-2">
|
||
<label className="flex items-center gap-2 cursor-pointer text-slate-600 dark:text-slate-400">
|
||
<input
|
||
type="checkbox"
|
||
checked={imapForm.imapSecure}
|
||
onChange={(e) => setImapForm((f) => ({ ...f, imapSecure: e.target.checked }))}
|
||
className="rounded border-slate-300 dark:border-slate-600"
|
||
/>
|
||
Use SSL
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
<div className="flex gap-2">
|
||
<Button type="submit" disabled={imapConnecting}>
|
||
{imapConnecting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : null}
|
||
Connect IMAP
|
||
</Button>
|
||
<Button type="button" variant="outline" onClick={() => { setShowImapForm(false); setImapForm({ email: '', password: '', imapHost: 'imap.porkbun.com', imapPort: 993, imapSecure: true }); }}>
|
||
Cancel
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'vip' && (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<Shield className="w-5 h-5 text-amber-500 dark:text-amber-400" />
|
||
VIP List
|
||
</CardTitle>
|
||
<CardDescription>Emails from these senders will always be marked as important</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-6">
|
||
<div className="flex gap-2">
|
||
<Input
|
||
placeholder="email@example.com"
|
||
value={newVipEmail}
|
||
onChange={(e) => setNewVipEmail(e.target.value)}
|
||
onKeyDown={(e) => e.key === 'Enter' && handleAddVip()}
|
||
/>
|
||
<Button onClick={handleAddVip}>
|
||
<Plus className="w-4 h-4 mr-2" />
|
||
Add
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
{vipSenders.length > 0 ? (
|
||
vipSenders.map((vip) => (
|
||
<div key={vip.email} className="flex items-center justify-between p-3 bg-amber-50 dark:bg-amber-900/30 border border-amber-100 dark:border-amber-800 rounded-lg">
|
||
<div className="flex items-center gap-3">
|
||
<Star className="w-5 h-5 text-amber-500 dark:text-amber-400" />
|
||
<span className="text-slate-700 dark:text-slate-300">{vip.email}</span>
|
||
</div>
|
||
<Button variant="ghost" size="icon" onClick={() => handleRemoveVip(vip.email)}>
|
||
<X className="w-4 h-4 text-slate-400 dark:text-slate-500 hover:text-red-500 dark:hover:text-red-400" />
|
||
</Button>
|
||
</div>
|
||
))
|
||
) : (
|
||
<p className="text-center text-slate-500 dark:text-slate-400 py-8">No VIP senders added yet</p>
|
||
)}
|
||
</div>
|
||
|
||
{vipSenders.length > 0 && (
|
||
<Button onClick={handleSaveVips} disabled={saving}>
|
||
{saving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
|
||
Save changes
|
||
</Button>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{activeTab === 'ai-control' && (
|
||
<div className="space-y-6 w-full">
|
||
{/* Header */}
|
||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||
<div>
|
||
<h2 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Control Panel</h2>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||
Configure how AI processes your emails and manages your inbox
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="secondary"
|
||
onClick={handleDiscardChanges}
|
||
disabled={!hasUnsavedChanges || saving}
|
||
size="sm"
|
||
className="flex-1 sm:flex-initial"
|
||
>
|
||
<RotateCcw className="w-4 h-4 mr-2" />
|
||
Reset
|
||
</Button>
|
||
<Button
|
||
onClick={handleSaveControlPanel}
|
||
disabled={!hasUnsavedChanges || saving}
|
||
size="sm"
|
||
className="flex-1 sm:flex-initial"
|
||
>
|
||
{saving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Check className="w-4 h-4 mr-2" />}
|
||
Save
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Sticky Save Bar */}
|
||
{hasUnsavedChanges && (
|
||
<div className="sticky top-0 z-50 bg-primary-600 dark:bg-primary-700 text-white px-4 py-3 rounded-lg flex items-center justify-between shadow-lg">
|
||
<span className="font-medium">Unsaved changes</span>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="secondary"
|
||
onClick={handleDiscardChanges}
|
||
disabled={saving}
|
||
size="sm"
|
||
className="bg-white/20 hover:bg-white/30 text-white border-0"
|
||
>
|
||
Discard
|
||
</Button>
|
||
<Button
|
||
onClick={handleSaveControlPanel}
|
||
disabled={saving}
|
||
size="sm"
|
||
className="bg-white text-primary-600 hover:bg-white/90"
|
||
>
|
||
{saving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Check className="w-4 h-4 mr-2" />}
|
||
Save
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Search/Filter Bar */}
|
||
<Card>
|
||
<CardContent className="py-4">
|
||
<div className="flex items-center gap-4">
|
||
<div className="flex-1 relative">
|
||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400 dark:text-slate-500" />
|
||
<Input
|
||
placeholder="Search categories or labels..."
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
className="pl-10"
|
||
/>
|
||
</div>
|
||
<select
|
||
value={filterEnabled}
|
||
onChange={(e) => setFilterEnabled((e.target.value || 'all') as 'all' | 'enabled' | 'disabled')}
|
||
className="px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"
|
||
>
|
||
<option value="all">All</option>
|
||
<option value="enabled">Enabled</option>
|
||
<option value="disabled">Disabled</option>
|
||
</select>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Tabs */}
|
||
<Tabs value={controlPanelTab} onValueChange={(v) => setControlPanelTab((v || 'rules') as 'rules' | 'cleanup' | 'labels')} className="w-full">
|
||
<TabsList className="grid w-full grid-cols-3">
|
||
<TabsTrigger value="rules" className="text-sm sm:text-base">Rules</TabsTrigger>
|
||
<TabsTrigger value="cleanup" className="text-sm sm:text-base">Cleanup</TabsTrigger>
|
||
<TabsTrigger value="labels" className="text-sm sm:text-base">Labels</TabsTrigger>
|
||
</TabsList>
|
||
|
||
{/* Rules Tab */}
|
||
<TabsContent value="rules" className="space-y-4 mt-4">
|
||
{/* Bulk Actions */}
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">Category Rules</h3>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">Configure how AI handles different email types</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => {
|
||
const allEnabled = categories.map(c => c.key)
|
||
setAiControlSettings({ ...aiControlSettings, enabledCategories: allEnabled })
|
||
setCategories(categories.map(c => ({ ...c, enabled: true })))
|
||
}}
|
||
>
|
||
Enable All
|
||
</Button>
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => {
|
||
setAiControlSettings({ ...aiControlSettings, enabledCategories: [] })
|
||
setCategories(categories.map(c => ({ ...c, enabled: false })))
|
||
}}
|
||
>
|
||
Disable All
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Category Cards Grid */}
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
{filteredCategories.map((category) => {
|
||
const action = aiControlSettings.categoryActions[category.key] || category.defaultAction
|
||
const actionLabels: Record<string, string> = {
|
||
inbox: 'Keep in Inbox',
|
||
archive_read: 'Archive & Read',
|
||
star: 'Star'
|
||
}
|
||
|
||
return (
|
||
<Card
|
||
key={category.key}
|
||
className="cursor-pointer hover:shadow-md dark:hover:shadow-slate-900/30 transition-all"
|
||
onClick={() => {
|
||
setSelectedCategory(category)
|
||
setShowCategoryPanel(true)
|
||
}}
|
||
>
|
||
<CardContent className="p-5">
|
||
<div className="flex items-start justify-between mb-3">
|
||
<div className="flex items-center gap-3">
|
||
<div
|
||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||
style={{ backgroundColor: category.color }}
|
||
/>
|
||
<div className="flex-1 min-w-0">
|
||
<h4 className="font-semibold text-slate-900 dark:text-slate-100 truncate">
|
||
{category.name}
|
||
</h4>
|
||
</div>
|
||
</div>
|
||
<Badge
|
||
variant={category.enabled ? 'default' : 'secondary'}
|
||
className="ml-2 flex-shrink-0"
|
||
>
|
||
{category.enabled ? 'Active' : 'Inactive'}
|
||
</Badge>
|
||
</div>
|
||
|
||
<p className="text-sm text-slate-600 dark:text-slate-400 mb-4 line-clamp-2">
|
||
{category.description}
|
||
</p>
|
||
|
||
{category.enabled && (
|
||
<div className="mt-4 pt-4 border-t border-slate-200 dark:border-slate-700">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs text-slate-500 dark:text-slate-400">Action</span>
|
||
<Badge variant="outline" className="text-xs">
|
||
{actionLabels[action]}
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{category.enabled && !aiControlSettings.categoryActions[category.key] && (
|
||
<div className="mt-3 text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1">
|
||
<AlertTriangle className="w-3 h-3" />
|
||
Action required
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
})}
|
||
</div>
|
||
</TabsContent>
|
||
|
||
{/* Cleanup Tab */}
|
||
<TabsContent value="cleanup" className="space-y-6 mt-4">
|
||
{/* Auto Cleanup Read Emails - Large Toggle Card */}
|
||
<Card className="border-slate-200 dark:border-slate-700">
|
||
<CardContent className="p-6">
|
||
<div className="flex items-start justify-between mb-6">
|
||
<div className="flex-1">
|
||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">
|
||
Auto cleanup read emails
|
||
</h3>
|
||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||
Automatically clean up emails that have been read after a grace period
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => {
|
||
if (!aiControlSettings.cleanup) return
|
||
setAiControlSettings({
|
||
...aiControlSettings,
|
||
cleanup: {
|
||
...aiControlSettings.cleanup,
|
||
readItems: {
|
||
...aiControlSettings.cleanup.readItems,
|
||
enabled: !aiControlSettings.cleanup.readItems.enabled,
|
||
},
|
||
},
|
||
})
|
||
}}
|
||
className={`w-12 h-6 rounded-full transition-colors flex-shrink-0 ${aiControlSettings.cleanup?.readItems.enabled ? 'bg-primary-500 dark:bg-primary-600' : 'bg-slate-300 dark:bg-slate-600'}`}
|
||
>
|
||
<div className={`w-5 h-5 bg-white dark:bg-slate-200 rounded-full transform transition-transform mx-0.5 ${
|
||
aiControlSettings.cleanup?.readItems.enabled ? 'translate-x-6' : 'translate-x-0'
|
||
}`} />
|
||
</button>
|
||
</div>
|
||
|
||
{aiControlSettings.cleanup?.readItems.enabled && (
|
||
<div className="space-y-6 pt-6 border-t border-slate-200 dark:border-slate-700">
|
||
{/* Action Selection */}
|
||
<div>
|
||
<Label htmlFor="read-items-action" className="text-sm font-medium mb-3 block">
|
||
Action
|
||
</Label>
|
||
<select
|
||
id="read-items-action"
|
||
value={aiControlSettings.cleanup.readItems.action}
|
||
onChange={(e) => {
|
||
if (!aiControlSettings.cleanup) return
|
||
setAiControlSettings({
|
||
...aiControlSettings,
|
||
cleanup: {
|
||
...aiControlSettings.cleanup,
|
||
readItems: {
|
||
...aiControlSettings.cleanup.readItems,
|
||
action: e.target.value as 'archive_read' | 'trash',
|
||
},
|
||
},
|
||
})
|
||
}}
|
||
className="w-full px-4 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||
>
|
||
<option value="archive_read">Archive & Mark Read</option>
|
||
<option value="trash">Move to Trash</option>
|
||
</select>
|
||
</div>
|
||
|
||
{/* Slider for Days */}
|
||
<div>
|
||
<div className="flex items-center justify-between mb-3">
|
||
<Label className="text-sm font-medium">Grace Period</Label>
|
||
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||
{aiControlSettings.cleanup.readItems.gracePeriodDays} {aiControlSettings.cleanup.readItems.gracePeriodDays === 1 ? 'day' : 'days'}
|
||
</span>
|
||
</div>
|
||
<Slider
|
||
value={aiControlSettings.cleanup.readItems.gracePeriodDays}
|
||
onValueChange={(value) => {
|
||
if (!aiControlSettings.cleanup) return
|
||
setAiControlSettings({
|
||
...aiControlSettings,
|
||
cleanup: {
|
||
...aiControlSettings.cleanup,
|
||
readItems: {
|
||
...aiControlSettings.cleanup.readItems,
|
||
gracePeriodDays: value,
|
||
},
|
||
},
|
||
})
|
||
}}
|
||
min={0}
|
||
max={90}
|
||
step={1}
|
||
/>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-2">
|
||
Emails will be cleaned up after this many days
|
||
</p>
|
||
</div>
|
||
|
||
{/* Warning Banner */}
|
||
{aiControlSettings.cleanup.readItems.action === 'trash' && (
|
||
<div className="flex items-start gap-3 p-4 bg-amber-50/50 dark:bg-amber-900/10 border border-amber-200/50 dark:border-amber-800/50 rounded-lg">
|
||
<AlertTriangle className="w-5 h-5 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
|
||
<div>
|
||
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">Delete action selected</p>
|
||
<p className="text-xs text-amber-700 dark:text-amber-300 mt-1">
|
||
Emails moved to trash can be permanently deleted. Use with caution.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Delete Promotions After X Days - Large Toggle Card */}
|
||
<Card className="border-slate-200 dark:border-slate-700">
|
||
<CardContent className="p-6">
|
||
<div className="flex items-start justify-between mb-6">
|
||
<div className="flex-1">
|
||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">
|
||
Delete promotions after X days
|
||
</h3>
|
||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||
Automatically clean up promotional emails and newsletters after a set period
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => {
|
||
if (!aiControlSettings.cleanup) return
|
||
setAiControlSettings({
|
||
...aiControlSettings,
|
||
cleanup: {
|
||
...aiControlSettings.cleanup,
|
||
promotions: {
|
||
...aiControlSettings.cleanup.promotions,
|
||
enabled: !aiControlSettings.cleanup.promotions.enabled,
|
||
},
|
||
},
|
||
})
|
||
}}
|
||
className={`w-12 h-6 rounded-full transition-colors flex-shrink-0 ${aiControlSettings.cleanup?.promotions?.enabled ? 'bg-primary-500 dark:bg-primary-600' : 'bg-slate-300 dark:bg-slate-600'}`}
|
||
>
|
||
<div className={`w-5 h-5 bg-white dark:bg-slate-200 rounded-full transform transition-transform mx-0.5 ${
|
||
aiControlSettings.cleanup?.promotions.enabled ? 'translate-x-6' : 'translate-x-0'
|
||
}`} />
|
||
</button>
|
||
</div>
|
||
|
||
{aiControlSettings.cleanup?.promotions.enabled && (
|
||
<div className="space-y-6 pt-6 border-t border-slate-200 dark:border-slate-700">
|
||
{/* Action Selection */}
|
||
<div>
|
||
<Label htmlFor="promotions-action" className="text-sm font-medium mb-3 block">
|
||
Action
|
||
</Label>
|
||
<select
|
||
id="promotions-action"
|
||
value={aiControlSettings.cleanup.promotions.action}
|
||
onChange={(e) => {
|
||
if (!aiControlSettings.cleanup) return
|
||
setAiControlSettings({
|
||
...aiControlSettings,
|
||
cleanup: {
|
||
...aiControlSettings.cleanup,
|
||
promotions: {
|
||
...aiControlSettings.cleanup.promotions,
|
||
action: e.target.value as 'archive_read' | 'trash',
|
||
},
|
||
},
|
||
})
|
||
}}
|
||
className="w-full px-4 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||
>
|
||
<option value="archive_read">Archive & Mark Read</option>
|
||
<option value="trash">Move to Trash</option>
|
||
</select>
|
||
</div>
|
||
|
||
{/* Preset Buttons */}
|
||
<div>
|
||
<Label className="text-sm font-medium mb-3 block">Delete/Archive After</Label>
|
||
<div className="flex items-center gap-3 mb-4">
|
||
{[7, 14, 30].map((days) => (
|
||
<Button
|
||
key={days}
|
||
variant={aiControlSettings.cleanup?.promotions.deleteAfterDays === days ? 'default' : 'outline'}
|
||
size="sm"
|
||
onClick={() => {
|
||
if (!aiControlSettings.cleanup) return
|
||
setAiControlSettings({
|
||
...aiControlSettings,
|
||
cleanup: {
|
||
...aiControlSettings.cleanup,
|
||
promotions: {
|
||
...aiControlSettings.cleanup.promotions,
|
||
deleteAfterDays: days,
|
||
},
|
||
},
|
||
})
|
||
}}
|
||
className="flex-1"
|
||
>
|
||
{days} days
|
||
</Button>
|
||
))}
|
||
</div>
|
||
<div className="flex items-center justify-between">
|
||
<Slider
|
||
value={aiControlSettings.cleanup.promotions.deleteAfterDays}
|
||
onValueChange={(value) => {
|
||
if (!aiControlSettings.cleanup) return
|
||
setAiControlSettings({
|
||
...aiControlSettings,
|
||
cleanup: {
|
||
...aiControlSettings.cleanup,
|
||
promotions: {
|
||
...aiControlSettings.cleanup.promotions,
|
||
deleteAfterDays: value,
|
||
},
|
||
},
|
||
})
|
||
}}
|
||
min={1}
|
||
max={90}
|
||
step={1}
|
||
className="flex-1 mr-4"
|
||
/>
|
||
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100 min-w-[60px] text-right">
|
||
{aiControlSettings.cleanup.promotions.deleteAfterDays} {aiControlSettings.cleanup.promotions.deleteAfterDays === 1 ? 'day' : 'days'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Match Categories */}
|
||
<div>
|
||
<Label className="text-sm font-medium mb-3 block">Match Categories</Label>
|
||
<div className="flex flex-wrap gap-3">
|
||
{categories.filter(c => ['promotions', 'newsletters', 'social'].includes(c.key)).map((category) => (
|
||
<button
|
||
key={category.key}
|
||
onClick={() => {
|
||
if (!aiControlSettings.cleanup) return
|
||
const current = aiControlSettings.cleanup.promotions.matchCategoriesOrLabels
|
||
const updated = current.includes(category.key)
|
||
? current.filter(c => c !== category.key)
|
||
: [...current, category.key]
|
||
setAiControlSettings({
|
||
...aiControlSettings,
|
||
cleanup: {
|
||
...aiControlSettings.cleanup,
|
||
promotions: {
|
||
...aiControlSettings.cleanup.promotions,
|
||
matchCategoriesOrLabels: updated,
|
||
},
|
||
},
|
||
})
|
||
}}
|
||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors flex items-center gap-2 ${
|
||
aiControlSettings.cleanup?.promotions?.matchCategoriesOrLabels?.includes(category.key)
|
||
? 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 border border-primary-300 dark:border-primary-700'
|
||
: 'bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-300 dark:border-slate-600 hover:bg-slate-200 dark:hover:bg-slate-700'
|
||
}`}
|
||
>
|
||
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: category.color }} />
|
||
{category.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Warning Banner */}
|
||
{aiControlSettings.cleanup.promotions.action === 'trash' && (
|
||
<div className="flex items-start gap-3 p-4 bg-amber-50/50 dark:bg-amber-900/10 border border-amber-200/50 dark:border-amber-800/50 rounded-lg">
|
||
<AlertTriangle className="w-5 h-5 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
|
||
<div>
|
||
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">Delete action selected</p>
|
||
<p className="text-xs text-amber-700 dark:text-amber-300 mt-1">
|
||
Promotional emails will be permanently deleted. Use with caution.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Preview Section */}
|
||
{((cleanupPreview && cleanupPreview.length > 0) || cleanupStatus) && (
|
||
<Card className="border-slate-200 dark:border-slate-700">
|
||
<CardContent className="p-6">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<div>
|
||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-1 flex items-center gap-2">
|
||
<Eye className="w-5 h-5 text-primary-500 dark:text-primary-400" />
|
||
Preview
|
||
</h3>
|
||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||
{(cleanupPreview && cleanupPreview.length > 0)
|
||
? `${cleanupPreview.length} emails affected in the last 7 days`
|
||
: cleanupStatus?.lastRun
|
||
? `Last run: ${new Date(cleanupStatus.lastRun).toLocaleDateString()}`
|
||
: 'No preview data available'}
|
||
</p>
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={loadCleanupPreview}
|
||
disabled={runningCleanup}
|
||
>
|
||
{runningCleanup ? <Loader2 className="w-4 h-4 animate-spin" /> : <Eye className="w-4 h-4" />}
|
||
Refresh
|
||
</Button>
|
||
</div>
|
||
{cleanupPreview && cleanupPreview.length > 0 && (
|
||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||
{cleanupPreview.slice(0, 10).map((item) => (
|
||
<div key={item.id} className="p-3 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50">
|
||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{item.subject}</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">{item.from} • {new Date(item.date).toLocaleDateString()}</p>
|
||
<Badge variant="secondary" className="mt-2 text-xs">{item.reason}</Badge>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Run Cleanup Now */}
|
||
<Card className="border-slate-200 dark:border-slate-700">
|
||
<CardContent className="p-6">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-1">Run Cleanup Now</h3>
|
||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||
Manually trigger cleanup for your account
|
||
</p>
|
||
{cleanupStatus?.lastRun && (
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-2">
|
||
Last run: {new Date(cleanupStatus.lastRun).toLocaleString()}
|
||
{cleanupStatus.lastRunCounts && (
|
||
<span className="ml-2">
|
||
({cleanupStatus.lastRunCounts.readItems + cleanupStatus.lastRunCounts.promotions} emails processed)
|
||
</span>
|
||
)}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<Button
|
||
onClick={handleRunCleanup}
|
||
disabled={runningCleanup}
|
||
variant="secondary"
|
||
size="lg"
|
||
>
|
||
{runningCleanup ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Play className="w-4 h-4 mr-2" />}
|
||
Run Now
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
|
||
{/* Labels Tab */}
|
||
<TabsContent value="labels" className="space-y-6 mt-4">
|
||
{/* Header with Actions */}
|
||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||
<div>
|
||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">Company Labels</h3>
|
||
<p className="text-sm text-slate-600 dark:text-slate-400 mt-1">Automatically label emails from specific companies</p>
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<select
|
||
value={labelSort}
|
||
onChange={(e) => setLabelSort((e.target.value || 'name') as 'name' | 'newest')}
|
||
className="px-3 py-2 text-sm border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"
|
||
>
|
||
<option value="name">Sort: Name</option>
|
||
<option value="newest">Sort: Newest</option>
|
||
</select>
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => {
|
||
const dataStr = JSON.stringify(companyLabels, null, 2)
|
||
const dataBlob = new Blob([dataStr], { type: 'application/json' })
|
||
const url = URL.createObjectURL(dataBlob)
|
||
const link = document.createElement('a')
|
||
link.href = url
|
||
link.download = 'company-labels.json'
|
||
link.click()
|
||
URL.revokeObjectURL(url)
|
||
}}
|
||
>
|
||
<Download className="w-4 h-4 mr-2" />
|
||
Export
|
||
</Button>
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => {
|
||
setLabelImportErrors([])
|
||
const input = document.createElement('input')
|
||
input.type = 'file'
|
||
input.accept = 'application/json'
|
||
input.onchange = async (e) => {
|
||
const file = (e.target as HTMLInputElement).files?.[0]
|
||
if (!file) return
|
||
try {
|
||
const text = await file.text()
|
||
const imported = JSON.parse(text)
|
||
const { labels, errors } = validateLabelImport(imported, companyLabels)
|
||
if (errors.length > 0) {
|
||
setLabelImportErrors(errors)
|
||
showMessage('error', 'Fix import errors before saving')
|
||
return
|
||
}
|
||
setLabelImportErrors([])
|
||
setCompanyLabels([...companyLabels, ...labels])
|
||
showMessage('success', `Imported ${labels.length} labels`)
|
||
} catch {
|
||
showMessage('error', 'Invalid JSON file')
|
||
setLabelImportErrors([])
|
||
}
|
||
}
|
||
input.click()
|
||
}}
|
||
>
|
||
<Upload className="w-4 h-4 mr-2" />
|
||
Import
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
onClick={() => {
|
||
setEditingLabel({ id: '', name: '', condition: '', category: 'promotions', enabled: true })
|
||
setShowLabelPanel(true)
|
||
}}
|
||
>
|
||
<Plus className="w-4 h-4 mr-2" />
|
||
Add Label
|
||
</Button>
|
||
</div>
|
||
{labelImportErrors.length > 0 && (
|
||
<div className="mt-3 w-full rounded-md border border-red-200 dark:border-red-900 bg-red-50 dark:bg-red-950/40 p-3 text-sm text-red-800 dark:text-red-200">
|
||
<p className="font-medium mb-1">Import issues</p>
|
||
<ul className="list-disc pl-5 space-y-0.5">
|
||
{labelImportErrors.map((err, i) => (
|
||
<li key={i}>{err}</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Auto-Detection Toggle */}
|
||
<Card className="border-slate-200 dark:border-slate-700">
|
||
<CardContent className="p-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="font-medium text-slate-900 dark:text-slate-100">Auto-Detect Known Companies</p>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">Automatically detect and label emails from Amazon, Google, Microsoft, etc.</p>
|
||
</div>
|
||
<button
|
||
onClick={() => {
|
||
const newValue = !aiControlSettings.autoDetectCompanies
|
||
setAiControlSettings({ ...aiControlSettings, autoDetectCompanies: newValue })
|
||
}}
|
||
className={`w-12 h-6 rounded-full transition-colors ${aiControlSettings.autoDetectCompanies ? 'bg-primary-500 dark:bg-primary-600' : 'bg-slate-300 dark:bg-slate-600'}`}
|
||
>
|
||
<div className={`w-5 h-5 bg-white dark:bg-slate-200 rounded-full transform transition-transform mx-0.5 ${
|
||
aiControlSettings.autoDetectCompanies ? 'translate-x-6' : 'translate-x-0'
|
||
}`} />
|
||
</button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Labels Table */}
|
||
<Card className="border-slate-200 dark:border-slate-700">
|
||
<CardContent className="p-0">
|
||
{filteredLabels.length > 0 ? (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full">
|
||
<thead>
|
||
<tr className="border-b border-slate-200 dark:border-slate-700">
|
||
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">Name</th>
|
||
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider hidden sm:table-cell">Status</th>
|
||
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider hidden md:table-cell">Category</th>
|
||
<th className="px-4 sm:px-6 py-3 text-right text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-200 dark:divide-slate-700">
|
||
{filteredLabels.map((label) => (
|
||
<tr
|
||
key={label.id}
|
||
className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors"
|
||
>
|
||
<td className="px-4 sm:px-6 py-4">
|
||
<div>
|
||
<p className="font-medium text-slate-900 dark:text-slate-100">{label.name}</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 font-mono mt-1 break-all">{label.condition}</p>
|
||
<div className="flex items-center gap-2 mt-2 sm:hidden">
|
||
<Badge variant={label.enabled ? 'default' : 'secondary'} className="text-xs">
|
||
{label.enabled ? 'Enabled' : 'Disabled'}
|
||
</Badge>
|
||
{label.category && (
|
||
<Badge variant="outline" className="text-xs capitalize">{label.category}</Badge>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td className="px-4 sm:px-6 py-4 hidden sm:table-cell">
|
||
<Badge variant={label.enabled ? 'default' : 'secondary'}>
|
||
{label.enabled ? 'Enabled' : 'Disabled'}
|
||
</Badge>
|
||
</td>
|
||
<td className="px-4 sm:px-6 py-4 hidden md:table-cell">
|
||
{label.category && (
|
||
<Badge variant="outline" className="text-xs capitalize">{label.category}</Badge>
|
||
)}
|
||
</td>
|
||
<td className="px-4 sm:px-6 py-4">
|
||
<div className="flex items-center justify-end gap-1 sm:gap-2">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => {
|
||
setEditingLabel(label)
|
||
setShowLabelPanel(true)
|
||
}}
|
||
className="hidden sm:inline-flex"
|
||
>
|
||
<Edit2 className="w-4 h-4 mr-1" />
|
||
Edit
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={() => {
|
||
setEditingLabel(label)
|
||
setShowLabelPanel(true)
|
||
}}
|
||
className="sm:hidden"
|
||
>
|
||
<Edit2 className="w-4 h-4" />
|
||
</Button>
|
||
<button
|
||
onClick={async () => {
|
||
if (!user?.$id || !label.id) return
|
||
try {
|
||
await api.saveCompanyLabel({ ...label, enabled: !label.enabled })
|
||
setCompanyLabels(companyLabels.map(l => l.id === label.id ? { ...l, enabled: !l.enabled } : l))
|
||
showMessage('success', 'Label updated!')
|
||
} catch {
|
||
showMessage('error', 'Failed to update label')
|
||
}
|
||
}}
|
||
className={`w-10 h-6 rounded-full transition-colors ${label.enabled ? 'bg-primary-500 dark:bg-primary-600' : 'bg-slate-300 dark:bg-slate-600'}`}
|
||
title={label.enabled ? 'Disable' : 'Enable'}
|
||
>
|
||
<div className={`w-4 h-4 bg-white dark:bg-slate-200 rounded-full transform transition-transform mx-1 ${
|
||
label.enabled ? 'translate-x-4' : 'translate-x-0'
|
||
}`} />
|
||
</button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={async () => {
|
||
if (!user?.$id || !label.id) return
|
||
if (!confirm('Are you sure you want to delete this label?')) return
|
||
try {
|
||
await api.deleteCompanyLabel(label.id)
|
||
setCompanyLabels(companyLabels.filter(l => l.id !== label.id))
|
||
showMessage('success', 'Label deleted!')
|
||
} catch {
|
||
showMessage('error', 'Failed to delete label')
|
||
}
|
||
}}
|
||
>
|
||
<Trash2 className="w-4 h-4 text-red-500 dark:text-red-400" />
|
||
</Button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
) : (
|
||
<div className="text-center py-12 text-slate-500 dark:text-slate-400">
|
||
<Building2 className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||
<p className="font-medium">{searchQuery ? 'No labels match your search' : 'No labels yet'}</p>
|
||
<p className="text-sm mt-1">{searchQuery ? 'Try adjusting your search query' : 'Create your first company label to get started'}</p>
|
||
{!searchQuery && (
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
className="mt-4"
|
||
onClick={() => {
|
||
setEditingLabel({ id: '', name: '', condition: '', category: 'promotions', enabled: true })
|
||
setShowLabelPanel(true)
|
||
}}
|
||
>
|
||
<Plus className="w-4 h-4 mr-2" />
|
||
Add Label
|
||
</Button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
</Tabs>
|
||
|
||
{/* Category Details Side Panel */}
|
||
<SidePanel open={showCategoryPanel} onOpenChange={setShowCategoryPanel}>
|
||
<SidePanelContent>
|
||
<SidePanelHeader>
|
||
<SidePanelCloseButton />
|
||
{selectedCategory && (
|
||
<>
|
||
<div className="flex items-center gap-3 mb-2">
|
||
<div
|
||
className="w-4 h-4 rounded-full"
|
||
style={{ backgroundColor: selectedCategory.color }}
|
||
/>
|
||
<SidePanelTitle>{selectedCategory.name}</SidePanelTitle>
|
||
</div>
|
||
<SidePanelDescription>{selectedCategory.description}</SidePanelDescription>
|
||
</>
|
||
)}
|
||
</SidePanelHeader>
|
||
|
||
{selectedCategory && (
|
||
<SidePanelBody>
|
||
<div className="space-y-6">
|
||
{/* Enable/Disable Toggle */}
|
||
<div className="flex items-center justify-between p-4 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50">
|
||
<div>
|
||
<Label className="text-base font-medium">Enable Category</Label>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||
Process emails in this category
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => {
|
||
const newEnabled = selectedCategory.enabled
|
||
? aiControlSettings.enabledCategories.filter(c => c !== selectedCategory.key)
|
||
: [...aiControlSettings.enabledCategories, selectedCategory.key]
|
||
setAiControlSettings({ ...aiControlSettings, enabledCategories: newEnabled })
|
||
setCategories(categories.map(c => c.key === selectedCategory.key ? { ...c, enabled: !c.enabled } : c))
|
||
setSelectedCategory({ ...selectedCategory, enabled: !selectedCategory.enabled })
|
||
}}
|
||
className={`w-10 h-6 rounded-full transition-colors ${selectedCategory.enabled ? 'bg-primary-500 dark:bg-primary-600' : 'bg-slate-300 dark:bg-slate-600'}`}
|
||
>
|
||
<div className={`w-4 h-4 bg-white dark:bg-slate-200 rounded-full transform transition-transform mx-1 ${
|
||
selectedCategory.enabled ? 'translate-x-4' : 'translate-x-0'
|
||
}`} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Action Selection */}
|
||
{selectedCategory.enabled && (
|
||
<div>
|
||
<Label htmlFor="category-action" className="text-base font-medium mb-2 block">
|
||
What should happen with these emails?
|
||
</Label>
|
||
<select
|
||
id="category-action"
|
||
value={aiControlSettings.categoryActions[selectedCategory.key] || selectedCategory.defaultAction}
|
||
onChange={(e) => {
|
||
const newActions = { ...aiControlSettings.categoryActions, [selectedCategory.key]: (e.target.value || 'inbox') as 'inbox' | 'archive_read' | 'star' }
|
||
setAiControlSettings({ ...aiControlSettings, categoryActions: newActions })
|
||
}}
|
||
className="w-full px-4 py-3 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||
>
|
||
<option value="inbox">Keep in Inbox</option>
|
||
<option value="archive_read">Archive & Mark Read</option>
|
||
<option value="star">Star</option>
|
||
</select>
|
||
{!aiControlSettings.categoryActions[selectedCategory.key] && (
|
||
<div className="mt-2 text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1">
|
||
<AlertTriangle className="w-3 h-3" />
|
||
Please select an action
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Advanced Options */}
|
||
{selectedCategory.enabled && (
|
||
<div>
|
||
<button
|
||
onClick={() => {
|
||
const newSet = new Set(expandedCategories)
|
||
if (expandedCategories.has(selectedCategory.key)) {
|
||
newSet.delete(selectedCategory.key)
|
||
} else {
|
||
newSet.add(selectedCategory.key)
|
||
}
|
||
setExpandedCategories(newSet)
|
||
}}
|
||
className="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300 hover:text-slate-900 dark:hover:text-slate-100 mb-3"
|
||
>
|
||
{expandedCategories.has(selectedCategory.key) ? <ChevronUp className="w-4 h-4 text-slate-600 dark:text-slate-400" /> : <ChevronDown className="w-4 h-4 text-slate-600 dark:text-slate-400" />}
|
||
Advanced Options
|
||
</button>
|
||
|
||
{expandedCategories.has(selectedCategory.key) && (
|
||
<div className="mt-3 space-y-4 p-4 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50">
|
||
<div>
|
||
<Label htmlFor="category-priority">Priority</Label>
|
||
<select
|
||
id="category-priority"
|
||
value={aiControlSettings.categoryAdvanced?.[selectedCategory.key]?.priority || 'medium'}
|
||
onChange={(e) => {
|
||
const newAdvanced = { ...aiControlSettings.categoryAdvanced }
|
||
if (!newAdvanced[selectedCategory.key]) newAdvanced[selectedCategory.key] = {}
|
||
newAdvanced[selectedCategory.key] = { ...newAdvanced[selectedCategory.key], priority: (e.target.value || 'medium') as 'low' | 'medium' | 'high' }
|
||
setAiControlSettings({ ...aiControlSettings, categoryAdvanced: newAdvanced })
|
||
}}
|
||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 mt-1"
|
||
>
|
||
<option value="low">Low</option>
|
||
<option value="medium">Medium</option>
|
||
<option value="high">High</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<Label htmlFor="category-exclude-keywords">Exclude Keywords</Label>
|
||
<Input
|
||
id="category-exclude-keywords"
|
||
value={(aiControlSettings.categoryAdvanced?.[selectedCategory.key]?.excludeKeywords || []).join(', ')}
|
||
onChange={(e) => {
|
||
const keywords = e.target.value.split(',').map(k => k.trim()).filter(k => k)
|
||
const newAdvanced = { ...aiControlSettings.categoryAdvanced }
|
||
if (!newAdvanced[selectedCategory.key]) newAdvanced[selectedCategory.key] = {}
|
||
newAdvanced[selectedCategory.key] = { ...newAdvanced[selectedCategory.key], excludeKeywords: keywords }
|
||
setAiControlSettings({ ...aiControlSettings, categoryAdvanced: newAdvanced })
|
||
}}
|
||
placeholder="spam, test, noreply"
|
||
className="mt-1"
|
||
/>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">Comma-separated keywords to exclude</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</SidePanelBody>
|
||
)}
|
||
</SidePanelContent>
|
||
</SidePanel>
|
||
|
||
{/* Label Editor Side Panel */}
|
||
<SidePanel open={showLabelPanel} onOpenChange={setShowLabelPanel}>
|
||
<SidePanelContent>
|
||
<SidePanelHeader>
|
||
<SidePanelCloseButton />
|
||
<SidePanelTitle>
|
||
{editingLabel?.id ? 'Edit Label' : 'Add New Label'}
|
||
</SidePanelTitle>
|
||
<SidePanelDescription>
|
||
{editingLabel?.id
|
||
? 'Update the company label settings'
|
||
: 'Create a new company label to automatically categorize emails'}
|
||
</SidePanelDescription>
|
||
</SidePanelHeader>
|
||
|
||
{editingLabel && (
|
||
<SidePanelBody>
|
||
<div className="space-y-6">
|
||
{/* Company Name */}
|
||
<div>
|
||
<Label htmlFor="label-name" className="text-base font-medium mb-2 block">
|
||
Company Name
|
||
</Label>
|
||
<Input
|
||
id="label-name"
|
||
placeholder="e.g., Amazon"
|
||
value={editingLabel.name}
|
||
onChange={(e) => setEditingLabel({ ...editingLabel, name: e.target.value })}
|
||
className="w-full"
|
||
/>
|
||
</div>
|
||
|
||
{/* Condition */}
|
||
<div>
|
||
<Label htmlFor="label-condition" className="text-base font-medium mb-2 block">
|
||
Condition
|
||
</Label>
|
||
<Input
|
||
id="label-condition"
|
||
placeholder="e.g., from:amazon.com OR from:amazon.de"
|
||
value={editingLabel.condition}
|
||
onChange={(e) => setEditingLabel({ ...editingLabel, condition: e.target.value })}
|
||
className="w-full font-mono text-sm"
|
||
/>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-2">
|
||
Use "from:domain.com" or "subject:keyword" syntax
|
||
</p>
|
||
</div>
|
||
|
||
{/* Category */}
|
||
<div>
|
||
<Label htmlFor="label-category" className="text-base font-medium mb-2 block">
|
||
Category
|
||
</Label>
|
||
<select
|
||
id="label-category"
|
||
value={editingLabel.category || 'promotions'}
|
||
onChange={(e) => setEditingLabel({ ...editingLabel, category: e.target.value })}
|
||
className="w-full px-4 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||
>
|
||
<option value="promotions">Promotions</option>
|
||
<option value="newsletters">Newsletter</option>
|
||
<option value="invoices">Invoices</option>
|
||
<option value="customers">Clients</option>
|
||
</select>
|
||
</div>
|
||
|
||
{/* Enabled Toggle */}
|
||
<div className="flex items-center justify-between p-4 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50">
|
||
<div>
|
||
<Label className="text-base font-medium">Enabled</Label>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||
This label will be active and applied to matching emails
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setEditingLabel({ ...editingLabel, enabled: !editingLabel.enabled })}
|
||
className={`w-12 h-6 rounded-full transition-colors ${editingLabel.enabled ? 'bg-primary-500 dark:bg-primary-600' : 'bg-slate-300 dark:bg-slate-600'}`}
|
||
>
|
||
<div className={`w-5 h-5 bg-white dark:bg-slate-200 rounded-full transform transition-transform mx-0.5 ${
|
||
editingLabel.enabled ? 'translate-x-6' : 'translate-x-0'
|
||
}`} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</SidePanelBody>
|
||
)}
|
||
|
||
<SidePanelFooter>
|
||
<Button
|
||
variant="secondary"
|
||
onClick={() => {
|
||
setShowLabelPanel(false)
|
||
setEditingLabel(null)
|
||
}}
|
||
>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
onClick={async () => {
|
||
if (!user?.$id || !editingLabel?.name || !editingLabel?.condition) {
|
||
showMessage('error', 'Please fill in all required fields')
|
||
return
|
||
}
|
||
try {
|
||
const saved = await api.saveCompanyLabel(editingLabel)
|
||
if (saved.data) {
|
||
if (editingLabel.id) {
|
||
setCompanyLabels(companyLabels.map(l => l.id === editingLabel.id ? (saved.data || l) : l))
|
||
showMessage('success', 'Label updated!')
|
||
} else {
|
||
setCompanyLabels([...companyLabels, saved.data])
|
||
showMessage('success', 'Label created!')
|
||
}
|
||
setShowLabelPanel(false)
|
||
setEditingLabel(null)
|
||
}
|
||
} catch {
|
||
showMessage('error', editingLabel.id ? 'Failed to update label' : 'Failed to create label')
|
||
}
|
||
}}
|
||
>
|
||
{editingLabel?.id ? 'Save Changes' : 'Create Label'}
|
||
</Button>
|
||
</SidePanelFooter>
|
||
</SidePanelContent>
|
||
</SidePanel>
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'name-labels' && isAdmin && (
|
||
<div className="space-y-6">
|
||
<Card className="border-amber-200 dark:border-amber-900">
|
||
<CardHeader>
|
||
<div className="flex items-center gap-2">
|
||
<Shield className="w-5 h-5 text-amber-600 dark:text-amber-400" />
|
||
<CardTitle>Reset sort data (admin)</CardTitle>
|
||
</div>
|
||
<CardDescription>
|
||
Clears email stats, digests, usage, and onboarding progress for the chosen user. Removes the{' '}
|
||
<code className="text-xs">$MailFlow-sorted</code> flag from all messages in each IMAP account's INBOX.
|
||
Does not remove email connections or subscriptions.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<Button
|
||
type="button"
|
||
variant="destructive"
|
||
disabled={resettingSort}
|
||
onClick={async () => {
|
||
const email = window.prompt('User email to reset:', 'support@webklar.com')
|
||
if (email == null) return
|
||
const trimmed = email.trim()
|
||
if (!trimmed) {
|
||
showMessage('error', 'Email is required')
|
||
return
|
||
}
|
||
if (
|
||
!window.confirm(
|
||
`Reset ALL sort-related data for ${trimmed}? This cannot be undone.`,
|
||
)
|
||
) {
|
||
return
|
||
}
|
||
setResettingSort(true)
|
||
try {
|
||
const res = await api.resetSortData(trimmed)
|
||
if (res.error) {
|
||
showMessage('error', res.error.message)
|
||
return
|
||
}
|
||
const d = res.data
|
||
showMessage(
|
||
'success',
|
||
`Reset OK. Stats/digests/usage cleared; IMAP $MailFlow-sorted removed from ${d?.imapCleared ?? 0} message(s).`,
|
||
)
|
||
} finally {
|
||
setResettingSort(false)
|
||
}
|
||
}}
|
||
>
|
||
{resettingSort ? (
|
||
<>
|
||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||
Resetting…
|
||
</>
|
||
) : (
|
||
'Reset sort data'
|
||
)}
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex items-center gap-2">
|
||
<Users className="w-5 h-5 text-primary-500 dark:text-primary-400" />
|
||
<CardTitle>Name Labels (Team)</CardTitle>
|
||
</div>
|
||
<CardDescription>
|
||
Personal labels for each team member. The AI will assign emails to a worker when they are clearly for that person (e.g. "für Max", "an Anna", subject/body mentions).
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{nameLabels.length > 0 ? (
|
||
<div className="space-y-3">
|
||
{nameLabels.map((label) => (
|
||
<div
|
||
key={label.id || label.name}
|
||
className="flex items-center justify-between p-4 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50"
|
||
>
|
||
<div>
|
||
<p className="font-medium text-slate-900 dark:text-slate-100">{label.name}</p>
|
||
{label.email && (
|
||
<p className="text-sm text-slate-500 dark:text-slate-400">{label.email}</p>
|
||
)}
|
||
{label.keywords?.length ? (
|
||
<p className="text-xs text-slate-400 dark:text-slate-500 mt-1">
|
||
Keywords: {label.keywords.join(', ')}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => {
|
||
setEditingNameLabel({ ...label })
|
||
setShowNameLabelPanel(true)
|
||
}}
|
||
>
|
||
<Edit2 className="w-4 h-4 mr-1" />
|
||
Edit
|
||
</Button>
|
||
<button
|
||
onClick={async () => {
|
||
if (!user?.$id || !label.id) return
|
||
try {
|
||
await api.saveNameLabel({ ...label, enabled: !label.enabled })
|
||
setNameLabels(nameLabels.map(l => l.id === label.id ? { ...l, enabled: !l.enabled } : l))
|
||
showMessage('success', 'Label updated!')
|
||
} catch {
|
||
showMessage('error', 'Failed to update label')
|
||
}
|
||
}}
|
||
className={`w-10 h-6 rounded-full transition-colors ${label.enabled ? 'bg-primary-500 dark:bg-primary-600' : 'bg-slate-300 dark:bg-slate-600'}`}
|
||
title={label.enabled ? 'Disable' : 'Enable'}
|
||
>
|
||
<div className={`w-4 h-4 bg-white dark:bg-slate-200 rounded-full transform transition-transform mx-1 ${
|
||
label.enabled ? 'translate-x-4' : 'translate-x-0'
|
||
}`} />
|
||
</button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={async () => {
|
||
if (!user?.$id || !label.id) return
|
||
if (!confirm('Delete this name label?')) return
|
||
try {
|
||
await api.deleteNameLabel(label.id)
|
||
setNameLabels(nameLabels.filter(l => l.id !== label.id))
|
||
showMessage('success', 'Label deleted!')
|
||
} catch {
|
||
showMessage('error', 'Failed to delete label')
|
||
}
|
||
}}
|
||
>
|
||
<Trash2 className="w-4 h-4 text-red-500 dark:text-red-400" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => {
|
||
setEditingNameLabel({ name: '', enabled: true })
|
||
setShowNameLabelPanel(true)
|
||
}}
|
||
>
|
||
<Plus className="w-4 h-4 mr-2" />
|
||
Add team member
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<div className="text-center py-12 text-slate-500 dark:text-slate-400">
|
||
<Users className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||
<p className="font-medium">No name labels yet</p>
|
||
<p className="text-sm mt-1">Add team members so the AI can assign emails to the right person</p>
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
className="mt-4"
|
||
onClick={() => {
|
||
setEditingNameLabel({ name: '', enabled: true })
|
||
setShowNameLabelPanel(true)
|
||
}}
|
||
>
|
||
<Plus className="w-4 h-4 mr-2" />
|
||
Add team member
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Name Label Editor Side Panel */}
|
||
<SidePanel open={showNameLabelPanel} onOpenChange={setShowNameLabelPanel}>
|
||
<SidePanelContent>
|
||
<SidePanelHeader>
|
||
<SidePanelCloseButton />
|
||
<SidePanelTitle>
|
||
{editingNameLabel?.id ? 'Edit Name Label' : 'Add Team Member'}
|
||
</SidePanelTitle>
|
||
<SidePanelDescription>
|
||
{editingNameLabel?.id
|
||
? 'Update the name label'
|
||
: 'Add a team member. The AI will assign emails to this person when they are clearly for them (e.g. "für Max", subject mentions).'}
|
||
</SidePanelDescription>
|
||
</SidePanelHeader>
|
||
{editingNameLabel && (
|
||
<SidePanelBody>
|
||
<div className="space-y-6">
|
||
<div>
|
||
<Label htmlFor="namelabel-name">Name</Label>
|
||
<Input
|
||
id="namelabel-name"
|
||
placeholder="e.g. Max, Anna"
|
||
value={editingNameLabel.name}
|
||
onChange={(e) => setEditingNameLabel({ ...editingNameLabel, name: e.target.value })}
|
||
className="mt-2"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label htmlFor="namelabel-email">Email (optional)</Label>
|
||
<Input
|
||
id="namelabel-email"
|
||
type="email"
|
||
placeholder="max@company.com"
|
||
value={editingNameLabel.email || ''}
|
||
onChange={(e) => setEditingNameLabel({ ...editingNameLabel, email: e.target.value || undefined })}
|
||
className="mt-2"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label htmlFor="namelabel-keywords">Keywords (optional, comma-separated)</Label>
|
||
<Input
|
||
id="namelabel-keywords"
|
||
placeholder="für Max, an Max, Max bitte"
|
||
value={(editingNameLabel.keywords || []).join(', ')}
|
||
onChange={(e) => setEditingNameLabel({
|
||
...editingNameLabel,
|
||
keywords: e.target.value.split(',').map(k => k.trim()).filter(Boolean),
|
||
})}
|
||
className="mt-2"
|
||
/>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">Hints for the AI to recognize emails for this person</p>
|
||
</div>
|
||
<div className="flex items-center justify-between p-4 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50">
|
||
<div>
|
||
<Label className="font-medium">Enabled</Label>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400">This label will be used when sorting</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setEditingNameLabel({ ...editingNameLabel, enabled: !editingNameLabel.enabled })}
|
||
className={`w-12 h-6 rounded-full transition-colors ${editingNameLabel.enabled ? 'bg-primary-500 dark:bg-primary-600' : 'bg-slate-300 dark:bg-slate-600'}`}
|
||
>
|
||
<div className={`w-5 h-5 bg-white dark:bg-slate-200 rounded-full transform transition-transform mx-0.5 ${
|
||
editingNameLabel.enabled ? 'translate-x-6' : 'translate-x-0'
|
||
}`} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</SidePanelBody>
|
||
)}
|
||
<SidePanelFooter>
|
||
<Button variant="secondary" onClick={() => { setShowNameLabelPanel(false); setEditingNameLabel(null) }}>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
onClick={async () => {
|
||
if (!user?.$id || !editingNameLabel?.name?.trim()) {
|
||
showMessage('error', 'Please enter a name')
|
||
return
|
||
}
|
||
try {
|
||
const saved = await api.saveNameLabel(editingNameLabel)
|
||
if (saved.data) {
|
||
if (editingNameLabel.id) {
|
||
setNameLabels(nameLabels.map(l => l.id === editingNameLabel.id ? (saved.data || l) : l))
|
||
showMessage('success', 'Label updated!')
|
||
} else {
|
||
setNameLabels([...nameLabels, saved.data])
|
||
showMessage('success', 'Label created!')
|
||
}
|
||
setShowNameLabelPanel(false)
|
||
setEditingNameLabel(null)
|
||
}
|
||
} catch {
|
||
showMessage('error', editingNameLabel.id ? 'Failed to update' : 'Failed to create')
|
||
}
|
||
}}
|
||
>
|
||
{editingNameLabel?.id ? 'Save Changes' : 'Add'}
|
||
</Button>
|
||
</SidePanelFooter>
|
||
</SidePanelContent>
|
||
</SidePanel>
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'referrals' && (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Referrals</CardTitle>
|
||
<CardDescription>
|
||
Share MailFlow and earn rewards
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{loadingReferral ? (
|
||
<div className="text-center py-8">
|
||
<Loader2 className="w-6 h-6 animate-spin mx-auto" />
|
||
</div>
|
||
) : (
|
||
<div className="space-y-4">
|
||
<div className="p-4 bg-gradient-to-r from-primary-50 to-accent-50 dark:from-primary-900/30 dark:to-accent-900/30 rounded-lg border border-primary-200 dark:border-primary-800">
|
||
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100 mb-2">Your referral code</p>
|
||
<div className="flex items-center gap-2">
|
||
<code className="flex-1 px-3 py-2 bg-white dark:bg-slate-800 border border-primary-200 dark:border-primary-700 rounded-lg font-mono text-lg font-bold text-primary-600 dark:text-primary-400">
|
||
{referralData?.referralCode || 'USER-XXXXXX'}
|
||
</code>
|
||
<Button
|
||
size="sm"
|
||
onClick={async () => {
|
||
const url = `${window.location.origin}?ref=${referralData?.referralCode}`
|
||
await navigator.clipboard.writeText(url)
|
||
showMessage('success', 'Referral link copied!')
|
||
}}
|
||
>
|
||
<Copy className="w-4 h-4 mr-2" />
|
||
Copy link
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div className="p-4 bg-slate-50 dark:bg-slate-800/50 rounded-lg">
|
||
<p className="text-sm text-slate-600 dark:text-slate-400 mb-1">People referred</p>
|
||
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{referralData?.referralCount || 0}</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{activeTab === 'privacy' && (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Privacy & Security</CardTitle>
|
||
<CardDescription>
|
||
Understand what data we access, what we store, and how to manage your privacy
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<PrivacySecurity
|
||
connectedAccounts={accounts.map(a => ({ id: a.id, email: a.email, provider: a.provider }))}
|
||
onDisconnect={async (accountId) => {
|
||
if (!user?.$id) return
|
||
try {
|
||
const result = await api.disconnectEmailAccount(accountId)
|
||
if (result.data) {
|
||
setAccounts(accounts.filter(a => a.id !== accountId))
|
||
showMessage('success', 'Account disconnected')
|
||
}
|
||
} catch {
|
||
showMessage('error', 'Failed to disconnect account')
|
||
}
|
||
}}
|
||
onDeleteAccount={async () => {
|
||
if (!user?.$id) return
|
||
if (!confirm('Are you absolutely sure? This cannot be undone.')) return
|
||
try {
|
||
const result = await api.deleteAccount()
|
||
if (result.data) {
|
||
showMessage('success', 'Account deleted. Redirecting...')
|
||
setTimeout(() => {
|
||
window.location.href = '/'
|
||
}, 2000)
|
||
}
|
||
} catch {
|
||
showMessage('error', 'Failed to delete account')
|
||
}
|
||
}}
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{activeTab === 'subscription' && (
|
||
<div className="space-y-6">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Current Subscription</CardTitle>
|
||
<CardDescription>Manage your MailFlow subscription</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{loading ? (
|
||
<p className="text-sm text-slate-500 dark:text-slate-400 py-6">
|
||
Loading subscription…
|
||
</p>
|
||
) : !subscription ? (
|
||
<div className="space-y-3 py-2">
|
||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||
Subscription status could not be loaded. Make sure you are signed in and the API is running.
|
||
</p>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={async () => {
|
||
const r = await api.getSubscriptionStatus()
|
||
if (r.data) {
|
||
setSubscription(r.data)
|
||
showMessage('success', 'Subscription loaded')
|
||
} else {
|
||
showMessage('error', r.error?.message || 'Failed to load subscription')
|
||
}
|
||
}}
|
||
>
|
||
Retry
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 p-4 bg-gradient-to-r from-primary-50 to-accent-50 dark:from-primary-900/30 dark:to-accent-900/30 rounded-xl border border-primary-100 dark:border-primary-800">
|
||
<div className="flex items-center gap-4">
|
||
<div className="w-14 h-14 rounded-xl bg-white dark:bg-slate-800 shadow-sm flex items-center justify-center shrink-0">
|
||
<Crown className="w-7 h-7 text-primary-500 dark:text-primary-400" />
|
||
</div>
|
||
<div>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<h3 className="font-bold text-lg text-slate-900 dark:text-slate-100">
|
||
{subscriptionTitle(subscription)}
|
||
</h3>
|
||
{(() => {
|
||
const b = subscriptionBadge(subscription)
|
||
return b.label ? (
|
||
<Badge variant={b.variant}>{b.label}</Badge>
|
||
) : null
|
||
})()}
|
||
</div>
|
||
{subscription.currentPeriodEnd && (
|
||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||
Next billing:{' '}
|
||
{new Date(subscription.currentPeriodEnd).toLocaleDateString('en-US')}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<Button className="shrink-0" onClick={handleManageSubscription}>
|
||
<ExternalLink className="w-4 h-4 mr-2" />
|
||
Manage
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Available Plans</CardTitle>
|
||
<CardDescription>Choose the plan that fits you</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="grid sm:grid-cols-3 gap-4">
|
||
{[
|
||
{ id: 'basic', name: 'Basic', price: '9', features: ['1 email account', '500 emails/day', 'Standard support'] },
|
||
{ id: 'pro', name: 'Pro', price: '19', features: ['3 email accounts', 'Unlimited emails', 'Historical sorting', 'Priority support'], popular: true },
|
||
{ id: 'business', name: 'Business', price: '49', features: ['10 email accounts', 'Unlimited emails', 'Team features', 'API access', '24/7 support'] },
|
||
].map((plan) => (
|
||
<div key={plan.id} className={`relative p-6 rounded-xl border-2 ${
|
||
plan.popular ? 'border-primary-500 dark:border-primary-600 bg-primary-50 dark:bg-primary-900/30' : 'border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800'
|
||
}`}>
|
||
{plan.popular && (
|
||
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||
<Badge className="bg-primary-500 dark:bg-primary-600 text-white">Popular</Badge>
|
||
</div>
|
||
)}
|
||
<h3 className="font-bold text-lg text-slate-900 dark:text-slate-100">{plan.name}</h3>
|
||
<div className="mt-2 mb-4">
|
||
<span className="text-3xl font-bold text-slate-900 dark:text-slate-100">${plan.price}</span>
|
||
<span className="text-slate-500 dark:text-slate-400">/month</span>
|
||
</div>
|
||
<ul className="space-y-2 mb-6">
|
||
{plan.features.map((feature, fi) => (
|
||
<li key={`${plan.id}-${fi}`} className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400">
|
||
<Check className="w-4 h-4 text-green-500 dark:text-green-400" />
|
||
{feature}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<Button
|
||
className="w-full"
|
||
variant={plan.popular ? 'default' : 'outline'}
|
||
onClick={() => handleUpgrade(plan.id)}
|
||
disabled={subscription?.plan === plan.id}
|
||
>
|
||
{subscription?.plan === plan.id ? 'Current plan' : 'Select'}
|
||
</Button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|