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

No files matched your search

+111
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
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
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
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
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!
+69 -2
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>
+24
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

+144
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
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

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