huhuih
hzgjuigik
This commit is contained in:
105
client/FAVICON_SETUP.md
Normal file
105
client/FAVICON_SETUP.md
Normal 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!
|
||||
@@ -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>
|
||||
|
||||
24
client/public/apple-touch-icon.svg
Normal file
24
client/public/apple-touch-icon.svg
Normal 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 |
144
client/public/favicon-generator.html
Normal file
144
client/public/favicon-generator.html
Normal 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
24
client/public/favicon.svg
Normal 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 |
21
client/public/site.webmanifest
Normal file
21
client/public/site.webmanifest
Normal 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": "/"
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
87
client/src/components/OnboardingProgress.tsx
Normal file
87
client/src/components/OnboardingProgress.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
226
client/src/components/PrivacySecurity.tsx
Normal file
226
client/src/components/PrivacySecurity.tsx
Normal 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
client/src/components/ShareResults.tsx
Normal file
101
client/src/components/ShareResults.tsx
Normal 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
client/src/components/UpgradePrompt.tsx
Normal file
113
client/src/components/UpgradePrompt.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -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
client/src/components/ui/side-panel.tsx
Normal file
148
client/src/components/ui/side-panel.tsx
Normal 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
client/src/components/ui/slider.tsx
Normal file
41
client/src/components/ui/slider.tsx
Normal 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
client/src/components/ui/tabs.tsx
Normal file
52
client/src/components/ui/tabs.tsx
Normal 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
client/src/hooks/useTheme.ts
Normal file
186
client/src/hooks/useTheme.ts
Normal 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 }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user