hzgjuigik
This commit is contained in:
ANDJ committed 2026-01-27 21:06:48 +01:00
1 parent 18c11d27bc
commit 6da8ce1cbd
51 files changed
+6206 -972

No files matched your search

+4
View File
@@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider, useAuth } from '@/context/AuthContext'
import { usePageTracking } from '@/hooks/useAnalytics'
import { initAnalytics } from '@/lib/analytics'
import { useTheme } from '@/hooks/useTheme'
import { Home } from '@/pages/Home'
import { Login } from '@/pages/Login'
import { Register } from '@/pages/Register'
@@ -142,6 +143,9 @@ function AppRoutes() {
}
function App() {
// Initialize theme detection
useTheme()
return (
<BrowserRouter>
<AuthProvider>
@@ -0,0 +1,87 @@
import { Button } from '@/components/ui/button'
import { X, Check } from 'lucide-react'
interface OnboardingProgressProps {
currentStep: string
completedSteps: string[]
totalSteps: number
onSkip: () => void
}
const stepLabels: Record<string, string> = {
'not_started': 'Not started',
'connect': 'Connect email',
'first_rule': 'Create first rule',
'see_results': 'See results',
'auto_schedule': 'Enable automation',
'completed': 'Completed',
}
export function OnboardingProgress({ currentStep, completedSteps, totalSteps, onSkip }: OnboardingProgressProps) {
const stepIndex = ['connect', 'first_rule', 'see_results', 'auto_schedule'].indexOf(currentStep)
const currentStepNumber = stepIndex >= 0 ? stepIndex + 1 : 0
const progress = totalSteps > 0 ? (completedSteps.length / totalSteps) * 100 : 0
if (currentStep === 'completed' || currentStep === 'not_started') {
return null
}
return (
<div className="bg-white border border-slate-200 rounded-lg p-4 shadow-sm">
<div className="flex items-center justify-between mb-3">
<div>
<p className="text-sm font-semibold text-slate-900">Getting started</p>
<p className="text-xs text-slate-500">Step {currentStepNumber} of {totalSteps}</p>
</div>
<Button variant="ghost" size="sm" onClick={onSkip} className="text-slate-500 hover:text-slate-700">
<X className="w-4 h-4 mr-1" />
Skip
</Button>
</div>
{/* Progress bar */}
<div className="w-full h-2 bg-slate-100 rounded-full overflow-hidden mb-2">
<div
className="h-full bg-primary-500 transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
{/* Step indicators */}
<div className="flex items-center gap-2 text-xs text-slate-500">
{['connect', 'first_rule', 'see_results', 'auto_schedule'].map((step, idx) => {
const isCompleted = completedSteps.includes(step)
const isCurrent = currentStep === step
return (
<div key={step} className="flex items-center gap-1 flex-1 min-w-0">
<div className={`flex-shrink-0 w-5 h-5 rounded-full flex items-center justify-center ${
isCompleted
? 'bg-green-500 text-white'
: isCurrent
? 'bg-primary-500 text-white ring-2 ring-primary-200'
: 'bg-slate-200 text-slate-400'
}`}>
{isCompleted ? (
<Check className="w-3 h-3" />
) : (
<span className="text-xs font-semibold">{idx + 1}</span>
)}
</div>
<span className={`truncate hidden sm:inline ${
isCurrent ? 'text-slate-900 font-medium' : ''
}`}>
{stepLabels[step] || step}
</span>
{idx < 3 && (
<div className={`flex-1 h-0.5 mx-1 ${
isCompleted ? 'bg-green-500' : 'bg-slate-200'
}`} />
)}
</div>
)
})}
</div>
</div>
)
}
+226
View File
@@ -0,0 +1,226 @@
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Shield, Lock, Trash2, X, Check, AlertTriangle } from 'lucide-react'
import { useState } from 'react'
interface PrivacySecurityProps {
onDisconnect?: (accountId: string) => void
onDeleteAccount?: () => void
connectedAccounts?: Array<{ id: string; email: string; provider: string }>
}
export function PrivacySecurity({ onDisconnect, onDeleteAccount, connectedAccounts = [] }: PrivacySecurityProps) {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
return (
<div className="space-y-6">
{/* What data is accessed */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="w-5 h-5 text-primary-500" />
What data is accessed
</CardTitle>
<CardDescription>We only access what's necessary for sorting</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-start gap-3 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<Check className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-green-900 dark:text-green-100">Email headers and metadata</p>
<p className="text-sm text-green-700 dark:text-green-300 mt-1">
We read: sender, subject, date, labels/categories. This is all we need to categorize emails.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<Check className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-green-900 dark:text-green-100">Email preview/snippet</p>
<p className="text-sm text-green-700 dark:text-green-300 mt-1">
We read the first few lines to help AI understand the email content.
</p>
</div>
</div>
</CardContent>
</Card>
{/* What is stored */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Lock className="w-5 h-5 text-primary-500 dark:text-primary-400" />
What is stored
</CardTitle>
<CardDescription>Your data stays secure</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<Check className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-blue-900 dark:text-blue-100">Your preferences</p>
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
VIP senders, category settings, company labels, sorting rules.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<Check className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-blue-900 dark:text-blue-100">Statistics</p>
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
Counts of sorted emails, categories, time saved. No email content.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<Check className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-blue-900 dark:text-blue-100">Account connection tokens</p>
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
Encrypted OAuth tokens to access your email (required for sorting).
</p>
</div>
</div>
</CardContent>
</Card>
{/* What is never stored */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<X className="w-5 h-5 text-red-500 dark:text-red-400" />
What is never stored
</CardTitle>
<CardDescription>Your privacy is protected</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-start gap-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<X className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-red-900 dark:text-red-100">Email bodies/content</p>
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
We never store the full content of your emails.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<X className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-red-900 dark:text-red-100">Attachments</p>
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
We never access or store file attachments.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<X className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-red-900 dark:text-red-100">Passwords</p>
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
We use OAuth - we never see or store your email passwords.
</p>
</div>
</div>
</CardContent>
</Card>
{/* How to disconnect */}
{connectedAccounts.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Disconnect email accounts</CardTitle>
<CardDescription>Remove access to your email accounts</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{connectedAccounts.map((account) => (
<div key={account.id} className="flex items-center justify-between p-4 border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800">
<div>
<p className="font-medium text-slate-900 dark:text-slate-100">{account.email}</p>
<p className="text-sm text-slate-500 dark:text-slate-400 capitalize">{account.provider}</p>
</div>
{onDisconnect && (
<Button
variant="outline"
size="sm"
onClick={() => onDisconnect(account.id)}
className="text-red-600 dark:text-red-400 border-red-200 dark:border-red-800 hover:bg-red-50 dark:hover:bg-red-900/20"
>
Disconnect
</Button>
)}
</div>
))}
</CardContent>
</Card>
)}
{/* Delete account */}
{onDeleteAccount && (
<Card className="border-red-200 dark:border-red-800">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-red-600 dark:text-red-400">
<Trash2 className="w-5 h-5" />
Delete my data
</CardTitle>
<CardDescription>Permanently delete all your data and account</CardDescription>
</CardHeader>
<CardContent>
{!showDeleteConfirm ? (
<div className="space-y-3">
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-sm text-red-800 dark:text-red-200 mb-2">
<AlertTriangle className="w-4 h-4 inline mr-1" />
This action cannot be undone
</p>
<p className="text-xs text-red-700 dark:text-red-300">
This will delete all your preferences, statistics, connected accounts, and subscription data.
</p>
</div>
<Button
variant="default"
onClick={() => setShowDeleteConfirm(true)}
className="w-full bg-red-600 hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-800 text-white"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete my account and data
</Button>
</div>
) : (
<div className="space-y-3">
<div className="p-4 bg-red-100 dark:bg-red-900/30 border-2 border-red-300 dark:border-red-700 rounded-lg">
<p className="font-semibold text-red-900 dark:text-red-100 mb-2">Are you absolutely sure?</p>
<p className="text-sm text-red-800 dark:text-red-200">
This will permanently delete:
</p>
<ul className="text-sm text-red-700 dark:text-red-300 mt-2 space-y-1 list-disc list-inside">
<li>All your email account connections</li>
<li>All sorting statistics</li>
<li>All preferences and settings</li>
<li>Your subscription (if active)</li>
</ul>
</div>
<div className="flex gap-2">
<Button
variant="default"
onClick={onDeleteAccount}
className="flex-1 bg-red-600 hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-800 text-white"
>
Yes, delete everything
</Button>
<Button
variant="outline"
onClick={() => setShowDeleteConfirm(false)}
className="flex-1"
>
Cancel
</Button>
</div>
</div>
)}
</CardContent>
</Card>
)}
</div>
)
}
+101
View File
@@ -0,0 +1,101 @@
import { Button } from '@/components/ui/button'
import { Share2, Copy, Check } from 'lucide-react'
import { useState } from 'react'
import { trackReferralShared } from '@/lib/analytics'
import { useAuth } from '@/context/AuthContext'
interface ShareResultsProps {
sortedCount: number
referralCode?: string
}
export function ShareResults({ sortedCount, referralCode }: ShareResultsProps) {
const [copied, setCopied] = useState(false)
const { user } = useAuth()
const shareText = `I cleaned up ${sortedCount} emails with EmailSorter${referralCode ? `! Use code ${referralCode} for a bonus.` : '!'}`
const shareUrl = referralCode
? `${window.location.origin}?ref=${referralCode}`
: window.location.origin
const handleCopy = async () => {
const text = `${shareText}\n${shareUrl}`
try {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('Failed to copy:', err)
}
}
const handleShare = async () => {
if (navigator.share) {
try {
await navigator.share({
title: 'EmailSorter - Clean Inbox',
text: shareText,
url: shareUrl,
})
if (user?.$id && referralCode) {
trackReferralShared(user.$id, referralCode)
}
} catch (err) {
// User cancelled or error
console.error('Share failed:', err)
}
} else {
// Fallback to copy
handleCopy()
}
}
if (sortedCount < 10) {
return null // Don't show for small results
}
return (
<div className="bg-gradient-to-r from-primary-50 to-accent-50 border border-primary-200 rounded-lg p-4">
<div className="flex items-start gap-3 mb-3">
<div className="w-10 h-10 rounded-lg bg-primary-500 flex items-center justify-center flex-shrink-0">
<Share2 className="w-5 h-5 text-white" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-slate-900 mb-1">Share your success!</h3>
<p className="text-sm text-slate-600">
{shareText}
</p>
</div>
</div>
<div className="flex gap-2">
{typeof navigator !== 'undefined' && 'share' in navigator && typeof navigator.share === 'function' && (
<Button
onClick={handleShare}
variant="outline"
className="flex-1 border-primary-300 text-primary-700 hover:bg-primary-50"
>
<Share2 className="w-4 h-4 mr-2" />
Share
</Button>
)}
<Button
onClick={handleCopy}
variant="outline"
className="flex-1 border-primary-300 text-primary-700 hover:bg-primary-50"
>
{copied ? (
<>
<Check className="w-4 h-4 mr-2" />
Copied!
</>
) : (
<>
<Copy className="w-4 h-4 mr-2" />
Copy
</>
)}
</Button>
</div>
</div>
)
}
+113
View File
@@ -0,0 +1,113 @@
import { Button } from '@/components/ui/button'
import { X, Sparkles, Zap, Infinity } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { trackUpgradeClicked } from '@/lib/analytics'
import { useAuth } from '@/context/AuthContext'
interface UpgradePromptProps {
title?: string
benefits?: string[]
source: 'after_sort' | 'limit_reached' | 'auto_schedule' | 'after_rules'
onDismiss: () => void
}
const defaultBenefits: Record<string, string[]> = {
after_sort: [
'Sort unlimited emails automatically',
'Set up auto-schedule for hands-free organization',
'Access all premium features',
],
limit_reached: [
'Unlimited email sorting',
'No monthly limits',
'Priority support',
],
auto_schedule: [
'Auto-schedule available in Pro plan',
'Set it and forget it',
'Keep your inbox clean automatically',
],
after_rules: [
'Automate with Pro plan',
'Unlimited rules and customizations',
'Advanced AI features',
],
}
export function UpgradePrompt({
title = 'Keep your inbox clean automatically',
benefits,
source,
onDismiss,
}: UpgradePromptProps) {
const navigate = useNavigate()
const { user } = useAuth()
const promptBenefits = benefits || defaultBenefits[source] || defaultBenefits.after_sort
// Check if this prompt was already shown in this session
const sessionKey = `upgrade_prompt_shown_${source}`
const wasShown = sessionStorage.getItem(sessionKey) === 'true'
if (wasShown) {
return null
}
const handleUpgrade = () => {
if (user?.$id) {
trackUpgradeClicked(user.$id, source)
}
navigate('/settings?tab=subscription')
}
const handleDismiss = () => {
sessionStorage.setItem(sessionKey, 'true')
onDismiss()
}
return (
<div className="bg-gradient-to-r from-primary-50 to-accent-50 border-2 border-primary-200 rounded-xl p-4 sm:p-6 shadow-lg">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-primary-500 flex items-center justify-center flex-shrink-0">
<Sparkles className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-lg font-bold text-slate-900">{title}</h3>
</div>
</div>
<button
onClick={handleDismiss}
className="text-slate-400 hover:text-slate-600 transition-colors flex-shrink-0"
>
<X className="w-5 h-5" />
</button>
</div>
<ul className="space-y-2 mb-4">
{promptBenefits.map((benefit, idx) => (
<li key={idx} className="flex items-start gap-2 text-sm text-slate-700">
<Zap className="w-4 h-4 text-primary-500 flex-shrink-0 mt-0.5" />
<span>{benefit}</span>
</li>
))}
</ul>
<div className="flex gap-3">
<Button
onClick={handleUpgrade}
className="flex-1 bg-primary-600 hover:bg-primary-700"
>
<Infinity className="w-4 h-4 mr-2" />
Upgrade
</Button>
<Button
variant="outline"
onClick={handleDismiss}
className="border-slate-300 text-slate-600 hover:bg-slate-50"
>
Not now
</Button>
</div>
</div>
)
}
+30 -23
View File
@@ -3,7 +3,6 @@ import {
Zap,
Shield,
Clock,
Tags,
Settings,
Inbox,
Filter
@@ -11,39 +10,42 @@ import {
const features = [
{
icon: Brain,
title: "AI-powered categorization",
description: "Our AI automatically recognizes whether an email is an invoice, newsletter, or important message.",
color: "from-violet-500 to-purple-600"
icon: Inbox,
title: "Stop drowning in emails",
description: "Clear inbox, less stress. Automatically sort newsletters, promotions, and social updates away from what matters.",
color: "from-violet-500 to-purple-600",
highlight: true,
},
{
icon: Zap,
title: "Real-time sorting",
description: "New emails are categorized instantly. Your inbox arrives already sorted.",
color: "from-amber-500 to-orange-600"
title: "One-click smart rules",
description: "AI suggests, you approve. Create smart rules in seconds and apply them with one click.",
color: "from-amber-500 to-orange-600",
highlight: true,
},
{
icon: Tags,
title: "Smart labels",
description: "Automatic labels for VIP, clients, invoices, newsletters, social media and more.",
color: "from-blue-500 to-cyan-600"
icon: Settings,
title: "Automation that keeps working",
description: "Set it and forget it. Your inbox stays organized automatically, day after day.",
color: "from-blue-500 to-cyan-600",
highlight: true,
},
{
icon: Brain,
title: "AI-powered smart sorting",
description: "Our AI automatically recognizes whether an email is an invoice, newsletter, or important message.",
color: "from-green-500 to-emerald-600"
},
{
icon: Shield,
title: "GDPR compliant",
description: "Your data stays secure. We only read email headers and metadata for sorting.",
color: "from-green-500 to-emerald-600"
color: "from-pink-500 to-rose-600"
},
{
icon: Clock,
title: "Save time",
description: "Average 2 hours per week less on email organization. More time for what matters.",
color: "from-pink-500 to-rose-600"
},
{
icon: Settings,
title: "Fully customizable",
description: "Define your own rules, VIP contacts, and categories based on your needs.",
color: "from-indigo-500 to-blue-600"
},
]
@@ -119,18 +121,23 @@ interface FeatureCardProps {
description: string
color: string
index: number
highlight?: boolean
}
function FeatureCard({ icon: Icon, title, description, color, index }: FeatureCardProps) {
function FeatureCard({ icon: Icon, title, description, color, index, highlight }: FeatureCardProps) {
return (
<div
className="group bg-white rounded-2xl p-6 border border-slate-200 hover:border-primary-200 hover:shadow-lg transition-all duration-300"
className={`group rounded-2xl p-6 border transition-all duration-300 ${
highlight
? 'bg-gradient-to-br from-white to-slate-50 border-primary-200 hover:border-primary-300 hover:shadow-xl'
: 'bg-white border-slate-200 hover:border-primary-200 hover:shadow-lg'
}`}
style={{ animationDelay: `${index * 0.1}s` }}
>
<div className={`w-14 h-14 rounded-xl bg-gradient-to-br ${color} flex items-center justify-center mb-5 group-hover:scale-110 transition-transform duration-300`}>
<div className={`w-14 h-14 rounded-xl bg-gradient-to-br ${color} flex items-center justify-center mb-5 group-hover:scale-110 transition-transform duration-300 shadow-lg`}>
<Icon className="w-7 h-7 text-white" />
</div>
<h3 className="text-xl font-semibold text-slate-900 mb-2">{title}</h3>
<h3 className={`${highlight ? 'text-2xl' : 'text-xl'} font-semibold text-slate-900 mb-2`}>{title}</h3>
<p className="text-slate-600">{description}</p>
</div>
)
+6 -1
View File
@@ -13,7 +13,7 @@ export function Footer() {
<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>
E-Mail-<span className="text-primary-400">Sorter</span>
</span>
</Link>
<p className="text-sm text-slate-400 mb-6">
@@ -132,6 +132,11 @@ export function Footer() {
Privacy Policy
</Link>
</li>
<li>
<Link to="/privacy-security" className="hover:text-white transition-colors">
Privacy & Security
</Link>
</li>
<li>
<Link to="/imprint" className="hover:text-white transition-colors">
Impressum
+12 -12
View File
@@ -37,35 +37,35 @@ export function Hero() {
</Badge>
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-extrabold text-white leading-tight mb-6">
Your inbox.
Clean inbox automatically
<br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-accent-400 to-primary-300">
Finally organized.
in minutes.
</span>
</h1>
<p className="text-lg sm:text-xl text-slate-300 mb-8 max-w-xl mx-auto lg:mx-0">
EmailSorter uses AI to automatically categorize your emails.
Newsletters, invoices, important contacts – everything lands
exactly where it belongs.
Create smart rules, apply in one click, keep it clean with automation.
Stop drowning in emails and start focusing on what matters.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center lg:justify-start mb-8">
<Button
size="xl"
onClick={handleCTAClick}
className="group"
onClick={() => navigate('/setup?demo=true')}
className="group bg-accent-500 hover:bg-accent-600"
>
Start 14-day free trial
<ArrowRight className="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" />
<Sparkles className="w-5 h-5 mr-2" />
Try Demo
</Button>
<Button
size="xl"
onClick={handleCTAClick}
variant="outline"
className="bg-white/10 border-white/20 text-white hover:bg-white/20"
onClick={() => document.getElementById('how-it-works')?.scrollIntoView({ behavior: 'smooth' })}
className="bg-white/10 border-white/20 text-white hover:bg-white/20 group"
>
See how it works
Connect inbox
<ArrowRight className="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" />
</Button>
</div>
+16 -16
View File
@@ -28,7 +28,7 @@ export function Navbar() {
}, [location.pathname, navigate])
return (
<nav className="fixed top-0 left-0 right-0 z-50 bg-white/80 backdrop-blur-lg border-b border-slate-100">
<nav className="fixed top-0 left-0 right-0 z-50 bg-white/80 dark:bg-slate-900/80 backdrop-blur-lg border-b border-slate-100 dark:border-slate-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Logo */}
@@ -36,8 +36,8 @@ export function Navbar() {
<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 className="text-xl font-bold text-slate-900 dark:text-slate-100">
E-Mail-<span className="text-primary-600 dark:text-primary-400">Sorter</span>
</span>
</Link>
@@ -45,25 +45,25 @@ export function Navbar() {
<div className="hidden md:flex items-center gap-8">
<button
onClick={() => scrollToSection('features')}
className="text-slate-600 hover:text-slate-900 font-medium transition-colors"
className="text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-slate-100 font-medium transition-colors"
>
Features
</button>
<button
onClick={() => scrollToSection('how-it-works')}
className="text-slate-600 hover:text-slate-900 font-medium transition-colors"
className="text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-slate-100 font-medium transition-colors"
>
How it works
</button>
<button
onClick={() => scrollToSection('pricing')}
className="text-slate-600 hover:text-slate-900 font-medium transition-colors"
className="text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-slate-100 font-medium transition-colors"
>
Pricing
</button>
<button
onClick={() => scrollToSection('faq')}
className="text-slate-600 hover:text-slate-900 font-medium transition-colors"
className="text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-slate-100 font-medium transition-colors"
>
FAQ
</button>
@@ -90,14 +90,14 @@ export function Navbar() {
{/* Mobile menu button */}
<button
className="md:hidden p-2.5 rounded-lg hover:bg-slate-100 active:bg-slate-200 touch-manipulation"
className="md:hidden p-2.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 active:bg-slate-200 dark:active:bg-slate-700 touch-manipulation"
onClick={() => setIsMenuOpen(!isMenuOpen)}
aria-label={isMenuOpen ? "Close menu" : "Open menu"}
>
{isMenuOpen ? (
<X className="w-6 h-6 text-slate-600" />
<X className="w-6 h-6 text-slate-600 dark:text-slate-300" />
) : (
<Menu className="w-6 h-6 text-slate-600" />
<Menu className="w-6 h-6 text-slate-600 dark:text-slate-300" />
)}
</button>
</div>
@@ -105,33 +105,33 @@ export function Navbar() {
{/* Mobile menu */}
{isMenuOpen && (
<div className="md:hidden bg-white border-t border-slate-100 shadow-lg animate-in slide-in-from-top-2 duration-200">
<div className="md:hidden bg-white dark:bg-slate-900 border-t border-slate-100 dark:border-slate-700 shadow-lg animate-in slide-in-from-top-2 duration-200">
<div className="px-3 py-3 space-y-1">
<button
onClick={() => scrollToSection('features')}
className="block w-full text-left px-4 py-3 text-sm font-medium text-slate-700 hover:bg-slate-50 active:bg-slate-100 rounded-lg transition-colors touch-manipulation"
className="block w-full text-left px-4 py-3 text-sm font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-800 active:bg-slate-100 dark:active:bg-slate-700 rounded-lg transition-colors touch-manipulation"
>
Features
</button>
<button
onClick={() => scrollToSection('how-it-works')}
className="block w-full text-left px-4 py-3 text-sm font-medium text-slate-700 hover:bg-slate-50 active:bg-slate-100 rounded-lg transition-colors touch-manipulation"
className="block w-full text-left px-4 py-3 text-sm font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-800 active:bg-slate-100 dark:active:bg-slate-700 rounded-lg transition-colors touch-manipulation"
>
How it works
</button>
<button
onClick={() => scrollToSection('pricing')}
className="block w-full text-left px-4 py-3 text-sm font-medium text-slate-700 hover:bg-slate-50 active:bg-slate-100 rounded-lg transition-colors touch-manipulation"
className="block w-full text-left px-4 py-3 text-sm font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-800 active:bg-slate-100 dark:active:bg-slate-700 rounded-lg transition-colors touch-manipulation"
>
Pricing
</button>
<button
onClick={() => scrollToSection('faq')}
className="block w-full text-left px-4 py-3 text-sm font-medium text-slate-700 hover:bg-slate-50 active:bg-slate-100 rounded-lg transition-colors touch-manipulation"
className="block w-full text-left px-4 py-3 text-sm font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-800 active:bg-slate-100 dark:active:bg-slate-700 rounded-lg transition-colors touch-manipulation"
>
FAQ
</button>
<div className="pt-3 mt-3 border-t border-slate-100 space-y-2">
<div className="pt-3 mt-3 border-t border-slate-100 dark:border-slate-700 space-y-2">
{user ? (
<Button className="w-full h-11" onClick={() => navigate('/dashboard')}>
Dashboard
+6 -6
View File
@@ -8,16 +8,16 @@ const badgeVariants = cva(
variants: {
variant: {
default:
"border-transparent bg-primary-100 text-primary-700",
"border-transparent bg-primary-100 dark:bg-primary-900 text-primary-700 dark:text-primary-200",
secondary:
"border-transparent bg-slate-100 text-slate-700",
"border-transparent bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-200",
success:
"border-transparent bg-green-100 text-green-700",
"border-transparent bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200",
warning:
"border-transparent bg-amber-100 text-amber-700",
"border-transparent bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-200",
destructive:
"border-transparent bg-red-100 text-red-700",
outline: "text-slate-600 border-slate-200",
"border-transparent bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-200",
outline: "text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-700",
},
},
defaultVariants: {
+7 -7
View File
@@ -4,22 +4,22 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-semibold ring-offset-white transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
"inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-semibold ring-offset-white dark:ring-offset-slate-900 transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-primary-600 text-white hover:bg-primary-700 shadow-lg shadow-primary-600/25 hover:shadow-primary-600/40",
"bg-primary-600 text-white hover:bg-primary-700 shadow-lg shadow-primary-600/25 hover:shadow-primary-600/40 dark:bg-primary-500 dark:hover:bg-primary-400",
secondary:
"bg-slate-100 text-slate-900 hover:bg-slate-200",
"bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700",
outline:
"border-2 border-slate-200 bg-transparent hover:bg-slate-50 hover:border-slate-300",
"border-2 border-slate-200 bg-transparent hover:bg-slate-50 hover:border-slate-300 dark:border-slate-700 dark:text-slate-100 dark:hover:bg-slate-800 dark:hover:border-slate-600",
ghost:
"hover:bg-slate-100 hover:text-slate-900",
"hover:bg-slate-100 hover:text-slate-900 dark:hover:bg-slate-800 dark:hover:text-slate-100",
link:
"text-primary-600 underline-offset-4 hover:underline",
"text-primary-600 underline-offset-4 hover:underline dark:text-primary-400 dark:hover:text-primary-300",
accent:
"bg-accent-500 text-white hover:bg-accent-600 shadow-lg shadow-accent-500/25",
"bg-accent-500 text-white hover:bg-accent-600 shadow-lg shadow-accent-500/25 dark:bg-accent-500 dark:hover:bg-accent-400",
},
size: {
default: "h-11 px-6 py-2",
+3 -3
View File
@@ -8,7 +8,7 @@ const Card = React.forwardRef<
<div
ref={ref}
className={cn(
"rounded-2xl border border-slate-200 bg-white shadow-sm transition-shadow hover:shadow-md",
"rounded-2xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 shadow-sm dark:shadow-slate-900/20 transition-shadow hover:shadow-md dark:hover:shadow-slate-900/30",
className
)}
{...props}
@@ -35,7 +35,7 @@ const CardTitle = React.forwardRef<
<h3
ref={ref}
className={cn(
"text-2xl font-bold leading-none tracking-tight text-slate-900",
"text-2xl font-bold leading-none tracking-tight text-slate-900 dark:text-slate-100",
className
)}
{...props}
@@ -49,7 +49,7 @@ const CardDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-slate-500", className)}
className={cn("text-sm text-slate-500 dark:text-slate-400", className)}
{...props}
/>
))
+1 -1
View File
@@ -13,7 +13,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
"flex h-11 w-full rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm text-slate-900 transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:border-primary-500 disabled:cursor-not-allowed disabled:opacity-50",
"flex h-11 w-full rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 px-4 py-2 text-sm text-slate-900 dark:text-slate-100 transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-400 dark:placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:border-primary-500 disabled:cursor-not-allowed disabled:opacity-50",
error && "border-red-500 focus-visible:ring-red-500",
className
)}
+148
View File
@@ -0,0 +1,148 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const SidePanel = DialogPrimitive.Root
const SidePanelTrigger = DialogPrimitive.Trigger
const SidePanelPortal = DialogPrimitive.Portal
const SidePanelClose = DialogPrimitive.Close
const SidePanelOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/50 dark:bg-black/70 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
SidePanelOverlay.displayName = DialogPrimitive.Overlay.displayName
const SidePanelContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<SidePanelPortal>
<SidePanelOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed z-50 gap-4 bg-white dark:bg-slate-900 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-right-full",
"top-0 right-0 h-full w-full sm:w-[480px] border-l border-slate-200 dark:border-slate-700",
"flex flex-col",
className
)}
{...props}
>
{children}
</DialogPrimitive.Content>
</SidePanelPortal>
))
SidePanelContent.displayName = DialogPrimitive.Content.displayName
const SidePanelHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 p-6 border-b border-slate-200 dark:border-slate-700",
className
)}
{...props}
/>
)
SidePanelHeader.displayName = "SidePanelHeader"
const SidePanelTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-xl font-semibold leading-none tracking-tight text-slate-900 dark:text-slate-100",
className
)}
{...props}
/>
))
SidePanelTitle.displayName = DialogPrimitive.Title.displayName
const SidePanelDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-slate-500 dark:text-slate-400", className)}
{...props}
/>
))
SidePanelDescription.displayName = DialogPrimitive.Description.displayName
const SidePanelCloseButton = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Close>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Close>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Close
ref={ref}
className={cn(
"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white dark:ring-offset-slate-900 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-slate-100 dark:data-[state=open]:bg-slate-800",
className
)}
{...props}
>
<X className="h-4 w-4 text-slate-500 dark:text-slate-400" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
))
SidePanelCloseButton.displayName = "SidePanelCloseButton"
const SidePanelBody = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex-1 overflow-y-auto p-6", className)}
{...props}
/>
)
SidePanelBody.displayName = "SidePanelBody"
const SidePanelFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 p-6 border-t border-slate-200 dark:border-slate-700",
className
)}
{...props}
/>
)
SidePanelFooter.displayName = "SidePanelFooter"
export {
SidePanel,
SidePanelPortal,
SidePanelOverlay,
SidePanelTrigger,
SidePanelClose,
SidePanelContent,
SidePanelHeader,
SidePanelTitle,
SidePanelDescription,
SidePanelCloseButton,
SidePanelBody,
SidePanelFooter,
}
+41
View File
@@ -0,0 +1,41 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface SliderProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
value?: number
onValueChange?: (value: number) => void
}
const Slider = React.forwardRef<HTMLInputElement, SliderProps>(
({ className, value, onValueChange, min = 0, max = 365, step = 1, ...props }, ref) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = parseInt(e.target.value)
onValueChange?.(newValue)
}
return (
<input
type="range"
ref={ref}
className={cn(
"w-full h-2 bg-slate-200 dark:bg-slate-700 rounded-lg appearance-none cursor-pointer",
"accent-primary-500 dark:accent-primary-600",
"[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary-500 [&::-webkit-slider-thumb]:cursor-pointer",
"dark:[&::-webkit-slider-thumb]:bg-primary-600",
"[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary-500 [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:cursor-pointer",
"dark:[&::-moz-range-thumb]:bg-primary-600",
className
)}
value={value}
onChange={handleChange}
min={min}
max={max}
step={step}
{...props}
/>
)
}
)
Slider.displayName = "Slider"
export { Slider }
+52
View File
@@ -0,0 +1,52 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-lg bg-slate-100 dark:bg-slate-800 p-1 text-slate-500 dark:text-slate-400",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-white dark:ring-offset-slate-900 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white dark:data-[state=active]:bg-slate-700 data-[state=active]:text-slate-900 dark:data-[state=active]:text-slate-100 data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-white dark:ring-offset-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
+186
View File
@@ -0,0 +1,186 @@
/**
* Theme Hook
* Handles dark mode detection and Dark Reader compatibility
* Uses MutationObserver for efficient Dark Reader detection
*/
import { useEffect, useState } from 'react'
export function useTheme() {
const [isDark, setIsDark] = useState(false)
const [hasDarkReader, setHasDarkReader] = useState(false)
useEffect(() => {
const html = document.documentElement
// Helper function to apply/remove dark mode
const applyDarkMode = (shouldBeDark: boolean) => {
setIsDark(shouldBeDark)
if (shouldBeDark) {
html.classList.add('dark')
html.setAttribute('data-theme', 'dark')
} else {
html.classList.remove('dark')
html.setAttribute('data-theme', 'light')
}
}
// Enhanced Dark Reader detection with multiple methods
const detectDarkReader = (): boolean => {
// Method 1: Check for Dark Reader data attributes on html element
const hasDarkReaderAttributes =
html.hasAttribute('data-darkreader-mode') ||
html.hasAttribute('data-darkreader-scheme') ||
html.hasAttribute('data-darkreader-policy')
// Method 2: Check for Dark Reader stylesheet or meta tags
const hasDarkReaderMeta =
document.querySelector('meta[name="darkreader"]') !== null ||
document.querySelector('style[data-darkreader]') !== null
// Method 3: Check computed styles for filter/invert (Dark Reader uses CSS filters)
const computedStyle = window.getComputedStyle(html)
const hasFilter = computedStyle.filter && computedStyle.filter !== 'none'
const hasInvert = computedStyle.filter?.includes('invert') ||
computedStyle.filter?.includes('brightness')
// Method 4: Check for Dark Reader's characteristic background color
// Dark Reader often sets a specific dark background
const bgColor = computedStyle.backgroundColor
const isDarkReaderBg = bgColor === 'rgb(24, 26, 27)' ||
bgColor === 'rgb(18, 18, 18)' ||
(window.matchMedia('(prefers-color-scheme: dark)').matches &&
bgColor !== 'rgba(0, 0, 0, 0)' &&
bgColor !== 'transparent' &&
!html.classList.contains('dark'))
// Method 5: Check for Dark Reader injected styles
const styleSheets = Array.from(document.styleSheets)
const hasDarkReaderStylesheet = styleSheets.some(sheet => {
try {
const href = sheet.href || ''
return href.includes('darkreader') ||
(sheet.ownerNode as Element)?.getAttribute('data-darkreader') !== null
} catch {
return false
}
})
return hasDarkReaderAttributes ||
hasDarkReaderMeta ||
(hasFilter && hasInvert) ||
isDarkReaderBg ||
hasDarkReaderStylesheet
}
// Check system preference
const checkSystemPreference = (): boolean => {
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
// Update theme based on current state
const updateTheme = () => {
const darkReaderDetected = detectDarkReader()
const systemPrefersDark = checkSystemPreference()
setHasDarkReader(darkReaderDetected)
// Only apply dark mode if system prefers it AND Dark Reader is not active
if (systemPrefersDark && !darkReaderDetected) {
applyDarkMode(true)
} else {
applyDarkMode(false)
}
}
// Initial check
updateTheme()
// Listen for system preference changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleSystemPreferenceChange = (e: MediaQueryListEvent) => {
updateTheme()
}
// Modern browsers
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener('change', handleSystemPreferenceChange)
} else {
// Fallback for older browsers
mediaQuery.addListener(handleSystemPreferenceChange)
}
// MutationObserver for Dark Reader attribute changes (more efficient than setInterval)
const observer = new MutationObserver((mutations) => {
let shouldUpdate = false
mutations.forEach((mutation) => {
// Check if Dark Reader attributes were added/removed
if (mutation.type === 'attributes') {
const attrName = mutation.attributeName
if (attrName?.startsWith('data-darkreader') ||
attrName === 'class' ||
attrName === 'data-theme') {
shouldUpdate = true
}
}
// Check if Dark Reader elements were added/removed
if (mutation.type === 'childList') {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as Element
if (el.hasAttribute?.('data-darkreader') ||
el.tagName === 'META' && el.getAttribute('name') === 'darkreader' ||
el.tagName === 'STYLE' && el.hasAttribute('data-darkreader')) {
shouldUpdate = true
}
}
})
}
})
if (shouldUpdate) {
updateTheme()
}
})
// Observe html element for Dark Reader attribute changes
observer.observe(html, {
attributes: true,
attributeFilter: ['data-darkreader-mode', 'data-darkreader-scheme', 'data-darkreader-policy', 'class', 'data-theme'],
childList: true,
subtree: false
})
// Also observe document head for Dark Reader meta/stylesheets
if (document.head) {
observer.observe(document.head, {
childList: true,
subtree: false
})
}
// Fallback: Periodic check (reduced frequency, only as safety net)
// This catches edge cases where MutationObserver might miss something
const fallbackInterval = setInterval(() => {
const currentDarkReader = detectDarkReader()
if (currentDarkReader !== hasDarkReader) {
updateTheme()
}
}, 5000) // Check every 5 seconds (reduced from 2 seconds)
return () => {
// Cleanup
if (mediaQuery.removeEventListener) {
mediaQuery.removeEventListener('change', handleSystemPreferenceChange)
} else {
mediaQuery.removeListener(handleSystemPreferenceChange)
}
observer.disconnect()
clearInterval(fallbackInterval)
}
}, [])
return { isDark, hasDarkReader }
}
+139 -1
View File
@@ -24,7 +24,7 @@
--color-accent-500: #10b981;
--color-accent-600: #059669;
/* Neutral/Slate colors */
/* Neutral/Slate colors - Keep original values for Tailwind compatibility */
--color-slate-50: #f8fafc;
--color-slate-100: #f1f5f9;
--color-slate-200: #e2e8f0;
@@ -38,6 +38,49 @@
--color-slate-950: #020617;
}
/* Dark Mode specific color variables for optimized appearance */
:root.dark,
:root[data-theme="dark"] {
/* Optimized dark mode colors - not pure black, more pleasant */
--color-bg-dark: #1e293b; /* slate-800 - pleasant dark background */
--color-bg-card-dark: #334155; /* slate-700 - cards stand out from background */
--color-text-dark: #f1f5f9; /* slate-100 - soft white, not pure white */
--color-text-muted-dark: #cbd5e1; /* slate-300 - muted text */
--color-border-dark: #475569; /* slate-600 - visible but subtle borders */
--color-accent-dark: #4ade80; /* primary-400 - slightly desaturated for dark mode */
}
/* Prevent double inversion when Dark Reader is active - Force Light Mode */
:root[data-darkreader-mode],
:root[data-darkreader-scheme],
:root[data-darkreader-policy] {
/* Explicitly remove dark mode classes and force light theme */
color-scheme: light !important;
}
:root[data-darkreader-mode] body,
:root[data-darkreader-scheme] body,
:root[data-darkreader-policy] body {
background-color: var(--color-slate-50) !important;
color: var(--color-slate-900) !important;
}
/* Prevent Dark Reader from applying dark mode when it's active */
:root[data-darkreader-mode] .dark,
:root[data-darkreader-scheme] .dark,
:root[data-darkreader-policy] .dark {
/* Force light mode styles even if dark class is present */
background-color: var(--color-slate-50) !important;
color: var(--color-slate-900) !important;
}
:root[data-darkreader-mode] *,
:root[data-darkreader-scheme] *,
:root[data-darkreader-policy] * {
/* Prevent Dark Reader from inverting our colors */
filter: none !important;
}
/* Base styles */
html {
scroll-behavior: smooth;
@@ -50,6 +93,17 @@ body {
/* Improve touch scrolling on mobile */
-webkit-overflow-scrolling: touch;
overflow-scrolling: touch;
/* Base colors - Tailwind will handle dark mode */
background-color: var(--color-slate-50);
color: var(--color-slate-900);
transition: background-color 0.2s ease, color 0.2s ease;
}
/* Dark mode body adjustments */
:root.dark body,
:root[data-theme="dark"] body {
background-color: var(--color-slate-900);
color: var(--color-slate-50);
}
/* Improve touch targets on mobile */
@@ -65,6 +119,11 @@ body {
}
}
:root.dark *,
:root[data-theme="dark"] * {
-webkit-tap-highlight-color: rgba(34, 197, 94, 0.2);
}
/* Touch manipulation for better performance */
.touch-manipulation {
touch-action: manipulation;
@@ -79,6 +138,12 @@ body {
color: var(--color-primary-900);
}
:root.dark ::selection,
:root[data-theme="dark"] ::selection {
background-color: var(--color-primary-600);
color: var(--color-primary-50);
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
@@ -98,11 +163,33 @@ body {
background: var(--color-slate-400);
}
/* Dark mode scrollbar - optimized colors */
:root.dark ::-webkit-scrollbar-track,
:root[data-theme="dark"] ::-webkit-scrollbar-track {
background: var(--color-bg-card-dark, var(--color-slate-700));
}
:root.dark ::-webkit-scrollbar-thumb,
:root[data-theme="dark"] ::-webkit-scrollbar-thumb {
background: var(--color-border-dark, var(--color-slate-600));
border-radius: 4px;
}
:root.dark ::-webkit-scrollbar-thumb:hover,
:root[data-theme="dark"] ::-webkit-scrollbar-thumb:hover {
background: var(--color-slate-500);
}
/* Gradient backgrounds */
.gradient-hero {
background: linear-gradient(135deg, var(--color-slate-900) 0%, var(--color-primary-900) 50%, var(--color-slate-800) 100%);
}
:root.dark .gradient-hero,
:root[data-theme="dark"] .gradient-hero {
background: linear-gradient(135deg, var(--color-slate-950) 0%, var(--color-primary-950) 50%, var(--color-slate-900) 100%);
}
.gradient-mesh {
background-image:
radial-gradient(at 40% 20%, var(--color-primary-500) 0px, transparent 50%),
@@ -112,6 +199,17 @@ body {
radial-gradient(at 0% 100%, var(--color-primary-600) 0px, transparent 50%);
}
:root.dark .gradient-mesh,
:root[data-theme="dark"] .gradient-mesh {
background-image:
radial-gradient(at 40% 20%, rgba(34, 197, 94, 0.15) 0px, transparent 50%),
radial-gradient(at 80% 0%, rgba(16, 185, 129, 0.15) 0px, transparent 50%),
radial-gradient(at 0% 50%, rgba(22, 163, 74, 0.15) 0px, transparent 50%),
radial-gradient(at 80% 50%, rgba(52, 211, 153, 0.15) 0px, transparent 50%),
radial-gradient(at 0% 100%, rgba(16, 185, 129, 0.15) 0px, transparent 50%);
opacity: 0.8;
}
/* Animation classes */
@keyframes float {
0%, 100% { transform: translateY(0px); }
@@ -142,3 +240,43 @@ body {
.stagger-3 { animation-delay: 0.3s; }
.stagger-4 { animation-delay: 0.4s; }
.stagger-5 { animation-delay: 0.5s; }
/* Dark mode specific adjustments */
:root.dark,
:root[data-theme="dark"] {
/* Ensure good contrast for focus states */
--focus-ring-color: var(--color-accent-dark, var(--color-primary-400));
--focus-ring-offset: var(--color-bg-dark, var(--color-slate-800));
}
/* Links in dark mode - optimized for visibility and comfort */
:root.dark a:not([class*="text-"]),
:root[data-theme="dark"] a:not([class*="text-"]) {
color: var(--color-accent-dark, var(--color-primary-400));
}
:root.dark a:not([class*="text-"]):hover,
:root[data-theme="dark"] a:not([class*="text-"]):hover {
color: var(--color-primary-300);
}
/* Borders in dark mode - subtle but visible with better contrast */
:root.dark hr:not([class*="border-"]),
:root[data-theme="dark"] hr:not([class*="border-"]) {
border-color: var(--color-border-dark, var(--color-slate-600));
}
/* Code blocks and pre elements - better contrast */
:root.dark code:not([class]),
:root[data-theme="dark"] code:not([class]),
:root.dark pre:not([class]),
:root[data-theme="dark"] pre:not([class]) {
background-color: var(--color-bg-card-dark, var(--color-slate-700));
color: var(--color-text-dark, var(--color-slate-100));
border-color: var(--color-border-dark, var(--color-slate-600));
}
/* Prevent transitions on initial load to avoid FOUC */
html:not(.dark-mode-initialized) body {
transition: none;
}
+134 -2
View File
@@ -15,13 +15,15 @@ export interface TrackingParams {
}
export interface ConversionEvent {
type: 'page_view' | 'signup' | 'trial_start' | 'purchase' | 'email_connected'
type: 'page_view' | 'signup' | 'trial_start' | 'purchase' | 'email_connected' | 'onboarding_step' | 'provider_connected' | 'demo_used' | 'suggested_rules_generated' | 'rule_created' | 'rules_applied' | 'limit_reached' | 'upgrade_clicked' | 'referral_shared' | 'sort_completed' | 'account_deleted'
userId?: string
metadata?: Record<string, any>
sessionId?: string
}
const STORAGE_KEY = 'emailsorter_utm_params'
const USER_ID_KEY = 'emailsorter_user_id'
const SESSION_ID_KEY = 'emailsorter_session_id'
/**
* Parse UTM parameters from URL
@@ -152,11 +154,11 @@ export async function trackEvent(
const payload = {
...event,
userId: event.userId || userId || undefined,
sessionId: event.sessionId || getSessionId(),
tracking: params,
timestamp: new Date().toISOString(),
page: window.location.pathname,
referrer: document.referrer || undefined,
userAgent: navigator.userAgent,
}
try {
@@ -312,3 +314,133 @@ export function getTrackingQueryString(): string {
? '&' + new URLSearchParams(entries as string[][]).toString()
: ''
}
/**
* Get or create session ID
*/
function getSessionId(): string {
try {
let sessionId = sessionStorage.getItem(SESSION_ID_KEY)
if (!sessionId) {
sessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
sessionStorage.setItem(SESSION_ID_KEY, sessionId)
}
return sessionId
} catch {
// Fallback if sessionStorage is not available
return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
}
/**
* Track onboarding step
*/
export function trackOnboardingStep(userId: string, step: string): void {
trackEvent({
type: 'onboarding_step',
userId,
metadata: {
step,
timestamp: new Date().toISOString(),
},
})
}
/**
* Track provider connection
*/
export function trackProviderConnected(userId: string, provider: string): void {
trackEvent({
type: 'provider_connected',
userId,
metadata: {
provider,
timestamp: new Date().toISOString(),
},
})
}
/**
* Track demo account usage
*/
export function trackDemoUsed(userId: string): void {
trackEvent({
type: 'demo_used',
userId,
metadata: {
timestamp: new Date().toISOString(),
},
})
}
/**
* Track sort completion
*/
export function trackSortCompleted(userId: string, sortedCount: number, isFirstRun: boolean): void {
trackEvent({
type: 'sort_completed',
userId,
metadata: {
sortedCount,
isFirstRun,
timestamp: new Date().toISOString(),
},
})
}
/**
* Track limit reached
*/
export function trackLimitReached(userId: string, limit: number, used: number): void {
trackEvent({
type: 'limit_reached',
userId,
metadata: {
limit,
used,
timestamp: new Date().toISOString(),
},
})
}
/**
* Track rules applied
*/
export function trackRulesApplied(userId: string, rulesCount: number): void {
trackEvent({
type: 'rules_applied',
userId,
metadata: {
rulesCount,
timestamp: new Date().toISOString(),
},
})
}
/**
* Track upgrade clicked
*/
export function trackUpgradeClicked(userId: string, source: string): void {
trackEvent({
type: 'upgrade_clicked',
userId,
metadata: {
source,
timestamp: new Date().toISOString(),
},
})
}
/**
* Track referral shared
*/
export function trackReferralShared(userId: string, referralCode: string): void {
trackEvent({
type: 'referral_shared',
userId,
metadata: {
referralCode,
timestamp: new Date().toISOString(),
},
})
}
+136
View File
@@ -7,6 +7,8 @@ interface ApiResponse<T> {
code: string
message: string
fields?: Record<string, string[]>
limit?: number
used?: number
}
}
@@ -97,6 +99,14 @@ export const api = {
suggestions: Array<{ type: string; message: string }>
provider?: string
isDemo?: boolean
isFirstRun?: boolean
suggestedRules?: Array<{
type: string
name: string
description: string
confidence: number
action: any
}>
}>('/email/sort', {
method: 'POST',
body: JSON.stringify({ userId, accountId, maxEmails, processAll }),
@@ -205,6 +215,9 @@ export const api = {
return fetchApi<{
status: string
plan: string
isFreeTier: boolean
emailsUsedThisMonth?: number
emailsLimit?: number
features: {
emailAccounts: number
emailsPerDay: number
@@ -279,6 +292,9 @@ export const api = {
enabledCategories: string[]
categoryActions: Record<string, 'inbox' | 'archive_read' | 'star'>
autoDetectCompanies: boolean
cleanup?: any
categoryAdvanced?: Record<string, any>
version?: number
}>(`/preferences/ai-control?userId=${userId}`)
},
@@ -286,6 +302,9 @@ export const api = {
enabledCategories?: string[]
categoryActions?: Record<string, 'inbox' | 'archive_read' | 'star'>
autoDetectCompanies?: boolean
cleanup?: any
categoryAdvanced?: Record<string, any>
version?: number
}) {
return fetchApi<{ success: boolean }>('/preferences/ai-control', {
method: 'POST',
@@ -293,6 +312,57 @@ export const api = {
})
},
// Cleanup Preview - shows what would be cleaned up without actually doing it
// TODO: Backend endpoint needs to be implemented
// GET /api/preferences/ai-control/cleanup/preview?userId=xxx
// Response: { preview: Array<{id, subject, from, date, reason}> }
async getCleanupPreview(userId: string) {
// TODO: Implement backend endpoint
return fetchApi<{
preview: Array<{
id: string
subject: string
from: string
date: string
reason: 'read' | 'promotion'
}>
}>(`/preferences/ai-control/cleanup/preview?userId=${userId}`)
},
// Run cleanup now - executes cleanup for the user
// POST /api/preferences/ai-control/cleanup/run
// Body: { userId: string }
// Response: { success: boolean, data: { readItems: number, promotions: number } }
async runCleanup(userId: string) {
// Uses existing /api/email/cleanup endpoint
return fetchApi<{
usersProcessed: number
emailsProcessed: {
readItems: number
promotions: number
}
errors: Array<{ userId: string; error: string }>
}>('/email/cleanup', {
method: 'POST',
body: JSON.stringify({ userId }),
})
},
// Get cleanup status - last run info and counts
// TODO: Backend endpoint needs to be implemented
// GET /api/preferences/ai-control/cleanup/status?userId=xxx
// Response: { lastRun?: string, lastRunCounts?: { readItems: number, promotions: number } }
async getCleanupStatus(userId: string) {
// TODO: Implement backend endpoint
return fetchApi<{
lastRun?: string
lastRunCounts?: {
readItems: number
promotions: number
}
}>(`/preferences/ai-control/cleanup/status?userId=${userId}`)
},
// ═══════════════════════════════════════════════════════════════════════════
// COMPANY LABELS
// ═══════════════════════════════════════════════════════════════════════════
@@ -386,6 +456,72 @@ export const api = {
uptime: number
}>('/health')
},
// ═══════════════════════════════════════════════════════════════════════════
// ONBOARDING
// ═══════════════════════════════════════════════════════════════════════════
async getOnboardingStatus(userId: string) {
return fetchApi<{
onboarding_step: string
completedSteps: string[]
first_value_seen_at?: string
skipped_at?: string
}>(`/onboarding/status?userId=${userId}`)
},
async updateOnboardingStep(userId: string, step: string, completedSteps: string[] = []) {
return fetchApi<{ step: string; completedSteps: string[] }>('/onboarding/step', {
method: 'POST',
body: JSON.stringify({ userId, step, completedSteps }),
})
},
async skipOnboarding(userId: string) {
return fetchApi<{ skipped: boolean }>('/onboarding/skip', {
method: 'POST',
body: JSON.stringify({ userId }),
})
},
async resumeOnboarding(userId: string) {
return fetchApi<{
onboarding_step: string
completedSteps: string[]
}>('/onboarding/resume', {
method: 'POST',
body: JSON.stringify({ userId }),
})
},
// ═══════════════════════════════════════════════════════════════════════════
// ACCOUNT MANAGEMENT
// ═══════════════════════════════════════════════════════════════════════════
async deleteAccount(userId: string) {
return fetchApi<{ success: boolean }>('/account/delete', {
method: 'DELETE',
body: JSON.stringify({ userId }),
})
},
// ═══════════════════════════════════════════════════════════════════════════
// REFERRALS
// ═══════════════════════════════════════════════════════════════════════════
async getReferralCode(userId: string) {
return fetchApi<{
referralCode: string
referralCount: number
}>(`/referrals/code?userId=${userId}`)
},
async trackReferral(userId: string, referralCode: string) {
return fetchApi<{ success: boolean }>('/referrals/track', {
method: 'POST',
body: JSON.stringify({ userId, referralCode }),
})
},
}
export default api
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -5,11 +5,11 @@ export function Imprint() {
return (
<div className="min-h-screen bg-slate-50">
{/* Header */}
<header className="bg-white border-b border-slate-200">
<header className="bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700">
<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"
className="inline-flex items-center gap-2 text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-slate-100 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
<span className="text-sm font-medium">Back to Home</span>
+1 -1
View File
@@ -41,7 +41,7 @@ export function Login() {
<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>
E-Mail-<span className="text-primary-400">Sorter</span>
</span>
</Link>
+2 -2
View File
@@ -5,11 +5,11 @@ export function Privacy() {
return (
<div className="min-h-screen bg-slate-50">
{/* Header */}
<header className="bg-white border-b border-slate-200">
<header className="bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700">
<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"
className="inline-flex items-center gap-2 text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-slate-100 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
<span className="text-sm font-medium">Back to Home</span>
+21 -10
View File
@@ -3,6 +3,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { useAuth } from '@/context/AuthContext'
import { analytics } from '@/hooks/useAnalytics'
import { captureUTMParams } from '@/lib/analytics'
import { api } from '@/lib/api'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -12,6 +13,7 @@ import { Mail, Lock, User, ArrowRight, AlertCircle, Check, Sparkles } from 'luci
export function Register() {
const [searchParams] = useSearchParams()
const selectedPlan = searchParams.get('plan') || 'pro'
const referralCode = searchParams.get('ref') || null
const [name, setName] = useState('')
const [email, setEmail] = useState('')
@@ -20,7 +22,7 @@ export function Register() {
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { register } = useAuth()
const { register, user } = useAuth()
const navigate = useNavigate()
// Capture UTM parameters on mount
@@ -28,6 +30,22 @@ export function Register() {
captureUTMParams()
}, [])
// Track referral and signup after user is registered
useEffect(() => {
if (user?.$id && referralCode) {
// Track referral if code exists
api.trackReferral(user.$id, referralCode).catch((err) => {
console.error('Failed to track referral:', err)
})
}
if (user?.$id) {
// Track signup conversion with UTM parameters
analytics.trackSignup(user.$id, email)
analytics.setUserId(user.$id)
}
}, [user, referralCode, email])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
@@ -45,14 +63,7 @@ export function Register() {
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)
}
await register(email, password, name)
navigate('/setup')
} catch (err: any) {
setError(err.message || 'Registration failed. Please try again.')
@@ -111,7 +122,7 @@ export function Register() {
<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>
E-Mail-<span className="text-primary-600">Sorter</span>
</span>
</Link>
File diff suppressed because it is too large. Load diff
+193 -41
View File
@@ -4,7 +4,9 @@ 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 { OnboardingProgress } from '@/components/OnboardingProgress'
import { api } from '@/lib/api'
import { trackOnboardingStep, trackProviderConnected, trackDemoUsed } from '@/lib/analytics'
import {
Mail,
ArrowRight,
@@ -24,7 +26,6 @@ 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)
@@ -40,9 +41,48 @@ export function Setup() {
])
const [saving, setSaving] = useState(false)
const [checkingAccounts, setCheckingAccounts] = useState(isFromCheckout)
const [onboardingState, setOnboardingState] = useState<{
onboarding_step: string
completedSteps: string[]
} | null>(null)
const [loadingOnboarding, setLoadingOnboarding] = useState(true)
const { user } = useAuth()
const navigate = useNavigate()
const resumeOnboarding = searchParams.get('resume') === 'true'
// Load onboarding state
useEffect(() => {
if (user?.$id) {
const loadOnboarding = async () => {
try {
const stateRes = await api.getOnboardingStatus(user.$id)
if (stateRes.data) {
setOnboardingState(stateRes.data)
// If resuming, restore step
if (resumeOnboarding && stateRes.data.onboarding_step !== 'completed' && stateRes.data.onboarding_step !== 'not_started') {
const stepMap: Record<string, Step> = {
'connect': 'connect',
'first_rule': 'preferences',
'see_results': 'categories',
'auto_schedule': 'complete',
}
const mappedStep = stepMap[stateRes.data.onboarding_step]
if (mappedStep) {
setCurrentStep(mappedStep)
}
}
}
} catch (err) {
console.error('Error loading onboarding state:', err)
} finally {
setLoadingOnboarding(false)
}
}
loadOnboarding()
}
}, [user, resumeOnboarding])
// Check if user already has connected accounts after successful checkout
useEffect(() => {
@@ -82,11 +122,17 @@ export function Setup() {
try {
const response = await api.getOAuthUrl('gmail', user.$id)
if (response.data?.url) {
// Track onboarding step before redirect
await api.updateOnboardingStep(user.$id, 'connect', ['connect'])
window.location.href = response.data.url
} else {
setConnectedProvider('gmail')
setConnectedEmail(user.email)
setCurrentStep('preferences')
// Track onboarding step
await api.updateOnboardingStep(user.$id, 'first_rule', ['connect'])
trackOnboardingStep(user.$id, 'first_rule')
trackProviderConnected(user.$id, 'gmail')
}
} catch (err) {
setError('Gmail connection failed. Please try again.')
@@ -103,11 +149,15 @@ export function Setup() {
try {
const response = await api.getOAuthUrl('outlook', user.$id)
if (response.data?.url) {
// Track onboarding step before redirect
await api.updateOnboardingStep(user.$id, 'connect', ['connect'])
window.location.href = response.data.url
} else {
setConnectedProvider('outlook')
setConnectedEmail(user.email)
setCurrentStep('preferences')
// Track onboarding step
await api.updateOnboardingStep(user.$id, 'first_rule', ['connect'])
}
} catch (err) {
setError('Outlook connection failed. Please try again.')
@@ -116,10 +166,54 @@ export function Setup() {
}
}
const handleNext = () => {
const handleConnectDemo = async () => {
if (!user?.$id) return
setConnecting('demo')
setError(null)
try {
const response = await api.connectDemoAccount(user.$id)
if (response.data) {
setConnectedProvider('demo')
setConnectedEmail(response.data.email)
setCurrentStep('preferences')
// Track onboarding step
await api.updateOnboardingStep(user.$id, 'first_rule', ['connect'])
trackOnboardingStep(user.$id, 'first_rule')
trackDemoUsed(user.$id)
}
} catch (err) {
setError('Demo connection failed. Please try again.')
} finally {
setConnecting(null)
}
}
const handleNext = async () => {
const nextIndex = stepIndex + 1
if (nextIndex < steps.length) {
setCurrentStep(steps[nextIndex].id)
const nextStep = steps[nextIndex].id
setCurrentStep(nextStep)
// Track onboarding progress
if (user?.$id) {
const stepMap: Record<Step, string> = {
'connect': 'connect',
'preferences': 'first_rule',
'categories': 'see_results',
'complete': 'auto_schedule',
}
const onboardingStep = stepMap[nextStep]
const completedSteps = onboardingState?.completedSteps || []
if (onboardingStep && !completedSteps.includes(stepMap[currentStep])) {
const newCompleted = [...completedSteps, stepMap[currentStep]]
await api.updateOnboardingStep(user.$id, onboardingStep, newCompleted)
setOnboardingState({
onboarding_step: onboardingStep,
completedSteps: newCompleted,
})
}
}
}
}
@@ -144,6 +238,9 @@ export function Setup() {
customRules: [],
priorityTopics: selectedCategories,
})
// Mark onboarding as completed
await api.updateOnboardingStep(user.$id, 'completed', ['connect', 'first_rule', 'see_results', 'auto_schedule'])
} catch (err) {
console.error('Failed to save preferences:', err)
} finally {
@@ -152,6 +249,18 @@ export function Setup() {
}
}
const handleSkipOnboarding = async () => {
if (!user?.$id) return
try {
await api.skipOnboarding(user.$id)
navigate('/dashboard')
} catch (err) {
console.error('Failed to skip onboarding:', err)
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' },
@@ -185,18 +294,18 @@ export function Setup() {
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">
<header className="bg-white/80 dark:bg-slate-900/80 backdrop-blur-sm border-b border-slate-200 dark:border-slate-700 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 className="text-lg font-bold text-slate-900 dark:text-slate-100">
E-Mail-<span className="text-primary-600 dark:text-primary-400">Sorter</span>
</span>
</Link>
<Button variant="ghost" onClick={() => navigate('/dashboard')}>
<Button variant="ghost" onClick={handleSkipOnboarding}>
Skip
</Button>
</div>
@@ -221,6 +330,18 @@ export function Setup() {
)}
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{/* Onboarding Progress */}
{!loadingOnboarding && onboardingState && onboardingState.onboarding_step !== 'completed' && (
<div className="mb-6">
<OnboardingProgress
currentStep={onboardingState.onboarding_step}
completedSteps={onboardingState.completedSteps}
totalSteps={4}
onSkip={handleSkipOnboarding}
/>
</div>
)}
{/* Progress */}
<div className="mb-12">
<div className="flex items-center justify-between mb-4">
@@ -272,51 +393,82 @@ export function Setup() {
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">
<div className="space-y-4 max-w-lg mx-auto">
{/* Try Demo - Prominent Option */}
<button
onClick={handleConnectGmail}
onClick={handleConnectDemo}
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"
className="w-full flex items-center gap-4 p-6 bg-gradient-to-r from-primary-500 to-primary-600 text-white rounded-2xl border-2 border-primary-400 hover:border-primary-300 hover:shadow-2xl hover:shadow-primary-500/30 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" />
{connecting === 'demo' ? (
<Loader2 className="w-12 h-12 animate-spin text-white" />
) : (
<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 className="w-12 h-12 rounded-xl bg-white/20 flex items-center justify-center group-hover:bg-white/30 transition-colors">
<Sparkles className="w-7 h-7 text-white" />
</div>
)}
<div className="flex-1">
<p className="font-semibold text-slate-900">Gmail</p>
<p className="text-sm text-slate-500">Google Workspace</p>
<p className="font-semibold text-white text-lg">Try Demo</p>
<p className="text-sm text-primary-100">See how it works without connecting your account</p>
</div>
<ChevronRight className="w-5 h-5 text-slate-400 group-hover:text-red-500 group-hover:translate-x-1 transition-all" />
<ChevronRight className="w-5 h-5 text-white/80 group-hover:text-white 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 className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-slate-300"></div>
</div>
<ChevronRight className="w-5 h-5 text-slate-400 group-hover:text-blue-500 group-hover:translate-x-1 transition-all" />
</button>
<div className="relative flex justify-center text-sm">
<span className="px-4 bg-white text-slate-500">Or connect your inbox</span>
</div>
</div>
<div className="grid sm:grid-cols-2 gap-4">
<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>
<div className="mt-10 p-4 bg-slate-50 rounded-xl max-w-lg mx-auto">
+50
View File
@@ -2,10 +2,60 @@
* TypeScript types for Settings and AI Control
*/
export interface CleanupReadItems {
enabled: boolean
action: 'archive_read' | 'trash'
gracePeriodDays: number
}
export interface CleanupPromotions {
enabled: boolean
matchCategoriesOrLabels: string[]
action: 'archive_read' | 'trash'
deleteAfterDays: number
}
export interface CleanupSafety {
requireConfirmForDelete: boolean
dryRun?: boolean
maxDeletesPerRun?: number
}
export interface CleanupSettings {
enabled: boolean
readItems: CleanupReadItems
promotions: CleanupPromotions
safety: CleanupSafety
}
export interface CategoryAdvanced {
priority?: 'low' | 'medium' | 'high'
includeLabels?: string[]
excludeKeywords?: string[]
}
export interface CleanupStatus {
lastRun?: string
lastRunCounts?: {
readItems: number
promotions: number
}
preview?: Array<{
id: string
subject: string
from: string
date: string
reason: 'read' | 'promotion'
}>
}
export interface AIControlSettings {
version?: number
enabledCategories: string[]
categoryActions: Record<string, 'inbox' | 'archive_read' | 'star'>
autoDetectCompanies: boolean
cleanup?: CleanupSettings
categoryAdvanced?: Record<string, CategoryAdvanced>
}
export interface CompanyLabel {