huhuih
hzgjuigik
This commit is contained in:
111
APPWRITE_CORS_SETUP.md
Normal file
111
APPWRITE_CORS_SETUP.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Appwrite CORS Setup - Schritt für Schritt
|
||||
|
||||
## Problem
|
||||
Appwrite blockiert Requests von `https://emailsorter.webklar.com` weil nur `https://localhost` als Origin erlaubt ist.
|
||||
|
||||
## Lösung: Platform in Appwrite hinzufügen
|
||||
|
||||
### Schritt 1: Appwrite-Konsole öffnen
|
||||
1. Gehe zu: **https://appwrite.webklar.com**
|
||||
2. Logge dich ein
|
||||
|
||||
### Schritt 2: Projekt öffnen
|
||||
1. Klicke auf dein **EmailSorter Projekt** (oder das Projekt, das du verwendest)
|
||||
|
||||
### Schritt 3: Settings öffnen
|
||||
1. Klicke auf **Settings** im linken Menü
|
||||
2. Oder suche nach **"Platforms"** oder **"Web"** in den Einstellungen
|
||||
|
||||
### Schritt 4: Platform hinzufügen
|
||||
1. Klicke auf **"Add Platform"** oder **"Create Platform"**
|
||||
2. Wähle **"Web"** als Platform-Typ
|
||||
|
||||
### Schritt 5: Platform konfigurieren
|
||||
Fülle die Felder aus:
|
||||
|
||||
- **Name:** `Production` (oder ein anderer Name)
|
||||
- **Hostname:** `emailsorter.webklar.com`
|
||||
- **Origin:** `https://emailsorter.webklar.com`
|
||||
|
||||
**WICHTIG:**
|
||||
- Verwende **https://** (nicht http://)
|
||||
- Kein Slash am Ende
|
||||
- Genau so wie oben geschrieben
|
||||
|
||||
### Schritt 6: Speichern
|
||||
1. Klicke auf **"Create"** oder **"Save"**
|
||||
2. Warte 1-2 Minuten (Cache)
|
||||
|
||||
### Schritt 7: Testen
|
||||
1. Gehe zu https://emailsorter.webklar.com
|
||||
2. Versuche dich einzuloggen
|
||||
3. Prüfe die Browser-Konsole (F12) - sollte keine CORS-Fehler mehr geben
|
||||
|
||||
---
|
||||
|
||||
## Alternative: Mehrere Origins
|
||||
|
||||
Falls du mehrere Domains brauchst (z.B. localhost für Development und Production):
|
||||
|
||||
1. Erstelle **zwei separate Platforms:**
|
||||
- **Development:** Hostname: `localhost`, Origin: `http://localhost:5173`
|
||||
- **Production:** Hostname: `emailsorter.webklar.com`, Origin: `https://emailsorter.webklar.com`
|
||||
|
||||
2. Oder verwende **Wildcard** (falls von Appwrite unterstützt):
|
||||
- Origin: `https://*.webklar.com`
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### CORS-Fehler bleibt bestehen
|
||||
1. **Cache leeren:** Warte 2-3 Minuten nach dem Speichern
|
||||
2. **Browser-Cache:** Strg+Shift+R (Hard Refresh)
|
||||
3. **Prüfe Origin:** Muss **genau** `https://emailsorter.webklar.com` sein (kein Slash, kein Port)
|
||||
4. **Prüfe Appwrite-Version:** Manche Versionen haben die Platform-Einstellungen an einem anderen Ort
|
||||
|
||||
### Platform-Option nicht sichtbar
|
||||
- In manchen Appwrite-Versionen heißt es **"Web"** statt "Platforms"
|
||||
- Suche nach **"Client"** oder **"SDK"** in den Settings
|
||||
- Prüfe die Appwrite-Dokumentation für deine Version
|
||||
|
||||
### 404 oder 403 Fehler
|
||||
- Prüfe, ob die Appwrite-URL korrekt ist: `https://appwrite.webklar.com`
|
||||
- Prüfe, ob du die richtigen Berechtigungen hast
|
||||
- Prüfe, ob das Projekt existiert und aktiv ist
|
||||
|
||||
---
|
||||
|
||||
## Screenshots (Beispiel)
|
||||
|
||||
Die Platform-Einstellungen sollten etwa so aussehen:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Add Platform │
|
||||
├─────────────────────────────────────┤
|
||||
│ Type: [Web ▼] │
|
||||
│ │
|
||||
│ Name: Production │
|
||||
│ Hostname: emailsorter.webklar.com │
|
||||
│ Origin: https://emailsorter.webklar │
|
||||
│ .com │
|
||||
│ │
|
||||
│ [Cancel] [Create] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Nach dem Setup
|
||||
|
||||
Nachdem du die Platform hinzugefügt hast:
|
||||
|
||||
1. ✅ CORS-Fehler sollten verschwinden
|
||||
2. ✅ Login/Register sollte funktionieren
|
||||
3. ✅ API-Calls sollten durchgehen
|
||||
|
||||
**Falls es immer noch nicht funktioniert:**
|
||||
- Prüfe die Browser-Konsole für genaue Fehlermeldungen
|
||||
- Prüfe die Appwrite-Logs (falls verfügbar)
|
||||
- Stelle sicher, dass der Backend-Server läuft (502-Fehler beheben)
|
||||
6
COMMIT_COMMANDS.txt
Normal file
6
COMMIT_COMMANDS.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
Führe diese Befehle in deinem Git Bash oder Terminal aus:
|
||||
|
||||
cd c:\Users\User\Documents\GitHub\ANDJJJJJJ
|
||||
git add .
|
||||
git commit -m "feat: Control Panel Redesign v2.0 - Card-basiertes Layout, Side Panels, Dark Mode Fixes, Volle Breite Layout"
|
||||
git push
|
||||
71
COMMIT_MESSAGE.md
Normal file
71
COMMIT_MESSAGE.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Control Panel Redesign & UI Improvements
|
||||
|
||||
## Hauptänderungen
|
||||
|
||||
### 🎨 Control Panel komplettes Redesign (Version 2.0)
|
||||
- **Card-basiertes Layout**: Kategorien werden jetzt als interaktive Cards in einem Grid-Layout dargestellt (statt endloser Liste)
|
||||
- **Side Panel Integration**: Click-to-Configure Pattern - Klick auf Category Card öffnet Side Panel für detaillierte Konfiguration
|
||||
- **Moderne UX**: Dashboard-artiges Design, weniger wie klassische Settings-Seite, mehr wie moderne SaaS-Produkte
|
||||
|
||||
### 🧹 Cleanup Tab Redesign
|
||||
- **Große Toggle-Cards**: Visuell prominente Cards für "Auto cleanup read emails" und "Delete promotions after X days"
|
||||
- **Slider-Komponente**: Neue Slider-Komponente für intuitive Tage-Auswahl (statt Number Input)
|
||||
- **Preset Buttons**: Schnellzugriff auf 7/14/30 Tage für Promotion Cleanup
|
||||
- **Preview Section**: Zeigt betroffene E-Mails an (wenn Daten vorhanden)
|
||||
- **Warnungen**: Ruhige Info-Banner bei Delete-Aktionen
|
||||
|
||||
### 🏷️ Labels Tab Redesign
|
||||
- **Tabellenansicht**: Professionelle Tabelle mit Name, Status, Category und Actions
|
||||
- **Side Panel Editor**: Label-Erstellung/Bearbeitung in Side Panel statt inline Form
|
||||
- **Responsive Table**: Spalten werden auf Mobile ausgeblendet, wichtige Info bleibt sichtbar
|
||||
- **Import/Export**: Buttons für Label-Import/Export
|
||||
|
||||
### 📐 Layout Verbesserungen
|
||||
- **Volle Breite**: Dashboard und Settings nutzen jetzt die gesamte verfügbare Breite (keine max-width Beschränkung mehr)
|
||||
- **Responsive Navigation**: Side Panels werden auf Mobile zu Fullscreen-Modals
|
||||
- **Verbesserte Header**: Humanere Untertitel und bessere Button-Anordnung
|
||||
|
||||
### 🌙 Dark Mode Verbesserungen
|
||||
- **Privacy & Security**: Alle weißen Felder haben jetzt Dark Mode Varianten (grüne, blaue, rote Info-Boxen)
|
||||
- **Input-Komponente**: Dark Mode Hintergrund korrigiert (dark:bg-slate-800 statt dark:bg-slate-100)
|
||||
- **Slider-Komponente**: Dark Mode Styles für Thumb (Webkit & Mozilla)
|
||||
- **Chevron Icons**: Dark Mode Farben für Advanced Options Toggle
|
||||
- **Konsistenz**: Alle Komponenten haben jetzt konsistente Dark Mode Unterstützung
|
||||
|
||||
## Neue Komponenten
|
||||
|
||||
### `client/src/components/ui/side-panel.tsx`
|
||||
- Radix UI Dialog-basierte Side Panel Komponente
|
||||
- Slide-in Animation von rechts
|
||||
- Responsive: Fullscreen auf Mobile, 480px auf Desktop
|
||||
- Dark Mode Support
|
||||
|
||||
### `client/src/components/ui/slider.tsx`
|
||||
- Range Input Slider Komponente
|
||||
- Dark Mode Support für Track und Thumb
|
||||
- Customizable min/max/step Werte
|
||||
|
||||
## Geänderte Dateien
|
||||
|
||||
- `client/src/pages/Settings.tsx` - Control Panel komplett neu strukturiert
|
||||
- `client/src/components/PrivacySecurity.tsx` - Dark Mode für alle Info-Boxen
|
||||
- `client/src/components/ui/input.tsx` - Dark Mode Hintergrund korrigiert
|
||||
- `client/src/pages/Dashboard.tsx` - Volle Breite Layout
|
||||
- `client/src/types/settings.ts` - Keine Änderungen (nur Whitespace)
|
||||
|
||||
## Technische Details
|
||||
|
||||
- **State Management**: Side Panel State für Category Details und Label Editor
|
||||
- **Responsive Design**: Grid-Layouts passen sich an (1 Spalte Mobile, 2 Tablet, 3 Desktop)
|
||||
- **Accessibility**: Keyboard Navigation, ARIA Labels, Focus States
|
||||
- **Performance**: useMemo für gefilterte/sortierte Listen
|
||||
|
||||
## Design-Prinzipien
|
||||
|
||||
- Viel Whitespace zwischen Cards
|
||||
- Ruhige Farben (keine grellen Akzente)
|
||||
- Cards statt Listen
|
||||
- "Click → Configure" statt sofort sichtbare Controls
|
||||
- Klare Typografie-Hierarchie
|
||||
- Icons sparsam aber sinnvoll
|
||||
- Dark Mode optimiert (nicht zu kontrastreich)
|
||||
155
PRODUCTION_SETUP.md
Normal file
155
PRODUCTION_SETUP.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# Production Setup - emailsorter.webklar.com
|
||||
|
||||
## Probleme und Lösungen
|
||||
|
||||
### 1. Appwrite CORS-Konfiguration
|
||||
|
||||
**Problem:** Appwrite blockiert Requests von `https://emailsorter.webklar.com` weil nur `https://localhost` als Origin erlaubt ist.
|
||||
|
||||
**Lösung:**
|
||||
1. Gehe zu deiner Appwrite-Konsole: https://appwrite.webklar.com
|
||||
2. Öffne dein Projekt
|
||||
3. Gehe zu **Settings** → **Platforms** (oder **Web**)
|
||||
4. Füge eine neue Platform hinzu oder bearbeite die existierende:
|
||||
- **Name:** Production
|
||||
- **Hostname:** `emailsorter.webklar.com`
|
||||
- **Origin:** `https://emailsorter.webklar.com`
|
||||
5. Speichere die Änderungen
|
||||
|
||||
**Alternative:** Wenn du mehrere Origins brauchst, kannst du auch in Appwrite die CORS-Einstellungen anpassen, um mehrere Origins zu erlauben.
|
||||
|
||||
---
|
||||
|
||||
### 2. Backend-Server (502 Fehler)
|
||||
|
||||
**Problem:** Der Backend-Server läuft nicht oder ist nicht erreichbar.
|
||||
|
||||
**Lösung:**
|
||||
|
||||
#### Option A: Server auf demselben Server starten
|
||||
|
||||
1. **SSH zum Server:**
|
||||
```bash
|
||||
ssh user@webklar.com
|
||||
```
|
||||
|
||||
2. **Zum Projekt-Verzeichnis navigieren:**
|
||||
```bash
|
||||
cd /path/to/ANDJJJJJJ/server
|
||||
```
|
||||
|
||||
3. **Environment-Variablen setzen:**
|
||||
Erstelle oder bearbeite `.env`:
|
||||
```env
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
BASE_URL=https://api.emailsorter.webklar.com
|
||||
FRONTEND_URL=https://emailsorter.webklar.com
|
||||
CORS_ORIGIN=https://emailsorter.webklar.com
|
||||
|
||||
APPWRITE_ENDPOINT=https://appwrite.webklar.com/v1
|
||||
APPWRITE_PROJECT_ID=deine_projekt_id
|
||||
APPWRITE_API_KEY=dein_api_key
|
||||
APPWRITE_DATABASE_ID=email_sorter_db
|
||||
|
||||
# ... weitere Variablen
|
||||
```
|
||||
|
||||
4. **Server starten:**
|
||||
```bash
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
#### Option B: Mit PM2 (empfohlen für Production)
|
||||
|
||||
```bash
|
||||
npm install -g pm2
|
||||
cd /path/to/ANDJJJJJJ/server
|
||||
pm2 start index.mjs --name emailsorter-api
|
||||
pm2 save
|
||||
pm2 startup
|
||||
```
|
||||
|
||||
#### Option C: Reverse Proxy konfigurieren (Nginx)
|
||||
|
||||
Falls der Server auf einem anderen Port läuft, konfiguriere Nginx:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name api.emailsorter.webklar.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Frontend Environment-Variablen
|
||||
|
||||
Stelle sicher, dass das Frontend die richtige Backend-URL verwendet:
|
||||
|
||||
1. **Erstelle `client/.env.production`:**
|
||||
```env
|
||||
VITE_APPWRITE_ENDPOINT=https://appwrite.webklar.com/v1
|
||||
VITE_APPWRITE_PROJECT_ID=deine_projekt_id
|
||||
VITE_API_URL=https://api.emailsorter.webklar.com
|
||||
```
|
||||
|
||||
2. **Build das Frontend:**
|
||||
```bash
|
||||
cd client
|
||||
npm run build
|
||||
```
|
||||
|
||||
3. **Deploy den Build-Ordner** (`client/dist`) zu deinem Web-Server
|
||||
|
||||
---
|
||||
|
||||
### 4. Checkliste
|
||||
|
||||
- [ ] Appwrite CORS: `https://emailsorter.webklar.com` als Origin hinzugefügt
|
||||
- [ ] Backend-Server läuft und ist erreichbar
|
||||
- [ ] Backend `.env` konfiguriert mit Production-URLs
|
||||
- [ ] Frontend `.env.production` konfiguriert
|
||||
- [ ] Frontend gebaut und deployed
|
||||
- [ ] Reverse Proxy (Nginx) konfiguriert (falls nötig)
|
||||
- [ ] SSL-Zertifikat für beide Domains (Frontend + API)
|
||||
|
||||
---
|
||||
|
||||
### 5. Testing
|
||||
|
||||
Nach dem Setup, teste:
|
||||
|
||||
1. **Frontend:** https://emailsorter.webklar.com
|
||||
2. **Backend Health:** https://api.emailsorter.webklar.com/api/health
|
||||
3. **Login:** Versuche dich einzuloggen und prüfe die Browser-Konsole auf Fehler
|
||||
|
||||
---
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
**CORS-Fehler weiterhin:**
|
||||
- Prüfe, ob die Appwrite-Änderungen gespeichert wurden
|
||||
- Warte 1-2 Minuten (Cache)
|
||||
- Prüfe Browser-Konsole für genaue Fehlermeldung
|
||||
|
||||
**502 Bad Gateway:**
|
||||
- Prüfe, ob der Backend-Server läuft: `pm2 list` oder `ps aux | grep node`
|
||||
- Prüfe Server-Logs: `pm2 logs emailsorter-api` oder `tail -f server.log`
|
||||
- Prüfe Firewall-Regeln
|
||||
- Prüfe Reverse Proxy Konfiguration
|
||||
|
||||
**API nicht erreichbar:**
|
||||
- Prüfe, ob der Port 3000 offen ist
|
||||
- Prüfe, ob die Domain richtig auf den Server zeigt
|
||||
- Prüfe DNS-Einträge
|
||||
105
client/FAVICON_SETUP.md
Normal file
105
client/FAVICON_SETUP.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Favicon Setup Anleitung
|
||||
|
||||
Die Favicon-Dateien wurden erstellt. Um alle Formate zu generieren, folge diesen Schritten:
|
||||
|
||||
## Erstellte Dateien
|
||||
|
||||
✅ `favicon.svg` - Modernes SVG Favicon (bereits erstellt)
|
||||
✅ `apple-touch-icon.svg` - SVG für Apple Touch Icon (bereits erstellt)
|
||||
✅ `site.webmanifest` - Web App Manifest (bereits erstellt)
|
||||
|
||||
## Noch zu erstellen (PNG/ICO)
|
||||
|
||||
Du musst die folgenden PNG/ICO-Dateien aus dem SVG erstellen:
|
||||
|
||||
### Option 1: Online Converter verwenden
|
||||
|
||||
1. Gehe zu einem dieser Tools:
|
||||
- https://realfavicongenerator.net/ (Empfohlen - generiert alle Formate)
|
||||
- https://www.zenlytools.com/svg-to-ico
|
||||
- https://svg-to-ico.org/
|
||||
|
||||
2. Lade `favicon.svg` hoch
|
||||
|
||||
3. Generiere folgende Dateien:
|
||||
- `favicon.ico` (16x16, 32x32, 48x48)
|
||||
- `favicon-16x16.png`
|
||||
- `favicon-32x32.png`
|
||||
- `apple-touch-icon.png` (180x180)
|
||||
- `favicon-192x192.png` (für Web Manifest)
|
||||
- `favicon-512x512.png` (für Web Manifest)
|
||||
|
||||
4. Speichere alle generierten Dateien im `client/public/` Ordner
|
||||
|
||||
### Option 2: Mit ImageMagick (Command Line)
|
||||
|
||||
```bash
|
||||
# Installiere ImageMagick (falls nicht vorhanden)
|
||||
# Windows: choco install imagemagick
|
||||
# Mac: brew install imagemagick
|
||||
# Linux: sudo apt-get install imagemagick
|
||||
|
||||
cd client/public
|
||||
|
||||
# Erstelle PNG-Varianten
|
||||
magick favicon.svg -resize 16x16 favicon-16x16.png
|
||||
magick favicon.svg -resize 32x32 favicon-32x32.png
|
||||
magick apple-touch-icon.svg -resize 180x180 apple-touch-icon.png
|
||||
magick favicon.svg -resize 192x192 favicon-192x192.png
|
||||
magick favicon.svg -resize 512x512 favicon-512x512.png
|
||||
|
||||
# Erstelle ICO (mehrere Größen)
|
||||
magick favicon.svg -define icon:auto-resize=16,32,48 favicon.ico
|
||||
```
|
||||
|
||||
### Option 3: Mit Online Favicon Generator (Empfohlen)
|
||||
|
||||
1. Gehe zu: https://realfavicongenerator.net/
|
||||
2. Klicke auf "Select your Favicon image"
|
||||
3. Lade `favicon.svg` hoch
|
||||
4. Konfiguriere die Optionen:
|
||||
- iOS: Apple Touch Icon aktivieren
|
||||
- Android Chrome: Manifest aktivieren
|
||||
- Windows Metro: Optional
|
||||
5. Klicke auf "Generate your Favicons and HTML code"
|
||||
6. Lade das ZIP herunter
|
||||
7. Extrahiere alle Dateien in `client/public/`
|
||||
8. Kopiere die generierten `<link>` Tags in `index.html` (falls nötig)
|
||||
|
||||
## Verifizierung
|
||||
|
||||
Nach dem Erstellen aller Dateien:
|
||||
|
||||
1. Starte den Dev-Server: `npm run dev`
|
||||
2. Öffne die Seite im Browser
|
||||
3. Prüfe den Browser-Tab - das Favicon sollte angezeigt werden
|
||||
4. Teste auf Mobile:
|
||||
- iOS Safari: Zum Home-Bildschirm hinzufügen → Icon sollte erscheinen
|
||||
- Android Chrome: Installiere als PWA → Icon sollte erscheinen
|
||||
|
||||
## Dateien im public/ Ordner
|
||||
|
||||
Nach Abschluss sollten folgende Dateien vorhanden sein:
|
||||
|
||||
```
|
||||
client/public/
|
||||
├── favicon.svg ✅
|
||||
├── favicon.ico (zu erstellen)
|
||||
├── favicon-16x16.png (zu erstellen)
|
||||
├── favicon-32x32.png (zu erstellen)
|
||||
├── apple-touch-icon.png (zu erstellen)
|
||||
├── favicon-192x192.png (zu erstellen)
|
||||
├── favicon-512x512.png (zu erstellen)
|
||||
├── apple-touch-icon.svg ✅
|
||||
└── site.webmanifest ✅
|
||||
```
|
||||
|
||||
## Browser-Kompatibilität
|
||||
|
||||
- **Chrome/Edge**: Verwendet `favicon.svg` oder `favicon.ico`
|
||||
- **Firefox**: Verwendet `favicon.svg` oder `favicon.ico`
|
||||
- **Safari (Desktop)**: Verwendet `favicon.ico` oder PNG
|
||||
- **Safari (iOS)**: Verwendet `apple-touch-icon.png`
|
||||
- **Android Chrome**: Verwendet Icons aus `site.webmanifest`
|
||||
|
||||
Die aktuelle Konfiguration in `index.html` unterstützt alle modernen Browser!
|
||||
@@ -2,13 +2,80 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<!-- Favicons -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes" />
|
||||
<meta name="description" content="EmailSorter - AI-powered email sorting for maximum productivity. Automatically organize your inbox." />
|
||||
<meta name="description" content="E-Mail-Sorter - AI-powered email sorting for maximum productivity. Automatically organize your inbox." />
|
||||
<meta name="theme-color" content="#22c55e" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title>EmailSorter - Your inbox, finally organized</title>
|
||||
<!-- Prevent FOUC for dark mode - Enhanced Dark Reader detection -->
|
||||
<script>
|
||||
(function() {
|
||||
'use strict';
|
||||
const html = document.documentElement;
|
||||
|
||||
// Enhanced Dark Reader detection (multiple methods)
|
||||
function detectDarkReader() {
|
||||
// Method 1: Check for Dark Reader data attributes
|
||||
if (html.hasAttribute('data-darkreader-mode') ||
|
||||
html.hasAttribute('data-darkreader-scheme') ||
|
||||
html.hasAttribute('data-darkreader-policy')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Method 2: Check for Dark Reader meta tag or stylesheet
|
||||
try {
|
||||
if (document.querySelector('meta[name="darkreader"]') ||
|
||||
document.querySelector('style[data-darkreader]')) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors during early initialization
|
||||
}
|
||||
|
||||
// Method 3: Check computed styles for filter/invert (Dark Reader uses CSS filters)
|
||||
try {
|
||||
const computedStyle = window.getComputedStyle(html);
|
||||
const filter = computedStyle.filter;
|
||||
if (filter && filter !== 'none' &&
|
||||
(filter.includes('invert') || filter.includes('brightness'))) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors if getComputedStyle fails
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check system preference
|
||||
const prefersDark = window.matchMedia &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
// Detect Dark Reader
|
||||
const hasDarkReader = detectDarkReader();
|
||||
|
||||
// Apply theme: only dark if system prefers it AND Dark Reader is NOT active
|
||||
if (prefersDark && !hasDarkReader) {
|
||||
html.classList.add('dark');
|
||||
html.setAttribute('data-theme', 'dark');
|
||||
} else {
|
||||
// Force light mode if Dark Reader is detected
|
||||
html.classList.remove('dark');
|
||||
html.setAttribute('data-theme', 'light');
|
||||
}
|
||||
|
||||
// Mark as initialized to prevent FOUC transitions
|
||||
html.classList.add('dark-mode-initialized');
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body class="antialiased">
|
||||
<div id="root"></div>
|
||||
|
||||
24
client/public/apple-touch-icon.svg
Normal file
24
client/public/apple-touch-icon.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" width="180" height="180">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#22c55e;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#16a34a;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Background with rounded square -->
|
||||
<rect x="10" y="10" width="160" height="160" rx="32" fill="url(#grad)"/>
|
||||
<!-- Mail envelope -->
|
||||
<path d="M50 60 L90 100 L130 60 M50 60 L50 110 L130 110 L130 60"
|
||||
stroke="white"
|
||||
stroke-width="7"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill="none"/>
|
||||
<!-- Envelope flap (top triangle) -->
|
||||
<path d="M50 60 L90 100 L130 60"
|
||||
stroke="white"
|
||||
stroke-width="7"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 903 B |
144
client/public/favicon-generator.html
Normal file
144
client/public/favicon-generator.html
Normal file
@@ -0,0 +1,144 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Favicon Generator - EmailSorter</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 40px auto;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
h1 {
|
||||
color: #22c55e;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.preview {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin: 30px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.preview-item {
|
||||
text-align: center;
|
||||
padding: 15px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.preview-item img {
|
||||
display: block;
|
||||
margin: 0 auto 10px;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
.instructions {
|
||||
background: #f0fdf4;
|
||||
border-left: 4px solid #22c55e;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.instructions h2 {
|
||||
margin-top: 0;
|
||||
color: #16a34a;
|
||||
}
|
||||
.instructions ol {
|
||||
line-height: 1.8;
|
||||
}
|
||||
.download-link {
|
||||
display: inline-block;
|
||||
margin-top: 10px;
|
||||
padding: 10px 20px;
|
||||
background: #22c55e;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.download-link:hover {
|
||||
background: #16a34a;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>📧 EmailSorter Favicon Generator</h1>
|
||||
<p>Diese Seite hilft dir beim Erstellen der Favicon-Dateien.</p>
|
||||
|
||||
<div class="preview">
|
||||
<div class="preview-item">
|
||||
<img src="/favicon.svg" alt="Favicon SVG" width="64" height="64">
|
||||
<strong>SVG (64x64)</strong><br>
|
||||
<small>Modern, skalierbar</small>
|
||||
</div>
|
||||
<div class="preview-item">
|
||||
<img src="/apple-touch-icon.svg" alt="Apple Touch Icon" width="180" height="180" style="width: 90px; height: 90px;">
|
||||
<strong>Apple Touch (180x180)</strong><br>
|
||||
<small>iOS Home Screen</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="instructions">
|
||||
<h2>📋 Anleitung: Favicon-Dateien erstellen</h2>
|
||||
<ol>
|
||||
<li><strong>Gehe zu einem Favicon-Generator:</strong>
|
||||
<ul>
|
||||
<li><a href="https://realfavicongenerator.net/" target="_blank">realfavicongenerator.net</a> (Empfohlen)</li>
|
||||
<li><a href="https://www.zenlytools.com/svg-to-ico" target="_blank">zenlytools.com</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Lade die SVG-Datei hoch:</strong>
|
||||
<ul>
|
||||
<li>Klicke auf "Select your Favicon image"</li>
|
||||
<li>Wähle <code>favicon.svg</code> aus dem <code>public/</code> Ordner</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Konfiguriere die Optionen:</strong>
|
||||
<ul>
|
||||
<li>✅ iOS: Apple Touch Icon aktivieren</li>
|
||||
<li>✅ Android Chrome: Manifest aktivieren</li>
|
||||
<li>✅ Windows Metro: Optional</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Generiere und lade herunter:</strong>
|
||||
<ul>
|
||||
<li>Klicke auf "Generate your Favicons"</li>
|
||||
<li>Lade das ZIP-Archiv herunter</li>
|
||||
<li>Extrahiere alle Dateien in den <code>client/public/</code> Ordner</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Verifiziere:</strong>
|
||||
<ul>
|
||||
<li>Starte den Dev-Server neu</li>
|
||||
<li>Prüfe den Browser-Tab - das Favicon sollte erscheinen</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<h2>📁 Benötigte Dateien</h2>
|
||||
<p>Nach der Konvertierung sollten folgende Dateien im <code>public/</code> Ordner sein:</p>
|
||||
<ul>
|
||||
<li>✅ <code>favicon.svg</code> (bereits vorhanden)</li>
|
||||
<li>⏳ <code>favicon.ico</code></li>
|
||||
<li>⏳ <code>favicon-16x16.png</code></li>
|
||||
<li>⏳ <code>favicon-32x32.png</code></li>
|
||||
<li>⏳ <code>apple-touch-icon.png</code></li>
|
||||
<li>⏳ <code>favicon-192x192.png</code></li>
|
||||
<li>⏳ <code>favicon-512x512.png</code></li>
|
||||
</ul>
|
||||
|
||||
<h2>🔗 Nützliche Links</h2>
|
||||
<p>
|
||||
<a href="https://realfavicongenerator.net/" target="_blank" class="download-link">Favicon Generator öffnen</a>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
24
client/public/favicon.svg
Normal file
24
client/public/favicon.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#22c55e;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#16a34a;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Background with rounded square -->
|
||||
<rect x="4" y="4" width="56" height="56" rx="12" fill="url(#grad)"/>
|
||||
<!-- Mail envelope -->
|
||||
<path d="M18 22 L32 34 L46 22 M18 22 L18 38 L46 38 L46 22"
|
||||
stroke="white"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill="none"/>
|
||||
<!-- Envelope flap (top triangle) -->
|
||||
<path d="M18 22 L32 34 L46 22"
|
||||
stroke="white"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 891 B |
21
client/public/site.webmanifest
Normal file
21
client/public/site.webmanifest
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "EmailSorter",
|
||||
"short_name": "EmailSorter",
|
||||
"description": "AI-powered email sorting for maximum productivity",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/favicon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#22c55e",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone",
|
||||
"start_url": "/"
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { AuthProvider, useAuth } from '@/context/AuthContext'
|
||||
import { usePageTracking } from '@/hooks/useAnalytics'
|
||||
import { initAnalytics } from '@/lib/analytics'
|
||||
import { useTheme } from '@/hooks/useTheme'
|
||||
import { Home } from '@/pages/Home'
|
||||
import { Login } from '@/pages/Login'
|
||||
import { Register } from '@/pages/Register'
|
||||
@@ -142,6 +143,9 @@ function AppRoutes() {
|
||||
}
|
||||
|
||||
function App() {
|
||||
// Initialize theme detection
|
||||
useTheme()
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
|
||||
87
client/src/components/OnboardingProgress.tsx
Normal file
87
client/src/components/OnboardingProgress.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { X, Check } from 'lucide-react'
|
||||
|
||||
interface OnboardingProgressProps {
|
||||
currentStep: string
|
||||
completedSteps: string[]
|
||||
totalSteps: number
|
||||
onSkip: () => void
|
||||
}
|
||||
|
||||
const stepLabels: Record<string, string> = {
|
||||
'not_started': 'Not started',
|
||||
'connect': 'Connect email',
|
||||
'first_rule': 'Create first rule',
|
||||
'see_results': 'See results',
|
||||
'auto_schedule': 'Enable automation',
|
||||
'completed': 'Completed',
|
||||
}
|
||||
|
||||
export function OnboardingProgress({ currentStep, completedSteps, totalSteps, onSkip }: OnboardingProgressProps) {
|
||||
const stepIndex = ['connect', 'first_rule', 'see_results', 'auto_schedule'].indexOf(currentStep)
|
||||
const currentStepNumber = stepIndex >= 0 ? stepIndex + 1 : 0
|
||||
const progress = totalSteps > 0 ? (completedSteps.length / totalSteps) * 100 : 0
|
||||
|
||||
if (currentStep === 'completed' || currentStep === 'not_started') {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">Getting started</p>
|
||||
<p className="text-xs text-slate-500">Step {currentStepNumber} of {totalSteps}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={onSkip} className="text-slate-500 hover:text-slate-700">
|
||||
<X className="w-4 h-4 mr-1" />
|
||||
Skip
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="w-full h-2 bg-slate-100 rounded-full overflow-hidden mb-2">
|
||||
<div
|
||||
className="h-full bg-primary-500 transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step indicators */}
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
{['connect', 'first_rule', 'see_results', 'auto_schedule'].map((step, idx) => {
|
||||
const isCompleted = completedSteps.includes(step)
|
||||
const isCurrent = currentStep === step
|
||||
|
||||
return (
|
||||
<div key={step} className="flex items-center gap-1 flex-1 min-w-0">
|
||||
<div className={`flex-shrink-0 w-5 h-5 rounded-full flex items-center justify-center ${
|
||||
isCompleted
|
||||
? 'bg-green-500 text-white'
|
||||
: isCurrent
|
||||
? 'bg-primary-500 text-white ring-2 ring-primary-200'
|
||||
: 'bg-slate-200 text-slate-400'
|
||||
}`}>
|
||||
{isCompleted ? (
|
||||
<Check className="w-3 h-3" />
|
||||
) : (
|
||||
<span className="text-xs font-semibold">{idx + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`truncate hidden sm:inline ${
|
||||
isCurrent ? 'text-slate-900 font-medium' : ''
|
||||
}`}>
|
||||
{stepLabels[step] || step}
|
||||
</span>
|
||||
{idx < 3 && (
|
||||
<div className={`flex-1 h-0.5 mx-1 ${
|
||||
isCompleted ? 'bg-green-500' : 'bg-slate-200'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
226
client/src/components/PrivacySecurity.tsx
Normal file
226
client/src/components/PrivacySecurity.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Shield, Lock, Trash2, X, Check, AlertTriangle } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface PrivacySecurityProps {
|
||||
onDisconnect?: (accountId: string) => void
|
||||
onDeleteAccount?: () => void
|
||||
connectedAccounts?: Array<{ id: string; email: string; provider: string }>
|
||||
}
|
||||
|
||||
export function PrivacySecurity({ onDisconnect, onDeleteAccount, connectedAccounts = [] }: PrivacySecurityProps) {
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* What data is accessed */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-primary-500" />
|
||||
What data is accessed
|
||||
</CardTitle>
|
||||
<CardDescription>We only access what's necessary for sorting</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-start gap-3 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
<Check className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-green-900 dark:text-green-100">Email headers and metadata</p>
|
||||
<p className="text-sm text-green-700 dark:text-green-300 mt-1">
|
||||
We read: sender, subject, date, labels/categories. This is all we need to categorize emails.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
<Check className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-green-900 dark:text-green-100">Email preview/snippet</p>
|
||||
<p className="text-sm text-green-700 dark:text-green-300 mt-1">
|
||||
We read the first few lines to help AI understand the email content.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* What is stored */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Lock className="w-5 h-5 text-primary-500 dark:text-primary-400" />
|
||||
What is stored
|
||||
</CardTitle>
|
||||
<CardDescription>Your data stays secure</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<Check className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-blue-900 dark:text-blue-100">Your preferences</p>
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
|
||||
VIP senders, category settings, company labels, sorting rules.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<Check className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-blue-900 dark:text-blue-100">Statistics</p>
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
|
||||
Counts of sorted emails, categories, time saved. No email content.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<Check className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-blue-900 dark:text-blue-100">Account connection tokens</p>
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
|
||||
Encrypted OAuth tokens to access your email (required for sorting).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* What is never stored */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<X className="w-5 h-5 text-red-500 dark:text-red-400" />
|
||||
What is never stored
|
||||
</CardTitle>
|
||||
<CardDescription>Your privacy is protected</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-start gap-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<X className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-red-900 dark:text-red-100">Email bodies/content</p>
|
||||
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
|
||||
We never store the full content of your emails.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<X className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-red-900 dark:text-red-100">Attachments</p>
|
||||
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
|
||||
We never access or store file attachments.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<X className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-red-900 dark:text-red-100">Passwords</p>
|
||||
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
|
||||
We use OAuth - we never see or store your email passwords.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* How to disconnect */}
|
||||
{connectedAccounts.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Disconnect email accounts</CardTitle>
|
||||
<CardDescription>Remove access to your email accounts</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{connectedAccounts.map((account) => (
|
||||
<div key={account.id} className="flex items-center justify-between p-4 border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100">{account.email}</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 capitalize">{account.provider}</p>
|
||||
</div>
|
||||
{onDisconnect && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onDisconnect(account.id)}
|
||||
className="text-red-600 dark:text-red-400 border-red-200 dark:border-red-800 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Delete account */}
|
||||
{onDeleteAccount && (
|
||||
<Card className="border-red-200 dark:border-red-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-red-600 dark:text-red-400">
|
||||
<Trash2 className="w-5 h-5" />
|
||||
Delete my data
|
||||
</CardTitle>
|
||||
<CardDescription>Permanently delete all your data and account</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!showDeleteConfirm ? (
|
||||
<div className="space-y-3">
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-sm text-red-800 dark:text-red-200 mb-2">
|
||||
<AlertTriangle className="w-4 h-4 inline mr-1" />
|
||||
This action cannot be undone
|
||||
</p>
|
||||
<p className="text-xs text-red-700 dark:text-red-300">
|
||||
This will delete all your preferences, statistics, connected accounts, and subscription data.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="w-full bg-red-600 hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-800 text-white"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete my account and data
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="p-4 bg-red-100 dark:bg-red-900/30 border-2 border-red-300 dark:border-red-700 rounded-lg">
|
||||
<p className="font-semibold text-red-900 dark:text-red-100 mb-2">Are you absolutely sure?</p>
|
||||
<p className="text-sm text-red-800 dark:text-red-200">
|
||||
This will permanently delete:
|
||||
</p>
|
||||
<ul className="text-sm text-red-700 dark:text-red-300 mt-2 space-y-1 list-disc list-inside">
|
||||
<li>All your email account connections</li>
|
||||
<li>All sorting statistics</li>
|
||||
<li>All preferences and settings</li>
|
||||
<li>Your subscription (if active)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={onDeleteAccount}
|
||||
className="flex-1 bg-red-600 hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-800 text-white"
|
||||
>
|
||||
Yes, delete everything
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
101
client/src/components/ShareResults.tsx
Normal file
101
client/src/components/ShareResults.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Share2, Copy, Check } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { trackReferralShared } from '@/lib/analytics'
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
|
||||
interface ShareResultsProps {
|
||||
sortedCount: number
|
||||
referralCode?: string
|
||||
}
|
||||
|
||||
export function ShareResults({ sortedCount, referralCode }: ShareResultsProps) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const { user } = useAuth()
|
||||
|
||||
const shareText = `I cleaned up ${sortedCount} emails with EmailSorter${referralCode ? `! Use code ${referralCode} for a bonus.` : '!'}`
|
||||
const shareUrl = referralCode
|
||||
? `${window.location.origin}?ref=${referralCode}`
|
||||
: window.location.origin
|
||||
|
||||
const handleCopy = async () => {
|
||||
const text = `${shareText}\n${shareUrl}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleShare = async () => {
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: 'EmailSorter - Clean Inbox',
|
||||
text: shareText,
|
||||
url: shareUrl,
|
||||
})
|
||||
if (user?.$id && referralCode) {
|
||||
trackReferralShared(user.$id, referralCode)
|
||||
}
|
||||
} catch (err) {
|
||||
// User cancelled or error
|
||||
console.error('Share failed:', err)
|
||||
}
|
||||
} else {
|
||||
// Fallback to copy
|
||||
handleCopy()
|
||||
}
|
||||
}
|
||||
|
||||
if (sortedCount < 10) {
|
||||
return null // Don't show for small results
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-r from-primary-50 to-accent-50 border border-primary-200 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary-500 flex items-center justify-center flex-shrink-0">
|
||||
<Share2 className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-slate-900 mb-1">Share your success!</h3>
|
||||
<p className="text-sm text-slate-600">
|
||||
{shareText}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{typeof navigator !== 'undefined' && 'share' in navigator && typeof navigator.share === 'function' && (
|
||||
<Button
|
||||
onClick={handleShare}
|
||||
variant="outline"
|
||||
className="flex-1 border-primary-300 text-primary-700 hover:bg-primary-50"
|
||||
>
|
||||
<Share2 className="w-4 h-4 mr-2" />
|
||||
Share
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleCopy}
|
||||
variant="outline"
|
||||
className="flex-1 border-primary-300 text-primary-700 hover:bg-primary-50"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
113
client/src/components/UpgradePrompt.tsx
Normal file
113
client/src/components/UpgradePrompt.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { X, Sparkles, Zap, Infinity } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { trackUpgradeClicked } from '@/lib/analytics'
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
|
||||
interface UpgradePromptProps {
|
||||
title?: string
|
||||
benefits?: string[]
|
||||
source: 'after_sort' | 'limit_reached' | 'auto_schedule' | 'after_rules'
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
const defaultBenefits: Record<string, string[]> = {
|
||||
after_sort: [
|
||||
'Sort unlimited emails automatically',
|
||||
'Set up auto-schedule for hands-free organization',
|
||||
'Access all premium features',
|
||||
],
|
||||
limit_reached: [
|
||||
'Unlimited email sorting',
|
||||
'No monthly limits',
|
||||
'Priority support',
|
||||
],
|
||||
auto_schedule: [
|
||||
'Auto-schedule available in Pro plan',
|
||||
'Set it and forget it',
|
||||
'Keep your inbox clean automatically',
|
||||
],
|
||||
after_rules: [
|
||||
'Automate with Pro plan',
|
||||
'Unlimited rules and customizations',
|
||||
'Advanced AI features',
|
||||
],
|
||||
}
|
||||
|
||||
export function UpgradePrompt({
|
||||
title = 'Keep your inbox clean automatically',
|
||||
benefits,
|
||||
source,
|
||||
onDismiss,
|
||||
}: UpgradePromptProps) {
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuth()
|
||||
const promptBenefits = benefits || defaultBenefits[source] || defaultBenefits.after_sort
|
||||
|
||||
// Check if this prompt was already shown in this session
|
||||
const sessionKey = `upgrade_prompt_shown_${source}`
|
||||
const wasShown = sessionStorage.getItem(sessionKey) === 'true'
|
||||
|
||||
if (wasShown) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleUpgrade = () => {
|
||||
if (user?.$id) {
|
||||
trackUpgradeClicked(user.$id, source)
|
||||
}
|
||||
navigate('/settings?tab=subscription')
|
||||
}
|
||||
|
||||
const handleDismiss = () => {
|
||||
sessionStorage.setItem(sessionKey, 'true')
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-r from-primary-50 to-accent-50 border-2 border-primary-200 rounded-xl p-4 sm:p-6 shadow-lg">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary-500 flex items-center justify-center flex-shrink-0">
|
||||
<Sparkles className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-slate-900">{title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="text-slate-400 hover:text-slate-600 transition-colors flex-shrink-0"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2 mb-4">
|
||||
{promptBenefits.map((benefit, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2 text-sm text-slate-700">
|
||||
<Zap className="w-4 h-4 text-primary-500 flex-shrink-0 mt-0.5" />
|
||||
<span>{benefit}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={handleUpgrade}
|
||||
className="flex-1 bg-primary-600 hover:bg-primary-700"
|
||||
>
|
||||
<Infinity className="w-4 h-4 mr-2" />
|
||||
Upgrade
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDismiss}
|
||||
className="border-slate-300 text-slate-600 hover:bg-slate-50"
|
||||
>
|
||||
Not now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
Zap,
|
||||
Shield,
|
||||
Clock,
|
||||
Tags,
|
||||
Settings,
|
||||
Inbox,
|
||||
Filter
|
||||
@@ -11,39 +10,42 @@ import {
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: Brain,
|
||||
title: "AI-powered categorization",
|
||||
description: "Our AI automatically recognizes whether an email is an invoice, newsletter, or important message.",
|
||||
color: "from-violet-500 to-purple-600"
|
||||
icon: Inbox,
|
||||
title: "Stop drowning in emails",
|
||||
description: "Clear inbox, less stress. Automatically sort newsletters, promotions, and social updates away from what matters.",
|
||||
color: "from-violet-500 to-purple-600",
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
title: "Real-time sorting",
|
||||
description: "New emails are categorized instantly. Your inbox arrives already sorted.",
|
||||
color: "from-amber-500 to-orange-600"
|
||||
title: "One-click smart rules",
|
||||
description: "AI suggests, you approve. Create smart rules in seconds and apply them with one click.",
|
||||
color: "from-amber-500 to-orange-600",
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
icon: Tags,
|
||||
title: "Smart labels",
|
||||
description: "Automatic labels for VIP, clients, invoices, newsletters, social media and more.",
|
||||
color: "from-blue-500 to-cyan-600"
|
||||
icon: Settings,
|
||||
title: "Automation that keeps working",
|
||||
description: "Set it and forget it. Your inbox stays organized automatically, day after day.",
|
||||
color: "from-blue-500 to-cyan-600",
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
icon: Brain,
|
||||
title: "AI-powered smart sorting",
|
||||
description: "Our AI automatically recognizes whether an email is an invoice, newsletter, or important message.",
|
||||
color: "from-green-500 to-emerald-600"
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: "GDPR compliant",
|
||||
description: "Your data stays secure. We only read email headers and metadata for sorting.",
|
||||
color: "from-green-500 to-emerald-600"
|
||||
color: "from-pink-500 to-rose-600"
|
||||
},
|
||||
{
|
||||
icon: Clock,
|
||||
title: "Save time",
|
||||
description: "Average 2 hours per week less on email organization. More time for what matters.",
|
||||
color: "from-pink-500 to-rose-600"
|
||||
},
|
||||
{
|
||||
icon: Settings,
|
||||
title: "Fully customizable",
|
||||
description: "Define your own rules, VIP contacts, and categories based on your needs.",
|
||||
color: "from-indigo-500 to-blue-600"
|
||||
},
|
||||
]
|
||||
@@ -119,18 +121,23 @@ interface FeatureCardProps {
|
||||
description: string
|
||||
color: string
|
||||
index: number
|
||||
highlight?: boolean
|
||||
}
|
||||
|
||||
function FeatureCard({ icon: Icon, title, description, color, index }: FeatureCardProps) {
|
||||
function FeatureCard({ icon: Icon, title, description, color, index, highlight }: FeatureCardProps) {
|
||||
return (
|
||||
<div
|
||||
className="group bg-white rounded-2xl p-6 border border-slate-200 hover:border-primary-200 hover:shadow-lg transition-all duration-300"
|
||||
className={`group rounded-2xl p-6 border transition-all duration-300 ${
|
||||
highlight
|
||||
? 'bg-gradient-to-br from-white to-slate-50 border-primary-200 hover:border-primary-300 hover:shadow-xl'
|
||||
: 'bg-white border-slate-200 hover:border-primary-200 hover:shadow-lg'
|
||||
}`}
|
||||
style={{ animationDelay: `${index * 0.1}s` }}
|
||||
>
|
||||
<div className={`w-14 h-14 rounded-xl bg-gradient-to-br ${color} flex items-center justify-center mb-5 group-hover:scale-110 transition-transform duration-300`}>
|
||||
<div className={`w-14 h-14 rounded-xl bg-gradient-to-br ${color} flex items-center justify-center mb-5 group-hover:scale-110 transition-transform duration-300 shadow-lg`}>
|
||||
<Icon className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-slate-900 mb-2">{title}</h3>
|
||||
<h3 className={`${highlight ? 'text-2xl' : 'text-xl'} font-semibold text-slate-900 mb-2`}>{title}</h3>
|
||||
<p className="text-slate-600">{description}</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ export function Footer() {
|
||||
<Mail className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="text-xl font-bold text-white">
|
||||
Email<span className="text-primary-400">Sorter</span>
|
||||
E-Mail-<span className="text-primary-400">Sorter</span>
|
||||
</span>
|
||||
</Link>
|
||||
<p className="text-sm text-slate-400 mb-6">
|
||||
@@ -132,6 +132,11 @@ export function Footer() {
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/privacy-security" className="hover:text-white transition-colors">
|
||||
Privacy & Security
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/imprint" className="hover:text-white transition-colors">
|
||||
Impressum
|
||||
|
||||
@@ -37,35 +37,35 @@ export function Hero() {
|
||||
</Badge>
|
||||
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-extrabold text-white leading-tight mb-6">
|
||||
Your inbox.
|
||||
Clean inbox automatically
|
||||
<br />
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-accent-400 to-primary-300">
|
||||
Finally organized.
|
||||
in minutes.
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-lg sm:text-xl text-slate-300 mb-8 max-w-xl mx-auto lg:mx-0">
|
||||
EmailSorter uses AI to automatically categorize your emails.
|
||||
Newsletters, invoices, important contacts – everything lands
|
||||
exactly where it belongs.
|
||||
Create smart rules, apply in one click, keep it clean with automation.
|
||||
Stop drowning in emails and start focusing on what matters.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center lg:justify-start mb-8">
|
||||
<Button
|
||||
size="xl"
|
||||
onClick={handleCTAClick}
|
||||
className="group"
|
||||
onClick={() => navigate('/setup?demo=true')}
|
||||
className="group bg-accent-500 hover:bg-accent-600"
|
||||
>
|
||||
Start 14-day free trial
|
||||
<ArrowRight className="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" />
|
||||
<Sparkles className="w-5 h-5 mr-2" />
|
||||
Try Demo
|
||||
</Button>
|
||||
<Button
|
||||
size="xl"
|
||||
onClick={handleCTAClick}
|
||||
variant="outline"
|
||||
className="bg-white/10 border-white/20 text-white hover:bg-white/20"
|
||||
onClick={() => document.getElementById('how-it-works')?.scrollIntoView({ behavior: 'smooth' })}
|
||||
className="bg-white/10 border-white/20 text-white hover:bg-white/20 group"
|
||||
>
|
||||
See how it works
|
||||
Connect inbox
|
||||
<ArrowRight className="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export function Navbar() {
|
||||
}, [location.pathname, navigate])
|
||||
|
||||
return (
|
||||
<nav className="fixed top-0 left-0 right-0 z-50 bg-white/80 backdrop-blur-lg border-b border-slate-100">
|
||||
<nav className="fixed top-0 left-0 right-0 z-50 bg-white/80 dark:bg-slate-900/80 backdrop-blur-lg border-b border-slate-100 dark:border-slate-700">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Logo */}
|
||||
@@ -36,8 +36,8 @@ export function Navbar() {
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center">
|
||||
<Mail className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="text-xl font-bold text-slate-900">
|
||||
Email<span className="text-primary-600">Sorter</span>
|
||||
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
E-Mail-<span className="text-primary-600 dark:text-primary-400">Sorter</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
@@ -45,25 +45,25 @@ export function Navbar() {
|
||||
<div className="hidden md:flex items-center gap-8">
|
||||
<button
|
||||
onClick={() => scrollToSection('features')}
|
||||
className="text-slate-600 hover:text-slate-900 font-medium transition-colors"
|
||||
className="text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-slate-100 font-medium transition-colors"
|
||||
>
|
||||
Features
|
||||
</button>
|
||||
<button
|
||||
onClick={() => scrollToSection('how-it-works')}
|
||||
className="text-slate-600 hover:text-slate-900 font-medium transition-colors"
|
||||
className="text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-slate-100 font-medium transition-colors"
|
||||
>
|
||||
How it works
|
||||
</button>
|
||||
<button
|
||||
onClick={() => scrollToSection('pricing')}
|
||||
className="text-slate-600 hover:text-slate-900 font-medium transition-colors"
|
||||
className="text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-slate-100 font-medium transition-colors"
|
||||
>
|
||||
Pricing
|
||||
</button>
|
||||
<button
|
||||
onClick={() => scrollToSection('faq')}
|
||||
className="text-slate-600 hover:text-slate-900 font-medium transition-colors"
|
||||
className="text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-slate-100 font-medium transition-colors"
|
||||
>
|
||||
FAQ
|
||||
</button>
|
||||
@@ -90,14 +90,14 @@ export function Navbar() {
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
className="md:hidden p-2.5 rounded-lg hover:bg-slate-100 active:bg-slate-200 touch-manipulation"
|
||||
className="md:hidden p-2.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 active:bg-slate-200 dark:active:bg-slate-700 touch-manipulation"
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
aria-label={isMenuOpen ? "Close menu" : "Open menu"}
|
||||
>
|
||||
{isMenuOpen ? (
|
||||
<X className="w-6 h-6 text-slate-600" />
|
||||
<X className="w-6 h-6 text-slate-600 dark:text-slate-300" />
|
||||
) : (
|
||||
<Menu className="w-6 h-6 text-slate-600" />
|
||||
<Menu className="w-6 h-6 text-slate-600 dark:text-slate-300" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -105,33 +105,33 @@ export function Navbar() {
|
||||
|
||||
{/* Mobile menu */}
|
||||
{isMenuOpen && (
|
||||
<div className="md:hidden bg-white border-t border-slate-100 shadow-lg animate-in slide-in-from-top-2 duration-200">
|
||||
<div className="md:hidden bg-white dark:bg-slate-900 border-t border-slate-100 dark:border-slate-700 shadow-lg animate-in slide-in-from-top-2 duration-200">
|
||||
<div className="px-3 py-3 space-y-1">
|
||||
<button
|
||||
onClick={() => scrollToSection('features')}
|
||||
className="block w-full text-left px-4 py-3 text-sm font-medium text-slate-700 hover:bg-slate-50 active:bg-slate-100 rounded-lg transition-colors touch-manipulation"
|
||||
className="block w-full text-left px-4 py-3 text-sm font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-800 active:bg-slate-100 dark:active:bg-slate-700 rounded-lg transition-colors touch-manipulation"
|
||||
>
|
||||
Features
|
||||
</button>
|
||||
<button
|
||||
onClick={() => scrollToSection('how-it-works')}
|
||||
className="block w-full text-left px-4 py-3 text-sm font-medium text-slate-700 hover:bg-slate-50 active:bg-slate-100 rounded-lg transition-colors touch-manipulation"
|
||||
className="block w-full text-left px-4 py-3 text-sm font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-800 active:bg-slate-100 dark:active:bg-slate-700 rounded-lg transition-colors touch-manipulation"
|
||||
>
|
||||
How it works
|
||||
</button>
|
||||
<button
|
||||
onClick={() => scrollToSection('pricing')}
|
||||
className="block w-full text-left px-4 py-3 text-sm font-medium text-slate-700 hover:bg-slate-50 active:bg-slate-100 rounded-lg transition-colors touch-manipulation"
|
||||
className="block w-full text-left px-4 py-3 text-sm font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-800 active:bg-slate-100 dark:active:bg-slate-700 rounded-lg transition-colors touch-manipulation"
|
||||
>
|
||||
Pricing
|
||||
</button>
|
||||
<button
|
||||
onClick={() => scrollToSection('faq')}
|
||||
className="block w-full text-left px-4 py-3 text-sm font-medium text-slate-700 hover:bg-slate-50 active:bg-slate-100 rounded-lg transition-colors touch-manipulation"
|
||||
className="block w-full text-left px-4 py-3 text-sm font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-800 active:bg-slate-100 dark:active:bg-slate-700 rounded-lg transition-colors touch-manipulation"
|
||||
>
|
||||
FAQ
|
||||
</button>
|
||||
<div className="pt-3 mt-3 border-t border-slate-100 space-y-2">
|
||||
<div className="pt-3 mt-3 border-t border-slate-100 dark:border-slate-700 space-y-2">
|
||||
{user ? (
|
||||
<Button className="w-full h-11" onClick={() => navigate('/dashboard')}>
|
||||
Dashboard
|
||||
|
||||
@@ -8,16 +8,16 @@ const badgeVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary-100 text-primary-700",
|
||||
"border-transparent bg-primary-100 dark:bg-primary-900 text-primary-700 dark:text-primary-200",
|
||||
secondary:
|
||||
"border-transparent bg-slate-100 text-slate-700",
|
||||
"border-transparent bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-200",
|
||||
success:
|
||||
"border-transparent bg-green-100 text-green-700",
|
||||
"border-transparent bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200",
|
||||
warning:
|
||||
"border-transparent bg-amber-100 text-amber-700",
|
||||
"border-transparent bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-200",
|
||||
destructive:
|
||||
"border-transparent bg-red-100 text-red-700",
|
||||
outline: "text-slate-600 border-slate-200",
|
||||
"border-transparent bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-200",
|
||||
outline: "text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-700",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -4,22 +4,22 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-semibold ring-offset-white transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-semibold ring-offset-white dark:ring-offset-slate-900 transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary-600 text-white hover:bg-primary-700 shadow-lg shadow-primary-600/25 hover:shadow-primary-600/40",
|
||||
"bg-primary-600 text-white hover:bg-primary-700 shadow-lg shadow-primary-600/25 hover:shadow-primary-600/40 dark:bg-primary-500 dark:hover:bg-primary-400",
|
||||
secondary:
|
||||
"bg-slate-100 text-slate-900 hover:bg-slate-200",
|
||||
"bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700",
|
||||
outline:
|
||||
"border-2 border-slate-200 bg-transparent hover:bg-slate-50 hover:border-slate-300",
|
||||
"border-2 border-slate-200 bg-transparent hover:bg-slate-50 hover:border-slate-300 dark:border-slate-700 dark:text-slate-100 dark:hover:bg-slate-800 dark:hover:border-slate-600",
|
||||
ghost:
|
||||
"hover:bg-slate-100 hover:text-slate-900",
|
||||
"hover:bg-slate-100 hover:text-slate-900 dark:hover:bg-slate-800 dark:hover:text-slate-100",
|
||||
link:
|
||||
"text-primary-600 underline-offset-4 hover:underline",
|
||||
"text-primary-600 underline-offset-4 hover:underline dark:text-primary-400 dark:hover:text-primary-300",
|
||||
accent:
|
||||
"bg-accent-500 text-white hover:bg-accent-600 shadow-lg shadow-accent-500/25",
|
||||
"bg-accent-500 text-white hover:bg-accent-600 shadow-lg shadow-accent-500/25 dark:bg-accent-500 dark:hover:bg-accent-400",
|
||||
},
|
||||
size: {
|
||||
default: "h-11 px-6 py-2",
|
||||
|
||||
@@ -8,7 +8,7 @@ const Card = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-2xl border border-slate-200 bg-white shadow-sm transition-shadow hover:shadow-md",
|
||||
"rounded-2xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 shadow-sm dark:shadow-slate-900/20 transition-shadow hover:shadow-md dark:hover:shadow-slate-900/30",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -35,7 +35,7 @@ const CardTitle = React.forwardRef<
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-bold leading-none tracking-tight text-slate-900",
|
||||
"text-2xl font-bold leading-none tracking-tight text-slate-900 dark:text-slate-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -49,7 +49,7 @@ const CardDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-slate-500", className)}
|
||||
className={cn("text-sm text-slate-500 dark:text-slate-400", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -13,7 +13,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm text-slate-900 transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:border-primary-500 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-11 w-full rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 px-4 py-2 text-sm text-slate-900 dark:text-slate-100 transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-400 dark:placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:border-primary-500 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
error && "border-red-500 focus-visible:ring-red-500",
|
||||
className
|
||||
)}
|
||||
|
||||
148
client/src/components/ui/side-panel.tsx
Normal file
148
client/src/components/ui/side-panel.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const SidePanel = DialogPrimitive.Root
|
||||
|
||||
const SidePanelTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const SidePanelPortal = DialogPrimitive.Portal
|
||||
|
||||
const SidePanelClose = DialogPrimitive.Close
|
||||
|
||||
const SidePanelOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 dark:bg-black/70 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SidePanelOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const SidePanelContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SidePanelPortal>
|
||||
<SidePanelOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed z-50 gap-4 bg-white dark:bg-slate-900 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-right-full",
|
||||
"top-0 right-0 h-full w-full sm:w-[480px] border-l border-slate-200 dark:border-slate-700",
|
||||
"flex flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DialogPrimitive.Content>
|
||||
</SidePanelPortal>
|
||||
))
|
||||
SidePanelContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const SidePanelHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 p-6 border-b border-slate-200 dark:border-slate-700",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SidePanelHeader.displayName = "SidePanelHeader"
|
||||
|
||||
const SidePanelTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-xl font-semibold leading-none tracking-tight text-slate-900 dark:text-slate-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SidePanelTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const SidePanelDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-slate-500 dark:text-slate-400", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SidePanelDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
const SidePanelCloseButton = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white dark:ring-offset-slate-900 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-slate-100 dark:data-[state=open]:bg-slate-800",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4 text-slate-500 dark:text-slate-400" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
))
|
||||
SidePanelCloseButton.displayName = "SidePanelCloseButton"
|
||||
|
||||
const SidePanelBody = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("flex-1 overflow-y-auto p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SidePanelBody.displayName = "SidePanelBody"
|
||||
|
||||
const SidePanelFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 p-6 border-t border-slate-200 dark:border-slate-700",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SidePanelFooter.displayName = "SidePanelFooter"
|
||||
|
||||
export {
|
||||
SidePanel,
|
||||
SidePanelPortal,
|
||||
SidePanelOverlay,
|
||||
SidePanelTrigger,
|
||||
SidePanelClose,
|
||||
SidePanelContent,
|
||||
SidePanelHeader,
|
||||
SidePanelTitle,
|
||||
SidePanelDescription,
|
||||
SidePanelCloseButton,
|
||||
SidePanelBody,
|
||||
SidePanelFooter,
|
||||
}
|
||||
41
client/src/components/ui/slider.tsx
Normal file
41
client/src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface SliderProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
||||
value?: number
|
||||
onValueChange?: (value: number) => void
|
||||
}
|
||||
|
||||
const Slider = React.forwardRef<HTMLInputElement, SliderProps>(
|
||||
({ className, value, onValueChange, min = 0, max = 365, step = 1, ...props }, ref) => {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = parseInt(e.target.value)
|
||||
onValueChange?.(newValue)
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
type="range"
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"w-full h-2 bg-slate-200 dark:bg-slate-700 rounded-lg appearance-none cursor-pointer",
|
||||
"accent-primary-500 dark:accent-primary-600",
|
||||
"[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary-500 [&::-webkit-slider-thumb]:cursor-pointer",
|
||||
"dark:[&::-webkit-slider-thumb]:bg-primary-600",
|
||||
"[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary-500 [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:cursor-pointer",
|
||||
"dark:[&::-moz-range-thumb]:bg-primary-600",
|
||||
className
|
||||
)}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Slider.displayName = "Slider"
|
||||
|
||||
export { Slider }
|
||||
52
client/src/components/ui/tabs.tsx
Normal file
52
client/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-lg bg-slate-100 dark:bg-slate-800 p-1 text-slate-500 dark:text-slate-400",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-white dark:ring-offset-slate-900 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white dark:data-[state=active]:bg-slate-700 data-[state=active]:text-slate-900 dark:data-[state=active]:text-slate-100 data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-white dark:ring-offset-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
186
client/src/hooks/useTheme.ts
Normal file
186
client/src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Theme Hook
|
||||
* Handles dark mode detection and Dark Reader compatibility
|
||||
* Uses MutationObserver for efficient Dark Reader detection
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export function useTheme() {
|
||||
const [isDark, setIsDark] = useState(false)
|
||||
const [hasDarkReader, setHasDarkReader] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const html = document.documentElement
|
||||
|
||||
// Helper function to apply/remove dark mode
|
||||
const applyDarkMode = (shouldBeDark: boolean) => {
|
||||
setIsDark(shouldBeDark)
|
||||
if (shouldBeDark) {
|
||||
html.classList.add('dark')
|
||||
html.setAttribute('data-theme', 'dark')
|
||||
} else {
|
||||
html.classList.remove('dark')
|
||||
html.setAttribute('data-theme', 'light')
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced Dark Reader detection with multiple methods
|
||||
const detectDarkReader = (): boolean => {
|
||||
// Method 1: Check for Dark Reader data attributes on html element
|
||||
const hasDarkReaderAttributes =
|
||||
html.hasAttribute('data-darkreader-mode') ||
|
||||
html.hasAttribute('data-darkreader-scheme') ||
|
||||
html.hasAttribute('data-darkreader-policy')
|
||||
|
||||
// Method 2: Check for Dark Reader stylesheet or meta tags
|
||||
const hasDarkReaderMeta =
|
||||
document.querySelector('meta[name="darkreader"]') !== null ||
|
||||
document.querySelector('style[data-darkreader]') !== null
|
||||
|
||||
// Method 3: Check computed styles for filter/invert (Dark Reader uses CSS filters)
|
||||
const computedStyle = window.getComputedStyle(html)
|
||||
const hasFilter = computedStyle.filter && computedStyle.filter !== 'none'
|
||||
const hasInvert = computedStyle.filter?.includes('invert') ||
|
||||
computedStyle.filter?.includes('brightness')
|
||||
|
||||
// Method 4: Check for Dark Reader's characteristic background color
|
||||
// Dark Reader often sets a specific dark background
|
||||
const bgColor = computedStyle.backgroundColor
|
||||
const isDarkReaderBg = bgColor === 'rgb(24, 26, 27)' ||
|
||||
bgColor === 'rgb(18, 18, 18)' ||
|
||||
(window.matchMedia('(prefers-color-scheme: dark)').matches &&
|
||||
bgColor !== 'rgba(0, 0, 0, 0)' &&
|
||||
bgColor !== 'transparent' &&
|
||||
!html.classList.contains('dark'))
|
||||
|
||||
// Method 5: Check for Dark Reader injected styles
|
||||
const styleSheets = Array.from(document.styleSheets)
|
||||
const hasDarkReaderStylesheet = styleSheets.some(sheet => {
|
||||
try {
|
||||
const href = sheet.href || ''
|
||||
return href.includes('darkreader') ||
|
||||
(sheet.ownerNode as Element)?.getAttribute('data-darkreader') !== null
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
return hasDarkReaderAttributes ||
|
||||
hasDarkReaderMeta ||
|
||||
(hasFilter && hasInvert) ||
|
||||
isDarkReaderBg ||
|
||||
hasDarkReaderStylesheet
|
||||
}
|
||||
|
||||
// Check system preference
|
||||
const checkSystemPreference = (): boolean => {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
|
||||
// Update theme based on current state
|
||||
const updateTheme = () => {
|
||||
const darkReaderDetected = detectDarkReader()
|
||||
const systemPrefersDark = checkSystemPreference()
|
||||
|
||||
setHasDarkReader(darkReaderDetected)
|
||||
|
||||
// Only apply dark mode if system prefers it AND Dark Reader is not active
|
||||
if (systemPrefersDark && !darkReaderDetected) {
|
||||
applyDarkMode(true)
|
||||
} else {
|
||||
applyDarkMode(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Initial check
|
||||
updateTheme()
|
||||
|
||||
// Listen for system preference changes
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const handleSystemPreferenceChange = (e: MediaQueryListEvent) => {
|
||||
updateTheme()
|
||||
}
|
||||
|
||||
// Modern browsers
|
||||
if (mediaQuery.addEventListener) {
|
||||
mediaQuery.addEventListener('change', handleSystemPreferenceChange)
|
||||
} else {
|
||||
// Fallback for older browsers
|
||||
mediaQuery.addListener(handleSystemPreferenceChange)
|
||||
}
|
||||
|
||||
// MutationObserver for Dark Reader attribute changes (more efficient than setInterval)
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
let shouldUpdate = false
|
||||
|
||||
mutations.forEach((mutation) => {
|
||||
// Check if Dark Reader attributes were added/removed
|
||||
if (mutation.type === 'attributes') {
|
||||
const attrName = mutation.attributeName
|
||||
if (attrName?.startsWith('data-darkreader') ||
|
||||
attrName === 'class' ||
|
||||
attrName === 'data-theme') {
|
||||
shouldUpdate = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check if Dark Reader elements were added/removed
|
||||
if (mutation.type === 'childList') {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = node as Element
|
||||
if (el.hasAttribute?.('data-darkreader') ||
|
||||
el.tagName === 'META' && el.getAttribute('name') === 'darkreader' ||
|
||||
el.tagName === 'STYLE' && el.hasAttribute('data-darkreader')) {
|
||||
shouldUpdate = true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (shouldUpdate) {
|
||||
updateTheme()
|
||||
}
|
||||
})
|
||||
|
||||
// Observe html element for Dark Reader attribute changes
|
||||
observer.observe(html, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-darkreader-mode', 'data-darkreader-scheme', 'data-darkreader-policy', 'class', 'data-theme'],
|
||||
childList: true,
|
||||
subtree: false
|
||||
})
|
||||
|
||||
// Also observe document head for Dark Reader meta/stylesheets
|
||||
if (document.head) {
|
||||
observer.observe(document.head, {
|
||||
childList: true,
|
||||
subtree: false
|
||||
})
|
||||
}
|
||||
|
||||
// Fallback: Periodic check (reduced frequency, only as safety net)
|
||||
// This catches edge cases where MutationObserver might miss something
|
||||
const fallbackInterval = setInterval(() => {
|
||||
const currentDarkReader = detectDarkReader()
|
||||
if (currentDarkReader !== hasDarkReader) {
|
||||
updateTheme()
|
||||
}
|
||||
}, 5000) // Check every 5 seconds (reduced from 2 seconds)
|
||||
|
||||
return () => {
|
||||
// Cleanup
|
||||
if (mediaQuery.removeEventListener) {
|
||||
mediaQuery.removeEventListener('change', handleSystemPreferenceChange)
|
||||
} else {
|
||||
mediaQuery.removeListener(handleSystemPreferenceChange)
|
||||
}
|
||||
observer.disconnect()
|
||||
clearInterval(fallbackInterval)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { isDark, hasDarkReader }
|
||||
}
|
||||
@@ -24,7 +24,7 @@
|
||||
--color-accent-500: #10b981;
|
||||
--color-accent-600: #059669;
|
||||
|
||||
/* Neutral/Slate colors */
|
||||
/* Neutral/Slate colors - Keep original values for Tailwind compatibility */
|
||||
--color-slate-50: #f8fafc;
|
||||
--color-slate-100: #f1f5f9;
|
||||
--color-slate-200: #e2e8f0;
|
||||
@@ -38,6 +38,49 @@
|
||||
--color-slate-950: #020617;
|
||||
}
|
||||
|
||||
/* Dark Mode specific color variables for optimized appearance */
|
||||
:root.dark,
|
||||
:root[data-theme="dark"] {
|
||||
/* Optimized dark mode colors - not pure black, more pleasant */
|
||||
--color-bg-dark: #1e293b; /* slate-800 - pleasant dark background */
|
||||
--color-bg-card-dark: #334155; /* slate-700 - cards stand out from background */
|
||||
--color-text-dark: #f1f5f9; /* slate-100 - soft white, not pure white */
|
||||
--color-text-muted-dark: #cbd5e1; /* slate-300 - muted text */
|
||||
--color-border-dark: #475569; /* slate-600 - visible but subtle borders */
|
||||
--color-accent-dark: #4ade80; /* primary-400 - slightly desaturated for dark mode */
|
||||
}
|
||||
|
||||
/* Prevent double inversion when Dark Reader is active - Force Light Mode */
|
||||
:root[data-darkreader-mode],
|
||||
:root[data-darkreader-scheme],
|
||||
:root[data-darkreader-policy] {
|
||||
/* Explicitly remove dark mode classes and force light theme */
|
||||
color-scheme: light !important;
|
||||
}
|
||||
|
||||
:root[data-darkreader-mode] body,
|
||||
:root[data-darkreader-scheme] body,
|
||||
:root[data-darkreader-policy] body {
|
||||
background-color: var(--color-slate-50) !important;
|
||||
color: var(--color-slate-900) !important;
|
||||
}
|
||||
|
||||
/* Prevent Dark Reader from applying dark mode when it's active */
|
||||
:root[data-darkreader-mode] .dark,
|
||||
:root[data-darkreader-scheme] .dark,
|
||||
:root[data-darkreader-policy] .dark {
|
||||
/* Force light mode styles even if dark class is present */
|
||||
background-color: var(--color-slate-50) !important;
|
||||
color: var(--color-slate-900) !important;
|
||||
}
|
||||
|
||||
:root[data-darkreader-mode] *,
|
||||
:root[data-darkreader-scheme] *,
|
||||
:root[data-darkreader-policy] * {
|
||||
/* Prevent Dark Reader from inverting our colors */
|
||||
filter: none !important;
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
@@ -50,6 +93,17 @@ body {
|
||||
/* Improve touch scrolling on mobile */
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overflow-scrolling: touch;
|
||||
/* Base colors - Tailwind will handle dark mode */
|
||||
background-color: var(--color-slate-50);
|
||||
color: var(--color-slate-900);
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Dark mode body adjustments */
|
||||
:root.dark body,
|
||||
:root[data-theme="dark"] body {
|
||||
background-color: var(--color-slate-900);
|
||||
color: var(--color-slate-50);
|
||||
}
|
||||
|
||||
/* Improve touch targets on mobile */
|
||||
@@ -65,6 +119,11 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
:root.dark *,
|
||||
:root[data-theme="dark"] * {
|
||||
-webkit-tap-highlight-color: rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
/* Touch manipulation for better performance */
|
||||
.touch-manipulation {
|
||||
touch-action: manipulation;
|
||||
@@ -79,6 +138,12 @@ body {
|
||||
color: var(--color-primary-900);
|
||||
}
|
||||
|
||||
:root.dark ::selection,
|
||||
:root[data-theme="dark"] ::selection {
|
||||
background-color: var(--color-primary-600);
|
||||
color: var(--color-primary-50);
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
@@ -98,11 +163,33 @@ body {
|
||||
background: var(--color-slate-400);
|
||||
}
|
||||
|
||||
/* Dark mode scrollbar - optimized colors */
|
||||
:root.dark ::-webkit-scrollbar-track,
|
||||
:root[data-theme="dark"] ::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-card-dark, var(--color-slate-700));
|
||||
}
|
||||
|
||||
:root.dark ::-webkit-scrollbar-thumb,
|
||||
:root[data-theme="dark"] ::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-dark, var(--color-slate-600));
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
:root.dark ::-webkit-scrollbar-thumb:hover,
|
||||
:root[data-theme="dark"] ::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-slate-500);
|
||||
}
|
||||
|
||||
/* Gradient backgrounds */
|
||||
.gradient-hero {
|
||||
background: linear-gradient(135deg, var(--color-slate-900) 0%, var(--color-primary-900) 50%, var(--color-slate-800) 100%);
|
||||
}
|
||||
|
||||
:root.dark .gradient-hero,
|
||||
:root[data-theme="dark"] .gradient-hero {
|
||||
background: linear-gradient(135deg, var(--color-slate-950) 0%, var(--color-primary-950) 50%, var(--color-slate-900) 100%);
|
||||
}
|
||||
|
||||
.gradient-mesh {
|
||||
background-image:
|
||||
radial-gradient(at 40% 20%, var(--color-primary-500) 0px, transparent 50%),
|
||||
@@ -112,6 +199,17 @@ body {
|
||||
radial-gradient(at 0% 100%, var(--color-primary-600) 0px, transparent 50%);
|
||||
}
|
||||
|
||||
:root.dark .gradient-mesh,
|
||||
:root[data-theme="dark"] .gradient-mesh {
|
||||
background-image:
|
||||
radial-gradient(at 40% 20%, rgba(34, 197, 94, 0.15) 0px, transparent 50%),
|
||||
radial-gradient(at 80% 0%, rgba(16, 185, 129, 0.15) 0px, transparent 50%),
|
||||
radial-gradient(at 0% 50%, rgba(22, 163, 74, 0.15) 0px, transparent 50%),
|
||||
radial-gradient(at 80% 50%, rgba(52, 211, 153, 0.15) 0px, transparent 50%),
|
||||
radial-gradient(at 0% 100%, rgba(16, 185, 129, 0.15) 0px, transparent 50%);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Animation classes */
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px); }
|
||||
@@ -142,3 +240,43 @@ body {
|
||||
.stagger-3 { animation-delay: 0.3s; }
|
||||
.stagger-4 { animation-delay: 0.4s; }
|
||||
.stagger-5 { animation-delay: 0.5s; }
|
||||
|
||||
/* Dark mode specific adjustments */
|
||||
:root.dark,
|
||||
:root[data-theme="dark"] {
|
||||
/* Ensure good contrast for focus states */
|
||||
--focus-ring-color: var(--color-accent-dark, var(--color-primary-400));
|
||||
--focus-ring-offset: var(--color-bg-dark, var(--color-slate-800));
|
||||
}
|
||||
|
||||
/* Links in dark mode - optimized for visibility and comfort */
|
||||
:root.dark a:not([class*="text-"]),
|
||||
:root[data-theme="dark"] a:not([class*="text-"]) {
|
||||
color: var(--color-accent-dark, var(--color-primary-400));
|
||||
}
|
||||
|
||||
:root.dark a:not([class*="text-"]):hover,
|
||||
:root[data-theme="dark"] a:not([class*="text-"]):hover {
|
||||
color: var(--color-primary-300);
|
||||
}
|
||||
|
||||
/* Borders in dark mode - subtle but visible with better contrast */
|
||||
:root.dark hr:not([class*="border-"]),
|
||||
:root[data-theme="dark"] hr:not([class*="border-"]) {
|
||||
border-color: var(--color-border-dark, var(--color-slate-600));
|
||||
}
|
||||
|
||||
/* Code blocks and pre elements - better contrast */
|
||||
:root.dark code:not([class]),
|
||||
:root[data-theme="dark"] code:not([class]),
|
||||
:root.dark pre:not([class]),
|
||||
:root[data-theme="dark"] pre:not([class]) {
|
||||
background-color: var(--color-bg-card-dark, var(--color-slate-700));
|
||||
color: var(--color-text-dark, var(--color-slate-100));
|
||||
border-color: var(--color-border-dark, var(--color-slate-600));
|
||||
}
|
||||
|
||||
/* Prevent transitions on initial load to avoid FOUC */
|
||||
html:not(.dark-mode-initialized) body {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@@ -15,13 +15,15 @@ export interface TrackingParams {
|
||||
}
|
||||
|
||||
export interface ConversionEvent {
|
||||
type: 'page_view' | 'signup' | 'trial_start' | 'purchase' | 'email_connected'
|
||||
type: 'page_view' | 'signup' | 'trial_start' | 'purchase' | 'email_connected' | 'onboarding_step' | 'provider_connected' | 'demo_used' | 'suggested_rules_generated' | 'rule_created' | 'rules_applied' | 'limit_reached' | 'upgrade_clicked' | 'referral_shared' | 'sort_completed' | 'account_deleted'
|
||||
userId?: string
|
||||
metadata?: Record<string, any>
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'emailsorter_utm_params'
|
||||
const USER_ID_KEY = 'emailsorter_user_id'
|
||||
const SESSION_ID_KEY = 'emailsorter_session_id'
|
||||
|
||||
/**
|
||||
* Parse UTM parameters from URL
|
||||
@@ -152,11 +154,11 @@ export async function trackEvent(
|
||||
const payload = {
|
||||
...event,
|
||||
userId: event.userId || userId || undefined,
|
||||
sessionId: event.sessionId || getSessionId(),
|
||||
tracking: params,
|
||||
timestamp: new Date().toISOString(),
|
||||
page: window.location.pathname,
|
||||
referrer: document.referrer || undefined,
|
||||
userAgent: navigator.userAgent,
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -312,3 +314,133 @@ export function getTrackingQueryString(): string {
|
||||
? '&' + new URLSearchParams(entries as string[][]).toString()
|
||||
: ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create session ID
|
||||
*/
|
||||
function getSessionId(): string {
|
||||
try {
|
||||
let sessionId = sessionStorage.getItem(SESSION_ID_KEY)
|
||||
if (!sessionId) {
|
||||
sessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
sessionStorage.setItem(SESSION_ID_KEY, sessionId)
|
||||
}
|
||||
return sessionId
|
||||
} catch {
|
||||
// Fallback if sessionStorage is not available
|
||||
return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track onboarding step
|
||||
*/
|
||||
export function trackOnboardingStep(userId: string, step: string): void {
|
||||
trackEvent({
|
||||
type: 'onboarding_step',
|
||||
userId,
|
||||
metadata: {
|
||||
step,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Track provider connection
|
||||
*/
|
||||
export function trackProviderConnected(userId: string, provider: string): void {
|
||||
trackEvent({
|
||||
type: 'provider_connected',
|
||||
userId,
|
||||
metadata: {
|
||||
provider,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Track demo account usage
|
||||
*/
|
||||
export function trackDemoUsed(userId: string): void {
|
||||
trackEvent({
|
||||
type: 'demo_used',
|
||||
userId,
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Track sort completion
|
||||
*/
|
||||
export function trackSortCompleted(userId: string, sortedCount: number, isFirstRun: boolean): void {
|
||||
trackEvent({
|
||||
type: 'sort_completed',
|
||||
userId,
|
||||
metadata: {
|
||||
sortedCount,
|
||||
isFirstRun,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Track limit reached
|
||||
*/
|
||||
export function trackLimitReached(userId: string, limit: number, used: number): void {
|
||||
trackEvent({
|
||||
type: 'limit_reached',
|
||||
userId,
|
||||
metadata: {
|
||||
limit,
|
||||
used,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Track rules applied
|
||||
*/
|
||||
export function trackRulesApplied(userId: string, rulesCount: number): void {
|
||||
trackEvent({
|
||||
type: 'rules_applied',
|
||||
userId,
|
||||
metadata: {
|
||||
rulesCount,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Track upgrade clicked
|
||||
*/
|
||||
export function trackUpgradeClicked(userId: string, source: string): void {
|
||||
trackEvent({
|
||||
type: 'upgrade_clicked',
|
||||
userId,
|
||||
metadata: {
|
||||
source,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Track referral shared
|
||||
*/
|
||||
export function trackReferralShared(userId: string, referralCode: string): void {
|
||||
trackEvent({
|
||||
type: 'referral_shared',
|
||||
userId,
|
||||
metadata: {
|
||||
referralCode,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ interface ApiResponse<T> {
|
||||
code: string
|
||||
message: string
|
||||
fields?: Record<string, string[]>
|
||||
limit?: number
|
||||
used?: number
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +99,14 @@ export const api = {
|
||||
suggestions: Array<{ type: string; message: string }>
|
||||
provider?: string
|
||||
isDemo?: boolean
|
||||
isFirstRun?: boolean
|
||||
suggestedRules?: Array<{
|
||||
type: string
|
||||
name: string
|
||||
description: string
|
||||
confidence: number
|
||||
action: any
|
||||
}>
|
||||
}>('/email/sort', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId, accountId, maxEmails, processAll }),
|
||||
@@ -205,6 +215,9 @@ export const api = {
|
||||
return fetchApi<{
|
||||
status: string
|
||||
plan: string
|
||||
isFreeTier: boolean
|
||||
emailsUsedThisMonth?: number
|
||||
emailsLimit?: number
|
||||
features: {
|
||||
emailAccounts: number
|
||||
emailsPerDay: number
|
||||
@@ -279,6 +292,9 @@ export const api = {
|
||||
enabledCategories: string[]
|
||||
categoryActions: Record<string, 'inbox' | 'archive_read' | 'star'>
|
||||
autoDetectCompanies: boolean
|
||||
cleanup?: any
|
||||
categoryAdvanced?: Record<string, any>
|
||||
version?: number
|
||||
}>(`/preferences/ai-control?userId=${userId}`)
|
||||
},
|
||||
|
||||
@@ -286,6 +302,9 @@ export const api = {
|
||||
enabledCategories?: string[]
|
||||
categoryActions?: Record<string, 'inbox' | 'archive_read' | 'star'>
|
||||
autoDetectCompanies?: boolean
|
||||
cleanup?: any
|
||||
categoryAdvanced?: Record<string, any>
|
||||
version?: number
|
||||
}) {
|
||||
return fetchApi<{ success: boolean }>('/preferences/ai-control', {
|
||||
method: 'POST',
|
||||
@@ -293,6 +312,57 @@ export const api = {
|
||||
})
|
||||
},
|
||||
|
||||
// Cleanup Preview - shows what would be cleaned up without actually doing it
|
||||
// TODO: Backend endpoint needs to be implemented
|
||||
// GET /api/preferences/ai-control/cleanup/preview?userId=xxx
|
||||
// Response: { preview: Array<{id, subject, from, date, reason}> }
|
||||
async getCleanupPreview(userId: string) {
|
||||
// TODO: Implement backend endpoint
|
||||
return fetchApi<{
|
||||
preview: Array<{
|
||||
id: string
|
||||
subject: string
|
||||
from: string
|
||||
date: string
|
||||
reason: 'read' | 'promotion'
|
||||
}>
|
||||
}>(`/preferences/ai-control/cleanup/preview?userId=${userId}`)
|
||||
},
|
||||
|
||||
// Run cleanup now - executes cleanup for the user
|
||||
// POST /api/preferences/ai-control/cleanup/run
|
||||
// Body: { userId: string }
|
||||
// Response: { success: boolean, data: { readItems: number, promotions: number } }
|
||||
async runCleanup(userId: string) {
|
||||
// Uses existing /api/email/cleanup endpoint
|
||||
return fetchApi<{
|
||||
usersProcessed: number
|
||||
emailsProcessed: {
|
||||
readItems: number
|
||||
promotions: number
|
||||
}
|
||||
errors: Array<{ userId: string; error: string }>
|
||||
}>('/email/cleanup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId }),
|
||||
})
|
||||
},
|
||||
|
||||
// Get cleanup status - last run info and counts
|
||||
// TODO: Backend endpoint needs to be implemented
|
||||
// GET /api/preferences/ai-control/cleanup/status?userId=xxx
|
||||
// Response: { lastRun?: string, lastRunCounts?: { readItems: number, promotions: number } }
|
||||
async getCleanupStatus(userId: string) {
|
||||
// TODO: Implement backend endpoint
|
||||
return fetchApi<{
|
||||
lastRun?: string
|
||||
lastRunCounts?: {
|
||||
readItems: number
|
||||
promotions: number
|
||||
}
|
||||
}>(`/preferences/ai-control/cleanup/status?userId=${userId}`)
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// COMPANY LABELS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -386,6 +456,72 @@ export const api = {
|
||||
uptime: number
|
||||
}>('/health')
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// ONBOARDING
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async getOnboardingStatus(userId: string) {
|
||||
return fetchApi<{
|
||||
onboarding_step: string
|
||||
completedSteps: string[]
|
||||
first_value_seen_at?: string
|
||||
skipped_at?: string
|
||||
}>(`/onboarding/status?userId=${userId}`)
|
||||
},
|
||||
|
||||
async updateOnboardingStep(userId: string, step: string, completedSteps: string[] = []) {
|
||||
return fetchApi<{ step: string; completedSteps: string[] }>('/onboarding/step', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId, step, completedSteps }),
|
||||
})
|
||||
},
|
||||
|
||||
async skipOnboarding(userId: string) {
|
||||
return fetchApi<{ skipped: boolean }>('/onboarding/skip', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId }),
|
||||
})
|
||||
},
|
||||
|
||||
async resumeOnboarding(userId: string) {
|
||||
return fetchApi<{
|
||||
onboarding_step: string
|
||||
completedSteps: string[]
|
||||
}>('/onboarding/resume', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId }),
|
||||
})
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// ACCOUNT MANAGEMENT
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async deleteAccount(userId: string) {
|
||||
return fetchApi<{ success: boolean }>('/account/delete', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ userId }),
|
||||
})
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// REFERRALS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async getReferralCode(userId: string) {
|
||||
return fetchApi<{
|
||||
referralCode: string
|
||||
referralCount: number
|
||||
}>(`/referrals/code?userId=${userId}`)
|
||||
},
|
||||
|
||||
async trackReferral(userId: string, referralCode: string) {
|
||||
return fetchApi<{ success: boolean }>('/referrals/track', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId, referralCode }),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default api
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,11 +5,11 @@ export function Imprint() {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
{/* Header */}
|
||||
<header className="bg-white border-b border-slate-200">
|
||||
<header className="bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center gap-2 text-slate-600 hover:text-slate-900 transition-colors"
|
||||
className="inline-flex items-center gap-2 text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-slate-100 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">Back to Home</span>
|
||||
|
||||
@@ -41,7 +41,7 @@ export function Login() {
|
||||
<Mail className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="text-xl font-bold text-white">
|
||||
Email<span className="text-primary-400">Sorter</span>
|
||||
E-Mail-<span className="text-primary-400">Sorter</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@ export function Privacy() {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
{/* Header */}
|
||||
<header className="bg-white border-b border-slate-200">
|
||||
<header className="bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center gap-2 text-slate-600 hover:text-slate-900 transition-colors"
|
||||
className="inline-flex items-center gap-2 text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-slate-100 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">Back to Home</span>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
import { analytics } from '@/hooks/useAnalytics'
|
||||
import { captureUTMParams } from '@/lib/analytics'
|
||||
import { api } from '@/lib/api'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -12,6 +13,7 @@ import { Mail, Lock, User, ArrowRight, AlertCircle, Check, Sparkles } from 'luci
|
||||
export function Register() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const selectedPlan = searchParams.get('plan') || 'pro'
|
||||
const referralCode = searchParams.get('ref') || null
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
@@ -20,7 +22,7 @@ export function Register() {
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const { register } = useAuth()
|
||||
const { register, user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Capture UTM parameters on mount
|
||||
@@ -28,6 +30,22 @@ export function Register() {
|
||||
captureUTMParams()
|
||||
}, [])
|
||||
|
||||
// Track referral and signup after user is registered
|
||||
useEffect(() => {
|
||||
if (user?.$id && referralCode) {
|
||||
// Track referral if code exists
|
||||
api.trackReferral(user.$id, referralCode).catch((err) => {
|
||||
console.error('Failed to track referral:', err)
|
||||
})
|
||||
}
|
||||
|
||||
if (user?.$id) {
|
||||
// Track signup conversion with UTM parameters
|
||||
analytics.trackSignup(user.$id, email)
|
||||
analytics.setUserId(user.$id)
|
||||
}
|
||||
}, [user, referralCode, email])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
@@ -45,14 +63,7 @@ export function Register() {
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const user = await register(email, password, name)
|
||||
|
||||
// Track signup conversion with UTM parameters
|
||||
if (user?.$id) {
|
||||
analytics.trackSignup(user.$id, email)
|
||||
analytics.setUserId(user.$id)
|
||||
}
|
||||
|
||||
await register(email, password, name)
|
||||
navigate('/setup')
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Registration failed. Please try again.')
|
||||
@@ -111,7 +122,7 @@ export function Register() {
|
||||
<Mail className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="text-xl font-bold text-slate-900">
|
||||
Email<span className="text-primary-600">Sorter</span>
|
||||
E-Mail-<span className="text-primary-600">Sorter</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,9 @@ import { useAuth } from '@/context/AuthContext'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { OnboardingProgress } from '@/components/OnboardingProgress'
|
||||
import { api } from '@/lib/api'
|
||||
import { trackOnboardingStep, trackProviderConnected, trackDemoUsed } from '@/lib/analytics'
|
||||
import {
|
||||
Mail,
|
||||
ArrowRight,
|
||||
@@ -24,7 +26,6 @@ type Step = 'connect' | 'preferences' | 'categories' | 'complete'
|
||||
export function Setup() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const isFromCheckout = searchParams.get('subscription') === 'success'
|
||||
const autoSetup = searchParams.get('setup') === 'auto'
|
||||
|
||||
const [currentStep, setCurrentStep] = useState<Step>('connect')
|
||||
const [connectedProvider, setConnectedProvider] = useState<string | null>(null)
|
||||
@@ -40,9 +41,48 @@ export function Setup() {
|
||||
])
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [checkingAccounts, setCheckingAccounts] = useState(isFromCheckout)
|
||||
const [onboardingState, setOnboardingState] = useState<{
|
||||
onboarding_step: string
|
||||
completedSteps: string[]
|
||||
} | null>(null)
|
||||
const [loadingOnboarding, setLoadingOnboarding] = useState(true)
|
||||
|
||||
const { user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const resumeOnboarding = searchParams.get('resume') === 'true'
|
||||
|
||||
// Load onboarding state
|
||||
useEffect(() => {
|
||||
if (user?.$id) {
|
||||
const loadOnboarding = async () => {
|
||||
try {
|
||||
const stateRes = await api.getOnboardingStatus(user.$id)
|
||||
if (stateRes.data) {
|
||||
setOnboardingState(stateRes.data)
|
||||
|
||||
// If resuming, restore step
|
||||
if (resumeOnboarding && stateRes.data.onboarding_step !== 'completed' && stateRes.data.onboarding_step !== 'not_started') {
|
||||
const stepMap: Record<string, Step> = {
|
||||
'connect': 'connect',
|
||||
'first_rule': 'preferences',
|
||||
'see_results': 'categories',
|
||||
'auto_schedule': 'complete',
|
||||
}
|
||||
const mappedStep = stepMap[stateRes.data.onboarding_step]
|
||||
if (mappedStep) {
|
||||
setCurrentStep(mappedStep)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading onboarding state:', err)
|
||||
} finally {
|
||||
setLoadingOnboarding(false)
|
||||
}
|
||||
}
|
||||
loadOnboarding()
|
||||
}
|
||||
}, [user, resumeOnboarding])
|
||||
|
||||
// Check if user already has connected accounts after successful checkout
|
||||
useEffect(() => {
|
||||
@@ -82,11 +122,17 @@ export function Setup() {
|
||||
try {
|
||||
const response = await api.getOAuthUrl('gmail', user.$id)
|
||||
if (response.data?.url) {
|
||||
// Track onboarding step before redirect
|
||||
await api.updateOnboardingStep(user.$id, 'connect', ['connect'])
|
||||
window.location.href = response.data.url
|
||||
} else {
|
||||
setConnectedProvider('gmail')
|
||||
setConnectedEmail(user.email)
|
||||
setCurrentStep('preferences')
|
||||
// Track onboarding step
|
||||
await api.updateOnboardingStep(user.$id, 'first_rule', ['connect'])
|
||||
trackOnboardingStep(user.$id, 'first_rule')
|
||||
trackProviderConnected(user.$id, 'gmail')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Gmail connection failed. Please try again.')
|
||||
@@ -103,11 +149,15 @@ export function Setup() {
|
||||
try {
|
||||
const response = await api.getOAuthUrl('outlook', user.$id)
|
||||
if (response.data?.url) {
|
||||
// Track onboarding step before redirect
|
||||
await api.updateOnboardingStep(user.$id, 'connect', ['connect'])
|
||||
window.location.href = response.data.url
|
||||
} else {
|
||||
setConnectedProvider('outlook')
|
||||
setConnectedEmail(user.email)
|
||||
setCurrentStep('preferences')
|
||||
// Track onboarding step
|
||||
await api.updateOnboardingStep(user.$id, 'first_rule', ['connect'])
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Outlook connection failed. Please try again.')
|
||||
@@ -116,10 +166,54 @@ export function Setup() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
const handleConnectDemo = async () => {
|
||||
if (!user?.$id) return
|
||||
setConnecting('demo')
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await api.connectDemoAccount(user.$id)
|
||||
if (response.data) {
|
||||
setConnectedProvider('demo')
|
||||
setConnectedEmail(response.data.email)
|
||||
setCurrentStep('preferences')
|
||||
// Track onboarding step
|
||||
await api.updateOnboardingStep(user.$id, 'first_rule', ['connect'])
|
||||
trackOnboardingStep(user.$id, 'first_rule')
|
||||
trackDemoUsed(user.$id)
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Demo connection failed. Please try again.')
|
||||
} finally {
|
||||
setConnecting(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNext = async () => {
|
||||
const nextIndex = stepIndex + 1
|
||||
if (nextIndex < steps.length) {
|
||||
setCurrentStep(steps[nextIndex].id)
|
||||
const nextStep = steps[nextIndex].id
|
||||
setCurrentStep(nextStep)
|
||||
|
||||
// Track onboarding progress
|
||||
if (user?.$id) {
|
||||
const stepMap: Record<Step, string> = {
|
||||
'connect': 'connect',
|
||||
'preferences': 'first_rule',
|
||||
'categories': 'see_results',
|
||||
'complete': 'auto_schedule',
|
||||
}
|
||||
const onboardingStep = stepMap[nextStep]
|
||||
const completedSteps = onboardingState?.completedSteps || []
|
||||
if (onboardingStep && !completedSteps.includes(stepMap[currentStep])) {
|
||||
const newCompleted = [...completedSteps, stepMap[currentStep]]
|
||||
await api.updateOnboardingStep(user.$id, onboardingStep, newCompleted)
|
||||
setOnboardingState({
|
||||
onboarding_step: onboardingStep,
|
||||
completedSteps: newCompleted,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +238,9 @@ export function Setup() {
|
||||
customRules: [],
|
||||
priorityTopics: selectedCategories,
|
||||
})
|
||||
|
||||
// Mark onboarding as completed
|
||||
await api.updateOnboardingStep(user.$id, 'completed', ['connect', 'first_rule', 'see_results', 'auto_schedule'])
|
||||
} catch (err) {
|
||||
console.error('Failed to save preferences:', err)
|
||||
} finally {
|
||||
@@ -152,6 +249,18 @@ export function Setup() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSkipOnboarding = async () => {
|
||||
if (!user?.$id) return
|
||||
|
||||
try {
|
||||
await api.skipOnboarding(user.$id)
|
||||
navigate('/dashboard')
|
||||
} catch (err) {
|
||||
console.error('Failed to skip onboarding:', err)
|
||||
navigate('/dashboard')
|
||||
}
|
||||
}
|
||||
|
||||
const categories = [
|
||||
{ id: 'vip', name: 'Important / VIP', description: 'Priority contacts', icon: '⭐', color: 'bg-amber-500' },
|
||||
{ id: 'customers', name: 'Clients / Projects', description: 'Business correspondence', icon: '💼', color: 'bg-blue-500' },
|
||||
@@ -185,18 +294,18 @@ export function Setup() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white">
|
||||
<header className="bg-white/80 backdrop-blur-sm border-b border-slate-200 sticky top-0 z-40">
|
||||
<header className="bg-white/80 dark:bg-slate-900/80 backdrop-blur-sm border-b border-slate-200 dark:border-slate-700 sticky top-0 z-40">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center shadow-lg shadow-primary-500/20">
|
||||
<Mail className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="text-lg font-bold text-slate-900">
|
||||
Email<span className="text-primary-600">Sorter</span>
|
||||
<span className="text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||
E-Mail-<span className="text-primary-600 dark:text-primary-400">Sorter</span>
|
||||
</span>
|
||||
</Link>
|
||||
<Button variant="ghost" onClick={() => navigate('/dashboard')}>
|
||||
<Button variant="ghost" onClick={handleSkipOnboarding}>
|
||||
Skip
|
||||
</Button>
|
||||
</div>
|
||||
@@ -221,6 +330,18 @@ export function Setup() {
|
||||
)}
|
||||
|
||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
{/* Onboarding Progress */}
|
||||
{!loadingOnboarding && onboardingState && onboardingState.onboarding_step !== 'completed' && (
|
||||
<div className="mb-6">
|
||||
<OnboardingProgress
|
||||
currentStep={onboardingState.onboarding_step}
|
||||
completedSteps={onboardingState.completedSteps}
|
||||
totalSteps={4}
|
||||
onSkip={handleSkipOnboarding}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress */}
|
||||
<div className="mb-12">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@@ -272,7 +393,37 @@ 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={handleConnectDemo}
|
||||
disabled={connecting !== null}
|
||||
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 === 'demo' ? (
|
||||
<Loader2 className="w-12 h-12 animate-spin text-white" />
|
||||
) : (
|
||||
<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-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-white/80 group-hover:text-white group-hover:translate-x-1 transition-all" />
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-slate-300"></div>
|
||||
</div>
|
||||
<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}
|
||||
@@ -318,6 +469,7 @@ export function Setup() {
|
||||
<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">
|
||||
<p className="text-sm text-slate-500">
|
||||
|
||||
@@ -2,10 +2,60 @@
|
||||
* TypeScript types for Settings and AI Control
|
||||
*/
|
||||
|
||||
export interface CleanupReadItems {
|
||||
enabled: boolean
|
||||
action: 'archive_read' | 'trash'
|
||||
gracePeriodDays: number
|
||||
}
|
||||
|
||||
export interface CleanupPromotions {
|
||||
enabled: boolean
|
||||
matchCategoriesOrLabels: string[]
|
||||
action: 'archive_read' | 'trash'
|
||||
deleteAfterDays: number
|
||||
}
|
||||
|
||||
export interface CleanupSafety {
|
||||
requireConfirmForDelete: boolean
|
||||
dryRun?: boolean
|
||||
maxDeletesPerRun?: number
|
||||
}
|
||||
|
||||
export interface CleanupSettings {
|
||||
enabled: boolean
|
||||
readItems: CleanupReadItems
|
||||
promotions: CleanupPromotions
|
||||
safety: CleanupSafety
|
||||
}
|
||||
|
||||
export interface CategoryAdvanced {
|
||||
priority?: 'low' | 'medium' | 'high'
|
||||
includeLabels?: string[]
|
||||
excludeKeywords?: string[]
|
||||
}
|
||||
|
||||
export interface CleanupStatus {
|
||||
lastRun?: string
|
||||
lastRunCounts?: {
|
||||
readItems: number
|
||||
promotions: number
|
||||
}
|
||||
preview?: Array<{
|
||||
id: string
|
||||
subject: string
|
||||
from: string
|
||||
date: string
|
||||
reason: 'read' | 'promotion'
|
||||
}>
|
||||
}
|
||||
|
||||
export interface AIControlSettings {
|
||||
version?: number
|
||||
enabledCategories: string[]
|
||||
categoryActions: Record<string, 'inbox' | 'archive_read' | 'star'>
|
||||
autoDetectCompanies: boolean
|
||||
cleanup?: CleanupSettings
|
||||
categoryAdvanced?: Record<string, CategoryAdvanced>
|
||||
}
|
||||
|
||||
export interface CompanyLabel {
|
||||
|
||||
16
git-commit.bat
Normal file
16
git-commit.bat
Normal file
@@ -0,0 +1,16 @@
|
||||
@echo off
|
||||
REM Git Commit Script für Control Panel Redesign (Windows Batch)
|
||||
|
||||
cd /d c:\Users\User\Documents\GitHub\ANDJJJJJJ
|
||||
|
||||
REM Alle Änderungen stagen
|
||||
git add .
|
||||
|
||||
REM Commit mit detaillierter Nachricht
|
||||
git commit -m "feat: Control Panel Redesign v2.0 & UI Improvements" -m "🎨 Control Panel komplettes Redesign (Version 2.0)" -m "- Card-basiertes Layout: Kategorien als interaktive Cards im Grid" -m "- Side Panel Integration: Click-to-Configure Pattern für Category Details" -m "- Moderne UX: Dashboard-artiges Design statt klassischer Settings-Seite" -m "" -m "🧹 Cleanup Tab Redesign" -m "- Große Toggle-Cards für Auto Cleanup Features" -m "- Neue Slider-Komponente für intuitive Tage-Auswahl" -m "- Preset Buttons (7/14/30 Tage) für Promotion Cleanup" -m "- Preview Section für betroffene E-Mails" -m "" -m "🏷️ Labels Tab Redesign" -m "- Professionelle Tabellenansicht mit Name, Status, Category, Actions" -m "- Side Panel Editor für Label-Erstellung/Bearbeitung" -m "- Responsive Table (Spalten werden auf Mobile ausgeblendet)" -m "- Import/Export Funktionalität" -m "" -m "📐 Layout Verbesserungen" -m "- Volle Breite: Dashboard und Settings nutzen gesamte verfügbare Breite" -m "- Responsive Navigation: Side Panels werden auf Mobile zu Fullscreen-Modals" -m "- Verbesserte Header mit humaneren Untertiteln" -m "" -m "🌙 Dark Mode Verbesserungen" -m "- Privacy & Security: Alle Info-Boxen haben Dark Mode Varianten" -m "- Input-Komponente: Dark Mode Hintergrund korrigiert" -m "- Slider-Komponente: Dark Mode Styles für Track und Thumb" -m "- Chevron Icons: Dark Mode Farben für Advanced Options" -m "- Konsistente Dark Mode Unterstützung in allen Komponenten" -m "" -m "✨ Neue Komponenten" -m "- client/src/components/ui/side-panel.tsx: Radix UI Dialog-basierte Side Panel" -m "- client/src/components/ui/slider.tsx: Range Input Slider mit Dark Mode" -m "" -m "📝 Geänderte Dateien" -m "- client/src/pages/Settings.tsx: Control Panel komplett neu strukturiert" -m "- client/src/components/PrivacySecurity.tsx: Dark Mode für alle Info-Boxen" -m "- client/src/components/ui/input.tsx: Dark Mode Hintergrund korrigiert" -m "- client/src/pages/Dashboard.tsx: Volle Breite Layout"
|
||||
|
||||
REM Pushen
|
||||
git push
|
||||
|
||||
echo ✅ Commit erfolgreich erstellt und gepusht!
|
||||
pause
|
||||
54
git-commit.sh
Normal file
54
git-commit.sh
Normal file
@@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
# Git Commit Script für Control Panel Redesign
|
||||
|
||||
cd c:\Users\User\Documents\GitHub\ANDJJJJJJ
|
||||
|
||||
# Alle Änderungen stagen
|
||||
git add .
|
||||
|
||||
# Commit mit detaillierter Nachricht
|
||||
git commit -m "feat: Control Panel Redesign v2.0 & UI Improvements
|
||||
|
||||
🎨 Control Panel komplettes Redesign (Version 2.0)
|
||||
- Card-basiertes Layout: Kategorien als interaktive Cards im Grid
|
||||
- Side Panel Integration: Click-to-Configure Pattern für Category Details
|
||||
- Moderne UX: Dashboard-artiges Design statt klassischer Settings-Seite
|
||||
|
||||
🧹 Cleanup Tab Redesign
|
||||
- Große Toggle-Cards für Auto Cleanup Features
|
||||
- Neue Slider-Komponente für intuitive Tage-Auswahl
|
||||
- Preset Buttons (7/14/30 Tage) für Promotion Cleanup
|
||||
- Preview Section für betroffene E-Mails
|
||||
|
||||
🏷️ Labels Tab Redesign
|
||||
- Professionelle Tabellenansicht mit Name, Status, Category, Actions
|
||||
- Side Panel Editor für Label-Erstellung/Bearbeitung
|
||||
- Responsive Table (Spalten werden auf Mobile ausgeblendet)
|
||||
- Import/Export Funktionalität
|
||||
|
||||
📐 Layout Verbesserungen
|
||||
- Volle Breite: Dashboard und Settings nutzen gesamte verfügbare Breite
|
||||
- Responsive Navigation: Side Panels werden auf Mobile zu Fullscreen-Modals
|
||||
- Verbesserte Header mit humaneren Untertiteln
|
||||
|
||||
🌙 Dark Mode Verbesserungen
|
||||
- Privacy & Security: Alle Info-Boxen haben Dark Mode Varianten
|
||||
- Input-Komponente: Dark Mode Hintergrund korrigiert
|
||||
- Slider-Komponente: Dark Mode Styles für Track und Thumb
|
||||
- Chevron Icons: Dark Mode Farben für Advanced Options
|
||||
- Konsistente Dark Mode Unterstützung in allen Komponenten
|
||||
|
||||
✨ Neue Komponenten
|
||||
- client/src/components/ui/side-panel.tsx: Radix UI Dialog-basierte Side Panel
|
||||
- client/src/components/ui/slider.tsx: Range Input Slider mit Dark Mode
|
||||
|
||||
📝 Geänderte Dateien
|
||||
- client/src/pages/Settings.tsx: Control Panel komplett neu strukturiert
|
||||
- client/src/components/PrivacySecurity.tsx: Dark Mode für alle Info-Boxen
|
||||
- client/src/components/ui/input.tsx: Dark Mode Hintergrund korrigiert
|
||||
- client/src/pages/Dashboard.tsx: Volle Breite Layout"
|
||||
|
||||
# Pushen
|
||||
git push
|
||||
|
||||
echo "✅ Commit erfolgreich erstellt und gepusht!"
|
||||
77
run-git-commit.ps1
Normal file
77
run-git-commit.ps1
Normal 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
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 })
|
||||
}))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -64,6 +64,16 @@ export function redirect(res, url, statusCode = 302) {
|
||||
return res.redirect(statusCode, url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Error response
|
||||
*/
|
||||
export function error(res, errorData, statusCode = 400) {
|
||||
return res.status(statusCode).json({
|
||||
success: false,
|
||||
error: errorData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Response helpers object
|
||||
*/
|
||||
@@ -73,6 +83,7 @@ export const respond = {
|
||||
noContent,
|
||||
paginated,
|
||||
redirect,
|
||||
error,
|
||||
}
|
||||
|
||||
export default respond
|
||||
|
||||
142
setup-production.ps1
Normal file
142
setup-production.ps1
Normal 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
|
||||
Reference in New Issue
Block a user