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

111
APPWRITE_CORS_SETUP.md Normal file
View File

@@ -0,0 +1,111 @@
# Appwrite CORS Setup - Schritt für Schritt
## Problem
Appwrite blockiert Requests von `https://emailsorter.webklar.com` weil nur `https://localhost` als Origin erlaubt ist.
## Lösung: Platform in Appwrite hinzufügen
### Schritt 1: Appwrite-Konsole öffnen
1. Gehe zu: **https://appwrite.webklar.com**
2. Logge dich ein
### Schritt 2: Projekt öffnen
1. Klicke auf dein **EmailSorter Projekt** (oder das Projekt, das du verwendest)
### Schritt 3: Settings öffnen
1. Klicke auf **Settings** im linken Menü
2. Oder suche nach **"Platforms"** oder **"Web"** in den Einstellungen
### Schritt 4: Platform hinzufügen
1. Klicke auf **"Add Platform"** oder **"Create Platform"**
2. Wähle **"Web"** als Platform-Typ
### Schritt 5: Platform konfigurieren
Fülle die Felder aus:
- **Name:** `Production` (oder ein anderer Name)
- **Hostname:** `emailsorter.webklar.com`
- **Origin:** `https://emailsorter.webklar.com`
**WICHTIG:**
- Verwende **https://** (nicht http://)
- Kein Slash am Ende
- Genau so wie oben geschrieben
### Schritt 6: Speichern
1. Klicke auf **"Create"** oder **"Save"**
2. Warte 1-2 Minuten (Cache)
### Schritt 7: Testen
1. Gehe zu https://emailsorter.webklar.com
2. Versuche dich einzuloggen
3. Prüfe die Browser-Konsole (F12) - sollte keine CORS-Fehler mehr geben
---
## Alternative: Mehrere Origins
Falls du mehrere Domains brauchst (z.B. localhost für Development und Production):
1. Erstelle **zwei separate Platforms:**
- **Development:** Hostname: `localhost`, Origin: `http://localhost:5173`
- **Production:** Hostname: `emailsorter.webklar.com`, Origin: `https://emailsorter.webklar.com`
2. Oder verwende **Wildcard** (falls von Appwrite unterstützt):
- Origin: `https://*.webklar.com`
---
## Troubleshooting
### CORS-Fehler bleibt bestehen
1. **Cache leeren:** Warte 2-3 Minuten nach dem Speichern
2. **Browser-Cache:** Strg+Shift+R (Hard Refresh)
3. **Prüfe Origin:** Muss **genau** `https://emailsorter.webklar.com` sein (kein Slash, kein Port)
4. **Prüfe Appwrite-Version:** Manche Versionen haben die Platform-Einstellungen an einem anderen Ort
### Platform-Option nicht sichtbar
- In manchen Appwrite-Versionen heißt es **"Web"** statt "Platforms"
- Suche nach **"Client"** oder **"SDK"** in den Settings
- Prüfe die Appwrite-Dokumentation für deine Version
### 404 oder 403 Fehler
- Prüfe, ob die Appwrite-URL korrekt ist: `https://appwrite.webklar.com`
- Prüfe, ob du die richtigen Berechtigungen hast
- Prüfe, ob das Projekt existiert und aktiv ist
---
## Screenshots (Beispiel)
Die Platform-Einstellungen sollten etwa so aussehen:
```
┌─────────────────────────────────────┐
│ Add Platform │
├─────────────────────────────────────┤
│ Type: [Web ▼] │
│ │
│ Name: Production │
│ Hostname: emailsorter.webklar.com │
│ Origin: https://emailsorter.webklar │
│ .com │
│ │
│ [Cancel] [Create] │
└─────────────────────────────────────┘
```
---
## Nach dem Setup
Nachdem du die Platform hinzugefügt hast:
1. ✅ CORS-Fehler sollten verschwinden
2. ✅ Login/Register sollte funktionieren
3. ✅ API-Calls sollten durchgehen
**Falls es immer noch nicht funktioniert:**
- Prüfe die Browser-Konsole für genaue Fehlermeldungen
- Prüfe die Appwrite-Logs (falls verfügbar)
- Stelle sicher, dass der Backend-Server läuft (502-Fehler beheben)

6
COMMIT_COMMANDS.txt Normal file
View File

@@ -0,0 +1,6 @@
Führe diese Befehle in deinem Git Bash oder Terminal aus:
cd c:\Users\User\Documents\GitHub\ANDJJJJJJ
git add .
git commit -m "feat: Control Panel Redesign v2.0 - Card-basiertes Layout, Side Panels, Dark Mode Fixes, Volle Breite Layout"
git push

71
COMMIT_MESSAGE.md Normal file
View File

@@ -0,0 +1,71 @@
# Control Panel Redesign & UI Improvements
## Hauptänderungen
### 🎨 Control Panel komplettes Redesign (Version 2.0)
- **Card-basiertes Layout**: Kategorien werden jetzt als interaktive Cards in einem Grid-Layout dargestellt (statt endloser Liste)
- **Side Panel Integration**: Click-to-Configure Pattern - Klick auf Category Card öffnet Side Panel für detaillierte Konfiguration
- **Moderne UX**: Dashboard-artiges Design, weniger wie klassische Settings-Seite, mehr wie moderne SaaS-Produkte
### 🧹 Cleanup Tab Redesign
- **Große Toggle-Cards**: Visuell prominente Cards für "Auto cleanup read emails" und "Delete promotions after X days"
- **Slider-Komponente**: Neue Slider-Komponente für intuitive Tage-Auswahl (statt Number Input)
- **Preset Buttons**: Schnellzugriff auf 7/14/30 Tage für Promotion Cleanup
- **Preview Section**: Zeigt betroffene E-Mails an (wenn Daten vorhanden)
- **Warnungen**: Ruhige Info-Banner bei Delete-Aktionen
### 🏷️ Labels Tab Redesign
- **Tabellenansicht**: Professionelle Tabelle mit Name, Status, Category und Actions
- **Side Panel Editor**: Label-Erstellung/Bearbeitung in Side Panel statt inline Form
- **Responsive Table**: Spalten werden auf Mobile ausgeblendet, wichtige Info bleibt sichtbar
- **Import/Export**: Buttons für Label-Import/Export
### 📐 Layout Verbesserungen
- **Volle Breite**: Dashboard und Settings nutzen jetzt die gesamte verfügbare Breite (keine max-width Beschränkung mehr)
- **Responsive Navigation**: Side Panels werden auf Mobile zu Fullscreen-Modals
- **Verbesserte Header**: Humanere Untertitel und bessere Button-Anordnung
### 🌙 Dark Mode Verbesserungen
- **Privacy & Security**: Alle weißen Felder haben jetzt Dark Mode Varianten (grüne, blaue, rote Info-Boxen)
- **Input-Komponente**: Dark Mode Hintergrund korrigiert (dark:bg-slate-800 statt dark:bg-slate-100)
- **Slider-Komponente**: Dark Mode Styles für Thumb (Webkit & Mozilla)
- **Chevron Icons**: Dark Mode Farben für Advanced Options Toggle
- **Konsistenz**: Alle Komponenten haben jetzt konsistente Dark Mode Unterstützung
## Neue Komponenten
### `client/src/components/ui/side-panel.tsx`
- Radix UI Dialog-basierte Side Panel Komponente
- Slide-in Animation von rechts
- Responsive: Fullscreen auf Mobile, 480px auf Desktop
- Dark Mode Support
### `client/src/components/ui/slider.tsx`
- Range Input Slider Komponente
- Dark Mode Support für Track und Thumb
- Customizable min/max/step Werte
## Geänderte Dateien
- `client/src/pages/Settings.tsx` - Control Panel komplett neu strukturiert
- `client/src/components/PrivacySecurity.tsx` - Dark Mode für alle Info-Boxen
- `client/src/components/ui/input.tsx` - Dark Mode Hintergrund korrigiert
- `client/src/pages/Dashboard.tsx` - Volle Breite Layout
- `client/src/types/settings.ts` - Keine Änderungen (nur Whitespace)
## Technische Details
- **State Management**: Side Panel State für Category Details und Label Editor
- **Responsive Design**: Grid-Layouts passen sich an (1 Spalte Mobile, 2 Tablet, 3 Desktop)
- **Accessibility**: Keyboard Navigation, ARIA Labels, Focus States
- **Performance**: useMemo für gefilterte/sortierte Listen
## Design-Prinzipien
- Viel Whitespace zwischen Cards
- Ruhige Farben (keine grellen Akzente)
- Cards statt Listen
- "Click → Configure" statt sofort sichtbare Controls
- Klare Typografie-Hierarchie
- Icons sparsam aber sinnvoll
- Dark Mode optimiert (nicht zu kontrastreich)

155
PRODUCTION_SETUP.md Normal file
View File

@@ -0,0 +1,155 @@
# Production Setup - emailsorter.webklar.com
## Probleme und Lösungen
### 1. Appwrite CORS-Konfiguration
**Problem:** Appwrite blockiert Requests von `https://emailsorter.webklar.com` weil nur `https://localhost` als Origin erlaubt ist.
**Lösung:**
1. Gehe zu deiner Appwrite-Konsole: https://appwrite.webklar.com
2. Öffne dein Projekt
3. Gehe zu **Settings****Platforms** (oder **Web**)
4. Füge eine neue Platform hinzu oder bearbeite die existierende:
- **Name:** Production
- **Hostname:** `emailsorter.webklar.com`
- **Origin:** `https://emailsorter.webklar.com`
5. Speichere die Änderungen
**Alternative:** Wenn du mehrere Origins brauchst, kannst du auch in Appwrite die CORS-Einstellungen anpassen, um mehrere Origins zu erlauben.
---
### 2. Backend-Server (502 Fehler)
**Problem:** Der Backend-Server läuft nicht oder ist nicht erreichbar.
**Lösung:**
#### Option A: Server auf demselben Server starten
1. **SSH zum Server:**
```bash
ssh user@webklar.com
```
2. **Zum Projekt-Verzeichnis navigieren:**
```bash
cd /path/to/ANDJJJJJJ/server
```
3. **Environment-Variablen setzen:**
Erstelle oder bearbeite `.env`:
```env
NODE_ENV=production
PORT=3000
BASE_URL=https://api.emailsorter.webklar.com
FRONTEND_URL=https://emailsorter.webklar.com
CORS_ORIGIN=https://emailsorter.webklar.com
APPWRITE_ENDPOINT=https://appwrite.webklar.com/v1
APPWRITE_PROJECT_ID=deine_projekt_id
APPWRITE_API_KEY=dein_api_key
APPWRITE_DATABASE_ID=email_sorter_db
# ... weitere Variablen
```
4. **Server starten:**
```bash
npm install
npm start
```
#### Option B: Mit PM2 (empfohlen für Production)
```bash
npm install -g pm2
cd /path/to/ANDJJJJJJ/server
pm2 start index.mjs --name emailsorter-api
pm2 save
pm2 startup
```
#### Option C: Reverse Proxy konfigurieren (Nginx)
Falls der Server auf einem anderen Port läuft, konfiguriere Nginx:
```nginx
server {
listen 80;
server_name api.emailsorter.webklar.com;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
```
---
### 3. Frontend Environment-Variablen
Stelle sicher, dass das Frontend die richtige Backend-URL verwendet:
1. **Erstelle `client/.env.production`:**
```env
VITE_APPWRITE_ENDPOINT=https://appwrite.webklar.com/v1
VITE_APPWRITE_PROJECT_ID=deine_projekt_id
VITE_API_URL=https://api.emailsorter.webklar.com
```
2. **Build das Frontend:**
```bash
cd client
npm run build
```
3. **Deploy den Build-Ordner** (`client/dist`) zu deinem Web-Server
---
### 4. Checkliste
- [ ] Appwrite CORS: `https://emailsorter.webklar.com` als Origin hinzugefügt
- [ ] Backend-Server läuft und ist erreichbar
- [ ] Backend `.env` konfiguriert mit Production-URLs
- [ ] Frontend `.env.production` konfiguriert
- [ ] Frontend gebaut und deployed
- [ ] Reverse Proxy (Nginx) konfiguriert (falls nötig)
- [ ] SSL-Zertifikat für beide Domains (Frontend + API)
---
### 5. Testing
Nach dem Setup, teste:
1. **Frontend:** https://emailsorter.webklar.com
2. **Backend Health:** https://api.emailsorter.webklar.com/api/health
3. **Login:** Versuche dich einzuloggen und prüfe die Browser-Konsole auf Fehler
---
### Troubleshooting
**CORS-Fehler weiterhin:**
- Prüfe, ob die Appwrite-Änderungen gespeichert wurden
- Warte 1-2 Minuten (Cache)
- Prüfe Browser-Konsole für genaue Fehlermeldung
**502 Bad Gateway:**
- Prüfe, ob der Backend-Server läuft: `pm2 list` oder `ps aux | grep node`
- Prüfe Server-Logs: `pm2 logs emailsorter-api` oder `tail -f server.log`
- Prüfe Firewall-Regeln
- Prüfe Reverse Proxy Konfiguration
**API nicht erreichbar:**
- Prüfe, ob der Port 3000 offen ist
- Prüfe, ob die Domain richtig auf den Server zeigt
- Prüfe DNS-Einträge

105
client/FAVICON_SETUP.md Normal file
View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 903 B

View File

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

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

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

After

Width:  |  Height:  |  Size: 891 B

View File

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

View File

@@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider, useAuth } from '@/context/AuthContext'
import { usePageTracking } from '@/hooks/useAnalytics'
import { initAnalytics } from '@/lib/analytics'
import { useTheme } from '@/hooks/useTheme'
import { Home } from '@/pages/Home'
import { Login } from '@/pages/Login'
import { Register } from '@/pages/Register'
@@ -142,6 +143,9 @@ function AppRoutes() {
}
function App() {
// Initialize theme detection
useTheme()
return (
<BrowserRouter>
<AuthProvider>

View File

@@ -0,0 +1,87 @@
import { Button } from '@/components/ui/button'
import { X, Check } from 'lucide-react'
interface OnboardingProgressProps {
currentStep: string
completedSteps: string[]
totalSteps: number
onSkip: () => void
}
const stepLabels: Record<string, string> = {
'not_started': 'Not started',
'connect': 'Connect email',
'first_rule': 'Create first rule',
'see_results': 'See results',
'auto_schedule': 'Enable automation',
'completed': 'Completed',
}
export function OnboardingProgress({ currentStep, completedSteps, totalSteps, onSkip }: OnboardingProgressProps) {
const stepIndex = ['connect', 'first_rule', 'see_results', 'auto_schedule'].indexOf(currentStep)
const currentStepNumber = stepIndex >= 0 ? stepIndex + 1 : 0
const progress = totalSteps > 0 ? (completedSteps.length / totalSteps) * 100 : 0
if (currentStep === 'completed' || currentStep === 'not_started') {
return null
}
return (
<div className="bg-white border border-slate-200 rounded-lg p-4 shadow-sm">
<div className="flex items-center justify-between mb-3">
<div>
<p className="text-sm font-semibold text-slate-900">Getting started</p>
<p className="text-xs text-slate-500">Step {currentStepNumber} of {totalSteps}</p>
</div>
<Button variant="ghost" size="sm" onClick={onSkip} className="text-slate-500 hover:text-slate-700">
<X className="w-4 h-4 mr-1" />
Skip
</Button>
</div>
{/* Progress bar */}
<div className="w-full h-2 bg-slate-100 rounded-full overflow-hidden mb-2">
<div
className="h-full bg-primary-500 transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
{/* Step indicators */}
<div className="flex items-center gap-2 text-xs text-slate-500">
{['connect', 'first_rule', 'see_results', 'auto_schedule'].map((step, idx) => {
const isCompleted = completedSteps.includes(step)
const isCurrent = currentStep === step
return (
<div key={step} className="flex items-center gap-1 flex-1 min-w-0">
<div className={`flex-shrink-0 w-5 h-5 rounded-full flex items-center justify-center ${
isCompleted
? 'bg-green-500 text-white'
: isCurrent
? 'bg-primary-500 text-white ring-2 ring-primary-200'
: 'bg-slate-200 text-slate-400'
}`}>
{isCompleted ? (
<Check className="w-3 h-3" />
) : (
<span className="text-xs font-semibold">{idx + 1}</span>
)}
</div>
<span className={`truncate hidden sm:inline ${
isCurrent ? 'text-slate-900 font-medium' : ''
}`}>
{stepLabels[step] || step}
</span>
{idx < 3 && (
<div className={`flex-1 h-0.5 mx-1 ${
isCompleted ? 'bg-green-500' : 'bg-slate-200'
}`} />
)}
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,226 @@
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Shield, Lock, Trash2, X, Check, AlertTriangle } from 'lucide-react'
import { useState } from 'react'
interface PrivacySecurityProps {
onDisconnect?: (accountId: string) => void
onDeleteAccount?: () => void
connectedAccounts?: Array<{ id: string; email: string; provider: string }>
}
export function PrivacySecurity({ onDisconnect, onDeleteAccount, connectedAccounts = [] }: PrivacySecurityProps) {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
return (
<div className="space-y-6">
{/* What data is accessed */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="w-5 h-5 text-primary-500" />
What data is accessed
</CardTitle>
<CardDescription>We only access what's necessary for sorting</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-start gap-3 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<Check className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-green-900 dark:text-green-100">Email headers and metadata</p>
<p className="text-sm text-green-700 dark:text-green-300 mt-1">
We read: sender, subject, date, labels/categories. This is all we need to categorize emails.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<Check className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-green-900 dark:text-green-100">Email preview/snippet</p>
<p className="text-sm text-green-700 dark:text-green-300 mt-1">
We read the first few lines to help AI understand the email content.
</p>
</div>
</div>
</CardContent>
</Card>
{/* What is stored */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Lock className="w-5 h-5 text-primary-500 dark:text-primary-400" />
What is stored
</CardTitle>
<CardDescription>Your data stays secure</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<Check className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-blue-900 dark:text-blue-100">Your preferences</p>
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
VIP senders, category settings, company labels, sorting rules.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<Check className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-blue-900 dark:text-blue-100">Statistics</p>
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
Counts of sorted emails, categories, time saved. No email content.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<Check className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-blue-900 dark:text-blue-100">Account connection tokens</p>
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
Encrypted OAuth tokens to access your email (required for sorting).
</p>
</div>
</div>
</CardContent>
</Card>
{/* What is never stored */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<X className="w-5 h-5 text-red-500 dark:text-red-400" />
What is never stored
</CardTitle>
<CardDescription>Your privacy is protected</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-start gap-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<X className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-red-900 dark:text-red-100">Email bodies/content</p>
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
We never store the full content of your emails.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<X className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-red-900 dark:text-red-100">Attachments</p>
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
We never access or store file attachments.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<X className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-red-900 dark:text-red-100">Passwords</p>
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
We use OAuth - we never see or store your email passwords.
</p>
</div>
</div>
</CardContent>
</Card>
{/* How to disconnect */}
{connectedAccounts.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Disconnect email accounts</CardTitle>
<CardDescription>Remove access to your email accounts</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{connectedAccounts.map((account) => (
<div key={account.id} className="flex items-center justify-between p-4 border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800">
<div>
<p className="font-medium text-slate-900 dark:text-slate-100">{account.email}</p>
<p className="text-sm text-slate-500 dark:text-slate-400 capitalize">{account.provider}</p>
</div>
{onDisconnect && (
<Button
variant="outline"
size="sm"
onClick={() => onDisconnect(account.id)}
className="text-red-600 dark:text-red-400 border-red-200 dark:border-red-800 hover:bg-red-50 dark:hover:bg-red-900/20"
>
Disconnect
</Button>
)}
</div>
))}
</CardContent>
</Card>
)}
{/* Delete account */}
{onDeleteAccount && (
<Card className="border-red-200 dark:border-red-800">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-red-600 dark:text-red-400">
<Trash2 className="w-5 h-5" />
Delete my data
</CardTitle>
<CardDescription>Permanently delete all your data and account</CardDescription>
</CardHeader>
<CardContent>
{!showDeleteConfirm ? (
<div className="space-y-3">
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-sm text-red-800 dark:text-red-200 mb-2">
<AlertTriangle className="w-4 h-4 inline mr-1" />
This action cannot be undone
</p>
<p className="text-xs text-red-700 dark:text-red-300">
This will delete all your preferences, statistics, connected accounts, and subscription data.
</p>
</div>
<Button
variant="default"
onClick={() => setShowDeleteConfirm(true)}
className="w-full bg-red-600 hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-800 text-white"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete my account and data
</Button>
</div>
) : (
<div className="space-y-3">
<div className="p-4 bg-red-100 dark:bg-red-900/30 border-2 border-red-300 dark:border-red-700 rounded-lg">
<p className="font-semibold text-red-900 dark:text-red-100 mb-2">Are you absolutely sure?</p>
<p className="text-sm text-red-800 dark:text-red-200">
This will permanently delete:
</p>
<ul className="text-sm text-red-700 dark:text-red-300 mt-2 space-y-1 list-disc list-inside">
<li>All your email account connections</li>
<li>All sorting statistics</li>
<li>All preferences and settings</li>
<li>Your subscription (if active)</li>
</ul>
</div>
<div className="flex gap-2">
<Button
variant="default"
onClick={onDeleteAccount}
className="flex-1 bg-red-600 hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-800 text-white"
>
Yes, delete everything
</Button>
<Button
variant="outline"
onClick={() => setShowDeleteConfirm(false)}
className="flex-1"
>
Cancel
</Button>
</div>
</div>
)}
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,101 @@
import { Button } from '@/components/ui/button'
import { Share2, Copy, Check } from 'lucide-react'
import { useState } from 'react'
import { trackReferralShared } from '@/lib/analytics'
import { useAuth } from '@/context/AuthContext'
interface ShareResultsProps {
sortedCount: number
referralCode?: string
}
export function ShareResults({ sortedCount, referralCode }: ShareResultsProps) {
const [copied, setCopied] = useState(false)
const { user } = useAuth()
const shareText = `I cleaned up ${sortedCount} emails with EmailSorter${referralCode ? `! Use code ${referralCode} for a bonus.` : '!'}`
const shareUrl = referralCode
? `${window.location.origin}?ref=${referralCode}`
: window.location.origin
const handleCopy = async () => {
const text = `${shareText}\n${shareUrl}`
try {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('Failed to copy:', err)
}
}
const handleShare = async () => {
if (navigator.share) {
try {
await navigator.share({
title: 'EmailSorter - Clean Inbox',
text: shareText,
url: shareUrl,
})
if (user?.$id && referralCode) {
trackReferralShared(user.$id, referralCode)
}
} catch (err) {
// User cancelled or error
console.error('Share failed:', err)
}
} else {
// Fallback to copy
handleCopy()
}
}
if (sortedCount < 10) {
return null // Don't show for small results
}
return (
<div className="bg-gradient-to-r from-primary-50 to-accent-50 border border-primary-200 rounded-lg p-4">
<div className="flex items-start gap-3 mb-3">
<div className="w-10 h-10 rounded-lg bg-primary-500 flex items-center justify-center flex-shrink-0">
<Share2 className="w-5 h-5 text-white" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-slate-900 mb-1">Share your success!</h3>
<p className="text-sm text-slate-600">
{shareText}
</p>
</div>
</div>
<div className="flex gap-2">
{typeof navigator !== 'undefined' && 'share' in navigator && typeof navigator.share === 'function' && (
<Button
onClick={handleShare}
variant="outline"
className="flex-1 border-primary-300 text-primary-700 hover:bg-primary-50"
>
<Share2 className="w-4 h-4 mr-2" />
Share
</Button>
)}
<Button
onClick={handleCopy}
variant="outline"
className="flex-1 border-primary-300 text-primary-700 hover:bg-primary-50"
>
{copied ? (
<>
<Check className="w-4 h-4 mr-2" />
Copied!
</>
) : (
<>
<Copy className="w-4 h-4 mr-2" />
Copy
</>
)}
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,113 @@
import { Button } from '@/components/ui/button'
import { X, Sparkles, Zap, Infinity } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { trackUpgradeClicked } from '@/lib/analytics'
import { useAuth } from '@/context/AuthContext'
interface UpgradePromptProps {
title?: string
benefits?: string[]
source: 'after_sort' | 'limit_reached' | 'auto_schedule' | 'after_rules'
onDismiss: () => void
}
const defaultBenefits: Record<string, string[]> = {
after_sort: [
'Sort unlimited emails automatically',
'Set up auto-schedule for hands-free organization',
'Access all premium features',
],
limit_reached: [
'Unlimited email sorting',
'No monthly limits',
'Priority support',
],
auto_schedule: [
'Auto-schedule available in Pro plan',
'Set it and forget it',
'Keep your inbox clean automatically',
],
after_rules: [
'Automate with Pro plan',
'Unlimited rules and customizations',
'Advanced AI features',
],
}
export function UpgradePrompt({
title = 'Keep your inbox clean automatically',
benefits,
source,
onDismiss,
}: UpgradePromptProps) {
const navigate = useNavigate()
const { user } = useAuth()
const promptBenefits = benefits || defaultBenefits[source] || defaultBenefits.after_sort
// Check if this prompt was already shown in this session
const sessionKey = `upgrade_prompt_shown_${source}`
const wasShown = sessionStorage.getItem(sessionKey) === 'true'
if (wasShown) {
return null
}
const handleUpgrade = () => {
if (user?.$id) {
trackUpgradeClicked(user.$id, source)
}
navigate('/settings?tab=subscription')
}
const handleDismiss = () => {
sessionStorage.setItem(sessionKey, 'true')
onDismiss()
}
return (
<div className="bg-gradient-to-r from-primary-50 to-accent-50 border-2 border-primary-200 rounded-xl p-4 sm:p-6 shadow-lg">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-primary-500 flex items-center justify-center flex-shrink-0">
<Sparkles className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-lg font-bold text-slate-900">{title}</h3>
</div>
</div>
<button
onClick={handleDismiss}
className="text-slate-400 hover:text-slate-600 transition-colors flex-shrink-0"
>
<X className="w-5 h-5" />
</button>
</div>
<ul className="space-y-2 mb-4">
{promptBenefits.map((benefit, idx) => (
<li key={idx} className="flex items-start gap-2 text-sm text-slate-700">
<Zap className="w-4 h-4 text-primary-500 flex-shrink-0 mt-0.5" />
<span>{benefit}</span>
</li>
))}
</ul>
<div className="flex gap-3">
<Button
onClick={handleUpgrade}
className="flex-1 bg-primary-600 hover:bg-primary-700"
>
<Infinity className="w-4 h-4 mr-2" />
Upgrade
</Button>
<Button
variant="outline"
onClick={handleDismiss}
className="border-slate-300 text-slate-600 hover:bg-slate-50"
>
Not now
</Button>
</div>
</div>
)
}

View File

@@ -3,7 +3,6 @@ import {
Zap,
Shield,
Clock,
Tags,
Settings,
Inbox,
Filter
@@ -11,39 +10,42 @@ import {
const features = [
{
icon: Brain,
title: "AI-powered categorization",
description: "Our AI automatically recognizes whether an email is an invoice, newsletter, or important message.",
color: "from-violet-500 to-purple-600"
icon: Inbox,
title: "Stop drowning in emails",
description: "Clear inbox, less stress. Automatically sort newsletters, promotions, and social updates away from what matters.",
color: "from-violet-500 to-purple-600",
highlight: true,
},
{
icon: Zap,
title: "Real-time sorting",
description: "New emails are categorized instantly. Your inbox arrives already sorted.",
color: "from-amber-500 to-orange-600"
title: "One-click smart rules",
description: "AI suggests, you approve. Create smart rules in seconds and apply them with one click.",
color: "from-amber-500 to-orange-600",
highlight: true,
},
{
icon: Tags,
title: "Smart labels",
description: "Automatic labels for VIP, clients, invoices, newsletters, social media and more.",
color: "from-blue-500 to-cyan-600"
icon: Settings,
title: "Automation that keeps working",
description: "Set it and forget it. Your inbox stays organized automatically, day after day.",
color: "from-blue-500 to-cyan-600",
highlight: true,
},
{
icon: Brain,
title: "AI-powered smart sorting",
description: "Our AI automatically recognizes whether an email is an invoice, newsletter, or important message.",
color: "from-green-500 to-emerald-600"
},
{
icon: Shield,
title: "GDPR compliant",
description: "Your data stays secure. We only read email headers and metadata for sorting.",
color: "from-green-500 to-emerald-600"
color: "from-pink-500 to-rose-600"
},
{
icon: Clock,
title: "Save time",
description: "Average 2 hours per week less on email organization. More time for what matters.",
color: "from-pink-500 to-rose-600"
},
{
icon: Settings,
title: "Fully customizable",
description: "Define your own rules, VIP contacts, and categories based on your needs.",
color: "from-indigo-500 to-blue-600"
},
]
@@ -119,18 +121,23 @@ interface FeatureCardProps {
description: string
color: string
index: number
highlight?: boolean
}
function FeatureCard({ icon: Icon, title, description, color, index }: FeatureCardProps) {
function FeatureCard({ icon: Icon, title, description, color, index, highlight }: FeatureCardProps) {
return (
<div
className="group bg-white rounded-2xl p-6 border border-slate-200 hover:border-primary-200 hover:shadow-lg transition-all duration-300"
className={`group rounded-2xl p-6 border transition-all duration-300 ${
highlight
? 'bg-gradient-to-br from-white to-slate-50 border-primary-200 hover:border-primary-300 hover:shadow-xl'
: 'bg-white border-slate-200 hover:border-primary-200 hover:shadow-lg'
}`}
style={{ animationDelay: `${index * 0.1}s` }}
>
<div className={`w-14 h-14 rounded-xl bg-gradient-to-br ${color} flex items-center justify-center mb-5 group-hover:scale-110 transition-transform duration-300`}>
<div className={`w-14 h-14 rounded-xl bg-gradient-to-br ${color} flex items-center justify-center mb-5 group-hover:scale-110 transition-transform duration-300 shadow-lg`}>
<Icon className="w-7 h-7 text-white" />
</div>
<h3 className="text-xl font-semibold text-slate-900 mb-2">{title}</h3>
<h3 className={`${highlight ? 'text-2xl' : 'text-xl'} font-semibold text-slate-900 mb-2`}>{title}</h3>
<p className="text-slate-600">{description}</p>
</div>
)

View File

@@ -13,7 +13,7 @@ export function Footer() {
<Mail className="w-5 h-5 text-white" />
</div>
<span className="text-xl font-bold text-white">
Email<span className="text-primary-400">Sorter</span>
E-Mail-<span className="text-primary-400">Sorter</span>
</span>
</Link>
<p className="text-sm text-slate-400 mb-6">
@@ -132,6 +132,11 @@ export function Footer() {
Privacy Policy
</Link>
</li>
<li>
<Link to="/privacy-security" className="hover:text-white transition-colors">
Privacy & Security
</Link>
</li>
<li>
<Link to="/imprint" className="hover:text-white transition-colors">
Impressum

View File

@@ -37,35 +37,35 @@ export function Hero() {
</Badge>
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-extrabold text-white leading-tight mb-6">
Your inbox.
Clean inbox automatically
<br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-accent-400 to-primary-300">
Finally organized.
in minutes.
</span>
</h1>
<p className="text-lg sm:text-xl text-slate-300 mb-8 max-w-xl mx-auto lg:mx-0">
EmailSorter uses AI to automatically categorize your emails.
Newsletters, invoices, important contacts everything lands
exactly where it belongs.
Create smart rules, apply in one click, keep it clean with automation.
Stop drowning in emails and start focusing on what matters.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center lg:justify-start mb-8">
<Button
size="xl"
onClick={handleCTAClick}
className="group"
onClick={() => navigate('/setup?demo=true')}
className="group bg-accent-500 hover:bg-accent-600"
>
Start 14-day free trial
<ArrowRight className="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" />
<Sparkles className="w-5 h-5 mr-2" />
Try Demo
</Button>
<Button
size="xl"
onClick={handleCTAClick}
variant="outline"
className="bg-white/10 border-white/20 text-white hover:bg-white/20"
onClick={() => document.getElementById('how-it-works')?.scrollIntoView({ behavior: 'smooth' })}
className="bg-white/10 border-white/20 text-white hover:bg-white/20 group"
>
See how it works
Connect inbox
<ArrowRight className="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" />
</Button>
</div>

View File

@@ -28,7 +28,7 @@ export function Navbar() {
}, [location.pathname, navigate])
return (
<nav className="fixed top-0 left-0 right-0 z-50 bg-white/80 backdrop-blur-lg border-b border-slate-100">
<nav className="fixed top-0 left-0 right-0 z-50 bg-white/80 dark:bg-slate-900/80 backdrop-blur-lg border-b border-slate-100 dark:border-slate-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Logo */}
@@ -36,8 +36,8 @@ export function Navbar() {
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center">
<Mail className="w-5 h-5 text-white" />
</div>
<span className="text-xl font-bold text-slate-900">
Email<span className="text-primary-600">Sorter</span>
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">
E-Mail-<span className="text-primary-600 dark:text-primary-400">Sorter</span>
</span>
</Link>
@@ -45,25 +45,25 @@ export function Navbar() {
<div className="hidden md:flex items-center gap-8">
<button
onClick={() => scrollToSection('features')}
className="text-slate-600 hover:text-slate-900 font-medium transition-colors"
className="text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-slate-100 font-medium transition-colors"
>
Features
</button>
<button
onClick={() => scrollToSection('how-it-works')}
className="text-slate-600 hover:text-slate-900 font-medium transition-colors"
className="text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-slate-100 font-medium transition-colors"
>
How it works
</button>
<button
onClick={() => scrollToSection('pricing')}
className="text-slate-600 hover:text-slate-900 font-medium transition-colors"
className="text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-slate-100 font-medium transition-colors"
>
Pricing
</button>
<button
onClick={() => scrollToSection('faq')}
className="text-slate-600 hover:text-slate-900 font-medium transition-colors"
className="text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-slate-100 font-medium transition-colors"
>
FAQ
</button>
@@ -90,14 +90,14 @@ export function Navbar() {
{/* Mobile menu button */}
<button
className="md:hidden p-2.5 rounded-lg hover:bg-slate-100 active:bg-slate-200 touch-manipulation"
className="md:hidden p-2.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 active:bg-slate-200 dark:active:bg-slate-700 touch-manipulation"
onClick={() => setIsMenuOpen(!isMenuOpen)}
aria-label={isMenuOpen ? "Close menu" : "Open menu"}
>
{isMenuOpen ? (
<X className="w-6 h-6 text-slate-600" />
<X className="w-6 h-6 text-slate-600 dark:text-slate-300" />
) : (
<Menu className="w-6 h-6 text-slate-600" />
<Menu className="w-6 h-6 text-slate-600 dark:text-slate-300" />
)}
</button>
</div>
@@ -105,33 +105,33 @@ export function Navbar() {
{/* Mobile menu */}
{isMenuOpen && (
<div className="md:hidden bg-white border-t border-slate-100 shadow-lg animate-in slide-in-from-top-2 duration-200">
<div className="md:hidden bg-white dark:bg-slate-900 border-t border-slate-100 dark:border-slate-700 shadow-lg animate-in slide-in-from-top-2 duration-200">
<div className="px-3 py-3 space-y-1">
<button
onClick={() => scrollToSection('features')}
className="block w-full text-left px-4 py-3 text-sm font-medium text-slate-700 hover:bg-slate-50 active:bg-slate-100 rounded-lg transition-colors touch-manipulation"
className="block w-full text-left px-4 py-3 text-sm font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-800 active:bg-slate-100 dark:active:bg-slate-700 rounded-lg transition-colors touch-manipulation"
>
Features
</button>
<button
onClick={() => scrollToSection('how-it-works')}
className="block w-full text-left px-4 py-3 text-sm font-medium text-slate-700 hover:bg-slate-50 active:bg-slate-100 rounded-lg transition-colors touch-manipulation"
className="block w-full text-left px-4 py-3 text-sm font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-800 active:bg-slate-100 dark:active:bg-slate-700 rounded-lg transition-colors touch-manipulation"
>
How it works
</button>
<button
onClick={() => scrollToSection('pricing')}
className="block w-full text-left px-4 py-3 text-sm font-medium text-slate-700 hover:bg-slate-50 active:bg-slate-100 rounded-lg transition-colors touch-manipulation"
className="block w-full text-left px-4 py-3 text-sm font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-800 active:bg-slate-100 dark:active:bg-slate-700 rounded-lg transition-colors touch-manipulation"
>
Pricing
</button>
<button
onClick={() => scrollToSection('faq')}
className="block w-full text-left px-4 py-3 text-sm font-medium text-slate-700 hover:bg-slate-50 active:bg-slate-100 rounded-lg transition-colors touch-manipulation"
className="block w-full text-left px-4 py-3 text-sm font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-800 active:bg-slate-100 dark:active:bg-slate-700 rounded-lg transition-colors touch-manipulation"
>
FAQ
</button>
<div className="pt-3 mt-3 border-t border-slate-100 space-y-2">
<div className="pt-3 mt-3 border-t border-slate-100 dark:border-slate-700 space-y-2">
{user ? (
<Button className="w-full h-11" onClick={() => navigate('/dashboard')}>
Dashboard

View File

@@ -8,16 +8,16 @@ const badgeVariants = cva(
variants: {
variant: {
default:
"border-transparent bg-primary-100 text-primary-700",
"border-transparent bg-primary-100 dark:bg-primary-900 text-primary-700 dark:text-primary-200",
secondary:
"border-transparent bg-slate-100 text-slate-700",
"border-transparent bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-200",
success:
"border-transparent bg-green-100 text-green-700",
"border-transparent bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200",
warning:
"border-transparent bg-amber-100 text-amber-700",
"border-transparent bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-200",
destructive:
"border-transparent bg-red-100 text-red-700",
outline: "text-slate-600 border-slate-200",
"border-transparent bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-200",
outline: "text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-700",
},
},
defaultVariants: {

View File

@@ -4,22 +4,22 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-semibold ring-offset-white transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
"inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-semibold ring-offset-white dark:ring-offset-slate-900 transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-primary-600 text-white hover:bg-primary-700 shadow-lg shadow-primary-600/25 hover:shadow-primary-600/40",
"bg-primary-600 text-white hover:bg-primary-700 shadow-lg shadow-primary-600/25 hover:shadow-primary-600/40 dark:bg-primary-500 dark:hover:bg-primary-400",
secondary:
"bg-slate-100 text-slate-900 hover:bg-slate-200",
"bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700",
outline:
"border-2 border-slate-200 bg-transparent hover:bg-slate-50 hover:border-slate-300",
"border-2 border-slate-200 bg-transparent hover:bg-slate-50 hover:border-slate-300 dark:border-slate-700 dark:text-slate-100 dark:hover:bg-slate-800 dark:hover:border-slate-600",
ghost:
"hover:bg-slate-100 hover:text-slate-900",
"hover:bg-slate-100 hover:text-slate-900 dark:hover:bg-slate-800 dark:hover:text-slate-100",
link:
"text-primary-600 underline-offset-4 hover:underline",
"text-primary-600 underline-offset-4 hover:underline dark:text-primary-400 dark:hover:text-primary-300",
accent:
"bg-accent-500 text-white hover:bg-accent-600 shadow-lg shadow-accent-500/25",
"bg-accent-500 text-white hover:bg-accent-600 shadow-lg shadow-accent-500/25 dark:bg-accent-500 dark:hover:bg-accent-400",
},
size: {
default: "h-11 px-6 py-2",

View File

@@ -8,7 +8,7 @@ const Card = React.forwardRef<
<div
ref={ref}
className={cn(
"rounded-2xl border border-slate-200 bg-white shadow-sm transition-shadow hover:shadow-md",
"rounded-2xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 shadow-sm dark:shadow-slate-900/20 transition-shadow hover:shadow-md dark:hover:shadow-slate-900/30",
className
)}
{...props}
@@ -35,7 +35,7 @@ const CardTitle = React.forwardRef<
<h3
ref={ref}
className={cn(
"text-2xl font-bold leading-none tracking-tight text-slate-900",
"text-2xl font-bold leading-none tracking-tight text-slate-900 dark:text-slate-100",
className
)}
{...props}
@@ -49,7 +49,7 @@ const CardDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-slate-500", className)}
className={cn("text-sm text-slate-500 dark:text-slate-400", className)}
{...props}
/>
))

View File

@@ -13,7 +13,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
"flex h-11 w-full rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm text-slate-900 transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:border-primary-500 disabled:cursor-not-allowed disabled:opacity-50",
"flex h-11 w-full rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 px-4 py-2 text-sm text-slate-900 dark:text-slate-100 transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-400 dark:placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:border-primary-500 disabled:cursor-not-allowed disabled:opacity-50",
error && "border-red-500 focus-visible:ring-red-500",
className
)}

View File

@@ -0,0 +1,148 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const SidePanel = DialogPrimitive.Root
const SidePanelTrigger = DialogPrimitive.Trigger
const SidePanelPortal = DialogPrimitive.Portal
const SidePanelClose = DialogPrimitive.Close
const SidePanelOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/50 dark:bg-black/70 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
SidePanelOverlay.displayName = DialogPrimitive.Overlay.displayName
const SidePanelContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<SidePanelPortal>
<SidePanelOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed z-50 gap-4 bg-white dark:bg-slate-900 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-right-full",
"top-0 right-0 h-full w-full sm:w-[480px] border-l border-slate-200 dark:border-slate-700",
"flex flex-col",
className
)}
{...props}
>
{children}
</DialogPrimitive.Content>
</SidePanelPortal>
))
SidePanelContent.displayName = DialogPrimitive.Content.displayName
const SidePanelHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 p-6 border-b border-slate-200 dark:border-slate-700",
className
)}
{...props}
/>
)
SidePanelHeader.displayName = "SidePanelHeader"
const SidePanelTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-xl font-semibold leading-none tracking-tight text-slate-900 dark:text-slate-100",
className
)}
{...props}
/>
))
SidePanelTitle.displayName = DialogPrimitive.Title.displayName
const SidePanelDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-slate-500 dark:text-slate-400", className)}
{...props}
/>
))
SidePanelDescription.displayName = DialogPrimitive.Description.displayName
const SidePanelCloseButton = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Close>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Close>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Close
ref={ref}
className={cn(
"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white dark:ring-offset-slate-900 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-slate-100 dark:data-[state=open]:bg-slate-800",
className
)}
{...props}
>
<X className="h-4 w-4 text-slate-500 dark:text-slate-400" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
))
SidePanelCloseButton.displayName = "SidePanelCloseButton"
const SidePanelBody = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex-1 overflow-y-auto p-6", className)}
{...props}
/>
)
SidePanelBody.displayName = "SidePanelBody"
const SidePanelFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 p-6 border-t border-slate-200 dark:border-slate-700",
className
)}
{...props}
/>
)
SidePanelFooter.displayName = "SidePanelFooter"
export {
SidePanel,
SidePanelPortal,
SidePanelOverlay,
SidePanelTrigger,
SidePanelClose,
SidePanelContent,
SidePanelHeader,
SidePanelTitle,
SidePanelDescription,
SidePanelCloseButton,
SidePanelBody,
SidePanelFooter,
}

View File

@@ -0,0 +1,41 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface SliderProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
value?: number
onValueChange?: (value: number) => void
}
const Slider = React.forwardRef<HTMLInputElement, SliderProps>(
({ className, value, onValueChange, min = 0, max = 365, step = 1, ...props }, ref) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = parseInt(e.target.value)
onValueChange?.(newValue)
}
return (
<input
type="range"
ref={ref}
className={cn(
"w-full h-2 bg-slate-200 dark:bg-slate-700 rounded-lg appearance-none cursor-pointer",
"accent-primary-500 dark:accent-primary-600",
"[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary-500 [&::-webkit-slider-thumb]:cursor-pointer",
"dark:[&::-webkit-slider-thumb]:bg-primary-600",
"[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary-500 [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:cursor-pointer",
"dark:[&::-moz-range-thumb]:bg-primary-600",
className
)}
value={value}
onChange={handleChange}
min={min}
max={max}
step={step}
{...props}
/>
)
}
)
Slider.displayName = "Slider"
export { Slider }

View File

@@ -0,0 +1,52 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-lg bg-slate-100 dark:bg-slate-800 p-1 text-slate-500 dark:text-slate-400",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-white dark:ring-offset-slate-900 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white dark:data-[state=active]:bg-slate-700 data-[state=active]:text-slate-900 dark:data-[state=active]:text-slate-100 data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-white dark:ring-offset-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,186 @@
/**
* Theme Hook
* Handles dark mode detection and Dark Reader compatibility
* Uses MutationObserver for efficient Dark Reader detection
*/
import { useEffect, useState } from 'react'
export function useTheme() {
const [isDark, setIsDark] = useState(false)
const [hasDarkReader, setHasDarkReader] = useState(false)
useEffect(() => {
const html = document.documentElement
// Helper function to apply/remove dark mode
const applyDarkMode = (shouldBeDark: boolean) => {
setIsDark(shouldBeDark)
if (shouldBeDark) {
html.classList.add('dark')
html.setAttribute('data-theme', 'dark')
} else {
html.classList.remove('dark')
html.setAttribute('data-theme', 'light')
}
}
// Enhanced Dark Reader detection with multiple methods
const detectDarkReader = (): boolean => {
// Method 1: Check for Dark Reader data attributes on html element
const hasDarkReaderAttributes =
html.hasAttribute('data-darkreader-mode') ||
html.hasAttribute('data-darkreader-scheme') ||
html.hasAttribute('data-darkreader-policy')
// Method 2: Check for Dark Reader stylesheet or meta tags
const hasDarkReaderMeta =
document.querySelector('meta[name="darkreader"]') !== null ||
document.querySelector('style[data-darkreader]') !== null
// Method 3: Check computed styles for filter/invert (Dark Reader uses CSS filters)
const computedStyle = window.getComputedStyle(html)
const hasFilter = computedStyle.filter && computedStyle.filter !== 'none'
const hasInvert = computedStyle.filter?.includes('invert') ||
computedStyle.filter?.includes('brightness')
// Method 4: Check for Dark Reader's characteristic background color
// Dark Reader often sets a specific dark background
const bgColor = computedStyle.backgroundColor
const isDarkReaderBg = bgColor === 'rgb(24, 26, 27)' ||
bgColor === 'rgb(18, 18, 18)' ||
(window.matchMedia('(prefers-color-scheme: dark)').matches &&
bgColor !== 'rgba(0, 0, 0, 0)' &&
bgColor !== 'transparent' &&
!html.classList.contains('dark'))
// Method 5: Check for Dark Reader injected styles
const styleSheets = Array.from(document.styleSheets)
const hasDarkReaderStylesheet = styleSheets.some(sheet => {
try {
const href = sheet.href || ''
return href.includes('darkreader') ||
(sheet.ownerNode as Element)?.getAttribute('data-darkreader') !== null
} catch {
return false
}
})
return hasDarkReaderAttributes ||
hasDarkReaderMeta ||
(hasFilter && hasInvert) ||
isDarkReaderBg ||
hasDarkReaderStylesheet
}
// Check system preference
const checkSystemPreference = (): boolean => {
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
// Update theme based on current state
const updateTheme = () => {
const darkReaderDetected = detectDarkReader()
const systemPrefersDark = checkSystemPreference()
setHasDarkReader(darkReaderDetected)
// Only apply dark mode if system prefers it AND Dark Reader is not active
if (systemPrefersDark && !darkReaderDetected) {
applyDarkMode(true)
} else {
applyDarkMode(false)
}
}
// Initial check
updateTheme()
// Listen for system preference changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleSystemPreferenceChange = (e: MediaQueryListEvent) => {
updateTheme()
}
// Modern browsers
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener('change', handleSystemPreferenceChange)
} else {
// Fallback for older browsers
mediaQuery.addListener(handleSystemPreferenceChange)
}
// MutationObserver for Dark Reader attribute changes (more efficient than setInterval)
const observer = new MutationObserver((mutations) => {
let shouldUpdate = false
mutations.forEach((mutation) => {
// Check if Dark Reader attributes were added/removed
if (mutation.type === 'attributes') {
const attrName = mutation.attributeName
if (attrName?.startsWith('data-darkreader') ||
attrName === 'class' ||
attrName === 'data-theme') {
shouldUpdate = true
}
}
// Check if Dark Reader elements were added/removed
if (mutation.type === 'childList') {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as Element
if (el.hasAttribute?.('data-darkreader') ||
el.tagName === 'META' && el.getAttribute('name') === 'darkreader' ||
el.tagName === 'STYLE' && el.hasAttribute('data-darkreader')) {
shouldUpdate = true
}
}
})
}
})
if (shouldUpdate) {
updateTheme()
}
})
// Observe html element for Dark Reader attribute changes
observer.observe(html, {
attributes: true,
attributeFilter: ['data-darkreader-mode', 'data-darkreader-scheme', 'data-darkreader-policy', 'class', 'data-theme'],
childList: true,
subtree: false
})
// Also observe document head for Dark Reader meta/stylesheets
if (document.head) {
observer.observe(document.head, {
childList: true,
subtree: false
})
}
// Fallback: Periodic check (reduced frequency, only as safety net)
// This catches edge cases where MutationObserver might miss something
const fallbackInterval = setInterval(() => {
const currentDarkReader = detectDarkReader()
if (currentDarkReader !== hasDarkReader) {
updateTheme()
}
}, 5000) // Check every 5 seconds (reduced from 2 seconds)
return () => {
// Cleanup
if (mediaQuery.removeEventListener) {
mediaQuery.removeEventListener('change', handleSystemPreferenceChange)
} else {
mediaQuery.removeListener(handleSystemPreferenceChange)
}
observer.disconnect()
clearInterval(fallbackInterval)
}
}, [])
return { isDark, hasDarkReader }
}

View File

@@ -24,7 +24,7 @@
--color-accent-500: #10b981;
--color-accent-600: #059669;
/* Neutral/Slate colors */
/* Neutral/Slate colors - Keep original values for Tailwind compatibility */
--color-slate-50: #f8fafc;
--color-slate-100: #f1f5f9;
--color-slate-200: #e2e8f0;
@@ -38,6 +38,49 @@
--color-slate-950: #020617;
}
/* Dark Mode specific color variables for optimized appearance */
:root.dark,
:root[data-theme="dark"] {
/* Optimized dark mode colors - not pure black, more pleasant */
--color-bg-dark: #1e293b; /* slate-800 - pleasant dark background */
--color-bg-card-dark: #334155; /* slate-700 - cards stand out from background */
--color-text-dark: #f1f5f9; /* slate-100 - soft white, not pure white */
--color-text-muted-dark: #cbd5e1; /* slate-300 - muted text */
--color-border-dark: #475569; /* slate-600 - visible but subtle borders */
--color-accent-dark: #4ade80; /* primary-400 - slightly desaturated for dark mode */
}
/* Prevent double inversion when Dark Reader is active - Force Light Mode */
:root[data-darkreader-mode],
:root[data-darkreader-scheme],
:root[data-darkreader-policy] {
/* Explicitly remove dark mode classes and force light theme */
color-scheme: light !important;
}
:root[data-darkreader-mode] body,
:root[data-darkreader-scheme] body,
:root[data-darkreader-policy] body {
background-color: var(--color-slate-50) !important;
color: var(--color-slate-900) !important;
}
/* Prevent Dark Reader from applying dark mode when it's active */
:root[data-darkreader-mode] .dark,
:root[data-darkreader-scheme] .dark,
:root[data-darkreader-policy] .dark {
/* Force light mode styles even if dark class is present */
background-color: var(--color-slate-50) !important;
color: var(--color-slate-900) !important;
}
:root[data-darkreader-mode] *,
:root[data-darkreader-scheme] *,
:root[data-darkreader-policy] * {
/* Prevent Dark Reader from inverting our colors */
filter: none !important;
}
/* Base styles */
html {
scroll-behavior: smooth;
@@ -50,6 +93,17 @@ body {
/* Improve touch scrolling on mobile */
-webkit-overflow-scrolling: touch;
overflow-scrolling: touch;
/* Base colors - Tailwind will handle dark mode */
background-color: var(--color-slate-50);
color: var(--color-slate-900);
transition: background-color 0.2s ease, color 0.2s ease;
}
/* Dark mode body adjustments */
:root.dark body,
:root[data-theme="dark"] body {
background-color: var(--color-slate-900);
color: var(--color-slate-50);
}
/* Improve touch targets on mobile */
@@ -65,6 +119,11 @@ body {
}
}
:root.dark *,
:root[data-theme="dark"] * {
-webkit-tap-highlight-color: rgba(34, 197, 94, 0.2);
}
/* Touch manipulation for better performance */
.touch-manipulation {
touch-action: manipulation;
@@ -79,6 +138,12 @@ body {
color: var(--color-primary-900);
}
:root.dark ::selection,
:root[data-theme="dark"] ::selection {
background-color: var(--color-primary-600);
color: var(--color-primary-50);
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
@@ -98,11 +163,33 @@ body {
background: var(--color-slate-400);
}
/* Dark mode scrollbar - optimized colors */
:root.dark ::-webkit-scrollbar-track,
:root[data-theme="dark"] ::-webkit-scrollbar-track {
background: var(--color-bg-card-dark, var(--color-slate-700));
}
:root.dark ::-webkit-scrollbar-thumb,
:root[data-theme="dark"] ::-webkit-scrollbar-thumb {
background: var(--color-border-dark, var(--color-slate-600));
border-radius: 4px;
}
:root.dark ::-webkit-scrollbar-thumb:hover,
:root[data-theme="dark"] ::-webkit-scrollbar-thumb:hover {
background: var(--color-slate-500);
}
/* Gradient backgrounds */
.gradient-hero {
background: linear-gradient(135deg, var(--color-slate-900) 0%, var(--color-primary-900) 50%, var(--color-slate-800) 100%);
}
:root.dark .gradient-hero,
:root[data-theme="dark"] .gradient-hero {
background: linear-gradient(135deg, var(--color-slate-950) 0%, var(--color-primary-950) 50%, var(--color-slate-900) 100%);
}
.gradient-mesh {
background-image:
radial-gradient(at 40% 20%, var(--color-primary-500) 0px, transparent 50%),
@@ -112,6 +199,17 @@ body {
radial-gradient(at 0% 100%, var(--color-primary-600) 0px, transparent 50%);
}
:root.dark .gradient-mesh,
:root[data-theme="dark"] .gradient-mesh {
background-image:
radial-gradient(at 40% 20%, rgba(34, 197, 94, 0.15) 0px, transparent 50%),
radial-gradient(at 80% 0%, rgba(16, 185, 129, 0.15) 0px, transparent 50%),
radial-gradient(at 0% 50%, rgba(22, 163, 74, 0.15) 0px, transparent 50%),
radial-gradient(at 80% 50%, rgba(52, 211, 153, 0.15) 0px, transparent 50%),
radial-gradient(at 0% 100%, rgba(16, 185, 129, 0.15) 0px, transparent 50%);
opacity: 0.8;
}
/* Animation classes */
@keyframes float {
0%, 100% { transform: translateY(0px); }
@@ -142,3 +240,43 @@ body {
.stagger-3 { animation-delay: 0.3s; }
.stagger-4 { animation-delay: 0.4s; }
.stagger-5 { animation-delay: 0.5s; }
/* Dark mode specific adjustments */
:root.dark,
:root[data-theme="dark"] {
/* Ensure good contrast for focus states */
--focus-ring-color: var(--color-accent-dark, var(--color-primary-400));
--focus-ring-offset: var(--color-bg-dark, var(--color-slate-800));
}
/* Links in dark mode - optimized for visibility and comfort */
:root.dark a:not([class*="text-"]),
:root[data-theme="dark"] a:not([class*="text-"]) {
color: var(--color-accent-dark, var(--color-primary-400));
}
:root.dark a:not([class*="text-"]):hover,
:root[data-theme="dark"] a:not([class*="text-"]):hover {
color: var(--color-primary-300);
}
/* Borders in dark mode - subtle but visible with better contrast */
:root.dark hr:not([class*="border-"]),
:root[data-theme="dark"] hr:not([class*="border-"]) {
border-color: var(--color-border-dark, var(--color-slate-600));
}
/* Code blocks and pre elements - better contrast */
:root.dark code:not([class]),
:root[data-theme="dark"] code:not([class]),
:root.dark pre:not([class]),
:root[data-theme="dark"] pre:not([class]) {
background-color: var(--color-bg-card-dark, var(--color-slate-700));
color: var(--color-text-dark, var(--color-slate-100));
border-color: var(--color-border-dark, var(--color-slate-600));
}
/* Prevent transitions on initial load to avoid FOUC */
html:not(.dark-mode-initialized) body {
transition: none;
}

View File

@@ -15,13 +15,15 @@ export interface TrackingParams {
}
export interface ConversionEvent {
type: 'page_view' | 'signup' | 'trial_start' | 'purchase' | 'email_connected'
type: 'page_view' | 'signup' | 'trial_start' | 'purchase' | 'email_connected' | 'onboarding_step' | 'provider_connected' | 'demo_used' | 'suggested_rules_generated' | 'rule_created' | 'rules_applied' | 'limit_reached' | 'upgrade_clicked' | 'referral_shared' | 'sort_completed' | 'account_deleted'
userId?: string
metadata?: Record<string, any>
sessionId?: string
}
const STORAGE_KEY = 'emailsorter_utm_params'
const USER_ID_KEY = 'emailsorter_user_id'
const SESSION_ID_KEY = 'emailsorter_session_id'
/**
* Parse UTM parameters from URL
@@ -152,11 +154,11 @@ export async function trackEvent(
const payload = {
...event,
userId: event.userId || userId || undefined,
sessionId: event.sessionId || getSessionId(),
tracking: params,
timestamp: new Date().toISOString(),
page: window.location.pathname,
referrer: document.referrer || undefined,
userAgent: navigator.userAgent,
}
try {
@@ -312,3 +314,133 @@ export function getTrackingQueryString(): string {
? '&' + new URLSearchParams(entries as string[][]).toString()
: ''
}
/**
* Get or create session ID
*/
function getSessionId(): string {
try {
let sessionId = sessionStorage.getItem(SESSION_ID_KEY)
if (!sessionId) {
sessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
sessionStorage.setItem(SESSION_ID_KEY, sessionId)
}
return sessionId
} catch {
// Fallback if sessionStorage is not available
return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
}
/**
* Track onboarding step
*/
export function trackOnboardingStep(userId: string, step: string): void {
trackEvent({
type: 'onboarding_step',
userId,
metadata: {
step,
timestamp: new Date().toISOString(),
},
})
}
/**
* Track provider connection
*/
export function trackProviderConnected(userId: string, provider: string): void {
trackEvent({
type: 'provider_connected',
userId,
metadata: {
provider,
timestamp: new Date().toISOString(),
},
})
}
/**
* Track demo account usage
*/
export function trackDemoUsed(userId: string): void {
trackEvent({
type: 'demo_used',
userId,
metadata: {
timestamp: new Date().toISOString(),
},
})
}
/**
* Track sort completion
*/
export function trackSortCompleted(userId: string, sortedCount: number, isFirstRun: boolean): void {
trackEvent({
type: 'sort_completed',
userId,
metadata: {
sortedCount,
isFirstRun,
timestamp: new Date().toISOString(),
},
})
}
/**
* Track limit reached
*/
export function trackLimitReached(userId: string, limit: number, used: number): void {
trackEvent({
type: 'limit_reached',
userId,
metadata: {
limit,
used,
timestamp: new Date().toISOString(),
},
})
}
/**
* Track rules applied
*/
export function trackRulesApplied(userId: string, rulesCount: number): void {
trackEvent({
type: 'rules_applied',
userId,
metadata: {
rulesCount,
timestamp: new Date().toISOString(),
},
})
}
/**
* Track upgrade clicked
*/
export function trackUpgradeClicked(userId: string, source: string): void {
trackEvent({
type: 'upgrade_clicked',
userId,
metadata: {
source,
timestamp: new Date().toISOString(),
},
})
}
/**
* Track referral shared
*/
export function trackReferralShared(userId: string, referralCode: string): void {
trackEvent({
type: 'referral_shared',
userId,
metadata: {
referralCode,
timestamp: new Date().toISOString(),
},
})
}

View File

@@ -7,6 +7,8 @@ interface ApiResponse<T> {
code: string
message: string
fields?: Record<string, string[]>
limit?: number
used?: number
}
}
@@ -97,6 +99,14 @@ export const api = {
suggestions: Array<{ type: string; message: string }>
provider?: string
isDemo?: boolean
isFirstRun?: boolean
suggestedRules?: Array<{
type: string
name: string
description: string
confidence: number
action: any
}>
}>('/email/sort', {
method: 'POST',
body: JSON.stringify({ userId, accountId, maxEmails, processAll }),
@@ -205,6 +215,9 @@ export const api = {
return fetchApi<{
status: string
plan: string
isFreeTier: boolean
emailsUsedThisMonth?: number
emailsLimit?: number
features: {
emailAccounts: number
emailsPerDay: number
@@ -279,6 +292,9 @@ export const api = {
enabledCategories: string[]
categoryActions: Record<string, 'inbox' | 'archive_read' | 'star'>
autoDetectCompanies: boolean
cleanup?: any
categoryAdvanced?: Record<string, any>
version?: number
}>(`/preferences/ai-control?userId=${userId}`)
},
@@ -286,6 +302,9 @@ export const api = {
enabledCategories?: string[]
categoryActions?: Record<string, 'inbox' | 'archive_read' | 'star'>
autoDetectCompanies?: boolean
cleanup?: any
categoryAdvanced?: Record<string, any>
version?: number
}) {
return fetchApi<{ success: boolean }>('/preferences/ai-control', {
method: 'POST',
@@ -293,6 +312,57 @@ export const api = {
})
},
// Cleanup Preview - shows what would be cleaned up without actually doing it
// TODO: Backend endpoint needs to be implemented
// GET /api/preferences/ai-control/cleanup/preview?userId=xxx
// Response: { preview: Array<{id, subject, from, date, reason}> }
async getCleanupPreview(userId: string) {
// TODO: Implement backend endpoint
return fetchApi<{
preview: Array<{
id: string
subject: string
from: string
date: string
reason: 'read' | 'promotion'
}>
}>(`/preferences/ai-control/cleanup/preview?userId=${userId}`)
},
// Run cleanup now - executes cleanup for the user
// POST /api/preferences/ai-control/cleanup/run
// Body: { userId: string }
// Response: { success: boolean, data: { readItems: number, promotions: number } }
async runCleanup(userId: string) {
// Uses existing /api/email/cleanup endpoint
return fetchApi<{
usersProcessed: number
emailsProcessed: {
readItems: number
promotions: number
}
errors: Array<{ userId: string; error: string }>
}>('/email/cleanup', {
method: 'POST',
body: JSON.stringify({ userId }),
})
},
// Get cleanup status - last run info and counts
// TODO: Backend endpoint needs to be implemented
// GET /api/preferences/ai-control/cleanup/status?userId=xxx
// Response: { lastRun?: string, lastRunCounts?: { readItems: number, promotions: number } }
async getCleanupStatus(userId: string) {
// TODO: Implement backend endpoint
return fetchApi<{
lastRun?: string
lastRunCounts?: {
readItems: number
promotions: number
}
}>(`/preferences/ai-control/cleanup/status?userId=${userId}`)
},
// ═══════════════════════════════════════════════════════════════════════════
// COMPANY LABELS
// ═══════════════════════════════════════════════════════════════════════════
@@ -386,6 +456,72 @@ export const api = {
uptime: number
}>('/health')
},
// ═══════════════════════════════════════════════════════════════════════════
// ONBOARDING
// ═══════════════════════════════════════════════════════════════════════════
async getOnboardingStatus(userId: string) {
return fetchApi<{
onboarding_step: string
completedSteps: string[]
first_value_seen_at?: string
skipped_at?: string
}>(`/onboarding/status?userId=${userId}`)
},
async updateOnboardingStep(userId: string, step: string, completedSteps: string[] = []) {
return fetchApi<{ step: string; completedSteps: string[] }>('/onboarding/step', {
method: 'POST',
body: JSON.stringify({ userId, step, completedSteps }),
})
},
async skipOnboarding(userId: string) {
return fetchApi<{ skipped: boolean }>('/onboarding/skip', {
method: 'POST',
body: JSON.stringify({ userId }),
})
},
async resumeOnboarding(userId: string) {
return fetchApi<{
onboarding_step: string
completedSteps: string[]
}>('/onboarding/resume', {
method: 'POST',
body: JSON.stringify({ userId }),
})
},
// ═══════════════════════════════════════════════════════════════════════════
// ACCOUNT MANAGEMENT
// ═══════════════════════════════════════════════════════════════════════════
async deleteAccount(userId: string) {
return fetchApi<{ success: boolean }>('/account/delete', {
method: 'DELETE',
body: JSON.stringify({ userId }),
})
},
// ═══════════════════════════════════════════════════════════════════════════
// REFERRALS
// ═══════════════════════════════════════════════════════════════════════════
async getReferralCode(userId: string) {
return fetchApi<{
referralCode: string
referralCount: number
}>(`/referrals/code?userId=${userId}`)
},
async trackReferral(userId: string, referralCode: string) {
return fetchApi<{ success: boolean }>('/referrals/track', {
method: 'POST',
body: JSON.stringify({ userId, referralCode }),
})
},
}
export default api

File diff suppressed because it is too large Load Diff

View File

@@ -5,11 +5,11 @@ export function Imprint() {
return (
<div className="min-h-screen bg-slate-50">
{/* Header */}
<header className="bg-white border-b border-slate-200">
<header className="bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<Link
to="/"
className="inline-flex items-center gap-2 text-slate-600 hover:text-slate-900 transition-colors"
className="inline-flex items-center gap-2 text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-slate-100 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
<span className="text-sm font-medium">Back to Home</span>

View File

@@ -41,7 +41,7 @@ export function Login() {
<Mail className="w-5 h-5 text-white" />
</div>
<span className="text-xl font-bold text-white">
Email<span className="text-primary-400">Sorter</span>
E-Mail-<span className="text-primary-400">Sorter</span>
</span>
</Link>

View File

@@ -5,11 +5,11 @@ export function Privacy() {
return (
<div className="min-h-screen bg-slate-50">
{/* Header */}
<header className="bg-white border-b border-slate-200">
<header className="bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<Link
to="/"
className="inline-flex items-center gap-2 text-slate-600 hover:text-slate-900 transition-colors"
className="inline-flex items-center gap-2 text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-slate-100 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
<span className="text-sm font-medium">Back to Home</span>

View File

@@ -3,6 +3,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { useAuth } from '@/context/AuthContext'
import { analytics } from '@/hooks/useAnalytics'
import { captureUTMParams } from '@/lib/analytics'
import { api } from '@/lib/api'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -12,6 +13,7 @@ import { Mail, Lock, User, ArrowRight, AlertCircle, Check, Sparkles } from 'luci
export function Register() {
const [searchParams] = useSearchParams()
const selectedPlan = searchParams.get('plan') || 'pro'
const referralCode = searchParams.get('ref') || null
const [name, setName] = useState('')
const [email, setEmail] = useState('')
@@ -20,7 +22,7 @@ export function Register() {
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { register } = useAuth()
const { register, user } = useAuth()
const navigate = useNavigate()
// Capture UTM parameters on mount
@@ -28,6 +30,22 @@ export function Register() {
captureUTMParams()
}, [])
// Track referral and signup after user is registered
useEffect(() => {
if (user?.$id && referralCode) {
// Track referral if code exists
api.trackReferral(user.$id, referralCode).catch((err) => {
console.error('Failed to track referral:', err)
})
}
if (user?.$id) {
// Track signup conversion with UTM parameters
analytics.trackSignup(user.$id, email)
analytics.setUserId(user.$id)
}
}, [user, referralCode, email])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
@@ -45,14 +63,7 @@ export function Register() {
setLoading(true)
try {
const user = await register(email, password, name)
// Track signup conversion with UTM parameters
if (user?.$id) {
analytics.trackSignup(user.$id, email)
analytics.setUserId(user.$id)
}
await register(email, password, name)
navigate('/setup')
} catch (err: any) {
setError(err.message || 'Registration failed. Please try again.')
@@ -111,7 +122,7 @@ export function Register() {
<Mail className="w-5 h-5 text-white" />
</div>
<span className="text-xl font-bold text-slate-900">
Email<span className="text-primary-600">Sorter</span>
E-Mail-<span className="text-primary-600">Sorter</span>
</span>
</Link>

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,9 @@ import { useAuth } from '@/context/AuthContext'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { OnboardingProgress } from '@/components/OnboardingProgress'
import { api } from '@/lib/api'
import { trackOnboardingStep, trackProviderConnected, trackDemoUsed } from '@/lib/analytics'
import {
Mail,
ArrowRight,
@@ -24,7 +26,6 @@ type Step = 'connect' | 'preferences' | 'categories' | 'complete'
export function Setup() {
const [searchParams] = useSearchParams()
const isFromCheckout = searchParams.get('subscription') === 'success'
const autoSetup = searchParams.get('setup') === 'auto'
const [currentStep, setCurrentStep] = useState<Step>('connect')
const [connectedProvider, setConnectedProvider] = useState<string | null>(null)
@@ -40,9 +41,48 @@ export function Setup() {
])
const [saving, setSaving] = useState(false)
const [checkingAccounts, setCheckingAccounts] = useState(isFromCheckout)
const [onboardingState, setOnboardingState] = useState<{
onboarding_step: string
completedSteps: string[]
} | null>(null)
const [loadingOnboarding, setLoadingOnboarding] = useState(true)
const { user } = useAuth()
const navigate = useNavigate()
const resumeOnboarding = searchParams.get('resume') === 'true'
// Load onboarding state
useEffect(() => {
if (user?.$id) {
const loadOnboarding = async () => {
try {
const stateRes = await api.getOnboardingStatus(user.$id)
if (stateRes.data) {
setOnboardingState(stateRes.data)
// If resuming, restore step
if (resumeOnboarding && stateRes.data.onboarding_step !== 'completed' && stateRes.data.onboarding_step !== 'not_started') {
const stepMap: Record<string, Step> = {
'connect': 'connect',
'first_rule': 'preferences',
'see_results': 'categories',
'auto_schedule': 'complete',
}
const mappedStep = stepMap[stateRes.data.onboarding_step]
if (mappedStep) {
setCurrentStep(mappedStep)
}
}
}
} catch (err) {
console.error('Error loading onboarding state:', err)
} finally {
setLoadingOnboarding(false)
}
}
loadOnboarding()
}
}, [user, resumeOnboarding])
// Check if user already has connected accounts after successful checkout
useEffect(() => {
@@ -82,11 +122,17 @@ export function Setup() {
try {
const response = await api.getOAuthUrl('gmail', user.$id)
if (response.data?.url) {
// Track onboarding step before redirect
await api.updateOnboardingStep(user.$id, 'connect', ['connect'])
window.location.href = response.data.url
} else {
setConnectedProvider('gmail')
setConnectedEmail(user.email)
setCurrentStep('preferences')
// Track onboarding step
await api.updateOnboardingStep(user.$id, 'first_rule', ['connect'])
trackOnboardingStep(user.$id, 'first_rule')
trackProviderConnected(user.$id, 'gmail')
}
} catch (err) {
setError('Gmail connection failed. Please try again.')
@@ -103,11 +149,15 @@ export function Setup() {
try {
const response = await api.getOAuthUrl('outlook', user.$id)
if (response.data?.url) {
// Track onboarding step before redirect
await api.updateOnboardingStep(user.$id, 'connect', ['connect'])
window.location.href = response.data.url
} else {
setConnectedProvider('outlook')
setConnectedEmail(user.email)
setCurrentStep('preferences')
// Track onboarding step
await api.updateOnboardingStep(user.$id, 'first_rule', ['connect'])
}
} catch (err) {
setError('Outlook connection failed. Please try again.')
@@ -116,10 +166,54 @@ export function Setup() {
}
}
const handleNext = () => {
const handleConnectDemo = async () => {
if (!user?.$id) return
setConnecting('demo')
setError(null)
try {
const response = await api.connectDemoAccount(user.$id)
if (response.data) {
setConnectedProvider('demo')
setConnectedEmail(response.data.email)
setCurrentStep('preferences')
// Track onboarding step
await api.updateOnboardingStep(user.$id, 'first_rule', ['connect'])
trackOnboardingStep(user.$id, 'first_rule')
trackDemoUsed(user.$id)
}
} catch (err) {
setError('Demo connection failed. Please try again.')
} finally {
setConnecting(null)
}
}
const handleNext = async () => {
const nextIndex = stepIndex + 1
if (nextIndex < steps.length) {
setCurrentStep(steps[nextIndex].id)
const nextStep = steps[nextIndex].id
setCurrentStep(nextStep)
// Track onboarding progress
if (user?.$id) {
const stepMap: Record<Step, string> = {
'connect': 'connect',
'preferences': 'first_rule',
'categories': 'see_results',
'complete': 'auto_schedule',
}
const onboardingStep = stepMap[nextStep]
const completedSteps = onboardingState?.completedSteps || []
if (onboardingStep && !completedSteps.includes(stepMap[currentStep])) {
const newCompleted = [...completedSteps, stepMap[currentStep]]
await api.updateOnboardingStep(user.$id, onboardingStep, newCompleted)
setOnboardingState({
onboarding_step: onboardingStep,
completedSteps: newCompleted,
})
}
}
}
}
@@ -144,6 +238,9 @@ export function Setup() {
customRules: [],
priorityTopics: selectedCategories,
})
// Mark onboarding as completed
await api.updateOnboardingStep(user.$id, 'completed', ['connect', 'first_rule', 'see_results', 'auto_schedule'])
} catch (err) {
console.error('Failed to save preferences:', err)
} finally {
@@ -152,6 +249,18 @@ export function Setup() {
}
}
const handleSkipOnboarding = async () => {
if (!user?.$id) return
try {
await api.skipOnboarding(user.$id)
navigate('/dashboard')
} catch (err) {
console.error('Failed to skip onboarding:', err)
navigate('/dashboard')
}
}
const categories = [
{ id: 'vip', name: 'Important / VIP', description: 'Priority contacts', icon: '⭐', color: 'bg-amber-500' },
{ id: 'customers', name: 'Clients / Projects', description: 'Business correspondence', icon: '💼', color: 'bg-blue-500' },
@@ -185,18 +294,18 @@ export function Setup() {
return (
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white">
<header className="bg-white/80 backdrop-blur-sm border-b border-slate-200 sticky top-0 z-40">
<header className="bg-white/80 dark:bg-slate-900/80 backdrop-blur-sm border-b border-slate-200 dark:border-slate-700 sticky top-0 z-40">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<Link to="/" className="flex items-center gap-2">
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center shadow-lg shadow-primary-500/20">
<Mail className="w-5 h-5 text-white" />
</div>
<span className="text-lg font-bold text-slate-900">
Email<span className="text-primary-600">Sorter</span>
<span className="text-lg font-bold text-slate-900 dark:text-slate-100">
E-Mail-<span className="text-primary-600 dark:text-primary-400">Sorter</span>
</span>
</Link>
<Button variant="ghost" onClick={() => navigate('/dashboard')}>
<Button variant="ghost" onClick={handleSkipOnboarding}>
Skip
</Button>
</div>
@@ -221,6 +330,18 @@ export function Setup() {
)}
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{/* Onboarding Progress */}
{!loadingOnboarding && onboardingState && onboardingState.onboarding_step !== 'completed' && (
<div className="mb-6">
<OnboardingProgress
currentStep={onboardingState.onboarding_step}
completedSteps={onboardingState.completedSteps}
totalSteps={4}
onSkip={handleSkipOnboarding}
/>
</div>
)}
{/* Progress */}
<div className="mb-12">
<div className="flex items-center justify-between mb-4">
@@ -272,51 +393,82 @@ export function Setup() {
Choose your email provider. The connection is secure and your data stays private.
</p>
<div className="grid sm:grid-cols-2 gap-4 max-w-lg mx-auto">
<div className="space-y-4 max-w-lg mx-auto">
{/* Try Demo - Prominent Option */}
<button
onClick={handleConnectGmail}
onClick={handleConnectDemo}
disabled={connecting !== null}
className="flex items-center gap-4 p-6 bg-white rounded-2xl border-2 border-slate-200 hover:border-red-300 hover:shadow-xl hover:shadow-red-500/10 transition-all text-left group disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full flex items-center gap-4 p-6 bg-gradient-to-r from-primary-500 to-primary-600 text-white rounded-2xl border-2 border-primary-400 hover:border-primary-300 hover:shadow-2xl hover:shadow-primary-500/30 transition-all text-left group disabled:opacity-50 disabled:cursor-not-allowed"
>
{connecting === 'gmail' ? (
<Loader2 className="w-12 h-12 animate-spin text-red-500" />
{connecting === 'demo' ? (
<Loader2 className="w-12 h-12 animate-spin text-white" />
) : (
<div className="w-12 h-12 rounded-xl bg-red-50 flex items-center justify-center group-hover:bg-red-100 transition-colors">
<svg viewBox="0 0 24 24" className="w-7 h-7">
<path fill="#EA4335" d="M5.26 9.71L12 14.04l6.74-4.33-6.74-4.33z"/>
<path fill="#34A853" d="M12 14.04l6.74-4.33v7.65c0 .7-.57 1.26-1.26 1.26H6.52c-.7 0-1.26-.57-1.26-1.26V9.71l6.74 4.33z"/>
<path fill="#4285F4" d="M18.74 5.38H5.26c-.7 0-1.26.57-1.26 1.26v3.07l8 5.13 8-5.13V6.64c0-.7-.57-1.26-1.26-1.26z"/>
<path fill="#FBBC05" d="M4 9.71V6.64c0-.7.57-1.26 1.26-1.26h.01L12 9.71 4 13.84V9.71z"/>
</svg>
<div className="w-12 h-12 rounded-xl bg-white/20 flex items-center justify-center group-hover:bg-white/30 transition-colors">
<Sparkles className="w-7 h-7 text-white" />
</div>
)}
<div className="flex-1">
<p className="font-semibold text-slate-900">Gmail</p>
<p className="text-sm text-slate-500">Google Workspace</p>
<p className="font-semibold text-white text-lg">Try Demo</p>
<p className="text-sm text-primary-100">See how it works without connecting your account</p>
</div>
<ChevronRight className="w-5 h-5 text-slate-400 group-hover:text-red-500 group-hover:translate-x-1 transition-all" />
<ChevronRight className="w-5 h-5 text-white/80 group-hover:text-white group-hover:translate-x-1 transition-all" />
</button>
<button
onClick={handleConnectOutlook}
disabled={connecting !== null}
className="flex items-center gap-4 p-6 bg-white rounded-2xl border-2 border-slate-200 hover:border-blue-300 hover:shadow-xl hover:shadow-blue-500/10 transition-all text-left group disabled:opacity-50 disabled:cursor-not-allowed"
>
{connecting === 'outlook' ? (
<Loader2 className="w-12 h-12 animate-spin text-blue-500" />
) : (
<div className="w-12 h-12 rounded-xl bg-blue-50 flex items-center justify-center group-hover:bg-blue-100 transition-colors">
<svg viewBox="0 0 24 24" className="w-7 h-7">
<path fill="#0078D4" d="M11.5 3v8.5H3V3h8.5zm1 0H21v8.5h-8.5V3zM3 12.5h8.5V21H3v-8.5zm9.5 0H21V21h-8.5v-8.5z"/>
</svg>
</div>
)}
<div className="flex-1">
<p className="font-semibold text-slate-900">Outlook</p>
<p className="text-sm text-slate-500">Microsoft 365</p>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-slate-300"></div>
</div>
<ChevronRight className="w-5 h-5 text-slate-400 group-hover:text-blue-500 group-hover:translate-x-1 transition-all" />
</button>
<div className="relative flex justify-center text-sm">
<span className="px-4 bg-white text-slate-500">Or connect your inbox</span>
</div>
</div>
<div className="grid sm:grid-cols-2 gap-4">
<button
onClick={handleConnectGmail}
disabled={connecting !== null}
className="flex items-center gap-4 p-6 bg-white rounded-2xl border-2 border-slate-200 hover:border-red-300 hover:shadow-xl hover:shadow-red-500/10 transition-all text-left group disabled:opacity-50 disabled:cursor-not-allowed"
>
{connecting === 'gmail' ? (
<Loader2 className="w-12 h-12 animate-spin text-red-500" />
) : (
<div className="w-12 h-12 rounded-xl bg-red-50 flex items-center justify-center group-hover:bg-red-100 transition-colors">
<svg viewBox="0 0 24 24" className="w-7 h-7">
<path fill="#EA4335" d="M5.26 9.71L12 14.04l6.74-4.33-6.74-4.33z"/>
<path fill="#34A853" d="M12 14.04l6.74-4.33v7.65c0 .7-.57 1.26-1.26 1.26H6.52c-.7 0-1.26-.57-1.26-1.26V9.71l6.74 4.33z"/>
<path fill="#4285F4" d="M18.74 5.38H5.26c-.7 0-1.26.57-1.26 1.26v3.07l8 5.13 8-5.13V6.64c0-.7-.57-1.26-1.26-1.26z"/>
<path fill="#FBBC05" d="M4 9.71V6.64c0-.7.57-1.26 1.26-1.26h.01L12 9.71 4 13.84V9.71z"/>
</svg>
</div>
)}
<div className="flex-1">
<p className="font-semibold text-slate-900">Gmail</p>
<p className="text-sm text-slate-500">Google Workspace</p>
</div>
<ChevronRight className="w-5 h-5 text-slate-400 group-hover:text-red-500 group-hover:translate-x-1 transition-all" />
</button>
<button
onClick={handleConnectOutlook}
disabled={connecting !== null}
className="flex items-center gap-4 p-6 bg-white rounded-2xl border-2 border-slate-200 hover:border-blue-300 hover:shadow-xl hover:shadow-blue-500/10 transition-all text-left group disabled:opacity-50 disabled:cursor-not-allowed"
>
{connecting === 'outlook' ? (
<Loader2 className="w-12 h-12 animate-spin text-blue-500" />
) : (
<div className="w-12 h-12 rounded-xl bg-blue-50 flex items-center justify-center group-hover:bg-blue-100 transition-colors">
<svg viewBox="0 0 24 24" className="w-7 h-7">
<path fill="#0078D4" d="M11.5 3v8.5H3V3h8.5zm1 0H21v8.5h-8.5V3zM3 12.5h8.5V21H3v-8.5zm9.5 0H21V21h-8.5v-8.5z"/>
</svg>
</div>
)}
<div className="flex-1">
<p className="font-semibold text-slate-900">Outlook</p>
<p className="text-sm text-slate-500">Microsoft 365</p>
</div>
<ChevronRight className="w-5 h-5 text-slate-400 group-hover:text-blue-500 group-hover:translate-x-1 transition-all" />
</button>
</div>
</div>
<div className="mt-10 p-4 bg-slate-50 rounded-xl max-w-lg mx-auto">

View File

@@ -2,10 +2,60 @@
* TypeScript types for Settings and AI Control
*/
export interface CleanupReadItems {
enabled: boolean
action: 'archive_read' | 'trash'
gracePeriodDays: number
}
export interface CleanupPromotions {
enabled: boolean
matchCategoriesOrLabels: string[]
action: 'archive_read' | 'trash'
deleteAfterDays: number
}
export interface CleanupSafety {
requireConfirmForDelete: boolean
dryRun?: boolean
maxDeletesPerRun?: number
}
export interface CleanupSettings {
enabled: boolean
readItems: CleanupReadItems
promotions: CleanupPromotions
safety: CleanupSafety
}
export interface CategoryAdvanced {
priority?: 'low' | 'medium' | 'high'
includeLabels?: string[]
excludeKeywords?: string[]
}
export interface CleanupStatus {
lastRun?: string
lastRunCounts?: {
readItems: number
promotions: number
}
preview?: Array<{
id: string
subject: string
from: string
date: string
reason: 'read' | 'promotion'
}>
}
export interface AIControlSettings {
version?: number
enabledCategories: string[]
categoryActions: Record<string, 'inbox' | 'archive_read' | 'star'>
autoDetectCompanies: boolean
cleanup?: CleanupSettings
categoryAdvanced?: Record<string, CategoryAdvanced>
}
export interface CompanyLabel {

16
git-commit.bat Normal file
View File

@@ -0,0 +1,16 @@
@echo off
REM Git Commit Script für Control Panel Redesign (Windows Batch)
cd /d c:\Users\User\Documents\GitHub\ANDJJJJJJ
REM Alle Änderungen stagen
git add .
REM Commit mit detaillierter Nachricht
git commit -m "feat: Control Panel Redesign v2.0 & UI Improvements" -m "🎨 Control Panel komplettes Redesign (Version 2.0)" -m "- Card-basiertes Layout: Kategorien als interaktive Cards im Grid" -m "- Side Panel Integration: Click-to-Configure Pattern für Category Details" -m "- Moderne UX: Dashboard-artiges Design statt klassischer Settings-Seite" -m "" -m "🧹 Cleanup Tab Redesign" -m "- Große Toggle-Cards für Auto Cleanup Features" -m "- Neue Slider-Komponente für intuitive Tage-Auswahl" -m "- Preset Buttons (7/14/30 Tage) für Promotion Cleanup" -m "- Preview Section für betroffene E-Mails" -m "" -m "🏷️ Labels Tab Redesign" -m "- Professionelle Tabellenansicht mit Name, Status, Category, Actions" -m "- Side Panel Editor für Label-Erstellung/Bearbeitung" -m "- Responsive Table (Spalten werden auf Mobile ausgeblendet)" -m "- Import/Export Funktionalität" -m "" -m "📐 Layout Verbesserungen" -m "- Volle Breite: Dashboard und Settings nutzen gesamte verfügbare Breite" -m "- Responsive Navigation: Side Panels werden auf Mobile zu Fullscreen-Modals" -m "- Verbesserte Header mit humaneren Untertiteln" -m "" -m "🌙 Dark Mode Verbesserungen" -m "- Privacy & Security: Alle Info-Boxen haben Dark Mode Varianten" -m "- Input-Komponente: Dark Mode Hintergrund korrigiert" -m "- Slider-Komponente: Dark Mode Styles für Track und Thumb" -m "- Chevron Icons: Dark Mode Farben für Advanced Options" -m "- Konsistente Dark Mode Unterstützung in allen Komponenten" -m "" -m "✨ Neue Komponenten" -m "- client/src/components/ui/side-panel.tsx: Radix UI Dialog-basierte Side Panel" -m "- client/src/components/ui/slider.tsx: Range Input Slider mit Dark Mode" -m "" -m "📝 Geänderte Dateien" -m "- client/src/pages/Settings.tsx: Control Panel komplett neu strukturiert" -m "- client/src/components/PrivacySecurity.tsx: Dark Mode für alle Info-Boxen" -m "- client/src/components/ui/input.tsx: Dark Mode Hintergrund korrigiert" -m "- client/src/pages/Dashboard.tsx: Volle Breite Layout"
REM Pushen
git push
echo ✅ Commit erfolgreich erstellt und gepusht!
pause

54
git-commit.sh Normal file
View File

@@ -0,0 +1,54 @@
#!/bin/bash
# Git Commit Script für Control Panel Redesign
cd c:\Users\User\Documents\GitHub\ANDJJJJJJ
# Alle Änderungen stagen
git add .
# Commit mit detaillierter Nachricht
git commit -m "feat: Control Panel Redesign v2.0 & UI Improvements
🎨 Control Panel komplettes Redesign (Version 2.0)
- Card-basiertes Layout: Kategorien als interaktive Cards im Grid
- Side Panel Integration: Click-to-Configure Pattern für Category Details
- Moderne UX: Dashboard-artiges Design statt klassischer Settings-Seite
🧹 Cleanup Tab Redesign
- Große Toggle-Cards für Auto Cleanup Features
- Neue Slider-Komponente für intuitive Tage-Auswahl
- Preset Buttons (7/14/30 Tage) für Promotion Cleanup
- Preview Section für betroffene E-Mails
🏷️ Labels Tab Redesign
- Professionelle Tabellenansicht mit Name, Status, Category, Actions
- Side Panel Editor für Label-Erstellung/Bearbeitung
- Responsive Table (Spalten werden auf Mobile ausgeblendet)
- Import/Export Funktionalität
📐 Layout Verbesserungen
- Volle Breite: Dashboard und Settings nutzen gesamte verfügbare Breite
- Responsive Navigation: Side Panels werden auf Mobile zu Fullscreen-Modals
- Verbesserte Header mit humaneren Untertiteln
🌙 Dark Mode Verbesserungen
- Privacy & Security: Alle Info-Boxen haben Dark Mode Varianten
- Input-Komponente: Dark Mode Hintergrund korrigiert
- Slider-Komponente: Dark Mode Styles für Track und Thumb
- Chevron Icons: Dark Mode Farben für Advanced Options
- Konsistente Dark Mode Unterstützung in allen Komponenten
✨ Neue Komponenten
- client/src/components/ui/side-panel.tsx: Radix UI Dialog-basierte Side Panel
- client/src/components/ui/slider.tsx: Range Input Slider mit Dark Mode
📝 Geänderte Dateien
- client/src/pages/Settings.tsx: Control Panel komplett neu strukturiert
- client/src/components/PrivacySecurity.tsx: Dark Mode für alle Info-Boxen
- client/src/components/ui/input.tsx: Dark Mode Hintergrund korrigiert
- client/src/pages/Dashboard.tsx: Volle Breite Layout"
# Pushen
git push
echo "✅ Commit erfolgreich erstellt und gepusht!"

77
run-git-commit.ps1 Normal file
View File

@@ -0,0 +1,77 @@
# PowerShell Script zum Ausführen von Git-Befehlen
$ErrorActionPreference = "Stop"
# Git-Pfade zum Durchsuchen
$gitPaths = @(
"git", # Falls im PATH
"C:\Program Files\Git\bin\git.exe",
"C:\Program Files (x86)\Git\bin\git.exe",
"$env:LOCALAPPDATA\Programs\Git\bin\git.exe",
"$env:ProgramFiles\Git\cmd\git.exe",
"$env:ProgramFiles(x86)\Git\cmd\git.exe"
)
$gitExe = $null
foreach ($path in $gitPaths) {
try {
if ($path -eq "git") {
$gitExe = Get-Command git -ErrorAction SilentlyContinue
if ($gitExe) {
$gitExe = $gitExe.Source
break
}
} else {
if (Test-Path $path) {
$gitExe = $path
break
}
}
} catch {
continue
}
}
if (-not $gitExe) {
Write-Host "Git wurde nicht gefunden. Bitte installiere Git oder fuege es zum PATH hinzu." -ForegroundColor Red
Write-Host ""
Write-Host "Alternativ führe diese Befehle manuell in Git Bash aus:" -ForegroundColor Yellow
Write-Host "cd c:\Users\User\Documents\GitHub\ANDJJJJJJ" -ForegroundColor Cyan
Write-Host "git add ." -ForegroundColor Cyan
Write-Host "git commit -m `"feat: Control Panel Redesign v2.0 - Card-basiertes Layout, Side Panels, Dark Mode Fixes, Volle Breite Layout`"" -ForegroundColor Cyan
Write-Host "git push" -ForegroundColor Cyan
exit 1
}
Write-Host "Git gefunden: $gitExe" -ForegroundColor Green
Write-Host ""
# Zum Projektverzeichnis wechseln
Set-Location "c:\Users\User\Documents\GitHub\ANDJJJJJJ"
# Git-Befehle ausführen
Write-Host "Staging aller Aenderungen..." -ForegroundColor Yellow
& $gitExe add .
if ($LASTEXITCODE -ne 0) {
Write-Host "❌ Fehler beim Staging" -ForegroundColor Red
exit 1
}
Write-Host "Erstelle Commit..." -ForegroundColor Yellow
& $gitExe commit -m "feat: Control Panel Redesign v2.0 - Card-basiertes Layout, Side Panels, Dark Mode Fixes, Volle Breite Layout"
if ($LASTEXITCODE -ne 0) {
Write-Host "❌ Fehler beim Commit" -ForegroundColor Red
exit 1
}
Write-Host "Pushe Aenderungen..." -ForegroundColor Yellow
& $gitExe push
if ($LASTEXITCODE -ne 0) {
Write-Host "❌ Fehler beim Push" -ForegroundColor Red
exit 1
}
Write-Host ""
Write-Host "Erfolgreich committed und gepusht!" -ForegroundColor Green

View File

@@ -200,6 +200,58 @@ async function setupCollections() {
db.createStringAttribute(DB_ID, 'user_preferences', 'userId', 64, true));
await ensureAttribute('user_preferences', 'preferencesJson', () =>
db.createStringAttribute(DB_ID, 'user_preferences', 'preferencesJson', 16384, false));
// ==================== Onboarding State ====================
await ensureCollection('onboarding_state', 'Onboarding State', PERM_AUTHENTICATED);
await ensureAttribute('onboarding_state', 'userId', () =>
db.createStringAttribute(DB_ID, 'onboarding_state', 'userId', 64, true));
await ensureAttribute('onboarding_state', 'onboarding_step', () =>
db.createStringAttribute(DB_ID, 'onboarding_state', 'onboarding_step', 32, true));
await ensureAttribute('onboarding_state', 'completed_steps_json', () =>
db.createStringAttribute(DB_ID, 'onboarding_state', 'completed_steps_json', 1024, false));
await ensureAttribute('onboarding_state', 'first_value_seen_at', () =>
db.createDatetimeAttribute(DB_ID, 'onboarding_state', 'first_value_seen_at', false));
await ensureAttribute('onboarding_state', 'skipped_at', () =>
db.createDatetimeAttribute(DB_ID, 'onboarding_state', 'skipped_at', false));
await ensureAttribute('onboarding_state', 'last_updated', () =>
db.createDatetimeAttribute(DB_ID, 'onboarding_state', 'last_updated', false));
// ==================== Email Usage ====================
await ensureCollection('email_usage', 'Email Usage', PERM_AUTHENTICATED);
await ensureAttribute('email_usage', 'userId', () =>
db.createStringAttribute(DB_ID, 'email_usage', 'userId', 64, true));
await ensureAttribute('email_usage', 'month', () =>
db.createStringAttribute(DB_ID, 'email_usage', 'month', 16, true)); // "2026-01"
await ensureAttribute('email_usage', 'emailsProcessed', () =>
db.createIntegerAttribute(DB_ID, 'email_usage', 'emailsProcessed', true, 0));
await ensureAttribute('email_usage', 'lastReset', () =>
db.createDatetimeAttribute(DB_ID, 'email_usage', 'lastReset', false));
// ==================== Referrals ====================
await ensureCollection('referrals', 'Referrals', PERM_AUTHENTICATED);
await ensureAttribute('referrals', 'userId', () =>
db.createStringAttribute(DB_ID, 'referrals', 'userId', 64, true));
await ensureAttribute('referrals', 'referralCode', () =>
db.createStringAttribute(DB_ID, 'referrals', 'referralCode', 32, true));
await ensureAttribute('referrals', 'referredBy', () =>
db.createStringAttribute(DB_ID, 'referrals', 'referredBy', 64, false));
await ensureAttribute('referrals', 'referralCount', () =>
db.createIntegerAttribute(DB_ID, 'referrals', 'referralCount', true, 0));
await ensureAttribute('referrals', 'createdAt', () =>
db.createDatetimeAttribute(DB_ID, 'referrals', 'createdAt', false));
// ==================== Analytics Events ====================
await ensureCollection('analytics_events', 'Analytics Events', PERM_PUBLIC_READ);
await ensureAttribute('analytics_events', 'userId', () =>
db.createStringAttribute(DB_ID, 'analytics_events', 'userId', 64, false));
await ensureAttribute('analytics_events', 'eventType', () =>
db.createStringAttribute(DB_ID, 'analytics_events', 'eventType', 64, true));
await ensureAttribute('analytics_events', 'metadataJson', () =>
db.createStringAttribute(DB_ID, 'analytics_events', 'metadataJson', 4096, false));
await ensureAttribute('analytics_events', 'timestamp', () =>
db.createDatetimeAttribute(DB_ID, 'analytics_events', 'timestamp', false));
await ensureAttribute('analytics_events', 'sessionId', () =>
db.createStringAttribute(DB_ID, 'analytics_events', 'sessionId', 64, false));
}
async function main() {

View File

@@ -68,6 +68,13 @@ export const config = {
origin: process.env.CORS_ORIGIN || process.env.FRONTEND_URL || 'http://localhost:5173',
credentials: true,
},
// Free Tier Limits
freeTier: {
emailsPerMonth: parseInt(process.env.FREE_TIER_EMAILS_PER_MONTH || '500', 10),
emailAccounts: 1,
autoSchedule: false, // manual only
},
}
/**

View File

@@ -121,6 +121,7 @@ app.get('/api/preferences/ai-control', asyncHandler(async (req, res) => {
enabledCategories: preferences.enabledCategories || [],
categoryActions: preferences.categoryActions || {},
autoDetectCompanies: preferences.autoDetectCompanies !== undefined ? preferences.autoDetectCompanies : true,
cleanup: preferences.cleanup || userPreferences.getDefaults().cleanup,
})
}))
@@ -129,13 +130,14 @@ app.get('/api/preferences/ai-control', asyncHandler(async (req, res) => {
* Save AI Control settings
*/
app.post('/api/preferences/ai-control', asyncHandler(async (req, res) => {
const { userId, enabledCategories, categoryActions, autoDetectCompanies } = req.body
const { userId, enabledCategories, categoryActions, autoDetectCompanies, cleanup } = req.body
if (!userId) throw new ValidationError('userId is required')
const updates = {}
if (enabledCategories !== undefined) updates.enabledCategories = enabledCategories
if (categoryActions !== undefined) updates.categoryActions = categoryActions
if (autoDetectCompanies !== undefined) updates.autoDetectCompanies = autoDetectCompanies
if (cleanup !== undefined) updates.cleanup = cleanup
await userPreferences.upsert(userId, updates)
respond.success(res, null, 'AI Control settings saved')

View File

@@ -4,11 +4,57 @@
*/
import express from 'express'
import { asyncHandler } from '../middleware/errorHandler.mjs'
import { asyncHandler, ValidationError } from '../middleware/errorHandler.mjs'
import { respond } from '../utils/response.mjs'
import { db, Collections } from '../services/database.mjs'
import { log } from '../middleware/logger.mjs'
const router = express.Router()
// Whitelist of allowed event types
const ALLOWED_EVENT_TYPES = [
'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',
]
// Fields that should never be stored (PII)
const PII_FIELDS = ['email', 'password', 'emailContent', 'emailBody', 'subject', 'from', 'to', 'snippet', 'content']
function stripPII(metadata) {
if (!metadata || typeof metadata !== 'object') return {}
const cleaned = {}
for (const [key, value] of Object.entries(metadata)) {
if (PII_FIELDS.includes(key.toLowerCase())) {
continue // Skip PII fields
}
if (typeof value === 'string' && value.includes('@')) {
// Skip if looks like email
continue
}
if (typeof value === 'object' && value !== null) {
cleaned[key] = stripPII(value)
} else {
cleaned[key] = value
}
}
return cleaned
}
/**
* POST /api/analytics/track
* Track analytics events (page views, conversions, etc.)
@@ -39,29 +85,45 @@ router.post('/track', asyncHandler(async (req, res) => {
timestamp,
page,
referrer,
sessionId,
} = req.body
// Log analytics event (in production, send to analytics service)
// Validate event type
if (!type || !ALLOWED_EVENT_TYPES.includes(type)) {
throw new ValidationError(`Invalid event type. Allowed: ${ALLOWED_EVENT_TYPES.join(', ')}`)
}
// Strip PII from metadata
const cleanedMetadata = stripPII(metadata || {})
// Prepare event data
const eventData = {
userId: userId || null,
eventType: type,
metadataJson: JSON.stringify(cleanedMetadata),
timestamp: timestamp || new Date().toISOString(),
sessionId: sessionId || null,
}
// Store in database
try {
await db.create(Collections.ANALYTICS_EVENTS, eventData)
log.info(`Analytics event tracked: ${type}`, { userId, sessionId })
} catch (err) {
log.warn('Failed to store analytics event', { error: err.message, type })
// Don't fail the request if analytics storage fails
}
// Log in development
if (process.env.NODE_ENV === 'development') {
console.log('📊 Analytics Event:', {
type,
userId,
tracking,
metadata,
timestamp,
page,
referrer,
sessionId,
metadata: cleanedMetadata,
})
}
// TODO: Store in database for analytics dashboard
// For now, just log to console
// In production, you might want to:
// - Store in database
// - Send to Google Analytics / Plausible / etc.
// - Send to Mixpanel / Amplitude
// - Log to external analytics service
// Return success (client doesn't need to wait)
respond.success(res, { received: true })
}))

View File

@@ -7,9 +7,10 @@ import express from 'express'
import { asyncHandler, NotFoundError, ValidationError } from '../middleware/errorHandler.mjs'
import { validate, schemas, rules } from '../middleware/validate.mjs'
import { respond } from '../utils/response.mjs'
import { products, questions, submissions, orders } from '../services/database.mjs'
import { products, questions, submissions, orders, onboardingState, emailAccounts, emailStats, emailDigests, userPreferences, subscriptions, emailUsage, referrals, db, Collections, Query } from '../services/database.mjs'
import Stripe from 'stripe'
import { config } from '../config/index.mjs'
import { log } from '../middleware/logger.mjs'
const router = express.Router()
const stripe = new Stripe(config.stripe.secretKey)
@@ -171,4 +172,232 @@ router.get('/config', (req, res) => {
})
})
/**
* GET /api/onboarding/status
* Get current onboarding state
*/
router.get('/onboarding/status',
validate({
query: {
userId: [rules.required('userId')],
},
}),
asyncHandler(async (req, res) => {
const { userId } = req.query
const state = await onboardingState.getByUser(userId)
respond.success(res, state)
})
)
/**
* POST /api/onboarding/step
* Update onboarding step progress
*/
router.post('/onboarding/step',
validate({
body: {
userId: [rules.required('userId')],
step: [rules.required('step')],
completedSteps: [rules.isArray('completedSteps')],
},
}),
asyncHandler(async (req, res) => {
const { userId, step, completedSteps = [] } = req.body
await onboardingState.updateStep(userId, step, completedSteps)
respond.success(res, { step, completedSteps })
})
)
/**
* POST /api/onboarding/skip
* Skip onboarding
*/
router.post('/onboarding/skip',
validate({
body: {
userId: [rules.required('userId')],
},
}),
asyncHandler(async (req, res) => {
const { userId } = req.body
await onboardingState.skip(userId)
respond.success(res, { skipped: true })
})
)
/**
* POST /api/onboarding/resume
* Resume onboarding
*/
router.post('/onboarding/resume',
validate({
body: {
userId: [rules.required('userId')],
},
}),
asyncHandler(async (req, res) => {
const { userId } = req.body
await onboardingState.resume(userId)
const state = await onboardingState.getByUser(userId)
respond.success(res, state)
})
)
/**
* DELETE /api/account/delete
* Delete all user data and account
*/
router.delete('/account/delete',
validate({
body: {
userId: [rules.required('userId')],
},
}),
asyncHandler(async (req, res) => {
const { userId } = req.body
log.info(`Account deletion requested for user ${userId}`)
// Delete all user data
try {
// Delete email accounts
const accounts = await emailAccounts.getByUser(userId)
for (const account of accounts) {
try {
await db.delete(Collections.EMAIL_ACCOUNTS, account.$id)
} catch (err) {
log.warn(`Failed to delete account ${account.$id}`, { error: err.message })
}
}
// Delete stats
const stats = await emailStats.getByUser(userId)
if (stats) {
try {
await db.delete(Collections.EMAIL_STATS, stats.$id)
} catch (err) {
log.warn(`Failed to delete stats`, { error: err.message })
}
}
// Delete digests
const digests = await emailDigests.getByUser(userId)
for (const digest of digests) {
try {
await db.delete(Collections.EMAIL_DIGESTS, digest.$id)
} catch (err) {
log.warn(`Failed to delete digest ${digest.$id}`, { error: err.message })
}
}
// Delete preferences
const prefs = await userPreferences.getByUser(userId)
if (prefs) {
try {
await db.delete(Collections.USER_PREFERENCES, prefs.$id)
} catch (err) {
log.warn(`Failed to delete preferences`, { error: err.message })
}
}
// Delete subscription
const subscription = await subscriptions.getByUser(userId)
if (subscription && subscription.$id) {
try {
await db.delete(Collections.SUBSCRIPTIONS, subscription.$id)
} catch (err) {
log.warn(`Failed to delete subscription`, { error: err.message })
}
}
// Delete email usage
const usageRecords = await db.list(Collections.EMAIL_USAGE, [Query.equal('userId', userId)])
for (const usage of usageRecords) {
try {
await db.delete(Collections.EMAIL_USAGE, usage.$id)
} catch (err) {
log.warn(`Failed to delete usage record`, { error: err.message })
}
}
// Delete onboarding state
const onboarding = await onboardingState.getByUser(userId)
if (onboarding && onboarding.$id) {
try {
await db.delete(Collections.ONBOARDING_STATE, onboarding.$id)
} catch (err) {
log.warn(`Failed to delete onboarding state`, { error: err.message })
}
}
log.success(`Account deletion completed for user ${userId}`)
respond.success(res, { success: true, message: 'All data deleted successfully' })
} catch (err) {
log.error('Account deletion failed', { error: err.message, userId })
throw new ValidationError('Failed to delete account data')
}
})
)
/**
* GET /api/referrals/code
* Get or create referral code for user
*/
router.get('/referrals/code',
validate({
query: {
userId: [rules.required('userId')],
},
}),
asyncHandler(async (req, res) => {
const { userId } = req.query
const referral = await referrals.getOrCreateCode(userId)
respond.success(res, {
referralCode: referral.referralCode,
referralCount: referral.referralCount || 0,
})
})
)
/**
* POST /api/referrals/track
* Track a referral (when new user signs up with referral code)
*/
router.post('/referrals/track',
validate({
body: {
userId: [rules.required('userId')],
referralCode: [rules.required('referralCode')],
},
}),
asyncHandler(async (req, res) => {
const { userId, referralCode } = req.body
// Find referrer by code
const referrer = await referrals.getByCode(referralCode)
if (!referrer) {
throw new NotFoundError('Referral code')
}
// Don't allow self-referral
if (referrer.userId === userId) {
throw new ValidationError('Cannot refer yourself')
}
// Update referrer's count
await referrals.incrementCount(referrer.userId)
// Store referral relationship
await referrals.getOrCreateCode(userId)
const userReferral = await referrals.getOrCreateCode(userId)
await db.update(Collections.REFERRALS, userReferral.$id, {
referredBy: referrer.userId,
})
log.info(`Referral tracked: ${userId} referred by ${referrer.userId} (code: ${referralCode})`)
respond.success(res, { success: true })
})
)
export default router

View File

@@ -8,7 +8,7 @@ import { asyncHandler, NotFoundError, AuthorizationError, ValidationError } from
import { validate, rules } from '../middleware/validate.mjs'
import { limiters } from '../middleware/rateLimit.mjs'
import { respond } from '../utils/response.mjs'
import { emailAccounts, emailStats, emailDigests, userPreferences } from '../services/database.mjs'
import { emailAccounts, emailStats, emailDigests, userPreferences, emailUsage, subscriptions } from '../services/database.mjs'
import { config, features } from '../config/index.mjs'
import { log } from '../middleware/logger.mjs'
@@ -270,7 +270,34 @@ router.post('/sort',
}),
asyncHandler(async (req, res) => {
const { userId, accountId, maxEmails = 500, processAll = true } = req.body
const effectiveMax = Math.min(maxEmails, 2000) // Cap at 2000 emails
// Check subscription status and free tier limits
const subscription = await subscriptions.getByUser(userId)
const isFreeTier = subscription?.isFreeTier || false
// Check free tier limit
if (isFreeTier) {
const usage = await emailUsage.getUsage(userId)
const limit = subscription?.emailsLimit || config.freeTier.emailsPerMonth
if (usage.emailsProcessed >= limit) {
return respond.error(res, {
code: 'LIMIT_REACHED',
message: `You've processed ${limit} emails this month. Upgrade for unlimited sorting.`,
limit,
used: usage.emailsProcessed,
}, 403)
}
}
// Check if this is first run (no stats exist)
const existingStats = await emailStats.getByUser(userId)
const isFirstRun = !existingStats || existingStats.totalSorted === 0
// For first run, limit to 50 emails for speed
const effectiveMax = isFirstRun
? Math.min(maxEmails, 50)
: Math.min(maxEmails, 2000) // Cap at 2000 emails
// Get account
const account = await emailAccounts.get(accountId)
@@ -287,6 +314,7 @@ router.post('/sort',
const sorter = await getAISorter()
let sortedCount = 0
const results = { byCategory: {} }
let emailSamples = [] // For suggested rules generation
// ═══════════════════════════════════════════════════════════════════════
// DEMO MODE - Sorting with simulated emails
@@ -304,10 +332,20 @@ router.post('/sort',
// Real AI sorting with demo data
const classified = await sorter.batchCategorize(emailsToSort, preferences)
for (const { classification } of classified) {
for (const { email, classification } of classified) {
const category = classification.category
sortedCount++
results.byCategory[category] = (results.byCategory[category] || 0) + 1
// Collect samples for suggested rules (first run only, max 50)
if (isFirstRun && emailSamples.length < 50) {
emailSamples.push({
from: email.from,
subject: email.subject,
snippet: email.snippet,
category,
})
}
}
log.success(`AI sorting completed: ${sortedCount} demo emails`)
@@ -351,6 +389,16 @@ router.post('/sort',
sortedCount++
results.byCategory[category] = (results.byCategory[category] || 0) + 1
// Collect samples for suggested rules (first run only, max 50)
if (isFirstRun && emailSamples.length < 50) {
emailSamples.push({
from: email.from,
subject: email.subject,
snippet: email.snippet || '',
category,
})
}
}
log.success(`Rule-based sorting completed: ${sortedCount} demo emails`)
@@ -512,6 +560,16 @@ router.post('/sort',
category,
companyLabel,
})
// Collect samples for suggested rules (first run only, max 50)
if (isFirstRun && emailSamples.length < 50) {
emailSamples.push({
from: emailData.from,
subject: emailData.subject,
snippet: emailData.snippet,
category,
})
}
}
// Apply labels/categories and actions
@@ -740,8 +798,13 @@ router.post('/sort',
// Update last sync
await emailAccounts.updateLastSync(accountId)
// Update email usage (for free tier tracking)
if (isFreeTier) {
await emailUsage.increment(userId, sortedCount)
}
// Update stats
const timeSaved = Math.round(sortedCount * 0.25) // 15 seconds per email
const timeSaved = Math.round(sortedCount * 0.1) // 6 seconds per email = 0.1 minutes
await emailStats.increment(userId, {
total: sortedCount,
today: sortedCount,
@@ -810,6 +873,17 @@ router.post('/sort',
log.success(`Sorting completed: ${sortedCount} emails for ${account.email}`)
// Generate suggested rules for first run
let suggestedRules = []
if (isFirstRun && emailSamples.length > 0 && features.ai()) {
try {
suggestedRules = await sorter.generateSuggestedRules(userId, emailSamples)
log.info(`Generated ${suggestedRules.length} suggested rules for first run`)
} catch (err) {
log.warn('Failed to generate suggested rules', { error: err.message })
}
}
respond.success(res, {
sorted: sortedCount,
inboxCleared,
@@ -818,6 +892,8 @@ router.post('/sort',
minutes: timeSaved,
formatted: timeSaved > 0 ? `${timeSaved} minutes` : '< 1 minute',
},
isFirstRun,
suggestedRules: suggestedRules.length > 0 ? suggestedRules : undefined,
highlights,
suggestions,
provider: account.provider,
@@ -1083,4 +1159,237 @@ router.post('/webhook/outlook', asyncHandler(async (req, res) => {
}
}))
/**
* POST /api/email/cleanup
* Run auto-cleanup for read items and promotions
* Can be called manually or by cron job
*/
router.post('/cleanup', asyncHandler(async (req, res) => {
const { userId } = req.body // Optional: only process this user, otherwise all users
log.info('Cleanup job started', { userId: userId || 'all' })
const results = {
usersProcessed: 0,
emailsProcessed: {
readItems: 0,
promotions: 0,
},
errors: [],
}
try {
// Get all users with cleanup enabled
let usersToProcess = []
if (userId) {
// Single user mode
const prefs = await userPreferences.getByUser(userId)
if (prefs?.preferences?.cleanup?.enabled) {
usersToProcess = [{ userId, preferences: prefs.preferences }]
}
} else {
// All users mode - get all user preferences
// Note: This is a simplified approach. In production, you might want to add an index
// or query optimization for users with cleanup.enabled = true
const allPrefs = await emailAccounts.getByUser('*') // This won't work, need different approach
// For now, we'll process users individually when they have accounts
// TODO: Add efficient query for users with cleanup enabled
log.warn('Processing all users not yet implemented efficiently. Use userId parameter for single user cleanup.')
}
// If userId provided, process that user
if (userId) {
const prefs = await userPreferences.getByUser(userId)
if (!prefs?.preferences?.cleanup?.enabled) {
return respond.success(res, { ...results, message: 'Cleanup not enabled for this user' })
}
const accounts = await emailAccounts.getByUser(userId)
if (!accounts || accounts.length === 0) {
return respond.success(res, { ...results, message: 'No email accounts found' })
}
for (const account of accounts) {
if (!account.isActive || !account.accessToken) continue
try {
const cleanup = prefs.preferences.cleanup
// Read Items Cleanup
if (cleanup.readItems?.enabled) {
const readItemsCount = await processReadItemsCleanup(
account,
cleanup.readItems.action,
cleanup.readItems.gracePeriodDays
)
results.emailsProcessed.readItems += readItemsCount
}
// Promotion Cleanup
if (cleanup.promotions?.enabled) {
const promotionsCount = await processPromotionsCleanup(
account,
cleanup.promotions.action,
cleanup.promotions.deleteAfterDays,
cleanup.promotions.matchCategoriesOrLabels || []
)
results.emailsProcessed.promotions += promotionsCount
}
results.usersProcessed = 1
} catch (error) {
log.error(`Cleanup failed for account ${account.email}`, { error: error.message })
results.errors.push({ userId, accountId: account.id, error: error.message })
}
}
}
log.success('Cleanup job completed', results)
respond.success(res, results, 'Cleanup completed')
} catch (error) {
log.error('Cleanup job failed', { error: error.message })
throw error
}
}))
/**
* Process read items cleanup for an account
*/
async function processReadItemsCleanup(account, action, gracePeriodDays) {
const cutoffDate = new Date()
cutoffDate.setDate(cutoffDate.getDate() - gracePeriodDays)
let processedCount = 0
try {
if (account.provider === 'gmail') {
const gmail = await getGmailService(account.accessToken, account.refreshToken)
// Find read emails older than grace period
// Query: -is:unread AND before:YYYY/MM/DD
const query = `-is:unread before:${cutoffDate.getFullYear()}/${String(cutoffDate.getMonth() + 1).padStart(2, '0')}/${String(cutoffDate.getDate()).padStart(2, '0')}`
const response = await gmail.gmail.users.messages.list({
userId: 'me',
q: query,
maxResults: 500, // Limit to avoid rate limits
})
const messages = response.data.messages || []
for (const message of messages) {
try {
if (action === 'archive_read') {
await gmail.archiveEmail(message.id)
await gmail.markAsRead(message.id)
} else if (action === 'trash') {
await gmail.trashEmail(message.id)
}
processedCount++
} catch (err) {
log.error(`Failed to process message ${message.id}`, { error: err.message })
}
}
} else if (account.provider === 'outlook') {
const outlook = await getOutlookService(account.accessToken)
// Find read emails older than grace period
const filter = `isRead eq true and receivedDateTime lt ${cutoffDate.toISOString()}`
const messages = await outlook._request(`/me/messages?$filter=${encodeURIComponent(filter)}&$top=500`)
for (const message of messages.value || []) {
try {
if (action === 'archive_read') {
await outlook.archiveEmail(message.id)
await outlook.markAsRead(message.id)
} else if (action === 'trash') {
await outlook.deleteEmail(message.id) // Outlook uses deleteEmail for trash
}
processedCount++
} catch (err) {
log.error(`Failed to process message ${message.id}`, { error: err.message })
}
}
}
} catch (error) {
log.error(`Read items cleanup failed for ${account.email}`, { error: error.message })
throw error
}
return processedCount
}
/**
* Process promotions cleanup for an account
*/
async function processPromotionsCleanup(account, action, deleteAfterDays, matchCategories) {
const cutoffDate = new Date()
cutoffDate.setDate(cutoffDate.getDate() - deleteAfterDays)
let processedCount = 0
try {
if (account.provider === 'gmail') {
const gmail = await getGmailService(account.accessToken, account.refreshToken)
// Find emails with matching categories/labels older than deleteAfterDays
// Look for emails with EmailSorter labels matching the categories
const labelQueries = matchCategories.map(cat => `label:EmailSorter/${cat}`).join(' OR ')
const query = `(${labelQueries}) before:${cutoffDate.getFullYear()}/${String(cutoffDate.getMonth() + 1).padStart(2, '0')}/${String(cutoffDate.getDate()).padStart(2, '0')}`
const response = await gmail.gmail.users.messages.list({
userId: 'me',
q: query,
maxResults: 500,
})
const messages = response.data.messages || []
for (const message of messages) {
try {
if (action === 'archive_read') {
await gmail.archiveEmail(message.id)
await gmail.markAsRead(message.id)
} else if (action === 'trash') {
await gmail.trashEmail(message.id)
}
processedCount++
} catch (err) {
log.error(`Failed to process promotion message ${message.id}`, { error: err.message })
}
}
} else if (account.provider === 'outlook') {
const outlook = await getOutlookService(account.accessToken)
// For Outlook, we'd need to check categories or use a different approach
// This is a simplified version - in production, you might store category info
const filter = `receivedDateTime lt ${cutoffDate.toISOString()}`
const messages = await outlook._request(`/me/messages?$filter=${encodeURIComponent(filter)}&$top=500`)
// Filter by category if available (would need to be stored during sorting)
for (const message of messages.value || []) {
// TODO: Check if message category matches matchCategories
// This requires storing category info during sorting
try {
if (action === 'archive_read') {
await outlook.archiveEmail(message.id)
await outlook.markAsRead(message.id)
} else if (action === 'trash') {
await outlook.deleteEmail(message.id) // Outlook uses deleteEmail for trash
}
processedCount++
} catch (err) {
log.error(`Failed to process promotion message ${message.id}`, { error: err.message })
}
}
}
} catch (error) {
log.error(`Promotions cleanup failed for ${account.email}`, { error: error.message })
throw error
}
return processedCount
}
export default router

View File

@@ -234,6 +234,121 @@ export class AISorterService {
return null
}
/**
* Generate suggested rules based on email patterns
* Analyzes email samples to detect patterns and suggest rules
*/
async generateSuggestedRules(userId, emailSamples) {
if (!emailSamples || emailSamples.length === 0) {
return []
}
const suggestions = []
const senderCounts = {}
const domainCounts = {}
const subjectPatterns = {}
const categoryPatterns = {}
// Analyze patterns
for (const email of emailSamples) {
const from = email.from?.toLowerCase() || ''
const subject = email.subject?.toLowerCase() || ''
// Extract domain
const emailMatch = from.match(/@([^\s>]+)/)
if (emailMatch) {
const domain = emailMatch[1].toLowerCase()
domainCounts[domain] = (domainCounts[domain] || 0) + 1
}
// Count senders
const senderEmail = from.split('<')[1]?.split('>')[0] || from
senderCounts[senderEmail] = (senderCounts[senderEmail] || 0) + 1
// Detect category patterns
const category = email.category || 'review'
categoryPatterns[category] = (categoryPatterns[category] || 0) + 1
}
const totalEmails = emailSamples.length
const threshold = Math.max(3, Math.ceil(totalEmails * 0.1)) // At least 3 emails or 10% of total
// Suggest VIP senders (frequent senders)
const frequentSenders = Object.entries(senderCounts)
.filter(([_, count]) => count >= threshold)
.sort(([_, a], [__, b]) => b - a)
.slice(0, 3)
for (const [sender, count] of frequentSenders) {
suggestions.push({
type: 'vip_sender',
name: `Mark ${sender.split('@')[0]} as VIP`,
description: `${count} emails from this sender`,
confidence: Math.min(0.9, count / totalEmails),
action: {
type: 'add_vip',
email: sender,
},
})
}
// Suggest company labels (frequent domains)
const frequentDomains = Object.entries(domainCounts)
.filter(([domain, count]) => count >= threshold && !KNOWN_COMPANIES[domain])
.sort(([_, a], [__, b]) => b - a)
.slice(0, 3)
for (const [domain, count] of frequentDomains) {
const companyName = domain.split('.')[0].charAt(0).toUpperCase() + domain.split('.')[0].slice(1)
suggestions.push({
type: 'company_label',
name: `Label ${companyName} emails`,
description: `${count} emails from ${domain}`,
confidence: Math.min(0.85, count / totalEmails),
action: {
type: 'add_company_label',
name: companyName,
condition: `from:${domain}`,
category: 'promotions', // Default, user can change
},
})
}
// Suggest category-specific rules based on patterns
if (categoryPatterns.newsletters >= threshold) {
suggestions.push({
type: 'category_rule',
name: 'Archive newsletters automatically',
description: `${categoryPatterns.newsletters} newsletter emails detected`,
confidence: 0.8,
action: {
type: 'enable_category',
category: 'newsletters',
action: 'archive_read',
},
})
}
if (categoryPatterns.promotions >= threshold) {
suggestions.push({
type: 'category_rule',
name: 'Archive promotions automatically',
description: `${categoryPatterns.promotions} promotion emails detected`,
confidence: 0.75,
action: {
type: 'enable_category',
category: 'promotions',
action: 'archive_read',
},
})
}
// Sort by confidence and return top 5
return suggestions
.sort((a, b) => b.confidence - a.confidence)
.slice(0, 5)
}
/**
* Check if email matches a company label condition
*/

View File

@@ -30,6 +30,10 @@ export const Collections = {
EMAIL_DIGESTS: 'email_digests',
SUBSCRIPTIONS: 'subscriptions',
USER_PREFERENCES: 'user_preferences',
ONBOARDING_STATE: 'onboarding_state',
EMAIL_USAGE: 'email_usage',
REFERRALS: 'referrals',
ANALYTICS_EVENTS: 'analytics_events',
}
/**
@@ -251,12 +255,86 @@ export const emailStats = {
},
}
/**
* Email usage operations
*/
export const emailUsage = {
async getCurrentMonth(userId) {
const month = new Date().toISOString().slice(0, 7) // "2026-01"
return db.findOne(Collections.EMAIL_USAGE, [
Query.equal('userId', userId),
Query.equal('month', month),
])
},
async increment(userId, count) {
const month = new Date().toISOString().slice(0, 7)
const existing = await this.getCurrentMonth(userId)
if (existing) {
return db.update(Collections.EMAIL_USAGE, existing.$id, {
emailsProcessed: (existing.emailsProcessed || 0) + count,
lastReset: new Date().toISOString(),
})
}
return db.create(Collections.EMAIL_USAGE, {
userId,
month,
emailsProcessed: count,
lastReset: new Date().toISOString(),
})
},
async getUsage(userId) {
const usage = await this.getCurrentMonth(userId)
return {
emailsProcessed: usage?.emailsProcessed || 0,
month: new Date().toISOString().slice(0, 7),
}
},
}
/**
* Subscriptions operations
*/
export const subscriptions = {
async getByUser(userId) {
return db.findOne(Collections.SUBSCRIPTIONS, [Query.equal('userId', userId)])
const subscription = await db.findOne(Collections.SUBSCRIPTIONS, [Query.equal('userId', userId)])
// If no subscription, user is on free tier
if (!subscription) {
const usage = await emailUsage.getUsage(userId)
return {
plan: 'free',
status: 'active',
isFreeTier: true,
emailsUsedThisMonth: usage.emailsProcessed,
emailsLimit: 500, // From config
}
}
// Check if subscription is active
const isActive = subscription.status === 'active'
const isFreeTier = !isActive || subscription.plan === 'free'
// Get usage for free tier users
let emailsUsedThisMonth = 0
let emailsLimit = -1 // Unlimited for paid
if (isFreeTier) {
const usage = await emailUsage.getUsage(userId)
emailsUsedThisMonth = usage.emailsProcessed
emailsLimit = 500 // From config
}
return {
...subscription,
plan: subscription.plan || 'free',
isFreeTier,
emailsUsedThisMonth,
emailsLimit,
}
},
async getByStripeId(stripeSubscriptionId) {
@@ -296,6 +374,27 @@ export const userPreferences = {
categoryActions: {},
companyLabels: [],
autoDetectCompanies: true,
version: 1,
categoryAdvanced: {},
cleanup: {
enabled: false,
readItems: {
enabled: false,
action: 'archive_read',
gracePeriodDays: 7,
},
promotions: {
enabled: false,
matchCategoriesOrLabels: ['promotions', 'newsletters'],
action: 'archive_read',
deleteAfterDays: 30,
},
safety: {
requireConfirmForDelete: true,
dryRun: false,
maxDeletesPerRun: 100,
},
},
}
},
@@ -347,6 +446,170 @@ export const userPreferences = {
},
}
/**
* Onboarding state operations
*/
export const onboardingState = {
async getByUser(userId) {
const state = await db.findOne(Collections.ONBOARDING_STATE, [
Query.equal('userId', userId),
])
if (state?.completed_steps_json) {
return {
...state,
completedSteps: JSON.parse(state.completed_steps_json),
}
}
return {
...state,
completedSteps: [],
onboarding_step: state?.onboarding_step || 'not_started',
}
},
async updateStep(userId, step, completedSteps = []) {
const existing = await db.findOne(Collections.ONBOARDING_STATE, [
Query.equal('userId', userId),
])
const data = {
onboarding_step: step,
completed_steps_json: JSON.stringify(completedSteps),
last_updated: new Date().toISOString(),
}
if (existing) {
return db.update(Collections.ONBOARDING_STATE, existing.$id, data)
}
return db.create(Collections.ONBOARDING_STATE, { userId, ...data })
},
async markValueSeen(userId) {
const existing = await db.findOne(Collections.ONBOARDING_STATE, [
Query.equal('userId', userId),
])
const data = {
first_value_seen_at: new Date().toISOString(),
last_updated: new Date().toISOString(),
}
if (existing) {
return db.update(Collections.ONBOARDING_STATE, existing.$id, data)
}
return db.create(Collections.ONBOARDING_STATE, {
userId,
onboarding_step: 'see_results',
completed_steps_json: JSON.stringify(['connect', 'first_rule', 'see_results']),
...data,
})
},
async skip(userId) {
const existing = await db.findOne(Collections.ONBOARDING_STATE, [
Query.equal('userId', userId),
])
const data = {
skipped_at: new Date().toISOString(),
last_updated: new Date().toISOString(),
}
if (existing) {
return db.update(Collections.ONBOARDING_STATE, existing.$id, data)
}
return db.create(Collections.ONBOARDING_STATE, {
userId,
onboarding_step: 'not_started',
completed_steps_json: JSON.stringify([]),
...data,
})
},
async resume(userId) {
const existing = await db.findOne(Collections.ONBOARDING_STATE, [
Query.equal('userId', userId),
])
if (existing) {
return db.update(Collections.ONBOARDING_STATE, existing.$id, {
skipped_at: null,
last_updated: new Date().toISOString(),
})
}
// If no state exists, create initial state
return db.create(Collections.ONBOARDING_STATE, {
userId,
onboarding_step: 'connect',
completed_steps_json: JSON.stringify([]),
last_updated: new Date().toISOString(),
})
},
}
/**
* Referrals operations
*/
export const referrals = {
async getOrCreateCode(userId) {
const existing = await db.findOne(Collections.REFERRALS, [
Query.equal('userId', userId),
])
if (existing) {
return existing
}
// Generate unique code: USER-ABC123
const randomPart = Math.random().toString(36).substring(2, 8).toUpperCase()
const code = `USER-${randomPart}`
// Ensure uniqueness
let uniqueCode = code
let attempts = 0
while (attempts < 10) {
const existingCode = await db.findOne(Collections.REFERRALS, [
Query.equal('referralCode', uniqueCode),
])
if (!existingCode) break
uniqueCode = `USER-${Math.random().toString(36).substring(2, 8).toUpperCase()}`
attempts++
}
return db.create(Collections.REFERRALS, {
userId,
referralCode: uniqueCode,
referralCount: 0,
createdAt: new Date().toISOString(),
})
},
async getByCode(code) {
return db.findOne(Collections.REFERRALS, [
Query.equal('referralCode', code),
])
},
async incrementCount(userId) {
const referral = await db.findOne(Collections.REFERRALS, [
Query.equal('userId', userId),
])
if (referral) {
return db.update(Collections.REFERRALS, referral.$id, {
referralCount: (referral.referralCount || 0) + 1,
})
}
return null
},
async getReferrals(userId) {
return db.list(Collections.REFERRALS, [
Query.equal('referredBy', userId),
])
},
}
/**
* Orders operations
*/
@@ -450,6 +713,8 @@ export const emailDigests = {
},
}
export { Query }
export default {
db,
products,

View File

@@ -64,6 +64,16 @@ export function redirect(res, url, statusCode = 302) {
return res.redirect(statusCode, url)
}
/**
* Error response
*/
export function error(res, errorData, statusCode = 400) {
return res.status(statusCode).json({
success: false,
error: errorData,
})
}
/**
* Response helpers object
*/
@@ -73,6 +83,7 @@ export const respond = {
noContent,
paginated,
redirect,
error,
}
export default respond

142
setup-production.ps1 Normal file
View File

@@ -0,0 +1,142 @@
# EmailSorter Production Setup Script
# Dieses Script hilft beim Setup für Production
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "EmailSorter Production Setup" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# Prüfe ob Node.js installiert ist
Write-Host "[1/5] Prüfe Node.js Installation..." -ForegroundColor Yellow
try {
$nodeVersion = node --version
Write-Host "✓ Node.js gefunden: $nodeVersion" -ForegroundColor Green
} catch {
Write-Host "✗ Node.js ist nicht installiert!" -ForegroundColor Red
Write-Host " Bitte installiere Node.js von https://nodejs.org/" -ForegroundColor Yellow
exit 1
}
# Prüfe ob PM2 installiert ist
Write-Host ""
Write-Host "[2/5] Prüfe PM2 Installation..." -ForegroundColor Yellow
try {
$pm2Version = pm2 --version
Write-Host "✓ PM2 gefunden: $pm2Version" -ForegroundColor Green
} catch {
Write-Host "✗ PM2 ist nicht installiert" -ForegroundColor Yellow
Write-Host " Installiere PM2..." -ForegroundColor Yellow
npm install -g pm2
Write-Host "✓ PM2 installiert" -ForegroundColor Green
}
# Backend Setup
Write-Host ""
Write-Host "[3/5] Backend Setup..." -ForegroundColor Yellow
$serverPath = Join-Path $PSScriptRoot "server"
if (Test-Path $serverPath) {
Set-Location $serverPath
# Prüfe .env Datei
if (-not (Test-Path ".env")) {
Write-Host "⚠ .env Datei nicht gefunden!" -ForegroundColor Yellow
Write-Host " Erstelle .env aus env.example..." -ForegroundColor Yellow
Copy-Item "env.example" ".env"
Write-Host " ⚠ WICHTIG: Bearbeite server/.env und setze die Production-Werte:" -ForegroundColor Red
Write-Host " - NODE_ENV=production" -ForegroundColor Yellow
Write-Host " - FRONTEND_URL=https://emailsorter.webklar.com" -ForegroundColor Yellow
Write-Host " - CORS_ORIGIN=https://emailsorter.webklar.com" -ForegroundColor Yellow
Write-Host " - BASE_URL=https://api.emailsorter.webklar.com (oder deine API URL)" -ForegroundColor Yellow
Write-Host ""
Write-Host " Drücke Enter, wenn du die .env Datei bearbeitet hast..." -ForegroundColor Cyan
Read-Host
}
# Installiere Dependencies
Write-Host " Installiere Backend Dependencies..." -ForegroundColor Yellow
npm install
Write-Host "✓ Backend Dependencies installiert" -ForegroundColor Green
# Prüfe ob Server bereits läuft
$pm2List = pm2 list 2>&1
if ($pm2List -match "emailsorter-api") {
Write-Host " Server läuft bereits. Neustart..." -ForegroundColor Yellow
pm2 restart emailsorter-api
} else {
Write-Host " Starte Backend Server mit PM2..." -ForegroundColor Yellow
pm2 start index.mjs --name emailsorter-api
pm2 save
}
Write-Host "✓ Backend Server gestartet" -ForegroundColor Green
Write-Host " Status: pm2 status" -ForegroundColor Cyan
Write-Host " Logs: pm2 logs emailsorter-api" -ForegroundColor Cyan
} else {
Write-Host "✗ Server Verzeichnis nicht gefunden!" -ForegroundColor Red
}
# Frontend Build
Write-Host ""
Write-Host "[4/5] Frontend Build..." -ForegroundColor Yellow
$clientPath = Join-Path $PSScriptRoot "client"
if (Test-Path $clientPath) {
Set-Location $clientPath
# Prüfe .env.production
if (-not (Test-Path ".env.production")) {
Write-Host " Erstelle .env.production..." -ForegroundColor Yellow
$envContent = @"
VITE_APPWRITE_ENDPOINT=https://appwrite.webklar.com/v1
VITE_APPWRITE_PROJECT_ID=DEINE_PROJEKT_ID
VITE_API_URL=https://api.emailsorter.webklar.com
"@
Set-Content -Path ".env.production" -Value $envContent
Write-Host " ⚠ WICHTIG: Bearbeite client/.env.production und setze die richtigen Werte!" -ForegroundColor Red
Write-Host ""
Write-Host " Drücke Enter, wenn du die .env.production Datei bearbeitet hast..." -ForegroundColor Cyan
Read-Host
}
# Installiere Dependencies
Write-Host " Installiere Frontend Dependencies..." -ForegroundColor Yellow
npm install
Write-Host "✓ Frontend Dependencies installiert" -ForegroundColor Green
# Build
Write-Host " Baue Frontend für Production..." -ForegroundColor Yellow
npm run build
Write-Host "✓ Frontend Build abgeschlossen" -ForegroundColor Green
Write-Host " Build-Ordner: client/dist" -ForegroundColor Cyan
Write-Host " ⚠ WICHTIG: Deploye den client/dist Ordner zu deinem Web-Server!" -ForegroundColor Yellow
} else {
Write-Host "✗ Client Verzeichnis nicht gefunden!" -ForegroundColor Red
}
# Zusammenfassung
Write-Host ""
Write-Host "[5/5] Zusammenfassung" -ForegroundColor Yellow
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "✓ Backend Setup abgeschlossen" -ForegroundColor Green
Write-Host "✓ Frontend Build abgeschlossen" -ForegroundColor Green
Write-Host ""
Write-Host "⚠ NOCH ZU TUN:" -ForegroundColor Red
Write-Host ""
Write-Host "1. APPWRITE CORS KONFIGURIEREN:" -ForegroundColor Yellow
Write-Host " - Gehe zu https://appwrite.webklar.com" -ForegroundColor White
Write-Host " - Öffne dein Projekt" -ForegroundColor White
Write-Host " - Settings → Platforms → Add Platform" -ForegroundColor White
Write-Host " - Hostname: emailsorter.webklar.com" -ForegroundColor White
Write-Host " - Origin: https://emailsorter.webklar.com" -ForegroundColor White
Write-Host ""
Write-Host "2. FRONTEND DEPLOYEN:" -ForegroundColor Yellow
Write-Host " - Kopiere client/dist zu deinem Web-Server" -ForegroundColor White
Write-Host " - Stelle sicher, dass die Domain richtig konfiguriert ist" -ForegroundColor White
Write-Host ""
Write-Host "3. BACKEND ÜBERWACHEN:" -ForegroundColor Yellow
Write-Host " - pm2 status (Server Status prüfen)" -ForegroundColor White
Write-Host " - pm2 logs emailsorter-api (Logs ansehen)" -ForegroundColor White
Write-Host " - pm2 monit (Live Monitoring)" -ForegroundColor White
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Set-Location $PSScriptRoot