Email Sorter Beta
Ich habe soweit automatisiert the Emails sortieren aber ich muss noch schauen was es fur bugs es gibt wenn die app online ist deswegen wurde ich mit diesen Commit die website veroffentlichen obwohjl es sein konnte das es noch nicht fertig ist und verkaufs bereit
This commit is contained in:
707
client/src/pages/Dashboard.tsx
Normal file
707
client/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,707 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate, Link } 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 { Badge } from '@/components/ui/badge'
|
||||
import { api } from '@/lib/api'
|
||||
import {
|
||||
Mail,
|
||||
Inbox,
|
||||
Tag,
|
||||
Clock,
|
||||
TrendingUp,
|
||||
Plus,
|
||||
Settings,
|
||||
LogOut,
|
||||
Zap,
|
||||
BarChart3,
|
||||
Users,
|
||||
FileText,
|
||||
Bell,
|
||||
Shield,
|
||||
HelpCircle,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Check,
|
||||
AlertCircle,
|
||||
Sparkles,
|
||||
AlertTriangle,
|
||||
Lightbulb,
|
||||
Archive
|
||||
} from 'lucide-react'
|
||||
|
||||
interface EmailStats {
|
||||
totalSorted: number
|
||||
todaySorted: number
|
||||
weekSorted: number
|
||||
categories: Record<string, number>
|
||||
timeSaved: number
|
||||
}
|
||||
|
||||
interface EmailAccount {
|
||||
id: string
|
||||
email: string
|
||||
provider: string
|
||||
connected: boolean
|
||||
lastSync?: string
|
||||
}
|
||||
|
||||
interface SortResult {
|
||||
sorted: number
|
||||
inboxCleared: number
|
||||
categories: Record<string, number>
|
||||
timeSaved: { minutes: number; formatted: string }
|
||||
highlights: Array<{ type: string; count: number; message: string }>
|
||||
suggestions: Array<{ type: string; message: string }>
|
||||
provider?: string
|
||||
isDemo?: boolean
|
||||
}
|
||||
|
||||
interface Digest {
|
||||
date: string
|
||||
totalSorted: number
|
||||
inboxCleared: number
|
||||
timeSavedMinutes: number
|
||||
stats: Record<string, number>
|
||||
highlights: Array<{ type: string; count: number; message: string }>
|
||||
suggestions: Array<{ type: string; message: string }>
|
||||
hasData: boolean
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const { user, logout } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [stats, setStats] = useState<EmailStats | null>(null)
|
||||
const [accounts, setAccounts] = useState<EmailAccount[]>([])
|
||||
const [digest, setDigest] = useState<Digest | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [sorting, setSorting] = useState(false)
|
||||
const [sortResult, setSortResult] = useState<SortResult | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.$id) {
|
||||
loadData()
|
||||
}
|
||||
}, [user])
|
||||
|
||||
const loadData = async () => {
|
||||
if (!user?.$id) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const [statsRes, accountsRes, digestRes] = await Promise.all([
|
||||
api.getEmailStats(user.$id),
|
||||
api.getEmailAccounts(user.$id),
|
||||
api.getDigest(user.$id),
|
||||
])
|
||||
|
||||
if (statsRes.data) setStats(statsRes.data)
|
||||
if (accountsRes.data) setAccounts(accountsRes.data)
|
||||
if (digestRes.data) setDigest(digestRes.data)
|
||||
} catch (err) {
|
||||
console.error('Error loading dashboard data:', err)
|
||||
setError('Failed to load data')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSortNow = async () => {
|
||||
if (!user?.$id || accounts.length === 0) {
|
||||
setError('Connect an email account first to start sorting.')
|
||||
return
|
||||
}
|
||||
|
||||
setSorting(true)
|
||||
setSortResult(null)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await api.sortEmails(user.$id, accounts[0].id)
|
||||
if (result.data) {
|
||||
setSortResult(result.data)
|
||||
// Refresh stats and digest
|
||||
const [statsRes, digestRes] = await Promise.all([
|
||||
api.getEmailStats(user.$id),
|
||||
api.getDigest(user.$id),
|
||||
])
|
||||
if (statsRes.data) setStats(statsRes.data)
|
||||
if (digestRes.data) setDigest(digestRes.data)
|
||||
} else if (result.error) {
|
||||
setError(result.error.message || 'Sorting failed')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error sorting emails:', err)
|
||||
setError('Error sorting emails')
|
||||
} finally {
|
||||
setSorting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConnectDemo = async () => {
|
||||
if (!user?.$id) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await api.connectDemoAccount(user.$id)
|
||||
if (result.data) {
|
||||
const accountsRes = await api.getEmailAccounts(user.$id)
|
||||
if (accountsRes.data) setAccounts(accountsRes.data)
|
||||
} else if (result.error) {
|
||||
setError(result.error.message || 'Could not create demo account')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error connecting demo:', err)
|
||||
setError('Error creating demo account')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
navigate('/')
|
||||
}
|
||||
|
||||
const displayStats: EmailStats = stats || {
|
||||
totalSorted: 0,
|
||||
todaySorted: 0,
|
||||
weekSorted: 0,
|
||||
categories: {},
|
||||
timeSaved: 0,
|
||||
}
|
||||
|
||||
const categoryColors: Record<string, string> = {
|
||||
'vip': 'bg-amber-500',
|
||||
'Important': 'bg-amber-500',
|
||||
'customers': 'bg-blue-500',
|
||||
'Clients': 'bg-blue-500',
|
||||
'invoices': 'bg-green-500',
|
||||
'Invoices': 'bg-green-500',
|
||||
'newsletters': 'bg-purple-500',
|
||||
'Newsletter': 'bg-purple-500',
|
||||
'social': 'bg-pink-500',
|
||||
'Social': 'bg-pink-500',
|
||||
'promotions': 'bg-orange-500',
|
||||
'Promotions': 'bg-orange-500',
|
||||
'security': 'bg-red-500',
|
||||
'Security': 'bg-red-500',
|
||||
'calendar': 'bg-indigo-500',
|
||||
'Calendar': 'bg-indigo-500',
|
||||
'review': 'bg-slate-500',
|
||||
'Review': 'bg-slate-500',
|
||||
}
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
'vip': 'Important',
|
||||
'customers': 'Clients',
|
||||
'invoices': 'Invoices',
|
||||
'newsletters': 'Newsletter',
|
||||
'social': 'Social',
|
||||
'promotions': 'Promotions',
|
||||
'security': 'Security',
|
||||
'calendar': 'Calendar',
|
||||
'review': 'Review',
|
||||
}
|
||||
|
||||
const formatCategoryName = (key: string) => categoryLabels[key] || key
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-slate-100">
|
||||
{/* Header */}
|
||||
<header className="bg-white/90 backdrop-blur-md border-b border-slate-200 sticky top-0 z-50 shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-14 sm:h-16">
|
||||
<Link to="/" className="flex items-center gap-1.5 sm:gap-2 flex-shrink-0">
|
||||
<div className="w-8 h-8 sm:w-9 sm:h-9 rounded-lg bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center shadow-lg shadow-primary-500/20">
|
||||
<Mail className="w-4 h-4 sm:w-5 sm:h-5 text-white" />
|
||||
</div>
|
||||
<span className="text-base sm:text-lg font-bold text-slate-900 whitespace-nowrap">
|
||||
Email<span className="text-primary-600">Sorter</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-1.5 sm:gap-2 lg:gap-4">
|
||||
<Button variant="ghost" size="icon" className="hidden lg:flex h-9 w-9">
|
||||
<Bell className="w-5 h-5 text-slate-500" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="hidden lg:flex h-9 w-9">
|
||||
<HelpCircle className="w-5 h-5 text-slate-500" />
|
||||
</Button>
|
||||
<div className="hidden lg:block h-6 w-px bg-slate-200" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate('/settings')}
|
||||
className="hidden lg:flex h-9"
|
||||
>
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Settings
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="lg:hidden h-9 w-9"
|
||||
onClick={() => navigate('/settings')}
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="w-5 h-5 text-slate-600" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleLogout}
|
||||
className="hidden lg:flex h-9"
|
||||
>
|
||||
<LogOut className="w-4 h-4 mr-2" />
|
||||
Sign out
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="lg:hidden h-9 w-9"
|
||||
onClick={handleLogout}
|
||||
title="Sign out"
|
||||
>
|
||||
<LogOut className="w-4 h-4 text-slate-600" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-6">
|
||||
{/* Welcome section */}
|
||||
<div className="mb-4 sm:mb-6">
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-slate-900 mb-0.5">
|
||||
Welcome back{user?.name ? `, ${user.name}` : ''}! 👋
|
||||
</h1>
|
||||
<p className="text-xs sm:text-sm text-slate-600">
|
||||
Your email overview for today.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="mb-3 sm:mb-4 p-2.5 sm:p-3 bg-red-50 border border-red-200 rounded-lg flex items-start sm:items-center gap-2 text-red-700">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5 sm:mt-0" />
|
||||
<p className="text-xs sm:text-sm flex-1">{error}</p>
|
||||
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-600 text-base font-semibold leading-none">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sort Result Toast */}
|
||||
{sortResult && (
|
||||
<div className="mb-3 sm:mb-4 p-2.5 sm:p-3 bg-green-50 border border-green-200 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2 flex-wrap gap-1.5">
|
||||
<div className="flex items-center gap-2 text-green-700">
|
||||
<Check className="w-4 h-4 flex-shrink-0" />
|
||||
<p className="text-xs sm:text-sm font-semibold">Sorting complete!</p>
|
||||
</div>
|
||||
{sortResult.isDemo && (
|
||||
<Badge variant="secondary" className="bg-amber-100 text-amber-700 text-xs">
|
||||
Demo
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3 text-xs">
|
||||
<div>
|
||||
<p className="text-xs text-green-600 mb-0.5">Sorted</p>
|
||||
<p className="text-lg sm:text-xl font-bold text-green-800">{sortResult.sorted}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-green-600 mb-0.5">Time saved</p>
|
||||
<p className="text-lg sm:text-xl font-bold text-green-800">{sortResult.timeSaved.formatted}</p>
|
||||
</div>
|
||||
{Object.entries(sortResult.categories).slice(0, 2).map(([cat, count]) => (
|
||||
<div key={cat}>
|
||||
<p className="text-xs text-green-600 mb-0.5 truncate">{formatCategoryName(cat)}</p>
|
||||
<p className="text-lg sm:text-xl font-bold text-green-800">{count}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{Object.keys(sortResult.categories).length > 2 && (
|
||||
<div className="mt-2 pt-2 border-t border-green-200">
|
||||
<p className="text-xs text-green-600 mb-1.5">Categories:</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Object.entries(sortResult.categories).map(([cat, count]) => (
|
||||
<span
|
||||
key={cat}
|
||||
className={`px-1.5 py-0.5 rounded-full text-xs font-medium text-white ${categoryColors[cat] || 'bg-slate-500'}`}
|
||||
>
|
||||
{formatCategoryName(cat)}: {count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-10 h-10 animate-spin text-primary-500 mx-auto mb-4" />
|
||||
<p className="text-slate-500">Loading dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Daily Digest Card */}
|
||||
{digest?.hasData && (
|
||||
<Card className="mb-4 sm:mb-6 shadow-lg border-0 bg-gradient-to-r from-primary-50 via-white to-accent-50 overflow-hidden">
|
||||
<CardContent className="p-3 sm:p-4">
|
||||
<div className="flex items-start justify-between mb-3 gap-2">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-lg bg-gradient-to-br from-primary-500 to-accent-500 flex items-center justify-center shadow-lg shadow-primary-500/30 flex-shrink-0">
|
||||
<Sparkles className="w-4 h-4 sm:w-5 sm:h-5 text-white" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-sm sm:text-base font-bold text-slate-900 truncate">Today's Digest</h3>
|
||||
<p className="text-xs text-slate-500 truncate">{new Date(digest.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
{digest.inboxCleared > 0 && (
|
||||
<Badge className="bg-green-100 text-green-700 border-green-200 text-xs whitespace-nowrap flex-shrink-0">
|
||||
<Archive className="w-3 h-3 mr-0.5" />
|
||||
{digest.inboxCleared}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 mb-3">
|
||||
<div className="bg-white/80 rounded-lg p-2 sm:p-3 border border-slate-200/50">
|
||||
<p className="text-xs text-slate-500 mb-0.5">Processed</p>
|
||||
<p className="text-base sm:text-lg font-bold text-slate-900">{digest.totalSorted}</p>
|
||||
</div>
|
||||
<div className="bg-white/80 rounded-lg p-2 sm:p-3 border border-slate-200/50">
|
||||
<p className="text-xs text-slate-500 mb-0.5">Cleared</p>
|
||||
<p className="text-base sm:text-lg font-bold text-green-600">{digest.inboxCleared}</p>
|
||||
</div>
|
||||
<div className="bg-white/80 rounded-lg p-2 sm:p-3 border border-slate-200/50">
|
||||
<p className="text-xs text-slate-500 mb-0.5">Saved</p>
|
||||
<p className="text-base sm:text-lg font-bold text-primary-600">
|
||||
{digest.timeSavedMinutes > 60
|
||||
? `${Math.floor(digest.timeSavedMinutes / 60)}h ${digest.timeSavedMinutes % 60}m`
|
||||
: `${digest.timeSavedMinutes}m`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Highlights */}
|
||||
{digest.highlights.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<p className="text-xs font-medium text-slate-700 mb-1.5 flex items-center gap-1.5">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-amber-500" />
|
||||
Needs Attention
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{digest.highlights.map((highlight, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`px-2 py-1 rounded-md text-xs ${
|
||||
highlight.type === 'vip' ? 'bg-amber-100 text-amber-800' :
|
||||
highlight.type === 'security' ? 'bg-red-100 text-red-800' :
|
||||
highlight.type === 'invoices' ? 'bg-green-100 text-green-800' :
|
||||
'bg-slate-100 text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{highlight.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggestions */}
|
||||
{digest.suggestions.length > 0 && (
|
||||
<div className="pt-2.5 border-t border-slate-200/50">
|
||||
<p className="text-xs font-medium text-slate-700 mb-1.5 flex items-center gap-1.5">
|
||||
<Lightbulb className="w-3.5 h-3.5 text-primary-500" />
|
||||
Suggestions
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{digest.suggestions.map((suggestion, idx) => (
|
||||
<p key={idx} className="text-xs text-slate-600 bg-white/60 rounded-md px-2 py-1">
|
||||
{suggestion.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Stats cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-2.5 sm:gap-3 lg:gap-4 mb-4 sm:mb-6">
|
||||
<StatsCard
|
||||
icon={Inbox}
|
||||
title="Sorted today"
|
||||
value={displayStats.todaySorted.toString()}
|
||||
subtitle="emails"
|
||||
color="bg-primary-500"
|
||||
/>
|
||||
<StatsCard
|
||||
icon={TrendingUp}
|
||||
title="This week"
|
||||
value={displayStats.weekSorted.toString()}
|
||||
subtitle="emails"
|
||||
color="bg-accent-500"
|
||||
/>
|
||||
<StatsCard
|
||||
icon={Clock}
|
||||
title="Time saved"
|
||||
value={displayStats.timeSaved > 60
|
||||
? `${Math.floor(displayStats.timeSaved / 60)}h ${displayStats.timeSaved % 60}m`
|
||||
: `${displayStats.timeSaved}m`}
|
||||
subtitle="this week"
|
||||
color="bg-green-500"
|
||||
/>
|
||||
<StatsCard
|
||||
icon={BarChart3}
|
||||
title="Total sorted"
|
||||
value={displayStats.totalSorted.toLocaleString('en-US')}
|
||||
subtitle="emails"
|
||||
color="bg-violet-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-3 sm:gap-4">
|
||||
{/* Categories breakdown */}
|
||||
<Card className="lg:col-span-2 shadow-lg border-0 order-2 lg:order-1">
|
||||
<CardHeader className="p-3 sm:p-4 pb-2 sm:pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm sm:text-base">
|
||||
<Tag className="w-4 h-4 text-primary-500" />
|
||||
Categories Overview
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Distribution this week
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3 sm:p-4 pt-0">
|
||||
{Object.keys(displayStats.categories).length > 0 ? (
|
||||
<div className="space-y-2.5">
|
||||
{Object.entries(displayStats.categories).map(([category, count]) => {
|
||||
const total = Object.values(displayStats.categories).reduce((a, b) => a + b, 0)
|
||||
const percentage = total > 0 ? Math.round((count / total) * 100) : 0
|
||||
|
||||
return (
|
||||
<div key={category}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<div className={`w-2 h-2 sm:w-2.5 sm:h-2.5 rounded-full flex-shrink-0 ${categoryColors[category] || 'bg-slate-400'}`} />
|
||||
<span className="text-xs font-medium text-slate-700 truncate">{formatCategoryName(category)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<span className="text-xs text-slate-500 whitespace-nowrap">{count}</span>
|
||||
<span className="text-xs text-slate-400 whitespace-nowrap">({percentage}%)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${categoryColors[category] || 'bg-slate-400'}`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<Tag className="w-10 h-10 text-slate-300 mx-auto mb-3" />
|
||||
<p className="text-xs text-slate-500 mb-1">No category statistics yet</p>
|
||||
<p className="text-xs text-slate-400">Start a sort to see statistics</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Connected accounts */}
|
||||
<Card className="shadow-lg border-0 order-1 lg:order-2">
|
||||
<CardHeader className="p-3 sm:p-4 pb-2 sm:pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm sm:text-base">
|
||||
<Users className="w-4 h-4 text-primary-500" />
|
||||
Email Accounts
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Connected mailboxes
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2.5 sm:space-y-3 p-3 sm:p-4 pt-0">
|
||||
{accounts.length > 0 ? (
|
||||
accounts.map((account) => (
|
||||
<div
|
||||
key={account.id}
|
||||
className="flex items-center justify-between p-2 sm:p-2.5 bg-gradient-to-r from-slate-50 to-slate-100 rounded-lg gap-2"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div className={`w-8 h-8 sm:w-9 sm:h-9 rounded-lg flex items-center justify-center shadow-sm flex-shrink-0 ${
|
||||
account.provider === 'gmail' ? 'bg-red-100' : 'bg-blue-100'
|
||||
}`}>
|
||||
<Mail className={`w-3.5 h-3.5 sm:w-4 sm:h-4 ${
|
||||
account.provider === 'gmail' ? 'text-red-600' : 'text-blue-600'
|
||||
}`} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-slate-700 truncate">{account.email}</p>
|
||||
<p className="text-xs text-slate-500 capitalize truncate">{account.provider}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={account.connected ? 'success' : 'secondary'} className="text-xs whitespace-nowrap flex-shrink-0 px-1.5 py-0.5">
|
||||
{account.connected ? 'Active' : 'Off'}
|
||||
</Badge>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-6">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-gradient-to-br from-slate-100 to-slate-200 flex items-center justify-center">
|
||||
<Mail className="w-6 h-6 text-slate-400" />
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mb-1">
|
||||
No email accounts connected
|
||||
</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
Connect an account to get started
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5 pt-2">
|
||||
<Button
|
||||
className="w-full h-9 sm:h-10 text-xs sm:text-sm"
|
||||
variant="outline"
|
||||
onClick={() => navigate('/setup')}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 mr-1.5" />
|
||||
Connect account
|
||||
</Button>
|
||||
{accounts.length === 0 && (
|
||||
<Button
|
||||
className="w-full h-9 sm:h-10 text-xs sm:text-sm"
|
||||
variant="ghost"
|
||||
onClick={handleConnectDemo}
|
||||
disabled={loading}
|
||||
>
|
||||
<Zap className="w-3.5 h-3.5 mr-1.5" />
|
||||
Try demo account
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="mt-4 sm:mt-6">
|
||||
<h2 className="text-sm sm:text-base font-semibold text-slate-900 mb-2.5 sm:mb-3">Quick Actions</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-2.5 sm:gap-3">
|
||||
<QuickAction
|
||||
icon={sorting ? RefreshCw : Zap}
|
||||
title={sorting ? "Sorting..." : "Sort now"}
|
||||
description={sorting ? "Please wait..." : "Start manual sorting"}
|
||||
onClick={handleSortNow}
|
||||
disabled={sorting || accounts.length === 0}
|
||||
loading={sorting}
|
||||
highlight
|
||||
/>
|
||||
<QuickAction
|
||||
icon={Settings}
|
||||
title="Adjust rules"
|
||||
description="Edit sorting rules"
|
||||
onClick={() => navigate('/settings?tab=rules')}
|
||||
/>
|
||||
<QuickAction
|
||||
icon={Shield}
|
||||
title="VIP List"
|
||||
description="Manage important contacts"
|
||||
onClick={() => navigate('/settings?tab=vip')}
|
||||
/>
|
||||
<QuickAction
|
||||
icon={FileText}
|
||||
title="Reports"
|
||||
description="Detailed statistics"
|
||||
onClick={() => {}}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface StatsCardProps {
|
||||
icon: React.ElementType
|
||||
title: string
|
||||
value: string
|
||||
subtitle: string
|
||||
color: string
|
||||
}
|
||||
|
||||
function StatsCard({ icon: Icon, title, value, subtitle, color }: StatsCardProps) {
|
||||
return (
|
||||
<Card className="shadow-lg border-0 overflow-hidden">
|
||||
<CardContent className="p-3 sm:p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs text-slate-500 mb-0.5 truncate">{title}</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-slate-900 leading-tight">{value}</p>
|
||||
<p className="text-xs text-slate-400 truncate mt-0.5">{subtitle}</p>
|
||||
</div>
|
||||
<div className={`w-9 h-9 sm:w-10 sm:h-10 rounded-lg ${color} flex items-center justify-center shadow-lg flex-shrink-0`}>
|
||||
<Icon className="w-4 h-4 sm:w-5 sm:h-5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
interface QuickActionProps {
|
||||
icon: React.ElementType
|
||||
title: string
|
||||
description: string
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
highlight?: boolean
|
||||
}
|
||||
|
||||
function QuickAction({ icon: Icon, title, description, onClick, disabled, loading, highlight }: QuickActionProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`flex items-center gap-2 p-2.5 sm:p-3 rounded-lg border-2 transition-all text-left w-full group disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
highlight && !disabled
|
||||
? 'bg-gradient-to-r from-primary-50 to-accent-50 border-primary-200 hover:border-primary-400 hover:shadow-lg hover:shadow-primary-500/10 active:scale-[0.98]'
|
||||
: 'bg-white border-slate-200 hover:border-primary-200 hover:shadow-md active:scale-[0.98]'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-9 h-9 sm:w-10 sm:h-10 rounded-lg flex items-center justify-center transition-colors flex-shrink-0 ${
|
||||
highlight && !disabled
|
||||
? 'bg-primary-500 shadow-lg shadow-primary-500/30'
|
||||
: 'bg-primary-50 group-hover:bg-primary-100'
|
||||
}`}>
|
||||
<Icon className={`w-4 h-4 sm:w-5 sm:h-5 ${highlight && !disabled ? 'text-white' : 'text-primary-600'} ${loading ? 'animate-spin' : ''}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-xs sm:text-sm font-semibold ${highlight && !disabled ? 'text-primary-900' : 'text-slate-900'} truncate`}>{title}</p>
|
||||
<p className="text-xs text-slate-500 truncate leading-tight">{description}</p>
|
||||
</div>
|
||||
<ChevronRight className={`w-3.5 h-3.5 sm:w-4 sm:h-4 transition-colors flex-shrink-0 ${
|
||||
highlight && !disabled ? 'text-primary-400' : 'text-slate-400'
|
||||
} group-hover:text-primary-500 group-hover:translate-x-0.5 transition-transform`} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
131
client/src/pages/ForgotPassword.tsx
Normal file
131
client/src/pages/ForgotPassword.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
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 { auth } from '@/lib/appwrite'
|
||||
import { Mail, ArrowLeft, Loader2, CheckCircle } from 'lucide-react'
|
||||
|
||||
export function ForgotPassword() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [sent, setSent] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
await auth.forgotPassword(email)
|
||||
setSent(true)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Fehler beim Senden der E-Mail')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<Link to="/" className="flex items-center justify-center gap-2 mb-8">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center">
|
||||
<Mail className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="text-xl font-bold text-slate-900">
|
||||
Email<span className="text-primary-600">Sorter</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<Card className="shadow-xl border-0">
|
||||
<CardHeader className="text-center pb-2">
|
||||
<CardTitle className="text-2xl">Passwort vergessen?</CardTitle>
|
||||
<CardDescription>
|
||||
{sent
|
||||
? 'Prüfe dein E-Mail-Postfach'
|
||||
: 'Gib deine E-Mail-Adresse ein und wir senden dir einen Link zum Zurücksetzen.'
|
||||
}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sent ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-green-100 flex items-center justify-center">
|
||||
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-slate-900 mb-2">E-Mail gesendet!</h3>
|
||||
<p className="text-slate-600 mb-6">
|
||||
Wir haben dir eine E-Mail mit einem Link zum Zurücksetzen deines Passworts an <strong>{email}</strong> gesendet.
|
||||
</p>
|
||||
<p className="text-sm text-slate-500 mb-6">
|
||||
Keine E-Mail erhalten? Prüfe deinen Spam-Ordner oder versuche es erneut.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setSent(false)}
|
||||
>
|
||||
Erneut senden
|
||||
</Button>
|
||||
<Link to="/login">
|
||||
<Button variant="ghost" className="w-full">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Zurück zum Login
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-Mail-Adresse</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="name@beispiel.de"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Wird gesendet...
|
||||
</>
|
||||
) : (
|
||||
'Link senden'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="text-center">
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-sm text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 inline mr-1" />
|
||||
Zurück zum Login
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
client/src/pages/Home.tsx
Normal file
23
client/src/pages/Home.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Navbar } from '@/components/landing/Navbar'
|
||||
import { Hero } from '@/components/landing/Hero'
|
||||
import { Features } from '@/components/landing/Features'
|
||||
import { HowItWorks } from '@/components/landing/HowItWorks'
|
||||
import { Pricing } from '@/components/landing/Pricing'
|
||||
import { Testimonials } from '@/components/landing/Testimonials'
|
||||
import { FAQ } from '@/components/landing/FAQ'
|
||||
import { Footer } from '@/components/landing/Footer'
|
||||
|
||||
export function Home() {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Navbar />
|
||||
<Hero />
|
||||
<Features />
|
||||
<HowItWorks />
|
||||
<Testimonials />
|
||||
<Pricing />
|
||||
<FAQ />
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
156
client/src/pages/Imprint.tsx
Normal file
156
client/src/pages/Imprint.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowLeft, Building2 } from 'lucide-react'
|
||||
|
||||
export function Imprint() {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
{/* Header */}
|
||||
<header className="bg-white border-b border-slate-200">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center gap-2 text-slate-600 hover:text-slate-900 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">Back to Home</span>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-8 md:p-12">
|
||||
{/* Title */}
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<div className="w-12 h-12 rounded-lg bg-primary-100 flex items-center justify-center">
|
||||
<Building2 className="w-6 h-6 text-primary-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-900">Impressum</h1>
|
||||
<p className="text-slate-500 mt-1">Legal Information</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content - Placeholder for webklar.com content */}
|
||||
<div className="prose prose-slate max-w-none">
|
||||
<p className="text-slate-600 mb-6">
|
||||
<strong>Note:</strong> This imprint is managed by webklar.com. Please refer to their imprint for detailed information.
|
||||
</p>
|
||||
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-6 mb-8">
|
||||
<h2 className="text-xl font-semibold text-slate-900 mb-4">Information according to § 5 TMG</h2>
|
||||
|
||||
<div className="space-y-6 text-slate-700">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Operator</h3>
|
||||
<p className="mb-2">EmailSorter is operated by:</p>
|
||||
<p className="mb-4">
|
||||
<strong>webklar.com</strong><br />
|
||||
Kenso Grimm, Justin Klein
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 mb-4">
|
||||
For complete contact details and legal information, please visit:{' '}
|
||||
<a
|
||||
href="https://webklar.com/impressum"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-600 hover:text-primary-700 underline"
|
||||
>
|
||||
webklar.com/impressum
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 border-t border-slate-200">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Contact</h3>
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
<strong>Email:</strong>{' '}
|
||||
<a
|
||||
href="mailto:support@webklar.com"
|
||||
className="text-primary-600 hover:text-primary-700 underline"
|
||||
>
|
||||
support@webklar.com
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Phone:</strong>{' '}
|
||||
<a
|
||||
href="tel:+4917623726355"
|
||||
className="text-primary-600 hover:text-primary-700 underline"
|
||||
>
|
||||
+49 176 23726355
|
||||
</a>
|
||||
{' / '}
|
||||
<a
|
||||
href="tel:+491704969375"
|
||||
className="text-primary-600 hover:text-primary-700 underline"
|
||||
>
|
||||
+49 170 4969375
|
||||
</a>
|
||||
</p>
|
||||
<p className="mt-4 text-sm text-slate-600">
|
||||
For questions regarding EmailSorter specifically:{' '}
|
||||
<a
|
||||
href="mailto:support@emailsorter.com"
|
||||
className="text-primary-600 hover:text-primary-700 underline"
|
||||
>
|
||||
support@emailsorter.com
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 border-t border-slate-200">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Responsible for Content</h3>
|
||||
<p>
|
||||
The content of this website is the responsibility of webklar.com.
|
||||
For detailed information, please refer to the official imprint at{' '}
|
||||
<a
|
||||
href="https://webklar.com/impressum"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-600 hover:text-primary-700 underline"
|
||||
>
|
||||
webklar.com/impressum
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 border-t border-slate-200">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Liability for Links</h3>
|
||||
<p>
|
||||
Our website contains links to external websites. We have no influence on the content of these websites.
|
||||
Therefore, we cannot assume any liability for these external contents.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 border-t border-slate-200">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Copyright</h3>
|
||||
<p>
|
||||
The content and works on this website are subject to German copyright law.
|
||||
Reproduction, processing, distribution, and any form of commercialization require the written consent of the respective author or creator.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-slate-200">
|
||||
<p className="text-sm text-slate-500">
|
||||
<strong>Important:</strong> This is a simplified version. For the complete and legally binding imprint, please visit{' '}
|
||||
<a
|
||||
href="https://webklar.com/impressum"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-600 hover:text-primary-700 underline"
|
||||
>
|
||||
webklar.com/impressum
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
142
client/src/pages/Login.tsx
Normal file
142
client/src/pages/Login.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Mail, Lock, ArrowRight, AlertCircle } from 'lucide-react'
|
||||
|
||||
export function Login() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const { login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
await login(email, password)
|
||||
navigate('/dashboard')
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Login failed. Please check your credentials.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex">
|
||||
{/* Left side - Form */}
|
||||
<div className="flex-1 flex items-center justify-center px-4 sm:px-6 lg:px-8 bg-slate-900">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<Link to="/" className="flex items-center gap-2 mb-8">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center">
|
||||
<Mail className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="text-xl font-bold text-white">
|
||||
Email<span className="text-primary-400">Sorter</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<h1 className="text-3xl font-bold text-white mb-2">
|
||||
Welcome back
|
||||
</h1>
|
||||
<p className="text-slate-300 mb-8">
|
||||
Sign in to access your dashboard.
|
||||
</p>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-900/30 border border-red-500/50 rounded-xl flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-slate-200">Email address</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="john@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="pl-10 bg-slate-800 border-slate-700 text-white placeholder:text-slate-400 focus:border-primary-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password" className="text-slate-200">Password</Label>
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-sm text-primary-400 hover:text-primary-300"
|
||||
>
|
||||
Forgot?
|
||||
</Link>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="pl-10 bg-slate-800 border-slate-700 text-white placeholder:text-slate-400 focus:border-primary-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" size="lg" disabled={loading}>
|
||||
{loading ? (
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
Sign in
|
||||
<ArrowRight className="w-5 h-5 ml-2" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-8 text-center text-slate-300">
|
||||
Don't have an account?{' '}
|
||||
<Link to="/register" className="text-primary-400 font-semibold hover:text-primary-300">
|
||||
Sign up free
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Decorative */}
|
||||
<div className="hidden lg:flex lg:flex-1 bg-gradient-to-br from-primary-600 to-primary-900 items-center justify-center p-12">
|
||||
<div className="max-w-md text-center">
|
||||
<div className="w-24 h-24 mx-auto mb-8 rounded-3xl bg-white/10 backdrop-blur flex items-center justify-center">
|
||||
<Mail className="w-12 h-12 text-white" />
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-white mb-4">
|
||||
Your inbox under control
|
||||
</h2>
|
||||
<p className="text-primary-100">
|
||||
Thousands of users already trust EmailSorter for more productive email communication.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
168
client/src/pages/Privacy.tsx
Normal file
168
client/src/pages/Privacy.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowLeft, Shield } from 'lucide-react'
|
||||
|
||||
export function Privacy() {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
{/* Header */}
|
||||
<header className="bg-white border-b border-slate-200">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center gap-2 text-slate-600 hover:text-slate-900 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">Back to Home</span>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-8 md:p-12">
|
||||
{/* Title */}
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<div className="w-12 h-12 rounded-lg bg-primary-100 flex items-center justify-center">
|
||||
<Shield className="w-6 h-6 text-primary-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-900">Privacy Policy</h1>
|
||||
<p className="text-slate-500 mt-1">Last updated: {new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content - Placeholder for webklar.com content */}
|
||||
<div className="prose prose-slate max-w-none">
|
||||
<p className="text-slate-600 mb-6">
|
||||
<strong>Note:</strong> This privacy policy is managed by webklar.com. Please refer to their privacy policy for detailed information.
|
||||
</p>
|
||||
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-6 mb-8">
|
||||
<h2 className="text-xl font-semibold text-slate-900 mb-4">Data Protection Information</h2>
|
||||
<p className="text-slate-700 mb-4">
|
||||
EmailSorter is operated by webklar.com. The following privacy policy applies to the use of this website and our services.
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold text-slate-900 mt-6 mb-3">1. Responsible Party</h3>
|
||||
<p className="text-slate-700 mb-4">
|
||||
The responsible party for data processing on this website is:
|
||||
</p>
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
|
||||
<p className="text-slate-700 mb-2">
|
||||
<strong>webklar.com</strong><br />
|
||||
Kenso Grimm, Justin Klein
|
||||
</p>
|
||||
<p className="text-slate-700 mb-2">
|
||||
<strong>Contact:</strong><br />
|
||||
Email: <a href="mailto:support@webklar.com" className="text-primary-600 hover:text-primary-700 underline">support@webklar.com</a><br />
|
||||
Phone: <a href="tel:+4917623726355" className="text-primary-600 hover:text-primary-700 underline">+49 176 23726355</a>
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 mt-3">
|
||||
For complete contact details, please refer to the <Link to="/imprint" className="text-primary-600 hover:text-primary-700 underline">Impressum</Link>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-slate-900 mt-6 mb-3">2. Data Collection and Processing</h3>
|
||||
<p className="text-slate-700 mb-4">
|
||||
When you use EmailSorter, we collect and process the following data:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-slate-700 mb-4 space-y-2 ml-4">
|
||||
<li>Account information (email address, name)</li>
|
||||
<li>Email metadata (sender, subject, date) for sorting purposes</li>
|
||||
<li>Usage statistics and preferences</li>
|
||||
<li>Payment information (processed securely via Stripe)</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold text-slate-900 mt-6 mb-3">3. Purpose of Data Processing</h3>
|
||||
<p className="text-slate-700 mb-4">
|
||||
We process your data exclusively for the following purposes:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-slate-700 mb-4 space-y-2 ml-4">
|
||||
<li>Providing and improving the EmailSorter service</li>
|
||||
<li>Automated email sorting and categorization</li>
|
||||
<li>Processing payments and subscriptions</li>
|
||||
<li>Customer support and communication</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold text-slate-900 mt-6 mb-3">4. Data Security</h3>
|
||||
<p className="text-slate-700 mb-4">
|
||||
We implement appropriate technical and organizational measures to protect your data against unauthorized access, loss, or destruction.
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold text-slate-900 mt-6 mb-3">5. Your Rights</h3>
|
||||
<p className="text-slate-700 mb-4">
|
||||
You have the right to:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-slate-700 mb-4 space-y-2 ml-4">
|
||||
<li>Access your personal data</li>
|
||||
<li>Correct inaccurate data</li>
|
||||
<li>Request deletion of your data</li>
|
||||
<li>Object to data processing</li>
|
||||
<li>Data portability</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold text-slate-900 mt-6 mb-3">6. Hosting and Third-Party Services</h3>
|
||||
<p className="text-slate-700 mb-4">
|
||||
<strong>Hosting:</strong> Our website is hosted by Netlify, which acts as a data processor.
|
||||
</p>
|
||||
<p className="text-slate-700 mb-4">
|
||||
We use the following third-party services:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-slate-700 mb-4 space-y-2 ml-4">
|
||||
<li><strong>Appwrite:</strong> User authentication and database</li>
|
||||
<li><strong>Stripe:</strong> Payment processing</li>
|
||||
<li><strong>Mistral AI:</strong> Email categorization</li>
|
||||
<li><strong>Gmail/Outlook API:</strong> Email access (with your explicit consent)</li>
|
||||
<li><strong>Plausible (optional):</strong> Privacy-friendly analytics tool, if enabled</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold text-slate-900 mt-6 mb-3">6.1. Cookies and Tracking</h3>
|
||||
<p className="text-slate-700 mb-4">
|
||||
We do not use external fonts or unnecessary cookies. If we use any tracking tools (such as Plausible),
|
||||
they are privacy-friendly and do not store personal data. We only process personal data to the extent
|
||||
that it is technically or organizationally necessary.
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold text-slate-900 mt-6 mb-3">7. Contact Form Data</h3>
|
||||
<p className="text-slate-700 mb-4">
|
||||
Data that you send to us via contact forms will be stored and used for processing your inquiry.
|
||||
This data will not be shared with third parties without your consent.
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold text-slate-900 mt-6 mb-3">8. Contact</h3>
|
||||
<p className="text-slate-700 mb-4">
|
||||
For questions regarding data protection, please contact us:
|
||||
</p>
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
|
||||
<p className="text-slate-700">
|
||||
<strong>Email:</strong>{' '}
|
||||
<a href="mailto:support@webklar.com" className="text-primary-600 hover:text-primary-700 underline">
|
||||
support@webklar.com
|
||||
</a>
|
||||
</p>
|
||||
<p className="text-slate-700 mt-2">
|
||||
<strong>Phone:</strong>{' '}
|
||||
<a href="tel:+4917623726355" className="text-primary-600 hover:text-primary-700 underline">
|
||||
+49 176 23726355
|
||||
</a>
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 mt-3">
|
||||
For complete contact details, please refer to the <Link to="/imprint" className="text-primary-600 hover:text-primary-700 underline">Impressum</Link>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-slate-200">
|
||||
<p className="text-sm text-slate-500">
|
||||
<strong>Important:</strong> This is a simplified version. For the complete and legally binding privacy policy, please visit{' '}
|
||||
<a href="https://webklar.com/datenschutz" target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:text-primary-700 underline">
|
||||
webklar.com/datenschutz
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
226
client/src/pages/Register.tsx
Normal file
226
client/src/pages/Register.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
import { analytics } from '@/hooks/useAnalytics'
|
||||
import { captureUTMParams } from '@/lib/analytics'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Mail, Lock, User, ArrowRight, AlertCircle, Check, Sparkles } from 'lucide-react'
|
||||
|
||||
export function Register() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const selectedPlan = searchParams.get('plan') || 'pro'
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const { register } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Capture UTM parameters on mount
|
||||
useEffect(() => {
|
||||
captureUTMParams()
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match.')
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
setError('Password must be at least 8 characters long.')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const user = await register(email, password, name)
|
||||
|
||||
// Track signup conversion with UTM parameters
|
||||
if (user?.$id) {
|
||||
analytics.trackSignup(user.$id, email)
|
||||
analytics.setUserId(user.$id)
|
||||
}
|
||||
|
||||
navigate('/setup')
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Registration failed. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex">
|
||||
{/* Left side - Decorative */}
|
||||
<div className="hidden lg:flex lg:flex-1 bg-gradient-to-br from-slate-900 via-primary-900 to-slate-900 items-center justify-center p-12 relative overflow-hidden">
|
||||
{/* Background pattern */}
|
||||
<div className="absolute inset-0 gradient-mesh opacity-20" />
|
||||
|
||||
<div className="relative max-w-md">
|
||||
<Badge className="mb-6 bg-accent-500/20 text-accent-300 border-accent-400/30">
|
||||
<Sparkles className="w-3 h-3 mr-1" />
|
||||
14-day free trial
|
||||
</Badge>
|
||||
|
||||
<h2 className="text-4xl font-bold text-white mb-6">
|
||||
Start with EmailSorter today
|
||||
</h2>
|
||||
|
||||
<ul className="space-y-4 mb-8">
|
||||
{[
|
||||
'No credit card required',
|
||||
'Gmail & Outlook support',
|
||||
'AI-powered categorization',
|
||||
'Cancel anytime',
|
||||
].map((item, index) => (
|
||||
<li key={index} className="flex items-center gap-3 text-slate-300">
|
||||
<div className="w-6 h-6 rounded-full bg-accent-500/20 flex items-center justify-center">
|
||||
<Check className="w-4 h-4 text-accent-400" />
|
||||
</div>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Plan indicator */}
|
||||
<div className="bg-white/10 backdrop-blur rounded-xl p-4 border border-white/10">
|
||||
<p className="text-sm text-slate-400 mb-1">Selected plan</p>
|
||||
<p className="text-xl font-semibold text-white capitalize">{selectedPlan}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Form */}
|
||||
<div className="flex-1 flex items-center justify-center px-4 sm:px-6 lg:px-8 bg-white">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<Link to="/" className="flex items-center gap-2 mb-8">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center">
|
||||
<Mail className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="text-xl font-bold text-slate-900">
|
||||
Email<span className="text-primary-600">Sorter</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<h1 className="text-3xl font-bold text-slate-900 mb-2">
|
||||
Create account
|
||||
</h1>
|
||||
<p className="text-slate-600 mb-8">
|
||||
Ready to go in less than a minute.
|
||||
</p>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name (optional)</Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="John Smith"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="john@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="pl-10"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="At least 8 characters"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="pl-10"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirm password</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
placeholder="Repeat password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="pl-10"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" size="lg" disabled={loading}>
|
||||
{loading ? (
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
Get started free
|
||||
<ArrowRight className="w-5 h-5 ml-2" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-slate-500 text-center">
|
||||
By signing up, you agree to our{' '}
|
||||
<a href="#" className="text-primary-600 hover:underline">Terms of Service</a> and{' '}
|
||||
<a href="#" className="text-primary-600 hover:underline">Privacy Policy</a>.
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<p className="mt-8 text-center text-slate-600">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="text-primary-600 font-semibold hover:text-primary-700">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
225
client/src/pages/ResetPassword.tsx
Normal file
225
client/src/pages/ResetPassword.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link, useSearchParams, useNavigate } from 'react-router-dom'
|
||||
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 { auth } from '@/lib/appwrite'
|
||||
import { Mail, Loader2, CheckCircle, XCircle, Eye, EyeOff } from 'lucide-react'
|
||||
|
||||
export function ResetPassword() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const userId = searchParams.get('userId')
|
||||
const secret = searchParams.get('secret')
|
||||
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId || !secret) {
|
||||
setError('Ungültiger oder abgelaufener Link')
|
||||
}
|
||||
}, [userId, secret])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwörter stimmen nicht überein')
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
setError('Passwort muss mindestens 8 Zeichen lang sein')
|
||||
return
|
||||
}
|
||||
|
||||
if (!userId || !secret) {
|
||||
setError('Ungültiger Link')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
await auth.resetPassword(userId, secret, password)
|
||||
setSuccess(true)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Fehler beim Zurücksetzen des Passworts')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Password strength indicator
|
||||
const getPasswordStrength = () => {
|
||||
if (!password) return { strength: 0, label: '', color: '' }
|
||||
|
||||
let strength = 0
|
||||
if (password.length >= 8) strength++
|
||||
if (/[A-Z]/.test(password)) strength++
|
||||
if (/[a-z]/.test(password)) strength++
|
||||
if (/[0-9]/.test(password)) strength++
|
||||
if (/[^A-Za-z0-9]/.test(password)) strength++
|
||||
|
||||
const levels = [
|
||||
{ strength: 1, label: 'Sehr schwach', color: 'bg-red-500' },
|
||||
{ strength: 2, label: 'Schwach', color: 'bg-orange-500' },
|
||||
{ strength: 3, label: 'Mittel', color: 'bg-yellow-500' },
|
||||
{ strength: 4, label: 'Stark', color: 'bg-green-500' },
|
||||
{ strength: 5, label: 'Sehr stark', color: 'bg-green-600' },
|
||||
]
|
||||
|
||||
return levels[strength - 1] || { strength: 0, label: '', color: '' }
|
||||
}
|
||||
|
||||
const passwordStrength = getPasswordStrength()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<Link to="/" className="flex items-center justify-center gap-2 mb-8">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center">
|
||||
<Mail className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="text-xl font-bold text-slate-900">
|
||||
Email<span className="text-primary-600">Sorter</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<Card className="shadow-xl border-0">
|
||||
<CardHeader className="text-center pb-2">
|
||||
<CardTitle className="text-2xl">
|
||||
{success ? 'Passwort geändert!' : 'Neues Passwort festlegen'}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{success
|
||||
? 'Dein Passwort wurde erfolgreich geändert.'
|
||||
: 'Wähle ein sicheres neues Passwort für deinen Account.'
|
||||
}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{success ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-green-100 flex items-center justify-center">
|
||||
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<p className="text-slate-600 mb-6">
|
||||
Du kannst dich jetzt mit deinem neuen Passwort anmelden.
|
||||
</p>
|
||||
<Button onClick={() => navigate('/login')} className="w-full">
|
||||
Zum Login
|
||||
</Button>
|
||||
</div>
|
||||
) : !userId || !secret ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 flex items-center justify-center">
|
||||
<XCircle className="w-8 h-8 text-red-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-slate-900 mb-2">Ungültiger Link</h3>
|
||||
<p className="text-slate-600 mb-6">
|
||||
Dieser Link zum Zurücksetzen des Passworts ist ungültig oder abgelaufen.
|
||||
</p>
|
||||
<Link to="/forgot-password">
|
||||
<Button className="w-full">Neuen Link anfordern</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Neues Passwort</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Password strength indicator */}
|
||||
{password && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3, 4, 5].map((level) => (
|
||||
<div
|
||||
key={level}
|
||||
className={`h-1 flex-1 rounded-full transition-colors ${
|
||||
level <= passwordStrength.strength
|
||||
? passwordStrength.color
|
||||
: 'bg-slate-200'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className={`text-xs ${
|
||||
passwordStrength.strength < 3 ? 'text-red-500' : 'text-green-600'
|
||||
}`}>
|
||||
{passwordStrength.label}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Passwort bestätigen</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="••••••••"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
{confirmPassword && password !== confirmPassword && (
|
||||
<p className="text-xs text-red-500">Passwörter stimmen nicht überein</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={loading || password !== confirmPassword || password.length < 8}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Wird gespeichert...
|
||||
</>
|
||||
) : (
|
||||
'Passwort speichern'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
593
client/src/pages/Settings.tsx
Normal file
593
client/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,593 @@
|
||||
import { useState, useEffect } 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 { api } from '@/lib/api'
|
||||
import {
|
||||
Mail,
|
||||
User,
|
||||
CreditCard,
|
||||
Shield,
|
||||
Settings as SettingsIcon,
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
Trash2,
|
||||
Check,
|
||||
X,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Crown,
|
||||
Star,
|
||||
} from 'lucide-react'
|
||||
|
||||
type TabType = 'profile' | 'accounts' | 'vip' | 'rules' | 'subscription'
|
||||
|
||||
interface EmailAccount {
|
||||
id: string
|
||||
email: string
|
||||
provider: 'gmail' | 'outlook'
|
||||
connected: boolean
|
||||
lastSync?: string
|
||||
}
|
||||
|
||||
interface VIPSender {
|
||||
email: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
interface SortRule {
|
||||
id: string
|
||||
name: string
|
||||
condition: string
|
||||
category: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface Subscription {
|
||||
status: string
|
||||
plan: string
|
||||
currentPeriodEnd?: string
|
||||
cancelAtPeriodEnd?: boolean
|
||||
}
|
||||
|
||||
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 [accounts, setAccounts] = useState<EmailAccount[]>([])
|
||||
const [connectingProvider, setConnectingProvider] = useState<string | null>(null)
|
||||
const [vipSenders, setVipSenders] = useState<VIPSender[]>([])
|
||||
const [newVipEmail, setNewVipEmail] = useState('')
|
||||
const [rules, setRules] = useState<SortRule[]>([
|
||||
{ id: '1', name: 'Boss Emails', condition: 'from:boss@company.com', category: 'Important', enabled: true },
|
||||
{ id: '2', name: 'Support Tickets', condition: 'subject:Ticket #', category: 'Clients', enabled: true },
|
||||
])
|
||||
const [subscription, setSubscription] = useState<Subscription | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [user])
|
||||
|
||||
const loadData = async () => {
|
||||
if (!user?.$id) return
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const [accountsRes, subsRes, prefsRes] = await Promise.all([
|
||||
api.getEmailAccounts(user.$id),
|
||||
api.getSubscriptionStatus(user.$id),
|
||||
api.getUserPreferences(user.$id),
|
||||
])
|
||||
|
||||
if (accountsRes.data) setAccounts(accountsRes.data)
|
||||
if (subsRes.data) setSubscription(subsRes.data)
|
||||
if (prefsRes.data?.vipSenders) setVipSenders(prefsRes.data.vipSenders)
|
||||
} 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)
|
||||
}
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
showMessage('success', 'Profile saved!')
|
||||
} catch {
|
||||
showMessage('error', 'Failed to save')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConnectAccount = async (provider: 'gmail' | 'outlook') => {
|
||||
if (!user?.$id) return
|
||||
setConnectingProvider(provider)
|
||||
|
||||
try {
|
||||
const res = await api.getOAuthUrl(provider, user.$id)
|
||||
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, user.$id)
|
||||
setAccounts(accounts.filter(a => a.id !== accountId))
|
||||
showMessage('success', 'Account disconnected')
|
||||
} catch {
|
||||
showMessage('error', 'Failed to disconnect')
|
||||
}
|
||||
}
|
||||
|
||||
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(user.$id, { vipSenders })
|
||||
showMessage('success', 'VIP list saved!')
|
||||
} catch {
|
||||
showMessage('error', 'Failed to save')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleRule = (ruleId: string) => {
|
||||
setRules(rules.map(r =>
|
||||
r.id === ruleId ? { ...r, enabled: !r.enabled } : r
|
||||
))
|
||||
}
|
||||
|
||||
const handleManageSubscription = async () => {
|
||||
if (!user?.$id) return
|
||||
|
||||
try {
|
||||
const res = await api.createPortalSession(user.$id)
|
||||
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.$id, user.email)
|
||||
if (res.data?.url) {
|
||||
window.location.href = res.data.url
|
||||
}
|
||||
} catch {
|
||||
showMessage('error', 'Failed to start checkout')
|
||||
}
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ 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: 'rules' as TabType, label: 'Sorting Rules', icon: SettingsIcon },
|
||||
{ id: 'subscription' as TabType, label: 'Subscription', icon: CreditCard },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<header className="bg-white border-b border-slate-200 sticky top-0 z-40">
|
||||
<div className="max-w-5xl mx-auto 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" />
|
||||
<h1 className="text-lg font-semibold text-slate-900">Settings</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-5xl mx-auto 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 text-green-700 border border-green-200'
|
||||
: 'bg-red-50 text-red-700 border border-red-200'
|
||||
}`}>
|
||||
{message.type === 'success' ? <Check className="w-5 h-5" /> : <X className="w-5 h-5" />}
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
<nav className="lg:w-64 flex-shrink-0">
|
||||
<div className="bg-white rounded-xl border border-slate-200 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 text-primary-700 border-l-4 border-primary-500'
|
||||
: 'text-slate-600 hover:bg-slate-50 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" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === 'profile' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profile</CardTitle>
|
||||
<CardDescription>Manage your personal information</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center text-white text-2xl font-bold">
|
||||
{name?.charAt(0)?.toUpperCase() || email?.charAt(0)?.toUpperCase() || 'U'}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">{name || 'User'}</h3>
|
||||
<p className="text-slate-500">{email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Your name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input id="email" value={email} disabled className="bg-slate-50" />
|
||||
<p className="text-xs text-slate-500 mt-1">Email address cannot be changed</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSaveProfile} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
|
||||
Save
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{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 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' : 'bg-blue-100'
|
||||
}`}>
|
||||
<Mail className={`w-6 h-6 ${account.provider === 'gmail' ? 'text-red-600' : 'text-blue-600'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{account.email}</p>
|
||||
<p className="text-sm text-slate-500 capitalize">{account.provider}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant={account.connected ? 'success' : 'secondary'}>
|
||||
{account.connected ? 'Connected' : 'Disconnected'}
|
||||
</Badge>
|
||||
<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 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 rounded-xl hover:border-red-300 hover:bg-red-50 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">Gmail</p>
|
||||
<p className="text-sm text-slate-500">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 rounded-xl hover:border-blue-300 hover:bg-blue-50 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">Outlook</p>
|
||||
<p className="text-sm text-slate-500">Connect Microsoft account</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'vip' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-amber-500" />
|
||||
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 border border-amber-100 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Star className="w-5 h-5 text-amber-500" />
|
||||
<span className="text-slate-700">{vip.email}</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleRemoveVip(vip.email)}>
|
||||
<X className="w-4 h-4 text-slate-400 hover:text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-center text-slate-500 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 === 'rules' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sorting Rules</CardTitle>
|
||||
<CardDescription>Custom rules for email sorting</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{rules.map((rule) => (
|
||||
<div key={rule.id} className={`flex items-center justify-between p-4 rounded-lg border ${
|
||||
rule.enabled ? 'bg-white border-slate-200' : 'bg-slate-50 border-slate-100'
|
||||
}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => toggleRule(rule.id)}
|
||||
className={`w-10 h-6 rounded-full transition-colors ${rule.enabled ? 'bg-primary-500' : 'bg-slate-300'}`}
|
||||
>
|
||||
<div className={`w-4 h-4 bg-white rounded-full transform transition-transform mx-1 ${
|
||||
rule.enabled ? 'translate-x-4' : 'translate-x-0'
|
||||
}`} />
|
||||
</button>
|
||||
<div>
|
||||
<p className={`font-medium ${rule.enabled ? 'text-slate-900' : 'text-slate-500'}`}>{rule.name}</p>
|
||||
<p className="text-sm text-slate-500 font-mono">{rule.condition}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={rule.enabled ? 'default' : 'secondary'}>{rule.category}</Badge>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button variant="outline" className="w-full">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create new rule
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'subscription' && (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Current Subscription</CardTitle>
|
||||
<CardDescription>Manage your EmailSorter subscription</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between p-4 bg-gradient-to-r from-primary-50 to-accent-50 rounded-xl border border-primary-100">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-xl bg-white shadow-sm flex items-center justify-center">
|
||||
<Crown className="w-7 h-7 text-primary-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-bold text-lg text-slate-900">{subscription?.plan || 'Trial'}</h3>
|
||||
<Badge variant={subscription?.status === 'active' ? 'success' : 'warning'}>
|
||||
{subscription?.status === 'active' ? 'Active' : 'Trial'}
|
||||
</Badge>
|
||||
</div>
|
||||
{subscription?.currentPeriodEnd && (
|
||||
<p className="text-sm text-slate-500">
|
||||
Next billing: {new Date(subscription.currentPeriodEnd).toLocaleDateString('en-US')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button 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 bg-primary-50' : 'border-slate-200 bg-white'
|
||||
}`}>
|
||||
{plan.popular && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||
<Badge className="bg-primary-500 text-white">Popular</Badge>
|
||||
</div>
|
||||
)}
|
||||
<h3 className="font-bold text-lg text-slate-900">{plan.name}</h3>
|
||||
<div className="mt-2 mb-4">
|
||||
<span className="text-3xl font-bold text-slate-900">${plan.price}</span>
|
||||
<span className="text-slate-500">/month</span>
|
||||
</div>
|
||||
<ul className="space-y-2 mb-6">
|
||||
{plan.features.map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-2 text-sm text-slate-600">
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
{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>
|
||||
)
|
||||
}
|
||||
492
client/src/pages/Setup.tsx
Normal file
492
client/src/pages/Setup.tsx
Normal file
@@ -0,0 +1,492 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate, Link, useSearchParams } from 'react-router-dom'
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { api } from '@/lib/api'
|
||||
import {
|
||||
Mail,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
Check,
|
||||
Sparkles,
|
||||
ChevronRight,
|
||||
Link2,
|
||||
Settings,
|
||||
Zap,
|
||||
Loader2,
|
||||
AlertCircle
|
||||
} from 'lucide-react'
|
||||
|
||||
type Step = 'connect' | 'preferences' | 'categories' | 'complete'
|
||||
|
||||
export function Setup() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const isFromCheckout = searchParams.get('subscription') === 'success'
|
||||
const autoSetup = searchParams.get('setup') === 'auto'
|
||||
|
||||
const [currentStep, setCurrentStep] = useState<Step>('connect')
|
||||
const [connectedProvider, setConnectedProvider] = useState<string | null>(null)
|
||||
const [connectedEmail, setConnectedEmail] = useState<string | null>(null)
|
||||
const [connecting, setConnecting] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [preferences, setPreferences] = useState({
|
||||
sortingStrictness: 'medium',
|
||||
historicalSync: true,
|
||||
})
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([
|
||||
'vip', 'customers', 'invoices', 'newsletters', 'social'
|
||||
])
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [checkingAccounts, setCheckingAccounts] = useState(isFromCheckout)
|
||||
|
||||
const { user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Check if user already has connected accounts after successful checkout
|
||||
useEffect(() => {
|
||||
if (isFromCheckout && user?.$id) {
|
||||
const checkAccounts = async () => {
|
||||
try {
|
||||
const accountsRes = await api.getEmailAccounts(user.$id)
|
||||
if (accountsRes.data && accountsRes.data.length > 0) {
|
||||
// User already has accounts connected - redirect to dashboard
|
||||
navigate('/dashboard?subscription=success&ready=true')
|
||||
} else {
|
||||
setCheckingAccounts(false)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error checking accounts:', err)
|
||||
setCheckingAccounts(false)
|
||||
}
|
||||
}
|
||||
checkAccounts()
|
||||
}
|
||||
}, [isFromCheckout, user, navigate])
|
||||
|
||||
const steps: { id: Step; title: string; description: string }[] = [
|
||||
{ id: 'connect', title: 'Connect email', description: 'Link your mailbox' },
|
||||
{ id: 'preferences', title: 'Settings', description: 'Sorting preferences' },
|
||||
{ id: 'categories', title: 'Categories', description: 'Choose categories' },
|
||||
{ id: 'complete', title: 'Done', description: 'Get started!' },
|
||||
]
|
||||
|
||||
const stepIndex = steps.findIndex(s => s.id === currentStep)
|
||||
|
||||
const handleConnectGmail = async () => {
|
||||
if (!user?.$id) return
|
||||
setConnecting('gmail')
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await api.getOAuthUrl('gmail', user.$id)
|
||||
if (response.data?.url) {
|
||||
window.location.href = response.data.url
|
||||
} else {
|
||||
setConnectedProvider('gmail')
|
||||
setConnectedEmail(user.email)
|
||||
setCurrentStep('preferences')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Gmail connection failed. Please try again.')
|
||||
} finally {
|
||||
setConnecting(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConnectOutlook = async () => {
|
||||
if (!user?.$id) return
|
||||
setConnecting('outlook')
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await api.getOAuthUrl('outlook', user.$id)
|
||||
if (response.data?.url) {
|
||||
window.location.href = response.data.url
|
||||
} else {
|
||||
setConnectedProvider('outlook')
|
||||
setConnectedEmail(user.email)
|
||||
setCurrentStep('preferences')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Outlook connection failed. Please try again.')
|
||||
} finally {
|
||||
setConnecting(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
const nextIndex = stepIndex + 1
|
||||
if (nextIndex < steps.length) {
|
||||
setCurrentStep(steps[nextIndex].id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
const prevIndex = stepIndex - 1
|
||||
if (prevIndex >= 0) {
|
||||
setCurrentStep(steps[prevIndex].id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleComplete = async () => {
|
||||
if (!user?.$id) {
|
||||
navigate('/dashboard')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.saveUserPreferences(user.$id, {
|
||||
vipSenders: [],
|
||||
blockedSenders: [],
|
||||
customRules: [],
|
||||
priorityTopics: selectedCategories,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to save preferences:', err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
navigate('/dashboard')
|
||||
}
|
||||
}
|
||||
|
||||
const categories = [
|
||||
{ id: 'vip', name: 'Important / VIP', description: 'Priority contacts', icon: '⭐', color: 'bg-amber-500' },
|
||||
{ id: 'customers', name: 'Clients / Projects', description: 'Business correspondence', icon: '💼', color: 'bg-blue-500' },
|
||||
{ id: 'invoices', name: 'Invoices / Receipts', description: 'Financial documents', icon: '📄', color: 'bg-green-500' },
|
||||
{ id: 'newsletters', name: 'Newsletter', description: 'Subscribed newsletters', icon: '📰', color: 'bg-purple-500' },
|
||||
{ id: 'social', name: 'Social / Platforms', description: 'LinkedIn, Twitter, etc.', icon: '👥', color: 'bg-pink-500' },
|
||||
{ id: 'security', name: 'Security / 2FA', description: 'Security codes', icon: '🔐', color: 'bg-red-500' },
|
||||
{ id: 'calendar', name: 'Calendar / Events', description: 'Appointments & invites', icon: '📅', color: 'bg-indigo-500' },
|
||||
{ id: 'promotions', name: 'Promotions / Deals', description: 'Marketing emails', icon: '🏷️', color: 'bg-orange-500' },
|
||||
]
|
||||
|
||||
const toggleCategory = (id: string) => {
|
||||
setSelectedCategories(prev =>
|
||||
prev.includes(id)
|
||||
? prev.filter(c => c !== id)
|
||||
: [...prev, id]
|
||||
)
|
||||
}
|
||||
|
||||
// Show loading while checking accounts
|
||||
if (checkingAccounts) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary-600 mx-auto mb-4" />
|
||||
<p className="text-slate-600">Setting up your account...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white">
|
||||
<header className="bg-white/80 backdrop-blur-sm border-b border-slate-200 sticky top-0 z-40">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center shadow-lg shadow-primary-500/20">
|
||||
<Mail className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="text-lg font-bold text-slate-900">
|
||||
Email<span className="text-primary-600">Sorter</span>
|
||||
</span>
|
||||
</Link>
|
||||
<Button variant="ghost" onClick={() => navigate('/dashboard')}>
|
||||
Skip
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Success message after checkout */}
|
||||
{isFromCheckout && (
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pt-8">
|
||||
<div className="bg-green-50 border border-green-200 rounded-xl p-6 mb-6 flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-full bg-green-500 flex items-center justify-center flex-shrink-0">
|
||||
<Check className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-green-900 mb-1">Payment successful!</h3>
|
||||
<p className="text-sm text-green-700">
|
||||
Your subscription is active. Let's connect your email account to get started.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
{/* Progress */}
|
||||
<div className="mb-12">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.id} className="flex items-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold transition-all duration-300 ${
|
||||
index < stepIndex
|
||||
? 'bg-green-500 text-white shadow-lg shadow-green-500/30'
|
||||
: index === stepIndex
|
||||
? 'bg-primary-500 text-white ring-4 ring-primary-100 shadow-lg shadow-primary-500/30'
|
||||
: 'bg-slate-100 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{index < stepIndex ? <Check className="w-5 h-5" /> : index + 1}
|
||||
</div>
|
||||
<p className={`mt-2 text-xs font-medium hidden sm:block transition-colors ${
|
||||
index <= stepIndex ? 'text-slate-900' : 'text-slate-400'
|
||||
}`}>
|
||||
{step.title}
|
||||
</p>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div className={`w-16 sm:w-24 h-1 mx-2 rounded-full transition-colors duration-500 ${
|
||||
index < stepIndex ? 'bg-green-500' : 'bg-slate-200'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl flex items-center gap-3 text-red-700">
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-8">
|
||||
{currentStep === 'connect' && (
|
||||
<div className="text-center">
|
||||
<div className="w-24 h-24 mx-auto mb-6 rounded-2xl bg-gradient-to-br from-primary-100 to-primary-200 flex items-center justify-center shadow-xl shadow-primary-500/10">
|
||||
<Link2 className="w-12 h-12 text-primary-600" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-slate-900 mb-3">Connect your email account</h1>
|
||||
<p className="text-lg text-slate-600 mb-10 max-w-md mx-auto">
|
||||
Choose your email provider. The connection is secure and your data stays private.
|
||||
</p>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-4 max-w-lg mx-auto">
|
||||
<button
|
||||
onClick={handleConnectGmail}
|
||||
disabled={connecting !== null}
|
||||
className="flex items-center gap-4 p-6 bg-white rounded-2xl border-2 border-slate-200 hover:border-red-300 hover:shadow-xl hover:shadow-red-500/10 transition-all text-left group disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{connecting === 'gmail' ? (
|
||||
<Loader2 className="w-12 h-12 animate-spin text-red-500" />
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-xl bg-red-50 flex items-center justify-center group-hover:bg-red-100 transition-colors">
|
||||
<svg viewBox="0 0 24 24" className="w-7 h-7">
|
||||
<path fill="#EA4335" d="M5.26 9.71L12 14.04l6.74-4.33-6.74-4.33z"/>
|
||||
<path fill="#34A853" d="M12 14.04l6.74-4.33v7.65c0 .7-.57 1.26-1.26 1.26H6.52c-.7 0-1.26-.57-1.26-1.26V9.71l6.74 4.33z"/>
|
||||
<path fill="#4285F4" d="M18.74 5.38H5.26c-.7 0-1.26.57-1.26 1.26v3.07l8 5.13 8-5.13V6.64c0-.7-.57-1.26-1.26-1.26z"/>
|
||||
<path fill="#FBBC05" d="M4 9.71V6.64c0-.7.57-1.26 1.26-1.26h.01L12 9.71 4 13.84V9.71z"/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-slate-900">Gmail</p>
|
||||
<p className="text-sm text-slate-500">Google Workspace</p>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-slate-400 group-hover:text-red-500 group-hover:translate-x-1 transition-all" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleConnectOutlook}
|
||||
disabled={connecting !== null}
|
||||
className="flex items-center gap-4 p-6 bg-white rounded-2xl border-2 border-slate-200 hover:border-blue-300 hover:shadow-xl hover:shadow-blue-500/10 transition-all text-left group disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{connecting === 'outlook' ? (
|
||||
<Loader2 className="w-12 h-12 animate-spin text-blue-500" />
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-50 flex items-center justify-center group-hover:bg-blue-100 transition-colors">
|
||||
<svg viewBox="0 0 24 24" className="w-7 h-7">
|
||||
<path fill="#0078D4" d="M11.5 3v8.5H3V3h8.5zm1 0H21v8.5h-8.5V3zM3 12.5h8.5V21H3v-8.5zm9.5 0H21V21h-8.5v-8.5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-slate-900">Outlook</p>
|
||||
<p className="text-sm text-slate-500">Microsoft 365</p>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-slate-400 group-hover:text-blue-500 group-hover:translate-x-1 transition-all" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 p-4 bg-slate-50 rounded-xl max-w-lg mx-auto">
|
||||
<p className="text-sm text-slate-500">
|
||||
🔒 Your data is secure. We don't store email content and only have read access.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 'preferences' && (
|
||||
<div>
|
||||
<div className="text-center mb-10">
|
||||
<div className="w-24 h-24 mx-auto mb-6 rounded-2xl bg-gradient-to-br from-primary-100 to-primary-200 flex items-center justify-center shadow-xl shadow-primary-500/10">
|
||||
<Settings className="w-12 h-12 text-primary-600" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-slate-900 mb-3">Sorting Settings</h1>
|
||||
<p className="text-lg text-slate-600 max-w-md mx-auto">
|
||||
Customize how strictly the AI should sort your emails.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="max-w-lg mx-auto shadow-xl border-0">
|
||||
<CardContent className="p-8 space-y-8">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-900 mb-4">Sorting Intensity</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ id: 'light', name: 'Light', desc: 'Only obvious distractions', emoji: '🌱' },
|
||||
{ id: 'medium', name: 'Medium', desc: 'Balanced sorting', emoji: '⚖️' },
|
||||
{ id: 'strict', name: 'Strict', desc: 'Inbox nearly empty', emoji: '🎯' },
|
||||
].map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => setPreferences(p => ({ ...p, sortingStrictness: option.id }))}
|
||||
className={`p-4 rounded-xl border-2 text-center transition-all ${
|
||||
preferences.sortingStrictness === option.id
|
||||
? 'border-primary-500 bg-primary-50 shadow-lg shadow-primary-500/10'
|
||||
: 'border-slate-200 hover:border-slate-300 bg-white'
|
||||
}`}
|
||||
>
|
||||
<span className="text-2xl mb-2 block">{option.emoji}</span>
|
||||
<p className="font-semibold text-slate-900">{option.name}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">{option.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-5 bg-gradient-to-r from-slate-50 to-slate-100 rounded-xl">
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900">Historical emails</p>
|
||||
<p className="text-sm text-slate-500">Analyze and sort last 30 days</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setPreferences(p => ({ ...p, historicalSync: !p.historicalSync }))}
|
||||
className={`w-14 h-8 rounded-full transition-all duration-300 ${
|
||||
preferences.historicalSync ? 'bg-primary-500 shadow-lg shadow-primary-500/30' : 'bg-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-6 h-6 bg-white rounded-full shadow-md transition-transform duration-300 ${
|
||||
preferences.historicalSync ? 'translate-x-7' : 'translate-x-1'
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 'categories' && (
|
||||
<div>
|
||||
<div className="text-center mb-10">
|
||||
<div className="w-24 h-24 mx-auto mb-6 rounded-2xl bg-gradient-to-br from-primary-100 to-primary-200 flex items-center justify-center shadow-xl shadow-primary-500/10">
|
||||
<Zap className="w-12 h-12 text-primary-600" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-slate-900 mb-3">Choose your categories</h1>
|
||||
<p className="text-lg text-slate-600 max-w-md mx-auto">
|
||||
Which categories should your emails be sorted into?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-4 max-w-2xl mx-auto">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.id}
|
||||
onClick={() => toggleCategory(category.id)}
|
||||
className={`flex items-center gap-4 p-5 rounded-xl border-2 text-left transition-all ${
|
||||
selectedCategories.includes(category.id)
|
||||
? 'border-primary-500 bg-primary-50 shadow-lg shadow-primary-500/10'
|
||||
: 'border-slate-200 bg-white hover:border-slate-300 hover:shadow-md'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-12 h-12 rounded-xl ${category.color} flex items-center justify-center text-2xl shadow-lg`}>
|
||||
{category.icon}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-slate-900">{category.name}</p>
|
||||
<p className="text-sm text-slate-500">{category.description}</p>
|
||||
</div>
|
||||
<div className={`w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all ${
|
||||
selectedCategories.includes(category.id)
|
||||
? 'border-primary-500 bg-primary-500'
|
||||
: 'border-slate-300'
|
||||
}`}>
|
||||
{selectedCategories.includes(category.id) && <Check className="w-4 h-4 text-white" />}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-slate-500 mt-6">
|
||||
You can change these categories later in settings.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 'complete' && (
|
||||
<div className="text-center">
|
||||
<div className="w-28 h-28 mx-auto mb-8 rounded-full bg-gradient-to-br from-green-100 to-green-200 flex items-center justify-center shadow-2xl shadow-green-500/20 animate-pulse">
|
||||
<Sparkles className="w-14 h-14 text-green-600" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold text-slate-900 mb-4">All set! 🎉</h1>
|
||||
<p className="text-xl text-slate-600 mb-10 max-w-md mx-auto">
|
||||
Your email account is connected. The AI will now start intelligent sorting.
|
||||
</p>
|
||||
|
||||
<div className="inline-flex items-center gap-4 p-5 bg-gradient-to-r from-slate-50 to-slate-100 rounded-2xl mb-10 shadow-lg">
|
||||
<div className="w-14 h-14 rounded-xl bg-white flex items-center justify-center shadow-md">
|
||||
<Mail className="w-7 h-7 text-primary-500" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="font-semibold text-slate-900 text-lg">
|
||||
{connectedProvider === 'gmail' ? 'Gmail' : connectedProvider === 'outlook' ? 'Outlook' : 'Email'} connected
|
||||
</p>
|
||||
<p className="text-slate-500">{connectedEmail || user?.email}</p>
|
||||
</div>
|
||||
<Badge variant="success" className="text-sm px-3 py-1">Active</Badge>
|
||||
</div>
|
||||
|
||||
<Button size="lg" onClick={handleComplete} disabled={saving} className="text-lg px-8 py-6 shadow-xl shadow-primary-500/20">
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Go to Dashboard
|
||||
<ArrowRight className="w-5 h-5 ml-2" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{currentStep !== 'connect' && currentStep !== 'complete' && (
|
||||
<div className="flex justify-between max-w-lg mx-auto">
|
||||
<Button variant="ghost" onClick={handleBack} className="text-slate-600">
|
||||
<ArrowLeft className="w-5 h-5 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={handleNext} className="shadow-lg shadow-primary-500/20">
|
||||
Next
|
||||
<ArrowRight className="w-5 h-5 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
154
client/src/pages/VerifyEmail.tsx
Normal file
154
client/src/pages/VerifyEmail.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link, useSearchParams, useNavigate } from 'react-router-dom'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { auth } from '@/lib/appwrite'
|
||||
import { Mail, Loader2, CheckCircle, XCircle, RefreshCw } from 'lucide-react'
|
||||
|
||||
export function VerifyEmail() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const userId = searchParams.get('userId')
|
||||
const secret = searchParams.get('secret')
|
||||
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
verifyEmail()
|
||||
}, [userId, secret])
|
||||
|
||||
const verifyEmail = async () => {
|
||||
if (!userId || !secret) {
|
||||
setStatus('error')
|
||||
setError('Ungültiger Verifizierungslink')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await auth.verifyEmail(userId, secret)
|
||||
setStatus('success')
|
||||
} catch (err: any) {
|
||||
setStatus('error')
|
||||
setError(err.message || 'Fehler bei der Verifizierung')
|
||||
}
|
||||
}
|
||||
|
||||
const handleResendVerification = async () => {
|
||||
setStatus('loading')
|
||||
setError('')
|
||||
|
||||
try {
|
||||
await auth.sendVerification()
|
||||
setError('')
|
||||
alert('Neue Verifizierungs-E-Mail wurde gesendet!')
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Fehler beim Senden')
|
||||
} finally {
|
||||
setStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<Link to="/" className="flex items-center justify-center gap-2 mb-8">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center">
|
||||
<Mail className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="text-xl font-bold text-slate-900">
|
||||
Email<span className="text-primary-600">Sorter</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<Card className="shadow-xl border-0">
|
||||
<CardHeader className="text-center pb-2">
|
||||
<CardTitle className="text-2xl">
|
||||
{status === 'loading' && 'E-Mail wird verifiziert...'}
|
||||
{status === 'success' && 'E-Mail verifiziert!'}
|
||||
{status === 'error' && 'Verifizierung fehlgeschlagen'}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{status === 'loading' && 'Bitte warte einen Moment.'}
|
||||
{status === 'success' && 'Deine E-Mail-Adresse wurde erfolgreich bestätigt.'}
|
||||
{status === 'error' && error}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{status === 'loading' && (
|
||||
<div className="flex flex-col items-center py-12">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-primary-500 mb-4" />
|
||||
<p className="text-slate-500">Verifizierung läuft...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-green-100 flex items-center justify-center">
|
||||
<CheckCircle className="w-10 h-10 text-green-600" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-green-50 border border-green-100 rounded-xl">
|
||||
<p className="text-green-700 font-medium">
|
||||
Dein Account ist jetzt vollständig aktiviert!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-slate-600">
|
||||
Du kannst jetzt alle Features von EmailSorter nutzen.
|
||||
</p>
|
||||
|
||||
<Button onClick={() => navigate('/dashboard')} className="w-full">
|
||||
Zum Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-red-100 flex items-center justify-center">
|
||||
<XCircle className="w-10 h-10 text-red-600" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-red-50 border border-red-100 rounded-xl">
|
||||
<p className="text-red-700">
|
||||
{error || 'Der Verifizierungslink ist ungültig oder abgelaufen.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-slate-600 text-sm">
|
||||
Falls dein Link abgelaufen ist, kannst du eine neue Verifizierungs-E-Mail anfordern.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button onClick={handleResendVerification} variant="outline" className="w-full">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Neue E-Mail senden
|
||||
</Button>
|
||||
|
||||
<Button onClick={() => navigate('/login')} variant="ghost" className="w-full">
|
||||
Zurück zum Login
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Help text */}
|
||||
<p className="text-center text-sm text-slate-500 mt-6">
|
||||
Probleme? Kontaktiere uns unter{' '}
|
||||
<a href="mailto:support@emailsorter.de" className="text-primary-600 hover:underline">
|
||||
support@emailsorter.de
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user