hzgjuigik
This commit is contained in:
2026-01-27 21:06:48 +01:00
parent 18c11d27bc
commit 6da8ce1cbd
51 changed files with 6208 additions and 974 deletions

105
client/FAVICON_SETUP.md Normal file
View File

@@ -0,0 +1,105 @@
# Favicon Setup Anleitung
Die Favicon-Dateien wurden erstellt. Um alle Formate zu generieren, folge diesen Schritten:
## Erstellte Dateien
`favicon.svg` - Modernes SVG Favicon (bereits erstellt)
`apple-touch-icon.svg` - SVG für Apple Touch Icon (bereits erstellt)
`site.webmanifest` - Web App Manifest (bereits erstellt)
## Noch zu erstellen (PNG/ICO)
Du musst die folgenden PNG/ICO-Dateien aus dem SVG erstellen:
### Option 1: Online Converter verwenden
1. Gehe zu einem dieser Tools:
- https://realfavicongenerator.net/ (Empfohlen - generiert alle Formate)
- https://www.zenlytools.com/svg-to-ico
- https://svg-to-ico.org/
2. Lade `favicon.svg` hoch
3. Generiere folgende Dateien:
- `favicon.ico` (16x16, 32x32, 48x48)
- `favicon-16x16.png`
- `favicon-32x32.png`
- `apple-touch-icon.png` (180x180)
- `favicon-192x192.png` (für Web Manifest)
- `favicon-512x512.png` (für Web Manifest)
4. Speichere alle generierten Dateien im `client/public/` Ordner
### Option 2: Mit ImageMagick (Command Line)
```bash
# Installiere ImageMagick (falls nicht vorhanden)
# Windows: choco install imagemagick
# Mac: brew install imagemagick
# Linux: sudo apt-get install imagemagick
cd client/public
# Erstelle PNG-Varianten
magick favicon.svg -resize 16x16 favicon-16x16.png
magick favicon.svg -resize 32x32 favicon-32x32.png
magick apple-touch-icon.svg -resize 180x180 apple-touch-icon.png
magick favicon.svg -resize 192x192 favicon-192x192.png
magick favicon.svg -resize 512x512 favicon-512x512.png
# Erstelle ICO (mehrere Größen)
magick favicon.svg -define icon:auto-resize=16,32,48 favicon.ico
```
### Option 3: Mit Online Favicon Generator (Empfohlen)
1. Gehe zu: https://realfavicongenerator.net/
2. Klicke auf "Select your Favicon image"
3. Lade `favicon.svg` hoch
4. Konfiguriere die Optionen:
- iOS: Apple Touch Icon aktivieren
- Android Chrome: Manifest aktivieren
- Windows Metro: Optional
5. Klicke auf "Generate your Favicons and HTML code"
6. Lade das ZIP herunter
7. Extrahiere alle Dateien in `client/public/`
8. Kopiere die generierten `<link>` Tags in `index.html` (falls nötig)
## Verifizierung
Nach dem Erstellen aller Dateien:
1. Starte den Dev-Server: `npm run dev`
2. Öffne die Seite im Browser
3. Prüfe den Browser-Tab - das Favicon sollte angezeigt werden
4. Teste auf Mobile:
- iOS Safari: Zum Home-Bildschirm hinzufügen → Icon sollte erscheinen
- Android Chrome: Installiere als PWA → Icon sollte erscheinen
## Dateien im public/ Ordner
Nach Abschluss sollten folgende Dateien vorhanden sein:
```
client/public/
├── favicon.svg ✅
├── favicon.ico (zu erstellen)
├── favicon-16x16.png (zu erstellen)
├── favicon-32x32.png (zu erstellen)
├── apple-touch-icon.png (zu erstellen)
├── favicon-192x192.png (zu erstellen)
├── favicon-512x512.png (zu erstellen)
├── apple-touch-icon.svg ✅
└── site.webmanifest ✅
```
## Browser-Kompatibilität
- **Chrome/Edge**: Verwendet `favicon.svg` oder `favicon.ico`
- **Firefox**: Verwendet `favicon.svg` oder `favicon.ico`
- **Safari (Desktop)**: Verwendet `favicon.ico` oder PNG
- **Safari (iOS)**: Verwendet `apple-touch-icon.png`
- **Android Chrome**: Verwendet Icons aus `site.webmanifest`
Die aktuelle Konfiguration in `index.html` unterstützt alle modernen Browser!

View File

@@ -2,13 +2,80 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<!-- Favicons -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes" />
<meta name="description" content="EmailSorter - AI-powered email sorting for maximum productivity. Automatically organize your inbox." />
<meta name="description" content="E-Mail-Sorter - AI-powered email sorting for maximum productivity. Automatically organize your inbox." />
<meta name="theme-color" content="#22c55e" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<title>EmailSorter - Your inbox, finally organized</title>
<!-- Prevent FOUC for dark mode - Enhanced Dark Reader detection -->
<script>
(function() {
'use strict';
const html = document.documentElement;
// Enhanced Dark Reader detection (multiple methods)
function detectDarkReader() {
// Method 1: Check for Dark Reader data attributes
if (html.hasAttribute('data-darkreader-mode') ||
html.hasAttribute('data-darkreader-scheme') ||
html.hasAttribute('data-darkreader-policy')) {
return true;
}
// Method 2: Check for Dark Reader meta tag or stylesheet
try {
if (document.querySelector('meta[name="darkreader"]') ||
document.querySelector('style[data-darkreader]')) {
return true;
}
} catch (e) {
// Ignore errors during early initialization
}
// Method 3: Check computed styles for filter/invert (Dark Reader uses CSS filters)
try {
const computedStyle = window.getComputedStyle(html);
const filter = computedStyle.filter;
if (filter && filter !== 'none' &&
(filter.includes('invert') || filter.includes('brightness'))) {
return true;
}
} catch (e) {
// Ignore errors if getComputedStyle fails
}
return false;
}
// Check system preference
const prefersDark = window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches;
// Detect Dark Reader
const hasDarkReader = detectDarkReader();
// Apply theme: only dark if system prefers it AND Dark Reader is NOT active
if (prefersDark && !hasDarkReader) {
html.classList.add('dark');
html.setAttribute('data-theme', 'dark');
} else {
// Force light mode if Dark Reader is detected
html.classList.remove('dark');
html.setAttribute('data-theme', 'light');
}
// Mark as initialized to prevent FOUC transitions
html.classList.add('dark-mode-initialized');
})();
</script>
</head>
<body class="antialiased">
<div id="root"></div>

View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" width="180" height="180">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#22c55e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#16a34a;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background with rounded square -->
<rect x="10" y="10" width="160" height="160" rx="32" fill="url(#grad)"/>
<!-- Mail envelope -->
<path d="M50 60 L90 100 L130 60 M50 60 L50 110 L130 110 L130 60"
stroke="white"
stroke-width="7"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"/>
<!-- Envelope flap (top triangle) -->
<path d="M50 60 L90 100 L130 60"
stroke="white"
stroke-width="7"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 903 B

View File

@@ -0,0 +1,144 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Favicon Generator - EmailSorter</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 800px;
margin: 40px auto;
padding: 20px;
background: #f5f5f5;
}
.container {
background: white;
padding: 30px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
h1 {
color: #22c55e;
margin-bottom: 10px;
}
.preview {
display: flex;
gap: 20px;
margin: 30px 0;
flex-wrap: wrap;
}
.preview-item {
text-align: center;
padding: 15px;
background: #f9f9f9;
border-radius: 8px;
}
.preview-item img {
display: block;
margin: 0 auto 10px;
border: 1px solid #ddd;
}
.instructions {
background: #f0fdf4;
border-left: 4px solid #22c55e;
padding: 20px;
margin: 20px 0;
}
.instructions h2 {
margin-top: 0;
color: #16a34a;
}
.instructions ol {
line-height: 1.8;
}
.download-link {
display: inline-block;
margin-top: 10px;
padding: 10px 20px;
background: #22c55e;
color: white;
text-decoration: none;
border-radius: 6px;
font-weight: 500;
}
.download-link:hover {
background: #16a34a;
}
</style>
</head>
<body>
<div class="container">
<h1>📧 EmailSorter Favicon Generator</h1>
<p>Diese Seite hilft dir beim Erstellen der Favicon-Dateien.</p>
<div class="preview">
<div class="preview-item">
<img src="/favicon.svg" alt="Favicon SVG" width="64" height="64">
<strong>SVG (64x64)</strong><br>
<small>Modern, skalierbar</small>
</div>
<div class="preview-item">
<img src="/apple-touch-icon.svg" alt="Apple Touch Icon" width="180" height="180" style="width: 90px; height: 90px;">
<strong>Apple Touch (180x180)</strong><br>
<small>iOS Home Screen</small>
</div>
</div>
<div class="instructions">
<h2>📋 Anleitung: Favicon-Dateien erstellen</h2>
<ol>
<li><strong>Gehe zu einem Favicon-Generator:</strong>
<ul>
<li><a href="https://realfavicongenerator.net/" target="_blank">realfavicongenerator.net</a> (Empfohlen)</li>
<li><a href="https://www.zenlytools.com/svg-to-ico" target="_blank">zenlytools.com</a></li>
</ul>
</li>
<li><strong>Lade die SVG-Datei hoch:</strong>
<ul>
<li>Klicke auf "Select your Favicon image"</li>
<li>Wähle <code>favicon.svg</code> aus dem <code>public/</code> Ordner</li>
</ul>
</li>
<li><strong>Konfiguriere die Optionen:</strong>
<ul>
<li>✅ iOS: Apple Touch Icon aktivieren</li>
<li>✅ Android Chrome: Manifest aktivieren</li>
<li>✅ Windows Metro: Optional</li>
</ul>
</li>
<li><strong>Generiere und lade herunter:</strong>
<ul>
<li>Klicke auf "Generate your Favicons"</li>
<li>Lade das ZIP-Archiv herunter</li>
<li>Extrahiere alle Dateien in den <code>client/public/</code> Ordner</li>
</ul>
</li>
<li><strong>Verifiziere:</strong>
<ul>
<li>Starte den Dev-Server neu</li>
<li>Prüfe den Browser-Tab - das Favicon sollte erscheinen</li>
</ul>
</li>
</ol>
</div>
<h2>📁 Benötigte Dateien</h2>
<p>Nach der Konvertierung sollten folgende Dateien im <code>public/</code> Ordner sein:</p>
<ul>
<li><code>favicon.svg</code> (bereits vorhanden)</li>
<li><code>favicon.ico</code></li>
<li><code>favicon-16x16.png</code></li>
<li><code>favicon-32x32.png</code></li>
<li><code>apple-touch-icon.png</code></li>
<li><code>favicon-192x192.png</code></li>
<li><code>favicon-512x512.png</code></li>
</ul>
<h2>🔗 Nützliche Links</h2>
<p>
<a href="https://realfavicongenerator.net/" target="_blank" class="download-link">Favicon Generator öffnen</a>
</p>
</div>
</body>
</html>

24
client/public/favicon.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#22c55e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#16a34a;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background with rounded square -->
<rect x="4" y="4" width="56" height="56" rx="12" fill="url(#grad)"/>
<!-- Mail envelope -->
<path d="M18 22 L32 34 L46 22 M18 22 L18 38 L46 38 L46 22"
stroke="white"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"/>
<!-- Envelope flap (top triangle) -->
<path d="M18 22 L32 34 L46 22"
stroke="white"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 891 B

View File

@@ -0,0 +1,21 @@
{
"name": "EmailSorter",
"short_name": "EmailSorter",
"description": "AI-powered email sorting for maximum productivity",
"icons": [
{
"src": "/favicon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/favicon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#22c55e",
"background_color": "#ffffff",
"display": "standalone",
"start_url": "/"
}

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>

View File

@@ -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>
)
}

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>
)
}

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>
)
}

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>
)
}

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>
)

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

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>

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

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: {

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",

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}
/>
))

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
)}

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,
}

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 }

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 }

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 }
}

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;
}

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(),
},
})
}

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

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>

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>

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>

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

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">

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 {