Compare commits

...

10 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
6ba5563d54 Update GIT_AUTHENTICATION_FIX.md with correct token method for GitHub Desktop 2026-01-25 14:44:13 +01:00
abf761db07 Email Sorter Beta
Ich habe soweit automatisiert the Emails sortieren aber ich muss noch schauen was es fur bugs es gibt wenn die app online  ist deswegen wurde ich mit diesen Commit die website veroffentlichen obwohjl es sein konnte  das es noch nicht fertig ist und verkaufs bereit
2026-01-22 19:32:12 +01:00
660 changed files with 66579 additions and 51293 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)

399
README.md
View File

@@ -1,229 +1,264 @@
# Email Sortierer Setup
# EmailSorter
Ein Multi-Step-Formular zur Konfiguration von Email-Präferenzen mit Appwrite-Datenspeicherung und Stripe-Bezahlung.
KI-gestützte E-Mail-Sortierung für mehr Produktivität und weniger Stress.
## Überblick
EmailSorter ist eine SaaS-Anwendung, die automatisch E-Mails kategorisiert und sortiert. Die Anwendung nutzt:
- **React + Vite** Frontend mit Tailwind CSS
- **Node.js + Express** Backend
- **Appwrite** für Datenbank und Authentifizierung
- **Stripe** für Zahlungen und Subscriptions
- **Mistral AI** für KI-basierte E-Mail-Kategorisierung
- **Gmail/Outlook API** für E-Mail-Integration
- **n8n** (optional) für Automatisierungsworkflows
## Projektstruktur
```
/
├── client/ # React Frontend
│ ├── src/
│ │ ├── components/ # UI Komponenten
│ │ ├── pages/ # Seiten
│ │ ├── context/ # React Context
│ │ └── lib/ # Utilities
│ └── package.json
├── server/ # Node.js Backend
│ ├── 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/
└── README.md # Diese Datei
```
## Quick Start
### 1. Repository klonen
```bash
# 1. Dependencies installieren
cd server
npm install
# 2. Setup überprüfen
npm run verify
# 3. Umgebungsvariablen konfigurieren
cp ../.env.example .env
# Bearbeiten Sie .env und fügen Sie Ihre Credentials ein
# 4. Datenbank initialisieren
npm run bootstrap
# Kopieren Sie die Database-ID und fügen Sie sie in .env ein
# 5. Tests ausführen
npm test
# 6. Server starten
npm start
# 7. Browser öffnen
# http://localhost:3000
git clone <repo-url>
cd emailsorter
```
## Voraussetzungen
- Node.js (v18 oder höher)
- Appwrite Account (https://cloud.appwrite.io)
- Stripe Account (https://stripe.com)
## Installation
1. **Repository klonen und Dependencies installieren:**
### 2. Dependencies installieren
```bash
cd server
# Frontend
cd client
npm install
# Backend
cd ../server
npm install
```
2. **Umgebungsvariablen konfigurieren:**
Kopieren Sie `.env.example` zu `.env` und füllen Sie alle Werte aus:
### 3. Umgebungsvariablen konfigurieren
```bash
cp .env.example .env
# Frontend
cd client
cp env.example .env
# Bearbeite .env mit deinen Appwrite Credentials
# Backend
cd ../server
cp env.example .env
# Bearbeite .env mit allen erforderlichen Credentials
```
Erforderliche Werte:
- `APPWRITE_ENDPOINT`: Ihre Appwrite API Endpoint (z.B. https://cloud.appwrite.io/v1)
- `APPWRITE_PROJECT_ID`: Ihre Appwrite Projekt-ID
- `APPWRITE_API_KEY`: Ihr Appwrite API Key (mit allen Berechtigungen)
- `APPWRITE_DATABASE_ID`: Wird nach Bootstrap-Script automatisch gesetzt
- `STRIPE_SECRET_KEY`: Ihr Stripe Secret Key (sk_test_...)
- `STRIPE_WEBHOOK_SECRET`: Ihr Stripe Webhook Secret (whsec_...)
3. **Appwrite Datenbank initialisieren:**
### 4. Appwrite Datenbank einrichten
```bash
npm run bootstrap
cd server
npm run bootstrap:v2
```
Dieses Script erstellt:
- Eine neue Datenbank "EmailSorter"
- 5 Collections: products, questions, submissions, answers, orders
- Ein Produkt "Email Sorter Setup"
- 13 Fragen für den Fragebogen
**Wichtig:** Nach dem Bootstrap-Script wird die Database-ID in der Konsole ausgegeben. Kopieren Sie diese ID und fügen Sie sie in Ihre `.env` Datei als `APPWRITE_DATABASE_ID` ein.
4. **Stripe Webhook konfigurieren:**
Für lokale Entwicklung mit Stripe CLI:
### 5. Development Server starten
```bash
stripe listen --forward-to localhost:3000/stripe/webhook
# Terminal 1: Backend
cd server
npm run dev
# Terminal 2: Frontend
cd client
npm run dev
```
Kopieren Sie das angezeigte Webhook-Secret und fügen Sie es als `STRIPE_WEBHOOK_SECRET` in Ihre `.env` Datei ein.
Die App ist nun erreichbar unter:
- Frontend: http://localhost:5173
- Backend: http://localhost:3000
Für Produktion: Erstellen Sie einen Webhook in Ihrem Stripe Dashboard mit der URL `https://ihre-domain.com/stripe/webhook` und dem Event `checkout.session.completed`.
## Konfiguration
## Server starten
### Appwrite Setup
1. Erstelle ein Projekt auf [cloud.appwrite.io](https://cloud.appwrite.io)
2. Erstelle einen API Key mit allen Berechtigungen
3. Führe `npm run bootstrap:v2` aus, um die Datenbank zu erstellen
### Stripe Setup
1. Erstelle einen Account auf [stripe.com](https://stripe.com)
2. Erstelle Produkte und Preise für Basic, Pro, Business Pläne
3. Konfiguriere den Webhook für `/api/subscription/webhook`
### Google OAuth (Gmail)
1. Erstelle ein Projekt in der [Google Cloud Console](https://console.cloud.google.com)
2. Aktiviere die Gmail API
3. Erstelle OAuth 2.0 Credentials
4. Füge `http://localhost:3000/api/oauth/gmail/callback` als Redirect URI hinzu
### Microsoft OAuth (Outlook)
1. Registriere eine App in [Azure AD](https://portal.azure.com)
2. Füge Microsoft Graph Berechtigungen hinzu (Mail.Read, Mail.ReadWrite)
3. Füge `http://localhost:3000/api/oauth/outlook/callback` als Redirect URI hinzu
### Mistral AI API
1. Erstelle einen API Key auf [console.mistral.ai](https://console.mistral.ai)
2. Füge den Key als `MISTRAL_API_KEY` hinzu
## Features
### Landing Page
- Hero Section mit CTA
- Feature-Übersicht
- Pricing-Tabelle mit 3 Plänen
- Testimonials
- FAQ Sektion
### Authentifizierung
- E-Mail/Passwort Registration
- Login mit Session-Management
- Passwort-Reset (konfigurierbar)
### Dashboard
- E-Mail-Statistiken (sortiert heute/Woche/Monat)
- Kategorien-Verteilung
- Verbundene E-Mail-Konten
- Schnellzugriff-Aktionen
### E-Mail-Sortierung
- Automatische Kategorisierung mit KI
- Unterstützte Kategorien:
- VIP / Wichtig
- Kunden / Projekte
- Rechnungen / Belege
- Newsletter
- Werbung / Promotions
- Social Media
- Security / 2FA
- Versand / Bestellungen
### Subscription
- 14 Tage kostenlose Testphase
- 3 Pläne: Basic (9€), Pro (19€), Business (49€)
- Stripe Customer Portal
- Automatische Verlängerung
## API Dokumentation
### Authentifizierung
Die API nutzt Appwrite Sessions für Authentifizierung.
### Endpoints
#### E-Mail
- `GET /api/email/accounts` - Verbundene E-Mail-Konten abrufen
- `POST /api/email/connect` - Neues E-Mail-Konto verbinden
- `DELETE /api/email/accounts/:id` - E-Mail-Konto trennen
- `GET /api/email/stats` - Sortierstatistiken abrufen
- `POST /api/email/sort` - Manuelle Sortierung auslösen
#### OAuth
- `GET /api/oauth/gmail` - Gmail OAuth starten
- `GET /api/oauth/gmail/callback` - Gmail OAuth Callback
- `GET /api/oauth/outlook` - Outlook OAuth starten
- `GET /api/oauth/outlook/callback` - Outlook OAuth Callback
#### Subscription
- `POST /api/subscription/checkout` - Checkout Session erstellen
- `GET /api/subscription/status` - Subscription Status abrufen
- `POST /api/subscription/portal` - Customer Portal Session
## n8n Integration (Optional)
Für visuelle Automatisierung kann n8n verwendet werden:
1. Importiere den Workflow aus `n8n/workflows/email-sorter-workflow.json`
2. Konfiguriere Gmail OAuth und OpenAI Credentials
3. Aktiviere den Webhook-Trigger
Siehe `n8n/README.md` für Details.
## Deployment
Siehe `docs/deployment/` für detaillierte Deployment-Anleitungen.
### Frontend (Vercel/Netlify)
```bash
npm start
cd client
npm run build
# Deploye dist/ Ordner
```
Der Server läuft auf http://localhost:3000
### Backend (Railway/Render/Heroku)
## Verwendung
1. Setze alle Umgebungsvariablen
2. Deploy mit `npm start` als Start-Befehl
1. Öffnen Sie http://localhost:3000 in Ihrem Browser
2. Füllen Sie den mehrstufigen Fragebogen aus
3. Überprüfen Sie die Zusammenfassung
4. Klicken Sie auf "Jetzt kaufen" um zur Stripe-Bezahlung weitergeleitet zu werden
5. Verwenden Sie Stripe Test-Kreditkarte: `4242 4242 4242 4242`
### Stripe Webhook
## API Endpunkte
### GET /api/questions
Lädt alle aktiven Fragen für ein Produkt.
**Query Parameter:**
- `productSlug`: Produkt-Slug (z.B. "email-sorter")
**Response:**
```json
[
{
"$id": "...",
"key": "email",
"label": "Ihre E-Mail-Adresse",
"type": "email",
"required": true,
"step": 1,
"order": 1
}
]
Aktualisiere die Webhook-URL im Stripe Dashboard auf deine Produktions-URL:
```
https://your-domain.com/api/subscription/webhook
```
### POST /api/submissions
Erstellt eine neue Submission mit Kundenantworten.
## Dokumentation
**Request Body:**
```json
{
"productSlug": "email-sorter",
"answers": {
"email": "kunde@example.com",
"name": "Max Mustermann"
}
}
```
Alle Dokumentation befindet sich im `docs/` Ordner:
**Response:**
```json
{
"submissionId": "..."
}
```
- **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
### POST /api/checkout
Erstellt eine Stripe Checkout Session.
**Request Body:**
```json
{
"submissionId": "..."
}
```
**Response:**
```json
{
"url": "https://checkout.stripe.com/..."
}
```
### POST /stripe/webhook
Empfängt Stripe Webhook Events (nur für Stripe).
## Datenmodell
### Products Collection
- `slug`: Eindeutiger Produkt-Identifier
- `title`: Produktname
- `priceCents`: Preis in Cent
- `currency`: Währung (z.B. "eur")
- `isActive`: Produkt aktiv/inaktiv
### Questions Collection
- `productId`: Referenz zum Produkt
- `key`: Eindeutiger Schlüssel für die Antwort
- `label`: Anzeigetext
- `type`: Feldtyp (text, email, select, multiselect, textarea)
- `required`: Pflichtfeld ja/nein
- `step`: Schritt-Nummer im Formular
- `order`: Reihenfolge innerhalb des Schritts
- `optionsJson`: JSON-Array mit Auswahloptionen (für select/multiselect)
- `isActive`: Frage aktiv/inaktiv
### Submissions Collection
- `productId`: Referenz zum Produkt
- `status`: Status (draft, paid)
- `customerEmail`: Kunden-Email
- `customerName`: Kundenname
- `finalSummaryJson`: JSON mit allen Antworten
- `priceCents`: Preis in Cent
- `currency`: Währung
### Answers Collection
- `submissionId`: Referenz zur Submission
- `answersJson`: JSON mit allen Antworten
### Orders Collection
- `submissionId`: Referenz zur Submission
- `orderDataJson`: JSON mit Stripe Session Daten
Siehe `docs/README.md` für eine vollständige Übersicht.
## Troubleshooting
### Server startet nicht
- Überprüfen Sie, dass alle Umgebungsvariablen in `.env` gesetzt sind
- Stellen Sie sicher, dass Port 3000 nicht bereits verwendet wird
### Frontend startet nicht
- Prüfe, ob alle npm packages installiert sind
- Prüfe `.env` Datei im client Ordner
### Fragen werden nicht geladen
- Überprüfen Sie die Appwrite-Verbindung und API-Key
- Stellen Sie sicher, dass das Bootstrap-Script erfolgreich durchgelaufen ist
- Überprüfen Sie die Browser-Konsole auf Fehler
### Backend-Fehler
- Prüfe alle Umgebungsvariablen in `.env`
- Prüfe Appwrite Verbindung und API Key
### Stripe Checkout funktioniert nicht
- Überprüfen Sie, dass `STRIPE_SECRET_KEY` korrekt gesetzt ist
- Für lokale Tests: Stellen Sie sicher, dass Stripe CLI läuft
- Überprüfen Sie die Server-Logs auf Fehler
### OAuth funktioniert nicht
- Prüfe Redirect URIs in Google/Microsoft Console
- Prüfe Client ID und Secret
### Webhook wird nicht empfangen
- Für lokale Tests: Stellen Sie sicher, dass `stripe listen` läuft
- Überprüfen Sie, dass `STRIPE_WEBHOOK_SECRET` korrekt gesetzt ist
- Überprüfen Sie die Stripe Dashboard Webhook-Logs
### KI-Kategorisierung fehlerhaft
- Prüfe Mistral API Key
- Prüfe Rate Limits auf console.mistral.ai
## Lizenz

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

24
client/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
client/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

10
client/env.example Normal file
View File

@@ -0,0 +1,10 @@
# EmailSorter Frontend Configuration
# Copy this file to .env and fill in your values
# Appwrite Configuration
VITE_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
VITE_APPWRITE_PROJECT_ID=your-project-id
# OAuth URLs (generated by your backend)
VITE_GMAIL_OAUTH_URL=http://localhost:3000/api/oauth/gmail
VITE_OUTLOOK_OAUTH_URL=http://localhost:3000/api/oauth/outlook

23
client/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

84
client/index.html Normal file
View File

@@ -0,0 +1,84 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- 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="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>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4954
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

44
client/package.json Normal file
View File

@@ -0,0 +1,44 @@
{
"name": "emailsorter-client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"appwrite": "^21.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.562.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.12.0",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"tailwindcss": "^4.1.18",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

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": "/"
}

1
client/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

146
client/src/App.tsx Normal file
View File

@@ -0,0 +1,146 @@
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'
import { Dashboard } from '@/pages/Dashboard'
import { Setup } from '@/pages/Setup'
import { Settings } from '@/pages/Settings'
import { ForgotPassword } from '@/pages/ForgotPassword'
import { ResetPassword } from '@/pages/ResetPassword'
import { VerifyEmail } from '@/pages/VerifyEmail'
import { Privacy } from '@/pages/Privacy'
import { Imprint } from '@/pages/Imprint'
// Initialize analytics on app startup
initAnalytics()
// Loading spinner component
function LoadingSpinner() {
return (
<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 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>
)
}
// Protected route wrapper - requires authentication
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth()
if (loading) {
return <LoadingSpinner />
}
if (!user) {
return <Navigate to="/login" replace />
}
return <>{children}</>
}
// Public route that redirects to dashboard if logged in
function PublicRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth()
if (loading) {
return <LoadingSpinner />
}
if (user) {
return <Navigate to="/dashboard" replace />
}
return <>{children}</>
}
function AppRoutes() {
// Track page views on route changes
usePageTracking()
return (
<Routes>
{/* Public pages */}
<Route path="/" element={<Home />} />
{/* Auth pages - redirect to dashboard if logged in */}
<Route
path="/login"
element={
<PublicRoute>
<Login />
</PublicRoute>
}
/>
<Route
path="/register"
element={
<PublicRoute>
<Register />
</PublicRoute>
}
/>
{/* Password recovery - always accessible */}
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
{/* Email verification - always accessible */}
<Route path="/verify" element={<VerifyEmail />} />
{/* Legal pages - always accessible */}
<Route path="/privacy" element={<Privacy />} />
<Route path="/imprint" element={<Imprint />} />
{/* Protected pages - require authentication */}
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/setup"
element={
<ProtectedRoute>
<Setup />
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute>
<Settings />
</ProtectedRoute>
}
/>
{/* Catch all - redirect to home */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}
function App() {
// Initialize theme detection
useTheme()
return (
<BrowserRouter>
<AuthProvider>
<AppRoutes />
</AuthProvider>
</BrowserRouter>
)
}
export default App

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

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

@@ -0,0 +1,66 @@
const faqs = [
{
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: "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 this mess up my inbox?",
answer: "We only add labels or move to folders. We don't delete. Disconnect and nothing stays changed."
},
{
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: "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() {
return (
<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-slate-600 dark:text-slate-400">
Straight answers. No fluff.
</p>
</div>
<div className="space-y-12">
{faqs.map((faq, index) => (
<div
key={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>
<p className="mt-10 text-center text-sm text-slate-600 dark:text-slate-400">
Still unsure?{' '}
<a
href="mailto:support@emailsorter.webklar.com"
className="text-slate-700 dark:text-slate-300 hover:underline"
>
Email us we reply fast
</a>
.
</p>
</div>
</section>
)
}

View File

@@ -0,0 +1,140 @@
import {
Brain,
Zap,
Shield,
Clock,
Settings,
Inbox,
Filter
} from 'lucide-react'
const features = [
{
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: "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: "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 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 dark:text-slate-100 mb-4">
What it does
</h2>
<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>
{/* Features grid */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{features.map((feature, index) => (
<FeatureCard key={index} {...feature} index={index} />
))}
</div>
{/* Bottom illustration */}
<div className="mt-20 relative">
<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 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 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 */}
<div className="hidden md:flex justify-center">
<div className="w-24 h-24 rounded-full bg-gradient-to-r from-primary-500 to-accent-500 flex items-center justify-center shadow-lg">
<Filter className="w-10 h-10 text-white" />
</div>
</div>
{/* After */}
<div className="text-center">
<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 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>
</div>
</div>
</section>
)
}
interface FeatureCardProps {
icon: React.ElementType
title: string
description: string
color: string
index: number
highlight?: boolean
}
function FeatureCard({ icon: Icon, title, description, color, index, highlight }: FeatureCardProps) {
return (
<div
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 shadow-lg`}>
<Icon className="w-7 h-7 text-white" />
</div>
<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

@@ -0,0 +1,152 @@
import { Link } from 'react-router-dom'
import { Mail, Twitter, Linkedin, Github } from 'lucide-react'
export function Footer() {
return (
<footer className="bg-slate-900 text-slate-300">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<div className="grid md:grid-cols-4 gap-12">
{/* Brand */}
<div className="md:col-span-1">
<Link to="/" className="flex items-center gap-2 mb-4">
<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-white">
E-Mail-<span className="text-primary-400">Sorter</span>
</span>
</Link>
<p className="text-sm text-slate-400 mb-6">
Email sorting for freelancers and small teams. Gmail & Outlook.
</p>
{/* Social links */}
<div className="flex gap-4">
<a
href="#"
className="w-10 h-10 rounded-lg bg-slate-800 flex items-center justify-center hover:bg-slate-700 transition-colors"
>
<Twitter className="w-5 h-5" />
</a>
<a
href="#"
className="w-10 h-10 rounded-lg bg-slate-800 flex items-center justify-center hover:bg-slate-700 transition-colors"
>
<Linkedin className="w-5 h-5" />
</a>
<a
href="#"
className="w-10 h-10 rounded-lg bg-slate-800 flex items-center justify-center hover:bg-slate-700 transition-colors"
>
<Github className="w-5 h-5" />
</a>
</div>
</div>
{/* Product */}
<div>
<h4 className="font-semibold text-white mb-4">Product</h4>
<ul className="space-y-3">
<li>
<button
onClick={() => document.getElementById('features')?.scrollIntoView({ behavior: 'smooth' })}
className="hover:text-white transition-colors"
>
Features
</button>
</li>
<li>
<button
onClick={() => document.getElementById('pricing')?.scrollIntoView({ behavior: 'smooth' })}
className="hover:text-white transition-colors"
>
Pricing
</button>
</li>
<li>
<button
onClick={() => document.getElementById('faq')?.scrollIntoView({ behavior: 'smooth' })}
className="hover:text-white transition-colors"
>
FAQ
</button>
</li>
</ul>
</div>
{/* Contact */}
<div>
<h4 className="font-semibold text-white mb-4">Contact</h4>
<ul className="space-y-3">
<li>
<a
href="mailto:support@emailsorter.webklar.com"
className="hover:text-white transition-colors"
>
support@emailsorter.webklar.com
</a>
</li>
<li>
<a
href="https://webklar.com"
target="_blank"
rel="noopener noreferrer"
className="hover:text-white transition-colors"
>
webklar.com
</a>
</li>
</ul>
</div>
{/* Legal */}
<div>
<h4 className="font-semibold text-white mb-4">Legal</h4>
<ul className="space-y-3">
<li>
<Link to="/privacy" className="hover:text-white transition-colors">
Privacy Policy
</Link>
</li>
<li>
<Link to="/privacy-security" className="hover:text-white transition-colors">
Privacy & Security
</Link>
</li>
<li>
<Link to="/imprint" className="hover:text-white transition-colors">
Impressum
</Link>
</li>
</ul>
</div>
</div>
{/* Bottom bar */}
<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
</p>
</div>
{/* webklar.com Verweis */}
<div className="flex flex-col md:flex-row items-center justify-center gap-2 pt-4 border-t border-slate-800">
<p className="text-sm text-slate-400">
Need a website?
</p>
<a
href="https://webklar.com"
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-primary-400 hover:text-primary-300 transition-colors inline-flex items-center gap-1"
>
Visit webklar.com
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
</div>
</div>
</footer>
)
}

View File

@@ -0,0 +1,155 @@
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, Sparkles, Check } from 'lucide-react'
export function Hero() {
const navigate = useNavigate()
const handleCTAClick = () => {
// Capture UTM parameters before navigation
captureUTMParams()
navigate('/register')
}
return (
<section className="relative min-h-screen flex items-center overflow-hidden">
{/* Background */}
<div className="absolute inset-0 gradient-hero" />
<div className="absolute inset-0 gradient-mesh opacity-30" />
{/* Grid pattern overlay */}
<div
className="absolute inset-0 opacity-10"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.4'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
}}
/>
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-32">
<div className="grid lg:grid-cols-2 gap-12 items-center">
{/* Left side - Text content */}
<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" />
For freelancers & small teams
</Badge>
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-extrabold text-white leading-tight mb-6">
Leads, clients, spam
<br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-accent-400 to-primary-300">
sorted automatically.
</span>
</h1>
<p className="text-lg sm:text-xl text-slate-300 mb-8 max-w-xl mx-auto lg:mx-0">
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-4">
<Button
size="xl"
onClick={handleCTAClick}
className="group bg-accent-500 hover:bg-accent-600"
>
Try it free
<ArrowRight className="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" />
</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
</div>
<div className="flex items-center gap-2">
<Check className="w-4 h-4 text-accent-400" />
Gmail & Outlook
</div>
<div className="flex items-center gap-2">
<Check className="w-4 h-4 text-accent-400" />
GDPR compliant
</div>
</div>
</div>
{/* Right side - Inbox visual (product screenshot feel) */}
<div className="relative hidden lg:block">
<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>
<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>
</div>
{/* Scroll indicator */}
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce">
<div className="w-6 h-10 rounded-full border-2 border-white/30 flex justify-center pt-2">
<div className="w-1.5 h-1.5 rounded-full bg-white/60" />
</div>
</div>
</section>
)
}
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',
}
interface InboxRowProps {
sender: string
subject: string
label: InboxLabel
isFocal?: boolean
}
function InboxRow({ sender, subject, label, isFocal = false }: InboxRowProps) {
return (
<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">
<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>
<span className={cn("text-xs flex-shrink-0", labelClass[label])}>{label}</span>
</div>
)
}

View File

@@ -0,0 +1,111 @@
import {
UserPlus,
Link2,
Sparkles,
PartyPopper,
ArrowDown
} from 'lucide-react'
const steps = [
{
icon: UserPlus,
step: "01",
title: "Create account",
description: "Sign up for free in less than 60 seconds. No credit card required.",
},
{
icon: Link2,
step: "02",
title: "Connect email",
description: "Sign in with Google or Microsoft. We never see your password.",
},
{
icon: Sparkles,
step: "03",
title: "We categorize",
description: "We read sender and subject, put each email in a category. No rules to write.",
},
{
icon: PartyPopper,
step: "04",
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 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 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 dark:text-slate-400 max-w-2xl mx-auto">
Get started in minutes no technical knowledge required.
</p>
</div>
{/* Steps */}
<div className="relative">
{/* Connection line */}
<div className="hidden lg:block absolute top-1/2 left-0 right-0 h-0.5 bg-gradient-to-r from-primary-200 via-primary-400 to-primary-200 -translate-y-1/2" />
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8">
{steps.map((item, index) => (
<StepCard key={index} {...item} />
))}
</div>
</div>
{/* CTA */}
<div className="mt-16 text-center">
<div className="inline-flex flex-col items-center">
<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 dark:text-primary-400 font-semibold hover:text-primary-700 dark:hover:text-primary-300 transition-colors"
>
Try it free now
</a>
</div>
</div>
</div>
</section>
)
}
interface StepCardProps {
icon: React.ElementType
step: string
title: string
description: string
}
function StepCard({ icon: Icon, step, title, description }: StepCardProps) {
return (
<div className="relative">
{/* Card */}
<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 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 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 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

@@ -0,0 +1,159 @@
import { useState, useCallback } from 'react'
import { Link, useNavigate, useLocation } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { useAuth } from '@/context/AuthContext'
import { Menu, X, Mail, Sparkles } from 'lucide-react'
export function Navbar() {
const [isMenuOpen, setIsMenuOpen] = useState(false)
const { user } = useAuth()
const navigate = useNavigate()
const location = useLocation()
// Smooth scroll to section
const scrollToSection = useCallback((sectionId: string) => {
setIsMenuOpen(false)
// If not on home page, navigate first
if (location.pathname !== '/') {
navigate('/')
setTimeout(() => {
const element = document.getElementById(sectionId)
element?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}, 100)
} else {
const element = document.getElementById(sectionId)
element?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}, [location.pathname, navigate])
return (
<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 */}
<Link to="/" className="flex items-center gap-2">
<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 dark:text-slate-100">
E-Mail-<span className="text-primary-600 dark:text-primary-400">Sorter</span>
</span>
</Link>
{/* Desktop Navigation */}
<div className="hidden md:flex items-center gap-8">
<button
onClick={() => scrollToSection('features')}
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 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 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 dark:text-slate-300 hover:text-slate-900 dark:hover:text-slate-100 font-medium transition-colors"
>
FAQ
</button>
</div>
{/* Desktop CTA */}
<div className="hidden md:flex items-center gap-4">
{user ? (
<Button onClick={() => navigate('/dashboard')}>
Dashboard
<Sparkles className="w-4 h-4 ml-2" />
</Button>
) : (
<>
<Button variant="ghost" onClick={() => navigate('/login')}>
Sign in
</Button>
<Button onClick={() => navigate('/register')}>
Try it free
</Button>
</>
)}
</div>
{/* Mobile menu button */}
<button
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 dark:text-slate-300" />
) : (
<Menu className="w-6 h-6 text-slate-600 dark:text-slate-300" />
)}
</button>
</div>
</div>
{/* Mobile menu */}
{isMenuOpen && (
<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 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 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 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 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 dark:border-slate-700 space-y-2">
{user ? (
<Button className="w-full h-11" onClick={() => navigate('/dashboard')}>
Dashboard
</Button>
) : (
<>
<Button
variant="outline"
className="w-full h-11"
onClick={() => navigate('/login')}
>
Sign in
</Button>
<Button className="w-full h-11" onClick={() => navigate('/register')}>
Try it free
</Button>
</>
)}
</div>
</div>
</div>
)}
</nav>
)
}

View File

@@ -0,0 +1,193 @@
import { useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Check, X, Sparkles } from 'lucide-react'
const plans = [
{
name: "Basic",
price: "9",
period: "/ month",
description: "Perfect for getting started",
features: [
{ text: "1 email account", included: true },
{ text: "500 emails / day", included: true },
{ text: "Basic categories", included: true },
{ text: "Email support", included: true },
{ text: "Historical email analysis", included: false },
{ text: "Custom rules", included: false },
{ text: "Priority support", included: false },
],
cta: "Start Basic",
popular: false,
priceId: "price_basic_monthly"
},
{
name: "Pro",
price: "19",
period: "/ month",
description: "For power users",
features: [
{ text: "3 email accounts", included: true },
{ text: "Unlimited emails", included: true },
{ text: "All categories", included: true },
{ text: "Email support", included: true },
{ text: "Historical email analysis", included: true },
{ text: "Custom rules", included: true },
{ text: "Priority support", included: false },
],
cta: "Start Pro",
popular: true,
priceId: "price_pro_monthly"
},
{
name: "Business",
price: "49",
period: "/ month",
description: "For teams & companies",
features: [
{ text: "10 email accounts", included: true },
{ text: "Unlimited emails", included: true },
{ text: "All categories", included: true },
{ text: "Email + chat support", included: true },
{ text: "Historical email analysis", included: true },
{ text: "Custom rules", included: true },
{ text: "Priority support", included: true },
],
cta: "Start Business",
popular: false,
priceId: "price_business_monthly"
},
]
export function Pricing() {
const navigate = useNavigate()
return (
<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">
<Badge className="mb-4">
<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 dark:text-slate-100 mb-4">
Simple, transparent pricing
</h2>
<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>
{/* Pricing cards */}
<div className="grid md:grid-cols-3 gap-8 max-w-5xl mx-auto">
{plans.map((plan, index) => (
<PricingCard
key={index}
{...plan}
onSelect={() => navigate(`/register?plan=${plan.name.toLowerCase()}`)}
/>
))}
</div>
{/* FAQ teaser */}
<div className="mt-16 text-center">
<p className="text-slate-600 dark:text-slate-400">
Still have questions?{' '}
<button
onClick={() => document.getElementById('faq')?.scrollIntoView({ behavior: 'smooth' })}
className="text-primary-600 dark:text-primary-400 font-semibold hover:text-primary-700 dark:hover:text-primary-300"
>
Check our FAQ
</button>
</p>
</div>
</div>
</section>
)
}
interface PricingCardProps {
name: string
price: string
period: string
description: string
features: { text: string; included: boolean }[]
cta: string
popular: boolean
onSelect: () => void
}
function PricingCard({
name,
price,
period,
description,
features,
cta,
popular,
onSelect
}: PricingCardProps) {
return (
<div
className={`relative bg-white dark:bg-slate-800 rounded-2xl p-8 ${
popular
? '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 dark:bg-primary-600 text-white border-0 shadow-md">
Most Popular
</Badge>
</div>
)}
{/* Header */}
<div className="text-center mb-6">
<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 dark:text-slate-100">${price}</span>
<span className="text-slate-500 dark:text-slate-400 ml-1">{period}</span>
</div>
</div>
{/* Features */}
<ul className="space-y-4 mb-8">
{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 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 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 dark:text-slate-300' : 'text-slate-400 dark:text-slate-500'}>
{feature.text}
</span>
</li>
))}
</ul>
{/* CTA */}
<Button
className="w-full"
variant={popular ? 'default' : 'outline'}
size="lg"
onClick={onSelect}
>
{cta}
</Button>
</div>
)
}

View File

@@ -0,0 +1,51 @@
import { Code2, Users, Zap } from 'lucide-react'
const items = [
{
icon: Code2,
title: "Built in public",
description: "We ship updates and share progress openly. No hype, no fake traction.",
},
{
icon: Users,
title: "Early users",
description: "We're in beta. Feedback from freelancers and small teams shapes the product.",
},
{
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-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h2 className="text-2xl sm:text-3xl font-bold text-white mb-2">
Honest context
</h2>
<p className="text-slate-400 text-sm sm:text-base">
We're a small product. Here's how we work.
</p>
</div>
<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>
)
}

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

@@ -0,0 +1,40 @@
/* 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"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary-100 dark:bg-primary-900 text-primary-700 dark:text-primary-200",
secondary:
"border-transparent bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-200",
success:
"border-transparent bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200",
warning:
"border-transparent bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-200",
destructive:
"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: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,60 @@
/* 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 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 dark:bg-primary-500 dark:hover:bg-primary-400",
secondary:
"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 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 dark:hover:bg-slate-800 dark:hover:text-slate-100",
link:
"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 dark:bg-accent-500 dark:hover:bg-accent-400",
},
size: {
default: "h-11 px-6 py-2",
sm: "h-9 rounded-md px-4",
lg: "h-14 rounded-xl px-8 text-base",
xl: "h-16 rounded-xl px-10 text-lg",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,78 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"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}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-bold leading-none tracking-tight text-slate-900 dark:text-slate-100",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-slate-500 dark:text-slate-400", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,32 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
error?: string
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, error, ...props }, ref) => {
return (
<div className="w-full">
<input
type={type}
className={cn(
"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
)}
ref={ref}
{...props}
/>
{error && (
<p className="mt-1 text-sm text-red-500">{error}</p>
)}
</div>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,23 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-slate-700"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,75 @@
/* 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'
interface AuthContextType {
user: Models.User<Models.Preferences> | null
loading: boolean
login: (email: string, password: string) => Promise<void>
register: (email: string, password: string, name?: string) => Promise<void>
logout: () => Promise<void>
refreshUser: () => Promise<void>
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<Models.User<Models.Preferences> | null>(null)
const [loading, setLoading] = useState(true)
const refreshUser = async () => {
try {
const currentUser = await auth.getCurrentUser()
setUser(currentUser)
} catch {
setUser(null)
}
}
useEffect(() => {
const init = async () => {
await refreshUser()
setLoading(false)
}
init()
}, [])
const login = async (email: string, password: string) => {
await auth.login(email, password)
await refreshUser()
}
const register = async (email: string, password: string, name?: string) => {
await auth.register(email, password, name)
await refreshUser()
}
const logout = async () => {
await auth.logout()
setUser(null)
}
return (
<AuthContext.Provider
value={{
user,
loading,
login,
register,
logout,
refreshUser,
}}
>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}

View File

@@ -0,0 +1,53 @@
/**
* React Hook for Analytics
* Provides easy access to analytics functions in components
*/
import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import {
trackPageView,
captureUTMParams,
getAllTrackingParams,
trackSignup,
trackTrialStart,
trackPurchase,
trackEmailConnected,
setUserId,
type TrackingParams,
} from '@/lib/analytics'
/**
* Hook to automatically track page views on route changes
*/
export function usePageTracking() {
const location = useLocation()
useEffect(() => {
// Capture UTM parameters on every navigation
captureUTMParams()
// Track page view
trackPageView(location.pathname)
}, [location])
}
/**
* Hook to get tracking parameters
*/
export function useTrackingParams(): TrackingParams {
return getAllTrackingParams()
}
/**
* Export analytics functions for use in components
*/
export const analytics = {
trackSignup,
trackTrialStart,
trackPurchase,
trackEmailConnected,
setUserId,
}
export default usePageTracking

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 }
}

282
client/src/index.css Normal file
View File

@@ -0,0 +1,282 @@
/* Custom fonts - imported before Tailwind */
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap');
@import "tailwindcss";
/* CSS Variables for theming */
@theme {
--font-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
/* Primary colors - Modern Green theme (webklar.com style) */
--color-primary-50: #f0fdf4;
--color-primary-100: #dcfce7;
--color-primary-200: #bbf7d0;
--color-primary-300: #86efac;
--color-primary-400: #4ade80;
--color-primary-500: #22c55e;
--color-primary-600: #16a34a;
--color-primary-700: #15803d;
--color-primary-800: #166534;
--color-primary-900: #14532d;
/* Accent colors - Modern Green/Emerald */
--color-accent-400: #34d399;
--color-accent-500: #10b981;
--color-accent-600: #059669;
/* Neutral/Slate colors - Keep original values for Tailwind compatibility */
--color-slate-50: #f8fafc;
--color-slate-100: #f1f5f9;
--color-slate-200: #e2e8f0;
--color-slate-300: #cbd5e1;
--color-slate-400: #94a3b8;
--color-slate-500: #64748b;
--color-slate-600: #475569;
--color-slate-700: #334155;
--color-slate-800: #1e293b;
--color-slate-900: #0f172a;
--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;
}
body {
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* 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 */
@media (max-width: 640px) {
button, a, [role="button"] {
min-height: 44px;
min-width: 44px;
}
/* Better tap highlighting */
* {
-webkit-tap-highlight-color: rgba(34, 197, 94, 0.1);
}
}
: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;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
/* Selection styling */
::selection {
background-color: var(--color-primary-200);
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;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-slate-100);
}
::-webkit-scrollbar-thumb {
background: var(--color-slate-300);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
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%),
radial-gradient(at 80% 0%, var(--color-accent-500) 0px, transparent 50%),
radial-gradient(at 0% 50%, var(--color-primary-700) 0px, transparent 50%),
radial-gradient(at 80% 50%, var(--color-accent-400) 0px, transparent 50%),
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); }
50% { transform: translateY(-10px); }
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 20px rgba(34, 197, 94, 0.3); }
50% { box-shadow: 0 0 40px rgba(34, 197, 94, 0.6); }
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-float {
animation: float 6s ease-in-out infinite;
}
.animate-pulse-glow {
animation: pulse-glow 2s ease-in-out infinite;
}
/* Stagger animation delays */
.stagger-1 { animation-delay: 0.1s; }
.stagger-2 { animation-delay: 0.2s; }
.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;
}

446
client/src/lib/analytics.ts Normal file
View File

@@ -0,0 +1,446 @@
/**
* Analytics & Tracking Utility
* Handles UTM parameter tracking and event analytics
*/
export interface TrackingParams {
utm_source?: string
utm_medium?: string
utm_campaign?: string
utm_term?: string
utm_content?: string
gclid?: string // Google Ads Click ID
fbclid?: string // Facebook Click ID
ref?: string // General referrer
}
export interface ConversionEvent {
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, 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
*/
export function parseUTMParams(): TrackingParams {
const params = new URLSearchParams(window.location.search)
const utmParams: TrackingParams = {}
const utmKeys: Array<keyof TrackingParams> = [
'utm_source',
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
'gclid',
'fbclid',
'ref',
]
utmKeys.forEach((key) => {
const value = params.get(key)
if (value) {
utmParams[key] = value
}
})
// If no UTM params but referrer exists, capture it
if (!utmParams.utm_source && document.referrer) {
try {
const referrerUrl = new URL(document.referrer)
if (referrerUrl.hostname !== window.location.hostname) {
utmParams.ref = referrerUrl.hostname
}
} catch {
// Invalid referrer URL, ignore
}
}
return utmParams
}
/**
* Store UTM parameters in localStorage (persists across sessions)
*/
export function storeUTMParams(params: TrackingParams): void {
if (Object.keys(params).length === 0) return
const existing = getStoredUTMParams()
const merged = { ...existing, ...params }
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(merged))
// Set expiration (30 days)
localStorage.setItem(`${STORAGE_KEY}_expiry`, String(Date.now() + 30 * 24 * 60 * 60 * 1000))
} catch (error) {
console.warn('Failed to store UTM parameters:', error)
}
}
/**
* Get stored UTM parameters from localStorage
*/
export function getStoredUTMParams(): TrackingParams {
try {
// Check expiry
const expiry = localStorage.getItem(`${STORAGE_KEY}_expiry`)
if (expiry && Date.now() > parseInt(expiry, 10)) {
localStorage.removeItem(STORAGE_KEY)
localStorage.removeItem(`${STORAGE_KEY}_expiry`)
return {}
}
const stored = localStorage.getItem(STORAGE_KEY)
return stored ? JSON.parse(stored) : {}
} catch (error) {
console.warn('Failed to get stored UTM parameters:', error)
return {}
}
}
/**
* Clear stored UTM parameters
*/
export function clearUTMParams(): void {
try {
localStorage.removeItem(STORAGE_KEY)
localStorage.removeItem(`${STORAGE_KEY}_expiry`)
} catch (error) {
console.warn('Failed to clear UTM parameters:', error)
}
}
/**
* Capture and store UTM parameters from current URL
* Call this on page load or navigation
*/
export function captureUTMParams(): TrackingParams {
const params = parseUTMParams()
if (Object.keys(params).length > 0) {
storeUTMParams(params)
}
return params
}
/**
* Get all tracking parameters (from URL + stored)
*/
export function getAllTrackingParams(): TrackingParams {
const urlParams = parseUTMParams()
const storedParams = getStoredUTMParams()
// URL params take precedence
return { ...storedParams, ...urlParams }
}
/**
* Track conversion event
* Send tracking data to server or analytics service
*/
export async function trackEvent(
event: ConversionEvent,
trackingParams?: TrackingParams
): Promise<void> {
const params = trackingParams || getAllTrackingParams()
const userId = localStorage.getItem(USER_ID_KEY)
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,
}
try {
// Send to your analytics endpoint
await fetch('/api/analytics/track', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
}).catch(() => {
// Silently fail if analytics endpoint doesn't exist yet
// This allows graceful degradation
})
// Also log to console in development
if (import.meta.env.DEV) {
console.log('📊 Analytics Event:', event.type, payload)
}
} catch (error) {
console.warn('Failed to track event:', error)
}
}
/**
* Track page view
*/
export function trackPageView(path?: string): void {
trackEvent({
type: 'page_view',
metadata: {
path: path || window.location.pathname,
title: document.title,
},
})
}
/**
* Track signup event
*/
export function trackSignup(userId: string, email: string): void {
const trackingParams = getAllTrackingParams()
trackEvent({
type: 'signup',
userId,
metadata: {
email: email,
source: trackingParams.utm_source,
medium: trackingParams.utm_medium,
campaign: trackingParams.utm_campaign,
},
})
// Store user ID for future events
try {
localStorage.setItem(USER_ID_KEY, userId)
} catch (error) {
console.warn('Failed to store user ID:', error)
}
}
/**
* Track trial start
*/
export function trackTrialStart(userId: string): void {
trackEvent({
type: 'trial_start',
userId,
metadata: {
timestamp: new Date().toISOString(),
},
})
}
/**
* Track purchase/subscription
*/
export function trackPurchase(userId: string, plan: string, amount: number): void {
const trackingParams = getAllTrackingParams()
trackEvent({
type: 'purchase',
userId,
metadata: {
plan,
amount,
currency: 'EUR',
source: trackingParams.utm_source,
medium: trackingParams.utm_medium,
campaign: trackingParams.utm_campaign,
},
})
}
/**
* Track email account connection
*/
export function trackEmailConnected(userId: string, provider: string): void {
trackEvent({
type: 'email_connected',
userId,
metadata: {
provider,
timestamp: new Date().toISOString(),
},
})
}
/**
* Set user ID (for authenticated users)
*/
export function setUserId(userId: string): void {
try {
localStorage.setItem(USER_ID_KEY, userId)
} catch (error) {
console.warn('Failed to store user ID:', error)
}
}
/**
* Get stored user ID
*/
export function getUserId(): string | null {
try {
return localStorage.getItem(USER_ID_KEY)
} catch {
return null
}
}
/**
* Initialize analytics
* Call this once on app startup
*/
export function initAnalytics(): void {
// Capture UTM parameters from URL
captureUTMParams()
// Track initial page view
trackPageView()
// Track page views on navigation (will be handled by React Router)
}
/**
* Get tracking parameters as query string (for API calls)
*/
export function getTrackingQueryString(): string {
const params = getAllTrackingParams()
const entries = Object.entries(params).filter(([, value]) => value)
return entries.length > 0
? '&' + 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(),
},
})
}

589
client/src/lib/api.ts Normal file
View File

@@ -0,0 +1,589 @@
const API_BASE = import.meta.env.VITE_API_URL || '/api'
interface ApiResponse<T> {
success?: boolean
data?: T
error?: {
code: string
message: string
fields?: Record<string, string[]>
limit?: number
used?: number
}
}
async function fetchApi<T>(
endpoint: string,
options?: RequestInit
): Promise<ApiResponse<T>> {
try {
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
})
const data = await response.json()
if (!response.ok || data.success === false) {
return {
error: data.error || {
code: 'UNKNOWN',
message: `HTTP ${response.status}`
}
}
}
return { success: true, data: data.data || data }
} catch (error) {
return {
error: {
code: 'NETWORK_ERROR',
message: error instanceof Error ? error.message : 'Network error'
}
}
}
}
export const api = {
// ═══════════════════════════════════════════════════════════════════════════
// EMAIL ACCOUNTS
// ═══════════════════════════════════════════════════════════════════════════
async getEmailAccounts(userId: string) {
return fetchApi<Array<{
id: string
email: string
provider: 'gmail' | 'outlook' | 'imap'
connected: boolean
lastSync?: string
}>>(`/email/accounts?userId=${userId}`)
},
async connectEmailAccount(userId: string, provider: 'gmail' | 'outlook', email: string, accessToken: string, refreshToken?: string) {
return fetchApi<{ accountId: string }>('/email/connect', {
method: 'POST',
body: JSON.stringify({ userId, provider, email, accessToken, refreshToken }),
})
},
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',
})
},
// ═══════════════════════════════════════════════════════════════════════════
// EMAIL STATS & SORTING
// ═══════════════════════════════════════════════════════════════════════════
async getEmailStats(userId: string) {
return fetchApi<{
totalSorted: number
todaySorted: number
weekSorted: number
categories: Record<string, number>
timeSaved: number
}>(`/email/stats?userId=${userId}`)
},
async sortEmails(userId: string, accountId: string, maxEmails?: number, processAll?: boolean) {
return fetchApi<{
sorted: number
inboxCleared: number
categories: Record<string, number>
timeSaved: { minutes: number; formatted: string }
highlights: Array<{ type: string; count: number; message: string }>
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 }),
})
},
// Demo sorting without account (for quick tests)
async sortDemo(count: number = 10) {
return fetchApi<{
sorted: number
emails: Array<{
from: string
subject: string
snippet: string
category: string
categoryName: string
confidence?: number
reason?: string
}>
categories: Record<string, number>
aiEnabled: boolean
}>('/email/sort-demo', {
method: 'POST',
body: JSON.stringify({ count }),
})
},
// Connect demo account
async connectDemoAccount(userId: string) {
return fetchApi<{
accountId: string
email: string
provider: string
message?: string
}>('/email/connect-demo', {
method: 'POST',
body: JSON.stringify({ userId }),
})
},
// Get categories
async getCategories() {
return fetchApi<Array<{
id: string
name: string
description: string
color: string
action: string
priority: number
}>>('/email/categories')
},
// Get today's digest
async getDigest(userId: string) {
return fetchApi<{
date: string
totalSorted: number
inboxCleared: number
timeSavedMinutes: number
stats: Record<string, number>
highlights: Array<{ type: string; count: number; message: string }>
suggestions: Array<{ type: string; message: string }>
hasData: boolean
}>(`/email/digest?userId=${userId}`)
},
// Get digest history
async getDigestHistory(userId: string, days: number = 7) {
return fetchApi<{
days: number
digests: Array<{
date: string
totalSorted: number
inboxCleared: number
timeSavedMinutes: number
stats: Record<string, number>
}>
totals: {
totalSorted: number
inboxCleared: number
timeSavedMinutes: number
}
}>(`/email/digest/history?userId=${userId}&days=${days}`)
},
// ═══════════════════════════════════════════════════════════════════════════
// OAUTH
// ═══════════════════════════════════════════════════════════════════════════
async getOAuthUrl(provider: 'gmail' | 'outlook', userId: string) {
return fetchApi<{ url: string }>(`/oauth/${provider}/connect?userId=${userId}`)
},
async getOAuthStatus() {
return fetchApi<{
gmail: { enabled: boolean; scopes: string[] }
outlook: { enabled: boolean; scopes: string[] }
}>('/oauth/status')
},
// ═══════════════════════════════════════════════════════════════════════════
// SUBSCRIPTION
// ═══════════════════════════════════════════════════════════════════════════
async getSubscriptionStatus(userId: string) {
return fetchApi<{
status: string
plan: string
isFreeTier: boolean
emailsUsedThisMonth?: number
emailsLimit?: number
features: {
emailAccounts: number
emailsPerDay: number
historicalSync: boolean
customRules: boolean
prioritySupport: boolean
}
currentPeriodEnd?: string
cancelAtPeriodEnd?: boolean
}>(`/subscription/status?userId=${userId}`)
},
async createSubscriptionCheckout(plan: string, userId: string, email?: string) {
return fetchApi<{ url: string; sessionId: string }>('/subscription/checkout', {
method: 'POST',
body: JSON.stringify({ userId, plan, email }),
})
},
async createPortalSession(userId: string) {
return fetchApi<{ url: string }>('/subscription/portal', {
method: 'POST',
body: JSON.stringify({ userId }),
})
},
async cancelSubscription(userId: string) {
return fetchApi<{ success: boolean }>('/subscription/cancel', {
method: 'POST',
body: JSON.stringify({ userId }),
})
},
async reactivateSubscription(userId: string) {
return fetchApi<{ success: boolean }>('/subscription/reactivate', {
method: 'POST',
body: JSON.stringify({ userId }),
})
},
// ═══════════════════════════════════════════════════════════════════════════
// USER PREFERENCES
// ═══════════════════════════════════════════════════════════════════════════
async getUserPreferences(userId: string) {
return fetchApi<{
vipSenders: Array<{ email: string; name?: string }>
blockedSenders: string[]
customRules: Array<{ condition: string; category: string }>
priorityTopics: string[]
}>(`/preferences?userId=${userId}`)
},
async saveUserPreferences(userId: string, preferences: {
vipSenders?: Array<{ email: string; name?: string }>
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',
body: JSON.stringify({ userId, ...preferences }),
})
},
// ═══════════════════════════════════════════════════════════════════════════
// 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<unknown[]>('/products')
},
async getQuestions(productSlug: string) {
return fetchApi<unknown[]>(`/questions?productSlug=${productSlug}`)
},
async createSubmission(productSlug: string, answers: Record<string, unknown>) {
return fetchApi<{ submissionId: string }>('/submissions', {
method: 'POST',
body: JSON.stringify({ productSlug, answers }),
})
},
async createCheckout(submissionId: string) {
return fetchApi<{ url: string; sessionId: string }>('/checkout', {
method: 'POST',
body: JSON.stringify({ submissionId }),
})
},
// ═══════════════════════════════════════════════════════════════════════════
// CONFIG
// ═══════════════════════════════════════════════════════════════════════════
async getConfig() {
return fetchApi<{
features: {
gmail: boolean
outlook: boolean
ai: boolean
}
pricing: {
basic: { price: number; currency: string; accounts: number }
pro: { price: number; currency: string; accounts: number }
business: { price: number; currency: string; accounts: number }
}
}>('/config')
},
async healthCheck() {
return fetchApi<{
status: string
timestamp: string
version: string
environment: string
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

View File

@@ -0,0 +1,81 @@
import { Client, Account, Databases, ID } from 'appwrite'
const client = new Client()
// Configure these in your .env file
const APPWRITE_ENDPOINT = import.meta.env.VITE_APPWRITE_ENDPOINT || 'https://cloud.appwrite.io/v1'
const APPWRITE_PROJECT_ID = import.meta.env.VITE_APPWRITE_PROJECT_ID || ''
client
.setEndpoint(APPWRITE_ENDPOINT)
.setProject(APPWRITE_PROJECT_ID)
export const account = new Account(client)
export const databases = new Databases(client)
export { ID }
// Auth helper functions
export const auth = {
// Create a new account
async register(email: string, password: string, name?: string) {
const user = await account.create(ID.unique(), email, password, name)
await this.login(email, password)
return user
},
// Login with email and password
async login(email: string, password: string) {
return await account.createEmailPasswordSession(email, password)
},
// Logout current session
async logout() {
return await account.deleteSession('current')
},
// Get current logged in user
async getCurrentUser() {
try {
return await account.get()
} catch {
return null
}
},
// Check if user is logged in
async isLoggedIn() {
try {
await account.get()
return true
} catch {
return false
}
},
// Send password recovery email
async forgotPassword(email: string) {
return await account.createRecovery(
email,
`${window.location.origin}/reset-password`
)
},
// Complete password recovery
async resetPassword(userId: string, secret: string, newPassword: string) {
return await account.updateRecovery(userId, secret, newPassword)
},
// Send verification email
async sendVerification() {
return await account.createVerification(
`${window.location.origin}/verify`
)
},
// Complete email verification
async verifyEmail(userId: string, secret: string) {
return await account.updateVerification(userId, secret)
},
}
export default client

6
client/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

10
client/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,960 @@
import { useState, useEffect } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useAuth } from '@/context/AuthContext'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { api } from '@/lib/api'
import {
Mail,
Inbox,
Tag,
Clock,
TrendingUp,
Plus,
Settings,
LogOut,
Zap,
BarChart3,
Users,
Shield,
Loader2,
Check,
AlertCircle,
Sparkles,
AlertTriangle,
Lightbulb,
Archive,
Brain,
ExternalLink
} from 'lucide-react'
import { UpgradePrompt } from '@/components/UpgradePrompt'
import { ShareResults } from '@/components/ShareResults'
import { trackSortCompleted, trackLimitReached, trackRulesApplied } from '@/lib/analytics'
interface EmailStats {
totalSorted: number
todaySorted: number
weekSorted: number
categories: Record<string, number>
timeSaved: number
}
interface EmailAccount {
id: string
email: string
provider: string
connected: boolean
lastSync?: string
}
interface SortResult {
sorted: number
inboxCleared: number
categories: Record<string, number>
timeSaved: { minutes: number; formatted: string }
highlights: Array<{ type: string; count: number; message: string }>
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?: string; condition?: string; category?: string }
}>
}
interface Digest {
date: string
totalSorted: number
inboxCleared: number
timeSavedMinutes: number
stats: Record<string, number>
highlights: Array<{ type: string; count: number; message: string }>
suggestions: Array<{ type: string; message: string }>
hasData: boolean
}
export function Dashboard() {
const { user, logout } = useAuth()
const navigate = useNavigate()
const [stats, setStats] = useState<EmailStats | null>(null)
const [accounts, setAccounts] = useState<EmailAccount[]>([])
const [digest, setDigest] = useState<Digest | null>(null)
const [subscription, setSubscription] = useState<{
plan: string
isFreeTier: boolean
emailsUsedThisMonth?: number
emailsLimit?: number
} | null>(null)
const [referralCode, setReferralCode] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [sorting, setSorting] = useState(false)
const [sortResult, setSortResult] = useState<SortResult | null>(null)
const [error, setError] = useState<string | null>(null)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const showMessage = (type: 'success' | 'error', text: string) => {
setMessage({ type, text })
setTimeout(() => setMessage(null), 5000)
}
useEffect(() => {
if (user?.$id) {
loadData()
}
}, [user])
const loadData = async () => {
if (!user?.$id) return
setLoading(true)
setError(null)
try {
const [statsRes, accountsRes, digestRes, subscriptionRes, referralRes] = await Promise.all([
api.getEmailStats(user.$id),
api.getEmailAccounts(user.$id),
api.getDigest(user.$id),
api.getSubscriptionStatus(user.$id),
api.getReferralCode(user.$id).catch(() => ({ data: null })),
])
if (statsRes.data) setStats(statsRes.data)
if (accountsRes.data) setAccounts(accountsRes.data)
if (digestRes.data) setDigest(digestRes.data)
if (subscriptionRes.data) setSubscription(subscriptionRes.data)
if (referralRes.data) setReferralCode(referralRes.data.referralCode)
} catch (err) {
console.error('Error loading dashboard data:', err)
setError('Couldnt load your data. Check your connection and refresh.')
} finally {
setLoading(false)
}
}
const handleSortNow = async () => {
if (!user?.$id || accounts.length === 0) {
setError('Connect your inbox first, then click Sort Now.')
return
}
setSorting(true)
setSortResult(null)
setError(null)
try {
const result = await api.sortEmails(user.$id, accounts[0].id)
if (result.data) {
setSortResult(result.data)
// Track sort completed
trackSortCompleted(user.$id, result.data.sorted, result.data.isFirstRun || false)
// Refresh stats, digest, and subscription
const [statsRes, digestRes, subscriptionRes] = await Promise.all([
api.getEmailStats(user.$id),
api.getDigest(user.$id),
api.getSubscriptionStatus(user.$id),
])
if (statsRes.data) setStats(statsRes.data)
if (digestRes.data) setDigest(digestRes.data)
if (subscriptionRes.data) setSubscription(subscriptionRes.data)
} else if (result.error) {
// Check if it's a limit reached error
if (result.error.code === 'LIMIT_REACHED') {
setError(result.error.message || 'Monthly limit reached')
trackLimitReached(user.$id, result.error.limit || 500, result.error.used || 500)
// Refresh subscription to show updated usage
const subscriptionRes = await api.getSubscriptionStatus(user.$id)
if (subscriptionRes.data) setSubscription(subscriptionRes.data)
} else {
setError(result.error.message || 'Email sorting failed. Please try again or reconnect your account.')
}
}
} catch (err) {
console.error('Error sorting emails:', err)
setError('Something went wrong. Check your connection and try again.')
} finally {
setSorting(false)
}
}
const handleLogout = async () => {
await logout()
navigate('/')
}
const displayStats: EmailStats = stats || {
totalSorted: 0,
todaySorted: 0,
weekSorted: 0,
categories: {},
timeSaved: 0,
}
const categoryColors: Record<string, string> = {
'vip': 'bg-amber-500',
'Important': 'bg-amber-500',
'customers': 'bg-blue-500',
'Clients': 'bg-blue-500',
'invoices': 'bg-green-500',
'Invoices': 'bg-green-500',
'newsletters': 'bg-purple-500',
'Newsletter': 'bg-purple-500',
'social': 'bg-pink-500',
'Social': 'bg-pink-500',
'promotions': 'bg-orange-500',
'Promotions': 'bg-orange-500',
'security': 'bg-red-500',
'Security': 'bg-red-500',
'calendar': 'bg-indigo-500',
'Calendar': 'bg-indigo-500',
'review': 'bg-slate-500',
'Review': 'bg-slate-500',
}
const categoryLabels: Record<string, string> = {
'vip': 'Important',
'customers': 'Clients',
'invoices': 'Invoices',
'newsletters': 'Newsletter',
'social': 'Social',
'promotions': 'Promotions',
'security': 'Security',
'calendar': 'Calendar',
'review': 'Review',
}
const formatCategoryName = (key: string) => categoryLabels[key] || key
return (
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800">
{/* Header */}
<header className="bg-white/90 dark:bg-slate-900/90 backdrop-blur-md border-b border-slate-200 dark:border-slate-700 sticky top-0 z-50 shadow-sm">
<div className="w-full px-3 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-14 sm:h-16">
<Link to="/" className="flex items-center gap-1.5 sm:gap-2 flex-shrink-0">
<div className="w-8 h-8 sm:w-9 sm: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-4 h-4 sm:w-5 sm:h-5 text-white" />
</div>
<span className="text-base sm:text-lg font-bold text-slate-900 dark:text-slate-100 whitespace-nowrap">
E-Mail-<span className="text-primary-600 dark:text-primary-400">Sorter</span>
</span>
</Link>
<div className="flex items-center gap-1.5 sm:gap-2 lg:gap-4">
<Button
variant="ghost"
onClick={() => navigate('/settings')}
className="hidden lg:flex h-9"
>
<Settings className="w-4 h-4 mr-2" />
Settings
</Button>
<Button
variant="ghost"
size="icon"
className="lg:hidden h-9 w-9"
onClick={() => navigate('/settings')}
title="Settings"
>
<Settings className="w-5 h-5 text-slate-600 dark:text-slate-300" />
</Button>
<Button
variant="outline"
onClick={handleLogout}
className="hidden lg:flex h-9"
>
<LogOut className="w-4 h-4 mr-2" />
Sign out
</Button>
<Button
variant="outline"
size="icon"
className="lg:hidden h-9 w-9"
onClick={handleLogout}
title="Sign out"
>
<LogOut className="w-4 h-4 text-slate-600 dark:text-slate-300" />
</Button>
</div>
</div>
</div>
</header>
<main className="w-full px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
{/* Dashboard Header */}
<div className="mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-slate-900 dark:text-slate-100 mb-1">
Dashboard
</h1>
<p className="text-sm text-slate-600 dark:text-slate-400">
{accounts.length > 0
? `${accounts.length} account${accounts.length > 1 ? 's' : ''} connected`
: 'Connect Gmail or Outlook to sort your first emails.'}
</p>
</div>
<div className="flex items-center gap-2 sm:gap-3">
{accounts.length === 0 ? (
<Button
onClick={() => navigate('/setup')}
className="flex-1 sm:flex-none"
aria-label="Connect email account"
>
<Plus className="w-4 h-4 mr-2" />
Connect inbox
</Button>
) : (
<Button
onClick={handleSortNow}
disabled={sorting || accounts.length === 0}
className="flex-1 sm:flex-none"
aria-label={sorting ? "Sorting emails" : "Sort emails now"}
>
{sorting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Sorting...
</>
) : (
<>
<Zap className="w-4 h-4 mr-2" />
Sort Now
</>
)}
</Button>
)}
</div>
</div>
{/* Success/Error messages */}
{message && (
<div className={`mb-3 sm:mb-4 p-2.5 sm:p-3 ${message.type === 'success' ? 'bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 text-green-700 dark:text-green-300' : 'bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300'} rounded-lg flex items-start sm:items-center gap-2`}>
{message.type === 'success' ? (
<Check className="w-4 h-4 flex-shrink-0 mt-0.5 sm:mt-0 text-green-700 dark:text-green-300" />
) : (
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5 sm:mt-0 text-red-700 dark:text-red-300" />
)}
<p className="text-xs sm:text-sm flex-1">{message.text}</p>
<button
onClick={() => setMessage(null)}
className={`${message.type === 'success' ? 'text-green-400 hover:text-green-600 dark:text-green-500 dark:hover:text-green-400' : 'text-red-400 hover:text-red-600 dark:text-red-500 dark:hover:text-red-400'} text-base font-semibold leading-none focus:outline-none focus:ring-2 focus:ring-primary-500 rounded`}
aria-label="Close message"
>
</button>
</div>
)}
{/* First-time hint: account connected, no sort yet */}
{!loading && accounts.length > 0 && !sortResult && !error && (
<div className="mb-4 p-4 bg-slate-100 dark:bg-slate-800/60 rounded-xl border border-slate-200 dark:border-slate-700">
<p className="text-sm text-slate-700 dark:text-slate-300">
Click <strong>Sort Now</strong> to categorize your inbox. Takes about 30 seconds. Nothing is deleted we only add labels or move mail to folders.
</p>
</div>
)}
{/* Error message */}
{error && (
<div className="mb-3 sm:mb-4 p-2.5 sm:p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg flex items-start sm:items-center gap-2 text-red-700 dark:text-red-300">
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5 sm:mt-0 text-red-700 dark:text-red-300" />
<p className="text-xs sm:text-sm flex-1">{error}</p>
<button
onClick={() => setError(null)}
className="text-red-400 hover:text-red-600 dark:text-red-500 dark:hover:text-red-400 text-base font-semibold leading-none focus:outline-none focus:ring-2 focus:ring-primary-500 rounded"
aria-label="Close error message"
>
</button>
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<Loader2 className="w-10 h-10 animate-spin text-primary-500 dark:text-primary-400 mx-auto mb-4" />
<p className="text-slate-500 dark:text-slate-400">Loading your data...</p>
<p className="text-slate-400 dark:text-slate-500 text-sm mt-1">One moment.</p>
</div>
</div>
) : (
<div className="grid lg:grid-cols-[2fr_1fr] gap-6">
{/* LEFT COLUMN - AI-Assistent Bereich */}
<div className="space-y-6">
{/* AI-Assistent Karte */}
<Card className="border border-slate-200 dark:border-slate-700 shadow-sm">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center">
<Sparkles className="w-5 h-5 text-white" />
</div>
<div>
<CardTitle className="text-lg">Email sorting</CardTitle>
<CardDescription className="text-sm">
Categorize inbox leads, clients, newsletters
</CardDescription>
</div>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Sort Result */}
{sortResult && (
<div className={`p-4 ${sortResult.isFirstRun ? 'bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/30 dark:to-emerald-900/30 border-2 border-green-300 dark:border-green-700 rounded-xl' : 'bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg'}`}>
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
<div className="flex items-center gap-2 text-green-700 dark:text-green-300">
<div className={`${sortResult.isFirstRun ? 'w-8 h-8' : 'w-5 h-5'} flex-shrink-0 bg-green-500 dark:bg-green-600 rounded-full flex items-center justify-center`}>
<Check className={`${sortResult.isFirstRun ? 'w-5 h-5' : 'w-4 h-4'} text-white`} />
</div>
<div>
<p className={`${sortResult.isFirstRun ? 'text-lg' : 'text-sm'} font-bold text-green-800 dark:text-green-200`}>
{sortResult.isFirstRun ? 'Done. Your inbox is sorted.' : 'Sort complete.'}
</p>
{sortResult.isFirstRun && (
<p className="text-sm text-green-700 dark:text-green-300 mt-0.5">Newsletters and promos are in folders. Check your inbox only important mail is left.</p>
)}
</div>
</div>
{sortResult.isDemo && (
<Badge variant="secondary" className="bg-amber-100 dark:bg-amber-900/50 text-amber-700 dark:text-amber-300 text-xs">
Demo
</Badge>
)}
</div>
<div className={`grid ${sortResult.isFirstRun ? 'grid-cols-4' : 'grid-cols-3'} gap-3 mb-4`}>
<div className={sortResult.isFirstRun ? 'bg-white/80 dark:bg-slate-800/80 p-3 rounded-lg' : ''}>
<p className="text-xs text-green-600 dark:text-green-400 mb-1">Emails categorized</p>
<p className={`${sortResult.isFirstRun ? 'text-2xl' : 'text-xl'} font-bold text-green-800 dark:text-green-200`}>{sortResult.sorted}</p>
</div>
<div className={sortResult.isFirstRun ? 'bg-white/80 dark:bg-slate-800/80 p-3 rounded-lg' : ''}>
<p className="text-xs text-green-600 dark:text-green-400 mb-1">Inbox reduced</p>
<p className={`${sortResult.isFirstRun ? 'text-2xl' : 'text-xl'} font-bold text-green-800 dark:text-green-200`}>{sortResult.inboxCleared}</p>
</div>
<div className={sortResult.isFirstRun ? 'bg-white/80 dark:bg-slate-800/80 p-3 rounded-lg' : ''}>
<p className="text-xs text-green-600 dark:text-green-400 mb-1">Time saved</p>
<p className={`${sortResult.isFirstRun ? 'text-2xl' : 'text-xl'} font-bold text-green-800 dark:text-green-200`}>{sortResult.timeSaved.formatted}</p>
</div>
{sortResult.isFirstRun && Object.entries(sortResult.categories).length > 0 && (
<div className="bg-white/80 dark:bg-slate-800/80 p-3 rounded-lg">
<p className="text-xs text-green-600 dark:text-green-400 mb-1">Top category</p>
<p className="text-xl font-bold text-green-800 dark:text-green-200">
{Object.entries(sortResult.categories).sort(([, a], [, b]) => b - a)[0]?.[1] || 0}
</p>
<p className="text-xs text-green-500 dark:text-green-400 mt-1">
{formatCategoryName(Object.entries(sortResult.categories).sort(([, a], [, b]) => b - a)[0]?.[0] || '')}
</p>
</div>
)}
</div>
{/* Suggested Rules */}
{sortResult.isFirstRun && sortResult.suggestedRules && sortResult.suggestedRules.length > 0 && (
<div className="mt-4 pt-4 border-t border-green-200 dark:border-green-800">
<div className="mb-3">
<p className="text-sm font-semibold text-green-900 dark:text-green-200">Suggestions for you</p>
<p className="text-xs text-green-600 dark:text-green-400 mt-0.5">Based on your email patterns</p>
</div>
<div className="space-y-2 mb-3">
{sortResult.suggestedRules.slice(0, 3).map((rule, idx) => (
<div key={idx} className="flex items-start gap-3 p-3 bg-white/60 dark:bg-slate-800/60 rounded-lg border border-green-200 dark:border-green-800">
<div className="w-6 h-6 rounded-full bg-green-100 dark:bg-green-900/50 flex items-center justify-center flex-shrink-0 mt-0.5">
<Lightbulb className="w-3 h-3 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{rule.name}</p>
<p className="text-xs text-slate-600 dark:text-slate-400 mt-0.5">{rule.description}</p>
</div>
</div>
))}
</div>
<Button
onClick={async () => {
if (!user?.$id || !sortResult.suggestedRules) return
setSorting(true)
try {
const vipSenders = sortResult.suggestedRules
.filter((r): r is typeof r & { action: { email: string } } => r.type === 'vip_sender' && !!r.action?.email)
.map(r => ({ email: r.action.email }))
const companyLabels = sortResult.suggestedRules
.filter((r): r is typeof r & { action: { name: string; condition?: string; category?: string } } => r.type === 'company_label' && !!r.action?.name)
.map(r => ({
name: r.action.name,
condition: r.action.condition ?? '',
category: r.action.category || 'promotions',
enabled: true,
}))
const updates: { vipSenders?: Array<{ email: string; name?: string }>; companyLabels?: Array<{ name: string; condition?: string; category: string; enabled: boolean }> } = {}
if (vipSenders.length > 0) {
updates.vipSenders = vipSenders
}
if (companyLabels.length > 0) {
updates.companyLabels = companyLabels
}
if (Object.keys(updates).length > 0) {
await api.saveUserPreferences(user.$id, updates)
trackRulesApplied(user.$id, sortResult.suggestedRules.length)
showMessage('success', `${sortResult.suggestedRules.length} rules applied. Your inbox will stay organized.`)
setSortResult({ ...sortResult, suggestedRules: [] })
}
} catch {
showMessage('error', 'Unable to apply rules right now. Please try again.')
} finally {
setSorting(false)
}
}}
disabled={sorting}
className="w-full bg-green-600 hover:bg-green-700 text-white"
aria-label="Apply suggested rules"
>
{sorting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Applying rules...
</>
) : (
<>
<Sparkles className="w-4 h-4 mr-2" />
Apply {sortResult.suggestedRules.length} suggested rules
</>
)}
</Button>
</div>
)}
{/* Share Results */}
{sortResult && sortResult.sorted >= 10 && (
<div className="mt-4">
<ShareResults
sortedCount={sortResult.sorted}
referralCode={referralCode || undefined}
/>
</div>
)}
{/* Upgrade Prompt after applying rules */}
{sortResult && !sortResult.suggestedRules && subscription?.isFreeTier && sortResult.sorted > 0 && (
<div className="mt-4">
<UpgradePrompt
source="after_rules"
onDismiss={() => {}}
/>
</div>
)}
{/* Categories breakdown (non-first-run) */}
{!sortResult.isFirstRun && Object.keys(sortResult.categories).length > 2 && (
<div className="mt-4 pt-4 border-t border-green-200 dark:border-green-800">
<p className="text-xs text-green-600 dark:text-green-400 mb-1.5">Categories:</p>
<div className="flex flex-wrap gap-1">
{Object.entries(sortResult.categories).map(([cat, count]) => (
<span
key={cat}
className={`px-1.5 py-0.5 rounded-full text-xs font-medium text-white ${categoryColors[cat] || 'bg-slate-500'}`}
>
{formatCategoryName(cat)}: {count}
</span>
))}
</div>
</div>
)}
</div>
)}
{/* Upgrade Prompt after successful sort (free tier) */}
{sortResult && subscription?.isFreeTier && !sortResult.isFirstRun && (
<UpgradePrompt
source="after_sort"
onDismiss={() => {}}
/>
)}
{/* Stats Cards - Kompakt */}
<div className="grid grid-cols-2 gap-3">
<div className="p-4 bg-slate-50 dark:bg-slate-800/50 rounded-lg border border-slate-200 dark:border-slate-700">
<div className="flex items-center justify-between mb-2">
<p className="text-xs text-slate-600 dark:text-slate-400">Sorted today</p>
<Inbox className="w-4 h-4 text-primary-500 dark:text-primary-400" />
</div>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{displayStats.todaySorted}</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">emails</p>
</div>
<div className="p-4 bg-slate-50 dark:bg-slate-800/50 rounded-lg border border-slate-200 dark:border-slate-700">
<div className="flex items-center justify-between mb-2">
<p className="text-xs text-slate-600 dark:text-slate-400">This week</p>
<TrendingUp className="w-4 h-4 text-accent-500 dark:text-accent-400" />
</div>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{displayStats.weekSorted}</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">emails</p>
</div>
<div className="p-4 bg-slate-50 dark:bg-slate-800/50 rounded-lg border border-slate-200 dark:border-slate-700">
<div className="flex items-center justify-between mb-2">
<p className="text-xs text-slate-600 dark:text-slate-400">Time saved</p>
<Clock className="w-4 h-4 text-green-500 dark:text-green-400" />
</div>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">
{displayStats.timeSaved > 60
? `${Math.floor(displayStats.timeSaved / 60)}h ${displayStats.timeSaved % 60}m`
: `${displayStats.timeSaved}m`}
</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">this week</p>
</div>
<div className="p-4 bg-slate-50 dark:bg-slate-800/50 rounded-lg border border-slate-200 dark:border-slate-700">
<div className="flex items-center justify-between mb-2">
<p className="text-xs text-slate-600 dark:text-slate-400">Total sorted</p>
<BarChart3 className="w-4 h-4 text-violet-500 dark:text-violet-400" />
</div>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{displayStats.totalSorted.toLocaleString('en-US')}</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">emails</p>
</div>
</div>
{/* Daily Digest */}
{digest?.hasData && (
<div className="p-4 bg-gradient-to-r from-primary-50 to-accent-50 dark:from-primary-900/30 dark:to-accent-900/30 rounded-lg border border-primary-200 dark:border-primary-800">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-primary-600 dark:text-primary-400" />
<div>
<h3 className="text-sm font-bold text-slate-900 dark:text-slate-100">Today's Digest</h3>
<p className="text-xs text-slate-600 dark:text-slate-400">{new Date(digest.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}</p>
</div>
</div>
{digest.inboxCleared > 0 && (
<Badge className="bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300 border-green-200 dark:border-green-800 text-xs">
<Archive className="w-3 h-3 mr-0.5" />
{digest.inboxCleared}
</Badge>
)}
</div>
<div className="grid grid-cols-3 gap-2 mb-3">
<div className="bg-white/80 dark:bg-slate-800/80 rounded-lg p-2 border border-slate-200/50 dark:border-slate-700/50">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-0.5">Processed</p>
<p className="text-base font-bold text-slate-900 dark:text-slate-100">{digest.totalSorted}</p>
</div>
<div className="bg-white/80 dark:bg-slate-800/80 rounded-lg p-2 border border-slate-200/50 dark:border-slate-700/50">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-0.5">Cleared</p>
<p className="text-base font-bold text-green-600 dark:text-green-400">{digest.inboxCleared}</p>
</div>
<div className="bg-white/80 dark:bg-slate-800/80 rounded-lg p-2 border border-slate-200/50 dark:border-slate-700/50">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-0.5">Saved</p>
<p className="text-base font-bold text-primary-600 dark:text-primary-400">
{digest.timeSavedMinutes > 60
? `${Math.floor(digest.timeSavedMinutes / 60)}h ${digest.timeSavedMinutes % 60}m`
: `${digest.timeSavedMinutes}m`}
</p>
</div>
</div>
{digest.highlights.length > 0 && (
<div className="mb-3">
<p className="text-xs font-medium text-slate-700 dark:text-slate-300 mb-1.5 flex items-center gap-1.5">
<AlertTriangle className="w-3.5 h-3.5 text-amber-500 dark:text-amber-400" />
Needs Attention
</p>
<div className="flex flex-wrap gap-1.5">
{digest.highlights.map((highlight, idx) => (
<div
key={idx}
className={`px-2 py-1 rounded-md text-xs ${
highlight.type === 'vip' ? 'bg-amber-100 dark:bg-amber-900/50 text-amber-800 dark:text-amber-300' :
highlight.type === 'security' ? 'bg-red-100 dark:bg-red-900/50 text-red-800 dark:text-red-300' :
highlight.type === 'invoices' ? 'bg-green-100 dark:bg-green-900/50 text-green-800 dark:text-green-300' :
'bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300'
}`}
>
{highlight.message}
</div>
))}
</div>
</div>
)}
{digest.suggestions.length > 0 && (
<div className="pt-2.5 border-t border-slate-200/50 dark:border-slate-700/50">
<p className="text-xs font-medium text-slate-700 dark:text-slate-300 mb-1.5 flex items-center gap-1.5">
<Lightbulb className="w-3.5 h-3.5 text-primary-500 dark:text-primary-400" />
Suggestions
</p>
<div className="space-y-1">
{digest.suggestions.map((suggestion, idx) => (
<p key={idx} className="text-xs text-slate-600 dark:text-slate-400 bg-white/60 dark:bg-slate-800/60 rounded-md px-2 py-1">
{suggestion.message}
</p>
))}
</div>
</div>
)}
</div>
)}
{/* Categories Overview */}
<div>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Tag className="w-4 h-4 text-primary-500 dark:text-primary-400" />
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">Categories Overview</h3>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">This week</p>
</div>
{Object.keys(displayStats.categories).length > 0 ? (
<div className="space-y-2.5">
{Object.entries(displayStats.categories).map(([category, count]) => {
const total = Object.values(displayStats.categories).reduce((a, b) => a + b, 0)
const percentage = total > 0 ? Math.round((count / total) * 100) : 0
return (
<div key={category}>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-1.5 min-w-0 flex-1">
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${categoryColors[category] || 'bg-slate-400 dark:bg-slate-500'}`} />
<span className="text-xs font-medium text-slate-700 dark:text-slate-300 truncate">{formatCategoryName(category)}</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<span className="text-xs text-slate-500 dark:text-slate-400 whitespace-nowrap">{count}</span>
<span className="text-xs text-slate-400 dark:text-slate-500 whitespace-nowrap">({percentage}%)</span>
</div>
</div>
<div className="h-1.5 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${categoryColors[category] || 'bg-slate-400 dark:bg-slate-500'}`}
style={{ width: `${percentage}%` }}
/>
</div>
</div>
)
})}
</div>
) : (
<div className="text-center py-8">
<Tag className="w-10 h-10 text-slate-300 dark:text-slate-600 mx-auto mb-3" />
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">No category statistics yet</p>
<p className="text-xs text-slate-400 dark:text-slate-500">Start a sort to see statistics</p>
</div>
)}
</div>
</CardContent>
</Card>
</div>
{/* RIGHT COLUMN - Control Panel, Einstellungen, Account */}
<div className="space-y-6">
{/* Control Panel Karte */}
<Card className="border border-slate-200 dark:border-slate-700 shadow-sm">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Brain className="w-5 h-5 text-primary-500 dark:text-primary-400" />
<CardTitle className="text-base">Control Panel</CardTitle>
</div>
<CardDescription className="text-xs">
Categories and rules
</CardDescription>
</CardHeader>
<CardContent>
<Button
onClick={() => navigate('/settings?tab=ai-control')}
variant="outline"
className="w-full"
aria-label="Open Control Panel settings"
>
<Settings className="w-4 h-4 mr-2" />
Open Control Panel
<ExternalLink className="w-3 h-3 ml-2" />
</Button>
</CardContent>
</Card>
{/* Einstellungen Karte */}
<Card className="border border-slate-200 dark:border-slate-700 shadow-sm">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Settings className="w-5 h-5 text-primary-500 dark:text-primary-400" />
<CardTitle className="text-base">Settings</CardTitle>
</div>
<CardDescription className="text-xs">
Quick access
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<Button
onClick={() => navigate('/settings?tab=vip')}
variant="ghost"
className="w-full justify-start"
aria-label="Manage VIP list"
>
<Shield className="w-4 h-4 mr-2" />
VIP List
</Button>
<Button
onClick={() => navigate('/settings?tab=accounts')}
variant="ghost"
className="w-full justify-start"
aria-label="Manage email accounts"
>
<Users className="w-4 h-4 mr-2" />
Email Accounts
</Button>
<Button
onClick={() => navigate('/settings')}
variant="ghost"
className="w-full justify-start"
aria-label="Open all settings"
>
<Settings className="w-4 h-4 mr-2" />
All settings
<ExternalLink className="w-3 h-3 ml-auto" />
</Button>
</CardContent>
</Card>
{/* Account/System Karte */}
<Card className="border border-slate-200 dark:border-slate-700 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base">Account</CardTitle>
<CardDescription className="text-xs">
Subscription and accounts
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Subscription Status */}
<div>
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-medium text-slate-700 dark:text-slate-300">Subscription</p>
<Badge variant={subscription?.isFreeTier ? 'secondary' : 'default'} className="text-xs">
{subscription?.plan === 'free' ? 'Free Tier' : subscription?.plan || 'Free Tier'}
</Badge>
</div>
{subscription?.isFreeTier && subscription.emailsLimit && (
<div className="mt-2">
<div className="flex items-center justify-between text-xs text-slate-600 dark:text-slate-400 mb-1">
<span>{subscription.emailsUsedThisMonth || 0} / {subscription.emailsLimit}</span>
<span>{Math.round(((subscription.emailsUsedThisMonth || 0) / subscription.emailsLimit) * 100)}%</span>
</div>
<div className="w-full h-1.5 bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-300 ${
(subscription.emailsUsedThisMonth || 0) >= subscription.emailsLimit
? 'bg-red-500 dark:bg-red-600'
: (subscription.emailsUsedThisMonth || 0) >= subscription.emailsLimit * 0.8
? 'bg-amber-500 dark:bg-amber-600'
: 'bg-primary-500 dark:bg-primary-600'
}`}
style={{
width: `${Math.min(100, ((subscription.emailsUsedThisMonth || 0) / subscription.emailsLimit) * 100)}%`
}}
/>
</div>
{(subscription.emailsUsedThisMonth || 0) >= subscription.emailsLimit && (
<div className="mt-2">
<UpgradePrompt
source="limit_reached"
onDismiss={() => {}}
/>
</div>
)}
{(subscription.emailsUsedThisMonth || 0) < subscription.emailsLimit && (
<Button
onClick={() => navigate('/settings?tab=subscription')}
variant="ghost"
size="sm"
className="w-full mt-2 text-xs"
aria-label="Upgrade subscription"
>
Upgrade
</Button>
)}
</div>
)}
{!subscription?.isFreeTier && (
<Button
onClick={() => navigate('/settings?tab=subscription')}
variant="ghost"
size="sm"
className="w-full mt-2 text-xs"
aria-label="Manage subscription"
>
Manage Subscription
</Button>
)}
</div>
{/* Email Accounts - Kompakt */}
<div>
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-medium text-slate-700 dark:text-slate-300">Email Accounts</p>
<Badge variant="secondary" className="text-xs">
{accounts.length} {accounts.length === 1 ? 'account' : 'accounts'}
</Badge>
</div>
{accounts.length > 0 ? (
<div className="space-y-2">
{accounts.slice(0, 2).map((account) => (
<div
key={account.id}
className="flex items-center justify-between p-2 bg-slate-50 dark:bg-slate-800/50 rounded-lg"
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<div className={`w-6 h-6 rounded flex items-center justify-center flex-shrink-0 ${
account.provider === 'gmail' ? 'bg-red-100 dark:bg-red-900/50' : account.provider === 'outlook' ? 'bg-blue-100 dark:bg-blue-900/50' : 'bg-slate-100 dark:bg-slate-700/50'
}`}>
<Mail className={`w-3 h-3 ${
account.provider === 'gmail' ? 'text-red-600 dark:text-red-400' : account.provider === 'outlook' ? 'text-blue-600 dark:text-blue-400' : 'text-slate-600 dark:text-slate-400'
}`} />
</div>
<p className="text-xs font-medium text-slate-700 dark:text-slate-300 truncate">{account.email}</p>
</div>
<Badge variant={account.connected ? 'success' : 'secondary'} className="text-xs px-1.5 py-0.5">
{account.connected ? 'Active' : 'Off'}
</Badge>
</div>
))}
{accounts.length > 2 && (
<Button
onClick={() => navigate('/settings?tab=accounts')}
variant="ghost"
size="sm"
className="w-full text-xs"
aria-label="View all email accounts"
>
View all {accounts.length} accounts
</Button>
)}
</div>
) : (
<div className="text-center py-4">
<Mail className="w-8 h-8 text-slate-300 dark:text-slate-600 mx-auto mb-2" />
<p className="text-xs text-slate-500 dark:text-slate-400 mb-2">No inbox connected yet</p>
<Button
onClick={() => navigate('/setup')}
variant="outline"
size="sm"
className="w-full text-xs"
aria-label="Connect email account"
>
<Plus className="w-3 h-3 mr-1.5" />
Connect inbox
</Button>
</div>
)}
</div>
</CardContent>
</Card>
</div>
</div>
)}
</main>
</div>
)
}

View File

@@ -0,0 +1,132 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { auth } from '@/lib/appwrite'
import { Mail, ArrowLeft, Loader2, CheckCircle } from 'lucide-react'
export function ForgotPassword() {
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [sent, setSent] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await auth.forgotPassword(email)
setSent(true)
} 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 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 dark:text-slate-100">
Email<span className="text-primary-600 dark:text-primary-400">Sorter</span>
</span>
</Link>
<Card className="shadow-xl border-0 dark:bg-slate-800 dark:border-slate-700">
<CardHeader className="text-center pb-2">
<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.'
}
</CardDescription>
</CardHeader>
<CardContent>
{sent ? (
<div className="text-center py-8">
<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 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 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">
<Button
variant="outline"
className="w-full"
onClick={() => setSent(false)}
>
Erneut senden
</Button>
<Link to="/login">
<Button variant="ghost" className="w-full">
<ArrowLeft className="w-4 h-4 mr-2" />
Zurück zum Login
</Button>
</Link>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<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" className="dark:text-slate-200">E-Mail-Adresse</Label>
<Input
id="email"
type="email"
placeholder="name@beispiel.de"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
className="dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100"
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Wird gesendet...
</>
) : (
'Link senden'
)}
</Button>
<div className="text-center">
<Link
to="/login"
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
</Link>
</div>
</form>
)}
</CardContent>
</Card>
</div>
</div>
)
}

25
client/src/pages/Home.tsx Normal file
View File

@@ -0,0 +1,25 @@
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 { 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'
export function Home() {
return (
<div className="min-h-screen">
<Navbar />
<Hero />
<Features />
<HowItWorks />
<Testimonials />
<TrustSection />
<Pricing />
<FAQ />
<Footer />
</div>
)
}

View File

@@ -0,0 +1,156 @@
import { Link } from 'react-router-dom'
import { ArrowLeft, Building2 } from 'lucide-react'
export function Imprint() {
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
{/* Header */}
<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 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>
</Link>
</div>
</header>
{/* Content */}
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-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 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 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 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 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 dark:text-slate-300">
<div>
<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 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 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 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 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 underline"
>
support@webklar.com
</a>
</p>
<p>
<strong>Phone:</strong>{' '}
<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>
{' / '}
<a
href="tel:+491704969375"
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 dark:text-slate-400">
For questions regarding EmailSorter specifically:{' '}
<a
href="mailto:support@emailsorter.com"
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 underline"
>
support@emailsorter.com
</a>
</p>
</div>
</div>
<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{' '}
<a
href="https://webklar.com/impressum"
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/impressum
</a>
</p>
</div>
<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 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.
</p>
</div>
</div>
<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 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 underline"
>
webklar.com/impressum
</a>
</p>
</div>
</div>
</div>
</div>
</main>
</div>
)
}

142
client/src/pages/Login.tsx Normal file
View File

@@ -0,0 +1,142 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuth } from '@/context/AuthContext'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Mail, Lock, ArrowRight, AlertCircle } from 'lucide-react'
export function Login() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { login } = useAuth()
const navigate = useNavigate()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await login(email, password)
navigate('/dashboard')
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Login failed. Please check your credentials.')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex">
{/* Left side - Form */}
<div className="flex-1 flex items-center justify-center px-4 sm:px-6 lg:px-8 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-white">
E-Mail-<span className="text-primary-400">Sorter</span>
</span>
</Link>
<h1 className="text-3xl font-bold text-white mb-2">
Welcome back
</h1>
<p className="text-slate-300 mb-8">
Sign in to access your dashboard.
</p>
{/* Error message */}
{error && (
<div className="mb-6 p-4 bg-red-900/30 border border-red-500/50 rounded-xl flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-300">{error}</p>
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="email" className="text-slate-200">Email address</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<Input
id="email"
type="email"
placeholder="john@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="pl-10 bg-slate-800 border-slate-700 text-white placeholder:text-slate-400 focus:border-primary-500"
required
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password" className="text-slate-200">Password</Label>
<Link
to="/forgot-password"
className="text-sm text-primary-400 hover:text-primary-300"
>
Forgot?
</Link>
</div>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pl-10 bg-slate-800 border-slate-700 text-white placeholder:text-slate-400 focus:border-primary-500"
required
/>
</div>
</div>
<Button type="submit" className="w-full" size="lg" disabled={loading}>
{loading ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
Sign in
<ArrowRight className="w-5 h-5 ml-2" />
</>
)}
</Button>
</form>
<p className="mt-8 text-center text-slate-300">
Don't have an account?{' '}
<Link to="/register" className="text-primary-400 font-semibold hover:text-primary-300">
Sign up free
</Link>
</p>
</div>
</div>
{/* Right side - Decorative */}
<div className="hidden lg:flex lg:flex-1 bg-gradient-to-br from-primary-600 to-primary-900 items-center justify-center p-12">
<div className="max-w-md text-center">
<div className="w-24 h-24 mx-auto mb-8 rounded-3xl bg-white/10 backdrop-blur flex items-center justify-center">
<Mail className="w-12 h-12 text-white" />
</div>
<h2 className="text-3xl font-bold text-white mb-4">
Your inbox under control
</h2>
<p className="text-primary-100">
Thousands of users already trust EmailSorter for more productive email communication.
</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,168 @@
import { Link } from 'react-router-dom'
import { ArrowLeft, Shield } from 'lucide-react'
export function Privacy() {
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
{/* Header */}
<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 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>
</Link>
</div>
</header>
{/* Content */}
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-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 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 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 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 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 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 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 dark:text-slate-300 mb-2">
<strong>Contact:</strong><br />
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 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 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 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 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 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 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 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 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>
<li>Object to data processing</li>
<li>Data portability</li>
</ul>
<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 dark:text-slate-300 mb-4">
We use the following third-party services:
</p>
<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>
<li><strong>Gmail/Outlook API:</strong> Email access (with your explicit consent)</li>
<li><strong>Plausible (optional):</strong> Privacy-friendly analytics tool, if enabled</li>
</ul>
<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 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 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 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 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 underline">
support@webklar.com
</a>
</p>
<p className="text-slate-700 dark:text-slate-300 mt-2">
<strong>Phone:</strong>{' '}
<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 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 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 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 underline">
webklar.com/datenschutz
</a>
</p>
</div>
</div>
</div>
</div>
</main>
</div>
)
}

View File

@@ -0,0 +1,237 @@
import { useState, useEffect } from 'react'
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'
import { Badge } from '@/components/ui/badge'
import { Mail, Lock, User, ArrowRight, AlertCircle, Check, Sparkles } from 'lucide-react'
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('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { register, user } = useAuth()
const navigate = useNavigate()
// Capture UTM parameters on mount
useEffect(() => {
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('')
if (password !== confirmPassword) {
setError('Passwords do not match.')
return
}
if (password.length < 8) {
setError('Password must be at least 8 characters long.')
return
}
setLoading(true)
try {
await register(email, password, name)
navigate('/setup')
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Registration failed. Please try again.')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex">
{/* Left side - Decorative */}
<div className="hidden lg:flex lg:flex-1 bg-gradient-to-br from-slate-900 via-primary-900 to-slate-900 items-center justify-center p-12 relative overflow-hidden">
{/* Background pattern */}
<div className="absolute inset-0 gradient-mesh opacity-20" />
<div className="relative max-w-md">
<Badge className="mb-6 bg-accent-500/20 text-accent-300 border-accent-400/30">
<Sparkles className="w-3 h-3 mr-1" />
14-day free trial
</Badge>
<h2 className="text-4xl font-bold text-white mb-6">
Start with EmailSorter today
</h2>
<ul className="space-y-4 mb-8">
{[
'No credit card required',
'Gmail & Outlook support',
'AI-powered categorization',
'Cancel anytime',
].map((item, index) => (
<li key={index} className="flex items-center gap-3 text-slate-300">
<div className="w-6 h-6 rounded-full bg-accent-500/20 flex items-center justify-center">
<Check className="w-4 h-4 text-accent-400" />
</div>
{item}
</li>
))}
</ul>
{/* Plan indicator */}
<div className="bg-white/10 backdrop-blur rounded-xl p-4 border border-white/10">
<p className="text-sm text-slate-400 mb-1">Selected plan</p>
<p className="text-xl font-semibold text-white capitalize">{selectedPlan}</p>
</div>
</div>
</div>
{/* Right side - Form */}
<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 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 dark:text-slate-100 mb-2">
Create account
</h1>
<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 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>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="name">Name (optional)</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<Input
id="name"
type="text"
placeholder="John Smith"
value={name}
onChange={(e) => setName(e.target.value)}
className="pl-10"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email address</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<Input
id="email"
type="email"
placeholder="john@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="pl-10"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<Input
id="password"
type="password"
placeholder="At least 8 characters"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pl-10"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<Input
id="confirmPassword"
type="password"
placeholder="Repeat password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="pl-10"
required
/>
</div>
</div>
<Button type="submit" className="w-full" size="lg" disabled={loading}>
{loading ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
Get started free
<ArrowRight className="w-5 h-5 ml-2" />
</>
)}
</Button>
<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 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 dark:text-slate-400">
Already have an account?{' '}
<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>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,227 @@
import { useState, useEffect } from 'react'
import { Link, useSearchParams, useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { auth } from '@/lib/appwrite'
import { Mail, Loader2, CheckCircle, XCircle, Eye, EyeOff } from 'lucide-react'
export function ResetPassword() {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const userId = searchParams.get('userId')
const secret = searchParams.get('secret')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [loading, setLoading] = useState(false)
const [success, setSuccess] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (!userId || !secret) {
setError('Ungültiger oder abgelaufener Link')
}
}, [userId, secret])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (password !== confirmPassword) {
setError('Passwörter stimmen nicht überein')
return
}
if (password.length < 8) {
setError('Passwort muss mindestens 8 Zeichen lang sein')
return
}
if (!userId || !secret) {
setError('Ungültiger Link')
return
}
setLoading(true)
try {
await auth.resetPassword(userId, secret, password)
setSuccess(true)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Fehler beim Zurücksetzen des Passworts')
} finally {
setLoading(false)
}
}
// Password strength indicator
const getPasswordStrength = () => {
if (!password) return { strength: 0, label: '', color: '' }
let strength = 0
if (password.length >= 8) strength++
if (/[A-Z]/.test(password)) strength++
if (/[a-z]/.test(password)) strength++
if (/[0-9]/.test(password)) strength++
if (/[^A-Za-z0-9]/.test(password)) strength++
const levels = [
{ strength: 1, label: 'Sehr schwach', color: 'bg-red-500' },
{ strength: 2, label: 'Schwach', color: 'bg-orange-500' },
{ strength: 3, label: 'Mittel', color: 'bg-yellow-500' },
{ strength: 4, label: 'Stark', color: 'bg-green-500' },
{ strength: 5, label: 'Sehr stark', color: 'bg-green-600' },
]
return levels[strength - 1] || { strength: 0, label: '', color: '' }
}
const passwordStrength = getPasswordStrength()
return (
<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 dark:text-slate-100">
Email<span className="text-primary-600 dark:text-primary-400">Sorter</span>
</span>
</Link>
<Card className="shadow-xl border-0 dark:bg-slate-800 dark:border-slate-700">
<CardHeader className="text-center pb-2">
<CardTitle className="text-2xl dark:text-slate-100">
{success ? 'Passwort geändert!' : 'Neues Passwort festlegen'}
</CardTitle>
<CardDescription className="dark:text-slate-400">
{success
? 'Dein Passwort wurde erfolgreich geändert.'
: 'Wähle ein sicheres neues Passwort für deinen Account.'
}
</CardDescription>
</CardHeader>
<CardContent>
{success ? (
<div className="text-center py-8">
<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 dark:text-slate-400 mb-6">
Du kannst dich jetzt mit deinem neuen Passwort anmelden.
</p>
<Button onClick={() => navigate('/login')} className="w-full">
Zum Login
</Button>
</div>
) : !userId || !secret ? (
<div className="text-center py-8">
<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 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">
<Button className="w-full">Neuen Link anfordern</Button>
</Link>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<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" className="dark:text-slate-200">Neues Passwort</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
placeholder="••••••••"
value={password}
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 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>
</div>
{/* Password strength indicator */}
{password && (
<div className="space-y-1">
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((level) => (
<div
key={level}
className={`h-1 flex-1 rounded-full transition-colors ${
level <= passwordStrength.strength
? passwordStrength.color
: 'bg-slate-200 dark:bg-slate-700'
}`}
/>
))}
</div>
<p className={`text-xs ${
passwordStrength.strength < 3 ? 'text-red-500 dark:text-red-400' : 'text-green-600 dark:text-green-400'
}`}>
{passwordStrength.label}
</p>
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword" className="dark:text-slate-200">Passwort bestätigen</Label>
<Input
id="confirmPassword"
type={showPassword ? 'text' : 'password'}
placeholder="••••••••"
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 dark:text-red-400">Passwörter stimmen nicht überein</p>
)}
</div>
<Button
type="submit"
className="w-full"
disabled={loading || password !== confirmPassword || password.length < 8}
>
{loading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Wird gespeichert...
</>
) : (
'Passwort speichern'
)}
</Button>
</form>
)}
</CardContent>
</Card>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

647
client/src/pages/Setup.tsx Normal file
View File

@@ -0,0 +1,647 @@
import { useState, useEffect } from 'react'
import { useNavigate, Link, useSearchParams } from 'react-router-dom'
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,
ArrowLeft,
Check,
Sparkles,
ChevronRight,
Link2,
Settings,
Zap,
Loader2,
AlertCircle
} from 'lucide-react'
type Step = 'connect' | 'preferences' | 'categories' | 'complete'
export function Setup() {
const [searchParams] = useSearchParams()
const isFromCheckout = searchParams.get('subscription') === 'success'
const [currentStep, setCurrentStep] = useState<Step>('connect')
const [connectedProvider, setConnectedProvider] = useState<string | null>(null)
const [connectedEmail, setConnectedEmail] = useState<string | null>(null)
const [connecting, setConnecting] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [preferences, setPreferences] = useState({
sortingStrictness: 'medium',
historicalSync: true,
})
const [selectedCategories, setSelectedCategories] = useState<string[]>([
'vip', 'customers', 'invoices', 'newsletters', 'social'
])
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(() => {
if (isFromCheckout && user?.$id) {
const checkAccounts = async () => {
try {
const accountsRes = await api.getEmailAccounts(user.$id)
if (accountsRes.data && accountsRes.data.length > 0) {
// User already has accounts connected - redirect to dashboard
navigate('/dashboard?subscription=success&ready=true')
} else {
setCheckingAccounts(false)
}
} catch (err) {
console.error('Error checking accounts:', err)
setCheckingAccounts(false)
}
}
checkAccounts()
}
}, [isFromCheckout, user, navigate])
const steps: { id: Step; title: string; description: string }[] = [
{ id: 'connect', title: 'Connect email', description: 'Link your mailbox' },
{ id: 'complete', title: 'Done', description: 'Go to dashboard' },
]
const stepIndex = steps.findIndex(s => s.id === currentStep)
const handleConnectGmail = async () => {
if (!user?.$id) return
setConnecting('gmail')
setError(null)
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('complete')
await api.updateOnboardingStep(user.$id, 'see_results', ['connect'])
trackOnboardingStep(user.$id, 'first_rule')
trackProviderConnected(user.$id, 'gmail')
}
} catch {
setError('Gmail connection failed. Please try again.')
} finally {
setConnecting(null)
}
}
const handleConnectOutlook = async () => {
if (!user?.$id) return
setConnecting('outlook')
setError(null)
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('complete')
await api.updateOnboardingStep(user.$id, 'see_results', ['connect'])
}
} catch {
setError('Outlook connection failed. Please try again.')
} finally {
setConnecting(null)
}
}
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) {
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,
})
}
}
}
}
const handleBack = () => {
const prevIndex = stepIndex - 1
if (prevIndex >= 0) {
setCurrentStep(steps[prevIndex].id)
}
}
const handleComplete = async () => {
if (!user?.$id) {
navigate('/dashboard')
return
}
setSaving(true)
try {
await api.saveUserPreferences(user.$id, {
vipSenders: [],
blockedSenders: [],
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 {
setSaving(false)
navigate('/dashboard')
}
}
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' },
{ id: 'invoices', name: 'Invoices / Receipts', description: 'Financial documents', icon: '📄', color: 'bg-green-500' },
{ id: 'newsletters', name: 'Newsletter', description: 'Subscribed newsletters', icon: '📰', color: 'bg-purple-500' },
{ id: 'social', name: 'Social / Platforms', description: 'LinkedIn, Twitter, etc.', icon: '👥', color: 'bg-pink-500' },
{ id: 'security', name: 'Security / 2FA', description: 'Security codes', icon: '🔐', color: 'bg-red-500' },
{ id: 'calendar', name: 'Calendar / Events', description: 'Appointments & invites', icon: '📅', color: 'bg-indigo-500' },
{ id: 'promotions', name: 'Promotions / Deals', description: 'Marketing emails', icon: '🏷️', color: 'bg-orange-500' },
]
const toggleCategory = (id: string) => {
setSelectedCategories(prev =>
prev.includes(id)
? prev.filter(c => c !== id)
: [...prev, id]
)
}
// Show loading while checking accounts
if (checkingAccounts) {
return (
<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 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 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 dark:text-slate-100">
E-Mail-<span className="text-primary-600 dark:text-primary-400">Sorter</span>
</span>
</Link>
<Button variant="ghost" onClick={handleSkipOnboarding}>
Skip
</Button>
</div>
</div>
</header>
{/* 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 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 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>
</div>
</div>
)}
<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">
{steps.map((step, index) => (
<div key={step.id} className="flex items-center">
<div className="flex flex-col items-center">
<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 dark:bg-green-600 text-white shadow-lg shadow-green-500/30'
: index === stepIndex
? '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 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 dark:bg-green-600' : 'bg-slate-200 dark:bg-slate-700'
}`} />
)}
</div>
))}
</div>
</div>
{error && (
<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>
)}
<div className="mb-8">
{currentStep === 'connect' && (
<div className="text-center">
<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 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="space-y-4 max-w-lg mx-auto">
{/* Try Demo - Prominent Option */}
<button
onClick={handleConnectDemo}
disabled={connecting !== null}
className="w-full flex items-center gap-4 p-6 bg-gradient-to-r from-primary-500 to-primary-600 text-white rounded-2xl border-2 border-primary-400 hover:border-primary-300 hover:shadow-2xl hover:shadow-primary-500/30 transition-all text-left group disabled:opacity-50 disabled:cursor-not-allowed"
>
{connecting === 'demo' ? (
<Loader2 className="w-12 h-12 animate-spin text-white" />
) : (
<div className="w-12 h-12 rounded-xl bg-white/20 flex items-center justify-center group-hover:bg-white/30 transition-colors">
<Sparkles className="w-7 h-7 text-white" />
</div>
)}
<div className="flex-1">
<p className="font-semibold text-white text-lg">Try Demo</p>
<p className="text-sm text-primary-100">See how it works without connecting your account</p>
</div>
<ChevronRight className="w-5 h-5 text-white/80 group-hover:text-white group-hover:translate-x-1 transition-all" />
</button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-slate-300 dark:border-slate-600"></div>
</div>
<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 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>
</div>
)}
{currentStep === 'preferences' && (
<div>
<div className="text-center mb-10">
<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 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 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 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: '🌱' },
{ id: 'medium', name: 'Medium', desc: 'Balanced sorting', emoji: '⚖️' },
{ id: 'strict', name: 'Strict', desc: 'Inbox nearly empty', emoji: '🎯' },
].map((option) => (
<button
key={option.id}
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 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 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 dark:from-slate-800 dark:to-slate-700 rounded-xl">
<div>
<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 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 dark:bg-slate-200 rounded-full shadow-md transition-transform duration-300 ${
preferences.historicalSync ? 'translate-x-7' : 'translate-x-1'
}`} />
</button>
</div>
</CardContent>
</Card>
</div>
)}
{currentStep === 'categories' && (
<div>
<div className="text-center mb-10">
<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 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>
<div className="grid sm:grid-cols-2 gap-4 max-w-2xl mx-auto">
{categories.map((category) => (
<button
key={category.id}
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 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 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 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>
</button>
))}
</div>
<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>
)}
{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 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 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 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 dark:text-slate-100 text-lg">
{connectedProvider === 'gmail' ? 'Gmail' : connectedProvider === 'outlook' ? 'Outlook' : 'Email'} connected
</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>
<Button size="lg" onClick={handleComplete} disabled={saving} className="text-lg px-8 py-6 shadow-xl shadow-primary-500/20">
{saving ? (
<>
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
Saving...
</>
) : (
<>
Go to Dashboard
<ArrowRight className="w-5 h-5 ml-2" />
</>
)}
</Button>
</div>
)}
</div>
{currentStep !== 'connect' && currentStep !== 'complete' && (
<div className="flex justify-between max-w-lg mx-auto">
<Button variant="ghost" onClick={handleBack} className="text-slate-600 dark:text-slate-400">
<ArrowLeft className="w-5 h-5 mr-2" />
Back
</Button>
<Button onClick={handleNext} className="shadow-lg shadow-primary-500/20">
Next
<ArrowRight className="w-5 h-5 ml-2" />
</Button>
</div>
)}
</main>
</div>
)
}

View File

@@ -0,0 +1,154 @@
import { useState, useEffect } from 'react'
import { Link, useSearchParams, useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { auth } from '@/lib/appwrite'
import { Mail, Loader2, CheckCircle, XCircle, RefreshCw } from 'lucide-react'
export function VerifyEmail() {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const userId = searchParams.get('userId')
const secret = searchParams.get('secret')
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
const [error, setError] = useState('')
useEffect(() => {
verifyEmail()
}, [userId, secret])
const verifyEmail = async () => {
if (!userId || !secret) {
setStatus('error')
setError('Ungültiger Verifizierungslink')
return
}
try {
await auth.verifyEmail(userId, secret)
setStatus('success')
} catch (err: unknown) {
setStatus('error')
setError(err instanceof Error ? err.message : 'Fehler bei der Verifizierung')
}
}
const handleResendVerification = async () => {
setStatus('loading')
setError('')
try {
await auth.sendVerification()
setError('')
alert('Neue Verifizierungs-E-Mail wurde gesendet!')
} 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 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 dark:text-slate-100">
Email<span className="text-primary-600 dark:text-primary-400">Sorter</span>
</span>
</Link>
<Card className="shadow-xl border-0 dark:bg-slate-800 dark:border-slate-700">
<CardHeader className="text-center pb-2">
<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 className="dark:text-slate-400">
{status === 'loading' && 'Bitte warte einen Moment.'}
{status === 'success' && 'Deine E-Mail-Adresse wurde erfolgreich bestätigt.'}
{status === 'error' && error}
</CardDescription>
</CardHeader>
<CardContent>
{status === 'loading' && (
<div className="flex flex-col items-center py-12">
<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 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 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 dark:text-slate-400">
Du kannst jetzt alle Features von EmailSorter nutzen.
</p>
<Button onClick={() => navigate('/dashboard')} className="w-full">
Zum Dashboard
</Button>
</div>
</div>
)}
{status === 'error' && (
<div className="text-center py-8">
<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 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 dark:text-slate-400 text-sm">
Falls dein Link abgelaufen ist, kannst du eine neue Verifizierungs-E-Mail anfordern.
</p>
<div className="space-y-3">
<Button onClick={handleResendVerification} variant="outline" className="w-full">
<RefreshCw className="w-4 h-4 mr-2" />
Neue E-Mail senden
</Button>
<Button onClick={() => navigate('/login')} variant="ghost" className="w-full">
Zurück zum Login
</Button>
</div>
</div>
</div>
)}
</CardContent>
</Card>
{/* Help text */}
<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 dark:text-primary-400 hover:underline">
support@emailsorter.de
</a>
</p>
</div>
</div>
)
}

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
}

34
client/tsconfig.app.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Path alias */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
client/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
client/tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

27
client/vite.config.ts Normal file
View File

@@ -0,0 +1,27 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
'/stripe': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
})

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,186 @@
# Git Authentication Problem - Lösung
## Problem
"Authentication failed" beim Push zu Gitea (git.webklar.com)
## ⚡ Quick Start (Schnellste Lösung)
1. **Gehe zu:** https://git.webklar.com/user/settings/applications
2. **Klicke auf:** "Generate New Token"
3. **Name:** `GitHub Desktop`
4. **Scopes:** Aktiviere `repo` (oder alle)
5. **Kopiere den Token** (wird nur einmal angezeigt!)
6. **In GitHub Desktop:** File → Options → Accounts → Sign in with token
---
## Lösung für GitHub Desktop
### Option 1: Token erneuern (Empfohlen)
1. **Gehe zu Gitea:**
- Öffne: https://git.webklar.com
- Logge dich ein
2. **Erstelle neues Token:**
**Weg 1 (Empfohlen):**
- Klicke auf dein **Profilbild/Avatar** (oben rechts)
- Klicke auf **Settings**
- Im linken Menü: Klicke auf **Applications**
- Unter "Manage Access Tokens": Klicke auf **Generate New Token**
**Weg 2 (Alternative):**
- Gehe direkt zu: https://git.webklar.com/user/settings/applications
- Unter "Manage Access Tokens": Klicke auf **Generate New Token**
**Token konfigurieren:**
- **Token Name:** `GitHub Desktop` (oder ein anderer Name)
- **Scopes:** Aktiviere **`repo`** (alle Repository-Berechtigungen)
- Falls `repo` nicht sichtbar ist, aktiviere alle verfügbaren Scopes
- Klicke auf **Generate Token**
- **WICHTIG:** Kopiere den Token **sofort** (wird nur einmal angezeigt!)
- Der Token beginnt normalerweise mit `gitea_` oder ähnlich
3. **In GitHub Desktop:**
- **File** → **Options****Accounts**
- Entferne den alten Account (falls vorhanden)
- Klicke auf **Sign in****Sign in with a token**
- Oder: **Sign in with your browser**
- Wenn Token nötig: Füge den Token ein
4. **Teste:**
- Versuche einen Push
- Sollte jetzt funktionieren
---
### Option 2: Repository neu hinzufügen
Falls Option 1 nicht funktioniert:
1. **In GitHub Desktop:**
- **File** → **Add Local Repository**
- Wähle deinen Ordner: `C:\Users\User\Documents\GitHub\ANDJJJJJJ`
- GitHub Desktop sollte nach Credentials fragen
2. **Oder Repository-Clone:**
- **File** → **Clone Repository**
- URL: `https://git.webklar.com/knso/EmailSorter`
- Wähle lokalen Pfad
- Logge dich mit Token ein
---
### Option 3: Remote URL prüfen
Falls das Repository umbenannt wurde:
1. **In GitHub Desktop:**
- Rechtsklick auf Repository → **Repository Settings**
- Prüfe **Remote Repository URL**
- Sollte sein: `https://git.webklar.com/knso/EmailSorter`
- Falls falsch: Aktualisiere auf die richtige URL
2. **Oder manuell:**
- Öffne `.git/config` im Editor
- Prüfe die `url` unter `[remote "origin"]`
- Sollte sein: `https://git.webklar.com/knso/EmailSorter`
---
### Option 4: Token in URL einbetten (Temporär)
**⚠️ Nur als letzte Lösung!**
1. **Token erstellen** (siehe Option 1, Schritt 2)
2. **Remote URL aktualisieren:**
- In GitHub Desktop: **Repository Settings****Remote Repository URL**
- Ändere zu: `https://DEIN_TOKEN@git.webklar.com/knso/EmailSorter`
- Ersetze `DEIN_TOKEN` mit deinem Token
3. **Oder manuell in `.git/config`:**
```
[remote "origin"]
url = https://DEIN_TOKEN@git.webklar.com/knso/EmailSorter
```
**⚠️ WICHTIG:** Token wird im Klartext gespeichert! Nicht für öffentliche Repositories!
---
## Wo finde ich "Applications" in Gitea?
Falls du "Applications" nicht findest:
1. **Prüfe die URL:**
- Nach dem Login sollte die URL sein: `https://git.webklar.com/`
- Klicke auf dein **Profilbild** (oben rechts, neben der Suchleiste)
- Ein Dropdown-Menü öffnet sich
- Klicke auf **"Settings"** oder **"Your Settings"**
2. **Im Settings-Menü:**
- Links siehst du ein Menü mit verschiedenen Optionen
- Suche nach **"Applications"** oder **"Access Tokens"**
- Falls nicht sichtbar: Prüfe, ob du die richtigen Berechtigungen hast
3. **Direkter Link (falls verfügbar):**
- Versuche: `https://git.webklar.com/user/settings/applications`
- Oder: `https://git.webklar.com/user/settings/tokens`
4. **Falls immer noch nicht sichtbar:**
- Frage deinen Kumpel (Repository-Admin), ob er dir die Berechtigung geben kann
- Oder nutze **Option 4** (Token in URL einbetten) als Alternative
---
## Häufige Probleme
### Problem: "Repository not found"
- **Lösung:** Prüfe, ob das Repository auf Gitea existiert
- Prüfe URL: https://git.webklar.com/knso/EmailSorter
- Falls nicht existiert: Erstelle es auf Gitea oder verwende den alten Namen
### Problem: "Permission denied"
- **Lösung:** Prüfe, ob du Schreibrechte auf das Repository hast
- Frage deinen Kumpel, ob er dir `write` oder `admin` Rechte gegeben hat
### Problem: "Token expired"
- **Lösung:** Erstelle neues Token (siehe Option 1)
---
## Schnell-Checkliste
- [ ] Bin ich auf Gitea eingeloggt?
- [ ] Existiert das Repository `EmailSorter` auf Gitea?
- [ ] Habe ich Schreibrechte auf das Repository?
- [ ] Ist mein Token noch gültig?
- [ ] Ist die Remote URL korrekt?
- [ ] Habe ich GitHub Desktop neu gestartet?
---
## Hilfe
Falls nichts funktioniert:
1. **Prüfe Gitea direkt:**
- Gehe zu: https://git.webklar.com/knso/EmailSorter
- Kannst du das Repository sehen?
- Hast du Schreibrechte?
2. **Frage deinen Kumpel:**
- Hat er das Repository umbenannt?
- Hat er dir die richtigen Rechte gegeben?
- Funktioniert Push bei ihm?
3. **Alternative:**
- Erstelle neues Repository auf Gitea
- Clone es neu
- Kopiere deine Dateien rein
---
**Meistens hilft:** Neues Token erstellen und in GitHub Desktop neu einloggen! 🔑

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

@@ -0,0 +1,115 @@
# Projekt Umbenennung: ANDJJJJJJ → EmailSorter
## ✅ Automatisch erledigt
Die folgenden Dateien wurden bereits aktualisiert:
1.**Git Remote URL** - Aktualisiert in `.git/config`
- Alt: `https://git.webklar.com/knso/ANDJJJJJJ`
- Neu: `https://git.webklar.com/knso/EmailSorter`
2.**Client package.json** - Name aktualisiert
- Alt: `"name": "client"`
- Neu: `"name": "emailsorter-client"`
3.**README.md** - Bereits korrekt (verwendet "EmailSorter")
## 📁 Manuelle Schritte (mit GitHub Desktop)
### Schritt 1: Repository auf Server umbenennen (falls noch nicht geschehen)
1. Gehe zu `https://git.webklar.com/knso/ANDJJJJJJ`
2. Benenne das Repository in "EmailSorter" um
3. Oder erstelle ein neues Repository "EmailSorter" und pushe den Code dorthin
### Schritt 2: Lokalen Ordner umbenennen
**Option A: Mit Windows Explorer**
1. Schließe alle Terminals/Editoren, die auf den Ordner zugreifen
2. Gehe zu `C:\Users\User\Documents\GitHub\`
3. Rechtsklick auf `ANDJJJJJJ` → Umbenennen
4. Benenne um zu `EmailSorter`
**Option B: Mit PowerShell**
```powershell
# Schließe alle Prozesse, die auf den Ordner zugreifen
# Dann:
cd C:\Users\User\Documents\GitHub
Rename-Item -Path "ANDJJJJJJ" -NewName "EmailSorter"
```
### Schritt 3: GitHub Desktop aktualisieren
1. Öffne GitHub Desktop
2. Klicke auf **File****Add Local Repository**
3. Wähle den umbenannten Ordner `C:\Users\User\Documents\GitHub\EmailSorter`
4. Oder: Wenn das Repository bereits in GitHub Desktop ist:
- Rechtsklick auf das Repository → **Repository Settings**
- Aktualisiere den **Local Path** auf den neuen Pfad
### Schritt 4: Git Remote URL verifizieren
In GitHub Desktop:
1. Öffne **Repository****Repository Settings****Remote**
2. Stelle sicher, dass die URL `https://git.webklar.com/knso/EmailSorter` ist
3. Falls nicht, aktualisiere sie manuell
Oder im Terminal:
```bash
cd C:\Users\User\Documents\GitHub\EmailSorter
git remote -v
```
Sollte zeigen:
```
origin https://git.webklar.com/knso/EmailSorter (fetch)
origin https://git.webklar.com/knso/EmailSorter (push)
```
### Schritt 5: Testen
1. Öffne ein neues Terminal im umbenannten Ordner
2. Teste Git:
```bash
git status
git remote -v
```
3. Teste die App:
```bash
cd client
npm run dev
```
## ⚠️ Wichtig
- **Schließe alle Terminals/Editoren** bevor du den Ordner umbenennst
- **Backup erstellen** (optional, aber empfohlen)
- **Git History bleibt erhalten** - keine Sorge, die Commits gehen nicht verloren
## ✅ Checkliste
- [ ] Repository auf Server umbenannt (oder neues Repository erstellt)
- [ ] Lokaler Ordner umbenannt
- [ ] GitHub Desktop aktualisiert
- [ ] Git Remote URL verifiziert
- [ ] App getestet (client und server starten)
## 🆘 Falls etwas schief geht
1. **Git Remote URL zurücksetzen:**
```bash
git remote set-url origin https://git.webklar.com/knso/EmailSorter
```
2. **GitHub Desktop neu einrichten:**
- Entferne das alte Repository
- Füge den umbenannten Ordner neu hinzu
3. **Falls der Ordner nicht umbenannt werden kann:**
- Stelle sicher, dass alle Prozesse geschlossen sind
- Prüfe, ob Dateien geöffnet sind
- Versuche es als Administrator
---
**Fertig!** Dein Projekt heißt jetzt "EmailSorter" 🎉

View File

@@ -0,0 +1,151 @@
# Projekt-Überprüfung: EmailSorter
**Datum:** 2026-01-20
**Status:** ⚠️ Mehrere Probleme gefunden
---
## ✅ Was gut ist
1. **Git Konfiguration** - Remote URL korrekt auf EmailSorter aktualisiert
2. **Package.json Dateien** - Namen korrekt (emailsorter-client, email-sorter-server)
3. **README.md** - Verwendet bereits "EmailSorter"
4. **Environment Files** - Keine hardcoded Secrets in .env.example
5. **Haupt-Bootstrap** - bootstrap-v2.mjs ist aktuell und korrekt
---
## 🔴 KRITISCHE Probleme
### 1. Hardcoded API Keys (Sicherheitsrisiko!)
**Gefunden in:**
- `setup-appwrite.ps1` (Zeilen 5-6)
- `server/cleanup.mjs` (Zeilen 5-6)
**Problem:**
- API Keys sind direkt im Code hardcoded
- Werden ins Git Repository committed
- Können von jedem eingesehen werden
**Lösung:**
- API Keys aus Code entfernen
- Stattdessen aus `.env` Datei oder Umgebungsvariablen lesen
- Falls bereits committed: API Keys im Appwrite Dashboard rotieren!
---
## ⚠️ WICHTIGE Probleme
### 2. Veraltete setup-appwrite.ps1
**Problem:**
- Verwendet `bootstrap-appwrite.mjs` (alt) statt `bootstrap-v2.mjs` (neu)
- Verwendet veraltete Umgebungsvariablen (DB_ID, TABLE_*)
- Nachricht spricht von "13 questions seeded" (nicht mehr relevant)
- Enthält hardcoded API Keys
**Lösung:**
- Datei aktualisieren oder als veraltet markieren
- Sollte `bootstrap-v2.mjs` verwenden
- API Keys aus `.env` lesen
### 3. Veraltete Referenzen zu bootstrap-appwrite.mjs
**Gefunden in:**
- `server/package.json` - Script "bootstrap" verweist noch auf alte Datei
- `server/verify-setup.mjs` - Prüft alte Datei
- `server/MANUAL_TEST_CHECKLIST.md` - Erwähnt alte Datei
- `server/TASK_4_COMPLETION_SUMMARY.md` - Erwähnt alte Datei
- `TASK_5_COMPLETION.md` - Erwähnt alte Datei
**Lösung:**
- Dokumentation aktualisieren
- package.json Script kann bleiben (für Rückwärtskompatibilität), aber sollte auf v2 verweisen
### 4. server/cleanup.mjs - Hardcoded Credentials
**Problem:**
- Enthält hardcoded Appwrite Project ID und API Key
- Sollte aus Umgebungsvariablen lesen
**Lösung:**
- Umstellen auf `dotenv` und Umgebungsvariablen
---
## 📝 KLEINERE Probleme / Verbesserungen
### 5. starter-for-react Ordner
**Frage:**
- Ist dieser Ordner noch benötigt?
- Scheint ein altes Template zu sein
- Kann möglicherweise entfernt werden
### 6. Konsistenz in Dokumentation
**Problem:**
- Einige MD-Dateien erwähnen noch `bootstrap-appwrite.mjs`
- Sollten auf `bootstrap-v2.mjs` verweisen
---
## ✅ Empfohlene Aktionen
### Sofort (Sicherheit):
1. **API Keys rotieren** in Appwrite Dashboard
- Alte Keys sind bereits im Git Repository sichtbar
- Neue Keys erstellen und alte deaktivieren
2. **Hardcoded Keys entfernen** aus:
- `setup-appwrite.ps1`
- `server/cleanup.mjs`
3. **.gitignore prüfen** - Sicherstellen, dass `.env` Dateien nicht committed werden
### Kurzfristig:
4. **setup-appwrite.ps1 aktualisieren** oder entfernen
5. **cleanup.mjs** auf Umgebungsvariablen umstellen
6. **Dokumentation aktualisieren** (bootstrap-appwrite → bootstrap-v2)
### Optional:
7. **starter-for-react** Ordner prüfen (entfernen falls nicht benötigt)
8. **Veraltete Dokumentation** aufräumen
---
## 📋 Checkliste
- [ ] API Keys in Appwrite rotiert
- [ ] Hardcoded Keys aus setup-appwrite.ps1 entfernt
- [ ] Hardcoded Keys aus cleanup.mjs entfernt
- [ ] setup-appwrite.ps1 aktualisiert oder entfernt
- [ ] cleanup.mjs auf .env umgestellt
- [ ] Dokumentation aktualisiert
- [ ] .gitignore geprüft
- [ ] starter-for-react geprüft/entfernt
---
## 🔍 Weitere Prüfungen
### Konfigurationsdateien:
-`client/package.json` - Korrekt
-`server/package.json` - Korrekt
-`.git/config` - Korrekt (EmailSorter)
-`README.md` - Korrekt
-`server/env.example` - Keine Secrets
-`client/env.example` - Keine Secrets
### Projektstruktur:
- ✅ Alle wichtigen Ordner vorhanden
- ✅ Bootstrap-Skripte vorhanden
- ✅ Dokumentation vorhanden
---
**Nächste Schritte:** Siehe "Empfohlene Aktionen" oben.

View File

@@ -0,0 +1,3 @@
VITE_APPWRITE_ENDPOINT=
VITE_APPWRITE_PROJECT_ID=
VITE_APPWRITE_PROJECT_NAME=

26
docs/examples/.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env

21
docs/examples/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Appwrite
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

26
docs/examples/README.md Normal file
View File

@@ -0,0 +1,26 @@
# React starter kit with Appwrite
Kickstart your React development with this ready-to-use starter project integrated with [Appwrite](https://www.appwrite.io)
## 🚀Getting started
###
Clone the Project
Clone this repository to your local machine using Git:
`git clone https://github.com/appwrite/starter-for-react`
## 🛠️ Development guid
1. **Configure Appwrite**<br/>
Navigate to `.env` and update the values to match your Appwrite project credentials.
2. **Customize as needed**<br/>
Modify the starter kit to suit your app's requirements. Adjust UI, features, or backend
integrations as per your needs.
3. **Install dependencies**<br/>
Run `npm install` to install all dependencies.
4. **Run the app**<br/>
Start the project by running `npm run dev`.
## 💡 Additional notes
- This starter project is designed to streamline your React development with Appwrite.
- Refer to the [Appwrite documentation](https://appwrite.io/docs) for detailed integration guidance.

View File

@@ -0,0 +1,38 @@
import js from '@eslint/js'
import globals from 'globals'
import react from 'eslint-plugin-react'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
export default [
{ ignores: ['dist'] },
{
files: ['**/*.{js,jsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
settings: { react: { version: '18.3' } },
plugins: {
react,
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...js.configs.recommended.rules,
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
...reactHooks.configs.recommended.rules,
'react/jsx-no-target-blank': 'off',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
]

19
docs/examples/index.html Normal file
View File

@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin />
<link
href="https://fonts.googleapis.com/css2?family=Fira+Code&family=Inter:opsz,wght@14..32,100..900&family=Poppins:wght@300;400&display=swap"
rel="stylesheet"
/>
<link rel="icon" type="image/svg+xml" href="/appwrite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Appwrite + React</title>
</head>
<body class="bg-[#FAFAFB] font-[Inter] text-sm text-[#56565C]">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

5087
docs/examples/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
{
"name": "react-starter-kit-for-appwrite",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@appwrite.io/pink-icons": "^1.0.0",
"@tailwindcss/vite": "^4.0.14",
"appwrite": "^21.2.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwindcss": "^4.0.14"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.19.0",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.18",
"globals": "^15.14.0",
"prettier": "3.5.3",
"vite": "^6.1.0"
}
}

View File

@@ -0,0 +1,8 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M24.4429 16.4322V21.9096H10.7519C6.76318 21.9096 3.28044 19.7067 1.4171 16.4322C1.14622 15.9561 0.909137 15.4567 0.710264 14.9383C0.319864 13.9225 0.0744552 12.8325 0 11.6952V10.2143C0.0161646 9.96089 0.0416361 9.70942 0.0749451 9.46095C0.143032 8.95105 0.245898 8.45211 0.381093 7.96711C1.66006 3.36909 5.81877 0 10.7519 0C15.6851 0 19.8433 3.36909 21.1223 7.96711H15.2682C14.3072 6.4683 12.6437 5.4774 10.7519 5.4774C8.86017 5.4774 7.19668 6.4683 6.23562 7.96711C5.9427 8.42274 5.71542 8.92516 5.56651 9.46095C5.43425 9.93599 5.36371 10.4369 5.36371 10.9548C5.36371 12.5248 6.01324 13.94 7.05463 14.9383C8.01961 15.865 9.32061 16.4322 10.7519 16.4322H24.4429Z"
fill="#FD366E" />
<path
d="M24.4429 9.46094V14.9383H14.4492C15.4906 13.94 16.1401 12.5248 16.1401 10.9548C16.1401 10.4369 16.0696 9.93598 15.9373 9.46094H24.4429Z"
fill="#FD366E" />
</svg>

After

Width:  |  Height:  |  Size: 1012 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true" role="img" className="iconify iconify--logos" width="35.93" height="32"
preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228">
<path fill="#00D8FF"
d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

20
docs/examples/src/App.css Normal file
View File

@@ -0,0 +1,20 @@
@import "tailwindcss";
summary::-webkit-details-marker {
display: none
}
.checker-background::before {
content: "";
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-image: linear-gradient(#e6e6e690 1px, transparent 1px),
linear-gradient(90deg, #e6e6e690 1px, transparent 1px);
background-size: 3.7em 3.7em;
mask-image: radial-gradient(ellipse at 50% 40%, black 0%, transparent 55%);
z-index: -1;
background-position-x: center;
}

311
docs/examples/src/App.jsx Normal file
View File

@@ -0,0 +1,311 @@
import { useState, useRef, useEffect, useCallback } from "react";
import "./App.css";
import { client } from "./lib/appwrite";
import { AppwriteException } from "appwrite";
import AppwriteSvg from "../public/appwrite.svg";
import ReactSvg from "../public/react.svg";
function App() {
const [detailHeight, setDetailHeight] = useState(55);
const [logs, setLogs] = useState([]);
const [status, setStatus] = useState("idle");
const [showLogs, setShowLogs] = useState(false);
const detailsRef = useRef(null);
const updateHeight = useCallback(() => {
if (detailsRef.current) {
setDetailHeight(detailsRef.current.clientHeight);
}
}, [logs, showLogs]);
useEffect(() => {
updateHeight();
window.addEventListener("resize", updateHeight);
return () => window.removeEventListener("resize", updateHeight);
}, [updateHeight]);
useEffect(() => {
if (!detailsRef.current) return;
detailsRef.current.addEventListener("toggle", updateHeight);
return () => {
if (!detailsRef.current) return;
detailsRef.current.removeEventListener("toggle", updateHeight);
};
}, []);
async function sendPing() {
if (status === "loading") return;
setStatus("loading");
try {
const result = await client.ping();
const log = {
date: new Date(),
method: "GET",
path: "/v1/ping",
status: 200,
response: JSON.stringify(result),
};
setLogs((prevLogs) => [log, ...prevLogs]);
setStatus("success");
} catch (err) {
const log = {
date: new Date(),
method: "GET",
path: "/v1/ping",
status: err instanceof AppwriteException ? err.code : 500,
response:
err instanceof AppwriteException
? err.message
: "Something went wrong",
};
setLogs((prevLogs) => [log, ...prevLogs]);
setStatus("error");
}
setShowLogs(true);
}
return (
<main
className="checker-background flex flex-col items-center p-5"
style={{ marginBottom: `${detailHeight}px` }}
>
<div className="mt-25 flex w-full max-w-[40em] items-center justify-center lg:mt-34">
<div className="rounded-[25%] border border-[#19191C0A] bg-[#F9F9FA] p-3 shadow-[0px_9.36px_9.36px_0px_hsla(0,0%,0%,0.04)]">
<div className="rounded-[25%] border border-[#FAFAFB] bg-white p-5 shadow-[0px_2px_12px_0px_hsla(0,0%,0%,0.03)] lg:p-9">
<img
alt={"React logo"}
src={ReactSvg}
className="h-14 w-14"
width={56}
height={56}
/>
</div>
</div>
<div
className={`flex w-38 items-center transition-opacity duration-2500 ${status === "success" ? "opacity-100" : "opacity-0"}`}
>
<div className="to-[rgba(253, 54, 110, 0.15)] h-[1px] flex-1 bg-gradient-to-l from-[#f02e65]"></div>
<div className="icon-check flex h-5 w-5 items-center justify-center rounded-full border border-[#FD366E52] bg-[#FD366E14] text-[#FD366E]"></div>
<div className="to-[rgba(253, 54, 110, 0.15)] h-[1px] flex-1 bg-gradient-to-r from-[#f02e65]"></div>
</div>
<div className="rounded-[25%] border border-[#19191C0A] bg-[#F9F9FA] p-3 shadow-[0px_9.36px_9.36px_0px_hsla(0,0%,0%,0.04)]">
<div className="rounded-[25%] border border-[#FAFAFB] bg-white p-5 shadow-[0px_2px_12px_0px_hsla(0,0%,0%,0.03)] lg:p-9">
<img
alt={"Appwrite logo"}
src={AppwriteSvg}
className="h-14 w-14"
width={56}
height={56}
/>
</div>
</div>
</div>
<section className="mt-12 flex h-52 flex-col items-center">
{status === "loading" ? (
<div className="flex flex-row gap-4">
<div role="status">
<svg
aria-hidden="true"
className="h-5 w-5 animate-spin fill-[#FD366E] text-gray-200 dark:text-gray-600"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span className="sr-only">Loading...</span>
</div>
<span>Waiting for connection...</span>
</div>
) : status === "success" ? (
<h1 className="font-[Poppins] text-2xl font-light text-[#2D2D31]">
Congratulations!
</h1>
) : (
<h1 className="font-[Poppins] text-2xl font-light text-[#2D2D31]">
Check connection
</h1>
)}
<p className="mt-2 mb-8">
{status === "success" ? (
<span>You connected your app successfully.</span>
) : status === "error" || status === "idle" ? (
<span>Send a ping to verify the connection</span>
) : null}
</p>
<button
onClick={sendPing}
className={`cursor-pointer rounded-md bg-[#FD366E] px-2.5 py-1.5 ${status === "loading" ? "hidden" : "visible"}`}
>
<span className="text-white">Send a ping</span>
</button>
</section>
<div className="grid grid-rows-3 gap-7 lg:grid-cols-3 lg:grid-rows-none">
<div className="flex h-full w-72 flex-col gap-2 rounded-md border border-[#EDEDF0] bg-white p-4">
<h2 className="text-xl font-light text-[#2D2D31]">Edit your app</h2>
<p>
Edit{" "}
<code className="rounded-sm bg-[#EDEDF0] p-1">app/page.js</code> to
get started with building your app.
</p>
</div>
<a
href="https://cloud.appwrite.io"
target="_blank"
rel="noopener noreferrer"
>
<div className="flex h-full w-72 flex-col gap-2 rounded-md border border-[#EDEDF0] bg-white p-4">
<div className="flex flex-row items-center justify-between">
<h2 className="text-xl font-light text-[#2D2D31]">
Go to console
</h2>
<span className="icon-arrow-right text-[#D8D8DB]"></span>
</div>
<p>
Navigate to the console to control and oversee the Appwrite
services.
</p>
</div>
</a>
<a
href="https://appwrite.io/docs"
target="_blank"
rel="noopener noreferrer"
>
<div className="flex h-full w-72 flex-col gap-2 rounded-md border border-[#EDEDF0] bg-white p-4">
<div className="flex flex-row items-center justify-between">
<h2 className="text-xl font-light text-[#2D2D31]">
Explore docs
</h2>
<span className="icon-arrow-right text-[#D8D8DB]"></span>
</div>
<p>
Discover the full power of Appwrite by diving into our
documentation.
</p>
</div>
</a>
</div>
<aside className="fixed bottom-0 flex w-full cursor-pointer border-t border-[#EDEDF0] bg-white">
<details open={showLogs} ref={detailsRef} className={"w-full"}>
<summary className="flex w-full flex-row justify-between p-4 marker:content-none">
<div className="flex gap-2">
<span className="font-semibold">Logs</span>
{logs.length > 0 && (
<div className="flex items-center rounded-md bg-[#E6E6E6] px-2">
<span className="font-semibold">{logs.length}</span>
</div>
)}
</div>
<div className="icon">
<span className="icon-cheveron-down" aria-hidden="true"></span>
</div>
</summary>
<div className="flex w-full flex-col lg:flex-row">
<div className="flex flex-col border-r border-[#EDEDF0]">
<div className="border-y border-[#EDEDF0] bg-[#FAFAFB] px-4 py-2 text-[#97979B]">
Project
</div>
<div className="grid grid-cols-2 gap-4 p-4">
<div className="flex flex-col">
<span className="text-[#97979B]">Endpoint</span>
<span className="truncate">
{import.meta.env.VITE_APPWRITE_ENDPOINT}
</span>
</div>
<div className="flex flex-col">
<span className="text-[#97979B]">Project-ID</span>
<span className="truncate">
{import.meta.env.VITE_APPWRITE_PROJECT_ID}
</span>
</div>
<div className="flex flex-col">
<span className="text-[#97979B]">Project name</span>
<span className="truncate">
{import.meta.env.VITE_APPWRITE_PROJECT_NAME}
</span>
</div>
</div>
</div>
<div className="flex-grow">
<table className="w-full">
<thead>
<tr className="border-y border-[#EDEDF0] bg-[#FAFAFB] text-[#97979B]">
{logs.length > 0 ? (
<>
<td className="w-52 py-2 pl-4">Date</td>
<td>Status</td>
<td>Method</td>
<td className="hidden lg:table-cell">Path</td>
<td className="hidden lg:table-cell">Response</td>
</>
) : (
<>
<td className="py-2 pl-4">Logs</td>
</>
)}
</tr>
</thead>
<tbody>
{logs.length > 0 ? (
logs.map((log) => (
<tr>
<td className="py-2 pl-4 font-[Fira_Code]">
{log.date.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</td>
<td>
{log.status > 400 ? (
<div className="w-fit rounded-sm bg-[#FF453A3D] px-1 text-[#B31212]">
{log.status}
</div>
) : (
<div className="w-fit rounded-sm bg-[#10B9813D] px-1 text-[#0A714F]">
{log.status}
</div>
)}
</td>
<td>{log.method}</td>
<td className="hidden lg:table-cell">{log.path}</td>
<td className="hidden font-[Fira_Code] lg:table-cell">
{log.response}
</td>
</tr>
))
) : (
<tr>
<td className="py-2 pl-4 font-[Fira_Code]">
There are no logs to show
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</details>
</aside>
</main>
);
}
export default App;

View File

@@ -0,0 +1,10 @@
import { Client, Account, Databases } from "appwrite";
const client = new Client()
.setEndpoint(import.meta.env.VITE_APPWRITE_ENDPOINT)
.setProject(import.meta.env.VITE_APPWRITE_PROJECT_ID);
const account = new Account(client);
const databases = new Databases(client);
export { client, account, databases };

View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import "@appwrite.io/pink-icons";
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
})

100
docs/legacy/cancel.html Normal file
View File

@@ -0,0 +1,100 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Zahlung Abgebrochen - EmailSorter</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0a0a0f;
--warning: #f59e0b;
--warning-glow: rgba(245, 158, 11, 0.2);
--text: #e4e4e7;
--text-muted: #71717a;
--accent: #6366f1;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Space Grotesk', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.bg {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: radial-gradient(circle at 50% 50%, var(--warning-glow) 0%, transparent 50%);
pointer-events: none;
}
.container {
position: relative;
z-index: 1;
padding: 2rem;
}
.icon {
width: 100px;
height: 100px;
background: rgba(245, 158, 11, 0.2);
border: 3px solid var(--warning);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 2rem;
font-size: 3rem;
}
h1 {
font-size: 2rem;
margin-bottom: 1rem;
}
p {
color: var(--text-muted);
margin-bottom: 2rem;
max-width: 400px;
}
.buttons {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
.btn {
display: inline-block;
padding: 1rem 2rem;
border-radius: 8px;
text-decoration: none;
font-weight: 600;
transition: all 0.2s;
}
.btn-primary {
background: var(--accent);
color: white;
}
.btn-secondary {
background: transparent;
color: var(--text-muted);
border: 1px solid var(--text-muted);
}
.btn:hover {
transform: translateY(-2px);
}
</style>
</head>
<body>
<div class="bg"></div>
<div class="container">
<div class="icon"></div>
<h1>Zahlung Abgebrochen</h1>
<p>Die Zahlung wurde abgebrochen. Keine Sorge, es wurde nichts berechnet. Du kannst jederzeit erneut versuchen.</p>
<div class="buttons">
<a href="/" class="btn btn-primary">Erneut Versuchen</a>
<a href="/" class="btn btn-secondary">Zur Startseite</a>
</div>
</div>
</body>
</html>

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