Compare commits

...

8 Commits

Author SHA1 Message Date
cbb225c001 feat: Gitea Webhook, IMAP, Settings & Deployment docs
- Webhook route and Gitea integration
- IMAP service and Nextcloud/Porkbun setup docs
- Settings UI improvements and API updates
- SSH/Webhook fix prompt for emailsorter.webklar.com
- Bootstrap, config and AI sorter updates
2026-01-31 15:00:00 +01:00
7e7ec1013b eerrerer
erdfsfsfsdf
2026-01-31 12:05:47 +01:00
a28ca580d2 Appwrite Fix 1.
Hopefully i can create accounts
2026-01-29 16:56:27 +01:00
5ba12cb738 chore: Docs umstrukturiert, Client-Updates, Scripts nach scripts/ 2026-01-28 20:00:37 +01:00
4b38da3b85 fix: Remove debug logs & fix favicon for production
- Remove all debug logs (127.0.0.1:7242) that cause errors in production
- Fix site.webmanifest to use existing SVG icons instead of missing PNG files
- Build erfolgreich
2026-01-28 17:20:15 +01:00
904dcd8260 fix: TypeScript errors & build fixes for Control Panel Redesign
- Fix unused imports (Trash, Filter, Bell, CategoryAdvanced)
- Fix undefined checks for cleanup settings
- Fix cleanupPreview undefined checks
- Fix useTheme unused parameter
- Fix companyLabels type safety
- Build erfolgreich durchgefuehrt
2026-01-28 17:10:36 +01:00
6da8ce1cbd huhuih
hzgjuigik
2026-01-27 21:06:48 +01:00
18c11d27bc feat: AI Control Settings mit Category Control und Company Labels
MAJOR FEATURES:
- AI Control Tab in Settings hinzugefügt mit vollständiger KI-Steuerung
- Category Control: Benutzer können Kategorien aktivieren/deaktivieren und Aktionen pro Kategorie festlegen (Keep in Inbox, Archive & Mark Read, Star)
- Company Labels: Automatische Erkennung bekannter Firmen (Amazon, Google, Microsoft, etc.) und optionale benutzerdefinierte Company Labels
- Auto-Detect Companies Toggle: Automatische Label-Erstellung für bekannte Firmen

UI/UX VERBESSERUNGEN:
- Sorting Rules Tab entfernt (war zu verwirrend)
- Save Buttons nach oben rechts verschoben (Category Control und Company Labels)
- Company Labels Section: Custom Labels sind jetzt in einem ausklappbaren Details-Element (Optional)
- Verbesserte Beschreibungen und Klarheit in der UI

BACKEND ÄNDERUNGEN:
- Neue API Endpoints: /api/preferences/ai-control (GET/POST) und /api/preferences/company-labels (GET/POST/DELETE)
- AI Sorter Service erweitert: detectCompany(), matchesCompanyLabel(), getCategoryAction(), getEnabledCategories()
- Database Service: Default-Werte und Merge-Logik für erweiterte User Preferences
- Email Routes: Integration der neuen AI Control Einstellungen in Gmail und Outlook Sortierung
- Label-Erstellung: Nur für enabledCategories, Custom Company Labels mit orange Farbe (#ff9800)

FRONTEND ÄNDERUNGEN:
- Neue TypeScript Types: client/src/types/settings.ts (AIControlSettings, CompanyLabel, CategoryInfo, KnownCompany)
- Settings.tsx: Komplett überarbeitet mit AI Control Tab, Category Toggles, Company Labels Management
- API Client erweitert: getAIControlSettings(), saveAIControlSettings(), getCompanyLabels(), saveCompanyLabel(), deleteCompanyLabel()
- Debug-Logs hinzugefügt für Troubleshooting (main.tsx, App.tsx, Settings.tsx)

BUGFIXES:
- JSX Syntax-Fehler behoben: Fehlende schließende </div> Tags in Company Labels Section
- TypeScript Typ-Fehler behoben: saved.data null-check für Company Labels
- Struktur-Fehler behoben: Conditional Blocks korrekt verschachtelt

TECHNISCHE DETAILS:
- 9 Kategorien verfügbar: VIP, Clients, Invoices, Newsletter, Promotions, Social, Security, Calendar, Review
- Company Labels unterstützen Bedingungen wie 'from:amazon.com OR from:amazon.de'
- Priorisierung: 1) Custom Company Labels, 2) Auto-Detected Companies, 3) AI Categorization
- Deaktivierte Kategorien werden automatisch als 'review' kategorisiert
2026-01-26 17:49:39 +01:00
128 changed files with 11259 additions and 1333 deletions

View File

@@ -24,6 +24,12 @@ PRODUCT_CURRENCY=eur
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key_here
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here
# Gitea Webhook (Deployment)
# Secret für X-Gitea-Signature und optional Bearer-Header (gleicher Wert möglich)
GITEA_WEBHOOK_SECRET=your_webhook_secret_here
# Optional: anderer Token nur für Authorization: Bearer (sonst wird GITEA_WEBHOOK_SECRET verwendet)
# GITEA_WEBHOOK_AUTH_TOKEN=
# Server Configuration
PORT=3000
BASE_URL=http://localhost:3000

97
AUFRÄUMEN.md Normal file
View File

@@ -0,0 +1,97 @@
# 🧹 Aufräumen - Dateien organisieren
Diese Datei listet alle Dateien auf, die aufgeräumt/entfernt werden können.
## ✅ Was wurde gemacht
1. ✅ Webhook-Dokumentation in `docs/deployment/` organisiert
2. ✅ Scripts-README aktualisiert
3. ✅ Deployment-Dokumentation strukturiert
4. ✅ Projekt-Ordnung dokumentiert (`PROJEKT_ORDNUNG.md`)
## 🗑️ Dateien die entfernt werden können
### Scripts (veraltete Git-Commit-Scripts)
Diese können gelöscht werden, da Git-Commits direkt über `git commit` gemacht werden sollten:
```bash
scripts/git-commit.bat
scripts/git-commit.sh
scripts/git-commit-fix.bat
scripts/FINAL_COMMIT.bat
scripts/run-git-commit.ps1
scripts/COMMIT_COMMANDS.txt
scripts/COMMIT_MESSAGE.md
```
**Befehl zum Entfernen:**
```bash
cd scripts
rm git-commit.bat git-commit.sh git-commit-fix.bat FINAL_COMMIT.bat run-git-commit.ps1 COMMIT_COMMANDS.txt COMMIT_MESSAGE.md
```
### Dokumentation (kann archiviert werden)
Diese Task-spezifischen Dokumentationsdateien können in `docs/archive/` verschoben werden:
```bash
docs/development/TASK_5_COMPLETION.md
docs/server/TASK_4_COMPLETION_SUMMARY.md
docs/development/PROJECT_REVIEW_SUMMARY.md
```
**Befehl zum Archivieren:**
```bash
mkdir -p docs/archive
mv docs/development/TASK_5_COMPLETION.md docs/archive/
mv docs/server/TASK_4_COMPLETION_SUMMARY.md docs/archive/
mv docs/development/PROJECT_REVIEW_SUMMARY.md docs/archive/
```
## 📋 Optionale Aufräumarbeiten
### Temporäre Dateien im Root
Falls es temporäre Markdown-Dateien im Root gibt (z.B. von mir erstellt), können diese entfernt werden, nachdem die Informationen in die richtige Dokumentation übernommen wurden:
- `GITEA_WEBHOOK_ZUSAMMENFASSUNG.md` (falls vorhanden)
- `GITEA_AUTHORIZATION_HEADER.md` (falls vorhanden)
- `DEPLOYMENT_STATUS.md` (falls vorhanden)
- `DEPLOYMENT_KONFIGURIERT.md` (falls vorhanden)
**Hinweis:** Diese sollten bereits in `docs/deployment/` sein.
## ✅ Wichtige Dateien (NICHT löschen!)
Diese Dateien müssen bleiben:
- Alle Dateien in `client/`, `server/`, `docs/` (außer oben genannte)
- `README.md`, `STRUCTURE.md`, `PROJEKT_ORDNUNG.md`
- Alle Konfigurationsdateien (`.gitignore`, `package.json`, etc.)
- Alle aktiven Scripts (`deploy-to-server.mjs`, `setup-*.ps1`)
## 🎯 Empfehlung
1. **Sofort entfernen:** Veraltete Git-Commit-Scripts (werden nicht mehr benötigt)
2. **Archivieren:** Task-spezifische Dokumentation (für Referenz behalten)
3. **Prüfen:** Temporäre Root-Dateien (falls vorhanden, nach Übernahme entfernen)
## 📝 Nach dem Aufräumen
Nach dem Aufräumen sollte die Struktur sauberer sein:
```
scripts/
├── deploy-to-server.mjs ✅
├── setup-appwrite.ps1 ✅
├── setup-production.ps1 ✅
└── README.md ✅
docs/
├── deployment/ ✅ (vollständig organisiert)
├── setup/ ✅
├── development/ ✅ (teilweise archiviert)
├── server/ ✅ (teilweise archiviert)
└── archive/ ✅ (neu, für veraltete Docs)
```

39
DEPLOY_CHECKLIST.md Normal file
View File

@@ -0,0 +1,39 @@
# Deploy-Checkliste emailsorter.webklar.com
Nach dem **Push** soll alles mit Appwrite verbunden laufen. Diese Schritte einmalig prüfen bzw. erledigen.
---
## 1. Appwrite: Web-Platform für Production
Damit **keine CORS-Fehler** auftreten, muss in Appwrite eine Web-Platform für deine Domain existieren.
1. Öffne **https://appwrite.webklar.com**
2. Projekt öffnen (z.B. EmailSorter)
3. **Settings****Platforms** (oder „Web“)
4. **Add Platform****Web**
5. Eintragen:
- **Name:** `Production`
- **Hostname:** `emailsorter.webklar.com`
- **Origin:** `https://emailsorter.webklar.com` (falls abgefragt)
6. Speichern, **12 Minuten** warten (Cache)
Ohne diesen Schritt blockiert der Browser Requests von `https://emailsorter.webklar.com` mit CORS.
---
## 2. Build & Deploy
- **Frontend:** `client/.env.production` ist für Production vorbereitet (Appwrite + API-URL).
- Build: `cd client && npm run build` → Auslieferung von `client/dist/` auf **emailsorter.webklar.com**.
- **Backend:** Auf dem Server `server/.env` mit Production-Werten (z.B. `APPWRITE_ENDPOINT`, `APPWRITE_PROJECT_ID`, `APPWRITE_API_KEY`, `FRONTEND_URL`, `CORS_ORIGIN`, `BASE_URL` / API-URL) setzen und API unter **api.emailsorter.webklar.com** betreiben.
---
## 3. Kurz-Check nach dem Deploy
- [ ] **https://emailsorter.webklar.com** lädt ohne Fehler
- [ ] Login/Registrierung funktioniert (keine CORS-Fehler in F12)
- [ ] API erreichbar: **https://api.emailsorter.webklar.com/api/health** (falls du diese Route hast)
Wenn etwas nicht geht: zuerst prüfen, ob die Appwrite-Platform wie in Abschnitt 1 angelegt ist.

53
FIX_CORS.md Normal file
View File

@@ -0,0 +1,53 @@
# CORS-Fehler beheben - Schnellanleitung
## Problem
Appwrite blockiert Requests von `https://emailsorter.webklar.com` weil nur `https://localhost` als Origin erlaubt ist.
## Lösung (Automatisch)
### Option 1: Node.js Script (empfohlen)
```bash
cd server
npm run setup:platform
```
**Falls der API Key nicht die richtigen Scopes hat:**
1. Gehe zu https://appwrite.webklar.com
2. Öffne dein Projekt → Settings → API Credentials
3. Erstelle einen neuen API Key mit Scopes: `platforms.read` und `platforms.write`
4. Aktualisiere `APPWRITE_API_KEY` in `server/.env`
5. Führe das Script erneut aus
### Option 2: PowerShell Script (Windows)
```powershell
cd scripts
.\setup-appwrite-cors-auto.ps1
```
## Lösung (Manuell)
1. **Gehe zu Appwrite Console:**
- https://appwrite.webklar.com
2. **Öffne dein Projekt**
3. **Gehe zu Settings → Platforms** (oder "Web" in manchen Versionen)
4. **Klicke auf "Add Platform" oder "Create Platform"**
5. **Wähle "Web" als Platform-Typ**
6. **Fülle die Felder aus:**
- **Name:** `Production`
- **Hostname:** `emailsorter.webklar.com`
- **Origin:** `https://emailsorter.webklar.com` (falls gefragt)
7. **Speichere die Änderungen**
8. **Warte 1-2 Minuten** (Cache)
9. **Teste die Anwendung** - CORS-Fehler sollten jetzt verschwunden sein
## Weitere Hilfe
Siehe `docs/setup/APPWRITE_CORS_SETUP.md` für detaillierte Anweisungen.

142
PROJEKT_ORDNUNG.md Normal file
View File

@@ -0,0 +1,142 @@
# 📁 Projekt-Ordnung und Dateistruktur
Diese Datei beschreibt die Organisation aller Dateien im Projekt.
## ✅ Wichtige Dateien (behalten)
### Root-Verzeichnis
- **README.md** - Hauptdokumentation des Projekts
- **STRUCTURE.md** - Detaillierte Projektstruktur
- **.gitignore** - Git-Ignore-Regeln
- **.env.example** - Beispiel-Umgebungsvariablen
### Client (`client/`)
- Alle Source-Dateien in `src/`
- Konfigurationsdateien (`package.json`, `vite.config.ts`, etc.)
- **README.md** - Client-spezifische Dokumentation
### Server (`server/`)
- Alle Backend-Dateien
- **routes/** - API-Routen (inkl. `webhook.mjs` für automatisches Deployment)
- **config/** - Konfiguration
- **.env** - Umgebungsvariablen (nicht im Git!)
### Dokumentation (`docs/`)
- **deployment/** - Deployment-Anleitungen
- `GITEA_WEBHOOK_SETUP.md` - Vollständige Webhook-Anleitung
- `WEBHOOK_QUICK_START.md` - Schnellstart
- `WEBHOOK_AUTHORIZATION.md` - Authentifizierung
- `DEPLOYMENT_INSTRUCTIONS.md` - Manuelles Deployment
- `PRODUCTION_SETUP.md` - Production-Setup
- `PRODUCTION_FIXES.md` - Bekannte Probleme
- **setup/** - Setup-Anleitungen
- **development/** - Development-Dokumentation
- **server/** - Server-Dokumentation
### Scripts (`scripts/`)
- **deploy-to-server.mjs** - Deployment-Skript (wird vom Webhook aufgerufen)
- **setup-*.ps1** - Setup-Scripts
- **README.md** - Scripts-Dokumentation
### Marketing (`marketing/`)
- Alle Marketing-Materialien und Anleitungen
## 🗑️ Kann entfernt werden (temporäre/veraltete Dateien)
### Scripts (`scripts/`)
Diese Git-Commit-Scripts sind veraltet und können entfernt werden:
- `git-commit.bat`
- `git-commit.sh`
- `git-commit-fix.bat`
- `FINAL_COMMIT.bat`
- `run-git-commit.ps1`
- `COMMIT_COMMANDS.txt`
- `COMMIT_MESSAGE.md`
**Grund:** Git-Commits sollten direkt über `git commit` gemacht werden.
### Dokumentation (`docs/`)
Einige temporäre/veraltete Dokumentationsdateien können archiviert werden:
- `development/TASK_5_COMPLETION.md` - Task-spezifisch, kann archiviert werden
- `server/TASK_4_COMPLETION_SUMMARY.md` - Task-spezifisch, kann archiviert werden
- `development/PROJECT_REVIEW_SUMMARY.md` - Review-spezifisch, kann archiviert werden
**Empfehlung:** Verschiebe diese in `docs/archive/` statt zu löschen.
## 📋 Dateien-Organisation
### Aktuelle Struktur
```
/
├── client/ # Frontend
├── server/ # Backend
├── docs/ # Dokumentation
│ ├── deployment/ # Deployment-Docs ✅
│ ├── setup/ # Setup-Docs ✅
│ ├── development/ # Development-Docs (teilweise archivieren)
│ └── server/ # Server-Docs (teilweise archivieren)
├── scripts/ # Scripts
│ ├── deploy-to-server.mjs ✅
│ ├── setup-*.ps1 ✅
│ └── [veraltete Git-Scripts] ❌
├── marketing/ # Marketing ✅
└── README.md # Hauptdokumentation ✅
```
## 🧹 Aufräumen-Empfehlungen
### 1. Veraltete Scripts entfernen
```bash
# Diese Dateien können gelöscht werden:
scripts/git-commit.bat
scripts/git-commit.sh
scripts/git-commit-fix.bat
scripts/FINAL_COMMIT.bat
scripts/run-git-commit.ps1
scripts/COMMIT_COMMANDS.txt
scripts/COMMIT_MESSAGE.md
```
### 2. Temporäre Dokumentation archivieren
Erstelle `docs/archive/` und verschiebe:
- `docs/development/TASK_5_COMPLETION.md`
- `docs/server/TASK_4_COMPLETION_SUMMARY.md`
- `docs/development/PROJECT_REVIEW_SUMMARY.md`
### 3. README aktualisieren
Die `scripts/README.md` wurde bereits aktualisiert.
## ✅ Checkliste
- [x] Webhook-Dokumentation in `docs/deployment/` organisiert
- [x] Scripts-README aktualisiert
- [x] Deployment-Dokumentation strukturiert
- [ ] Veraltete Scripts entfernen (optional)
- [ ] Temporäre Dokumentation archivieren (optional)
## 📝 Wichtige Hinweise
1. **`.env` Dateien** sind nie im Git (siehe `.gitignore`)
2. **Temporäre Anleitungen** können nach erfolgreicher Einrichtung entfernt werden
3. **Task-spezifische Dokumentation** kann archiviert werden, sollte aber nicht gelöscht werden
4. **Alle produktiven Dateien** (Code, Konfiguration, aktive Dokumentation) bleiben erhalten
## 🔄 Regelmäßige Wartung
- **Monatlich:** Prüfe auf veraltete Scripts/Dokumentation
- **Nach großen Features:** Aktualisiere README und Dokumentation
- **Nach Deployment:** Entferne temporäre Deployment-Anleitungen (falls nicht mehr benötigt)

View File

@@ -29,9 +29,19 @@ EmailSorter ist eine SaaS-Anwendung, die automatisch E-Mails kategorisiert und s
│ ├── routes/ # API Routen
│ ├── services/ # Business Logic
│ └── package.json
├── docs/ # Dokumentation
│ ├── setup/ # Setup-Anleitungen
│ ├── deployment/ # Deployment-Docs
│ ├── development/ # Development-Docs
│ └── server/ # Server-Dokumentation
├── scripts/ # Hilfs-Scripts
│ ├── git-commit.* # Git-Scripts
│ └── deploy-build.js # Deployment-Scripts
├── marketing/ # Marketing-Materialien
│ └── *.md # Marketing-Dokumentation
├── n8n/ # n8n Workflows
│ └── workflows/
└── public/ # Legacy Frontend
└── README.md # Diese Datei
```
## Quick Start
@@ -199,6 +209,8 @@ Siehe `n8n/README.md` für Details.
## Deployment
Siehe `docs/deployment/` für detaillierte Deployment-Anleitungen.
### Frontend (Vercel/Netlify)
```bash
@@ -219,6 +231,17 @@ Aktualisiere die Webhook-URL im Stripe Dashboard auf deine Produktions-URL:
https://your-domain.com/api/subscription/webhook
```
## Dokumentation
Alle Dokumentation befindet sich im `docs/` Ordner:
- **Setup:** `docs/setup/` - Setup-Anleitungen für Appwrite, OAuth, etc.
- **Deployment:** `docs/deployment/` - Production-Setup und Deployment
- **Development:** `docs/development/` - Development-Dokumentation
- **Server:** `docs/server/` - Server-spezifische Dokumentation
Siehe `docs/README.md` für eine vollständige Übersicht.
## Troubleshooting
### Frontend startet nicht

95
STRUCTURE.md Normal file
View File

@@ -0,0 +1,95 @@
# Projektstruktur-Übersicht
Diese Datei beschreibt die organisierte Struktur des Projekts.
## 📁 Hauptverzeichnisse
### `/client/`
React Frontend-Anwendung
- `src/` - Quellcode
- `public/` - Statische Assets
- `package.json` - Frontend-Dependencies
### `/server/`
Node.js Backend-Server
- `routes/` - API-Routen
- `services/` - Business-Logik
- `middleware/` - Express-Middleware
- `config/` - Konfiguration
- `utils/` - Utility-Funktionen
- `package.json` - Backend-Dependencies
### `/docs/`
Alle Dokumentation
- `setup/` - Setup-Anleitungen (Appwrite, OAuth, etc.)
- `deployment/` - Deployment & Production-Docs
- `development/` - Development-Dokumentation
- `server/` - Server-spezifische Dokumentation
- `examples/` - Beispiel-Code (z.B. starter-for-react)
- `legacy/` - Legacy-Dateien
### `/scripts/`
Hilfs-Scripts für Entwicklung & Deployment
- Git-Scripts (`git-commit.*`, `run-git-commit.ps1`)
- Deployment-Scripts (`deploy-build.js`)
- Setup-Scripts (`setup-appwrite.ps1`, `setup-production.ps1`)
- Commit-Hilfsdateien (`COMMIT_MESSAGE.md`, `COMMIT_COMMANDS.txt`)
### `/marketing/`
Marketing-Materialien und Dokumentation
- Logo-Dateien (SVG)
- Marketing-Guides (TikTok, YouTube, Product Hunt, etc.)
- Influencer-Templates
### `/n8n/`
n8n Workflow-Konfigurationen
- `workflows/` - Workflow-Definitionen
### `/.kiro/`
Kiro-Spezifikationen (Design, Requirements, Tasks)
## 📄 Root-Dateien
- `README.md` - Haupt-README mit Projektübersicht
- `STRUCTURE.md` - Diese Datei
- `.env.example` - Beispiel-Umgebungsvariablen
- `.gitignore` - Git-Ignore-Regeln
## 🎯 Organisationsprinzipien
1. **Dokumentation zentralisiert:** Alle `.md` Dateien sind in `docs/` organisiert
2. **Scripts getrennt:** Alle Scripts sind in `scripts/` gesammelt
3. **Sauberes Root:** Root-Verzeichnis enthält nur essenzielle Dateien
4. **Klare Kategorien:** Dokumentation nach Themen sortiert (setup, deployment, development)
## 📝 Wichtige Dateien
### Setup
- `docs/setup/SETUP_GUIDE.md` - Allgemeine Setup-Anleitung
- `docs/setup/APPWRITE_SETUP.md` - Appwrite-Konfiguration
- `docs/setup/GOOGLE_OAUTH_SETUP.md` - Google OAuth Setup
### Deployment
- `docs/deployment/PRODUCTION_SETUP.md` - Production-Server Setup
- `docs/deployment/DEPLOYMENT_INSTRUCTIONS.md` - Deployment-Anleitung
### Development
- `docs/development/PROJECT_REVIEW_SUMMARY.md` - Projekt-Review
- `docs/development/TESTING_SUMMARY.md` - Testing-Dokumentation
## 🔧 Scripts-Verwendung
Alle Scripts befinden sich in `scripts/`:
```bash
# Git-Commit (Windows)
scripts\git-commit.bat
# Git-Commit (PowerShell)
scripts\run-git-commit.ps1
# Deployment
node scripts\deploy-build.js
```
Siehe `scripts/README.md` für Details.

9
client/.env.production Normal file
View File

@@ -0,0 +1,9 @@
# EmailSorter Production - emailsorter.webklar.com
# Wird bei "npm run build" verwendet (Vite lädt .env.production)
# Appwrite (appwrite.webklar.com)
VITE_APPWRITE_ENDPOINT=https://appwrite.webklar.com/v1
VITE_APPWRITE_PROJECT_ID=696d0949001c70d6c6da
# Backend API (relativ oder absolute URL)
VITE_API_URL=https://api.emailsorter.webklar.com

View File

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

View File

@@ -1,11 +1,11 @@
{
"name": "client",
"name": "emailsorter-client",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "client",
"name": "emailsorter-client",
"version": "0.0.0",
"dependencies": {
"@radix-ui/react-accordion": "^1.2.12",

View File

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

After

Width:  |  Height:  |  Size: 903 B

View File

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

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

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

After

Width:  |  Height:  |  Size: 891 B

View File

@@ -0,0 +1,21 @@
{
"name": "EmailSorter",
"short_name": "EmailSorter",
"description": "AI-powered email sorting for maximum productivity",
"icons": [
{
"src": "/favicon.svg",
"sizes": "any",
"type": "image/svg+xml"
},
{
"src": "/apple-touch-icon.svg",
"sizes": "180x180",
"type": "image/svg+xml"
}
],
"theme_color": "#22c55e",
"background_color": "#ffffff",
"display": "standalone",
"start_url": "/"
}

View File

@@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider, useAuth } from '@/context/AuthContext'
import { usePageTracking } from '@/hooks/useAnalytics'
import { initAnalytics } from '@/lib/analytics'
import { useTheme } from '@/hooks/useTheme'
import { Home } from '@/pages/Home'
import { Login } from '@/pages/Login'
import { Register } from '@/pages/Register'
@@ -20,10 +21,10 @@ initAnalytics()
// Loading spinner component
function LoadingSpinner() {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50">
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900">
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 border-4 border-primary-200 border-t-primary-600 rounded-full animate-spin" />
<p className="text-slate-500 text-sm">Loading...</p>
<div className="w-10 h-10 border-4 border-primary-200 dark:border-primary-800 border-t-primary-600 dark:border-t-primary-400 rounded-full animate-spin" />
<p className="text-slate-500 dark:text-slate-400 text-sm">Loading...</p>
</div>
</div>
)
@@ -130,6 +131,9 @@ function AppRoutes() {
}
function App() {
// Initialize theme detection
useTheme()
return (
<BrowserRouter>
<AuthProvider>

View File

@@ -0,0 +1,89 @@
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',
}
const stepOrder = ['connect', 'first_rule', 'see_results', 'auto_schedule']
const stepOrderShort = ['connect', 'see_results']
export function OnboardingProgress({ currentStep, completedSteps, totalSteps, onSkip }: OnboardingProgressProps) {
const steps = totalSteps === 2 ? stepOrderShort : stepOrder
const stepIndex = steps.indexOf(currentStep)
const currentStepNumber = stepIndex >= 0 ? stepIndex + 1 : 1
const progress = totalSteps > 0 ? (completedSteps.filter(s => steps.includes(s)).length / totalSteps) * 100 : 0
if (currentStep === 'completed' || currentStep === 'not_started') {
return null
}
return (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 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 dark:text-slate-100">Getting started</p>
<p className="text-xs text-slate-500 dark:text-slate-400">Step {currentStepNumber} of {totalSteps}</p>
</div>
<Button variant="ghost" size="sm" onClick={onSkip} className="text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300">
<X className="w-4 h-4 mr-1" />
I&apos;ll do this later
</Button>
</div>
<div className="w-full h-2 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden mb-2">
<div
className="h-full bg-primary-500 transition-all duration-300"
style={{ width: `${Math.min(100, progress)}%` }}
/>
</div>
<div className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
{steps.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 dark:ring-primary-800'
: 'bg-slate-200 dark:bg-slate-600 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 dark:text-slate-100 font-medium' : ''
}`}>
{stepLabels[step] || (step === 'see_results' ? 'Done' : step)}
</span>
{idx < steps.length - 1 && (
<div className={`flex-1 h-0.5 mx-1 ${
isCompleted ? 'bg-green-500' : 'bg-slate-200 dark:bg-slate-600'
}`} />
)}
</div>
)
})}
</div>
</div>
)
}

View File

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

View File

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

View File

@@ -0,0 +1,113 @@
import { Button } from '@/components/ui/button'
import { X, Sparkles, Zap, Infinity as InfinityIcon } 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"
>
<InfinityIcon className="w-4 h-4 mr-2" />
Upgrade
</Button>
<Button
variant="outline"
onClick={handleDismiss}
className="border-slate-300 text-slate-600 hover:bg-slate-50"
>
Not now
</Button>
</div>
</div>
)
}

View File

@@ -1,119 +1,66 @@
import { useState } from 'react'
import { ChevronDown, HelpCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
const faqs = [
{
question: "Are my emails secure?",
answer: "Yes! We use OAuth we never see your password. Content is only analyzed briefly, never stored."
question: "Why not just use Gmail filters?",
answer: "Gmail filters need rules you write (sender, keywords). We read the email and put it in Lead, Client, or Noise — no rules."
},
{
question: "Which email providers work?",
answer: "Gmail and Outlook. More coming soon."
question: "What happens to my emails?",
answer: "We only read headers and a short snippet to choose the label. We don't store your mail or use it for ads. Disconnect and we stop."
},
{
question: "Can I create custom rules?",
answer: "Absolutely! You can set VIP contacts and define custom categories."
question: "Can this mess up my inbox?",
answer: "We only add labels or move to folders. We don't delete. Disconnect and nothing stays changed."
},
{
question: "What about old emails?",
answer: "The last 30 days are analyzed. You decide if they should be sorted too."
question: "Do you need my password?",
answer: "No. You sign in with Google or Microsoft. We never see or store your password. You can revoke access anytime."
},
{
question: "Can I cancel anytime?",
answer: "Yes, with one click. No tricks, no long commitments."
},
{
question: "Do I need a credit card?",
answer: "No, the 14-day trial is completely free."
},
{
question: "Does it work on mobile?",
answer: "Yes! Sorting runs on our servers works in any email app."
},
{
question: "What if the AI sorts wrong?",
answer: "Just correct it. The AI learns and gets better over time."
question: "What if I don't like it?",
answer: "Cancel anytime. No contract. Your data is yours; disconnect and we stop. Free trial, no card."
},
]
export function FAQ() {
const [openIndex, setOpenIndex] = useState<number | null>(0)
return (
<section id="faq" className="py-24 bg-slate-50">
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Section header */}
<div className="text-center mb-16">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-100 mb-6">
<HelpCircle className="w-8 h-8 text-primary-600" />
</div>
<h2 className="text-3xl sm:text-4xl font-bold text-slate-900 mb-4">
FAQ
<section id="faq" className="py-24 bg-slate-50 dark:bg-slate-900">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h2 className="text-3xl sm:text-4xl font-bold text-slate-900 dark:text-slate-100 mb-2">
Questions we get a lot
</h2>
<p className="text-lg text-slate-600">
Quick answers to common questions.
<p className="text-slate-600 dark:text-slate-400">
Straight answers. No fluff.
</p>
</div>
{/* FAQ items */}
<div className="space-y-3">
<div className="space-y-12">
{faqs.map((faq, index) => (
<FAQItem
<div
key={index}
question={faq.question}
answer={faq.answer}
isOpen={openIndex === index}
onClick={() => setOpenIndex(openIndex === index ? null : index)}
/>
className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-10 items-baseline"
>
<p className="text-lg md:text-xl font-semibold text-slate-900 dark:text-slate-100">
{faq.question}
</p>
<p className="text-sm text-slate-600 dark:text-slate-400 leading-relaxed">
{faq.answer}
</p>
</div>
))}
</div>
{/* Contact CTA */}
<div className="mt-12 text-center p-6 bg-white rounded-2xl border border-slate-200">
<p className="text-slate-600 mb-2">Still have questions?</p>
<p className="mt-10 text-center text-sm text-slate-600 dark:text-slate-400">
Still unsure?{' '}
<a
href="mailto:support@emailsorter.com"
className="text-primary-600 font-semibold hover:text-primary-700"
href="mailto:support@emailsorter.webklar.com"
className="text-slate-700 dark:text-slate-300 hover:underline"
>
Contact us
Email us we reply fast
</a>
</div>
.
</p>
</div>
</section>
)
}
interface FAQItemProps {
question: string
answer: string
isOpen: boolean
onClick: () => void
}
function FAQItem({ question, answer, isOpen, onClick }: FAQItemProps) {
return (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<button
className="w-full px-6 py-4 text-left flex items-center justify-between hover:bg-slate-50 transition-colors"
onClick={onClick}
>
<span className="font-semibold text-slate-900 pr-4">{question}</span>
<ChevronDown
className={cn(
"w-5 h-5 text-slate-400 transition-transform duration-200 flex-shrink-0",
isOpen && "rotate-180"
)}
/>
</button>
<div
className={cn(
"overflow-hidden transition-all duration-200",
isOpen ? "max-h-40" : "max-h-0"
)}
>
<p className="px-6 pb-4 text-slate-600">{answer}</p>
</div>
</div>
)
}

View File

@@ -3,7 +3,6 @@ import {
Zap,
Shield,
Clock,
Tags,
Settings,
Inbox,
Filter
@@ -11,58 +10,57 @@ 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: "Categories, not chaos",
description: "Leads, clients, invoices, newsletters — sorted into folders. Your inbox shows what pays first.",
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"
},
{
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: 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"
},
{
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"
title: "One click to sort",
description: "Connect your inbox, click Sort Now. No rules to write. We read and categorize; you review.",
color: "from-amber-500 to-orange-600",
highlight: true,
},
{
icon: Settings,
title: "Fully customizable",
description: "Define your own rules, VIP contacts, and categories based on your needs.",
title: "Runs when you want",
description: "Sort on demand or set a schedule. Your inbox stays organized without you touching it.",
color: "from-blue-500 to-cyan-600",
highlight: true,
},
{
icon: Brain,
title: "Content-aware sorting",
description: "We look at sender, subject, and a short snippet to decide the category. No keyword lists.",
color: "from-green-500 to-emerald-600"
},
{
icon: Shield,
title: "Minimal data",
description: "We only read what we need to categorize. No storing email body or attachments. GDPR compliant.",
color: "from-pink-500 to-rose-600"
},
{
icon: Clock,
title: "Less time on triage",
description: "Spend less time deciding what's important. Inbox shows clients and leads first.",
color: "from-indigo-500 to-blue-600"
},
]
export function Features() {
return (
<section id="features" className="py-24 bg-slate-50">
<section id="features" className="py-24 bg-slate-50 dark:bg-slate-900">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Section header */}
<div className="text-center mb-16">
<h2 className="text-3xl sm:text-4xl font-bold text-slate-900 mb-4">
Everything you need for{' '}
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary-600 to-accent-500">
Inbox Zero
</span>
<h2 className="text-3xl sm:text-4xl font-bold text-slate-900 dark:text-slate-100 mb-4">
What it does
</h2>
<p className="text-lg text-slate-600 max-w-2xl mx-auto">
EmailSorter combines AI technology with proven email management methods
for maximum productivity.
<p className="text-lg text-slate-600 dark:text-slate-400 max-w-2xl mx-auto">
Sort incoming mail into categories so your inbox shows what matters first. No rules to write.
</p>
</div>
@@ -75,17 +73,17 @@ export function Features() {
{/* Bottom illustration */}
<div className="mt-20 relative">
<div className="bg-white rounded-3xl border border-slate-200 shadow-xl p-8 max-w-4xl mx-auto">
<div className="bg-white dark:bg-slate-800 rounded-3xl border border-slate-200 dark:border-slate-700 shadow-xl p-8 max-w-4xl mx-auto">
<div className="grid md:grid-cols-3 gap-8 items-center">
{/* Before */}
<div className="text-center">
<div className="w-20 h-20 mx-auto mb-4 rounded-2xl bg-red-100 flex items-center justify-center">
<Inbox className="w-10 h-10 text-red-500" />
<div className="w-20 h-20 mx-auto mb-4 rounded-2xl bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<Inbox className="w-10 h-10 text-red-500 dark:text-red-400" />
</div>
<h4 className="font-semibold text-slate-900 mb-1">Before</h4>
<p className="text-sm text-slate-500">Inbox chaos</p>
<div className="mt-3 text-3xl font-bold text-red-500">847</div>
<p className="text-xs text-slate-400">unread emails</p>
<h4 className="font-semibold text-slate-900 dark:text-slate-100 mb-1">Before</h4>
<p className="text-sm text-slate-500 dark:text-slate-400">Inbox chaos</p>
<div className="mt-3 text-3xl font-bold text-red-500 dark:text-red-400">847</div>
<p className="text-xs text-slate-400 dark:text-slate-500">unread emails</p>
</div>
{/* Arrow */}
@@ -97,13 +95,13 @@ export function Features() {
{/* After */}
<div className="text-center">
<div className="w-20 h-20 mx-auto mb-4 rounded-2xl bg-green-100 flex items-center justify-center">
<Inbox className="w-10 h-10 text-green-500" />
<div className="w-20 h-20 mx-auto mb-4 rounded-2xl bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
<Inbox className="w-10 h-10 text-green-500 dark:text-green-400" />
</div>
<h4 className="font-semibold text-slate-900 mb-1">After</h4>
<p className="text-sm text-slate-500">All sorted</p>
<div className="mt-3 text-3xl font-bold text-green-500">12</div>
<p className="text-xs text-slate-400">important emails</p>
<h4 className="font-semibold text-slate-900 dark:text-slate-100 mb-1">After</h4>
<p className="text-sm text-slate-500 dark:text-slate-400">All sorted</p>
<div className="mt-3 text-3xl font-bold text-green-500 dark:text-green-400">12</div>
<p className="text-xs text-slate-400 dark:text-slate-500">important emails</p>
</div>
</div>
</div>
@@ -119,19 +117,24 @@ 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 dark:from-slate-800 to-slate-50 dark:to-slate-800/50 border-primary-200 dark:border-primary-800 hover:border-primary-300 dark:hover:border-primary-700 hover:shadow-xl'
: 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 hover:border-primary-200 dark:hover:border-primary-800 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>
<p className="text-slate-600">{description}</p>
<h3 className={`${highlight ? 'text-2xl' : 'text-xl'} font-semibold text-slate-900 dark:text-slate-100 mb-2`}>{title}</h3>
<p className="text-slate-600 dark:text-slate-400">{description}</p>
</div>
)
}

View File

@@ -13,11 +13,11 @@ 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">
AI-powered email sorting for more productivity and less stress.
Email sorting for freelancers and small teams. Gmail & Outlook.
</p>
{/* Social links */}
<div className="flex gap-4">
@@ -70,26 +70,19 @@ export function Footer() {
FAQ
</button>
</li>
<li>
<a href="#" className="hover:text-white transition-colors">
Roadmap
</a>
</li>
</ul>
</div>
{/* Company */}
{/* Contact */}
<div>
<h4 className="font-semibold text-white mb-4">Company</h4>
<h4 className="font-semibold text-white mb-4">Contact</h4>
<ul className="space-y-3">
<li>
<a
href="https://webklar.com"
target="_blank"
rel="noopener noreferrer"
href="mailto:support@emailsorter.webklar.com"
className="hover:text-white transition-colors"
>
About us
support@emailsorter.webklar.com
</a>
</li>
<li>
@@ -99,25 +92,7 @@ export function Footer() {
rel="noopener noreferrer"
className="hover:text-white transition-colors"
>
Blog
</a>
</li>
<li>
<a
href="https://webklar.com"
target="_blank"
rel="noopener noreferrer"
className="hover:text-white transition-colors"
>
Careers
</a>
</li>
<li>
<a
href="mailto:support@webklar.com"
className="hover:text-white transition-colors"
>
Contact
webklar.com
</a>
</li>
</ul>
@@ -133,19 +108,14 @@ export function Footer() {
</Link>
</li>
<li>
<Link to="/imprint" className="hover:text-white transition-colors">
Impressum
<Link to="/privacy-security" className="hover:text-white transition-colors">
Privacy & Security
</Link>
</li>
<li>
<a
href="https://webklar.com"
target="_blank"
rel="noopener noreferrer"
className="hover:text-white transition-colors"
>
webklar.com
</a>
<Link to="/imprint" className="hover:text-white transition-colors">
Impressum
</Link>
</li>
</ul>
</div>
@@ -155,10 +125,7 @@ export function Footer() {
<div className="mt-12 pt-8 border-t border-slate-800">
<div className="flex flex-col md:flex-row justify-between items-center gap-4 mb-4">
<p className="text-sm text-slate-500">
© {new Date().getFullYear()} EmailSorter. All rights reserved.
</p>
<p className="text-sm text-slate-500">
Made with
© {new Date().getFullYear()} EmailSorter
</p>
</div>
{/* webklar.com Verweis */}

View File

@@ -1,8 +1,9 @@
import { useNavigate } from 'react-router-dom'
import { captureUTMParams } from '@/lib/analytics'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { ArrowRight, Mail, Inbox, Sparkles, Check, Zap } from 'lucide-react'
import { ArrowRight, Sparkles, Check } from 'lucide-react'
export function Hero() {
const navigate = useNavigate()
@@ -33,47 +34,46 @@ export function Hero() {
<div className="text-center lg:text-left">
<Badge className="mb-6 bg-primary-500/20 text-primary-200 border-primary-400/30">
<Sparkles className="w-3 h-3 mr-1" />
AI-powered email sorting
For freelancers & small teams
</Badge>
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-extrabold text-white leading-tight mb-6">
Your inbox.
Leads, clients, spam
<br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-accent-400 to-primary-300">
Finally organized.
sorted automatically.
</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.
Connect Gmail or Outlook. We put newsletters, promos, and noise in folders so your inbox stays for what pays.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center lg:justify-start mb-8">
<div className="flex flex-col sm:flex-row gap-4 justify-center lg:justify-start mb-4">
<Button
size="xl"
onClick={handleCTAClick}
className="group"
className="group bg-accent-500 hover:bg-accent-600"
>
Start 14-day free trial
Try it free
<ArrowRight className="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" />
</Button>
<Button
size="xl"
variant="outline"
className="bg-white/10 border-white/20 text-white hover:bg-white/20"
onClick={() => document.getElementById('how-it-works')?.scrollIntoView({ behavior: 'smooth' })}
>
See how it works
</Button>
</div>
<p className="text-sm text-slate-400 mb-8">
<button
type="button"
onClick={() => navigate('/setup?demo=true')}
className="underline hover:text-slate-300 transition-colors"
>
Or try a 30-second demo first
</button>
</p>
{/* Trust badges */}
<div className="flex flex-wrap gap-6 justify-center lg:justify-start text-slate-400 text-sm">
<div className="flex items-center gap-2">
<Check className="w-4 h-4 text-accent-400" />
No credit card required
No credit card
</div>
<div className="flex items-center gap-2">
<Check className="w-4 h-4 text-accent-400" />
@@ -86,59 +86,22 @@ export function Hero() {
</div>
</div>
{/* Right side - Visual */}
{/* Right side - Inbox visual (product screenshot feel) */}
<div className="relative hidden lg:block">
<div className="relative">
{/* Main card */}
<div className="bg-white/10 backdrop-blur-xl rounded-3xl border border-white/20 p-6 shadow-2xl animate-float">
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center">
<Inbox className="w-6 h-6 text-white" />
</div>
<div>
<h3 className="font-semibold text-white">Your Inbox</h3>
<p className="text-sm text-slate-400">Auto-sorted</p>
</div>
</div>
{/* Email categories preview */}
<div className="space-y-3">
<EmailPreview
category="Important"
color="bg-red-500"
sender="John Smith"
subject="Meeting tomorrow at 10"
delay="stagger-1"
/>
<EmailPreview
category="Invoice"
color="bg-green-500"
sender="Amazon"
subject="Invoice for order #12345"
delay="stagger-2"
/>
<EmailPreview
category="Newsletter"
color="bg-purple-500"
sender="Tech Daily"
subject="Latest AI trends"
delay="stagger-3"
/>
<EmailPreview
category="Social"
color="bg-cyan-500"
sender="LinkedIn"
subject="3 new connection requests"
delay="stagger-4"
/>
</div>
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 max-w-md overflow-hidden">
<div className="border-b border-slate-200 dark:border-slate-700 px-4 py-2.5">
<span className="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">Inbox</span>
</div>
{/* Floating badge */}
<div className="absolute -right-4 top-1/4 bg-accent-500 text-white px-4 py-2 rounded-full text-sm font-semibold shadow-lg animate-pulse-glow">
<Zap className="w-4 h-4 inline mr-1" />
AI sorting
<div className="divide-y divide-slate-100 dark:divide-slate-800">
<InboxRow sender="Sarah Chen" subject="Re: Project quote" label="Lead" isFocal />
<InboxRow sender="Mike, Acme Inc" subject="Invoice #8821" label="Client" />
<InboxRow sender="Newsletter" subject="Your weekly digest" label="Noise" />
<InboxRow sender="Support" subject="Your ticket #443" label="Client" />
<InboxRow sender="Promo" subject="20% off this week" label="Noise" />
</div>
<p className="text-xs text-slate-400 dark:text-slate-500 px-4 py-3 border-t border-slate-100 dark:border-slate-800">
This happens automatically on new emails.
</p>
</div>
</div>
</div>
@@ -154,26 +117,39 @@ export function Hero() {
)
}
interface EmailPreviewProps {
category: string
color: string
sender: string
subject: string
delay: string
type InboxLabel = 'Lead' | 'Client' | 'Noise'
const labelClass: Record<InboxLabel, string> = {
Lead: 'text-primary-600 dark:text-primary-500',
Client: 'text-slate-600 dark:text-slate-600',
Noise: 'text-slate-400 dark:text-slate-500',
}
function EmailPreview({ category, color, sender, subject, delay }: EmailPreviewProps) {
interface InboxRowProps {
sender: string
subject: string
label: InboxLabel
isFocal?: boolean
}
function InboxRow({ sender, subject, label, isFocal = false }: InboxRowProps) {
return (
<div className={`flex items-center gap-3 bg-white/5 rounded-xl p-3 border border-white/10 opacity-0 animate-[fadeIn_0.5s_ease-out_forwards] ${delay}`}>
<div className={`w-2 h-10 rounded-full ${color}`} />
<div
className={cn(
"flex items-center gap-4 px-4 py-2.5",
isFocal && "border-l-2 border-l-primary-500 bg-slate-50/80 dark:bg-slate-800/50"
)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-sm font-medium text-white truncate">{sender}</span>
<span className={`text-xs px-2 py-0.5 rounded ${color} text-white`}>{category}</span>
</div>
<p className="text-sm text-slate-400 truncate">{subject}</p>
<p className={cn(
"truncate",
isFocal ? "text-sm font-semibold text-slate-900 dark:text-slate-100" : "text-sm font-medium text-slate-600 dark:text-slate-400"
)}>
{sender}
</p>
<p className="text-xs text-slate-500 dark:text-slate-500 truncate mt-0.5">{subject}</p>
</div>
<Mail className="w-4 h-4 text-slate-500 flex-shrink-0" />
<span className={cn("text-xs flex-shrink-0", labelClass[label])}>{label}</span>
</div>
)
}

View File

@@ -17,35 +17,35 @@ const steps = [
icon: Link2,
step: "02",
title: "Connect email",
description: "Connect Gmail or Outlook with one click. Secure OAuth authentication.",
description: "Sign in with Google or Microsoft. We never see your password.",
},
{
icon: Sparkles,
step: "03",
title: "AI analyzes",
description: "Our AI learns your email patterns and creates personalized sorting rules.",
title: "We categorize",
description: "We read sender and subject, put each email in a category. No rules to write.",
},
{
icon: PartyPopper,
step: "04",
title: "Enjoy Inbox Zero",
description: "Sit back and enjoy a clean inbox automatically.",
title: "Inbox stays clean",
description: "Newsletters and promos go to folders. Your inbox shows what matters first.",
},
]
export function HowItWorks() {
return (
<section id="how-it-works" className="py-24 bg-white">
<section id="how-it-works" className="py-24 bg-white dark:bg-slate-900">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Section header */}
<div className="text-center mb-16">
<h2 className="text-3xl sm:text-4xl font-bold text-slate-900 mb-4">
<h2 className="text-3xl sm:text-4xl font-bold text-slate-900 dark:text-slate-100 mb-4">
4 steps to a{' '}
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary-600 to-accent-500">
clean inbox
</span>
</h2>
<p className="text-lg text-slate-600 max-w-2xl mx-auto">
<p className="text-lg text-slate-600 dark:text-slate-400 max-w-2xl mx-auto">
Get started in minutes no technical knowledge required.
</p>
</div>
@@ -65,11 +65,11 @@ export function HowItWorks() {
{/* CTA */}
<div className="mt-16 text-center">
<div className="inline-flex flex-col items-center">
<ArrowDown className="w-8 h-8 text-primary-400 animate-bounce mb-4" />
<p className="text-slate-600 mb-2">Ready to get started?</p>
<ArrowDown className="w-8 h-8 text-primary-400 dark:text-primary-500 animate-bounce mb-4" />
<p className="text-slate-600 dark:text-slate-400 mb-2">Ready to get started?</p>
<a
href="/register"
className="text-primary-600 font-semibold hover:text-primary-700 transition-colors"
className="text-primary-600 dark:text-primary-400 font-semibold hover:text-primary-700 dark:hover:text-primary-300 transition-colors"
>
Try it free now
</a>
@@ -91,20 +91,20 @@ function StepCard({ icon: Icon, step, title, description }: StepCardProps) {
return (
<div className="relative">
{/* Card */}
<div className="bg-slate-50 rounded-2xl p-6 text-center hover:bg-white hover:shadow-xl transition-all duration-300 border border-transparent hover:border-slate-200">
<div className="bg-slate-50 dark:bg-slate-800 rounded-2xl p-6 text-center hover:bg-white dark:hover:bg-slate-700 hover:shadow-xl transition-all duration-300 border border-transparent hover:border-slate-200 dark:hover:border-slate-600">
{/* Step number */}
<div className="absolute -top-4 left-1/2 -translate-x-1/2 bg-gradient-to-r from-primary-500 to-primary-600 text-white text-sm font-bold px-4 py-1 rounded-full shadow-md">
<div className="absolute -top-4 left-1/2 -translate-x-1/2 bg-gradient-to-r from-primary-500 to-primary-600 dark:from-primary-600 dark:to-primary-700 text-white text-sm font-bold px-4 py-1 rounded-full shadow-md">
{step}
</div>
{/* Icon */}
<div className="w-16 h-16 mx-auto mt-4 mb-4 rounded-2xl bg-white shadow-md flex items-center justify-center">
<Icon className="w-8 h-8 text-primary-600" />
<div className="w-16 h-16 mx-auto mt-4 mb-4 rounded-2xl bg-white dark:bg-slate-700 shadow-md flex items-center justify-center">
<Icon className="w-8 h-8 text-primary-600 dark:text-primary-400" />
</div>
{/* Content */}
<h3 className="text-lg font-semibold text-slate-900 mb-2">{title}</h3>
<p className="text-slate-600 text-sm">{description}</p>
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">{title}</h3>
<p className="text-slate-600 dark:text-slate-400 text-sm">{description}</p>
</div>
</div>
)

View File

@@ -28,7 +28,7 @@ export function Navbar() {
}, [location.pathname, navigate])
return (
<nav className="fixed top-0 left-0 right-0 z-50 bg-white/80 backdrop-blur-lg border-b border-slate-100">
<nav className="fixed top-0 left-0 right-0 z-50 bg-white/80 dark:bg-slate-900/80 backdrop-blur-lg border-b border-slate-100 dark:border-slate-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Logo */}
@@ -36,8 +36,8 @@ export function Navbar() {
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center">
<Mail className="w-5 h-5 text-white" />
</div>
<span className="text-xl font-bold text-slate-900">
Email<span className="text-primary-600">Sorter</span>
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">
E-Mail-<span className="text-primary-600 dark:text-primary-400">Sorter</span>
</span>
</Link>
@@ -45,25 +45,25 @@ export function Navbar() {
<div className="hidden md:flex items-center gap-8">
<button
onClick={() => scrollToSection('features')}
className="text-slate-600 hover:text-slate-900 font-medium transition-colors"
className="text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-slate-100 font-medium transition-colors"
>
Features
</button>
<button
onClick={() => scrollToSection('how-it-works')}
className="text-slate-600 hover:text-slate-900 font-medium transition-colors"
className="text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-slate-100 font-medium transition-colors"
>
How it works
</button>
<button
onClick={() => scrollToSection('pricing')}
className="text-slate-600 hover:text-slate-900 font-medium transition-colors"
className="text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-slate-100 font-medium transition-colors"
>
Pricing
</button>
<button
onClick={() => scrollToSection('faq')}
className="text-slate-600 hover:text-slate-900 font-medium transition-colors"
className="text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-slate-100 font-medium transition-colors"
>
FAQ
</button>
@@ -82,7 +82,7 @@ export function Navbar() {
Sign in
</Button>
<Button onClick={() => navigate('/register')}>
Get started free
Try it free
</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
@@ -146,7 +146,7 @@ export function Navbar() {
Sign in
</Button>
<Button className="w-full h-11" onClick={() => navigate('/register')}>
Get started free
Try it free
</Button>
</>
)}

View File

@@ -64,7 +64,7 @@ export function Pricing() {
const navigate = useNavigate()
return (
<section id="pricing" className="py-24 bg-slate-50">
<section id="pricing" className="py-24 bg-slate-50 dark:bg-slate-900">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Section header */}
<div className="text-center mb-16">
@@ -72,10 +72,10 @@ export function Pricing() {
<Sparkles className="w-3 h-3 mr-1" />
14-day free trial
</Badge>
<h2 className="text-3xl sm:text-4xl font-bold text-slate-900 mb-4">
<h2 className="text-3xl sm:text-4xl font-bold text-slate-900 dark:text-slate-100 mb-4">
Simple, transparent pricing
</h2>
<p className="text-lg text-slate-600 max-w-2xl mx-auto">
<p className="text-lg text-slate-600 dark:text-slate-400 max-w-2xl mx-auto">
Choose the plan that fits you. Cancel anytime, no hidden costs.
</p>
</div>
@@ -93,11 +93,11 @@ export function Pricing() {
{/* FAQ teaser */}
<div className="mt-16 text-center">
<p className="text-slate-600">
<p className="text-slate-600 dark:text-slate-400">
Still have questions?{' '}
<button
onClick={() => document.getElementById('faq')?.scrollIntoView({ behavior: 'smooth' })}
className="text-primary-600 font-semibold hover:text-primary-700"
className="text-primary-600 dark:text-primary-400 font-semibold hover:text-primary-700 dark:hover:text-primary-300"
>
Check our FAQ
</button>
@@ -131,15 +131,15 @@ function PricingCard({
}: PricingCardProps) {
return (
<div
className={`relative bg-white rounded-2xl p-8 ${
className={`relative bg-white dark:bg-slate-800 rounded-2xl p-8 ${
popular
? 'ring-2 ring-primary-500 shadow-xl scale-105'
: 'border border-slate-200 hover:border-primary-200 hover:shadow-lg'
? 'ring-2 ring-primary-500 dark:ring-primary-400 shadow-xl scale-105'
: 'border border-slate-200 dark:border-slate-700 hover:border-primary-200 dark:hover:border-primary-800 hover:shadow-lg'
} transition-all duration-300`}
>
{popular && (
<div className="absolute -top-4 left-1/2 -translate-x-1/2">
<Badge className="bg-primary-500 text-white border-0 shadow-md">
<Badge className="bg-primary-500 dark:bg-primary-600 text-white border-0 shadow-md">
Most Popular
</Badge>
</div>
@@ -147,15 +147,15 @@ function PricingCard({
{/* Header */}
<div className="text-center mb-6">
<h3 className="text-xl font-bold text-slate-900 mb-1">{name}</h3>
<p className="text-sm text-slate-500">{description}</p>
<h3 className="text-xl font-bold text-slate-900 dark:text-slate-100 mb-1">{name}</h3>
<p className="text-sm text-slate-500 dark:text-slate-400">{description}</p>
</div>
{/* Price */}
<div className="text-center mb-8">
<div className="flex items-baseline justify-center">
<span className="text-5xl font-extrabold text-slate-900">${price}</span>
<span className="text-slate-500 ml-1">{period}</span>
<span className="text-5xl font-extrabold text-slate-900 dark:text-slate-100">${price}</span>
<span className="text-slate-500 dark:text-slate-400 ml-1">{period}</span>
</div>
</div>
@@ -164,15 +164,15 @@ function PricingCard({
{features.map((feature, index) => (
<li key={index} className="flex items-center gap-3">
{feature.included ? (
<div className="w-5 h-5 rounded-full bg-green-100 flex items-center justify-center flex-shrink-0">
<Check className="w-3 h-3 text-green-600" />
<div className="w-5 h-5 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center flex-shrink-0">
<Check className="w-3 h-3 text-green-600 dark:text-green-400" />
</div>
) : (
<div className="w-5 h-5 rounded-full bg-slate-100 flex items-center justify-center flex-shrink-0">
<X className="w-3 h-3 text-slate-400" />
<div className="w-5 h-5 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center flex-shrink-0">
<X className="w-3 h-3 text-slate-400 dark:text-slate-500" />
</div>
)}
<span className={feature.included ? 'text-slate-700' : 'text-slate-400'}>
<span className={feature.included ? 'text-slate-700 dark:text-slate-300' : 'text-slate-400 dark:text-slate-500'}>
{feature.text}
</span>
</li>

View File

@@ -1,67 +1,51 @@
import { CheckCircle2, Clock, Brain, Shield } from 'lucide-react'
import { Code2, Users, Zap } from 'lucide-react'
const benefits = [
const items = [
{
icon: Clock,
title: "Save 2+ hours/week",
description: "Less time sorting emails, more time for important tasks.",
icon: Code2,
title: "Built in public",
description: "We ship updates and share progress openly. No hype, no fake traction.",
},
{
icon: Brain,
title: "AI does it automatically",
description: "Set up once, then everything runs by itself.",
icon: Users,
title: "Early users",
description: "We're in beta. Feedback from freelancers and small teams shapes the product.",
},
{
icon: Shield,
title: "Privacy first",
description: "Your emails stay private. We don't store any content.",
},
{
icon: CheckCircle2,
title: "Easy to use",
description: "No learning curve. Ready to go in 2 minutes.",
icon: Zap,
title: "Simple setup",
description: "Connect Gmail or Outlook, click Sort. No long onboarding or sales call.",
},
]
export function Testimonials() {
return (
<section className="py-20 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Section header */}
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h2 className="text-3xl sm:text-4xl font-bold text-white mb-4">
Why EmailSorter?
<h2 className="text-2xl sm:text-3xl font-bold text-white mb-2">
Honest context
</h2>
<p className="text-lg text-slate-300 max-w-2xl mx-auto">
No more email chaos. Focus on what matters.
<p className="text-slate-400 text-sm sm:text-base">
We're a small product. Here's how we work.
</p>
</div>
{/* Benefits grid */}
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
{benefits.map((benefit, index) => (
<BenefitCard key={index} {...benefit} />
<div className="grid md:grid-cols-3 gap-6">
{items.map((item, index) => (
<div
key={index}
className="bg-white/5 backdrop-blur-sm rounded-xl p-6 border border-white/10"
>
<div className="w-10 h-10 rounded-lg bg-primary-500/20 flex items-center justify-center mb-4">
<item.icon className="w-5 h-5 text-primary-400" />
</div>
<h3 className="text-base font-semibold text-white mb-1">{item.title}</h3>
<p className="text-slate-400 text-sm">{item.description}</p>
</div>
))}
</div>
</div>
</section>
)
}
interface BenefitCardProps {
icon: React.ElementType
title: string
description: string
}
function BenefitCard({ icon: Icon, title, description }: BenefitCardProps) {
return (
<div className="bg-white/5 backdrop-blur-sm rounded-xl p-6 border border-white/10 hover:bg-white/10 transition-colors">
<div className="w-12 h-12 rounded-lg bg-primary-500/20 flex items-center justify-center mb-4">
<Icon className="w-6 h-6 text-primary-400" />
</div>
<h3 className="text-lg font-semibold text-white mb-2">{title}</h3>
<p className="text-slate-400 text-sm">{description}</p>
</div>
)
}

View File

@@ -0,0 +1,48 @@
import { Shield, Mail, Trash2 } from 'lucide-react'
export function TrustSection() {
return (
<section id="trust" className="py-16 sm:py-20 bg-white dark:bg-slate-900 border-y border-slate-200 dark:border-slate-800">
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 className="text-2xl sm:text-3xl font-bold text-slate-900 dark:text-slate-100 text-center mb-10">
Your data, in plain language
</h2>
<ul className="space-y-6">
<li className="flex gap-4 items-start">
<div className="w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center flex-shrink-0">
<Mail className="w-5 h-5 text-slate-600 dark:text-slate-400" />
</div>
<div>
<p className="font-semibold text-slate-900 dark:text-slate-100 mb-0.5">We only read what we need</p>
<p className="text-slate-600 dark:text-slate-400 text-sm">
We use sender, subject, and a short snippet to decide the category (e.g. newsletter vs client). We don&apos;t store your email body or attachments.
</p>
</div>
</li>
<li className="flex gap-4 items-start">
<div className="w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center flex-shrink-0">
<Shield className="w-5 h-5 text-slate-600 dark:text-slate-400" />
</div>
<div>
<p className="font-semibold text-slate-900 dark:text-slate-100 mb-0.5">No selling, no ads</p>
<p className="text-slate-600 dark:text-slate-400 text-sm">
Your email data is not used for advertising or sold to anyone. We run a paid product; our revenue comes from subscriptions, not your inbox.
</p>
</div>
</li>
<li className="flex gap-4 items-start">
<div className="w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center flex-shrink-0">
<Trash2 className="w-5 h-5 text-slate-600 dark:text-slate-400" />
</div>
<div>
<p className="font-semibold text-slate-900 dark:text-slate-100 mb-0.5">You can leave anytime</p>
<p className="text-slate-600 dark:text-slate-400 text-sm">
Disconnect your account and we stop. Cancel your subscription with one click. No lock-in, no &quot;contact sales&quot; to leave.
</p>
</div>
</li>
</ul>
</div>
</section>
)
}

View File

@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
@@ -8,16 +9,16 @@ const badgeVariants = cva(
variants: {
variant: {
default:
"border-transparent bg-primary-100 text-primary-700",
"border-transparent bg-primary-100 dark:bg-primary-900 text-primary-700 dark:text-primary-200",
secondary:
"border-transparent bg-slate-100 text-slate-700",
"border-transparent bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-200",
success:
"border-transparent bg-green-100 text-green-700",
"border-transparent bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200",
warning:
"border-transparent bg-amber-100 text-amber-700",
"border-transparent bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-200",
destructive:
"border-transparent bg-red-100 text-red-700",
outline: "text-slate-600 border-slate-200",
"border-transparent bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-200",
outline: "text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-700",
},
},
defaultVariants: {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import React, { createContext, useContext, useEffect, useState } from 'react'
import { auth } from '@/lib/appwrite'
import type { Models } from 'appwrite'

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,8 @@ interface ApiResponse<T> {
code: string
message: string
fields?: Record<string, string[]>
limit?: number
used?: number
}
}
@@ -54,7 +56,7 @@ export const api = {
return fetchApi<Array<{
id: string
email: string
provider: 'gmail' | 'outlook'
provider: 'gmail' | 'outlook' | 'imap'
connected: boolean
lastSync?: string
}>>(`/email/accounts?userId=${userId}`)
@@ -67,6 +69,24 @@ export const api = {
})
},
async connectImapAccount(
userId: string,
params: { email: string; password: string; imapHost?: string; imapPort?: number; imapSecure?: boolean }
) {
return fetchApi<{ accountId: string }>('/email/connect', {
method: 'POST',
body: JSON.stringify({
userId,
provider: 'imap',
email: params.email,
accessToken: params.password,
imapHost: params.imapHost,
imapPort: params.imapPort,
imapSecure: params.imapSecure,
}),
})
},
async disconnectEmailAccount(accountId: string, userId: string) {
return fetchApi<{ success: boolean }>(`/email/accounts/${accountId}?userId=${userId}`, {
method: 'DELETE',
@@ -97,6 +117,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?: { name?: string }
}>
}>('/email/sort', {
method: 'POST',
body: JSON.stringify({ userId, accountId, maxEmails, processAll }),
@@ -205,6 +233,9 @@ export const api = {
return fetchApi<{
status: string
plan: string
isFreeTier: boolean
emailsUsedThisMonth?: number
emailsLimit?: number
features: {
emailAccounts: number
emailsPerDay: number
@@ -263,6 +294,7 @@ export const api = {
blockedSenders?: string[]
customRules?: Array<{ condition: string; category: string }>
priorityTopics?: string[]
companyLabels?: Array<{ name: string; condition?: string; category: string; enabled: boolean }>
}) {
return fetchApi<{ success: boolean }>('/preferences', {
method: 'POST',
@@ -270,19 +302,181 @@ export const api = {
})
},
// ═══════════════════════════════════════════════════════════════════════════
// AI CONTROL
// ═══════════════════════════════════════════════════════════════════════════
async getAIControlSettings(userId: string) {
return fetchApi<{
enabledCategories: string[]
categoryActions: Record<string, 'inbox' | 'archive_read' | 'star'>
autoDetectCompanies: boolean
cleanup?: unknown
categoryAdvanced?: Record<string, unknown>
version?: number
}>(`/preferences/ai-control?userId=${userId}`)
},
async saveAIControlSettings(userId: string, settings: {
enabledCategories?: string[]
categoryActions?: Record<string, 'inbox' | 'archive_read' | 'star'>
autoDetectCompanies?: boolean
cleanup?: unknown
categoryAdvanced?: Record<string, unknown>
version?: number
}) {
return fetchApi<{ success: boolean }>('/preferences/ai-control', {
method: 'POST',
body: JSON.stringify({ userId, ...settings }),
})
},
// 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
// ═══════════════════════════════════════════════════════════════════════════
async getCompanyLabels(userId: string) {
return fetchApi<Array<{
id?: string
name: string
condition: string
enabled: boolean
category?: string
}>>(`/preferences/company-labels?userId=${userId}`)
},
async saveCompanyLabel(userId: string, companyLabel: {
id?: string
name: string
condition: string
enabled: boolean
category?: string
}) {
return fetchApi<{
id?: string
name: string
condition: string
enabled: boolean
category?: string
}>('/preferences/company-labels', {
method: 'POST',
body: JSON.stringify({ userId, companyLabel }),
})
},
async deleteCompanyLabel(userId: string, labelId: string) {
return fetchApi<{ success: boolean }>(`/preferences/company-labels/${labelId}?userId=${userId}`, {
method: 'DELETE',
})
},
// ═══════════════════════════════════════════════════════════════════════════
// ME / ADMIN
// ═══════════════════════════════════════════════════════════════════════════
async getMe(email: string) {
return fetchApi<{ isAdmin: boolean }>(`/me?email=${encodeURIComponent(email)}`)
},
// ═══════════════════════════════════════════════════════════════════════════
// NAME LABELS (Workers Admin only)
// ═══════════════════════════════════════════════════════════════════════════
async getNameLabels(userId: string, email: string) {
return fetchApi<Array<{
id?: string
name: string
email?: string
keywords?: string[]
enabled: boolean
}>>(`/preferences/name-labels?userId=${userId}&email=${encodeURIComponent(email)}`)
},
async saveNameLabel(
userId: string,
userEmail: string,
nameLabel: { id?: string; name: string; email?: string; keywords?: string[]; enabled: boolean }
) {
return fetchApi<{ id?: string; name: string; email?: string; keywords?: string[]; enabled: boolean }>(
'/preferences/name-labels',
{
method: 'POST',
body: JSON.stringify({ userId, email: userEmail, nameLabel }),
}
)
},
async deleteNameLabel(userId: string, userEmail: string, labelId: string) {
return fetchApi<{ success: boolean }>(
`/preferences/name-labels/${labelId}?userId=${userId}&email=${encodeURIComponent(userEmail)}`,
{ method: 'DELETE' }
)
},
// ═══════════════════════════════════════════════════════════════════════════
// PRODUCTS & QUESTIONS (Legacy)
// ═══════════════════════════════════════════════════════════════════════════
async getProducts() {
return fetchApi<any[]>('/products')
return fetchApi<unknown[]>('/products')
},
async getQuestions(productSlug: string) {
return fetchApi<any[]>(`/questions?productSlug=${productSlug}`)
return fetchApi<unknown[]>(`/questions?productSlug=${productSlug}`)
},
async createSubmission(productSlug: string, answers: Record<string, any>) {
async createSubmission(productSlug: string, answers: Record<string, unknown>) {
return fetchApi<{ submissionId: string }>('/submissions', {
method: 'POST',
body: JSON.stringify({ productSlug, answers }),
@@ -324,6 +518,72 @@ export const api = {
uptime: number
}>('/health')
},
// ═══════════════════════════════════════════════════════════════════════════
// ONBOARDING
// ═══════════════════════════════════════════════════════════════════════════
async getOnboardingStatus(userId: string) {
return fetchApi<{
onboarding_step: string
completedSteps: string[]
first_value_seen_at?: string
skipped_at?: string
}>(`/onboarding/status?userId=${userId}`)
},
async updateOnboardingStep(userId: string, step: string, completedSteps: string[] = []) {
return fetchApi<{ step: string; completedSteps: string[] }>('/onboarding/step', {
method: 'POST',
body: JSON.stringify({ userId, step, completedSteps }),
})
},
async skipOnboarding(userId: string) {
return fetchApi<{ skipped: boolean }>('/onboarding/skip', {
method: 'POST',
body: JSON.stringify({ userId }),
})
},
async resumeOnboarding(userId: string) {
return fetchApi<{
onboarding_step: string
completedSteps: string[]
}>('/onboarding/resume', {
method: 'POST',
body: JSON.stringify({ userId }),
})
},
// ═══════════════════════════════════════════════════════════════════════════
// ACCOUNT MANAGEMENT
// ═══════════════════════════════════════════════════════════════════════════
async deleteAccount(userId: string) {
return fetchApi<{ success: boolean }>('/account/delete', {
method: 'DELETE',
body: JSON.stringify({ userId }),
})
},
// ═══════════════════════════════════════════════════════════════════════════
// REFERRALS
// ═══════════════════════════════════════════════════════════════════════════
async getReferralCode(userId: string) {
return fetchApi<{
referralCode: string
referralCount: number
}>(`/referrals/code?userId=${userId}`)
},
async trackReferral(userId: string, referralCode: string) {
return fetchApi<{ success: boolean }>('/referrals/track', {
method: 'POST',
body: JSON.stringify({ userId, referralCode }),
})
},
}
export default api

File diff suppressed because it is too large Load Diff

View File

@@ -21,30 +21,30 @@ export function ForgotPassword() {
try {
await auth.forgotPassword(email)
setSent(true)
} catch (err: any) {
setError(err.message || 'Fehler beim Senden der E-Mail')
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Fehler beim Senden der E-Mail')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center p-4">
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Logo */}
<Link to="/" className="flex items-center justify-center gap-2 mb-8">
<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">
Email<span className="text-primary-600 dark:text-primary-400">Sorter</span>
</span>
</Link>
<Card className="shadow-xl border-0">
<Card className="shadow-xl border-0 dark:bg-slate-800 dark:border-slate-700">
<CardHeader className="text-center pb-2">
<CardTitle className="text-2xl">Passwort vergessen?</CardTitle>
<CardDescription>
<CardTitle className="text-2xl dark:text-slate-100">Passwort vergessen?</CardTitle>
<CardDescription className="dark:text-slate-400">
{sent
? 'Prüfe dein E-Mail-Postfach'
: 'Gib deine E-Mail-Adresse ein und wir senden dir einen Link zum Zurücksetzen.'
@@ -54,14 +54,14 @@ export function ForgotPassword() {
<CardContent>
{sent ? (
<div className="text-center py-8">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-green-100 flex items-center justify-center">
<CheckCircle className="w-8 h-8 text-green-600" />
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
<CheckCircle className="w-8 h-8 text-green-600 dark:text-green-400" />
</div>
<h3 className="font-semibold text-slate-900 mb-2">E-Mail gesendet!</h3>
<p className="text-slate-600 mb-6">
Wir haben dir eine E-Mail mit einem Link zum Zurücksetzen deines Passworts an <strong>{email}</strong> gesendet.
<h3 className="font-semibold text-slate-900 dark:text-slate-100 mb-2">E-Mail gesendet!</h3>
<p className="text-slate-600 dark:text-slate-400 mb-6">
Wir haben dir eine E-Mail mit einem Link zum Zurücksetzen deines Passworts an <strong className="text-slate-900 dark:text-slate-100">{email}</strong> gesendet.
</p>
<p className="text-sm text-slate-500 mb-6">
<p className="text-sm text-slate-500 dark:text-slate-400 mb-6">
Keine E-Mail erhalten? Prüfe deinen Spam-Ordner oder versuche es erneut.
</p>
<div className="space-y-3">
@@ -83,13 +83,13 @@ export function ForgotPassword() {
) : (
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
<div className="p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-300 text-sm">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="email">E-Mail-Adresse</Label>
<Label htmlFor="email" className="dark:text-slate-200">E-Mail-Adresse</Label>
<Input
id="email"
type="email"
@@ -98,6 +98,7 @@ export function ForgotPassword() {
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
className="dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100"
/>
</div>
@@ -115,7 +116,7 @@ export function ForgotPassword() {
<div className="text-center">
<Link
to="/login"
className="text-sm text-primary-600 hover:text-primary-700"
className="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300"
>
<ArrowLeft className="w-4 h-4 inline mr-1" />
Zurück zum Login

View File

@@ -2,8 +2,9 @@ import { Navbar } from '@/components/landing/Navbar'
import { Hero } from '@/components/landing/Hero'
import { Features } from '@/components/landing/Features'
import { HowItWorks } from '@/components/landing/HowItWorks'
import { Pricing } from '@/components/landing/Pricing'
import { Testimonials } from '@/components/landing/Testimonials'
import { TrustSection } from '@/components/landing/TrustSection'
import { Pricing } from '@/components/landing/Pricing'
import { FAQ } from '@/components/landing/FAQ'
import { Footer } from '@/components/landing/Footer'
@@ -15,6 +16,7 @@ export function Home() {
<Features />
<HowItWorks />
<Testimonials />
<TrustSection />
<Pricing />
<FAQ />
<Footer />

View File

@@ -3,13 +3,13 @@ import { ArrowLeft, Building2 } from 'lucide-react'
export function Imprint() {
return (
<div className="min-h-screen bg-slate-50">
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
{/* 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>
@@ -19,56 +19,56 @@ export function Imprint() {
{/* Content */}
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-8 md:p-12">
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 p-8 md:p-12">
{/* Title */}
<div className="flex items-center gap-3 mb-8">
<div className="w-12 h-12 rounded-lg bg-primary-100 flex items-center justify-center">
<Building2 className="w-6 h-6 text-primary-600" />
<div className="w-12 h-12 rounded-lg bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center">
<Building2 className="w-6 h-6 text-primary-600 dark:text-primary-400" />
</div>
<div>
<h1 className="text-3xl font-bold text-slate-900">Impressum</h1>
<p className="text-slate-500 mt-1">Legal Information</p>
<h1 className="text-3xl font-bold text-slate-900 dark:text-slate-100">Impressum</h1>
<p className="text-slate-500 dark:text-slate-400 mt-1">Legal Information</p>
</div>
</div>
{/* Content - Placeholder for webklar.com content */}
<div className="prose prose-slate max-w-none">
<p className="text-slate-600 mb-6">
<div className="prose prose-slate max-w-none dark:prose-invert">
<p className="text-slate-600 dark:text-slate-400 mb-6">
<strong>Note:</strong> This imprint is managed by webklar.com. Please refer to their imprint for detailed information.
</p>
<div className="bg-slate-50 border border-slate-200 rounded-lg p-6 mb-8">
<h2 className="text-xl font-semibold text-slate-900 mb-4">Information according to § 5 TMG</h2>
<div className="bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 rounded-lg p-6 mb-8">
<h2 className="text-xl font-semibold text-slate-900 dark:text-slate-100 mb-4">Information according to § 5 TMG</h2>
<div className="space-y-6 text-slate-700">
<div className="space-y-6 text-slate-700 dark:text-slate-300">
<div>
<h3 className="text-lg font-semibold text-slate-900 mb-2">Operator</h3>
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">Operator</h3>
<p className="mb-2">EmailSorter is operated by:</p>
<p className="mb-4">
<strong>webklar.com</strong><br />
Kenso Grimm, Justin Klein
</p>
<p className="text-sm text-slate-600 mb-4">
<p className="text-sm text-slate-600 dark:text-slate-400 mb-4">
For complete contact details and legal information, please visit:{' '}
<a
href="https://webklar.com/impressum"
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 hover:text-primary-700 underline"
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 underline"
>
webklar.com/impressum
</a>
</p>
</div>
<div className="pt-6 border-t border-slate-200">
<h3 className="text-lg font-semibold text-slate-900 mb-2">Contact</h3>
<div className="pt-6 border-t border-slate-200 dark:border-slate-700">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">Contact</h3>
<div className="space-y-2">
<p>
<strong>Email:</strong>{' '}
<a
href="mailto:support@webklar.com"
className="text-primary-600 hover:text-primary-700 underline"
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 underline"
>
support@webklar.com
</a>
@@ -77,23 +77,23 @@ export function Imprint() {
<strong>Phone:</strong>{' '}
<a
href="tel:+4917623726355"
className="text-primary-600 hover:text-primary-700 underline"
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 underline"
>
+49 176 23726355
</a>
{' / '}
<a
href="tel:+491704969375"
className="text-primary-600 hover:text-primary-700 underline"
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 underline"
>
+49 170 4969375
</a>
</p>
<p className="mt-4 text-sm text-slate-600">
<p className="mt-4 text-sm text-slate-600 dark:text-slate-400">
For questions regarding EmailSorter specifically:{' '}
<a
href="mailto:support@emailsorter.com"
className="text-primary-600 hover:text-primary-700 underline"
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 underline"
>
support@emailsorter.com
</a>
@@ -101,8 +101,8 @@ export function Imprint() {
</div>
</div>
<div className="pt-6 border-t border-slate-200">
<h3 className="text-lg font-semibold text-slate-900 mb-2">Responsible for Content</h3>
<div className="pt-6 border-t border-slate-200 dark:border-slate-700">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">Responsible for Content</h3>
<p>
The content of this website is the responsibility of webklar.com.
For detailed information, please refer to the official imprint at{' '}
@@ -110,23 +110,23 @@ export function Imprint() {
href="https://webklar.com/impressum"
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 hover:text-primary-700 underline"
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 underline"
>
webklar.com/impressum
</a>
</p>
</div>
<div className="pt-6 border-t border-slate-200">
<h3 className="text-lg font-semibold text-slate-900 mb-2">Liability for Links</h3>
<div className="pt-6 border-t border-slate-200 dark:border-slate-700">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">Liability for Links</h3>
<p>
Our website contains links to external websites. We have no influence on the content of these websites.
Therefore, we cannot assume any liability for these external contents.
</p>
</div>
<div className="pt-6 border-t border-slate-200">
<h3 className="text-lg font-semibold text-slate-900 mb-2">Copyright</h3>
<div className="pt-6 border-t border-slate-200 dark:border-slate-700">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">Copyright</h3>
<p>
The content and works on this website are subject to German copyright law.
Reproduction, processing, distribution, and any form of commercialization require the written consent of the respective author or creator.
@@ -134,14 +134,14 @@ export function Imprint() {
</div>
</div>
<div className="mt-8 pt-6 border-t border-slate-200">
<p className="text-sm text-slate-500">
<div className="mt-8 pt-6 border-t border-slate-200 dark:border-slate-700">
<p className="text-sm text-slate-500 dark:text-slate-400">
<strong>Important:</strong> This is a simplified version. For the complete and legally binding imprint, please visit{' '}
<a
href="https://webklar.com/impressum"
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 hover:text-primary-700 underline"
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 underline"
>
webklar.com/impressum
</a>

View File

@@ -23,8 +23,8 @@ export function Login() {
try {
await login(email, password)
navigate('/dashboard')
} catch (err: any) {
setError(err.message || 'Login failed. Please check your credentials.')
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Login failed. Please check your credentials.')
} finally {
setLoading(false)
}
@@ -41,7 +41,7 @@ export function Login() {
<Mail className="w-5 h-5 text-white" />
</div>
<span className="text-xl font-bold text-white">
Email<span className="text-primary-400">Sorter</span>
E-Mail-<span className="text-primary-400">Sorter</span>
</span>
</Link>

View File

@@ -3,13 +3,13 @@ import { ArrowLeft, Shield } from 'lucide-react'
export function Privacy() {
return (
<div className="min-h-screen bg-slate-50">
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
{/* 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>
@@ -19,81 +19,81 @@ export function Privacy() {
{/* Content */}
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-8 md:p-12">
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 p-8 md:p-12">
{/* Title */}
<div className="flex items-center gap-3 mb-8">
<div className="w-12 h-12 rounded-lg bg-primary-100 flex items-center justify-center">
<Shield className="w-6 h-6 text-primary-600" />
<div className="w-12 h-12 rounded-lg bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center">
<Shield className="w-6 h-6 text-primary-600 dark:text-primary-400" />
</div>
<div>
<h1 className="text-3xl font-bold text-slate-900">Privacy Policy</h1>
<p className="text-slate-500 mt-1">Last updated: {new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}</p>
<h1 className="text-3xl font-bold text-slate-900 dark:text-slate-100">Privacy Policy</h1>
<p className="text-slate-500 dark:text-slate-400 mt-1">Last updated: {new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}</p>
</div>
</div>
{/* Content - Placeholder for webklar.com content */}
<div className="prose prose-slate max-w-none">
<p className="text-slate-600 mb-6">
<div className="prose prose-slate max-w-none dark:prose-invert">
<p className="text-slate-600 dark:text-slate-400 mb-6">
<strong>Note:</strong> This privacy policy is managed by webklar.com. Please refer to their privacy policy for detailed information.
</p>
<div className="bg-slate-50 border border-slate-200 rounded-lg p-6 mb-8">
<h2 className="text-xl font-semibold text-slate-900 mb-4">Data Protection Information</h2>
<p className="text-slate-700 mb-4">
<div className="bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 rounded-lg p-6 mb-8">
<h2 className="text-xl font-semibold text-slate-900 dark:text-slate-100 mb-4">Data Protection Information</h2>
<p className="text-slate-700 dark:text-slate-300 mb-4">
EmailSorter is operated by webklar.com. The following privacy policy applies to the use of this website and our services.
</p>
<h3 className="text-lg font-semibold text-slate-900 mt-6 mb-3">1. Responsible Party</h3>
<p className="text-slate-700 mb-4">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mt-6 mb-3">1. Responsible Party</h3>
<p className="text-slate-700 dark:text-slate-300 mb-4">
The responsible party for data processing on this website is:
</p>
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
<p className="text-slate-700 mb-2">
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg p-4 mb-4">
<p className="text-slate-700 dark:text-slate-300 mb-2">
<strong>webklar.com</strong><br />
Kenso Grimm, Justin Klein
</p>
<p className="text-slate-700 mb-2">
<p className="text-slate-700 dark:text-slate-300 mb-2">
<strong>Contact:</strong><br />
Email: <a href="mailto:support@webklar.com" className="text-primary-600 hover:text-primary-700 underline">support@webklar.com</a><br />
Phone: <a href="tel:+4917623726355" className="text-primary-600 hover:text-primary-700 underline">+49 176 23726355</a>
Email: <a href="mailto:support@webklar.com" className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 underline">support@webklar.com</a><br />
Phone: <a href="tel:+4917623726355" className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 underline">+49 176 23726355</a>
</p>
<p className="text-sm text-slate-600 mt-3">
For complete contact details, please refer to the <Link to="/imprint" className="text-primary-600 hover:text-primary-700 underline">Impressum</Link>.
<p className="text-sm text-slate-600 dark:text-slate-400 mt-3">
For complete contact details, please refer to the <Link to="/imprint" className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 underline">Impressum</Link>.
</p>
</div>
<h3 className="text-lg font-semibold text-slate-900 mt-6 mb-3">2. Data Collection and Processing</h3>
<p className="text-slate-700 mb-4">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mt-6 mb-3">2. Data Collection and Processing</h3>
<p className="text-slate-700 dark:text-slate-300 mb-4">
When you use EmailSorter, we collect and process the following data:
</p>
<ul className="list-disc list-inside text-slate-700 mb-4 space-y-2 ml-4">
<ul className="list-disc list-inside text-slate-700 dark:text-slate-300 mb-4 space-y-2 ml-4">
<li>Account information (email address, name)</li>
<li>Email metadata (sender, subject, date) for sorting purposes</li>
<li>Usage statistics and preferences</li>
<li>Payment information (processed securely via Stripe)</li>
</ul>
<h3 className="text-lg font-semibold text-slate-900 mt-6 mb-3">3. Purpose of Data Processing</h3>
<p className="text-slate-700 mb-4">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mt-6 mb-3">3. Purpose of Data Processing</h3>
<p className="text-slate-700 dark:text-slate-300 mb-4">
We process your data exclusively for the following purposes:
</p>
<ul className="list-disc list-inside text-slate-700 mb-4 space-y-2 ml-4">
<ul className="list-disc list-inside text-slate-700 dark:text-slate-300 mb-4 space-y-2 ml-4">
<li>Providing and improving the EmailSorter service</li>
<li>Automated email sorting and categorization</li>
<li>Processing payments and subscriptions</li>
<li>Customer support and communication</li>
</ul>
<h3 className="text-lg font-semibold text-slate-900 mt-6 mb-3">4. Data Security</h3>
<p className="text-slate-700 mb-4">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mt-6 mb-3">4. Data Security</h3>
<p className="text-slate-700 dark:text-slate-300 mb-4">
We implement appropriate technical and organizational measures to protect your data against unauthorized access, loss, or destruction.
</p>
<h3 className="text-lg font-semibold text-slate-900 mt-6 mb-3">5. Your Rights</h3>
<p className="text-slate-700 mb-4">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mt-6 mb-3">5. Your Rights</h3>
<p className="text-slate-700 dark:text-slate-300 mb-4">
You have the right to:
</p>
<ul className="list-disc list-inside text-slate-700 mb-4 space-y-2 ml-4">
<ul className="list-disc list-inside text-slate-700 dark:text-slate-300 mb-4 space-y-2 ml-4">
<li>Access your personal data</li>
<li>Correct inaccurate data</li>
<li>Request deletion of your data</li>
@@ -101,14 +101,14 @@ export function Privacy() {
<li>Data portability</li>
</ul>
<h3 className="text-lg font-semibold text-slate-900 mt-6 mb-3">6. Hosting and Third-Party Services</h3>
<p className="text-slate-700 mb-4">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mt-6 mb-3">6. Hosting and Third-Party Services</h3>
<p className="text-slate-700 dark:text-slate-300 mb-4">
<strong>Hosting:</strong> Our website is hosted by Netlify, which acts as a data processor.
</p>
<p className="text-slate-700 mb-4">
<p className="text-slate-700 dark:text-slate-300 mb-4">
We use the following third-party services:
</p>
<ul className="list-disc list-inside text-slate-700 mb-4 space-y-2 ml-4">
<ul className="list-disc list-inside text-slate-700 dark:text-slate-300 mb-4 space-y-2 ml-4">
<li><strong>Appwrite:</strong> User authentication and database</li>
<li><strong>Stripe:</strong> Payment processing</li>
<li><strong>Mistral AI:</strong> Email categorization</li>
@@ -116,45 +116,45 @@ export function Privacy() {
<li><strong>Plausible (optional):</strong> Privacy-friendly analytics tool, if enabled</li>
</ul>
<h3 className="text-lg font-semibold text-slate-900 mt-6 mb-3">6.1. Cookies and Tracking</h3>
<p className="text-slate-700 mb-4">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mt-6 mb-3">6.1. Cookies and Tracking</h3>
<p className="text-slate-700 dark:text-slate-300 mb-4">
We do not use external fonts or unnecessary cookies. If we use any tracking tools (such as Plausible),
they are privacy-friendly and do not store personal data. We only process personal data to the extent
that it is technically or organizationally necessary.
</p>
<h3 className="text-lg font-semibold text-slate-900 mt-6 mb-3">7. Contact Form Data</h3>
<p className="text-slate-700 mb-4">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mt-6 mb-3">7. Contact Form Data</h3>
<p className="text-slate-700 dark:text-slate-300 mb-4">
Data that you send to us via contact forms will be stored and used for processing your inquiry.
This data will not be shared with third parties without your consent.
</p>
<h3 className="text-lg font-semibold text-slate-900 mt-6 mb-3">8. Contact</h3>
<p className="text-slate-700 mb-4">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mt-6 mb-3">8. Contact</h3>
<p className="text-slate-700 dark:text-slate-300 mb-4">
For questions regarding data protection, please contact us:
</p>
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
<p className="text-slate-700">
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg p-4 mb-4">
<p className="text-slate-700 dark:text-slate-300">
<strong>Email:</strong>{' '}
<a href="mailto:support@webklar.com" className="text-primary-600 hover:text-primary-700 underline">
<a href="mailto:support@webklar.com" className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 underline">
support@webklar.com
</a>
</p>
<p className="text-slate-700 mt-2">
<p className="text-slate-700 dark:text-slate-300 mt-2">
<strong>Phone:</strong>{' '}
<a href="tel:+4917623726355" className="text-primary-600 hover:text-primary-700 underline">
<a href="tel:+4917623726355" className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 underline">
+49 176 23726355
</a>
</p>
<p className="text-sm text-slate-600 mt-3">
For complete contact details, please refer to the <Link to="/imprint" className="text-primary-600 hover:text-primary-700 underline">Impressum</Link>.
<p className="text-sm text-slate-600 dark:text-slate-400 mt-3">
For complete contact details, please refer to the <Link to="/imprint" className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 underline">Impressum</Link>.
</p>
</div>
<div className="mt-8 pt-6 border-t border-slate-200">
<p className="text-sm text-slate-500">
<div className="mt-8 pt-6 border-t border-slate-200 dark:border-slate-700">
<p className="text-sm text-slate-500 dark:text-slate-400">
<strong>Important:</strong> This is a simplified version. For the complete and legally binding privacy policy, please visit{' '}
<a href="https://webklar.com/datenschutz" target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:text-primary-700 underline">
<a href="https://webklar.com/datenschutz" target="_blank" rel="noopener noreferrer" className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 underline">
webklar.com/datenschutz
</a>
</p>

View File

@@ -3,6 +3,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { useAuth } from '@/context/AuthContext'
import { analytics } from '@/hooks/useAnalytics'
import { captureUTMParams } from '@/lib/analytics'
import { api } from '@/lib/api'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -12,6 +13,7 @@ import { Mail, Lock, User, ArrowRight, AlertCircle, Check, Sparkles } from 'luci
export function Register() {
const [searchParams] = useSearchParams()
const selectedPlan = searchParams.get('plan') || 'pro'
const referralCode = searchParams.get('ref') || null
const [name, setName] = useState('')
const [email, setEmail] = useState('')
@@ -20,7 +22,7 @@ export function Register() {
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { register } = useAuth()
const { register, user } = useAuth()
const navigate = useNavigate()
// Capture UTM parameters on mount
@@ -28,6 +30,22 @@ export function Register() {
captureUTMParams()
}, [])
// Track referral and signup after user is registered
useEffect(() => {
if (user?.$id && referralCode) {
// Track referral if code exists
api.trackReferral(user.$id, referralCode).catch((err) => {
console.error('Failed to track referral:', err)
})
}
if (user?.$id) {
// Track signup conversion with UTM parameters
analytics.trackSignup(user.$id, email)
analytics.setUserId(user.$id)
}
}, [user, referralCode, email])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
@@ -45,17 +63,10 @@ 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.')
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Registration failed. Please try again.')
} finally {
setLoading(false)
}
@@ -103,30 +114,30 @@ export function Register() {
</div>
{/* Right side - Form */}
<div className="flex-1 flex items-center justify-center px-4 sm:px-6 lg:px-8 bg-white">
<div className="flex-1 flex items-center justify-center px-4 sm:px-6 lg:px-8 bg-white dark:bg-slate-900">
<div className="w-full max-w-md">
{/* Logo */}
<Link to="/" className="flex items-center gap-2 mb-8">
<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>
<h1 className="text-3xl font-bold text-slate-900 mb-2">
<h1 className="text-3xl font-bold text-slate-900 dark:text-slate-100 mb-2">
Create account
</h1>
<p className="text-slate-600 mb-8">
<p className="text-slate-600 dark:text-slate-400 mb-8">
Ready to go in less than a minute.
</p>
{/* Error message */}
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-600">{error}</p>
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-xl flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-500 dark:text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-600 dark:text-red-300">{error}</p>
</div>
)}
@@ -206,16 +217,16 @@ export function Register() {
)}
</Button>
<p className="text-xs text-slate-500 text-center">
<p className="text-xs text-slate-500 dark:text-slate-400 text-center">
By signing up, you agree to our{' '}
<a href="#" className="text-primary-600 hover:underline">Terms of Service</a> and{' '}
<a href="#" className="text-primary-600 hover:underline">Privacy Policy</a>.
<a href="#" className="text-primary-600 dark:text-primary-400 hover:underline">Terms of Service</a> and{' '}
<a href="#" className="text-primary-600 dark:text-primary-400 hover:underline">Privacy Policy</a>.
</p>
</form>
<p className="mt-8 text-center text-slate-600">
<p className="mt-8 text-center text-slate-600 dark:text-slate-400">
Already have an account?{' '}
<Link to="/login" className="text-primary-600 font-semibold hover:text-primary-700">
<Link to="/login" className="text-primary-600 dark:text-primary-400 font-semibold hover:text-primary-700 dark:hover:text-primary-300">
Sign in
</Link>
</p>

View File

@@ -51,8 +51,8 @@ export function ResetPassword() {
try {
await auth.resetPassword(userId, secret, password)
setSuccess(true)
} catch (err: any) {
setError(err.message || 'Fehler beim Zurücksetzen des Passworts')
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Fehler beim Zurücksetzen des Passworts')
} finally {
setLoading(false)
}
@@ -83,24 +83,24 @@ export function ResetPassword() {
const passwordStrength = getPasswordStrength()
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center p-4">
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Logo */}
<Link to="/" className="flex items-center justify-center gap-2 mb-8">
<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">
Email<span className="text-primary-600 dark:text-primary-400">Sorter</span>
</span>
</Link>
<Card className="shadow-xl border-0">
<Card className="shadow-xl border-0 dark:bg-slate-800 dark:border-slate-700">
<CardHeader className="text-center pb-2">
<CardTitle className="text-2xl">
<CardTitle className="text-2xl dark:text-slate-100">
{success ? 'Passwort geändert!' : 'Neues Passwort festlegen'}
</CardTitle>
<CardDescription>
<CardDescription className="dark:text-slate-400">
{success
? 'Dein Passwort wurde erfolgreich geändert.'
: 'Wähle ein sicheres neues Passwort für deinen Account.'
@@ -110,10 +110,10 @@ export function ResetPassword() {
<CardContent>
{success ? (
<div className="text-center py-8">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-green-100 flex items-center justify-center">
<CheckCircle className="w-8 h-8 text-green-600" />
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
<CheckCircle className="w-8 h-8 text-green-600 dark:text-green-400" />
</div>
<p className="text-slate-600 mb-6">
<p className="text-slate-600 dark:text-slate-400 mb-6">
Du kannst dich jetzt mit deinem neuen Passwort anmelden.
</p>
<Button onClick={() => navigate('/login')} className="w-full">
@@ -122,11 +122,11 @@ export function ResetPassword() {
</div>
) : !userId || !secret ? (
<div className="text-center py-8">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 flex items-center justify-center">
<XCircle className="w-8 h-8 text-red-600" />
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<XCircle className="w-8 h-8 text-red-600 dark:text-red-400" />
</div>
<h3 className="font-semibold text-slate-900 mb-2">Ungültiger Link</h3>
<p className="text-slate-600 mb-6">
<h3 className="font-semibold text-slate-900 dark:text-slate-100 mb-2">Ungültiger Link</h3>
<p className="text-slate-600 dark:text-slate-400 mb-6">
Dieser Link zum Zurücksetzen des Passworts ist ungültig oder abgelaufen.
</p>
<Link to="/forgot-password">
@@ -136,13 +136,13 @@ export function ResetPassword() {
) : (
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
<div className="p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-300 text-sm">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="password">Neues Passwort</Label>
<Label htmlFor="password" className="dark:text-slate-200">Neues Passwort</Label>
<div className="relative">
<Input
id="password"
@@ -152,11 +152,12 @@ export function ResetPassword() {
onChange={(e) => setPassword(e.target.value)}
required
autoFocus
className="dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500 hover:text-slate-600 dark:hover:text-slate-300"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
@@ -172,13 +173,13 @@ export function ResetPassword() {
className={`h-1 flex-1 rounded-full transition-colors ${
level <= passwordStrength.strength
? passwordStrength.color
: 'bg-slate-200'
: 'bg-slate-200 dark:bg-slate-700'
}`}
/>
))}
</div>
<p className={`text-xs ${
passwordStrength.strength < 3 ? 'text-red-500' : 'text-green-600'
passwordStrength.strength < 3 ? 'text-red-500 dark:text-red-400' : 'text-green-600 dark:text-green-400'
}`}>
{passwordStrength.label}
</p>
@@ -187,7 +188,7 @@ export function ResetPassword() {
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Passwort bestätigen</Label>
<Label htmlFor="confirmPassword" className="dark:text-slate-200">Passwort bestätigen</Label>
<Input
id="confirmPassword"
type={showPassword ? 'text' : 'password'}
@@ -195,9 +196,10 @@ export function ResetPassword() {
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
className="dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100"
/>
{confirmPassword && password !== confirmPassword && (
<p className="text-xs text-red-500">Passwörter stimmen nicht überein</p>
<p className="text-xs text-red-500 dark:text-red-400">Passwörter stimmen nicht überein</p>
)}
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,9 @@ import { useAuth } from '@/context/AuthContext'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { OnboardingProgress } from '@/components/OnboardingProgress'
import { api } from '@/lib/api'
import { trackOnboardingStep, trackProviderConnected, trackDemoUsed } from '@/lib/analytics'
import {
Mail,
ArrowRight,
@@ -24,7 +26,6 @@ type Step = 'connect' | 'preferences' | 'categories' | 'complete'
export function Setup() {
const [searchParams] = useSearchParams()
const isFromCheckout = searchParams.get('subscription') === 'success'
const autoSetup = searchParams.get('setup') === 'auto'
const [currentStep, setCurrentStep] = useState<Step>('connect')
const [connectedProvider, setConnectedProvider] = useState<string | null>(null)
@@ -40,9 +41,48 @@ export function Setup() {
])
const [saving, setSaving] = useState(false)
const [checkingAccounts, setCheckingAccounts] = useState(isFromCheckout)
const [onboardingState, setOnboardingState] = useState<{
onboarding_step: string
completedSteps: string[]
} | null>(null)
const [loadingOnboarding, setLoadingOnboarding] = useState(true)
const { user } = useAuth()
const navigate = useNavigate()
const resumeOnboarding = searchParams.get('resume') === 'true'
// Load onboarding state
useEffect(() => {
if (user?.$id) {
const loadOnboarding = async () => {
try {
const stateRes = await api.getOnboardingStatus(user.$id)
if (stateRes.data) {
setOnboardingState(stateRes.data)
// If resuming, restore step
if (resumeOnboarding && stateRes.data.onboarding_step !== 'completed' && stateRes.data.onboarding_step !== 'not_started') {
const stepMap: Record<string, Step> = {
'connect': 'connect',
'first_rule': 'preferences',
'see_results': 'categories',
'auto_schedule': 'complete',
}
const mappedStep = stepMap[stateRes.data.onboarding_step]
if (mappedStep) {
setCurrentStep(mappedStep)
}
}
}
} catch (err) {
console.error('Error loading onboarding state:', err)
} finally {
setLoadingOnboarding(false)
}
}
loadOnboarding()
}
}, [user, resumeOnboarding])
// Check if user already has connected accounts after successful checkout
useEffect(() => {
@@ -67,9 +107,7 @@ export function Setup() {
const steps: { id: Step; title: string; description: string }[] = [
{ id: 'connect', title: 'Connect email', description: 'Link your mailbox' },
{ id: 'preferences', title: 'Settings', description: 'Sorting preferences' },
{ id: 'categories', title: 'Categories', description: 'Choose categories' },
{ id: 'complete', title: 'Done', description: 'Get started!' },
{ id: 'complete', title: 'Done', description: 'Go to dashboard' },
]
const stepIndex = steps.findIndex(s => s.id === currentStep)
@@ -82,13 +120,18 @@ 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')
setCurrentStep('complete')
await api.updateOnboardingStep(user.$id, 'see_results', ['connect'])
trackOnboardingStep(user.$id, 'first_rule')
trackProviderConnected(user.$id, 'gmail')
}
} catch (err) {
} catch {
setError('Gmail connection failed. Please try again.')
} finally {
setConnecting(null)
@@ -103,23 +146,69 @@ 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')
setCurrentStep('complete')
await api.updateOnboardingStep(user.$id, 'see_results', ['connect'])
}
} catch (err) {
} catch {
setError('Outlook connection failed. Please try again.')
} finally {
setConnecting(null)
}
}
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('complete')
await api.updateOnboardingStep(user.$id, 'see_results', ['connect'])
trackOnboardingStep(user.$id, 'first_rule')
trackDemoUsed(user.$id)
}
} catch {
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 +233,9 @@ export function Setup() {
customRules: [],
priorityTopics: selectedCategories,
})
// Mark onboarding as completed
await api.updateOnboardingStep(user.$id, 'completed', ['connect', 'see_results'])
} catch (err) {
console.error('Failed to save preferences:', err)
} finally {
@@ -152,6 +244,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' },
@@ -174,29 +278,29 @@ export function Setup() {
// Show loading while checking accounts
if (checkingAccounts) {
return (
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white flex items-center justify-center">
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white dark:from-slate-900 dark:to-slate-800 flex items-center justify-center">
<div className="text-center">
<Loader2 className="w-8 h-8 animate-spin text-primary-600 mx-auto mb-4" />
<p className="text-slate-600">Setting up your account...</p>
<Loader2 className="w-8 h-8 animate-spin text-primary-600 dark:text-primary-400 mx-auto mb-4" />
<p className="text-slate-600 dark:text-slate-400">Setting up your account...</p>
</div>
</div>
)
}
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">
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white dark:from-slate-900 dark:to-slate-800">
<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>
@@ -206,13 +310,13 @@ export function Setup() {
{/* Success message after checkout */}
{isFromCheckout && (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pt-8">
<div className="bg-green-50 border border-green-200 rounded-xl p-6 mb-6 flex items-start gap-4">
<div className="w-10 h-10 rounded-full bg-green-500 flex items-center justify-center flex-shrink-0">
<div className="bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-xl p-6 mb-6 flex items-start gap-4">
<div className="w-10 h-10 rounded-full bg-green-500 dark:bg-green-600 flex items-center justify-center flex-shrink-0">
<Check className="w-6 h-6 text-white" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-green-900 mb-1">Payment successful!</h3>
<p className="text-sm text-green-700">
<h3 className="font-semibold text-green-900 dark:text-green-200 mb-1">Payment successful!</h3>
<p className="text-sm text-green-700 dark:text-green-300">
Your subscription is active. Let's connect your email account to get started.
</p>
</div>
@@ -221,6 +325,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={2}
onSkip={handleSkipOnboarding}
/>
</div>
)}
{/* Progress */}
<div className="mb-12">
<div className="flex items-center justify-between mb-4">
@@ -230,23 +346,23 @@ export function Setup() {
<div
className={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold transition-all duration-300 ${
index < stepIndex
? 'bg-green-500 text-white shadow-lg shadow-green-500/30'
? 'bg-green-500 dark:bg-green-600 text-white shadow-lg shadow-green-500/30'
: index === stepIndex
? 'bg-primary-500 text-white ring-4 ring-primary-100 shadow-lg shadow-primary-500/30'
: 'bg-slate-100 text-slate-400'
? 'bg-primary-500 dark:bg-primary-600 text-white ring-4 ring-primary-100 dark:ring-primary-900/50 shadow-lg shadow-primary-500/30'
: 'bg-slate-100 dark:bg-slate-700 text-slate-400 dark:text-slate-500'
}`}
>
{index < stepIndex ? <Check className="w-5 h-5" /> : index + 1}
</div>
<p className={`mt-2 text-xs font-medium hidden sm:block transition-colors ${
index <= stepIndex ? 'text-slate-900' : 'text-slate-400'
index <= stepIndex ? 'text-slate-900 dark:text-slate-100' : 'text-slate-400 dark:text-slate-500'
}`}>
{step.title}
</p>
</div>
{index < steps.length - 1 && (
<div className={`w-16 sm:w-24 h-1 mx-2 rounded-full transition-colors duration-500 ${
index < stepIndex ? 'bg-green-500' : 'bg-slate-200'
index < stepIndex ? 'bg-green-500 dark:bg-green-600' : 'bg-slate-200 dark:bg-slate-700'
}`} />
)}
</div>
@@ -255,7 +371,7 @@ export function Setup() {
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl flex items-center gap-3 text-red-700">
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-xl flex items-center gap-3 text-red-700 dark:text-red-300">
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<p>{error}</p>
</div>
@@ -267,60 +383,96 @@ export function Setup() {
<div className="w-24 h-24 mx-auto mb-6 rounded-2xl bg-gradient-to-br from-primary-100 to-primary-200 flex items-center justify-center shadow-xl shadow-primary-500/10">
<Link2 className="w-12 h-12 text-primary-600" />
</div>
<h1 className="text-3xl font-bold text-slate-900 mb-3">Connect your email account</h1>
<p className="text-lg text-slate-600 mb-10 max-w-md mx-auto">
<h1 className="text-3xl font-bold text-slate-900 dark:text-slate-100 mb-3">Connect your email account</h1>
<p className="text-lg text-slate-600 dark:text-slate-400 mb-10 max-w-md mx-auto">
Choose your email provider. The connection is secure and your data stays private.
</p>
<div className="grid sm:grid-cols-2 gap-4 max-w-lg mx-auto">
<div className="space-y-4 max-w-lg mx-auto">
{/* Try Demo - Prominent Option */}
<button
onClick={handleConnectGmail}
onClick={handleConnectDemo}
disabled={connecting !== null}
className="flex items-center gap-4 p-6 bg-white rounded-2xl border-2 border-slate-200 hover:border-red-300 hover:shadow-xl hover:shadow-red-500/10 transition-all text-left group disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full flex items-center gap-4 p-6 bg-gradient-to-r from-primary-500 to-primary-600 text-white rounded-2xl border-2 border-primary-400 hover:border-primary-300 hover:shadow-2xl hover:shadow-primary-500/30 transition-all text-left group disabled:opacity-50 disabled:cursor-not-allowed"
>
{connecting === 'gmail' ? (
<Loader2 className="w-12 h-12 animate-spin text-red-500" />
{connecting === 'demo' ? (
<Loader2 className="w-12 h-12 animate-spin text-white" />
) : (
<div className="w-12 h-12 rounded-xl bg-red-50 flex items-center justify-center group-hover:bg-red-100 transition-colors">
<svg viewBox="0 0 24 24" className="w-7 h-7">
<path fill="#EA4335" d="M5.26 9.71L12 14.04l6.74-4.33-6.74-4.33z"/>
<path fill="#34A853" d="M12 14.04l6.74-4.33v7.65c0 .7-.57 1.26-1.26 1.26H6.52c-.7 0-1.26-.57-1.26-1.26V9.71l6.74 4.33z"/>
<path fill="#4285F4" d="M18.74 5.38H5.26c-.7 0-1.26.57-1.26 1.26v3.07l8 5.13 8-5.13V6.64c0-.7-.57-1.26-1.26-1.26z"/>
<path fill="#FBBC05" d="M4 9.71V6.64c0-.7.57-1.26 1.26-1.26h.01L12 9.71 4 13.84V9.71z"/>
</svg>
<div className="w-12 h-12 rounded-xl bg-white/20 flex items-center justify-center group-hover:bg-white/30 transition-colors">
<Sparkles className="w-7 h-7 text-white" />
</div>
)}
<div className="flex-1">
<p className="font-semibold text-slate-900">Gmail</p>
<p className="text-sm text-slate-500">Google Workspace</p>
<p className="font-semibold text-white text-lg">Try Demo</p>
<p className="text-sm text-primary-100">See how it works without connecting your account</p>
</div>
<ChevronRight className="w-5 h-5 text-slate-400 group-hover:text-red-500 group-hover:translate-x-1 transition-all" />
<ChevronRight className="w-5 h-5 text-white/80 group-hover:text-white group-hover:translate-x-1 transition-all" />
</button>
<button
onClick={handleConnectOutlook}
disabled={connecting !== null}
className="flex items-center gap-4 p-6 bg-white rounded-2xl border-2 border-slate-200 hover:border-blue-300 hover:shadow-xl hover:shadow-blue-500/10 transition-all text-left group disabled:opacity-50 disabled:cursor-not-allowed"
>
{connecting === 'outlook' ? (
<Loader2 className="w-12 h-12 animate-spin text-blue-500" />
) : (
<div className="w-12 h-12 rounded-xl bg-blue-50 flex items-center justify-center group-hover:bg-blue-100 transition-colors">
<svg viewBox="0 0 24 24" className="w-7 h-7">
<path fill="#0078D4" d="M11.5 3v8.5H3V3h8.5zm1 0H21v8.5h-8.5V3zM3 12.5h8.5V21H3v-8.5zm9.5 0H21V21h-8.5v-8.5z"/>
</svg>
</div>
)}
<div className="flex-1">
<p className="font-semibold text-slate-900">Outlook</p>
<p className="text-sm text-slate-500">Microsoft 365</p>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-slate-300 dark:border-slate-600"></div>
</div>
<ChevronRight className="w-5 h-5 text-slate-400 group-hover:text-blue-500 group-hover:translate-x-1 transition-all" />
</button>
<div className="relative flex justify-center text-sm">
<span className="px-4 bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">Or connect your inbox</span>
</div>
</div>
<div className="grid sm:grid-cols-2 gap-4">
<button
onClick={handleConnectGmail}
disabled={connecting !== null}
className="flex items-center gap-4 p-6 bg-white dark:bg-slate-800 rounded-2xl border-2 border-slate-200 dark:border-slate-700 hover:border-red-300 dark:hover:border-red-600 hover:shadow-xl hover:shadow-red-500/10 transition-all text-left group disabled:opacity-50 disabled:cursor-not-allowed"
>
{connecting === 'gmail' ? (
<Loader2 className="w-12 h-12 animate-spin text-red-500 dark:text-red-400" />
) : (
<div className="w-12 h-12 rounded-xl bg-red-50 dark:bg-red-900/30 flex items-center justify-center group-hover:bg-red-100 dark:group-hover:bg-red-900/50 transition-colors">
<svg viewBox="0 0 24 24" className="w-7 h-7">
<path fill="#EA4335" d="M5.26 9.71L12 14.04l6.74-4.33-6.74-4.33z"/>
<path fill="#34A853" d="M12 14.04l6.74-4.33v7.65c0 .7-.57 1.26-1.26 1.26H6.52c-.7 0-1.26-.57-1.26-1.26V9.71l6.74 4.33z"/>
<path fill="#4285F4" d="M18.74 5.38H5.26c-.7 0-1.26.57-1.26 1.26v3.07l8 5.13 8-5.13V6.64c0-.7-.57-1.26-1.26-1.26z"/>
<path fill="#FBBC05" d="M4 9.71V6.64c0-.7.57-1.26 1.26-1.26h.01L12 9.71 4 13.84V9.71z"/>
</svg>
</div>
)}
<div className="flex-1">
<p className="font-semibold text-slate-900 dark:text-slate-100">Gmail</p>
<p className="text-sm text-slate-500 dark:text-slate-400">Google Workspace</p>
</div>
<ChevronRight className="w-5 h-5 text-slate-400 dark:text-slate-500 group-hover:text-red-500 dark:group-hover:text-red-400 group-hover:translate-x-1 transition-all" />
</button>
<button
onClick={handleConnectOutlook}
disabled={connecting !== null}
className="flex items-center gap-4 p-6 bg-white dark:bg-slate-800 rounded-2xl border-2 border-slate-200 dark:border-slate-700 hover:border-blue-300 dark:hover:border-blue-600 hover:shadow-xl hover:shadow-blue-500/10 transition-all text-left group disabled:opacity-50 disabled:cursor-not-allowed"
>
{connecting === 'outlook' ? (
<Loader2 className="w-12 h-12 animate-spin text-blue-500 dark:text-blue-400" />
) : (
<div className="w-12 h-12 rounded-xl bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center group-hover:bg-blue-100 dark:group-hover:bg-blue-900/50 transition-colors">
<svg viewBox="0 0 24 24" className="w-7 h-7">
<path fill="#0078D4" d="M11.5 3v8.5H3V3h8.5zm1 0H21v8.5h-8.5V3zM3 12.5h8.5V21H3v-8.5zm9.5 0H21V21h-8.5v-8.5z"/>
</svg>
</div>
)}
<div className="flex-1">
<p className="font-semibold text-slate-900 dark:text-slate-100">Outlook</p>
<p className="text-sm text-slate-500 dark:text-slate-400">Microsoft 365</p>
</div>
<ChevronRight className="w-5 h-5 text-slate-400 dark:text-slate-500 group-hover:text-blue-500 dark:group-hover:text-blue-400 group-hover:translate-x-1 transition-all" />
</button>
</div>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-4">
Using Porkbun, Nextcloud Mail, or another IMAP provider?{' '}
<Link to="/settings?tab=accounts" className="text-primary-600 dark:text-primary-400 hover:underline">Add your account in Settings → Accounts</Link>.
</p>
</div>
<div className="mt-10 p-4 bg-slate-50 rounded-xl max-w-lg mx-auto">
<p className="text-sm text-slate-500">
<div className="mt-10 p-4 bg-slate-50 dark:bg-slate-800/50 rounded-xl max-w-lg mx-auto">
<p className="text-sm text-slate-500 dark:text-slate-400">
🔒 Your data is secure. We don't store email content and only have read access.
</p>
</div>
@@ -333,16 +485,16 @@ export function Setup() {
<div className="w-24 h-24 mx-auto mb-6 rounded-2xl bg-gradient-to-br from-primary-100 to-primary-200 flex items-center justify-center shadow-xl shadow-primary-500/10">
<Settings className="w-12 h-12 text-primary-600" />
</div>
<h1 className="text-3xl font-bold text-slate-900 mb-3">Sorting Settings</h1>
<p className="text-lg text-slate-600 max-w-md mx-auto">
<h1 className="text-3xl font-bold text-slate-900 dark:text-slate-100 mb-3">Sorting Settings</h1>
<p className="text-lg text-slate-600 dark:text-slate-400 max-w-md mx-auto">
Customize how strictly the AI should sort your emails.
</p>
</div>
<Card className="max-w-lg mx-auto shadow-xl border-0">
<Card className="max-w-lg mx-auto shadow-xl border-0 dark:bg-slate-800 dark:border-slate-700">
<CardContent className="p-8 space-y-8">
<div>
<label className="block text-sm font-semibold text-slate-900 mb-4">Sorting Intensity</label>
<label className="block text-sm font-semibold text-slate-900 dark:text-slate-100 mb-4">Sorting Intensity</label>
<div className="grid grid-cols-3 gap-3">
{[
{ id: 'light', name: 'Light', desc: 'Only obvious distractions', emoji: '🌱' },
@@ -354,30 +506,30 @@ export function Setup() {
onClick={() => setPreferences(p => ({ ...p, sortingStrictness: option.id }))}
className={`p-4 rounded-xl border-2 text-center transition-all ${
preferences.sortingStrictness === option.id
? 'border-primary-500 bg-primary-50 shadow-lg shadow-primary-500/10'
: 'border-slate-200 hover:border-slate-300 bg-white'
? 'border-primary-500 dark:border-primary-400 bg-primary-50 dark:bg-primary-900/30 shadow-lg shadow-primary-500/10'
: 'border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600 bg-white dark:bg-slate-800'
}`}
>
<span className="text-2xl mb-2 block">{option.emoji}</span>
<p className="font-semibold text-slate-900">{option.name}</p>
<p className="text-xs text-slate-500 mt-1">{option.desc}</p>
<p className="font-semibold text-slate-900 dark:text-slate-100">{option.name}</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">{option.desc}</p>
</button>
))}
</div>
</div>
<div className="flex items-center justify-between p-5 bg-gradient-to-r from-slate-50 to-slate-100 rounded-xl">
<div className="flex items-center justify-between p-5 bg-gradient-to-r from-slate-50 to-slate-100 dark:from-slate-800 dark:to-slate-700 rounded-xl">
<div>
<p className="font-semibold text-slate-900">Historical emails</p>
<p className="text-sm text-slate-500">Analyze and sort last 30 days</p>
<p className="font-semibold text-slate-900 dark:text-slate-100">Historical emails</p>
<p className="text-sm text-slate-500 dark:text-slate-400">Analyze and sort last 30 days</p>
</div>
<button
onClick={() => setPreferences(p => ({ ...p, historicalSync: !p.historicalSync }))}
className={`w-14 h-8 rounded-full transition-all duration-300 ${
preferences.historicalSync ? 'bg-primary-500 shadow-lg shadow-primary-500/30' : 'bg-slate-300'
preferences.historicalSync ? 'bg-primary-500 dark:bg-primary-600 shadow-lg shadow-primary-500/30' : 'bg-slate-300 dark:bg-slate-600'
}`}
>
<div className={`w-6 h-6 bg-white rounded-full shadow-md transition-transform duration-300 ${
<div className={`w-6 h-6 bg-white dark:bg-slate-200 rounded-full shadow-md transition-transform duration-300 ${
preferences.historicalSync ? 'translate-x-7' : 'translate-x-1'
}`} />
</button>
@@ -393,8 +545,8 @@ export function Setup() {
<div className="w-24 h-24 mx-auto mb-6 rounded-2xl bg-gradient-to-br from-primary-100 to-primary-200 flex items-center justify-center shadow-xl shadow-primary-500/10">
<Zap className="w-12 h-12 text-primary-600" />
</div>
<h1 className="text-3xl font-bold text-slate-900 mb-3">Choose your categories</h1>
<p className="text-lg text-slate-600 max-w-md mx-auto">
<h1 className="text-3xl font-bold text-slate-900 dark:text-slate-100 mb-3">Choose your categories</h1>
<p className="text-lg text-slate-600 dark:text-slate-400 max-w-md mx-auto">
Which categories should your emails be sorted into?
</p>
</div>
@@ -406,21 +558,21 @@ export function Setup() {
onClick={() => toggleCategory(category.id)}
className={`flex items-center gap-4 p-5 rounded-xl border-2 text-left transition-all ${
selectedCategories.includes(category.id)
? 'border-primary-500 bg-primary-50 shadow-lg shadow-primary-500/10'
: 'border-slate-200 bg-white hover:border-slate-300 hover:shadow-md'
? 'border-primary-500 dark:border-primary-400 bg-primary-50 dark:bg-primary-900/30 shadow-lg shadow-primary-500/10'
: 'border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 hover:border-slate-300 dark:hover:border-slate-600 hover:shadow-md'
}`}
>
<div className={`w-12 h-12 rounded-xl ${category.color} flex items-center justify-center text-2xl shadow-lg`}>
{category.icon}
</div>
<div className="flex-1">
<p className="font-semibold text-slate-900">{category.name}</p>
<p className="text-sm text-slate-500">{category.description}</p>
<p className="font-semibold text-slate-900 dark:text-slate-100">{category.name}</p>
<p className="text-sm text-slate-500 dark:text-slate-400">{category.description}</p>
</div>
<div className={`w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all ${
selectedCategories.includes(category.id)
? 'border-primary-500 bg-primary-500'
: 'border-slate-300'
? 'border-primary-500 dark:border-primary-400 bg-primary-500 dark:bg-primary-600'
: 'border-slate-300 dark:border-slate-600'
}`}>
{selectedCategories.includes(category.id) && <Check className="w-4 h-4 text-white" />}
</div>
@@ -428,7 +580,7 @@ export function Setup() {
))}
</div>
<p className="text-center text-sm text-slate-500 mt-6">
<p className="text-center text-sm text-slate-500 dark:text-slate-400 mt-6">
You can change these categories later in settings.
</p>
</div>
@@ -436,23 +588,26 @@ export function Setup() {
{currentStep === 'complete' && (
<div className="text-center">
<div className="w-28 h-28 mx-auto mb-8 rounded-full bg-gradient-to-br from-green-100 to-green-200 flex items-center justify-center shadow-2xl shadow-green-500/20 animate-pulse">
<Sparkles className="w-14 h-14 text-green-600" />
<div className="w-28 h-28 mx-auto mb-8 rounded-full bg-gradient-to-br from-green-100 to-green-200 dark:from-green-900/40 dark:to-green-800/40 flex items-center justify-center shadow-2xl shadow-green-500/20 animate-pulse">
<Sparkles className="w-14 h-14 text-green-600 dark:text-green-400" />
</div>
<h1 className="text-4xl font-bold text-slate-900 mb-4">All set! 🎉</h1>
<p className="text-xl text-slate-600 mb-10 max-w-md mx-auto">
Your email account is connected. The AI will now start intelligent sorting.
<h1 className="text-4xl font-bold text-slate-900 dark:text-slate-100 mb-4">You&apos;re in 🎉</h1>
<p className="text-xl text-slate-600 dark:text-slate-400 mb-6 max-w-md mx-auto">
Click Sort Now on the dashboard to categorize your inbox. Takes about 30 seconds.
</p>
<p className="text-sm text-slate-500 dark:text-slate-500 mb-10">
<Link to="/settings" className="underline hover:text-slate-700 dark:hover:text-slate-300">Tune categories later in Settings</Link>
</p>
<div className="inline-flex items-center gap-4 p-5 bg-gradient-to-r from-slate-50 to-slate-100 rounded-2xl mb-10 shadow-lg">
<div className="w-14 h-14 rounded-xl bg-white flex items-center justify-center shadow-md">
<Mail className="w-7 h-7 text-primary-500" />
<div className="inline-flex items-center gap-4 p-5 bg-gradient-to-r from-slate-50 to-slate-100 dark:from-slate-800 dark:to-slate-700 rounded-2xl mb-10 shadow-lg">
<div className="w-14 h-14 rounded-xl bg-white dark:bg-slate-700 flex items-center justify-center shadow-md">
<Mail className="w-7 h-7 text-primary-500 dark:text-primary-400" />
</div>
<div className="text-left">
<p className="font-semibold text-slate-900 text-lg">
<p className="font-semibold text-slate-900 dark:text-slate-100 text-lg">
{connectedProvider === 'gmail' ? 'Gmail' : connectedProvider === 'outlook' ? 'Outlook' : 'Email'} connected
</p>
<p className="text-slate-500">{connectedEmail || user?.email}</p>
<p className="text-slate-500 dark:text-slate-400">{connectedEmail || user?.email}</p>
</div>
<Badge variant="success" className="text-sm px-3 py-1">Active</Badge>
</div>
@@ -476,7 +631,7 @@ export function Setup() {
{currentStep !== 'connect' && currentStep !== 'complete' && (
<div className="flex justify-between max-w-lg mx-auto">
<Button variant="ghost" onClick={handleBack} className="text-slate-600">
<Button variant="ghost" onClick={handleBack} className="text-slate-600 dark:text-slate-400">
<ArrowLeft className="w-5 h-5 mr-2" />
Back
</Button>

View File

@@ -29,9 +29,9 @@ export function VerifyEmail() {
try {
await auth.verifyEmail(userId, secret)
setStatus('success')
} catch (err: any) {
} catch (err: unknown) {
setStatus('error')
setError(err.message || 'Fehler bei der Verifizierung')
setError(err instanceof Error ? err.message : 'Fehler bei der Verifizierung')
}
}
@@ -43,34 +43,34 @@ export function VerifyEmail() {
await auth.sendVerification()
setError('')
alert('Neue Verifizierungs-E-Mail wurde gesendet!')
} catch (err: any) {
setError(err.message || 'Fehler beim Senden')
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Fehler beim Senden')
} finally {
setStatus('error')
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center p-4">
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Logo */}
<Link to="/" className="flex items-center justify-center gap-2 mb-8">
<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">
Email<span className="text-primary-600 dark:text-primary-400">Sorter</span>
</span>
</Link>
<Card className="shadow-xl border-0">
<Card className="shadow-xl border-0 dark:bg-slate-800 dark:border-slate-700">
<CardHeader className="text-center pb-2">
<CardTitle className="text-2xl">
<CardTitle className="text-2xl dark:text-slate-100">
{status === 'loading' && 'E-Mail wird verifiziert...'}
{status === 'success' && 'E-Mail verifiziert!'}
{status === 'error' && 'Verifizierung fehlgeschlagen'}
</CardTitle>
<CardDescription>
<CardDescription className="dark:text-slate-400">
{status === 'loading' && 'Bitte warte einen Moment.'}
{status === 'success' && 'Deine E-Mail-Adresse wurde erfolgreich bestätigt.'}
{status === 'error' && error}
@@ -79,25 +79,25 @@ export function VerifyEmail() {
<CardContent>
{status === 'loading' && (
<div className="flex flex-col items-center py-12">
<Loader2 className="w-12 h-12 animate-spin text-primary-500 mb-4" />
<p className="text-slate-500">Verifizierung läuft...</p>
<Loader2 className="w-12 h-12 animate-spin text-primary-500 dark:text-primary-400 mb-4" />
<p className="text-slate-500 dark:text-slate-400">Verifizierung läuft...</p>
</div>
)}
{status === 'success' && (
<div className="text-center py-8">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-green-100 flex items-center justify-center">
<CheckCircle className="w-10 h-10 text-green-600" />
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
<CheckCircle className="w-10 h-10 text-green-600 dark:text-green-400" />
</div>
<div className="space-y-4">
<div className="p-4 bg-green-50 border border-green-100 rounded-xl">
<p className="text-green-700 font-medium">
<div className="p-4 bg-green-50 dark:bg-green-900/30 border border-green-100 dark:border-green-800 rounded-xl">
<p className="text-green-700 dark:text-green-300 font-medium">
Dein Account ist jetzt vollständig aktiviert!
</p>
</div>
<p className="text-slate-600">
<p className="text-slate-600 dark:text-slate-400">
Du kannst jetzt alle Features von EmailSorter nutzen.
</p>
@@ -110,18 +110,18 @@ export function VerifyEmail() {
{status === 'error' && (
<div className="text-center py-8">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-red-100 flex items-center justify-center">
<XCircle className="w-10 h-10 text-red-600" />
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<XCircle className="w-10 h-10 text-red-600 dark:text-red-400" />
</div>
<div className="space-y-4">
<div className="p-4 bg-red-50 border border-red-100 rounded-xl">
<p className="text-red-700">
<div className="p-4 bg-red-50 dark:bg-red-900/30 border border-red-100 dark:border-red-800 rounded-xl">
<p className="text-red-700 dark:text-red-300">
{error || 'Der Verifizierungslink ist ungültig oder abgelaufen.'}
</p>
</div>
<p className="text-slate-600 text-sm">
<p className="text-slate-600 dark:text-slate-400 text-sm">
Falls dein Link abgelaufen ist, kannst du eine neue Verifizierungs-E-Mail anfordern.
</p>
@@ -142,9 +142,9 @@ export function VerifyEmail() {
</Card>
{/* Help text */}
<p className="text-center text-sm text-slate-500 mt-6">
<p className="text-center text-sm text-slate-500 dark:text-slate-400 mt-6">
Probleme? Kontaktiere uns unter{' '}
<a href="mailto:support@emailsorter.de" className="text-primary-600 hover:underline">
<a href="mailto:support@emailsorter.de" className="text-primary-600 dark:text-primary-400 hover:underline">
support@emailsorter.de
</a>
</p>

View File

@@ -0,0 +1,91 @@
/**
* 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 {
id?: string
name: string
condition: string
enabled: boolean
category?: string
}
/** Name label = personal label per worker (admin only). AI assigns emails to a worker when clearly for them. */
export interface NameLabel {
id?: string
name: string
email?: string
keywords?: string[]
enabled: boolean
}
export interface CategoryInfo {
key: string
name: string
description: string
defaultAction: 'inbox' | 'archive_read' | 'star'
color: string
enabled: boolean
}
export interface KnownCompany {
name: string
domain: string
enabled: boolean
}

View File

@@ -0,0 +1,133 @@
# Email Sorter — Product Strategy (2-Week / Reddit Launch)
**Role:** Product owner. **Goal:** First paying users from Reddit (r/buildinpublic, r/SaaS, r/freelance). **Constraint:** Understandable in under 10 seconds.
---
## 1. Homepage & Messaging
### Problems today
- **Hero:** "Clean inbox automatically in minutes" is vague. "Minutes" undersells; "clean" is generic.
- **Subhead:** "Create smart rules, apply in one click" — sounds like manual work, not automatic.
- **Badge:** "AI-powered email sorting" — buzzword; doesnt say who its for or what outcome.
- **CTAs:** "Try Demo" vs "Connect inbox" — two choices slow decision; primary action unclear.
### Proposed copy (exact)
| Element | Current | Proposed |
|--------|---------|----------|
| **Badge** | AI-powered email sorting | For freelancers & small teams |
| **Headline** | Clean inbox automatically in minutes. | **Leads, clients, spam — sorted automatically.** |
| **Subhead** | Create smart rules… | Connect Gmail or Outlook. We put newsletters, promos, and noise in folders so your inbox stays for what pays. |
| **Primary CTA** | Try Demo (first) | **Try it free** (one button; goes to register or demo) |
| **Secondary** | Connect inbox | See how it works (scroll or short demo) |
### Implementation
- One primary CTA above the fold: **Try it free**`/register`. Remove or demote "Try Demo" to a small link under the button: "Or try a 30-second demo first."
- Remove "in minutes" and "smart rules" from hero. No "Inbox Zero" in hero (use only in Features if at all).
- Trust line: keep "No credit card · Gmail & Outlook · GDPR compliant" but shorten to one line.
---
## 2. Activation & Onboarding (60-Second Flow)
### Minimum steps before value
1. **Sign up** (email + password or Google; no long form).
2. **Connect inbox** OR **Try Demo** (pick one as default; Demo gets you to "Sort complete" in one click).
3. **Done** → Dashboard with "Sort Now" or auto-result.
### What to remove or defer
- **Remove:** Step "Settings" (Sorting Intensity: Light/Medium/Strict). Use a single default: Medium. Expose in Settings later.
- **Remove:** Step "Choose your categories". Default: all 6 core categories (VIP, Clients, Invoices, Newsletter, Social, Security). No picker during onboarding.
- **Remove:** "Historical emails" toggle. Default: off for first run (faster). Optional in Settings.
- **Keep:** Connect email (Gmail/Outlook) + Demo. One click to "Done" then Dashboard.
- **Skip button:** Keep "Skip" but rename to "Ill do this later" and only show after theyve seen the connect step (so they can still land on dashboard with empty state).
### 60-second flow (concrete)
1. **015s:** Land on `/register` or home → click "Try it free" → sign up (email or Google).
2. **1545s:** One screen: "Connect Gmail or Outlook" + prominent "Try with sample inbox" (demo). No steps 23.
3. **4560s:** After connect or demo → "Youre in. Click Sort Now." → Dashboard. If demo: one "Sort Now" click → instant result.
### Implementation
- Collapse Setup into **one step**: Connect (with Demo as primary option for first-time). After connect or demo → go straight to Dashboard.
- Move "Sorting intensity" and "Categories" to Settings (and optional "tune later" link from dashboard empty state).
- Default for new users: Demo first (so they see a result in 30s), then "Connect your real inbox to sort it."
---
## 3. Core Feature Focus
### One main selling point
**"Automatic email categories: Leads, clients, invoices, newsletters, spam — without rules."**
- The moment of value: user sees **their** emails (or demo emails) sorted into clear categories and inbox count dropping.
- Everything in the app should point to: connect → sort once → see result. No "AI suggests, you approve" as hero message.
### Features to hide or delay (for 2-week launch)
- **Hide:** "Control Panel", "Smart suggestions" / "Apply suggested rules" as primary path. Keep in dashboard for power users but dont push in onboarding.
- **Hide:** Daily digest / "Todays Digest" for new users (show after 2nd sort or after 7 days).
- **Hide:** Referral / Share results until after first successful sort and upgrade prompt.
- **De-emphasize:** Multiple email accounts (show "1 account" in pricing; multi-account in Settings, not hero).
- **Remove from landing:** "Inbox Zero" as headline (overused). Use "sorted inbox" or "inbox that stays clean."
### Features to keep prominent
- Connect one inbox (Gmail/Outlook).
- **Sort Now** + result: "X emails categorized, inbox reduced by Y, time saved Z."
- Single clear upgrade moment: when they hit limit or after first sort ("Unlimited sorts from $X/month").
---
## 4. UX/UI Improvements
### Trust & clarity
- **Navbar:** Add one line under logo: "B2B email sorting" or keep minimal. CTA: "Try it free" (not "Get started free").
- **Pricing section:** One price for Reddit launch: e.g. **$9/month** or **$7/month** (single plan). "Most Popular" on the only paid plan. Remove Business tier for now.
- **Empty state (Dashboard, no account):** One sentence: "Connect Gmail or Outlook to sort your first emails." One button: "Connect inbox." No extra cards (Control Panel, Einstellungen) until one account is connected.
- **Empty state (Dashboard, account connected, no sort yet):** "Click Sort Now to categorize your inbox. Takes about 30 seconds." Big "Sort Now" button.
- **First-time sort result:** Keep current "First sort complete!" + numbers. Add one line: "Weve put newsletters and promos in folders. Check your inbox — only important mail is left."
### Defaults
- **Onboarding:** Default = Demo (so they see value without OAuth). Then "Connect your real inbox."
- **Categories:** All 6 selected by default; no picker during onboarding.
- **Strictness:** Medium; no selector in flow.
### Skeptical / impatient users
- **Above the fold:** No carousel, no "4 steps". One headline, one subhead, one CTA.
- **FAQ:** Move "Do I need a credit card?" and "Can I cancel anytime?" to top. Add: "What do you do with my email?" → "We only read headers and labels to assign categories. We dont store email content."
- **Footer:** Short. Imprint, Privacy, Contact. No long feature list.
---
## 5. Monetization (Early Stage)
### Pricing that feels "no-brainer" for freelancers
- **Free:** 1 account, 500 emails/month, basic categories. Enough to feel the product.
- **Single paid plan:** **$9/month** (or **$7/month** for first 100 customers). "Unlimited emails, 1 account, all categories, cancel anytime."
- **Remove for now:** $19 Pro, $49 Business. One plan = no choice paralysis.
- **Trial:** 14-day free trial, no card. After trial, card required or account stays free-tier (500/mo).
### Early-adopter experiment
- **Reddit launch offer:** "First 50 from r/SaaS or r/freelance: $5/month for 6 months." Use a coupon or a separate plan ID. Mention in Reddit post and a small banner on pricing: "Reddit launch: $5/mo for 6 months — use code REDDIT50."
- **Churn:** Focus on "Sort Now" success in first 7 days. If theyve done 2+ sorts and connected a real inbox, send one email: "Youve sorted X emails. Upgrade to unlimited for $9/mo." No aggressive upsells.
---
## 6. Retention & Defensibility
### One integration that increases switching cost
- **Gmail labels (or Outlook folders) as the integration.** Product already sorts into categories; make the output visible where they live:
- **Sync categories to Gmail labels** (e.g. "EmailSorter/Clients", "EmailSorter/Newsletter"). User sees labels in Gmail; moving away means losing those labels or redoing work.
- Implementation: After sort, apply Gmail API `users.labels` + `messages.modify` to add the label to each message. One-way: Email Sorter → Gmail. No need for bi-directional sync in v1.
- **Alternative (simpler):** **Weekly digest email.** "You sorted 47 emails this week. Top category: Newsletter (20)." Builds habit and touchpoint; unsubscribing = losing a small benefit.
- **Recommendation:** Gmail (and later Outlook) label sync. Real defensibility; realistic for a solo dev (Gmail API is well documented). Ship "Sync to Gmail labels" as a Pro feature or postfree-trial hook.
---
## Implementation Checklist (Priority Order)
- [ ] **Hero:** New headline, subhead, single CTA "Try it free", demo as secondary link.
- [ ] **Onboarding:** Single step (Connect or Demo) → Dashboard. Move Settings + Categories to Settings page.
- [ ] **Pricing:** One paid plan $9/mo; optional Reddit code REDDIT50 ($5/mo for 6 months).
- [ ] **Dashboard empty states:** Copy and single primary action per state.
- [ ] **FAQ:** Reorder; add "What do you do with my email?"; keep short.
- [ ] **Defensibility:** Design/spec "Sync categories to Gmail labels" for postlaunch.

60
docs/README.md Normal file
View File

@@ -0,0 +1,60 @@
# Dokumentation
Diese Dokumentation ist in verschiedene Kategorien unterteilt:
## 📁 Struktur
```
docs/
├── setup/ # Setup-Anleitungen
├── deployment/ # Deployment & Production
├── development/ # Development-Dokumentation
├── server/ # Server-spezifische Docs
├── examples/ # Beispiel-Code
└── legacy/ # Legacy-Dateien
```
## 📚 Kategorien
### Setup (`docs/setup/`)
- **APPWRITE_SETUP.md** - Appwrite Installation & Konfiguration
- **APPWRITE_CORS_SETUP.md** - CORS-Konfiguration für Appwrite
- **GOOGLE_OAUTH_SETUP.md** - Google OAuth Setup
- **SETUP_GUIDE.md** - Allgemeine Setup-Anleitung
- **FAVICON_SETUP.md** - Favicon-Konfiguration
### Deployment (`docs/deployment/`)
- **README.md** - Deployment-Übersicht
- **GITEA_WEBHOOK_SETUP.md** - Vollständige Anleitung für automatisches Deployment via Gitea Webhook
- **WEBHOOK_QUICK_START.md** - Schnellstart-Anleitung (5 Minuten)
- **WEBHOOK_AUTHORIZATION.md** - Webhook-Authentifizierung und Sicherheit
- **PRODUCTION_SETUP.md** - Production-Server Setup
- **PRODUCTION_FIXES.md** - Production-Fixes & Troubleshooting
- **DEPLOYMENT_INSTRUCTIONS.md** - Manuelle Deployment-Anleitungen
### Development (`docs/development/`)
- **GIT_AUTHENTICATION_FIX.md** - Git-Authentifizierung
- **PROJECT_RENAME_GUIDE.md** - Projekt-Umbenennung
- **PROJECT_REVIEW_SUMMARY.md** - Projekt-Review
- **TASK_5_COMPLETION.md** - Task-Completion-Dokumentation
- **TESTING_SUMMARY.md** - Testing-Zusammenfassung
### Server (`docs/server/`)
- **CORRECTNESS_VALIDATION.md** - Korrektheits-Validierung
- **E2E_TEST_GUIDE.md** - End-to-End Test Guide
- **ENDPOINT_VERIFICATION.md** - API-Endpoint-Verifikation
- **FRONTEND_VERIFICATION.md** - Frontend-Verifikation
- **MANUAL_TEST_CHECKLIST.md** - Manuelle Test-Checkliste
- **TASK_4_COMPLETION_SUMMARY.md** - Task 4 Completion
### Examples (`docs/examples/`)
- **starter-for-react/** - React Starter Template (Beispiel)
### Legacy (`docs/legacy/`)
- **public/** - Alte Public-Dateien (falls noch benötigt)
## 🚀 Schnellstart
1. **Erstes Setup:** Siehe `docs/setup/SETUP_GUIDE.md`
2. **Production Deployment:** Siehe `docs/deployment/PRODUCTION_SETUP.md`
3. **Development:** Siehe `docs/development/` für Development-Dokumentation

View File

@@ -0,0 +1,51 @@
# Deployment-Anleitung
## Status
**Build erfolgreich erstellt** - `client/dist` ist bereit für Deployment
## Git Commit & Push
Da Git nicht automatisch gefunden werden kann, führe bitte diese Befehle manuell aus:
```bash
cd c:\Users\User\Documents\GitHub\ANDJJJJJJ
git add .
git commit -m "fix: TypeScript errors & build fixes for Control Panel Redesign
- Fix unused imports (Trash, Filter, Bell, CategoryAdvanced)
- Fix undefined checks for cleanup settings
- Fix cleanupPreview undefined checks
- Fix useTheme unused parameter
- Fix companyLabels type safety
- Build erfolgreich durchgeführt"
git push
```
## Deployment des Builds
### Option 1: Manuelles Upload
1. Öffne den Ordner: `c:\Users\User\Documents\GitHub\ANDJJJJJJ\client\dist`
2. Kopiere alle Dateien aus diesem Ordner
3. Lade sie auf deinen Web-Server hoch (z.B. via FTP/SFTP zu `emailsorter.webklar.com`)
### Option 2: SSH/SCP (falls verfügbar)
```bash
scp -r client/dist/* user@webklar.com:/path/to/webserver/emailsorter/
```
### Option 3: GitHub Actions / CI/CD
Falls du CI/CD eingerichtet hast, sollte der Push automatisch deployen.
## Nach dem Deployment
1. Leere den Browser-Cache (Strg+Shift+R)
2. Prüfe die Website: https://emailsorter.webklar.com
3. Teste die neuen Features:
- Control Panel mit Card-Layout
- Side Panels für Category Configuration
- Cleanup Tab mit Slidern
- Labels Tab mit Tabelle
- Dark Mode Verbesserungen
## Wichtige Hinweise
- Stelle sicher, dass `.env.production` die richtigen Production-URLs hat
- Backend-Server muss laufen
- Appwrite CORS muss für `https://emailsorter.webklar.com` konfiguriert sein

View File

@@ -0,0 +1,231 @@
# Gitea Webhook Setup - Automatisches Deployment
Diese Anleitung erklärt, wie du einen Gitea-Webhook einrichtest, um automatisch zu deployen, wenn Code gepusht wird.
## Übersicht
Der Webhook funktioniert folgendermaßen:
1. **Push auf Gitea** → Gitea sendet Webhook-Event an deinen Server
2. **Webhook-Handler** empfängt das Event und verifiziert die Signatur
3. **Deployment-Skript** wird ausgeführt:
- Git Pull (falls auf Server)
- Frontend Build (`npm run build`)
- Upload auf Production-Server (via SCP/SSH)
- Backend Neustart (optional, via PM2)
## Voraussetzungen
- ✅ Gitea-Repository mit deinem Code
- ✅ Production-Server mit SSH-Zugriff
- ✅ Node.js auf dem Server installiert
- ✅ PM2 installiert (optional, für Backend-Neustart)
## Schritt 1: Webhook-Secret generieren
Generiere ein sicheres Secret für die Webhook-Signatur-Verification:
```bash
# Generiere ein zufälliges Secret (32 Zeichen)
node -e "console.log(require('crypto').randomBytes(16).toString('hex'))"
```
**Wichtig:** Speichere dieses Secret sicher - du brauchst es in Schritt 3 und 4.
## Schritt 2: Environment Variables konfigurieren
Füge folgende Variablen zu deiner `server/.env` Datei hinzu:
```bash
# Gitea Webhook Secret (aus Schritt 1)
GITEA_WEBHOOK_SECRET=dein_generiertes_secret_hier
# Optional: Authorization Header Token
GITEA_WEBHOOK_AUTH_TOKEN=dein_auth_token_hier
# Server-Deployment (optional, nur wenn automatischer Upload gewünscht)
DEPLOY_SERVER_HOST=91.99.156.85
DEPLOY_SERVER_USER=root
DEPLOY_SERVER_PATH=/var/www/emailsorter
DEPLOY_SSH_KEY=/path/to/ssh/private/key # Optional, falls SSH-Key benötigt wird
DEPLOY_FRONTEND_PATH=/var/www/emailsorter/client/dist
DEPLOY_BACKEND_PATH=/var/www/emailsorter/server
# PM2 für Backend-Neustart (optional)
USE_PM2=true
```
## Schritt 3: Webhook in Gitea konfigurieren
1. **Öffne dein Repository** in Gitea
2. Gehe zu **Settings****Webhooks**
3. Klicke auf **Add Webhook****Gitea**
4. Fülle die Felder aus:
- **Target URL:**
```
https://emailsorter.webklar.com/api/webhook/gitea
```
(Ersetze mit deiner tatsächlichen Domain)
- **HTTP Method:** `POST`
- **Post Content Type:** `application/json`
- **Secret:**
```
dein_generiertes_secret_hier
```
(Das gleiche Secret wie in Schritt 1)
- **Authorization Header:** (Optional)
```
Bearer dein_auth_token_hier
```
- **Trigger On:**
- ✅ **Push Events** (wichtig!)
- Optional: **Create**, **Delete** (falls gewünscht)
- **Branch Filter:** `main` oder `master` (je nach deinem Standard-Branch)
5. Klicke auf **Add Webhook**
## Schritt 4: Webhook testen
### Option A: Test über Gitea UI
1. Gehe zurück zu **Settings** → **Webhooks**
2. Klicke auf deinen Webhook
3. Klicke auf **Test Delivery** → **Push Events**
4. Prüfe die Antwort:
- ✅ **Status 202** = Webhook empfangen, Deployment gestartet
- ❌ **Status 401** = Secret falsch
- ❌ **Status 500** = Server-Fehler (prüfe Server-Logs)
### Option B: Test über Git Push
1. Mache eine kleine Änderung (z.B. Kommentar in einer Datei)
2. Committe und pushe:
```bash
git add .
git commit -m "test: Webhook test"
git push
```
3. Prüfe die Server-Logs:
```bash
# Auf dem Server
pm2 logs emailsorter-backend
# Oder
tail -f /var/log/emailsorter/webhook.log
```
4. Du solltest sehen:
```
📥 Gitea Webhook empfangen
🚀 Starte Deployment...
📦 Baue Frontend...
✅ Deployment erfolgreich abgeschlossen
```
## Schritt 5: Deployment-Logs prüfen
Die Webhook-Handler loggen alle Schritte. Prüfe die Logs:
```bash
# PM2 Logs
pm2 logs emailsorter-backend
# Oder direkt im Server
tail -f server/logs/webhook.log
```
## Fehlerbehebung
### Webhook wird nicht ausgelöst
- ✅ Prüfe, ob die **Target URL** korrekt ist
- ✅ Prüfe, ob der Server erreichbar ist (`curl https://emailsorter.webklar.com/api/webhook/status`)
- ✅ Prüfe Gitea-Logs: **Settings** → **Webhooks** → **Delivery Log**
### 502 Bad Gateway (von nginx)
Nginx meldet 502, wenn das Backend (Node/PM2) nicht antwortet oder abstürzt.
- ✅ **Backend läuft:** `pm2 list` Prozess muss „online“ sein
- ✅ **Backend neu starten:** `pm2 restart all` oder `pm2 start ecosystem.config.js`
- ✅ **Logs prüfen:** `pm2 logs` beim nächsten „Test Push“ sofort Fehler ansehen
- ✅ **Health prüfen:** `curl -s -o /dev/null -w "%{http_code}" https://emailsorter.webklar.com/api/health` → sollte `200` sein
- ✅ **Nginx-Upstream:** `proxy_pass` muss auf den richtigen Port zeigen (z.B. `http://127.0.0.1:3000`)
Nach einem Code-Deploy (größeres Body-Limit, robustere Fehlerbehandlung) Backend neu starten: `pm2 restart all`.
### "Ungültige Webhook-Signatur" (401/403)
- ✅ Prüfe, ob `GITEA_WEBHOOK_SECRET` in `server/.env` gesetzt ist
- ✅ Prüfe, ob das Secret in Gitea **genau gleich** ist (keine Leerzeichen!)
- ✅ Prüfe, ob der Webhook **"application/json"** als Content-Type verwendet
### Deployment schlägt fehl
- ✅ Prüfe Server-Logs für detaillierte Fehlermeldungen
- ✅ Prüfe, ob SSH-Zugriff funktioniert: `ssh root@91.99.156.85`
- ✅ Prüfe, ob `npm` und `node` auf dem Server installiert sind
- ✅ Prüfe, ob die Pfade (`DEPLOY_SERVER_PATH`) korrekt sind
### Frontend-Build fehlgeschlagen
- ✅ Prüfe, ob alle Dependencies installiert sind: `cd client && npm install`
- ✅ Prüfe, ob `.env.production` korrekt konfiguriert ist
- ✅ Prüfe Build-Logs für TypeScript/ESLint-Fehler
### Backend startet nicht neu
- ✅ Prüfe, ob PM2 installiert ist: `pm2 --version`
- ✅ Prüfe, ob `USE_PM2=true` in `.env` gesetzt ist
- ✅ Prüfe PM2-Status: `pm2 list`
## Sicherheit
### Best Practices
1. **Webhook-Secret:** Verwende immer ein starkes, zufälliges Secret
2. **HTTPS:** Stelle sicher, dass dein Server HTTPS verwendet (Let's Encrypt)
3. **Firewall:** Beschränke Webhook-Endpoint auf Gitea-IPs (optional)
4. **Rate Limiting:** Der Webhook-Endpoint ist bereits rate-limited
5. **Logs:** Prüfe regelmäßig die Webhook-Logs auf verdächtige Aktivitäten
## Alternative: Lokales Deployment ohne Server-Upload
Falls du den automatischen Upload auf den Server nicht möchtest, kannst du:
1. `DEPLOY_SERVER_HOST` **nicht** setzen
2. Das Deployment-Skript erstellt nur den Build lokal
3. Du lädst die Dateien manuell hoch oder verwendest ein anderes Tool
Der Webhook wird trotzdem ausgelöst und erstellt den Build, aber überspringt den Upload-Schritt.
## Manuelles Deployment auslösen
Du kannst das Deployment auch manuell auslösen:
```bash
# Auf dem Server
cd /var/www/emailsorter
node scripts/deploy-to-server.mjs
```
## Nächste Schritte
Nach erfolgreichem Setup:
1. ✅ Teste den Webhook mit einem kleinen Push
2. ✅ Prüfe, ob die Website aktualisiert wurde
3. ✅ Überwache die Logs für die ersten Deployments
4. ✅ Dokumentiere deine spezifische Konfiguration
## Support
Bei Problemen:
- Prüfe die Server-Logs
- Prüfe Gitea Webhook Delivery Logs
- Prüfe die Environment Variables
- Teste SSH-Verbindung manuell

View File

@@ -0,0 +1,51 @@
# Production Fixes - Wichtige Schritte
## ✅ Behoben
1. **Debug-Logs entfernt** - Alle Debug-Logs zu `127.0.0.1:7242` wurden entfernt
2. **Favicon-Problem behoben** - `site.webmanifest` verwendet jetzt vorhandene SVG-Dateien
## ⚠️ Noch zu beheben (im Appwrite Dashboard)
### 1. Appwrite CORS-Konfiguration
**Problem:** Appwrite erlaubt nur `https://localhost` statt `https://emailsorter.webklar.com`
**Lösung:**
1. Gehe zu: https://appwrite.webklar.com
2. Öffne dein Projekt
3. Gehe zu **Settings****Platforms** (oder **Web**)
4. Füge eine neue Platform hinzu:
- **Name:** Production
- **Hostname:** `emailsorter.webklar.com`
- **Origin:** `https://emailsorter.webklar.com`
5. Speichere die Änderungen
**ODER** bearbeite die existierende Platform und ändere den Hostname/Origin zu `https://emailsorter.webklar.com`
### 2. Backend-Server (502 Bad Gateway)
**Problem:** `/api/analytics/track` gibt 502 zurück - Backend-Server läuft nicht
**Lösung:**
1. SSH zum Server: `ssh user@webklar.com`
2. Prüfe ob Server läuft: `pm2 list` oder `ps aux | grep node`
3. Falls nicht: Starte den Server:
```bash
cd /path/to/ANDJJJJJJ/server
pm2 start index.mjs --name emailsorter-api
pm2 save
```
4. Prüfe Logs: `pm2 logs emailsorter-api`
### 3. Build deployen
Nach dem Commit und Push:
1. Kopiere den Inhalt von `client/dist` auf den Web-Server
2. Stelle sicher, dass die Dateien unter `https://emailsorter.webklar.com` erreichbar sind
## Nach allen Fixes
1. Leere den Browser-Cache (Strg+Shift+R)
2. Teste die Website
3. Prüfe die Browser-Konsole - sollte keine Fehler mehr zeigen

View File

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

60
docs/deployment/README.md Normal file
View File

@@ -0,0 +1,60 @@
# Deployment-Dokumentation
Diese Dokumentation beschreibt alle Aspekte des Deployments für E-Mail-Sorter.
## 📚 Inhaltsverzeichnis
### Automatisches Deployment
- **[Gitea Webhook Setup](./GITEA_WEBHOOK_SETUP.md)** - Vollständige Anleitung für automatisches Deployment via Gitea Webhook
- **[Webhook Quick Start](./WEBHOOK_QUICK_START.md)** - Schnellstart-Anleitung (5 Minuten)
- **[Webhook Authorization](./WEBHOOK_AUTHORIZATION.md)** - Authentifizierung und Sicherheit
### Manuelles Deployment
- **[Deployment Instructions](./DEPLOYMENT_INSTRUCTIONS.md)** - Manuelle Deployment-Schritte
- **[Production Setup](./PRODUCTION_SETUP.md)** - Production-Server Setup
- **[Production Fixes](./PRODUCTION_FIXES.md)** - Bekannte Probleme und Lösungen
## 🚀 Schnellstart
Für automatisches Deployment siehe [Webhook Quick Start](./WEBHOOK_QUICK_START.md).
## 📋 Übersicht
### Automatisches Deployment (Empfohlen)
1. **Gitea Webhook einrichten** → Siehe [GITEA_WEBHOOK_SETUP.md](./GITEA_WEBHOOK_SETUP.md)
2. **Bei jedem Push** wird automatisch deployed
3. **Keine manuellen Schritte** nötig
### Manuelles Deployment
1. **Frontend bauen:** `cd client && npm run build`
2. **Dateien hochladen** auf Server
3. **Backend neustarten** (falls nötig)
Siehe [DEPLOYMENT_INSTRUCTIONS.md](./DEPLOYMENT_INSTRUCTIONS.md) für Details.
## 🔧 Konfiguration
Alle Deployment-Konfigurationen finden sich in `server/.env`:
```bash
# Webhook-Konfiguration
GITEA_WEBHOOK_SECRET=...
GITEA_WEBHOOK_AUTH_TOKEN=...
# Server-Deployment
DEPLOY_SERVER_HOST=91.99.156.85
DEPLOY_SERVER_USER=root
DEPLOY_SERVER_PATH=/var/www/emailsorter
USE_PM2=true
```
## 📞 Support
Bei Problemen:
1. Prüfe die Server-Logs
2. Siehe [Production Fixes](./PRODUCTION_FIXES.md)
3. Prüfe Webhook Delivery Logs in Gitea

View File

@@ -0,0 +1,41 @@
# Anleitung für SSH nur EmailSorter (emailsorter.webklar.com) fixen
**Kopiere den folgenden Abschnitt und schick ihn an die Person am Server (oder nutze ihn als eigene Checkliste):**
---
## Kontext
- **Nur diese Website:** **emailsorter.webklar.com** (EmailSorter / Gitea-Webhook).
- **Nicht anfassen:** Alle anderen Websites/Projekte auf dem gleichen Server.
- **Problem:** Beim Gitea-Webhook („Test Push Event“) kommt **502 Bad Gateway** von nginx. Das Backend (Node/PM2) für emailsorter.webklar.com soll geprüft und ggf. neu gestartet werden.
## Was ich brauche
1. **PM2 prüfen (nur für EmailSorter):**
- `pm2 list` ausführen.
- Den Prozess finden, der zu **emailsorter.webklar.com** / EmailSorter gehört (Name oder Script-Pfad wie `server/index.mjs` oder `emailsorter`).
- Prüfen: Läuft er (Status „online“)? Wenn „stopped“ oder „errored“: das ist wahrscheinlich die Ursache für den 502.
2. **Backend für EmailSorter neu starten:**
- Nur den PM2-Prozess für EmailSorter neu starten (nicht `pm2 restart all`, wenn andere Sites davon betroffen wären).
- Beispiel, wenn der Prozess „emailsorter“ heißt: `pm2 restart emailsorter`
- Oder nur den einen Eintrag in der Liste per Name/ID neu starten.
3. **Env für EmailSorter prüfen (optional, nur wenn Webhook weiter 502/401 gibt):**
- In das Projektverzeichnis von EmailSorter wechseln (z.B. `/var/www/emailsorter` oder wo auch immer es liegt).
- Prüfen, ob in `server/.env` (oder im Root-`.env`) steht:
`GITEA_WEBHOOK_SECRET=<dein Webhook-Secret>`
- Wenn nicht: diese Zeile in der richtigen `.env` ergänzen (Secret bekommst du separat / steht in Gitea unter Webhook → Secret). Danach nur den EmailSorter-PM2-Prozess neu starten.
4. **Kurz testen:**
- `curl -s -o /dev/null -w "%{http_code}" https://emailsorter.webklar.com/api/health`
Sollte `200` ausgeben.
- `curl -s -o /dev/null -w "%{http_code}" https://emailsorter.webklar.com/api/webhook/status`
Sollte ebenfalls `200` ausgeben.
5. **Nichts anderes ändern:** Keine anderen Projekte, keine globalen nginx-/System-Konfigurationen anpassen nur EmailSorter (emailsorter.webklar.com) wie oben beschrieben.
---
**Wenn du den Code gerade neu deployed hast (git pull für EmailSorter):** Danach bitte nur den PM2-Prozess für EmailSorter neu starten (z.B. `pm2 restart <name-oder-id>`), damit die neuen Webhook-Fixes aktiv sind.

View File

@@ -0,0 +1,83 @@
# Webhook Authorization Header - Anleitung
Der Webhook unterstützt **zwei Authentifizierungsmethoden**:
1. **Signature-Verification** (Standard, von Gitea)
2. **Authorization Header** (Optional, zusätzliche Sicherheit)
## Option 1: Nur Signature (Standard)
Das ist die Standard-Methode, die Gitea automatisch verwendet:
### Konfiguration
In `server/.env`:
```bash
GITEA_WEBHOOK_SECRET=dein_secret_hier
```
### In Gitea
- **Secret:** Trage das gleiche Secret ein
- **Authorization Header:** Nicht nötig
Gitea sendet automatisch den `X-Gitea-Signature` Header.
## Option 2: Authorization Header (Zusätzliche Sicherheit)
Falls du zusätzliche Sicherheit möchtest oder den Webhook manuell aufrufst:
### Schritt 1: Token generieren
```bash
node -e "console.log(require('crypto').randomBytes(16).toString('hex'))"
```
### Schritt 2: Server konfigurieren
In `server/.env`:
```bash
GITEA_WEBHOOK_SECRET=dein_secret_hier
GITEA_WEBHOOK_AUTH_TOKEN=dein_auth_token_hier
```
### Schritt 3: In Gitea konfigurieren
Gitea unterstützt **keine** Authorization Header direkt, aber du kannst:
#### Option A: Nur Signature verwenden (empfohlen)
- Lass `GITEA_WEBHOOK_AUTH_TOKEN` leer
- Nur `GITEA_WEBHOOK_SECRET` verwenden
#### Option B: Manuelle Webhook-Aufrufe
Wenn du den Webhook manuell aufrufst (z.B. via curl), verwende:
```bash
curl -X POST https://emailsorter.webklar.com/api/webhook/gitea \
-H "Content-Type: application/json" \
-H "X-Gitea-Signature: sha256=..." \
-H "Authorization: Bearer dein_auth_token_hier" \
-d '{"ref":"refs/heads/main",...}'
```
## Option 3: Beide Methoden kombinieren
Für maximale Sicherheit kannst du beide verwenden:
```bash
# In server/.env
GITEA_WEBHOOK_SECRET=secret_fuer_signature
GITEA_WEBHOOK_AUTH_TOKEN=token_fuer_auth_header
```
**Verhalten:**
- Wenn beide gesetzt sind, müssen **beide** passen
- Wenn nur eine gesetzt ist, reicht diese
## Empfehlung
**Für Gitea-Webhooks:** Verwende nur `GITEA_WEBHOOK_SECRET` (Signature)
**Für manuelle Aufrufe:** Verwende `GITEA_WEBHOOK_AUTH_TOKEN` (Authorization Header)
**Für maximale Sicherheit:** Verwende beide

View File

@@ -0,0 +1,62 @@
# Gitea Webhook - Quick Start Guide
## 🚀 Schnellstart (5 Minuten)
### Schritt 1: Secret generieren
```bash
node -e "console.log(require('crypto').randomBytes(16).toString('hex'))"
```
Kopiere das generierte Secret - du brauchst es gleich!
### Schritt 2: Server konfigurieren
Füge zu `server/.env` hinzu:
```bash
GITEA_WEBHOOK_SECRET=dein_generiertes_secret_hier
DEPLOY_SERVER_HOST=91.99.156.85
DEPLOY_SERVER_USER=root
DEPLOY_SERVER_PATH=/var/www/emailsorter
USE_PM2=true
```
### Schritt 3: Gitea Webhook einrichten
1. Gehe zu deinem Repository → **Settings****Webhooks**
2. Klicke **Add Webhook****Gitea**
3. Fülle aus:
- **Target URL:** `https://emailsorter.webklar.com/api/webhook/gitea` (Produktion)
- **Secret:** `dein_generiertes_secret_hier` (aus Schritt 1)
- **Authorization Header (optional):** `Bearer dein_generiertes_secret_hier` (gleicher Wert wie Secret)
- **Trigger On:** ✅ **Push Events**
- **Branch Filter:** `main` oder `master`
4. Klicke **Add Webhook**
### Schritt 4: Testen
```bash
git add .
git commit -m "test: Webhook test"
git push
```
Prüfe die Server-Logs - du solltest sehen:
```
📥 Gitea Webhook empfangen
🚀 Starte Deployment...
✅ Deployment erfolgreich abgeschlossen
```
## ✅ Fertig!
Jetzt wird bei jedem Push automatisch deployed!
## 📚 Weitere Informationen
Siehe [GITEA_WEBHOOK_SETUP.md](./GITEA_WEBHOOK_SETUP.md) für:
- Detaillierte Anleitung
- Fehlerbehebung
- Sicherheitsbest Practices
- Server-Upload Konfiguration

View File

@@ -0,0 +1,183 @@
# Implementierungsplan: IMAP / Porkbun / Nextcloud
Plan, um EmailSorter um einen **IMAP-Provider** (z.B. Porkbun) zu erweitern. Dann funktioniert die Sortierung auch für Postfächer, die in Nextcloud Mail genutzt werden.
---
## Übersicht
| Phase | Inhalt | Aufwand (grobe Schätzung) |
|-------|--------|----------------------------|
| **1** | IMAP-Bibliothek + Service-Grundgerüst | 12 h |
| **2** | Datenbank + Connect-Route für IMAP | 1 h |
| **3** | Sortier-Logik für IMAP (Ordner statt Labels) | 23 h |
| **4** | Frontend: IMAP-Verbindung anlegen | 12 h |
| **5** | Testen, Feinschliff, Doku | 1 h |
---
## Phase 1: IMAP-Bibliothek und Service
**Ziel:** Backend kann sich per IMAP (z.B. Porkbun) verbinden, INBOX auflisten und E-Mails lesen.
### 1.1 Abhängigkeit hinzufügen
- **Datei:** `server/package.json`
- **Aktion:** Dependency `imapflow` hinzufügen (moderner IMAP-Client für Node, SSL-Support).
- **Befehl:** `npm install imapflow` im Ordner `server/`.
### 1.2 Neuer Service
- **Datei (neu):** `server/services/imap.mjs`
- **Inhalt (Kern-Interface):**
- **Konstruktor:** `ImapService({ host, port, secure, user, password })` z.B. für Porkbun: `host: 'imap.porkbun.com', port: 993, secure: true`.
- **connect()** Verbindung aufbauen (login).
- **listEmails(maxResults, fromSeq?)** Nachrichten aus INBOX (z.B. per FETCH ENVELOPE), Rückgabe: `{ messages: [{ id, uid, ... }], nextSeq }`.
- **getEmail(messageId)** bzw. **batchGetEmails(ids)** eine bzw. mehrere Mails laden, Rückgabe-Format wie Gmail/Outlook: `{ id, headers: { from, subject }, snippet }`.
- **close()** Verbindung sauber trennen (LOGOUT).
- **Hinweis:** IMAP nutzt oft UID oder Sequence Number als „id“; einheitlich als `id` nach außen geben (String), damit die Sortier-Route wie bei Gmail/Outlook arbeitet.
### 1.3 Akzeptanz Phase 1
- Ein kleines Test-Script (z.B. `server/scripts/test-imap.mjs`) oder ein temporärer Route-Handler liest Umgebungsvariablen (IMAP_HOST, IMAP_PORT, IMAP_USER, IMAP_PASSWORD), baut `ImapService` auf, ruft `listEmails(10)` und `getEmail(...)` auf und loggt das Ergebnis. Keine Credentials im Repo nur `.env` / Umgebungsvariablen.
---
## Phase 2: Datenbank und Connect-Route
**Ziel:** Ein neuer Account-Typ „imap“ kann angelegt werden; Zugangsdaten werden gespeichert.
### 2.1 Datenbank (Appwrite)
- **Datei:** `server/bootstrap-v2.mjs` (oder separates Migrations-Script).
- **Aktion:** In der Collection `email_accounts` optionale Attribute anlegen:
- `imapHost` (String, optional)
- `imapPort` (Integer, optional)
- `imapSecure` (Boolean, optional)
- **Alternative (einfacher für nur Porkbun):** Keine neuen Felder; Host/Port im Code fest (imap.porkbun.com, 993). Dann nur `email` + Passwort nötig; Passwort in bestehendem Feld `accessToken` speichern (semantisch „geheimer Token für IMAP“). Für spätere andere IMAP-Server die optionalen Felder nachziehen.
### 2.2 Connect-Route erweitern
- **Datei:** `server/routes/email.mjs`
- **Route:** `POST /api/email/connect` (bzw. die Route, die Accounts anlegt).
- **Aktionen:**
- Im Validierungs-Schema `provider` um `'imap'` erweitern: z.B. `rules.isIn('provider', ['gmail', 'outlook', 'demo', 'imap'])`.
- Body für IMAP: mindestens `userId`, `provider: 'imap'`, `email`, `password` (oder `accessToken` als Passwort). Optional: `imapHost`, `imapPort`, `imapSecure`.
- Wenn `provider === 'imap'`:
- Host/Port/Secure aus Body oder Default (Porkbun: imap.porkbun.com, 993, true).
- Passwort nicht loggen; in DB in `accessToken` (oder neuem Feld) speichern.
- Optional: einmalig `ImapService` instanziieren, `connect()` + `listEmails(1)` aufrufen; bei Erfolg Account anlegen, sonst Fehler zurückgeben („Ungültige Anmeldedaten“).
- Account-Dokument anlegen mit `provider: 'imap'`, `email`, `accessToken` (= Passwort), ggf. `imapHost`, `imapPort`, `imapSecure`.
### 2.3 Middleware/Validierung
- **Datei:** `server/middleware/validate.mjs` (falls dort Regeln liegen) oder direkt in der Route.
- **Aktion:** Für IMAP ggf. zusätzliche Felder erlauben: `imapHost`, `imapPort`, `imapSecure`, `password` (oder wie du das Feld nennst).
### 2.4 Akzeptanz Phase 2
- Per API-Client (Postman/curl) oder Frontend: POST mit `provider: 'imap'`, `email`, `password` (und optional Host/Port) an `/connect` senden. Erwartung: 201, Account in Appwrite mit `provider: 'imap'`. Bei falschem Passwort: 4xx mit verständlicher Meldung.
---
## Phase 3: Sortier-Logik für IMAP
**Ziel:** `POST /api/email/sort` funktioniert für Accounts mit `provider === 'imap'`: E-Mails werden per KI kategorisiert und in IMAP-Ordner verschoben.
### 3.1 Ordner-Mapping
- **Konzept:** Kategorien (z.B. `vip`, `promotions`, `newsletters`, `archive`) auf Ordner-Namen mappen. Z.B.:
- `archive` / `archive_read` → Ordner `Archive` oder `EmailSorter/Archive`
- `promotions``Promotions` oder `EmailSorter/Promotions`
- usw.
- **Datei:** Entweder in `server/services/imap.mjs` (Funktion `getFolderNameForCategory(category)`) oder in `server/services/ai-sorter.mjs` / Config. Einheitliche Liste (z.B. Objekt `categoryToFolder`) verwenden.
### 3.2 IMAP-Service erweitern
- **Datei:** `server/services/imap.mjs`
- **Neue Methoden:**
- **ensureFolder(folderName)** Ordner anlegen (CREATE), falls nicht vorhanden; Fehler „existiert bereits“ ignorieren.
- **moveToFolder(messageId, folderName)** Nachricht aus INBOX in den Ordner verschieben (MOVE oder COPY + DELETE aus INBOX).
- Optional: **markAsRead(messageId)** falls „archive_read“ = verschieben + als gelesen markieren.
### 3.3 Sortier-Route erweitern
- **Datei:** `server/routes/email.mjs`
- **Stelle:** Dort, wo `account.provider === 'gmail'` und `=== 'outlook'` abgefragt werden (und Demo).
- **Aktion:** Neuen Block `else if (account.provider === 'imap')` hinzufügen:
1. `ImapService` aus Account-Daten instanziieren (host, port, secure, user = email, password = accessToken).
2. `connect()`.
3. In einer Schleife (analog Gmail/Outlook):
- `listEmails(batchSize, nextSeq)` → Liste von Nachrichten.
- `batchGetEmails(ids)` → From, Subject, Snippet.
- Für jede E-Mail: KI-Kategorie ermitteln (bestehender `AISorterService`), dann `ensureFolder(categoryToFolder[category])` und `moveToFolder(id, folderName)`.
- Bei „archive_read“ ggf. zusätzlich als gelesen markieren.
4. Statistiken aktualisieren (wie bei Gmail/Outlook).
5. `close()` aufrufen.
- **Fehlerbehandlung:** Bei IMAP-Fehlern (z.B. „Invalid credentials“) sinnvolle Meldung zurückgeben und ggf. Account als „reconnect nötig“ markieren.
### 3.4 Akzeptanz Phase 3
- Ein IMAP-Account ist verbunden. Aufruf von `POST /api/email/sort` mit `userId` und `accountId`. Erwartung: E-Mails aus INBOX werden kategorisiert und in die richtigen Ordner verschoben; Response enthält z.B. `sortedCount` und Kategorie-Statistiken. In Nextcloud Mail (oder anderem IMAP-Client) erscheinen die neuen Ordner und verschobenen Mails.
---
## Phase 4: Frontend IMAP verbinden
**Ziel:** Nutzer können im UI „Anderes Postfach (IMAP)“ wählen und E-Mail + Passwort eingeben.
### 4.1 Verbindungs-Flow
- **Datei(en):** Dort, wo heute Gmail/Outlook/Demo angeboten werden (z.B. Setup, Settings, „E-Mail verbinden“).
- **Aktion:**
- Neue Option „IMAP / anderes Postfach“ (oder „Porkbun / eigenes Postfach“).
- Beim Klick: Formular anzeigen mit:
- E-Mail (Pflicht)
- Passwort / App-Passwort (Pflicht, Typ Passwort)
- Optional (z.B. für Power-User): Host, Port, SSL (Checkbox); Defaults: imap.porkbun.com, 993, SSL an.
- Submit: POST an Backend (z.B. `/api/email/connect`) mit `provider: 'imap'`, `email`, `password`, optional `imapHost`, `imapPort`, `imapSecure`.
- Bei Erfolg: Erfolgsmeldung, Account-Liste aktualisieren. Bei Fehler: Meldung anzeigen (z.B. „Anmeldung fehlgeschlagen prüfe E-Mail und Passwort“).
### 4.2 API-Client (Frontend)
- **Datei:** z.B. `client/src/lib/api.ts`
- **Aktion:** Methode `connectImapAccount(userId, { email, password, imapHost?, imapPort?, imapSecure? })` hinzufügen, die `POST /api/email/connect` mit diesen Daten aufruft.
### 4.3 Akzeptanz Phase 4
- Im UI „IMAP verbinden“ auswählen, E-Mail + Passwort eingeben, absenden. Account erscheint in der Account-Liste. Danach „Sortieren“ auslösbar und funktioniert wie in Phase 3.
---
## Phase 5: Testen und Doku
- **Manuell:** Mit einem echten Porkbun-Account (oder anderem IMAP) verbinden, Sortierung ausführen, in Nextcloud prüfen, ob Ordner und Mails stimmen.
- **Sicherheit:** Prüfen, dass Passwörter nirgends geloggt werden und nicht im Frontend gespeichert werden.
- **Doku:** `docs/setup/IMAP_NEXTCLOUD_PORKBUN.md` ggf. um „Konfiguration Porkbun“ und „Troubleshooting“ ergänzen (z.B. App-Passwort, 2FA).
---
## Kurz-Checkliste
- [x] Phase 1: `imapflow` installiert, `server/services/imap.mjs` mit connect, listEmails, getEmail, close; Test mit .env-Credentials.
- [x] Phase 2: Appwrite `email_accounts` ggf. um IMAP-Felder erweitert; Connect-Route akzeptiert `imap` und speichert Zugangsdaten; Test: Account per API anlegen.
- [x] Phase 3: Ordner-Mapping; ImapService: ensureFolder, moveToFolder; Sortier-Route: Block für `provider === 'imap'`; Test: Sortierung für IMAP-Account.
- [x] Phase 4: Frontend-Option „IMAP“, Formular E-Mail/Passwort, API-Anbindung; Test: End-to-End Verbindung + Sortierung aus UI.
- [ ] Phase 5: Manueller Test mit Porkbun/Nextcloud; Sicherheits-Check; Doku aktualisiert.
---
## Dateien-Übersicht
| Aktion | Datei |
|--------|--------|
| Neu | `server/services/imap.mjs` |
| Neu (optional) | `server/scripts/test-imap.mjs` |
| Ändern | `server/package.json` (imapflow) |
| Ändern | `server/bootstrap-v2.mjs` (optional: IMAP-Attribute) |
| Ändern | `server/routes/email.mjs` (provider imap, connect + sort) |
| Ändern | `server/middleware/validate.mjs` (falls nötig) |
| Ändern | Frontend: Connect-UI (Setup/Settings) + `client/src/lib/api.ts` |
| Ändern | `docs/setup/IMAP_NEXTCLOUD_PORKBUN.md` (Feinschliff) |
Wenn du mit Phase 1 startest, reicht zunächst: `imapflow` einbinden und `imap.mjs` mit connect + listEmails + getEmail implementieren und lokal mit Porkbun testen.

View File

Before

Width:  |  Height:  |  Size: 1012 B

After

Width:  |  Height:  |  Size: 1012 B

View File

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

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

105
docs/setup/FAVICON_SETUP.md Normal file
View File

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

View File

@@ -0,0 +1,189 @@
# IMAP / Nextcloud / Porkbun Integration
## Ziel
EmailSorter soll E-Mails nutzen, die über **Porkbun** (SMTP/IMAP) laufen und ggf. in **Nextcloud Mail** genutzt werden.
**Porkbun (von dir genutzt):**
| Dienst | Host | Port | Verschlüsselung |
|--------|------|------|-----------------|
| IMAP | imap.porkbun.com | 993 | SSL (SSL/TLS) |
| SMTP | smtp.porkbun.com | 587 | STARTTLS |
| SMTP (Alt.) | smtp.porkbun.com | 50587 | STARTTLS |
| SMTP | smtp.porkbun.com | 465 | Implicit TLS |
| POP | pop.porkbun.com | 995 | SSL (SSL/TLS) |
Für **Sortieren/Lesen** reicht **IMAP** (993, SSL). SMTP wird nur zum Senden gebraucht; EmailSorter sortiert nur, also: IMAP-Anbindung ist der relevante Teil.
---
## Aktueller Stand in EmailSorter
- **Unterstützt:** **Gmail** (OAuth), **Outlook** (OAuth), **IMAP** (E-Mail + Passwort/App-Passwort), **Demo** (Fake-Daten).
- **IMAP:** Generischer IMAP-Provider ist implementiert; Standard ist Porkbun (`imap.porkbun.com`, 993, SSL), andere IMAP-Server über „Advanced“ (Host/Port/SSL) konfigurierbar.
Ablauf:
- **Gmail:** `GmailService(accessToken, refreshToken)` → Gmail API (messages.list, get, labels).
- **Outlook:** `OutlookService(accessToken)` → Microsoft Graph (Mail API).
- **IMAP:** `ImapService(host, port, secure, user, password)` → IMAP (INBOX lesen, Ordner anlegen, Mails verschieben).
- **Demo:** feste Test-E-Mails, kein echter Zugriff.
Accounts werden in `email_accounts` mit `provider`, `email`, `accessToken` (bei IMAP = Passwort), optional `imapHost`, `imapPort`, `imapSecure` gespeichert.
---
## Was „Nextcloud integrieren“ bedeuten kann
1. **Nextcloud nur als Mail-Client**
- Nextcloud Mail nutzt im Hintergrund IMAP/SMTP (z.B. Porkbun).
- EmailSorter spricht **direkt mit dem gleichen IMAP-Server** (Porkbun), nicht mit Nextcloud.
- Nutzer verbindet in EmailSorter sein **Porkbun-Postfach** (IMAP: imap.porkbun.com, 993, E-Mail + App-Passwort).
- Dann: E-Mails, die in Nextcloud sichtbar sind, sind auch für EmailSorter über IMAP erreichbar und umgekehrt (Sortierung über EmailSorter wirkt in Nextcloud, weil dasselbe Postfach).
2. **Nextcloud als Identity/SSO**
- Würde bedeuten: Login bei EmailSorter über Nextcloud (OIDC/SAML). Das ist ein separates Thema (Auth), nicht die E-Mail-Sortierung.
3. **Nextcloud Mail API**
- Theoretisch könnte man die Nextcloud Mail-API ansprechen; typischerweise nutzt man aber direkt IMAP, weil es einfacher und überall gleich ist.
**Pragmatisch:** „In Nextcloud integrieren“ heißt hier: **IMAP-Provider in EmailSorter** so einbauen, dass du **Porkbun (IMAP)** verbinden kannst. Alles, was in Nextcloud über dieses Postfach läuft, wird damit automatisch mit EmailSorter synchron sein.
---
## Technisch: Was für IMAP (Porkbun) nötig ist
### 1. Neuer Provider `imap`
- In **Backend** (`server/routes/email.mjs`): `provider` um `'imap'` erweitern (z.B. neben `gmail`, `outlook`, `demo`).
- Beim **Verbinden** eines Accounts: für IMAP keine OAuth-Tokens, sondern z.B.:
- `imapHost` (z.B. `imap.porkbun.com`)
- `imapPort` (993)
- `imapSecure` (true für SSL)
- `email` (Login = E-Mail-Adresse)
- Passwort/App-Passwort (sicher speichern, z.B. in einem bestehenden Token-Feld oder neuem verschlüsselten Feld)
### 2. Datenbank (Appwrite) `email_accounts`
- Optional neue Attribute, z.B.:
- `imapHost` (string)
- `imapPort` (integer)
- `imapSecure` (boolean)
- Oder: für **nur Porkbun** Host/Port fest im Code (imap.porkbun.com, 993) und nur E-Mail + Passwort in DB speichern (z.B. in `accessToken` als Passwort, oder eigenes Feld).
### 3. Neuer Service `server/services/imap.mjs`
- **IMAP-Client** in Node (z.B. `imapflow` gut für Node, SSL, modern).
- Interface analog zu Gmail/Outlook:
- **listEmails(maxResults, pageToken)** → Liste von Nachrichten aus INBOX (UIDs/Seq + ggf. Envelope).
- **getEmail(messageId)** / **batchGetEmails(ids)** → From, Subject, Snippet (Body-Preview).
- **applySorting(messageId, category)** → bei IMAP: **Ordner** statt Labels (z.B. „Archive“, „Promotions“). D.h.:
- Ordner anlegen, falls nicht vorhanden (CREATE wenn nötig).
- Nachricht in den passenden Ordner **verschieben** (MOVE oder COPY + DELETE aus INBOX).
- Gmail nutzt Labels; IMAP nutzt **Folders**. Die Logik „Kategorie X“ muss also auf „Folder X“ gemappt werden (z.B. `Archive`, `Promotions`, `Newsletter`).
### 4. Sortier-Route `POST /api/email/sort`
- Wenn `account.provider === 'imap'`:
- `ImapService` mit gespeicherten IMAP-Daten instanziieren.
- Wie bei Gmail/Outlook: E-Mails holen → KI kategorisieren → Aktionen anwenden. Bei IMAP: Aktion = „in Ordner X verschieben“ statt „Label setzen“.
### 5. Frontend (Client)
- Neue Option „E-Mail mit IMAP verbinden“ (z.B. „Anderes Postfach (IMAP)“).
- Formular: E-Mail, App-Passwort; optional Host/Port (oder vorkonfiguriert für Porkbun).
- Kein OAuth-Flow; nach Submit werden Zugangsdaten an das Backend geschickt, Backend speichert sie und testet die Verbindung (z.B. einmaliger LOGIN + SELECT INBOX + DISCONNECT).
### 6. Sicherheit
- Passwort/App-Passwort **niemals** im Frontend speichern; nur beim Verbinden einmal an Backend senden.
- Im Backend: verschlüsselt oder in sicherem Secret-Storage ablegen (z.B. nur in DB, Zugriff nur server-seitig).
---
## Konfiguration in EmailSorter
1. **Einstellungen → Accounts** (oder Setup-Seite: Link „Add your account in Settings → Accounts“).
2. Auf **„IMAP / Other“** klicken es öffnet sich ein Formular.
3. **E-Mail** und **Passwort** (bzw. App-Passwort bei 2FA) eintragen.
4. Optional **„Advanced (host, port, SSL)“** aufklappen:
- **IMAP host:** Standard `imap.porkbun.com` (für andere Anbieter z.B. `imap.gmail.com` oder Nextcloud-IMAP-Host).
- **Port:** Standard **993** (SSL).
- **Use SSL:** aktiviert lassen für 993.
5. **„Connect IMAP“** klicken. Das Backend testet die Verbindung; bei Erfolg erscheint das Konto in der Account-Liste. Danach kann **„Sortieren“** wie bei Gmail/Outlook genutzt werden (E-Mails werden in IMAP-Ordner verschoben).
---
## So richtest du es in Nextcloud ein
EmailSorter wird **nicht in Nextcloud installiert**. Beide nutzen **dasselbe Postfach per IMAP**: Nextcloud Mail als Client zum Lesen/Schreiben, EmailSorter zum automatischen Sortieren. Ordner und verschobene Mails sind in beiden sichtbar.
### 1. In Nextcloud Mail: Postfach hinzufügen (falls noch nicht vorhanden)
1. In Nextcloud einloggen → **Mail**-App öffnen.
2. **Konto hinzufügen** (oder **Einstellungen** des Mail-Kontos).
3. **E-Mail-Adresse** und **Passwort** (bzw. **App-Passwort** bei 2FA) eintragen.
4. **IMAP-Server** manuell einstellen (nicht „Auto“), damit dieselben Werte wie in EmailSorter genutzt werden:
- **IMAP:**
- Server: `imap.porkbun.com` (bzw. dein IMAP-Host)
- Port: **993**
- Verschlüsselung: **SSL/TLS**
- **SMTP** (zum Senden):
- Server: `smtp.porkbun.com`
- Port: **587** (STARTTLS) oder **465** (SSL)
- Nutzer/Passwort wie IMAP
5. Speichern. Das Postfach erscheint in Nextcloud Mail; du liest und schreibst wie gewohnt.
### 2. In EmailSorter: dasselbe Postfach verbinden
1. Bei **EmailSorter** einloggen (z.B. emailsorter.webklar.com).
2. **Einstellungen → Accounts****„IMAP / Other“** klicken.
3. **Gleiche E-Mail-Adresse** und **gleiches Passwort** (bzw. App-Passwort) wie in Nextcloud eintragen.
4. Bei Porkbun reicht der Standard (**Advanced** geschlossen). Anderer Anbieter: **Advanced** öffnen und **IMAP-Host** (z.B. `imap.porkbun.com`), **Port 993**, **Use SSL** an setzen.
5. **„Connect IMAP“** klicken. Wenn die Verbindung klappt, erscheint das Konto unter „Connected Email Accounts“.
### 3. Nutzung
- **Nextcloud Mail:** E-Mails lesen, schreiben, Ordner manuell nutzen wie bisher.
- **EmailSorter:** Im Dashboard **„Sortieren“** ausführen. EmailSorter liest die INBOX, kategorisiert per KI und **verschiebt** Mails in Ordner (z.B. Archive, Promotions, Newsletter).
- **In Nextcloud:** Diese Ordner und die verschobenen Mails erscheinen automatisch, weil dasselbe IMAP-Postfach genutzt wird. Gegebenenfalls Mail-App aktualisieren oder kurz warten, bis die Ordnerliste neu geladen ist.
Es ist **keine Installation oder App in Nextcloud** nötig nur dasselbe Konto in Nextcloud Mail (IMAP) und in EmailSorter (IMAP) einrichten.
---
## Porkbun-spezifisch (kurz)
- **IMAP:** `imap.porkbun.com`, Port **993**, SSL.
- **Login:** volle E-Mail-Adresse + Passwort oder **App-Passwort** (wenn 2FA aktiv).
- In EmailSorter: Provider **IMAP** mit Standard Host/Port für Porkbun; andere IMAP-Server über „Advanced“ einstellbar.
---
## Troubleshooting
- **„Login failed check email and password“**
- E-Mail-Adresse exakt wie beim Anbieter (Groß-/Kleinschreibung bei manchen Servern relevant).
- Bei **2FA (Porkbun/Provider):** normales Passwort reicht oft nicht **App-Passwort** in den Account-Einstellungen des Anbieters erzeugen und dieses im EmailSorter-Formular eintragen.
- **Verbindung baut nicht auf (Timeout / SSL-Fehler)**
- Port **993** und **Use SSL** aktiviert für TLS.
- Firewall/Netzwerk: ausgehende Verbindung zu `imap.porkbun.com:993` erlauben.
- Bei eigenem IMAP-Server: Host/Port in „Advanced“ prüfen (z.B. 143 nur mit STARTTLS, nicht „Use SSL“ im gleichen Sinne bei Zweifel 993 + SSL verwenden).
- **Sortierung läuft, Ordner erscheinen in Nextcloud nicht**
- Nextcloud Mail nutzt dasselbe IMAP-Postfach; Ordner sollten nach kurzer Zeit sichtbar sein. Mail-App ggf. aktualisieren oder Abo des Postfachs prüfen.
---
## Reihenfolge der Umsetzung (Vorschlag)
1. **IMAP-Bibliothek** im Backend (z.B. `imapflow`) einbinden.
2. **`server/services/imap.mjs`** implementieren: connect, listEmails, getEmail, moveToFolder, createFolder.
3. **DB/Bootstrap:** `email_accounts` um IMAP-Felder erweitern (oder Nutzung bestehender Felder definieren).
4. **Route `/connect`:** für `provider: 'imap'` Host/Port/User/Passwort entgegennehmen und Account anlegen.
5. **Route `/sort`:** für `provider === 'imap'` die gleiche Sortier-Pipeline wie bei Gmail/Outlook, aber mit `ImapService` und Ordner-Verschiebung statt Labels.
6. **Frontend:** Verbindungs-UI für IMAP (E-Mail + Passwort, ggf. Host/Port).
Wenn du willst, kann als Nächstes ein konkreter Implementierungsplan (mit Dateinamen und API-Skizzen) oder ein kleines Proof-of-Concept nur für „Connect + Liste INBOX“ für Porkbun-IMAP ausgearbeitet werden.

View File

@@ -0,0 +1,13 @@
Führe diese Befehle in deinem Git Bash oder Terminal aus:
cd c:\Users\User\Documents\GitHub\ANDJJJJJJ
git add .
git commit -m "fix: TypeScript errors & build fixes for Control Panel Redesign
- Fix unused imports (Trash, Filter, Bell, CategoryAdvanced)
- Fix undefined checks for cleanup settings
- Fix cleanupPreview undefined checks
- Fix useTheme unused parameter
- Fix companyLabels type safety
- Build erfolgreich durchgeführt"
git push

71
scripts/COMMIT_MESSAGE.md Normal file
View File

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

Some files were not shown because too many files have changed in this diff Show More