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
This commit is contained in:
2026-01-22 19:32:12 +01:00
parent 95349af50b
commit abf761db07
596 changed files with 56405 additions and 51231 deletions

97
APPWRITE_SETUP.md Normal file
View File

@@ -0,0 +1,97 @@
# Appwrite Neu-Einrichtung - Schritt für Schritt
## Schritt 1: Neues Projekt in Appwrite erstellen
1. **Gehe zu Appwrite Dashboard:**
- Falls du cloud.appwrite.io nutzt: https://cloud.appwrite.io
- Falls du webklar.com nutzt: https://appwrite.webklar.com
2. **Erstelle ein neues Projekt:**
- Klicke auf "Create Project"
- Name: `EmailSorter` (oder ein anderer Name)
- Kopiere die **Project ID** (wird angezeigt)
## Schritt 2: API Key erstellen
1. **Gehe zu Settings → API Credentials**
2. **Klicke auf "Create API Key"**
3. **Konfiguration:**
- Name: `EmailSorter Backend`
- Scopes: Wähle **alle Berechtigungen** (Full Access)
- Expiration: Optional (oder leer lassen für kein Ablaufdatum)
4. **Kopiere den API Key** (wird nur einmal angezeigt!)
## Schritt 3: Datenbank erstellen
1. **Gehe zu Databases**
2. **Klicke auf "Create Database"**
3. **Konfiguration:**
- Database ID: `email_sorter_db` (oder ein anderer Name)
- Name: `EmailSorter Database`
4. **Kopiere die Database ID**
## Schritt 4: .env Dateien aktualisieren
### server/.env aktualisieren:
```env
APPWRITE_ENDPOINT=https://appwrite.webklar.com/v1
# ODER falls cloud.appwrite.io:
# APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
APPWRITE_PROJECT_ID=DEINE_NEW_PROJECT_ID_HIER
APPWRITE_API_KEY=DEIN_NEW_API_KEY_HIER
APPWRITE_DATABASE_ID=email_sorter_db
```
### client/.env aktualisieren:
```env
VITE_APPWRITE_ENDPOINT=https://appwrite.webklar.com/v1
# ODER falls cloud.appwrite.io:
# VITE_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
VITE_APPWRITE_PROJECT_ID=DEINE_NEW_PROJECT_ID_HIER
```
## Schritt 5: Bootstrap ausführen
Nachdem du die .env Dateien aktualisiert hast:
```powershell
cd server
npm run bootstrap:v2
```
Dies erstellt automatisch alle benötigten Collections:
- `products`
- `questions`
- `submissions`
- `answers`
- `orders`
- `email_accounts`
- `email_stats`
- `subscriptions`
- `user_preferences`
- `email_digests`
## Schritt 6: Verifizierung
Nach erfolgreichem Bootstrap solltest du sehen:
- ✓ Database created/exists
- ✓ Alle Collections wurden erstellt
- ✓ Alle Attribute wurden hinzugefügt
## Troubleshooting
**Fehler: "Project not found"**
- Prüfe, ob die PROJECT_ID korrekt ist
- Prüfe, ob du den richtigen Endpoint verwendest
**Fehler: "Unauthorized"**
- Prüfe, ob der API_KEY korrekt ist
- Stelle sicher, dass der API Key alle Berechtigungen hat
**Fehler: "Database not found"**
- Stelle sicher, dass die DATABASE_ID korrekt ist
- Das Bootstrap-Skript erstellt die Datenbank automatisch, wenn sie nicht existiert

75
GOOGLE_OAUTH_SETUP.md Normal file
View File

@@ -0,0 +1,75 @@
# Google OAuth Setup - Test Users hinzufügen
## Problem
Wenn du Google OAuth für Gmail verwendest, musst du während der Entwicklung **Test Users** hinzufügen, damit diese die App verwenden können.
## Lösung: Test Users hinzufügen
### Schritt 1: Google Cloud Console öffnen
1. Gehe zu [console.cloud.google.com](https://console.cloud.google.com)
2. Wähle dein Projekt aus
### Schritt 2: OAuth Consent Screen öffnen
1. Gehe zu **APIs & Services****OAuth consent screen**
2. Scroll nach unten zu **Test users**
### Schritt 3: Test Users hinzufügen
1. Klicke auf **+ ADD USERS**
2. Füge die E-Mail-Adressen hinzu, die die App verwenden sollen:
- Deine eigene E-Mail-Adresse
- E-Mail-Adressen von anderen Entwicklern/Test-Usern
3. Klicke auf **ADD**
### Schritt 4: Wichtig!
- **Jede E-Mail-Adresse**, die Gmail verbinden möchte, **muss** als Test User hinzugefügt sein
- Ohne Test User bekommst du den Fehler: `access_denied` oder `invalid_grant`
## App veröffentlichen (für Produktion) ✅
**Sobald die App veröffentlicht ist, müssen KEINE User mehr manuell hinzugefügt werden!**
### Veröffentlichungsschritte:
1. **Gehe zu OAuth consent screen**
- APIs & Services → OAuth consent screen
2. **Klicke auf "PUBLISH APP"**
- Die App wechselt von "Testing" zu "In production"
3. **Verifizierung (falls erforderlich)**
- Google kann zusätzliche Informationen anfordern
- Meist nur bei bestimmten Scopes oder vielen Nutzern nötig
4. **Fertig!**
- ✅ Alle Google-Nutzer können die App jetzt verwenden
- ✅ Keine Test Users mehr nötig
- ✅ Keine manuelle E-Mail-Eingabe mehr erforderlich
### Wichtig:
- **Während der Entwicklung:** Test Users müssen manuell hinzugefügt werden
- **Nach Veröffentlichung:** Alle können die App verwenden, keine manuelle Eingabe nötig!
**⚠️ Hinweis:** Wenn du die App wieder auf "Testing" zurücksetzt, müssen wieder Test Users hinzugefügt werden.
## Aktuelle Konfiguration prüfen
Deine Redirect URI sollte sein:
- Entwicklung: `http://localhost:3000/api/oauth/gmail/callback`
- Produktion: `https://deine-domain.de/api/oauth/gmail/callback`
Diese muss in **Credentials****OAuth 2.0 Client IDs****Authorized redirect URIs** eingetragen sein.
## Troubleshooting
**Fehler: "access_denied"**
- → Test User nicht hinzugefügt
- → Lösung: E-Mail als Test User hinzufügen
**Fehler: "invalid_grant"**
- → Redirect URI stimmt nicht überein
- → Lösung: Redirect URI in Google Cloud Console prüfen
**Fehler: "redirect_uri_mismatch"**
- → Redirect URI in .env stimmt nicht mit Google Cloud Console überein
- → Lösung: Beide prüfen und angleichen

115
PROJECT_RENAME_GUIDE.md Normal file
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" 🎉

151
PROJECT_REVIEW_SUMMARY.md Normal file
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.

382
README.md
View File

@@ -1,229 +1,241 @@
# 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
├── n8n/ # n8n Workflows
│ └── workflows/
└── public/ # Legacy Frontend
```
## 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
### 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:
```
### POST /api/submissions
Erstellt eine neue Submission mit Kundenantworten.
**Request Body:**
```json
{
"productSlug": "email-sorter",
"answers": {
"email": "kunde@example.com",
"name": "Max Mustermann"
}
}
https://your-domain.com/api/subscription/webhook
```
**Response:**
```json
{
"submissionId": "..."
}
```
### 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
## 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

373
SETUP_GUIDE.md Normal file
View File

@@ -0,0 +1,373 @@
# EmailSorter - Einrichtungsanleitung
Diese Anleitung führt dich durch die komplette Einrichtung von EmailSorter.
---
## Inhaltsverzeichnis
1. [Voraussetzungen](#voraussetzungen)
2. [Appwrite einrichten](#1-appwrite-einrichten)
3. [Stripe einrichten](#2-stripe-einrichten)
4. [Google OAuth einrichten](#3-google-oauth-einrichten-gmail)
5. [Microsoft OAuth einrichten](#4-microsoft-oauth-einrichten-outlook)
6. [Mistral AI einrichten](#5-mistral-ai-einrichten)
7. [Projekt starten](#6-projekt-starten)
8. [Fehlerbehebung](#fehlerbehebung)
---
## Voraussetzungen
- Node.js 18+ installiert
- npm oder yarn
- Git
---
## 1. Appwrite einrichten
### 1.1 Account erstellen
1. Gehe zu [cloud.appwrite.io](https://cloud.appwrite.io)
2. Erstelle einen kostenlosen Account
3. Erstelle ein neues Projekt (z.B. "EmailSorter")
### 1.2 API Key erstellen
1. Gehe zu **Settings****API Credentials**
2. Klicke auf **Create API Key**
3. Name: `EmailSorter Backend`
4. Wähle **alle Berechtigungen** aus (Full Access)
5. Kopiere den API Key
### 1.3 Datenbank erstellen
1. Gehe zu **Databases**
2. Klicke auf **Create Database**
3. Name: `email_sorter_db`
4. Kopiere die **Database ID**
### 1.4 Bootstrap ausführen
```bash
cd server
npm run bootstrap:v2
```
Dies erstellt automatisch alle benötigten Collections:
- `products`
- `questions`
- `submissions`
- `answers`
- `orders`
- `email_accounts`
- `email_stats`
- `subscriptions`
- `user_preferences`
### 1.5 .env konfigurieren
```env
# server/.env
APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
APPWRITE_PROJECT_ID=deine_projekt_id
APPWRITE_API_KEY=dein_api_key
APPWRITE_DATABASE_ID=email_sorter_db
```
```env
# client/.env
VITE_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
VITE_APPWRITE_PROJECT_ID=deine_projekt_id
```
---
## 2. Stripe einrichten
### 2.1 Account erstellen
1. Gehe zu [dashboard.stripe.com](https://dashboard.stripe.com)
2. Erstelle einen Account
3. Wechsle in den **Test Mode** (Toggle oben rechts)
### 2.2 API Keys kopieren
1. Gehe zu **Developers****API keys**
2. Kopiere den **Secret key** (beginnt mit `sk_test_`)
### 2.3 Produkte erstellen
Gehe zu **Products****Add product**:
#### Basic Plan
- Name: `EmailSorter Basic`
- Preis: `9.00 EUR` / Monat
- Kopiere die **Price ID** (beginnt mit `price_`)
#### Pro Plan
- Name: `EmailSorter Pro`
- Preis: `19.00 EUR` / Monat
- Kopiere die **Price ID**
#### Business Plan
- Name: `EmailSorter Business`
- Preis: `49.00 EUR` / Monat
- Kopiere die **Price ID**
### 2.4 Webhook einrichten
1. Gehe zu **Developers****Webhooks**
2. Klicke auf **Add endpoint**
3. URL: `https://deine-domain.de/api/subscription/webhook`
- Für lokale Tests: Nutze [Stripe CLI](https://stripe.com/docs/stripe-cli)
4. Events auswählen:
- `checkout.session.completed`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `invoice.payment_failed`
- `invoice.payment_succeeded`
5. Kopiere das **Signing Secret** (beginnt mit `whsec_`)
### 2.5 Lokaler Webhook-Test mit Stripe CLI
```bash
# Installieren
# Windows: scoop install stripe
# Mac: brew install stripe/stripe-cli/stripe
# Login
stripe login
# Webhook forwarden
stripe listen --forward-to localhost:3000/api/subscription/webhook
```
### 2.6 .env konfigurieren
```env
# server/.env
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRICE_BASIC=price_...
STRIPE_PRICE_PRO=price_...
STRIPE_PRICE_BUSINESS=price_...
```
---
## 3. Google OAuth einrichten (Gmail)
### 3.1 Google Cloud Projekt erstellen
1. Gehe zu [console.cloud.google.com](https://console.cloud.google.com)
2. Erstelle ein neues Projekt (oder wähle ein bestehendes)
### 3.2 Gmail API aktivieren
1. Gehe zu **APIs & Services****Library**
2. Suche nach "Gmail API"
3. Klicke auf **Enable**
### 3.3 OAuth Consent Screen
1. Gehe zu **APIs & Services****OAuth consent screen**
2. Wähle **External**
3. Fülle aus:
- App name: `EmailSorter`
- User support email: Deine E-Mail
- Developer contact: Deine E-Mail
4. **Scopes** hinzufügen:
- `https://www.googleapis.com/auth/gmail.modify`
- `https://www.googleapis.com/auth/gmail.labels`
- `https://www.googleapis.com/auth/userinfo.email`
5. **Test users** hinzufügen (während der Entwicklung)
### 3.4 OAuth Credentials erstellen
1. Gehe zu **APIs & Services****Credentials**
2. Klicke auf **Create Credentials****OAuth client ID**
3. Typ: **Web application**
4. Name: `EmailSorter Web`
5. **Authorized redirect URIs**:
- `http://localhost:3000/api/oauth/gmail/callback` (Entwicklung)
- `https://deine-domain.de/api/oauth/gmail/callback` (Produktion)
6. Kopiere **Client ID** und **Client Secret**
### 3.5 .env konfigurieren
```env
# server/.env
GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-xxx
GOOGLE_REDIRECT_URI=http://localhost:3000/api/oauth/gmail/callback
```
---
## 4. Microsoft OAuth einrichten (Outlook)
### 4.1 Azure App Registration
1. Gehe zu [portal.azure.com](https://portal.azure.com)
2. Suche nach "App registrations"
3. Klicke auf **New registration**
4. Name: `EmailSorter`
5. Supported account types: **Accounts in any organizational directory and personal Microsoft accounts**
6. Redirect URI: `Web``http://localhost:3000/api/oauth/outlook/callback`
### 4.2 Client Secret erstellen
1. Gehe zu **Certificates & secrets**
2. Klicke auf **New client secret**
3. Description: `EmailSorter Backend`
4. Expires: `24 months`
5. Kopiere den **Value** (wird nur einmal angezeigt!)
### 4.3 API Permissions
1. Gehe zu **API permissions**
2. Klicke auf **Add a permission****Microsoft Graph****Delegated permissions**
3. Füge hinzu:
- `Mail.ReadWrite`
- `User.Read`
- `offline_access`
4. Klicke auf **Grant admin consent** (falls möglich)
### 4.4 .env konfigurieren
```env
# server/.env
MICROSOFT_CLIENT_ID=xxx-xxx-xxx
MICROSOFT_CLIENT_SECRET=xxx
MICROSOFT_REDIRECT_URI=http://localhost:3000/api/oauth/outlook/callback
```
---
## 5. Mistral AI einrichten
### 5.1 Account erstellen
1. Gehe zu [console.mistral.ai](https://console.mistral.ai)
2. Erstelle einen Account
3. Gehe zu **API Keys**
4. Klicke auf **Create new key**
5. Kopiere den API Key
### 5.2 .env konfigurieren
```env
# server/.env
MISTRAL_API_KEY=dein_mistral_api_key
```
---
## 6. Projekt starten
### 6.1 Dependencies installieren
```bash
# Backend
cd server
npm install
# Frontend
cd ../client
npm install
```
### 6.2 Environment Files erstellen
```bash
# Server
cp server/env.example server/.env
# Fülle die Werte aus!
# Client
cp client/env.example client/.env
# Fülle die Werte aus!
```
### 6.3 Datenbank initialisieren
```bash
cd server
npm run bootstrap:v2
```
### 6.4 Server starten
```bash
# Terminal 1 - Backend
cd server
npm run dev
# Terminal 2 - Frontend
cd client
npm run dev
```
### 6.5 Öffnen
- **Frontend**: http://localhost:5173
- **Backend API**: http://localhost:3000
- **API Docs**: http://localhost:3000
---
## Fehlerbehebung
### "APPWRITE_PROJECT_ID is not defined"
Stelle sicher, dass die `.env` Datei existiert und die Variablen gesetzt sind.
### "Invalid OAuth redirect URI"
Die Redirect URI in der OAuth-Konfiguration muss **exakt** mit der in der `.env` übereinstimmen.
### "Rate limit exceeded"
- Gmail: Max 10.000 Requests/Tag
- Outlook: Max 10.000 Requests/App/Tag
### "Mistral AI: Model not found"
Prüfe, ob der API Key gültig ist und das Guthaben ausreicht.
### "Stripe webhook signature invalid"
- Nutze das korrekte Webhook Secret (`whsec_...`)
- Bei lokalem Test: Nutze die Stripe CLI
### Microsoft OAuth: "Client credential must not be empty"
Stelle sicher, dass `MICROSOFT_CLIENT_ID` und `MICROSOFT_CLIENT_SECRET` gesetzt sind. Falls du Outlook nicht nutzen möchtest, kannst du diese leer lassen - der Server startet trotzdem.
---
## Checkliste
- [ ] Appwrite Projekt erstellt
- [ ] Appwrite API Key erstellt
- [ ] Appwrite Database erstellt
- [ ] Bootstrap ausgeführt (`npm run bootstrap:v2`)
- [ ] Stripe Account erstellt
- [ ] Stripe Produkte erstellt (Basic, Pro, Business)
- [ ] Stripe Webhook eingerichtet
- [ ] Google OAuth Credentials erstellt (optional)
- [ ] Microsoft App Registration erstellt (optional)
- [ ] Mistral AI API Key erstellt
- [ ] Alle `.env` Dateien konfiguriert
- [ ] Server startet ohne Fehler
- [ ] Frontend startet ohne Fehler
---
## Support
Bei Fragen oder Problemen:
- GitHub Issues
- support@emailsorter.de

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,
},
},
])

17
client/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes" />
<meta name="description" content="EmailSorter - AI-powered email sorting for maximum productivity. Automatically organize your inbox." />
<meta name="theme-color" content="#22c55e" />
<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>
</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"
}
}

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

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

@@ -0,0 +1,142 @@
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 { 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">
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 border-4 border-primary-200 border-t-primary-600 rounded-full animate-spin" />
<p className="text-slate-500 text-sm">Loading...</p>
</div>
</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() {
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,119 @@
import { useState } from 'react'
import { ChevronDown, HelpCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
const faqs = [
{
question: "Are my emails secure?",
answer: "Yes! We use OAuth we never see your password. Content is only analyzed briefly, never stored."
},
{
question: "Which email providers work?",
answer: "Gmail and Outlook. More coming soon."
},
{
question: "Can I create custom rules?",
answer: "Absolutely! You can set VIP contacts and define custom categories."
},
{
question: "What about old emails?",
answer: "The last 30 days are analyzed. You decide if they should be sorted too."
},
{
question: "Can I cancel anytime?",
answer: "Yes, with one click. No tricks, no long commitments."
},
{
question: "Do I need a credit card?",
answer: "No, the 14-day trial is completely free."
},
{
question: "Does it work on mobile?",
answer: "Yes! Sorting runs on our servers works in any email app."
},
{
question: "What if the AI sorts wrong?",
answer: "Just correct it. The AI learns and gets better over time."
},
]
export function FAQ() {
const [openIndex, setOpenIndex] = useState<number | null>(0)
return (
<section id="faq" className="py-24 bg-slate-50">
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Section header */}
<div className="text-center mb-16">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-100 mb-6">
<HelpCircle className="w-8 h-8 text-primary-600" />
</div>
<h2 className="text-3xl sm:text-4xl font-bold text-slate-900 mb-4">
FAQ
</h2>
<p className="text-lg text-slate-600">
Quick answers to common questions.
</p>
</div>
{/* FAQ items */}
<div className="space-y-3">
{faqs.map((faq, index) => (
<FAQItem
key={index}
question={faq.question}
answer={faq.answer}
isOpen={openIndex === index}
onClick={() => setOpenIndex(openIndex === index ? null : index)}
/>
))}
</div>
{/* Contact CTA */}
<div className="mt-12 text-center p-6 bg-white rounded-2xl border border-slate-200">
<p className="text-slate-600 mb-2">Still have questions?</p>
<a
href="mailto:support@emailsorter.com"
className="text-primary-600 font-semibold hover:text-primary-700"
>
Contact us
</a>
</div>
</div>
</section>
)
}
interface FAQItemProps {
question: string
answer: string
isOpen: boolean
onClick: () => void
}
function FAQItem({ question, answer, isOpen, onClick }: FAQItemProps) {
return (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<button
className="w-full px-6 py-4 text-left flex items-center justify-between hover:bg-slate-50 transition-colors"
onClick={onClick}
>
<span className="font-semibold text-slate-900 pr-4">{question}</span>
<ChevronDown
className={cn(
"w-5 h-5 text-slate-400 transition-transform duration-200 flex-shrink-0",
isOpen && "rotate-180"
)}
/>
</button>
<div
className={cn(
"overflow-hidden transition-all duration-200",
isOpen ? "max-h-40" : "max-h-0"
)}
>
<p className="px-6 pb-4 text-slate-600">{answer}</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,137 @@
import {
Brain,
Zap,
Shield,
Clock,
Tags,
Settings,
Inbox,
Filter
} from 'lucide-react'
const features = [
{
icon: Brain,
title: "AI-powered categorization",
description: "Our AI automatically recognizes whether an email is an invoice, newsletter, or important message.",
color: "from-violet-500 to-purple-600"
},
{
icon: Zap,
title: "Real-time sorting",
description: "New emails are categorized instantly. Your inbox arrives already sorted.",
color: "from-amber-500 to-orange-600"
},
{
icon: Tags,
title: "Smart labels",
description: "Automatic labels for VIP, clients, invoices, newsletters, social media and more.",
color: "from-blue-500 to-cyan-600"
},
{
icon: Shield,
title: "GDPR compliant",
description: "Your data stays secure. We only read email headers and metadata for sorting.",
color: "from-green-500 to-emerald-600"
},
{
icon: Clock,
title: "Save time",
description: "Average 2 hours per week less on email organization. More time for what matters.",
color: "from-pink-500 to-rose-600"
},
{
icon: Settings,
title: "Fully customizable",
description: "Define your own rules, VIP contacts, and categories based on your needs.",
color: "from-indigo-500 to-blue-600"
},
]
export function Features() {
return (
<section id="features" className="py-24 bg-slate-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Section header */}
<div className="text-center mb-16">
<h2 className="text-3xl sm:text-4xl font-bold text-slate-900 mb-4">
Everything you need for{' '}
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary-600 to-accent-500">
Inbox Zero
</span>
</h2>
<p className="text-lg text-slate-600 max-w-2xl mx-auto">
EmailSorter combines AI technology with proven email management methods
for maximum productivity.
</p>
</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 rounded-3xl border border-slate-200 shadow-xl p-8 max-w-4xl mx-auto">
<div className="grid md:grid-cols-3 gap-8 items-center">
{/* Before */}
<div className="text-center">
<div className="w-20 h-20 mx-auto mb-4 rounded-2xl bg-red-100 flex items-center justify-center">
<Inbox className="w-10 h-10 text-red-500" />
</div>
<h4 className="font-semibold text-slate-900 mb-1">Before</h4>
<p className="text-sm text-slate-500">Inbox chaos</p>
<div className="mt-3 text-3xl font-bold text-red-500">847</div>
<p className="text-xs text-slate-400">unread emails</p>
</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 flex items-center justify-center">
<Inbox className="w-10 h-10 text-green-500" />
</div>
<h4 className="font-semibold text-slate-900 mb-1">After</h4>
<p className="text-sm text-slate-500">All sorted</p>
<div className="mt-3 text-3xl font-bold text-green-500">12</div>
<p className="text-xs text-slate-400">important emails</p>
</div>
</div>
</div>
</div>
</div>
</section>
)
}
interface FeatureCardProps {
icon: React.ElementType
title: string
description: string
color: string
index: number
}
function FeatureCard({ icon: Icon, title, description, color, index }: FeatureCardProps) {
return (
<div
className="group bg-white rounded-2xl p-6 border border-slate-200 hover:border-primary-200 hover:shadow-lg transition-all duration-300"
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`}>
<Icon className="w-7 h-7 text-white" />
</div>
<h3 className="text-xl font-semibold text-slate-900 mb-2">{title}</h3>
<p className="text-slate-600">{description}</p>
</div>
)
}

View File

@@ -0,0 +1,185 @@
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">
Email<span className="text-primary-400">Sorter</span>
</span>
</Link>
<p className="text-sm text-slate-400 mb-6">
AI-powered email sorting for more productivity and less stress.
</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>
<li>
<a href="#" className="hover:text-white transition-colors">
Roadmap
</a>
</li>
</ul>
</div>
{/* Company */}
<div>
<h4 className="font-semibold text-white mb-4">Company</h4>
<ul className="space-y-3">
<li>
<a
href="https://webklar.com"
target="_blank"
rel="noopener noreferrer"
className="hover:text-white transition-colors"
>
About us
</a>
</li>
<li>
<a
href="https://webklar.com"
target="_blank"
rel="noopener noreferrer"
className="hover:text-white transition-colors"
>
Blog
</a>
</li>
<li>
<a
href="https://webklar.com"
target="_blank"
rel="noopener noreferrer"
className="hover:text-white transition-colors"
>
Careers
</a>
</li>
<li>
<a
href="mailto:support@webklar.com"
className="hover:text-white transition-colors"
>
Contact
</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="/imprint" className="hover:text-white transition-colors">
Impressum
</Link>
</li>
<li>
<a
href="https://webklar.com"
target="_blank"
rel="noopener noreferrer"
className="hover:text-white transition-colors"
>
webklar.com
</a>
</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. All rights reserved.
</p>
<p className="text-sm text-slate-500">
Made with
</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,179 @@
import { useNavigate } from 'react-router-dom'
import { captureUTMParams } from '@/lib/analytics'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { ArrowRight, Mail, Inbox, Sparkles, Check, Zap } 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" />
AI-powered email sorting
</Badge>
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-extrabold text-white leading-tight mb-6">
Your inbox.
<br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-accent-400 to-primary-300">
Finally organized.
</span>
</h1>
<p className="text-lg sm:text-xl text-slate-300 mb-8 max-w-xl mx-auto lg:mx-0">
EmailSorter uses AI to automatically categorize your emails.
Newsletters, invoices, important contacts everything lands
exactly where it belongs.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center lg:justify-start mb-8">
<Button
size="xl"
onClick={handleCTAClick}
className="group"
>
Start 14-day free trial
<ArrowRight className="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" />
</Button>
<Button
size="xl"
variant="outline"
className="bg-white/10 border-white/20 text-white hover:bg-white/20"
onClick={() => document.getElementById('how-it-works')?.scrollIntoView({ behavior: 'smooth' })}
>
See how it works
</Button>
</div>
{/* Trust badges */}
<div className="flex flex-wrap gap-6 justify-center lg:justify-start text-slate-400 text-sm">
<div className="flex items-center gap-2">
<Check className="w-4 h-4 text-accent-400" />
No credit card required
</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 - Visual */}
<div className="relative hidden lg:block">
<div className="relative">
{/* Main card */}
<div className="bg-white/10 backdrop-blur-xl rounded-3xl border border-white/20 p-6 shadow-2xl animate-float">
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center">
<Inbox className="w-6 h-6 text-white" />
</div>
<div>
<h3 className="font-semibold text-white">Your Inbox</h3>
<p className="text-sm text-slate-400">Auto-sorted</p>
</div>
</div>
{/* Email categories preview */}
<div className="space-y-3">
<EmailPreview
category="Important"
color="bg-red-500"
sender="John Smith"
subject="Meeting tomorrow at 10"
delay="stagger-1"
/>
<EmailPreview
category="Invoice"
color="bg-green-500"
sender="Amazon"
subject="Invoice for order #12345"
delay="stagger-2"
/>
<EmailPreview
category="Newsletter"
color="bg-purple-500"
sender="Tech Daily"
subject="Latest AI trends"
delay="stagger-3"
/>
<EmailPreview
category="Social"
color="bg-cyan-500"
sender="LinkedIn"
subject="3 new connection requests"
delay="stagger-4"
/>
</div>
</div>
{/* Floating badge */}
<div className="absolute -right-4 top-1/4 bg-accent-500 text-white px-4 py-2 rounded-full text-sm font-semibold shadow-lg animate-pulse-glow">
<Zap className="w-4 h-4 inline mr-1" />
AI sorting
</div>
</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>
)
}
interface EmailPreviewProps {
category: string
color: string
sender: string
subject: string
delay: string
}
function EmailPreview({ category, color, sender, subject, delay }: EmailPreviewProps) {
return (
<div className={`flex items-center gap-3 bg-white/5 rounded-xl p-3 border border-white/10 opacity-0 animate-[fadeIn_0.5s_ease-out_forwards] ${delay}`}>
<div className={`w-2 h-10 rounded-full ${color}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-sm font-medium text-white truncate">{sender}</span>
<span className={`text-xs px-2 py-0.5 rounded ${color} text-white`}>{category}</span>
</div>
<p className="text-sm text-slate-400 truncate">{subject}</p>
</div>
<Mail className="w-4 h-4 text-slate-500 flex-shrink-0" />
</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: "Connect Gmail or Outlook with one click. Secure OAuth authentication.",
},
{
icon: Sparkles,
step: "03",
title: "AI analyzes",
description: "Our AI learns your email patterns and creates personalized sorting rules.",
},
{
icon: PartyPopper,
step: "04",
title: "Enjoy Inbox Zero",
description: "Sit back and enjoy a clean inbox automatically.",
},
]
export function HowItWorks() {
return (
<section id="how-it-works" className="py-24 bg-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Section header */}
<div className="text-center mb-16">
<h2 className="text-3xl sm:text-4xl font-bold text-slate-900 mb-4">
4 steps to a{' '}
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary-600 to-accent-500">
clean inbox
</span>
</h2>
<p className="text-lg text-slate-600 max-w-2xl mx-auto">
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 animate-bounce mb-4" />
<p className="text-slate-600 mb-2">Ready to get started?</p>
<a
href="/register"
className="text-primary-600 font-semibold hover:text-primary-700 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 rounded-2xl p-6 text-center hover:bg-white hover:shadow-xl transition-all duration-300 border border-transparent hover:border-slate-200">
{/* Step number */}
<div className="absolute -top-4 left-1/2 -translate-x-1/2 bg-gradient-to-r from-primary-500 to-primary-600 text-white text-sm font-bold px-4 py-1 rounded-full shadow-md">
{step}
</div>
{/* Icon */}
<div className="w-16 h-16 mx-auto mt-4 mb-4 rounded-2xl bg-white shadow-md flex items-center justify-center">
<Icon className="w-8 h-8 text-primary-600" />
</div>
{/* Content */}
<h3 className="text-lg font-semibold text-slate-900 mb-2">{title}</h3>
<p className="text-slate-600 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 backdrop-blur-lg border-b border-slate-100">
<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">
Email<span className="text-primary-600">Sorter</span>
</span>
</Link>
{/* Desktop Navigation */}
<div className="hidden md:flex items-center gap-8">
<button
onClick={() => scrollToSection('features')}
className="text-slate-600 hover:text-slate-900 font-medium transition-colors"
>
Features
</button>
<button
onClick={() => scrollToSection('how-it-works')}
className="text-slate-600 hover:text-slate-900 font-medium transition-colors"
>
How it works
</button>
<button
onClick={() => scrollToSection('pricing')}
className="text-slate-600 hover:text-slate-900 font-medium transition-colors"
>
Pricing
</button>
<button
onClick={() => scrollToSection('faq')}
className="text-slate-600 hover:text-slate-900 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')}>
Get started free
</Button>
</>
)}
</div>
{/* Mobile menu button */}
<button
className="md:hidden p-2.5 rounded-lg hover:bg-slate-100 active:bg-slate-200 touch-manipulation"
onClick={() => setIsMenuOpen(!isMenuOpen)}
aria-label={isMenuOpen ? "Close menu" : "Open menu"}
>
{isMenuOpen ? (
<X className="w-6 h-6 text-slate-600" />
) : (
<Menu className="w-6 h-6 text-slate-600" />
)}
</button>
</div>
</div>
{/* Mobile menu */}
{isMenuOpen && (
<div className="md:hidden bg-white border-t border-slate-100 shadow-lg animate-in slide-in-from-top-2 duration-200">
<div className="px-3 py-3 space-y-1">
<button
onClick={() => scrollToSection('features')}
className="block w-full text-left px-4 py-3 text-sm font-medium text-slate-700 hover:bg-slate-50 active:bg-slate-100 rounded-lg transition-colors touch-manipulation"
>
Features
</button>
<button
onClick={() => scrollToSection('how-it-works')}
className="block w-full text-left px-4 py-3 text-sm font-medium text-slate-700 hover:bg-slate-50 active:bg-slate-100 rounded-lg transition-colors touch-manipulation"
>
How it works
</button>
<button
onClick={() => scrollToSection('pricing')}
className="block w-full text-left px-4 py-3 text-sm font-medium text-slate-700 hover:bg-slate-50 active:bg-slate-100 rounded-lg transition-colors touch-manipulation"
>
Pricing
</button>
<button
onClick={() => scrollToSection('faq')}
className="block w-full text-left px-4 py-3 text-sm font-medium text-slate-700 hover:bg-slate-50 active:bg-slate-100 rounded-lg transition-colors touch-manipulation"
>
FAQ
</button>
<div className="pt-3 mt-3 border-t border-slate-100 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')}>
Get started 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">
<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 mb-4">
Simple, transparent pricing
</h2>
<p className="text-lg text-slate-600 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">
Still have questions?{' '}
<button
onClick={() => document.getElementById('faq')?.scrollIntoView({ behavior: 'smooth' })}
className="text-primary-600 font-semibold hover:text-primary-700"
>
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 rounded-2xl p-8 ${
popular
? 'ring-2 ring-primary-500 shadow-xl scale-105'
: 'border border-slate-200 hover:border-primary-200 hover:shadow-lg'
} transition-all duration-300`}
>
{popular && (
<div className="absolute -top-4 left-1/2 -translate-x-1/2">
<Badge className="bg-primary-500 text-white border-0 shadow-md">
Most Popular
</Badge>
</div>
)}
{/* Header */}
<div className="text-center mb-6">
<h3 className="text-xl font-bold text-slate-900 mb-1">{name}</h3>
<p className="text-sm text-slate-500">{description}</p>
</div>
{/* Price */}
<div className="text-center mb-8">
<div className="flex items-baseline justify-center">
<span className="text-5xl font-extrabold text-slate-900">${price}</span>
<span className="text-slate-500 ml-1">{period}</span>
</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 flex items-center justify-center flex-shrink-0">
<Check className="w-3 h-3 text-green-600" />
</div>
) : (
<div className="w-5 h-5 rounded-full bg-slate-100 flex items-center justify-center flex-shrink-0">
<X className="w-3 h-3 text-slate-400" />
</div>
)}
<span className={feature.included ? 'text-slate-700' : 'text-slate-400'}>
{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,67 @@
import { CheckCircle2, Clock, Brain, Shield } from 'lucide-react'
const benefits = [
{
icon: Clock,
title: "Save 2+ hours/week",
description: "Less time sorting emails, more time for important tasks.",
},
{
icon: Brain,
title: "AI does it automatically",
description: "Set up once, then everything runs by itself.",
},
{
icon: Shield,
title: "Privacy first",
description: "Your emails stay private. We don't store any content.",
},
{
icon: CheckCircle2,
title: "Easy to use",
description: "No learning curve. Ready to go in 2 minutes.",
},
]
export function Testimonials() {
return (
<section className="py-20 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Section header */}
<div className="text-center mb-12">
<h2 className="text-3xl sm:text-4xl font-bold text-white mb-4">
Why EmailSorter?
</h2>
<p className="text-lg text-slate-300 max-w-2xl mx-auto">
No more email chaos. Focus on what matters.
</p>
</div>
{/* Benefits grid */}
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
{benefits.map((benefit, index) => (
<BenefitCard key={index} {...benefit} />
))}
</div>
</div>
</section>
)
}
interface BenefitCardProps {
icon: React.ElementType
title: string
description: string
}
function BenefitCard({ icon: Icon, title, description }: BenefitCardProps) {
return (
<div className="bg-white/5 backdrop-blur-sm rounded-xl p-6 border border-white/10 hover:bg-white/10 transition-colors">
<div className="w-12 h-12 rounded-lg bg-primary-500/20 flex items-center justify-center mb-4">
<Icon className="w-6 h-6 text-primary-400" />
</div>
<h3 className="text-lg font-semibold text-white mb-2">{title}</h3>
<p className="text-slate-400 text-sm">{description}</p>
</div>
)
}

View File

@@ -0,0 +1,39 @@
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 text-primary-700",
secondary:
"border-transparent bg-slate-100 text-slate-700",
success:
"border-transparent bg-green-100 text-green-700",
warning:
"border-transparent bg-amber-100 text-amber-700",
destructive:
"border-transparent bg-red-100 text-red-700",
outline: "text-slate-600 border-slate-200",
},
},
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,59 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-semibold ring-offset-white transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-primary-600 text-white hover:bg-primary-700 shadow-lg shadow-primary-600/25 hover:shadow-primary-600/40",
secondary:
"bg-slate-100 text-slate-900 hover:bg-slate-200",
outline:
"border-2 border-slate-200 bg-transparent hover:bg-slate-50 hover:border-slate-300",
ghost:
"hover:bg-slate-100 hover:text-slate-900",
link:
"text-primary-600 underline-offset-4 hover:underline",
accent:
"bg-accent-500 text-white hover:bg-accent-600 shadow-lg shadow-accent-500/25",
},
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 bg-white shadow-sm transition-shadow hover:shadow-md",
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",
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", 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 bg-white px-4 py-2 text-sm text-slate-900 transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:border-primary-500 disabled:cursor-not-allowed disabled:opacity-50",
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,74 @@
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

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

@@ -0,0 +1,144 @@
/* 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 */
--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;
}
/* 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;
}
/* 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);
}
}
/* 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);
}
/* 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);
}
/* Gradient backgrounds */
.gradient-hero {
background: linear-gradient(135deg, var(--color-slate-900) 0%, var(--color-primary-900) 50%, var(--color-slate-800) 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%);
}
/* 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; }

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

@@ -0,0 +1,314 @@
/**
* 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'
userId?: string
metadata?: Record<string, any>
}
const STORAGE_KEY = 'emailsorter_utm_params'
const USER_ID_KEY = 'emailsorter_user_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,
tracking: params,
timestamp: new Date().toISOString(),
page: window.location.pathname,
referrer: document.referrer || undefined,
userAgent: navigator.userAgent,
}
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()
: ''
}

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

@@ -0,0 +1,329 @@
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[]>
}
}
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'
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 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
}>('/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
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[]
}) {
return fetchApi<{ success: boolean }>('/preferences', {
method: 'POST',
body: JSON.stringify({ userId, ...preferences }),
})
},
// ═══════════════════════════════════════════════════════════════════════════
// PRODUCTS & QUESTIONS (Legacy)
// ═══════════════════════════════════════════════════════════════════════════
async getProducts() {
return fetchApi<any[]>('/products')
},
async getQuestions(productSlug: string) {
return fetchApi<any[]>(`/questions?productSlug=${productSlug}`)
},
async createSubmission(productSlug: string, answers: Record<string, any>) {
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')
},
}
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,707 @@
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,
FileText,
Bell,
Shield,
HelpCircle,
ChevronRight,
Loader2,
RefreshCw,
Check,
AlertCircle,
Sparkles,
AlertTriangle,
Lightbulb,
Archive
} from 'lucide-react'
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
}
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 [loading, setLoading] = useState(true)
const [sorting, setSorting] = useState(false)
const [sortResult, setSortResult] = useState<SortResult | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (user?.$id) {
loadData()
}
}, [user])
const loadData = async () => {
if (!user?.$id) return
setLoading(true)
setError(null)
try {
const [statsRes, accountsRes, digestRes] = await Promise.all([
api.getEmailStats(user.$id),
api.getEmailAccounts(user.$id),
api.getDigest(user.$id),
])
if (statsRes.data) setStats(statsRes.data)
if (accountsRes.data) setAccounts(accountsRes.data)
if (digestRes.data) setDigest(digestRes.data)
} catch (err) {
console.error('Error loading dashboard data:', err)
setError('Failed to load data')
} finally {
setLoading(false)
}
}
const handleSortNow = async () => {
if (!user?.$id || accounts.length === 0) {
setError('Connect an email account first to start sorting.')
return
}
setSorting(true)
setSortResult(null)
setError(null)
try {
const result = await api.sortEmails(user.$id, accounts[0].id)
if (result.data) {
setSortResult(result.data)
// Refresh stats and digest
const [statsRes, digestRes] = await Promise.all([
api.getEmailStats(user.$id),
api.getDigest(user.$id),
])
if (statsRes.data) setStats(statsRes.data)
if (digestRes.data) setDigest(digestRes.data)
} else if (result.error) {
setError(result.error.message || 'Sorting failed')
}
} catch (err) {
console.error('Error sorting emails:', err)
setError('Error sorting emails')
} finally {
setSorting(false)
}
}
const handleConnectDemo = async () => {
if (!user?.$id) return
setLoading(true)
setError(null)
try {
const result = await api.connectDemoAccount(user.$id)
if (result.data) {
const accountsRes = await api.getEmailAccounts(user.$id)
if (accountsRes.data) setAccounts(accountsRes.data)
} else if (result.error) {
setError(result.error.message || 'Could not create demo account')
}
} catch (err) {
console.error('Error connecting demo:', err)
setError('Error creating demo account')
} finally {
setLoading(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">
{/* Header */}
<header className="bg-white/90 backdrop-blur-md border-b border-slate-200 sticky top-0 z-50 shadow-sm">
<div className="max-w-7xl mx-auto 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 whitespace-nowrap">
Email<span className="text-primary-600">Sorter</span>
</span>
</Link>
<div className="flex items-center gap-1.5 sm:gap-2 lg:gap-4">
<Button variant="ghost" size="icon" className="hidden lg:flex h-9 w-9">
<Bell className="w-5 h-5 text-slate-500" />
</Button>
<Button variant="ghost" size="icon" className="hidden lg:flex h-9 w-9">
<HelpCircle className="w-5 h-5 text-slate-500" />
</Button>
<div className="hidden lg:block h-6 w-px bg-slate-200" />
<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" />
</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" />
</Button>
</div>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-6">
{/* Welcome section */}
<div className="mb-4 sm:mb-6">
<h1 className="text-xl sm:text-2xl font-bold text-slate-900 mb-0.5">
Welcome back{user?.name ? `, ${user.name}` : ''}! 👋
</h1>
<p className="text-xs sm:text-sm text-slate-600">
Your email overview for today.
</p>
</div>
{/* Error message */}
{error && (
<div className="mb-3 sm:mb-4 p-2.5 sm:p-3 bg-red-50 border border-red-200 rounded-lg flex items-start sm:items-center gap-2 text-red-700">
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5 sm:mt-0" />
<p className="text-xs sm:text-sm flex-1">{error}</p>
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-600 text-base font-semibold leading-none">
</button>
</div>
)}
{/* Sort Result Toast */}
{sortResult && (
<div className="mb-3 sm:mb-4 p-2.5 sm:p-3 bg-green-50 border border-green-200 rounded-lg">
<div className="flex items-center justify-between mb-2 flex-wrap gap-1.5">
<div className="flex items-center gap-2 text-green-700">
<Check className="w-4 h-4 flex-shrink-0" />
<p className="text-xs sm:text-sm font-semibold">Sorting complete!</p>
</div>
{sortResult.isDemo && (
<Badge variant="secondary" className="bg-amber-100 text-amber-700 text-xs">
Demo
</Badge>
)}
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3 text-xs">
<div>
<p className="text-xs text-green-600 mb-0.5">Sorted</p>
<p className="text-lg sm:text-xl font-bold text-green-800">{sortResult.sorted}</p>
</div>
<div>
<p className="text-xs text-green-600 mb-0.5">Time saved</p>
<p className="text-lg sm:text-xl font-bold text-green-800">{sortResult.timeSaved.formatted}</p>
</div>
{Object.entries(sortResult.categories).slice(0, 2).map(([cat, count]) => (
<div key={cat}>
<p className="text-xs text-green-600 mb-0.5 truncate">{formatCategoryName(cat)}</p>
<p className="text-lg sm:text-xl font-bold text-green-800">{count}</p>
</div>
))}
</div>
{Object.keys(sortResult.categories).length > 2 && (
<div className="mt-2 pt-2 border-t border-green-200">
<p className="text-xs text-green-600 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>
)}
{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 mx-auto mb-4" />
<p className="text-slate-500">Loading dashboard...</p>
</div>
</div>
) : (
<>
{/* Daily Digest Card */}
{digest?.hasData && (
<Card className="mb-4 sm:mb-6 shadow-lg border-0 bg-gradient-to-r from-primary-50 via-white to-accent-50 overflow-hidden">
<CardContent className="p-3 sm:p-4">
<div className="flex items-start justify-between mb-3 gap-2">
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-lg bg-gradient-to-br from-primary-500 to-accent-500 flex items-center justify-center shadow-lg shadow-primary-500/30 flex-shrink-0">
<Sparkles className="w-4 h-4 sm:w-5 sm:h-5 text-white" />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-sm sm:text-base font-bold text-slate-900 truncate">Today's Digest</h3>
<p className="text-xs text-slate-500 truncate">{new Date(digest.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}</p>
</div>
</div>
{digest.inboxCleared > 0 && (
<Badge className="bg-green-100 text-green-700 border-green-200 text-xs whitespace-nowrap flex-shrink-0">
<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 rounded-lg p-2 sm:p-3 border border-slate-200/50">
<p className="text-xs text-slate-500 mb-0.5">Processed</p>
<p className="text-base sm:text-lg font-bold text-slate-900">{digest.totalSorted}</p>
</div>
<div className="bg-white/80 rounded-lg p-2 sm:p-3 border border-slate-200/50">
<p className="text-xs text-slate-500 mb-0.5">Cleared</p>
<p className="text-base sm:text-lg font-bold text-green-600">{digest.inboxCleared}</p>
</div>
<div className="bg-white/80 rounded-lg p-2 sm:p-3 border border-slate-200/50">
<p className="text-xs text-slate-500 mb-0.5">Saved</p>
<p className="text-base sm:text-lg font-bold text-primary-600">
{digest.timeSavedMinutes > 60
? `${Math.floor(digest.timeSavedMinutes / 60)}h ${digest.timeSavedMinutes % 60}m`
: `${digest.timeSavedMinutes}m`}
</p>
</div>
</div>
{/* Highlights */}
{digest.highlights.length > 0 && (
<div className="mb-3">
<p className="text-xs font-medium text-slate-700 mb-1.5 flex items-center gap-1.5">
<AlertTriangle className="w-3.5 h-3.5 text-amber-500" />
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 text-amber-800' :
highlight.type === 'security' ? 'bg-red-100 text-red-800' :
highlight.type === 'invoices' ? 'bg-green-100 text-green-800' :
'bg-slate-100 text-slate-700'
}`}
>
{highlight.message}
</div>
))}
</div>
</div>
)}
{/* Suggestions */}
{digest.suggestions.length > 0 && (
<div className="pt-2.5 border-t border-slate-200/50">
<p className="text-xs font-medium text-slate-700 mb-1.5 flex items-center gap-1.5">
<Lightbulb className="w-3.5 h-3.5 text-primary-500" />
Suggestions
</p>
<div className="space-y-1">
{digest.suggestions.map((suggestion, idx) => (
<p key={idx} className="text-xs text-slate-600 bg-white/60 rounded-md px-2 py-1">
{suggestion.message}
</p>
))}
</div>
</div>
)}
</CardContent>
</Card>
)}
{/* Stats cards */}
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-2.5 sm:gap-3 lg:gap-4 mb-4 sm:mb-6">
<StatsCard
icon={Inbox}
title="Sorted today"
value={displayStats.todaySorted.toString()}
subtitle="emails"
color="bg-primary-500"
/>
<StatsCard
icon={TrendingUp}
title="This week"
value={displayStats.weekSorted.toString()}
subtitle="emails"
color="bg-accent-500"
/>
<StatsCard
icon={Clock}
title="Time saved"
value={displayStats.timeSaved > 60
? `${Math.floor(displayStats.timeSaved / 60)}h ${displayStats.timeSaved % 60}m`
: `${displayStats.timeSaved}m`}
subtitle="this week"
color="bg-green-500"
/>
<StatsCard
icon={BarChart3}
title="Total sorted"
value={displayStats.totalSorted.toLocaleString('en-US')}
subtitle="emails"
color="bg-violet-500"
/>
</div>
<div className="grid lg:grid-cols-3 gap-3 sm:gap-4">
{/* Categories breakdown */}
<Card className="lg:col-span-2 shadow-lg border-0 order-2 lg:order-1">
<CardHeader className="p-3 sm:p-4 pb-2 sm:pb-3">
<CardTitle className="flex items-center gap-2 text-sm sm:text-base">
<Tag className="w-4 h-4 text-primary-500" />
Categories Overview
</CardTitle>
<CardDescription className="text-xs">
Distribution this week
</CardDescription>
</CardHeader>
<CardContent className="p-3 sm:p-4 pt-0">
{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 sm:w-2.5 sm:h-2.5 rounded-full flex-shrink-0 ${categoryColors[category] || 'bg-slate-400'}`} />
<span className="text-xs font-medium text-slate-700 truncate">{formatCategoryName(category)}</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<span className="text-xs text-slate-500 whitespace-nowrap">{count}</span>
<span className="text-xs text-slate-400 whitespace-nowrap">({percentage}%)</span>
</div>
</div>
<div className="h-1.5 bg-slate-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${categoryColors[category] || 'bg-slate-400'}`}
style={{ width: `${percentage}%` }}
/>
</div>
</div>
)
})}
</div>
) : (
<div className="text-center py-8">
<Tag className="w-10 h-10 text-slate-300 mx-auto mb-3" />
<p className="text-xs text-slate-500 mb-1">No category statistics yet</p>
<p className="text-xs text-slate-400">Start a sort to see statistics</p>
</div>
)}
</CardContent>
</Card>
{/* Connected accounts */}
<Card className="shadow-lg border-0 order-1 lg:order-2">
<CardHeader className="p-3 sm:p-4 pb-2 sm:pb-3">
<CardTitle className="flex items-center gap-2 text-sm sm:text-base">
<Users className="w-4 h-4 text-primary-500" />
Email Accounts
</CardTitle>
<CardDescription className="text-xs">
Connected mailboxes
</CardDescription>
</CardHeader>
<CardContent className="space-y-2.5 sm:space-y-3 p-3 sm:p-4 pt-0">
{accounts.length > 0 ? (
accounts.map((account) => (
<div
key={account.id}
className="flex items-center justify-between p-2 sm:p-2.5 bg-gradient-to-r from-slate-50 to-slate-100 rounded-lg gap-2"
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<div className={`w-8 h-8 sm:w-9 sm:h-9 rounded-lg flex items-center justify-center shadow-sm flex-shrink-0 ${
account.provider === 'gmail' ? 'bg-red-100' : 'bg-blue-100'
}`}>
<Mail className={`w-3.5 h-3.5 sm:w-4 sm:h-4 ${
account.provider === 'gmail' ? 'text-red-600' : 'text-blue-600'
}`} />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-slate-700 truncate">{account.email}</p>
<p className="text-xs text-slate-500 capitalize truncate">{account.provider}</p>
</div>
</div>
<Badge variant={account.connected ? 'success' : 'secondary'} className="text-xs whitespace-nowrap flex-shrink-0 px-1.5 py-0.5">
{account.connected ? 'Active' : 'Off'}
</Badge>
</div>
))
) : (
<div className="text-center py-6">
<div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-gradient-to-br from-slate-100 to-slate-200 flex items-center justify-center">
<Mail className="w-6 h-6 text-slate-400" />
</div>
<p className="text-xs text-slate-500 mb-1">
No email accounts connected
</p>
<p className="text-xs text-slate-400">
Connect an account to get started
</p>
</div>
)}
<div className="space-y-1.5 pt-2">
<Button
className="w-full h-9 sm:h-10 text-xs sm:text-sm"
variant="outline"
onClick={() => navigate('/setup')}
>
<Plus className="w-3.5 h-3.5 mr-1.5" />
Connect account
</Button>
{accounts.length === 0 && (
<Button
className="w-full h-9 sm:h-10 text-xs sm:text-sm"
variant="ghost"
onClick={handleConnectDemo}
disabled={loading}
>
<Zap className="w-3.5 h-3.5 mr-1.5" />
Try demo account
</Button>
)}
</div>
</CardContent>
</Card>
</div>
{/* Quick actions */}
<div className="mt-4 sm:mt-6">
<h2 className="text-sm sm:text-base font-semibold text-slate-900 mb-2.5 sm:mb-3">Quick Actions</h2>
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-2.5 sm:gap-3">
<QuickAction
icon={sorting ? RefreshCw : Zap}
title={sorting ? "Sorting..." : "Sort now"}
description={sorting ? "Please wait..." : "Start manual sorting"}
onClick={handleSortNow}
disabled={sorting || accounts.length === 0}
loading={sorting}
highlight
/>
<QuickAction
icon={Settings}
title="Adjust rules"
description="Edit sorting rules"
onClick={() => navigate('/settings?tab=rules')}
/>
<QuickAction
icon={Shield}
title="VIP List"
description="Manage important contacts"
onClick={() => navigate('/settings?tab=vip')}
/>
<QuickAction
icon={FileText}
title="Reports"
description="Detailed statistics"
onClick={() => {}}
disabled
/>
</div>
</div>
</>
)}
</main>
</div>
)
}
interface StatsCardProps {
icon: React.ElementType
title: string
value: string
subtitle: string
color: string
}
function StatsCard({ icon: Icon, title, value, subtitle, color }: StatsCardProps) {
return (
<Card className="shadow-lg border-0 overflow-hidden">
<CardContent className="p-3 sm:p-4">
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<p className="text-xs text-slate-500 mb-0.5 truncate">{title}</p>
<p className="text-xl sm:text-2xl font-bold text-slate-900 leading-tight">{value}</p>
<p className="text-xs text-slate-400 truncate mt-0.5">{subtitle}</p>
</div>
<div className={`w-9 h-9 sm:w-10 sm:h-10 rounded-lg ${color} flex items-center justify-center shadow-lg flex-shrink-0`}>
<Icon className="w-4 h-4 sm:w-5 sm:h-5 text-white" />
</div>
</div>
</CardContent>
</Card>
)
}
interface QuickActionProps {
icon: React.ElementType
title: string
description: string
onClick: () => void
disabled?: boolean
loading?: boolean
highlight?: boolean
}
function QuickAction({ icon: Icon, title, description, onClick, disabled, loading, highlight }: QuickActionProps) {
return (
<button
onClick={onClick}
disabled={disabled}
className={`flex items-center gap-2 p-2.5 sm:p-3 rounded-lg border-2 transition-all text-left w-full group disabled:opacity-50 disabled:cursor-not-allowed ${
highlight && !disabled
? 'bg-gradient-to-r from-primary-50 to-accent-50 border-primary-200 hover:border-primary-400 hover:shadow-lg hover:shadow-primary-500/10 active:scale-[0.98]'
: 'bg-white border-slate-200 hover:border-primary-200 hover:shadow-md active:scale-[0.98]'
}`}
>
<div className={`w-9 h-9 sm:w-10 sm:h-10 rounded-lg flex items-center justify-center transition-colors flex-shrink-0 ${
highlight && !disabled
? 'bg-primary-500 shadow-lg shadow-primary-500/30'
: 'bg-primary-50 group-hover:bg-primary-100'
}`}>
<Icon className={`w-4 h-4 sm:w-5 sm:h-5 ${highlight && !disabled ? 'text-white' : 'text-primary-600'} ${loading ? 'animate-spin' : ''}`} />
</div>
<div className="flex-1 min-w-0">
<p className={`text-xs sm:text-sm font-semibold ${highlight && !disabled ? 'text-primary-900' : 'text-slate-900'} truncate`}>{title}</p>
<p className="text-xs text-slate-500 truncate leading-tight">{description}</p>
</div>
<ChevronRight className={`w-3.5 h-3.5 sm:w-4 sm:h-4 transition-colors flex-shrink-0 ${
highlight && !disabled ? 'text-primary-400' : 'text-slate-400'
} group-hover:text-primary-500 group-hover:translate-x-0.5 transition-transform`} />
</button>
)
}

View File

@@ -0,0 +1,131 @@
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: any) {
setError(err.message || 'Fehler beim Senden der E-Mail')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Logo */}
<Link to="/" className="flex items-center justify-center gap-2 mb-8">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center">
<Mail className="w-5 h-5 text-white" />
</div>
<span className="text-xl font-bold text-slate-900">
Email<span className="text-primary-600">Sorter</span>
</span>
</Link>
<Card className="shadow-xl border-0">
<CardHeader className="text-center pb-2">
<CardTitle className="text-2xl">Passwort vergessen?</CardTitle>
<CardDescription>
{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 flex items-center justify-center">
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
<h3 className="font-semibold text-slate-900 mb-2">E-Mail gesendet!</h3>
<p className="text-slate-600 mb-6">
Wir haben dir eine E-Mail mit einem Link zum Zurücksetzen deines Passworts an <strong>{email}</strong> gesendet.
</p>
<p className="text-sm text-slate-500 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 border border-red-200 rounded-lg text-red-700 text-sm">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="email">E-Mail-Adresse</Label>
<Input
id="email"
type="email"
placeholder="name@beispiel.de"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
/>
</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 hover:text-primary-700"
>
<ArrowLeft className="w-4 h-4 inline mr-1" />
Zurück zum Login
</Link>
</div>
</form>
)}
</CardContent>
</Card>
</div>
</div>
)
}

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

@@ -0,0 +1,23 @@
import { Navbar } from '@/components/landing/Navbar'
import { Hero } from '@/components/landing/Hero'
import { Features } from '@/components/landing/Features'
import { HowItWorks } from '@/components/landing/HowItWorks'
import { Pricing } from '@/components/landing/Pricing'
import { Testimonials } from '@/components/landing/Testimonials'
import { 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 />
<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">
{/* Header */}
<header className="bg-white border-b border-slate-200">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<Link
to="/"
className="inline-flex items-center gap-2 text-slate-600 hover:text-slate-900 transition-colors"
>
<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 rounded-xl shadow-sm border border-slate-200 p-8 md:p-12">
{/* Title */}
<div className="flex items-center gap-3 mb-8">
<div className="w-12 h-12 rounded-lg bg-primary-100 flex items-center justify-center">
<Building2 className="w-6 h-6 text-primary-600" />
</div>
<div>
<h1 className="text-3xl font-bold text-slate-900">Impressum</h1>
<p className="text-slate-500 mt-1">Legal Information</p>
</div>
</div>
{/* Content - Placeholder for webklar.com content */}
<div className="prose prose-slate max-w-none">
<p className="text-slate-600 mb-6">
<strong>Note:</strong> This imprint is managed by webklar.com. Please refer to their imprint for detailed information.
</p>
<div className="bg-slate-50 border border-slate-200 rounded-lg p-6 mb-8">
<h2 className="text-xl font-semibold text-slate-900 mb-4">Information according to § 5 TMG</h2>
<div className="space-y-6 text-slate-700">
<div>
<h3 className="text-lg font-semibold text-slate-900 mb-2">Operator</h3>
<p className="mb-2">EmailSorter is operated by:</p>
<p className="mb-4">
<strong>webklar.com</strong><br />
Kenso Grimm, Justin Klein
</p>
<p className="text-sm text-slate-600 mb-4">
For complete contact details and legal information, please visit:{' '}
<a
href="https://webklar.com/impressum"
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 hover:text-primary-700 underline"
>
webklar.com/impressum
</a>
</p>
</div>
<div className="pt-6 border-t border-slate-200">
<h3 className="text-lg font-semibold text-slate-900 mb-2">Contact</h3>
<div className="space-y-2">
<p>
<strong>Email:</strong>{' '}
<a
href="mailto:support@webklar.com"
className="text-primary-600 hover:text-primary-700 underline"
>
support@webklar.com
</a>
</p>
<p>
<strong>Phone:</strong>{' '}
<a
href="tel:+4917623726355"
className="text-primary-600 hover:text-primary-700 underline"
>
+49 176 23726355
</a>
{' / '}
<a
href="tel:+491704969375"
className="text-primary-600 hover:text-primary-700 underline"
>
+49 170 4969375
</a>
</p>
<p className="mt-4 text-sm text-slate-600">
For questions regarding EmailSorter specifically:{' '}
<a
href="mailto:support@emailsorter.com"
className="text-primary-600 hover:text-primary-700 underline"
>
support@emailsorter.com
</a>
</p>
</div>
</div>
<div className="pt-6 border-t border-slate-200">
<h3 className="text-lg font-semibold text-slate-900 mb-2">Responsible for Content</h3>
<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 hover:text-primary-700 underline"
>
webklar.com/impressum
</a>
</p>
</div>
<div className="pt-6 border-t border-slate-200">
<h3 className="text-lg font-semibold text-slate-900 mb-2">Liability for Links</h3>
<p>
Our website contains links to external websites. We have no influence on the content of these websites.
Therefore, we cannot assume any liability for these external contents.
</p>
</div>
<div className="pt-6 border-t border-slate-200">
<h3 className="text-lg font-semibold text-slate-900 mb-2">Copyright</h3>
<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">
<p className="text-sm text-slate-500">
<strong>Important:</strong> This is a simplified version. For the complete and legally binding imprint, please visit{' '}
<a
href="https://webklar.com/impressum"
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 hover:text-primary-700 underline"
>
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: any) {
setError(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">
Email<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">
{/* Header */}
<header className="bg-white border-b border-slate-200">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<Link
to="/"
className="inline-flex items-center gap-2 text-slate-600 hover:text-slate-900 transition-colors"
>
<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 rounded-xl shadow-sm border border-slate-200 p-8 md:p-12">
{/* Title */}
<div className="flex items-center gap-3 mb-8">
<div className="w-12 h-12 rounded-lg bg-primary-100 flex items-center justify-center">
<Shield className="w-6 h-6 text-primary-600" />
</div>
<div>
<h1 className="text-3xl font-bold text-slate-900">Privacy Policy</h1>
<p className="text-slate-500 mt-1">Last updated: {new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}</p>
</div>
</div>
{/* Content - Placeholder for webklar.com content */}
<div className="prose prose-slate max-w-none">
<p className="text-slate-600 mb-6">
<strong>Note:</strong> This privacy policy is managed by webklar.com. Please refer to their privacy policy for detailed information.
</p>
<div className="bg-slate-50 border border-slate-200 rounded-lg p-6 mb-8">
<h2 className="text-xl font-semibold text-slate-900 mb-4">Data Protection Information</h2>
<p className="text-slate-700 mb-4">
EmailSorter is operated by webklar.com. The following privacy policy applies to the use of this website and our services.
</p>
<h3 className="text-lg font-semibold text-slate-900 mt-6 mb-3">1. Responsible Party</h3>
<p className="text-slate-700 mb-4">
The responsible party for data processing on this website is:
</p>
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
<p className="text-slate-700 mb-2">
<strong>webklar.com</strong><br />
Kenso Grimm, Justin Klein
</p>
<p className="text-slate-700 mb-2">
<strong>Contact:</strong><br />
Email: <a href="mailto:support@webklar.com" className="text-primary-600 hover:text-primary-700 underline">support@webklar.com</a><br />
Phone: <a href="tel:+4917623726355" className="text-primary-600 hover:text-primary-700 underline">+49 176 23726355</a>
</p>
<p className="text-sm text-slate-600 mt-3">
For complete contact details, please refer to the <Link to="/imprint" className="text-primary-600 hover:text-primary-700 underline">Impressum</Link>.
</p>
</div>
<h3 className="text-lg font-semibold text-slate-900 mt-6 mb-3">2. Data Collection and Processing</h3>
<p className="text-slate-700 mb-4">
When you use EmailSorter, we collect and process the following data:
</p>
<ul className="list-disc list-inside text-slate-700 mb-4 space-y-2 ml-4">
<li>Account information (email address, name)</li>
<li>Email metadata (sender, subject, date) for sorting purposes</li>
<li>Usage statistics and preferences</li>
<li>Payment information (processed securely via Stripe)</li>
</ul>
<h3 className="text-lg font-semibold text-slate-900 mt-6 mb-3">3. Purpose of Data Processing</h3>
<p className="text-slate-700 mb-4">
We process your data exclusively for the following purposes:
</p>
<ul className="list-disc list-inside text-slate-700 mb-4 space-y-2 ml-4">
<li>Providing and improving the EmailSorter service</li>
<li>Automated email sorting and categorization</li>
<li>Processing payments and subscriptions</li>
<li>Customer support and communication</li>
</ul>
<h3 className="text-lg font-semibold text-slate-900 mt-6 mb-3">4. Data Security</h3>
<p className="text-slate-700 mb-4">
We implement appropriate technical and organizational measures to protect your data against unauthorized access, loss, or destruction.
</p>
<h3 className="text-lg font-semibold text-slate-900 mt-6 mb-3">5. Your Rights</h3>
<p className="text-slate-700 mb-4">
You have the right to:
</p>
<ul className="list-disc list-inside text-slate-700 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 mt-6 mb-3">6. Hosting and Third-Party Services</h3>
<p className="text-slate-700 mb-4">
<strong>Hosting:</strong> Our website is hosted by Netlify, which acts as a data processor.
</p>
<p className="text-slate-700 mb-4">
We use the following third-party services:
</p>
<ul className="list-disc list-inside text-slate-700 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 mt-6 mb-3">6.1. Cookies and Tracking</h3>
<p className="text-slate-700 mb-4">
We do not use external fonts or unnecessary cookies. If we use any tracking tools (such as Plausible),
they are privacy-friendly and do not store personal data. We only process personal data to the extent
that it is technically or organizationally necessary.
</p>
<h3 className="text-lg font-semibold text-slate-900 mt-6 mb-3">7. Contact Form Data</h3>
<p className="text-slate-700 mb-4">
Data that you send to us via contact forms will be stored and used for processing your inquiry.
This data will not be shared with third parties without your consent.
</p>
<h3 className="text-lg font-semibold text-slate-900 mt-6 mb-3">8. Contact</h3>
<p className="text-slate-700 mb-4">
For questions regarding data protection, please contact us:
</p>
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
<p className="text-slate-700">
<strong>Email:</strong>{' '}
<a href="mailto:support@webklar.com" className="text-primary-600 hover:text-primary-700 underline">
support@webklar.com
</a>
</p>
<p className="text-slate-700 mt-2">
<strong>Phone:</strong>{' '}
<a href="tel:+4917623726355" className="text-primary-600 hover:text-primary-700 underline">
+49 176 23726355
</a>
</p>
<p className="text-sm text-slate-600 mt-3">
For complete contact details, please refer to the <Link to="/imprint" className="text-primary-600 hover:text-primary-700 underline">Impressum</Link>.
</p>
</div>
<div className="mt-8 pt-6 border-t border-slate-200">
<p className="text-sm text-slate-500">
<strong>Important:</strong> This is a simplified version. For the complete and legally binding privacy policy, please visit{' '}
<a href="https://webklar.com/datenschutz" target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:text-primary-700 underline">
webklar.com/datenschutz
</a>
</p>
</div>
</div>
</div>
</div>
</main>
</div>
)
}

View File

@@ -0,0 +1,226 @@
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 { 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 [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 } = useAuth()
const navigate = useNavigate()
// Capture UTM parameters on mount
useEffect(() => {
captureUTMParams()
}, [])
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 {
const user = await register(email, password, name)
// Track signup conversion with UTM parameters
if (user?.$id) {
analytics.trackSignup(user.$id, email)
analytics.setUserId(user.$id)
}
navigate('/setup')
} catch (err: any) {
setError(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">
<div className="w-full max-w-md">
{/* Logo */}
<Link to="/" className="flex items-center gap-2 mb-8">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center">
<Mail className="w-5 h-5 text-white" />
</div>
<span className="text-xl font-bold text-slate-900">
Email<span className="text-primary-600">Sorter</span>
</span>
</Link>
<h1 className="text-3xl font-bold text-slate-900 mb-2">
Create account
</h1>
<p className="text-slate-600 mb-8">
Ready to go in less than a minute.
</p>
{/* Error message */}
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-600">{error}</p>
</div>
)}
{/* 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 text-center">
By signing up, you agree to our{' '}
<a href="#" className="text-primary-600 hover:underline">Terms of Service</a> and{' '}
<a href="#" className="text-primary-600 hover:underline">Privacy Policy</a>.
</p>
</form>
<p className="mt-8 text-center text-slate-600">
Already have an account?{' '}
<Link to="/login" className="text-primary-600 font-semibold hover:text-primary-700">
Sign in
</Link>
</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,225 @@
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: any) {
setError(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 flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Logo */}
<Link to="/" className="flex items-center justify-center gap-2 mb-8">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center">
<Mail className="w-5 h-5 text-white" />
</div>
<span className="text-xl font-bold text-slate-900">
Email<span className="text-primary-600">Sorter</span>
</span>
</Link>
<Card className="shadow-xl border-0">
<CardHeader className="text-center pb-2">
<CardTitle className="text-2xl">
{success ? 'Passwort geändert!' : 'Neues Passwort festlegen'}
</CardTitle>
<CardDescription>
{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 flex items-center justify-center">
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
<p className="text-slate-600 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 flex items-center justify-center">
<XCircle className="w-8 h-8 text-red-600" />
</div>
<h3 className="font-semibold text-slate-900 mb-2">Ungültiger Link</h3>
<p className="text-slate-600 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 border border-red-200 rounded-lg text-red-700 text-sm">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="password">Neues Passwort</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoFocus
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
{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'
}`}
/>
))}
</div>
<p className={`text-xs ${
passwordStrength.strength < 3 ? 'text-red-500' : 'text-green-600'
}`}>
{passwordStrength.label}
</p>
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Passwort bestätigen</Label>
<Input
id="confirmPassword"
type={showPassword ? 'text' : 'password'}
placeholder="••••••••"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
/>
{confirmPassword && password !== confirmPassword && (
<p className="text-xs text-red-500">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>
)
}

View File

@@ -0,0 +1,593 @@
import { useState, useEffect } from 'react'
import { useNavigate, useSearchParams } 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 { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { api } from '@/lib/api'
import {
Mail,
User,
CreditCard,
Shield,
Settings as SettingsIcon,
ArrowLeft,
Plus,
Trash2,
Check,
X,
ExternalLink,
Loader2,
Crown,
Star,
} from 'lucide-react'
type TabType = 'profile' | 'accounts' | 'vip' | 'rules' | 'subscription'
interface EmailAccount {
id: string
email: string
provider: 'gmail' | 'outlook'
connected: boolean
lastSync?: string
}
interface VIPSender {
email: string
name?: string
}
interface SortRule {
id: string
name: string
condition: string
category: string
enabled: boolean
}
interface Subscription {
status: string
plan: string
currentPeriodEnd?: string
cancelAtPeriodEnd?: boolean
}
export function Settings() {
const { user } = useAuth()
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const activeTab = (searchParams.get('tab') as TabType) || 'profile'
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const [name, setName] = useState(user?.name || '')
const [email] = useState(user?.email || '')
const [accounts, setAccounts] = useState<EmailAccount[]>([])
const [connectingProvider, setConnectingProvider] = useState<string | null>(null)
const [vipSenders, setVipSenders] = useState<VIPSender[]>([])
const [newVipEmail, setNewVipEmail] = useState('')
const [rules, setRules] = useState<SortRule[]>([
{ id: '1', name: 'Boss Emails', condition: 'from:boss@company.com', category: 'Important', enabled: true },
{ id: '2', name: 'Support Tickets', condition: 'subject:Ticket #', category: 'Clients', enabled: true },
])
const [subscription, setSubscription] = useState<Subscription | null>(null)
useEffect(() => {
loadData()
}, [user])
const loadData = async () => {
if (!user?.$id) return
setLoading(true)
try {
const [accountsRes, subsRes, prefsRes] = await Promise.all([
api.getEmailAccounts(user.$id),
api.getSubscriptionStatus(user.$id),
api.getUserPreferences(user.$id),
])
if (accountsRes.data) setAccounts(accountsRes.data)
if (subsRes.data) setSubscription(subsRes.data)
if (prefsRes.data?.vipSenders) setVipSenders(prefsRes.data.vipSenders)
} catch (error) {
console.error('Failed to load settings data:', error)
} finally {
setLoading(false)
}
}
const setTab = (tab: TabType) => {
setSearchParams({ tab })
setMessage(null)
}
const showMessage = (type: 'success' | 'error', text: string) => {
setMessage({ type, text })
setTimeout(() => setMessage(null), 5000)
}
const handleSaveProfile = async () => {
setSaving(true)
try {
showMessage('success', 'Profile saved!')
} catch {
showMessage('error', 'Failed to save')
} finally {
setSaving(false)
}
}
const handleConnectAccount = async (provider: 'gmail' | 'outlook') => {
if (!user?.$id) return
setConnectingProvider(provider)
try {
const res = await api.getOAuthUrl(provider, user.$id)
if (res.data?.url) {
window.location.href = res.data.url
}
} catch {
showMessage('error', `Failed to connect ${provider}`)
setConnectingProvider(null)
}
}
const handleDisconnectAccount = async (accountId: string) => {
if (!user?.$id) return
try {
await api.disconnectEmailAccount(accountId, user.$id)
setAccounts(accounts.filter(a => a.id !== accountId))
showMessage('success', 'Account disconnected')
} catch {
showMessage('error', 'Failed to disconnect')
}
}
const handleAddVip = () => {
if (!newVipEmail.trim() || !newVipEmail.includes('@')) return
if (vipSenders.some(v => v.email === newVipEmail)) {
showMessage('error', 'This email is already in the VIP list')
return
}
setVipSenders([...vipSenders, { email: newVipEmail }])
setNewVipEmail('')
showMessage('success', 'VIP added')
}
const handleRemoveVip = (email: string) => {
setVipSenders(vipSenders.filter(v => v.email !== email))
}
const handleSaveVips = async () => {
if (!user?.$id) return
setSaving(true)
try {
await api.saveUserPreferences(user.$id, { vipSenders })
showMessage('success', 'VIP list saved!')
} catch {
showMessage('error', 'Failed to save')
} finally {
setSaving(false)
}
}
const toggleRule = (ruleId: string) => {
setRules(rules.map(r =>
r.id === ruleId ? { ...r, enabled: !r.enabled } : r
))
}
const handleManageSubscription = async () => {
if (!user?.$id) return
try {
const res = await api.createPortalSession(user.$id)
if (res.data?.url) {
window.location.href = res.data.url
}
} catch {
showMessage('error', 'Failed to open customer portal')
}
}
const handleUpgrade = async (plan: string) => {
if (!user?.$id) return
try {
const res = await api.createSubscriptionCheckout(plan, user.$id, user.email)
if (res.data?.url) {
window.location.href = res.data.url
}
} catch {
showMessage('error', 'Failed to start checkout')
}
}
const tabs = [
{ id: 'profile' as TabType, label: 'Profile', icon: User },
{ id: 'accounts' as TabType, label: 'Email Accounts', icon: Mail },
{ id: 'vip' as TabType, label: 'VIP List', icon: Star },
{ id: 'rules' as TabType, label: 'Sorting Rules', icon: SettingsIcon },
{ id: 'subscription' as TabType, label: 'Subscription', icon: CreditCard },
]
return (
<div className="min-h-screen bg-slate-50">
<header className="bg-white border-b border-slate-200 sticky top-0 z-40">
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center h-16">
<Button variant="ghost" onClick={() => navigate('/dashboard')} className="mr-4">
<ArrowLeft className="w-5 h-5 mr-2" />
Back
</Button>
<div className="flex items-center gap-2">
<SettingsIcon className="w-5 h-5 text-slate-500" />
<h1 className="text-lg font-semibold text-slate-900">Settings</h1>
</div>
</div>
</div>
</header>
<main className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{message && (
<div className={`mb-6 p-4 rounded-lg flex items-center gap-2 ${
message.type === 'success'
? 'bg-green-50 text-green-700 border border-green-200'
: 'bg-red-50 text-red-700 border border-red-200'
}`}>
{message.type === 'success' ? <Check className="w-5 h-5" /> : <X className="w-5 h-5" />}
{message.text}
</div>
)}
<div className="flex flex-col lg:flex-row gap-8">
<nav className="lg:w-64 flex-shrink-0">
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setTab(tab.id)}
className={`w-full flex items-center gap-3 px-4 py-3 text-left transition-colors ${
activeTab === tab.id
? 'bg-primary-50 text-primary-700 border-l-4 border-primary-500'
: 'text-slate-600 hover:bg-slate-50 border-l-4 border-transparent'
}`}
>
<tab.icon className="w-5 h-5" />
<span className="font-medium">{tab.label}</span>
</button>
))}
</div>
</nav>
<div className="flex-1 min-w-0">
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
</div>
) : (
<>
{activeTab === 'profile' && (
<Card>
<CardHeader>
<CardTitle>Profile</CardTitle>
<CardDescription>Manage your personal information</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center gap-4">
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center text-white text-2xl font-bold">
{name?.charAt(0)?.toUpperCase() || email?.charAt(0)?.toUpperCase() || 'U'}
</div>
<div>
<h3 className="font-semibold text-slate-900">{name || 'User'}</h3>
<p className="text-slate-500">{email}</p>
</div>
</div>
<div className="space-y-4">
<div>
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Your name"
/>
</div>
<div>
<Label htmlFor="email">Email</Label>
<Input id="email" value={email} disabled className="bg-slate-50" />
<p className="text-xs text-slate-500 mt-1">Email address cannot be changed</p>
</div>
</div>
<Button onClick={handleSaveProfile} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
Save
</Button>
</CardContent>
</Card>
)}
{activeTab === 'accounts' && (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Connected Email Accounts</CardTitle>
<CardDescription>Connect your email accounts for automatic sorting</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{accounts.length > 0 ? (
accounts.map((account) => (
<div key={account.id} className="flex items-center justify-between p-4 bg-slate-50 rounded-lg">
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-lg flex items-center justify-center ${
account.provider === 'gmail' ? 'bg-red-100' : 'bg-blue-100'
}`}>
<Mail className={`w-6 h-6 ${account.provider === 'gmail' ? 'text-red-600' : 'text-blue-600'}`} />
</div>
<div>
<p className="font-medium text-slate-900">{account.email}</p>
<p className="text-sm text-slate-500 capitalize">{account.provider}</p>
</div>
</div>
<div className="flex items-center gap-3">
<Badge variant={account.connected ? 'success' : 'secondary'}>
{account.connected ? 'Connected' : 'Disconnected'}
</Badge>
<Button variant="ghost" size="icon" onClick={() => handleDisconnectAccount(account.id)}>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</div>
))
) : (
<p className="text-center text-slate-500 py-8">No email accounts connected yet</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Add Account</CardTitle>
<CardDescription>Connect a new email account</CardDescription>
</CardHeader>
<CardContent>
<div className="grid sm:grid-cols-2 gap-4">
<button
onClick={() => handleConnectAccount('gmail')}
disabled={connectingProvider === 'gmail'}
className="flex items-center gap-4 p-4 border-2 border-slate-200 rounded-xl hover:border-red-300 hover:bg-red-50 transition-all disabled:opacity-50"
>
{connectingProvider === 'gmail' ? (
<Loader2 className="w-8 h-8 animate-spin text-red-500" />
) : (
<div className="w-12 h-12 rounded-lg bg-red-100 flex items-center justify-center">
<svg className="w-6 h-6" viewBox="0 0 24 24">
<path fill="#EA4335" d="M12 11.3L1.5 3.5h21z"/>
<path fill="#34A853" d="M12 12.7L1.5 20.5V3.5z"/>
<path fill="#FBBC05" d="M1.5 20.5h21v-17z"/>
<path fill="#4285F4" d="M22.5 3.5v17L12 12.7z"/>
</svg>
</div>
)}
<div className="text-left">
<p className="font-semibold text-slate-900">Gmail</p>
<p className="text-sm text-slate-500">Connect Google account</p>
</div>
</button>
<button
onClick={() => handleConnectAccount('outlook')}
disabled={connectingProvider === 'outlook'}
className="flex items-center gap-4 p-4 border-2 border-slate-200 rounded-xl hover:border-blue-300 hover:bg-blue-50 transition-all disabled:opacity-50"
>
{connectingProvider === 'outlook' ? (
<Loader2 className="w-8 h-8 animate-spin text-blue-500" />
) : (
<div className="w-12 h-12 rounded-lg bg-blue-100 flex items-center justify-center">
<svg className="w-6 h-6" viewBox="0 0 24 24">
<path fill="#0078D4" d="M2 6.5v11c0 .83.67 1.5 1.5 1.5h9c.83 0 1.5-.67 1.5-1.5v-11c0-.83-.67-1.5-1.5-1.5h-9C2.67 5 2 5.67 2 6.5z"/>
<path fill="#0078D4" d="M14 6v12l8-6z"/>
</svg>
</div>
)}
<div className="text-left">
<p className="font-semibold text-slate-900">Outlook</p>
<p className="text-sm text-slate-500">Connect Microsoft account</p>
</div>
</button>
</div>
</CardContent>
</Card>
</div>
)}
{activeTab === 'vip' && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="w-5 h-5 text-amber-500" />
VIP List
</CardTitle>
<CardDescription>Emails from these senders will always be marked as important</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex gap-2">
<Input
placeholder="email@example.com"
value={newVipEmail}
onChange={(e) => setNewVipEmail(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAddVip()}
/>
<Button onClick={handleAddVip}>
<Plus className="w-4 h-4 mr-2" />
Add
</Button>
</div>
<div className="space-y-2">
{vipSenders.length > 0 ? (
vipSenders.map((vip) => (
<div key={vip.email} className="flex items-center justify-between p-3 bg-amber-50 border border-amber-100 rounded-lg">
<div className="flex items-center gap-3">
<Star className="w-5 h-5 text-amber-500" />
<span className="text-slate-700">{vip.email}</span>
</div>
<Button variant="ghost" size="icon" onClick={() => handleRemoveVip(vip.email)}>
<X className="w-4 h-4 text-slate-400 hover:text-red-500" />
</Button>
</div>
))
) : (
<p className="text-center text-slate-500 py-8">No VIP senders added yet</p>
)}
</div>
{vipSenders.length > 0 && (
<Button onClick={handleSaveVips} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
Save changes
</Button>
)}
</CardContent>
</Card>
)}
{activeTab === 'rules' && (
<Card>
<CardHeader>
<CardTitle>Sorting Rules</CardTitle>
<CardDescription>Custom rules for email sorting</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{rules.map((rule) => (
<div key={rule.id} className={`flex items-center justify-between p-4 rounded-lg border ${
rule.enabled ? 'bg-white border-slate-200' : 'bg-slate-50 border-slate-100'
}`}>
<div className="flex items-center gap-4">
<button
onClick={() => toggleRule(rule.id)}
className={`w-10 h-6 rounded-full transition-colors ${rule.enabled ? 'bg-primary-500' : 'bg-slate-300'}`}
>
<div className={`w-4 h-4 bg-white rounded-full transform transition-transform mx-1 ${
rule.enabled ? 'translate-x-4' : 'translate-x-0'
}`} />
</button>
<div>
<p className={`font-medium ${rule.enabled ? 'text-slate-900' : 'text-slate-500'}`}>{rule.name}</p>
<p className="text-sm text-slate-500 font-mono">{rule.condition}</p>
</div>
</div>
<Badge variant={rule.enabled ? 'default' : 'secondary'}>{rule.category}</Badge>
</div>
))}
<Button variant="outline" className="w-full">
<Plus className="w-4 h-4 mr-2" />
Create new rule
</Button>
</CardContent>
</Card>
)}
{activeTab === 'subscription' && (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Current Subscription</CardTitle>
<CardDescription>Manage your EmailSorter subscription</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between p-4 bg-gradient-to-r from-primary-50 to-accent-50 rounded-xl border border-primary-100">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-xl bg-white shadow-sm flex items-center justify-center">
<Crown className="w-7 h-7 text-primary-500" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-bold text-lg text-slate-900">{subscription?.plan || 'Trial'}</h3>
<Badge variant={subscription?.status === 'active' ? 'success' : 'warning'}>
{subscription?.status === 'active' ? 'Active' : 'Trial'}
</Badge>
</div>
{subscription?.currentPeriodEnd && (
<p className="text-sm text-slate-500">
Next billing: {new Date(subscription.currentPeriodEnd).toLocaleDateString('en-US')}
</p>
)}
</div>
</div>
<Button onClick={handleManageSubscription}>
<ExternalLink className="w-4 h-4 mr-2" />
Manage
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Available Plans</CardTitle>
<CardDescription>Choose the plan that fits you</CardDescription>
</CardHeader>
<CardContent>
<div className="grid sm:grid-cols-3 gap-4">
{[
{ id: 'basic', name: 'Basic', price: '9', features: ['1 email account', '500 emails/day', 'Standard support'] },
{ id: 'pro', name: 'Pro', price: '19', features: ['3 email accounts', 'Unlimited emails', 'Historical sorting', 'Priority support'], popular: true },
{ id: 'business', name: 'Business', price: '49', features: ['10 email accounts', 'Unlimited emails', 'Team features', 'API access', '24/7 support'] },
].map((plan) => (
<div key={plan.id} className={`relative p-6 rounded-xl border-2 ${
plan.popular ? 'border-primary-500 bg-primary-50' : 'border-slate-200 bg-white'
}`}>
{plan.popular && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
<Badge className="bg-primary-500 text-white">Popular</Badge>
</div>
)}
<h3 className="font-bold text-lg text-slate-900">{plan.name}</h3>
<div className="mt-2 mb-4">
<span className="text-3xl font-bold text-slate-900">${plan.price}</span>
<span className="text-slate-500">/month</span>
</div>
<ul className="space-y-2 mb-6">
{plan.features.map((feature) => (
<li key={feature} className="flex items-center gap-2 text-sm text-slate-600">
<Check className="w-4 h-4 text-green-500" />
{feature}
</li>
))}
</ul>
<Button
className="w-full"
variant={plan.popular ? 'default' : 'outline'}
onClick={() => handleUpgrade(plan.id)}
disabled={subscription?.plan === plan.id}
>
{subscription?.plan === plan.id ? 'Current plan' : 'Select'}
</Button>
</div>
))}
</div>
</CardContent>
</Card>
</div>
)}
</>
)}
</div>
</div>
</main>
</div>
)
}

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

@@ -0,0 +1,492 @@
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 { api } from '@/lib/api'
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 autoSetup = searchParams.get('setup') === 'auto'
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 { user } = useAuth()
const navigate = useNavigate()
// 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: 'preferences', title: 'Settings', description: 'Sorting preferences' },
{ id: 'categories', title: 'Categories', description: 'Choose categories' },
{ id: 'complete', title: 'Done', description: 'Get started!' },
]
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) {
window.location.href = response.data.url
} else {
setConnectedProvider('gmail')
setConnectedEmail(user.email)
setCurrentStep('preferences')
}
} catch (err) {
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) {
window.location.href = response.data.url
} else {
setConnectedProvider('outlook')
setConnectedEmail(user.email)
setCurrentStep('preferences')
}
} catch (err) {
setError('Outlook connection failed. Please try again.')
} finally {
setConnecting(null)
}
}
const handleNext = () => {
const nextIndex = stepIndex + 1
if (nextIndex < steps.length) {
setCurrentStep(steps[nextIndex].id)
}
}
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,
})
} catch (err) {
console.error('Failed to save preferences:', err)
} finally {
setSaving(false)
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 flex items-center justify-center">
<div className="text-center">
<Loader2 className="w-8 h-8 animate-spin text-primary-600 mx-auto mb-4" />
<p className="text-slate-600">Setting up your account...</p>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white">
<header className="bg-white/80 backdrop-blur-sm border-b border-slate-200 sticky top-0 z-40">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<Link to="/" className="flex items-center gap-2">
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center shadow-lg shadow-primary-500/20">
<Mail className="w-5 h-5 text-white" />
</div>
<span className="text-lg font-bold text-slate-900">
Email<span className="text-primary-600">Sorter</span>
</span>
</Link>
<Button variant="ghost" onClick={() => navigate('/dashboard')}>
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 border border-green-200 rounded-xl p-6 mb-6 flex items-start gap-4">
<div className="w-10 h-10 rounded-full bg-green-500 flex items-center justify-center flex-shrink-0">
<Check className="w-6 h-6 text-white" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-green-900 mb-1">Payment successful!</h3>
<p className="text-sm text-green-700">
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">
{/* 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 text-white shadow-lg shadow-green-500/30'
: index === stepIndex
? 'bg-primary-500 text-white ring-4 ring-primary-100 shadow-lg shadow-primary-500/30'
: 'bg-slate-100 text-slate-400'
}`}
>
{index < stepIndex ? <Check className="w-5 h-5" /> : index + 1}
</div>
<p className={`mt-2 text-xs font-medium hidden sm:block transition-colors ${
index <= stepIndex ? 'text-slate-900' : 'text-slate-400'
}`}>
{step.title}
</p>
</div>
{index < steps.length - 1 && (
<div className={`w-16 sm:w-24 h-1 mx-2 rounded-full transition-colors duration-500 ${
index < stepIndex ? 'bg-green-500' : 'bg-slate-200'
}`} />
)}
</div>
))}
</div>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl flex items-center gap-3 text-red-700">
<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 mb-3">Connect your email account</h1>
<p className="text-lg text-slate-600 mb-10 max-w-md mx-auto">
Choose your email provider. The connection is secure and your data stays private.
</p>
<div className="grid sm:grid-cols-2 gap-4 max-w-lg mx-auto">
<button
onClick={handleConnectGmail}
disabled={connecting !== null}
className="flex items-center gap-4 p-6 bg-white rounded-2xl border-2 border-slate-200 hover:border-red-300 hover:shadow-xl hover:shadow-red-500/10 transition-all text-left group disabled:opacity-50 disabled:cursor-not-allowed"
>
{connecting === 'gmail' ? (
<Loader2 className="w-12 h-12 animate-spin text-red-500" />
) : (
<div className="w-12 h-12 rounded-xl bg-red-50 flex items-center justify-center group-hover:bg-red-100 transition-colors">
<svg viewBox="0 0 24 24" className="w-7 h-7">
<path fill="#EA4335" d="M5.26 9.71L12 14.04l6.74-4.33-6.74-4.33z"/>
<path fill="#34A853" d="M12 14.04l6.74-4.33v7.65c0 .7-.57 1.26-1.26 1.26H6.52c-.7 0-1.26-.57-1.26-1.26V9.71l6.74 4.33z"/>
<path fill="#4285F4" d="M18.74 5.38H5.26c-.7 0-1.26.57-1.26 1.26v3.07l8 5.13 8-5.13V6.64c0-.7-.57-1.26-1.26-1.26z"/>
<path fill="#FBBC05" d="M4 9.71V6.64c0-.7.57-1.26 1.26-1.26h.01L12 9.71 4 13.84V9.71z"/>
</svg>
</div>
)}
<div className="flex-1">
<p className="font-semibold text-slate-900">Gmail</p>
<p className="text-sm text-slate-500">Google Workspace</p>
</div>
<ChevronRight className="w-5 h-5 text-slate-400 group-hover:text-red-500 group-hover:translate-x-1 transition-all" />
</button>
<button
onClick={handleConnectOutlook}
disabled={connecting !== null}
className="flex items-center gap-4 p-6 bg-white rounded-2xl border-2 border-slate-200 hover:border-blue-300 hover:shadow-xl hover:shadow-blue-500/10 transition-all text-left group disabled:opacity-50 disabled:cursor-not-allowed"
>
{connecting === 'outlook' ? (
<Loader2 className="w-12 h-12 animate-spin text-blue-500" />
) : (
<div className="w-12 h-12 rounded-xl bg-blue-50 flex items-center justify-center group-hover:bg-blue-100 transition-colors">
<svg viewBox="0 0 24 24" className="w-7 h-7">
<path fill="#0078D4" d="M11.5 3v8.5H3V3h8.5zm1 0H21v8.5h-8.5V3zM3 12.5h8.5V21H3v-8.5zm9.5 0H21V21h-8.5v-8.5z"/>
</svg>
</div>
)}
<div className="flex-1">
<p className="font-semibold text-slate-900">Outlook</p>
<p className="text-sm text-slate-500">Microsoft 365</p>
</div>
<ChevronRight className="w-5 h-5 text-slate-400 group-hover:text-blue-500 group-hover:translate-x-1 transition-all" />
</button>
</div>
<div className="mt-10 p-4 bg-slate-50 rounded-xl max-w-lg mx-auto">
<p className="text-sm text-slate-500">
🔒 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 mb-3">Sorting Settings</h1>
<p className="text-lg text-slate-600 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">
<CardContent className="p-8 space-y-8">
<div>
<label className="block text-sm font-semibold text-slate-900 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 bg-primary-50 shadow-lg shadow-primary-500/10'
: 'border-slate-200 hover:border-slate-300 bg-white'
}`}
>
<span className="text-2xl mb-2 block">{option.emoji}</span>
<p className="font-semibold text-slate-900">{option.name}</p>
<p className="text-xs text-slate-500 mt-1">{option.desc}</p>
</button>
))}
</div>
</div>
<div className="flex items-center justify-between p-5 bg-gradient-to-r from-slate-50 to-slate-100 rounded-xl">
<div>
<p className="font-semibold text-slate-900">Historical emails</p>
<p className="text-sm text-slate-500">Analyze and sort last 30 days</p>
</div>
<button
onClick={() => setPreferences(p => ({ ...p, historicalSync: !p.historicalSync }))}
className={`w-14 h-8 rounded-full transition-all duration-300 ${
preferences.historicalSync ? 'bg-primary-500 shadow-lg shadow-primary-500/30' : 'bg-slate-300'
}`}
>
<div className={`w-6 h-6 bg-white 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 mb-3">Choose your categories</h1>
<p className="text-lg text-slate-600 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 bg-primary-50 shadow-lg shadow-primary-500/10'
: 'border-slate-200 bg-white hover:border-slate-300 hover:shadow-md'
}`}
>
<div className={`w-12 h-12 rounded-xl ${category.color} flex items-center justify-center text-2xl shadow-lg`}>
{category.icon}
</div>
<div className="flex-1">
<p className="font-semibold text-slate-900">{category.name}</p>
<p className="text-sm text-slate-500">{category.description}</p>
</div>
<div className={`w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all ${
selectedCategories.includes(category.id)
? 'border-primary-500 bg-primary-500'
: 'border-slate-300'
}`}>
{selectedCategories.includes(category.id) && <Check className="w-4 h-4 text-white" />}
</div>
</button>
))}
</div>
<p className="text-center text-sm text-slate-500 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 flex items-center justify-center shadow-2xl shadow-green-500/20 animate-pulse">
<Sparkles className="w-14 h-14 text-green-600" />
</div>
<h1 className="text-4xl font-bold text-slate-900 mb-4">All set! 🎉</h1>
<p className="text-xl text-slate-600 mb-10 max-w-md mx-auto">
Your email account is connected. The AI will now start intelligent sorting.
</p>
<div className="inline-flex items-center gap-4 p-5 bg-gradient-to-r from-slate-50 to-slate-100 rounded-2xl mb-10 shadow-lg">
<div className="w-14 h-14 rounded-xl bg-white flex items-center justify-center shadow-md">
<Mail className="w-7 h-7 text-primary-500" />
</div>
<div className="text-left">
<p className="font-semibold text-slate-900 text-lg">
{connectedProvider === 'gmail' ? 'Gmail' : connectedProvider === 'outlook' ? 'Outlook' : 'Email'} connected
</p>
<p className="text-slate-500">{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">
<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: any) {
setStatus('error')
setError(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: any) {
setError(err.message || 'Fehler beim Senden')
} finally {
setStatus('error')
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Logo */}
<Link to="/" className="flex items-center justify-center gap-2 mb-8">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center">
<Mail className="w-5 h-5 text-white" />
</div>
<span className="text-xl font-bold text-slate-900">
Email<span className="text-primary-600">Sorter</span>
</span>
</Link>
<Card className="shadow-xl border-0">
<CardHeader className="text-center pb-2">
<CardTitle className="text-2xl">
{status === 'loading' && 'E-Mail wird verifiziert...'}
{status === 'success' && 'E-Mail verifiziert!'}
{status === 'error' && 'Verifizierung fehlgeschlagen'}
</CardTitle>
<CardDescription>
{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 mb-4" />
<p className="text-slate-500">Verifizierung läuft...</p>
</div>
)}
{status === 'success' && (
<div className="text-center py-8">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-green-100 flex items-center justify-center">
<CheckCircle className="w-10 h-10 text-green-600" />
</div>
<div className="space-y-4">
<div className="p-4 bg-green-50 border border-green-100 rounded-xl">
<p className="text-green-700 font-medium">
Dein Account ist jetzt vollständig aktiviert!
</p>
</div>
<p className="text-slate-600">
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 flex items-center justify-center">
<XCircle className="w-10 h-10 text-red-600" />
</div>
<div className="space-y-4">
<div className="p-4 bg-red-50 border border-red-100 rounded-xl">
<p className="text-red-700">
{error || 'Der Verifizierungslink ist ungültig oder abgelaufen.'}
</p>
</div>
<p className="text-slate-600 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 mt-6">
Probleme? Kontaktiere uns unter{' '}
<a href="mailto:support@emailsorter.de" className="text-primary-600 hover:underline">
support@emailsorter.de
</a>
</p>
</div>
</div>
)
}

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,286 @@
# Influencer Outreach Templates für EmailSorter
## Outreach-Strategie Übersicht
### Zielgruppen für Influencer
1. **Productivity-Influencer** (10K-100K Follower)
- Tech-Tipps & Life Hacks
- Selbstständige/Freelancer
- Remote Work Content
2. **Freelancer-Influencer** (5K-50K Follower)
- Business-Tipps
- Work-Life-Balance
- Productivity-Tools
3. **Startup-Influencer** (10K-100K Follower)
- Entrepreneurship Content
- SaaS-Tools
- Business Growth
4. **Tech-Influencer** (20K-200K Follower)
- App-Reviews
- AI-Tools
- Digital Solutions
---
## Outreach Template #1: Personal & Warm (Empfohlen)
**Betreff:** Können wir zusammenarbeiten? [Name] + EmailSorter
```
Hey [Name],
ich verfolge deinen Content schon länger - besonders dein Video über [spezifisches Video/Thema] hat mir richtig geholfen. 💯
Ich habe EmailSorter gebaut, einen AI E-Mail-Sortierer, der genau das Problem löst, das du in deinem letzten Video angesprochen hast: [spezifisches Problem erwähnen].
Die Idee:
• KI sortiert E-Mails automatisch in Kategorien
• Spart 2+ Stunden pro Woche
• Perfekt für Freelancer & Unternehmer
Ich würde es super finden, wenn du es mal testen könntest. Falls es dir gefällt, könnten wir über eine Kooperation sprechen:
Option 1: Affiliate-Partnership (20-30% Provision pro Sale)
Option 2: Sponsored Post (Einmalzahlung)
Option 3: Kostenloser Zugang gegen ehrliche Review/Erwähnung
Was denkst du? Ich schicke dir gerne einen kostenlosen Zugang zum Testen.
Grüße,
[Dein Name]
P.S.: Falls du nicht interessiert bist, ist das total okay - ich mag deinen Content trotzdem weiterhin! 😊
```
---
## Outreach Template #2: Kürzer & Direkter
**Betreff:** Kooperation: EmailSorter + [Kanal-Name]
```
Hi [Name],
kurz und knapp: Ich habe EmailSorter gebaut - ein AI-Tool, das E-Mails automatisch sortiert.
Ich dachte, es könnte zu deiner Audience passen, weil du Content über [Thema] machst.
Möchtest du es kostenlos testen? Falls ja, können wir über eine Kooperation sprechen.
Kurzfassung:
✓ AI sortiert E-Mails automatisch
✓ Spart Zeit (2+ Std/Woche)
✓ 14 Tage kostenloser Trial
Sag einfach Bescheid, wenn Interesse besteht!
Grüße,
[Dein Name]
```
---
## Outreach Template #3: Value-First (Keine Verkaufsaussage)
**Betreff:** Kostenloses Tool für deine Community
```
Hey [Name],
ich bin ein großer Fan deines Contents und wollte dir etwas zeigen, das deiner Community helfen könnte.
Ich habe EmailSorter entwickelt - ein Tool, das E-Mails automatisch mit KI sortiert. Viele Freelancer/Unternehmer (wie deine Audience) haben das Problem mit unorganisierten E-Mails.
Ich dachte, es könnte für dich und deine Follower interessant sein. Falls ja, kannst du es gerne kostenlos nutzen - keine Verpflichtungen!
Falls du darüber berichten möchtest, wäre das natürlich super, aber kein Muss.
Interesse? Dann melde dich einfach!
Grüße,
[Dein Name]
```
---
## Follow-Up Template (nach 5-7 Tagen)
**Betreff:** Quick Follow-Up: EmailSorter
```
Hey [Name],
nur ein kurzes Follow-Up zu meiner letzten Nachricht - ich weiß, dass dein Postfach voll sein kann! 😅
Falls du EmailSorter mal testen möchtest, sag einfach Bescheid. Falls nicht, auch völlig in Ordnung.
Grüße,
[Dein Name]
```
---
## Influencer-Liste Template (für interne Organisation)
### Liste erstellen mit:
1. **Influencer Name:** @username
2. **Plattform:** TikTok/YouTube/Instagram
3. **Follower:** ~XX.XXX
4. **Niche:** Productivity/Freelancer/Startup
5. **Engagement Rate:** ~X% (basierend auf letzten 10 Posts)
6. **Kontakt:** Email/DM
7. **Outreach Status:** Not Contacted / Contacted / Responded / In Discussion / Partnered / Declined
8. **Notes:** Besondere Infos, warum er/sie passt
---
## Kooperations-Modelle (ausführlich)
### Modell 1: Affiliate-Partnership
**Für dich:** 20-30% Provision pro verkauftem Abo
**Für Influencer:** Passives Einkommen, kein Risiko
**Vorteil:** Win-Win, langfristige Partnerschaft möglich
**Vorlage für Affiliate-Agreement:**
```
EmailSorter Affiliate-Partnership
• Provision: 25% pro Sale (Monats- oder Jahresabo)
• Tracking: Einzigartiger Affiliate-Link
• Auszahlung: Monatlich, bei 50€+ Guthaben
• Materialien: Banner, Video-Assets, Promo-Codes
• Term: Keine Bindung, jederzeit kündbar
```
---
### Modell 2: Sponsored Post (Einmalzahlung)
**Für dich:** Einmalige Erwähnung/Review
**Für Influencer:** Festes Honorar (50-500€ je nach Reichweite)
**Vorteil:** Klare Vereinbarung, einmalig
**Preis-Leitfaden:**
- 5K-10K Follower: 50-100€
- 10K-50K Follower: 100-250€
- 50K-100K Follower: 250-500€
- 100K+ Follower: 500€+ (individuell verhandeln)
---
### Modell 3: Product for Post (Kostenloser Zugang)
**Für dich:** Kostenloser Zugang (normal 9-19€/Monat)
**Für Influencer:** Ehrliche Review/Erwähnung
**Vorteil:** Niedriges Budget, authentisch
**Erwartungen klar kommunizieren:**
- "Keine Verpflichtung, aber ehrliche Meinung wäre super"
- "Wenn es dir nicht gefällt, sag es auch"
- "Falls du es magst, wäre eine kurze Erwähnung toll"
---
### Modell 4: Ambassador-Programm (Langfristig)
**Für dich:** Monatliche Vergütung + Provision
**Für Influencer:** Regelmäßige Erwähnungen, exklusiver Zugang
**Vorteil:** Langfristige Partnerschaft, höhere Conversion
**Beispiel:**
```
EmailSorter Ambassador
• Monatliches Honorar: 200€
• Plus 20% Provision pro Sale durch deinen Code
• Regelmäßige Content-Collabs (2-4x/Monat)
• Exklusiver Zugang zu neuen Features
• Term: 3-6 Monate (verlängerbar)
```
---
## Outreach-Checkliste
### Vor dem Kontakt:
- [ ] Influencer-Profile durchgeschaut (letzte 10 Posts)
- [ ] Engagement Rate geprüft (echte Follower?)
- [ ] Kontakt-E-Mail/DM-Adresse gefunden
- [ ] Personalisierung vorbereitet (spezifisches Video/Thema erwähnen)
- [ ] Eigenes Profil professionell (falls er/sie zurückcheckt)
### Bei Kontakt:
- [ ] Persönliche Nachricht (kein Spam-Template)
- [ ] Wert anbieten (kostenloser Zugang)
- [ ] Flexible Kooperations-Modelle
- [ ] Keine Druckausübung
- [ ] Freundlich & respektvoll
### Nach Kontakt:
- [ ] Follow-Up nach 5-7 Tagen (falls keine Antwort)
- [ ] Geduldig sein (Influencer sind beschäftigt)
- [ ] Bei Absage höflich bleiben (vielleicht später)
- [ ] Bei Interesse schnell reagieren
---
## Tracking & Organisation
### Excel/Google Sheets Vorlage:
| Name | Platform | Follower | Niche | Status | Contact Date | Response | Notes |
|------|----------|----------|-------|--------|--------------|----------|-------|
| @username1 | TikTok | 25K | Productivity | Contacted | 2026-01-20 | - | - |
| @username2 | YouTube | 45K | Freelancer | Partnered | 2026-01-18 | Yes | 20% affiliate |
### UTM-Parameter für Tracking:
Für jeden Influencer einen eigenen UTM:
```
https://deine-domain.de/register?utm_source=influencer&utm_medium=tiktok&utm_campaign=@username&utm_content=promo-code-XYZ
```
---
## Erfolgsmetriken
### KPIs für Influencer-Kooperationen:
- **Reach:** Wie viele Leute haben den Content gesehen?
- **Click-Through Rate:** Wie viele haben auf den Link geklickt?
- **Conversion Rate:** Wie viele haben sich registriert?
- **ROI:** Revenue durch Influencer vs. Kosten (Honorar/Provision)
### Beispiel-Berechnung:
```
Influencer mit 50K Follower
Post-Reach: 30.000 (60% von Followern)
CTR: 2% = 600 Clicks
Conversion: 5% = 30 Signups
Free-to-Paid: 10% = 3 Paid Users
MRR: 3 × 9€ = 27€/Monat
Kosten:
- Sponsored Post: 250€ (einmalig)
- Oder Affiliate: 0€ vorab, ~7€/Monat Provision
ROI nach 1 Jahr:
27€ × 12 = 324€ Revenue
- 250€ Kosten = 74€ Profit
Oder bei Affiliate: 324€ Revenue, ~81€ Provision = 243€ Profit
```
---
## Red Flags (Influencer meiden)
**Fake Follower:** Engagement Rate < 1%
**Nicht passende Nische:** Komplett andere Zielgruppe
**Zu teuer:** Forderung > 1000€ bei < 100K Follower
**Unprofessionell:** Schlechte Kommunikation, unzuverlässig
**Negative Reviews:** Bekannt für schlechte Kooperationen
---
*Zuletzt aktualisiert: 2026-01-20*

View File

@@ -0,0 +1,88 @@
# Logo-Anleitung für TikTok
## Verfügbare Logos
Ich habe 3 verschiedene Logo-Varianten für dich erstellt:
### 1. `logo-emailsorter-icon-only.svg` ⭐ **EMPFOHLEN für TikTok**
- **Nur Icon** (kein Text)
- Perfekt für Profilbilder (quadratisch, 1:1)
- Modern und clean
- Grüner Hintergrund mit weißem E-Mail-Icon
### 2. `logo-emailsorter-simple.svg`
- Icon + Text "EmailSorter"
- Gut für Header-Bilder oder größere Formate
- Grüner Gradient-Hintergrund
### 3. `logo-emailsorter.svg`
- Detailliertes Design mit mehr Elementen
- Gut für größere Formate
---
## Verwendung für TikTok
### Profilbild (empfohlen: `logo-emailsorter-icon-only.svg`)
1. **SVG zu PNG konvertieren:**
- Öffne die SVG-Datei in einem Browser (Chrome, Edge, Firefox)
- Rechtsklick → "Bild speichern unter" → Als PNG speichern
- Oder nutze einen Online-Konverter: https://cloudconvert.com/svg-to-png
2. **Größe für TikTok:**
- TikTok Profilbild: **400x400px** (Minimum)
- Optimal: **512x512px** oder **1024x1024px**
- Format: PNG oder JPG
3. **Upload:**
- TikTok App öffnen
- Profil → Bearbeiten → Profilbild ändern
- PNG-Datei hochladen
---
## Alternative: Logo online erstellen
Falls du ein anderes Design möchtest, kannst du auch kostenlose Tools nutzen:
### Kostenlose Logo-Ersteller:
- **Canva** (https://canva.com) - Viele Templates, einfach zu bedienen
- **LogoMaker** (https://logomaker.com) - KI-generierte Logos
- **Hatchful** (https://hatchful.shopify.com) - Shopify's kostenloser Logo-Maker
### Design-Tipps für TikTok:
- **Einfach halten** - Weniger ist mehr
- **Hoher Kontrast** - Gut sichtbar auch als kleines Profilbild
- **Quadratisch** - 1:1 Format (512x512px optimal)
- **Grüne Farben** - Passt zu deiner Marke (EmailSorter)
---
## Farben für dein Logo
Basierend auf deinem Projekt:
- **Primär:** `#22c55e` (Grün)
- **Sekundär:** `#16a34a` (Dunkleres Grün)
- **Akzent:** `#10b981` (Emerald)
- **Hintergrund:** Weiß oder grüner Gradient
---
## Schnellstart
1. Öffne `logo-emailsorter-icon-only.svg` im Browser
2. Rechtsklick → "Bild speichern unter" → Als PNG speichern
3. Falls nötig, Größe anpassen (512x512px)
4. In TikTok hochladen
**Fertig!** 🎉
---
## Weitere Optionen
Falls du das Logo anpassen möchtest:
- SVG-Dateien können mit jedem Text-Editor bearbeitet werden
- Oder nutze kostenlose Tools wie Inkscape (https://inkscape.org)
- Ich kann auch Anpassungen vornehmen (andere Farben, Text, etc.)

View File

@@ -0,0 +1,428 @@
# Product Hunt Launch Guide für EmailSorter
## Vorbereitung (2-3 Wochen vor Launch)
### Woche 1: Community Building
**1. Account vorbereiten**
- [ ] Product Hunt Account erstellen/verifizieren
- [ ] Profilbild + Bio optimieren
- [ ] Maker-Profil vervollständigen
**2. Hunter finden**
- [ ] Hunter mit großer Followerschaft identifizieren (10K+ Upvotes)
- [ ] Produkte in ähnlicher Nische finden (Email, Productivity, AI)
- [ ] Top Hunters kontaktieren (siehe Template unten)
**3. Social Media vorbereiten**
- [ ] Twitter/X Account bereit für Launch
- [ ] LinkedIn Post vorbereiten
- [ ] Community-Gruppen identifizieren (Indie Hackers, etc.)
---
### Woche 2: Assets erstellen
**1. Launch-Assets**
- [ ] **Screenshots:** 3-5 hochwertige Produkt-Screenshots
- Dashboard Overview
- Before/After E-Mail-Inbox
- Kategorien-Ansicht
- Statistiken/Metrics
- [ ] **GIFs/Videos:** Kurze Demo (30-60 Sek)
- Screen Recording der E-Mail-Sortierung
- Quick Setup Tutorial
- Before/After Transformation
- [ ] **Thumbnail:** Eye-catching Hauptbild (1200x675px)
- Zeigt Hauptbenefit (organisierte E-Mails)
- Brand Colors
- Clear Value Proposition
**2. Text-Content**
- [ ] **Tagline:** Ein Satz der das Problem löst
- Beispiele:
- "AI sorts your emails automatically, saving 2+ hours per week"
- "Your inbox, finally organized. AI-powered email categorization."
- "Never miss an important email again. AI auto-sorts your inbox."
- [ ] **Description:** 2-3 Absätze
```
📧 EmailSorter uses AI to automatically categorize your emails
while you sleep, saving you 2+ hours per week.
Perfect for:
• Freelancers juggling multiple clients
• Entrepreneurs managing business communications
• Remote workers drowning in email chaos
Features:
✓ AI-powered categorization (Important, Invoices, Newsletters, etc.)
✓ Gmail & Outlook support
✓ Automatic sorting while you sleep
✓ Privacy-first (GDPR compliant)
✓ 14-day free trial, no credit card required
Try EmailSorter today and reclaim your inbox! 🚀
```
- [ ] **Topics/Tags:** 5 relevant topics
- Email Management
- Productivity
- AI Tools
- SaaS
- Business Tools
---
### Woche 3: Finale Vorbereitung
**1. Landing Page optimieren**
- [ ] Product Hunt Badge/Banner hinzufügen ("Made the Front Page!")
- [ ] Launch-Day Countdown (optional)
- [ ] Social Proof vorbereiten
**2. Email-Liste vorbereiten**
- [ ] E-Mail an bestehende Nutzer (wenn vorhanden)
- [ ] E-Mail an Newsletter-Subscribers
- [ ] E-Mail an Freunde/Family für Support
**3. Support vorbereiten**
- [ ] FAQ-Artikel vorbereiten
- [ ] Support-Channels ready (Discord, Email, etc.)
- [ ] Onboarding optimieren für neue Nutzer
---
## Launch-Day Strategie
### Tag davor (Launch um 00:01 PST/PDT)
**Am Vortag:**
- [ ] Posting um 00:01 PST veröffentlichen (8:01 Uhr CET / 9:01 Uhr CEST)
- [ ] Twitter Post sofort nach Launch
- [ ] LinkedIn Post veröffentlichen
- [ ] In Community-Gruppen posten (Indie Hackers, etc.)
### Launch-Day Timeline
**00:01 PST (Launch):**
- [ ] Posting auf Product Hunt live
- [ ] Social Media Posts veröffentlichen
- [ ] Freunde/Family benachrichtigen
**08:00 PST (erste 8 Stunden):**
- [ ] Aktive Kommunikation mit Kommentaren
- [ ] Antworte auf alle Fragen
- [ ] Share in Communities
**16:00 PST (Nachmittag):**
- [ ] Update-Post mit Status/Statistiken
- [ ] Weitere Communities ansprechen
- [ ] Dankes-Messages an Unterstützer
**00:00 PST (Ende des Tages):**
- [ ] Finale Updates
- [ ] Dankes-Post für alle Unterstützer
- [ ] Ergebnis teilen
---
## Launch-Day Checkliste
### Pre-Launch (Tag davor)
- [ ] Hunter bestätigt
- [ ] Alle Assets hochgeladen
- [ ] Text-Content finalisiert
- [ ] Social Media Posts vorbereitet
- [ ] Email-Listen bereit
- [ ] Team/Freunde informiert
### Launch-Moment (00:01 PST)
- [ ] Posting veröffentlichen
- [ ] Social Media Posts raus
- [ ] Direkte Nachrichten an Close Circle
- [ ] Post in Indie Hackers / Twitter
- [ ] Email an bestehende Nutzer (wenn vorhanden)
### Während des Tages
- [ ] Alle Kommentare beantworten
- [ ] Upvotes anerkennen (thank you!)
- [ ] Aktive Community-Engagement
- [ ] Updates bei Meilensteinen teilen
- [ ] Probleme schnell lösen (wenn auftreten)
### Nach dem Launch
- [ ] Ergebnis analysieren
- [ ] Top Kommentare beantworten
- [ ] Follow-up Posts in den nächsten Tagen
- [ ] Dankes-Messages an Top Supporters
- [ ] Badge auf Landing Page einbauen
---
## Hunter-Outreach Template
### Template #1: Erste Anfrage
**Betreff:** Would you like to hunt EmailSorter on Product Hunt?
```
Hey [Hunter Name],
I've been following your Product Hunt hunts and love how you support
AI and productivity tools.
I've built EmailSorter - an AI tool that automatically sorts emails
into categories (Important, Invoices, Newsletters, etc.), saving
users 2+ hours per week.
I'm planning to launch on [DATE] and would be honored if you'd
consider hunting it.
I can provide:
✓ All launch assets (screenshots, GIFs, videos)
✓ Complete product description
✓ Launch strategy & timeline
✓ Your name in the maker list (of course!)
Would you be interested? Happy to answer any questions!
Best,
[Your Name]
P.S.: You can test EmailSorter free for 14 days if you'd like to try it first.
```
---
### Template #2: Follow-Up (nach 5-7 Tagen)
**Betreff:** Quick follow-up: EmailSorter Product Hunt launch
```
Hey [Hunter Name],
Just a quick follow-up to my last message about EmailSorter.
Launch is planned for [DATE]. Still interested in hunting it?
If not, totally fine - just wanted to check in!
Best,
[Your Name]
```
---
## Social Media Posts für Launch-Day
### Twitter/X Post
```
🚀 Just launched EmailSorter on @ProductHunt!
AI sorts your emails automatically, saving 2+ hours per week.
📧 Never miss important emails again
⏰ Auto-sorting while you sleep
🎯 Perfect for freelancers & entrepreneurs
Support us: [Product Hunt Link]
#ProductHunt #EmailProductivity #AITools
```
---
### LinkedIn Post
```
I'm excited to share that EmailSorter is live on Product Hunt today! 🎉
As a freelancer, I was drowning in emails - spending hours sorting
newsletters from invoices, important messages from spam.
So I built EmailSorter: AI that automatically categorizes your emails
while you sleep, saving 2+ hours per week.
Perfect for:
✓ Freelancers juggling multiple clients
✓ Entrepreneurs managing business communications
✓ Anyone who wants their inbox organized
Would love your support: [Product Hunt Link]
If you have any questions or feedback, I'm here to chat!
#ProductHunt #SaaS #EmailProductivity
```
---
### Indie Hackers Post
```
Launched EmailSorter on Product Hunt today! 🚀
TL;DR: AI sorts your emails automatically, saving 2+ hours per week.
Problem: I was spending 2+ hours daily just sorting emails.
Solution: Built an AI that categorizes emails while I sleep.
Result: Reclaimed my inbox, saved 10+ hours per week.
If you'd like to support the launch: [Product Hunt Link]
Happy to answer questions about the build process, AI implementation,
or anything else!
#ProductHunt #IndieHackers #SaaS
```
---
## Communities zum Teilen
### Am Launch-Tag posten in:
1. **Indie Hackers**
- [ ] Post in "Ship" Kategorie
- [ ] Einladung zu Feedback
2. **Twitter/X**
- [ ] Post mit Product Hunt Link
- [ ] Hashtags: #ProductHunt #IndieHackers #SaaS
3. **LinkedIn**
- [ ] Post mit Story + Link
- [ ] In relevanten Gruppen teilen
4. **Reddit** (vorsichtig, Subreddit-Regeln prüfen!)
- [ ] r/SideProject (wenn erlaubt)
- [ ] r/productivity (als hilfreiches Tool, nicht direkt als Promotion)
5. **Discord Communities**
- [ ] Indie Hackers Discord
- [ ] Product Hunt Discord
- [ ] SaaS-Communities
---
## Erfolgs-Metriken
### Ziele für Launch-Day
**Optimistisch:**
- Top 5 Product of the Day
- 500+ Upvotes
- 50+ Kommentare
- Top 20 Product of the Week
**Realistisch:**
- Top 10 Product of the Day
- 200-500 Upvotes
- 20-50 Kommentare
- Top 50 Product of the Week
**Minimum:**
- Top 20 Product of the Day
- 100+ Upvotes
- 10+ Kommentare
- Top 100 Product of the Week
### Nach-Launch Metriken
**Woche 1:**
- Website Traffic Spike
- Signups durch Product Hunt
- Conversion Rate aus PH Traffic
- Press/Coverage (falls vorhanden)
**Woche 2-4:**
- Langfristige Traffic-Steigerung
- SEO-Boost durch Backlinks
- Community-Wachstum
- Paid Conversions
---
## FAQ für Launch-Day
### Häufige Fragen vorbereiten:
**Q: How does it work?**
A: Connect your Gmail or Outlook account. Our AI analyzes your emails
and sorts them into categories (Important, Invoices, Newsletters, etc.)
automatically. You can customize categories and rules to fit your needs.
**Q: Is it safe? What about privacy?**
A: Yes, we're GDPR compliant and only access emails you explicitly
authorize. We never read email content beyond what's needed for
categorization. You can revoke access anytime.
**Q: How much does it cost?**
A: 14-day free trial, no credit card required. After that:
- Basic: €9/month (500 emails/day)
- Pro: €19/month (Unlimited)
- Business: €49/month (Teams)
**Q: What email providers are supported?**
A: Currently Gmail and Outlook. More providers coming soon!
**Q: How accurate is the AI?**
A: Our AI has a 95%+ accuracy rate. You can always manually adjust
categories and train the AI on your preferences.
---
## Post-Launch Strategie
### Tag nach Launch
- [ ] Dankes-Post an Community
- [ ] Analyse der Metriken
- [ ] Follow-up mit Top Supportern
- [ ] Blog-Post über Launch-Erfahrung
### Woche nach Launch
- [ ] Badge auf Landing Page
- [ ] Testimonials von PH-Nutzern sammeln
- [ ] Press-Kit für weitere Coverage
- [ ] Nächste Schritte planen
### Monat nach Launch
- [ ] Retrospektive: Was hat funktioniert?
- [ ] Was kann besser werden beim nächsten Launch?
- [ ] Feedback in Produktentwicklung integrieren
---
## Produkt-Hunt-Spezifische Tipps
1. **Timing ist alles:** 00:01 PST Launch maximiert Visibility
2. **Engagement zählt:** Kommentare beantworten = mehr Upvotes
3. **Visuelle Assets:** GIFs/Videos performen besser als Screenshots
4. **Storytelling:** Persönliche Story > Feature-Liste
5. **Community:** Ehrliches Engagement > Selbstdarstellung
---
## Notfall-Plan
### Wenn Launch nicht wie geplant läuft:
**Plan B: Re-Launch**
- Warte 6-12 Monate
- Verbessere Produkt basierend auf Feedback
- Baue Community vorher auf
- Versuche es erneut
**Plan C: Soft Launch**
- Launch ohne Hunter
- Selbst posten
- Lerne aus dem Prozess
- Nächstes Mal mit Hunter
---
*Zuletzt aktualisiert: 2026-01-20*

216
marketing/README.md Normal file
View File

@@ -0,0 +1,216 @@
# Marketing & Promotion Strategie für EmailSorter
## Übersicht
Dieses Verzeichnis enthält alle Marketing-Dokumente und Strategien für die Promotion von EmailSorter. Die Strategie fokussiert sich auf organisches Wachstum durch TikTok, YouTube, Influencer-Marketing und Product Hunt.
---
## Dokumentation
### 1. **USPs_AND_MESSAGING.md**
Zielgruppen, Unique Selling Points, Messaging-Framework und Copy-Strategie für alle Kanäle.
**Verwendung:**
- Landing Page Copy
- Social Media Posts
- Werbetexte
- Email-Kampagnen
---
### 2. **TIKTOK_SETUP_GUIDE.md**
Kompletter Guide zum TikTok Account-Setup, Bio-Optimierung und ersten Schritten.
**Verwendung:**
- Account-Erstellung
- Profil-Optimierung
- Erste Videos planen
---
### 3. **TIKTOK_CONTENT_SCRIPTS.md**
10 sofort umsetzbare Video-Ideen mit Scripts, Hook-Formeln und Posting-Strategie.
**Verwendung:**
- Video-Ideen generieren
- Scripts als Vorlage nutzen
- Content-Plan erstellen
---
### 4. **YOUTUBE_STRATEGY.md**
Strategie für YouTube Shorts und Long-Form Content, SEO-Optimierung und Channel-Setup.
**Verwendung:**
- YouTube Channel aufbauen
- Video-Formate planen
- SEO-Optimierung
---
### 5. **INFLUENCER_OUTREACH_TEMPLATES.md**
Outreach-Templates, Kooperations-Modelle und Influencer-Liste-Vorlage.
**Verwendung:**
- Influencer anschreiben
- Kooperationen verhandeln
- Outreach organisieren
---
### 6. **PRODUCT_HUNT_LAUNCH_GUIDE.md**
Vollständiger Guide für Product Hunt Launch inkl. Vorbereitung, Launch-Day-Strategie und Assets.
**Verwendung:**
- Product Hunt Launch vorbereiten
- Launch-Day planen
- Post-Launch Strategie
---
## Implementierung im Code
### Analytics & Tracking
Das Analytics-System ist bereits implementiert:
- **Frontend:** `client/src/lib/analytics.ts` - UTM-Parameter Tracking
- **Frontend Hook:** `client/src/hooks/useAnalytics.ts` - React Hook für Analytics
- **Backend:** `server/routes/analytics.mjs` - Analytics Endpoint
**Verwendung:**
```typescript
import { trackSignup, trackPageView } from '@/lib/analytics'
// Track signup conversion
trackSignup(userId, email)
// Track page view
trackPageView()
```
**UTM-Parameter werden automatisch:**
- Aus URL gelesen
- In localStorage gespeichert (30 Tage)
- Bei Conversions mitgesendet
---
## Quick-Start Checkliste
### Phase 1: Vorbereitung (Woche 1-2)
- [ ] USPs & Messaging definiert (`USPs_AND_MESSAGING.md`)
- [ ] Demo-Material erstellt (Screenshots, Videos)
- [ ] TikTok Account erstellt (`TIKTOK_SETUP_GUIDE.md`)
- [ ] YouTube Channel erstellt (`YOUTUBE_STRATEGY.md`)
- [ ] Analytics implementiert (bereits erledigt ✓)
### Phase 2: Content-Start (Woche 2-6)
- [ ] 5 TikTok Videos erstellt (`TIKTOK_CONTENT_SCRIPTS.md`)
- [ ] 3 YouTube Shorts erstellt
- [ ] Bio-Links mit UTM-Parametern eingerichtet
- [ ] Posting-Schedule etabliert
### Phase 3: Wachstum (Woche 6-12)
- [ ] Influencer-Outreach gestartet (`INFLUENCER_OUTREACH_TEMPLATES.md`)
- [ ] Product Hunt Launch vorbereitet (`PRODUCT_HUNT_LAUNCH_GUIDE.md`)
- [ ] Analytics ausgewertet und optimiert
- [ ] Content-Strategie verfeinert
---
## UTM-Parameter Übersicht
### Für Social Media Links:
**TikTok:**
```
https://deine-domain.de/register?utm_source=tiktok&utm_medium=social&utm_campaign=organic
```
**YouTube:**
```
https://deine-domain.de/register?utm_source=youtube&utm_medium=video&utm_campaign=[video-title]
```
**Influencer:**
```
https://deine-domain.de/register?utm_source=influencer&utm_medium=[platform]&utm_campaign=[influencer-name]
```
**Product Hunt:**
```
https://deine-domain.de/register?utm_source=producthunt&utm_medium=referral&utm_campaign=launch
```
**Tracking:** Alle UTM-Parameter werden automatisch im Analytics-System erfasst.
---
## Metriken & Erfolgsmessung
### Wichtige KPIs:
**Social Media:**
- Follower Growth
- Engagement Rate (Likes, Comments, Shares)
- Click-Through Rate (Bio-Link)
**Website:**
- Traffic (nach Quelle)
- Conversion Rate (Visits → Signups)
- Free Trial → Paid Conversion
**Product:**
- Signups pro Tag
- Free Trial Start Rate
- Paid Subscription Rate
- Monthly Recurring Revenue (MRR)
### Analytics Dashboard:
In Development: Analytics Dashboard für Marketing-Metriken (später implementieren).
---
## Budget-Empfehlung
### Minimal-Start (200-400€):
- Ring Light + Mikrofon: 50-100€
- Canva Pro (Thumbnails): 12€/Monat
- Micro-Influencer Tests: 100-200€
- **Gesamt Start:** ~200-400€
### Scaling (später):
- Ads (TikTok/YouTube): 50-100€/Monat
- Influencer-Partnerships: 200-500€/Monat
- Content-Creation Tools: 20-50€/Monat
---
## Nächste Schritte
1. **Heute:** TikTok Account erstellen, erstes Video aufnehmen
2. **Diese Woche:** 5 Videos posten, Bio optimieren
3. **Nächste Woche:** YouTube Channel starten, Analytics verfolgen
4. **In 4 Wochen:** Influencer-Outreach starten
5. **In 8-12 Wochen:** Product Hunt Launch vorbereiten
---
## Support & Fragen
Bei Fragen zur Marketing-Strategie:
1. Dokumentation in diesem Verzeichnis durchlesen
2. Best Practices aus den Guides befolgen
3. Experimentieren & Metriken verfolgen
**Wichtigster Tipp:** Starte jetzt, nicht perfekt. Konsistenz schlägt Perfektion.
---
*Zuletzt aktualisiert: 2026-01-20*

View File

@@ -0,0 +1,527 @@
# TikTok Content Scripts & Ideen für EmailSorter
## Optimized Scripts (Based on User Input)
### Script Variant 1: Direct Problem Hook (Recommended)
**Timing: 15-25 seconds**
**Hook (0-3 sec):**
*Show messy inbox on screen*
"Do you have a messy email inbox too?"
**Problem (3-8 sec):**
*Point to screen showing hundreds of emails*
"Hundreds of emails, nothing organized, everything mixed together..."
**Solution (8-18 sec):**
*Switch to EmailSorter dashboard*
"I built EmailSorter - an AI that automatically sorts your emails into categories and labels. You get fewer emails to deal with, everything organized."
**CTA (18-25 sec):**
*Show before/after split screen*
"Try it today - link in bio, 14 days free!"
---
### Script Variant 2: Relatable Hook (More Engaging)
**Timing: 15-25 seconds**
**Hook (0-3 sec):**
*Show messy inbox*
"Stop scrolling if your email inbox looks like this..."
**Problem (3-9 sec):**
*Zoom in on chaotic inbox*
"Unorganized emails everywhere, can't find anything important, everything's a mess..."
**Solution (9-18 sec):**
*Switch to EmailSorter*
"I created EmailSorter - AI automatically organizes your emails with smart labels and categories. Less clutter, more focus."
**CTA (18-25 sec):**
"Want this too? Link in bio - try it free for 14 days!"
---
### Script Variant 3: Question Hook (Best for Engagement)
**Timing: 15-25 seconds**
**Hook (0-3 sec):**
"Do you also have a messy email account?"
**Problem (3-9 sec):**
*Show inbox with many unsorted emails*
"Tons of emails, nothing organized, everything mixed up..."
**Solution (9-19 sec):**
*Show EmailSorter in action*
"Well, I built EmailSorter - an AI that sorts your emails automatically. You get organized inboxes with labels and categories, fewer emails to stress about."
**CTA (19-25 sec):**
"Try it today - 14 days free trial, link in bio!"
---
### Script Variant 4: POV Hook (Most Viral Potential)
**Timing: 15-25 seconds**
**Hook (0-3 sec):**
"POV: You open your email and see hundreds of unorganized messages..."
**Problem (3-9 sec):**
*Show chaotic inbox*
"Everything's mixed together, can't tell what's important, total chaos..."
**Solution (9-19 sec):**
*Show EmailSorter dashboard*
"That's why I built EmailSorter. AI automatically sorts your emails into categories with smart labels. Clean inbox, less stress."
**CTA (19-25 sec):**
"Try it free for 14 days - link in bio!"
---
### Visual Guide for All Scripts:
**Screen Recording Sequence:**
1. **0-3 sec:** Show messy Gmail inbox (top right corner, many unread emails, no good labels)
2. **3-9 sec:** Zoom in on chaos, show confusion
3. **9-19 sec:** Switch to EmailSorter dashboard showing organized categories
4. **19-25 sec:** Show before/after comparison or product logo
**Text Overlays (Optional):**
- "Before" / "After" labels
- "AI-Powered" badge
- "14 Days Free" callout
- Arrow pointing to link in bio
**Hashtags:**
#emailhack #productivity #inboxzero #aitools #emailproductivity #freelancer #startup #techtools
**Background Music Recommendations:**
**Option 1: Upbeat/Productive (Recommended)**
- "Upbeat Corporate" or "Motivational Background" (TikTok Sounds)
- "Productivity Vibes" (TikTok Sounds)
- "Tech Startup" (TikTok Sounds)
- Energy level: Medium-high, keeps viewers engaged
**Option 2: Trendy/Modern**
- Search TikTok for trending sounds in #productivity or #tech niches
- Use sounds with 100K+ uses for algorithm boost
- Examples: "That's a vibe" remixes, "Let's go" variations
**Option 3: Calm/Professional**
- "Ambient Work Music" (TikTok Sounds)
- "Lo-Fi Productivity" (TikTok Sounds)
- "Calm Background Music" (TikTok Sounds)
- Better for tutorial/educational tone
**Option 4: Trend-Jacking (Best for Viral)**
- Use currently trending sounds (check TikTok "Trending" tab)
- Look for sounds with rising usage in last 24-48 hours
- Match the energy to your script (upbeat for problem/solution, calm for tutorial)
**Music Tips:**
- Keep volume at 20-30% (your voice should be clear)
- Use TikTok's built-in music library (copyright-safe)
- Test with headphones to ensure balance
- Avoid music with lyrics during your speaking parts
- Instrumental tracks work best for product demos
**Quick Search Terms in TikTok:**
- "productivity music"
- "tech background"
- "corporate upbeat"
- "motivational instrumental"
**Caption/Description Templates:**
**Template 1: Direct & Simple (Recommended)**
```
Do you also have a messy email inbox? 📧
I built EmailSorter - AI automatically sorts your emails into categories and labels. Less clutter, more focus! ✨
Try it free for 14 days - link in bio! 🔗
#emailhack #productivity #inboxzero #aitools #emailproductivity #freelancer #startup #techtools #smallbusiness #entrepreneur
```
**Template 2: Question-Based (Best for Engagement)**
```
Stop scrolling if your email inbox looks like this... 😅
Hundreds of emails, nothing organized, everything mixed together. Sound familiar?
I created EmailSorter to solve this - AI automatically organizes your emails with smart labels and categories. Game changer! 🚀
Try it free - link in bio! ↓
#emailhack #productivity #inboxzero #aitools #emailproductivity #freelancer #startup #techtools #remotework #lifehacks
```
**Template 3: Problem-Solution (Clear Value)**
```
POV: You open your email and see hundreds of unorganized messages... 📬
That's why I built EmailSorter. AI automatically sorts your emails into categories with smart labels. Clean inbox, less stress! ✨
14 days free trial - no credit card required! Link in bio 🔗
#emailhack #productivity #inboxzero #aitools #emailproductivity #freelancer #startup #techtools #tech #smallbusiness
```
**Template 4: Short & Punchy (Viral Potential)**
```
Messy inbox? I got you. 👇
EmailSorter = AI that organizes your emails automatically.
Try it free - link in bio! 🔗
#emailhack #productivity #inboxzero #aitools #emailproductivity #freelancer #startup #techtools
```
**Complete Hashtag Strategy:**
**Core Hashtags (Always Use - 3-5):**
- #emailhack (~500K posts)
- #productivity (~5M posts)
- #inboxzero (~200K posts)
- #aitools (~800K posts)
- #emailproductivity (~100K posts)
**Niche Hashtags (Rotate - 3-5):**
- #freelancer (~2M posts)
- #startup (~3M posts)
- #techtools (~1M posts)
- #remotework (~2M posts)
- #emailmanagement (~50K posts)
- #smallbusiness (~5M posts)
- #entrepreneur (~10M posts)
**Broad Hashtags (Use 1-2):**
- #tech (~20M posts)
- #lifehacks (~10M posts)
- #productivitytips (~500K posts)
- #businesshacks (~200K posts)
**Hashtag Rules:**
- Use 8-12 hashtags total (optimal for TikTok algorithm)
- Mix high-volume (5M+) and niche (50K-500K) hashtags
- Don't use all trending hashtags (looks spammy)
- Test different combinations to see what works
- Update hashtags based on what's trending in your niche
**Caption Best Practices:**
- Keep it under 150 characters for better engagement
- Use 1-2 emojis (not too many - looks unprofessional)
- Ask a question to encourage comments
- Include clear CTA (call-to-action)
- Use line breaks for readability
- Add "link in bio" or "🔗" to drive traffic
**Engagement Boosters:**
- End with a question: "What's your biggest email problem?"
- Use "POV:", "Stop scrolling if...", "Do you also..."
- Add urgency: "Try it today", "Limited time"
- Show value: "14 days free", "No credit card required"
---
## 10 Sofort umsetzbare Video-Ideen
### 1. Problem-Hook Video
**Hook (0-3 Sek):**
"POV: Du öffnest dein E-Mail-Postfach und siehst 500 ungelesene Nachrichten..."
**Problem zeigen (3-10 Sek):**
*Screen Recording: Chaotischer Posteingang mit hunderten ungelesenen E-Mails*
"Ich weiß nicht mehr, was wichtig ist, was ein Newsletter ist, was eine Rechnung..."
**Lösung (10-20 Sek):**
*Screen Recording: EmailSorter Dashboard*
"Also hab ich EmailSorter gebaut. Die KI sortiert meine E-Mails automatisch..."
**CTA (20-30 Sek):**
*Show Before/After*
"Vorher: Chaos. Nachher: Alles sortiert. Link in Bio, 14 Tage kostenlos testen!"
---
### 2. Transformation Video (Before/After)
**Hook:**
"Vorher vs. Nachher: Meine E-Mail-Inbox nach 1 Woche EmailSorter"
**Content:**
- Split Screen: Links "Vorher" (Chaos), Rechts "Nachher" (sortiert)
- Zeige Kategorien: Wichtig, Rechnungen, Newsletter, Social
- Statistik einblenden: "2 Stunden pro Woche gespart"
**CTA:**
"Willst du auch? Link in Bio ↓"
---
### 3. Tutorial "30 Sekunden Setup"
**Hook:**
"So sortiere ich meine E-Mails in 30 Sekunden - ohne Aufwand"
**Content:**
1. Öffne EmailSorter (3 Sek)
2. Klicke "E-Mail verbinden" (3 Sek)
3. Gmail/Outlook autorisieren (10 Sek)
4. Fertig! KI sortiert jetzt automatisch (5 Sek)
5. Zeige Ergebnis - sortierte E-Mails (9 Sek)
**CTA:**
"14 Tage kostenlos testen - Link in Bio!"
---
### 4. Behind the Scenes "Ich hab eine App gebaut"
**Hook:**
"POV: Du programmierst nachts eine App, weil dich dein Posteingang nervt..."
**Content:**
- Zeige Code-Snippets (nicht zu technisch)
- Zeige Entwicklungsprozess
- Zeige Resultat: Sortierte E-Mails
- Reaktion: "Es funktioniert tatsächlich!"
**CTA:**
"Wenn ihr es auch nutzen wollt - Link in Bio. 14 Tage kostenlos!"
---
### 5. Trend-Jacking "I Bet You Don't Know..."
**Hook:**
"I bet you don't know that AI can sort your emails while you sleep"
**Content:**
- Zeige EmailSorter in Aktion
- Zeige wie E-Mails über Nacht sortiert werden
- Zeige Dashboard mit Statistiken
**CTA:**
"Try it free for 14 days - link in bio!"
---
### 6. Pain Point "Relatable Moment"
**Hook:**
"Stopp scrolling wenn du auch jeden Tag im E-Mail-Chaos versinkst"
**Content:**
- Zeige typische Situation: Posteingang öffnen, überwältigt sein
- "Ich fühle dich" - Moment
- "Aber es gibt eine Lösung..."
- Zeige EmailSorter
**CTA:**
"Falls du es auch brauchst - Link in Bio!"
---
### 7. Statistik Video "Did You Know?"
**Hook:**
"Did you know the average person spends 2+ hours per day on emails?"
**Content:**
- Statistik-Grafik
- "Most of that time? Sorting and searching..."
- "What if AI could do that for you?"
- Zeige EmailSorter Demo
**CTA:**
"Save time with EmailSorter - 14 days free trial!"
---
### 8. User Testimonial (fake it till you make it)
**Hook:**
"My inbox looked like this 1 week ago..."
**Content:**
- Zeige "vorher" Screenshot
- "Now it's like this" - Nachher Screenshot
- "EmailSorter changed my life. No more email stress!"
**CTA:**
"Try it yourself - link in bio!"
---
### 9. Quick Tip Format
**Hook:**
"Here's how I organize 500+ emails per day without spending time on it"
**Content:**
- "I use EmailSorter - AI automatically sorts my emails"
- Zeige Kategorien
- Zeige wie einfach es ist
- "That's it. Game changer."
**CTA:**
"14 days free trial - link in Bio!"
---
### 10. Transformation Story
**Hook:**
"I built this app because my inbox was driving me crazy"
**Content:**
- Zeige Problem: Chaotischer Posteingang
- "So I coded EmailSorter..."
- Zeige Lösung: Sortierte E-Mails
- Zeige Statistiken: Zeit gespart, E-Mails sortiert
**CTA:**
"Now you can use it too - free trial in bio!"
---
## Viral-Formel Template
```
[HOOK 0-3 Sek]
→ [PROBLEM 3-10 Sek]
→ [LÖSUNG 10-20 Sek]
→ [CTA 20-30 Sek]
```
### Hook-Beispiele:
- "POV: Du..."
- "Stopp scrolling wenn..."
- "I bet you don't know..."
- "Did you know..."
- "Here's how I..."
### Problem-Beispiele:
- Zeige chaotischen Posteingang
- Zeige verwirrte Reaktion
- Zeige Statistik über Zeitverschwendung
### Lösungs-Beispiele:
- Screen Recording von EmailSorter
- Before/After Vergleich
- Demo der Funktionen
### CTA-Beispiele:
- "Link in Bio - 14 Tage kostenlos!"
- "Try it free - link below!"
- "Willst du auch? Link in Bio ↓"
---
## Hashtag-Strategie
### Core Hashtags (immer verwenden):
- #productivity
- #emailhack
- #inboxzero
### Niche Hashtags (abwechseln):
- #freelancer
- #startup
- #techtools
- #aitools
- #emailproductivity
- #remotework
- #emailmanagement
### Trending Hashtags (wenn passend):
- #smallbusiness
- #entrepreneur
- #tech
- #lifehacks
**Regel:** 3-5 Core Hashtags + 3-5 Niche Hashtags = 6-10 Hashtags total
---
## Posting-Schedule (Erste 4 Wochen)
### Woche 1: Aufbau
- **Montag:** Problem-Hook Video
- **Mittwoch:** Tutorial Video
- **Freitag:** Behind the Scenes
### Woche 2: Variation
- **Montag:** Transformation Video
- **Mittwoch:** Quick Tip
- **Freitag:** Trend-Jacking
### Woche 3: Engagement
- **Montag:** Statistik Video
- **Mittwoch:** Relatable Moment
- **Freitag:** User Testimonial
### Woche 4: Optimierung
- Wiederhole die Videos mit den besten Engagement-Raten
- Teste neue Formate
- Antworte auf Kommentare
---
## Engagement-Strategie
### In den ersten 30 Minuten nach Posting:
1. Antworte auf jeden Kommentar
2. Like alle Kommentare
3. Stelle Rückfragen, um Diskussion anzuregen
4. Teile in anderen TikTok-Communitys (wenn erlaubt)
### Content-Ideen für Kommentare:
- "Nutzt ihr EmailSorter schon?"
- "Wie organisiert ihr eure E-Mails?"
- "Was ist euer größtes E-Mail-Problem?"
---
## TikTok Bio Template
```
⚡ KI sortiert deine E-Mails automatisch
📧 Spare 2+ Stunden pro Woche
🔗 14 Tage kostenlos testen
↓ Link in Bio ↓
#emailhack #productivity #inboxzero
```
---
## Analytics Tracking
### Wichtige Metriken:
- **Views:** Ziel: 1000+ in Woche 1
- **Engagement Rate:** Ziel: 5%+
- **Click-Through Rate (Bio-Link):** Ziel: 1%+
- **Conversion Rate (Visits → Signups):** Ziel: 2%+
### Tracking UTM-Parameter:
Bio-Link sollte sein:
```
https://deine-domain.de/register?utm_source=tiktok&utm_medium=social&utm_campaign=organic
```
---
*Zuletzt aktualisiert: 2026-01-20*

View File

@@ -0,0 +1,64 @@
# TikTok Logo Upload - Schnellanleitung
## 🚀 Schnellste Methode (HTML-Datei)
1. **Öffne `logo-to-png.html`** im Browser (Chrome, Edge, Firefox)
2. **Wähle Größe:** 512x512px ist optimal für TikTok
3. **Klicke auf "Als PNG herunterladen"**
4. **Öffne TikTok App** → Profil → Bearbeiten → Profilbild ändern
5. **Lade die PNG-Datei hoch**
**Fertig!** 🎉
---
## Alternative Methoden
### Methode 1: Browser (Chrome/Edge)
1. Öffne `logo-emailsorter.svg` im Browser
2. Rechtsklick auf das Logo → "Bild speichern unter"
3. Wähle Format: PNG
4. Größe: 512x512px (Browser konvertiert automatisch)
### Methode 2: Online-Konverter
1. Gehe zu: https://cloudconvert.com/svg-to-png
2. Lade `logo-emailsorter.svg` hoch
3. Wähle Größe: 512x512px
4. Konvertiere und lade herunter
### Methode 3: Canva (falls du Anpassungen willst)
1. Gehe zu: https://canva.com
2. Erstelle neues Design: 512x512px
3. Lade SVG hoch und exportiere als PNG
---
## TikTok Profilbild Anforderungen
- **Format:** PNG oder JPG
- **Größe:** Minimum 400x400px
- **Optimal:** 512x512px oder 1024x1024px
- **Format:** Quadratisch (1:1)
---
## Dateien
- `logo-emailsorter.svg` - Original SVG (skalierbar)
- `logo-to-png.html` - **Einfachste Methode** - Öffnen und Download
- `TIKTOK_LOGO_ANLEITUNG.md` - Diese Anleitung
---
## Tipp
Die **HTML-Datei** (`logo-to-png.html`) ist die einfachste Methode:
- Öffnen im Browser
- Größe wählen
- Download klicken
- Fertig!
Keine zusätzlichen Tools nötig! ✨

View File

@@ -0,0 +1,318 @@
# TikTok Setup Guide für EmailSorter
## Account-Erstellung
### 1. Account-Typ wählen
**Option A: Personal Account (empfohlen für Start)**
- Einfacher zu starten
- Organisches Wachstum
- Authentischer wirkt
**Option B: Business Account (später)**
- Analytics-Features
- Werbe-Tools
- Profile-Website-Link
**Empfehlung:** Start mit Personal Account, upgrade später zu Business.
---
### 2. Account-Setup
**Username wählen:**
- Kurz, merkbar: `@emailsorter` oder `@inboxhack`
- Falls nicht verfügbar: `@emailsorter.app` oder `@emailsorterai`
**Bio schreiben (150 Zeichen):**
```
⚡ KI sortiert deine E-Mails automatisch
📧 Spare 2+ Stunden pro Woche
🔗 14 Tage kostenlos testen
↓ Link in Bio ↓
#emailhack #productivity #inboxzero
```
**Profilbild:**
- Logo oder Icon von EmailSorter
- Quadratisch (1:1), hochauflösend
- Erkennbar auch als Thumbnail
**Website-Link:**
- Landing Page mit UTM-Parameter:
```
https://deine-domain.de/register?utm_source=tiktok&utm_medium=social&utm_campaign=organic
```
---
## Erste Schritte
### Tag 1: Account optimieren
- [ ] Username setzen
- [ ] Bio schreiben
- [ ] Profilbild hochladen
- [ ] Website-Link einrichten
- [ ] Account auf "Public" stellen
- [ ] Erste 5 Videos vorbereiten
### Tag 2-7: Content-Start
- [ ] 5 Videos in Woche 1 posten
- [ ] Beste Zeiten testen (7-9 Uhr, 12-14 Uhr, 18-21 Uhr)
- [ ] Hashtags experimentieren
- [ ] Engagement verfolgen (Views, Likes, Comments)
### Woche 2-4: Optimierung
- [ ] Analytics auswerten (was funktioniert?)
- [ ] Mehr von erfolgreichen Formaten posten
- [ ] Community aufbauen (Kommentare beantworten)
- [ ] Cross-Promotion (YouTube, Instagram, etc.)
---
## Content-Strategie
### Video-Formate (siehe TIKTOK_CONTENT_SCRIPTS.md)
1. **Problem-Hook Videos** (15-30 Sek)
2. **Tutorial Videos** (30-60 Sek)
3. **Transformation Videos** (Before/After)
4. **Behind the Scenes** (15-45 Sek)
5. **Trend-Jacking** (15-30 Sek)
### Posting-Schedule
**Erste 4 Wochen:**
- **Minimum:** 3x pro Woche
- **Optimal:** 5x pro Woche
- **Beste Zeiten:** 7-9 Uhr, 12-14 Uhr, 18-21 Uhr (CET)
**Nach 4 Wochen:**
- Basierend auf Analytics optimieren
- Was funktioniert → mehr davon
- Was nicht funktioniert → weglassen/ändern
---
## Hashtag-Strategie
### Core Hashtags (immer verwenden):
- `#productivity` (~5M Posts)
- `#emailhack` (~500K Posts)
- `#inboxzero` (~200K Posts)
### Niche Hashtags (abwechseln):
- `#freelancer` (~2M Posts)
- `#startup` (~3M Posts)
- `#techtools` (~1M Posts)
- `#aitools` (~800K Posts)
- `#emailproductivity` (~100K Posts)
- `#remotework` (~2M Posts)
### Trending Hashtags (wenn passend):
- `#smallbusiness` (~5M Posts)
- `#entrepreneur` (~10M Posts)
- `#tech` (~20M Posts)
- `#lifehacks` (~10M Posts)
**Regel:** 3-5 Core Hashtags + 3-5 Niche Hashtags = 6-10 Hashtags total
---
## Posting-Best-Practices
### Upload-Optimierung
1. **Beste Zeiten:**
- 7-9 Uhr (Morgen-Commute)
- 12-14 Uhr (Mittagspause)
- 18-21 Uhr (Abend-Engagement)
2. **Video-Qualität:**
- Vertikal (9:16), 1080x1920px optimal
- HD-Qualität (mind. 720p)
- Gute Beleuchtung & Audio
3. **Hook in ersten 3 Sekunden:**
- Problem zeigen
- Frage stellen
- Interessantes Statement
4. **Engagement fördern:**
- Frage im Video stellen
- Kommentar-Pin verwenden
- Auf Kommentare schnell antworten
---
## Engagement-Strategie
### In ersten 30 Minuten nach Posting:
1. **Kommentare beantworten:**
- Jeden Kommentar innerhalb 1 Stunde beantworten
- Persönlich & authentisch
- Fragen stellen, Diskussion anregen
2. **Engagement boosten:**
- Like eigene Videos (nicht übertreiben!)
- Share in Story (wenn relevant)
- In anderen relevanten Videos kommentieren
3. **Community aufbauen:**
- Folge Accounts in ähnlicher Nische
- Engagiere dich in Kommentaren anderer Creator
- Teile wertvolle Tipps (nicht nur Promotion)
---
## Analytics verstehen
### Wichtige Metriken:
**Views:**
- Ziel: 1000+ Views in ersten 48h (guter Start)
- Viral: 100K+ Views in 48h
**Engagement Rate:**
- Likes: ~5-10% der Views (gut)
- Comments: ~1-2% der Views (gut)
- Shares: ~0.5-1% der Views (gut)
**Click-Through Rate (Bio-Link):**
- Ziel: 1%+ der Views
- Gut: 2-5% der Views
**Follower Growth:**
- Ziel: 50-100 Follower pro Woche (Start)
- Viral: 1000+ Follower pro Tag
---
## Content-Plan (Monat 1)
### Woche 1: Foundation
- **Montag:** Problem-Hook Video
- **Mittwoch:** Tutorial Video
- **Freitag:** Behind the Scenes
### Woche 2: Variation
- **Montag:** Transformation Video
- **Mittwoch:** Quick Tip
- **Freitag:** Trend-Jacking
### Woche 3: Engagement
- **Montag:** Statistik Video
- **Mittwoch:** Relatable Moment
- **Freitag:** User Testimonial
### Woche 4: Optimization
- **Montag:** Best Performing Format (Repost)
- **Mittwoch:** Neue Format testen
- **Freitag:** Community Q&A
---
## Equipment (Minimal)
### Budget: 0€
- **Kamera:** Handy-Kamera (Front-Kamera reicht)
- **Editor:** CapCut (kostenlos, sehr gut)
- **Audio:** Handy-Mikrofon (reicht für Start)
### Budget: 50-100€
- **Ring Light:** ~30-50€ (bessere Beleuchtung)
- **Mikrofon:** ~20-50€ (besseres Audio)
- **Dreibein:** ~10-20€ (stabilere Aufnahme)
---
## Häufige Fehler vermeiden
### ❌ Don'ts:
1. **Zu viel auf einmal:**
- Nicht 5 Videos an einem Tag posten
- Spacing: Minimum 3-4 Stunden zwischen Posts
2. **Zu wenig Variation:**
- Nicht immer gleiches Format
- Teste verschiedene Formate
3. **Zu verkaufsorientiert:**
- Nicht nur "Kauft mein Produkt!"
- 80% Mehrwert, 20% Promotion
4. **Schlechtes Timing:**
- Nicht mitten in der Nacht posten
- Beste Zeiten beachten
5. **Zu viele Hashtags:**
- Maximal 10-12 Hashtags
- Nicht 30+ Hashtags (wirkt spammy)
---
## Erfolgs-Tipps
### ✅ Do's:
1. **Konsistenz:**
- Regelmäßig posten (3-5x/Woche)
- Gleiche Tageszeiten (wenn möglich)
2. **Authentizität:**
- Sei du selbst, nicht perfekt
- Zeige Persönlichkeit
3. **Engagement:**
- Antworte auf Kommentare schnell
- Baue Community auf
4. **Experimentieren:**
- Teste verschiedene Formate
- Was funktioniert → mehr davon
5. **Geduld:**
- Wachstum dauert Zeit
- Erste 50 Videos sind zum Lernen
---
## Quick-Start Checkliste
### Heute (Tag 1):
- [ ] TikTok Account erstellen
- [ ] Username & Bio optimieren
- [ ] Profilbild hochladen
- [ ] Website-Link einrichten
### Diese Woche:
- [ ] 5 Video-Ideen brainstormen
- [ ] Erstes Video aufnehmen (Handy reicht!)
- [ ] Video mit CapCut schneiden
- [ ] Erstes Video posten
### Nächste Woche:
- [ ] 4 weitere Videos posten
- [ ] Analytics verfolgen
- [ ] Kommentare beantworten
- [ ] Community aufbauen
---
## Wichtigster Tipp
**Starte JETZT, nicht perfekt.**
Dein erstes Video wird nicht viral gehen - und das ist okay. Die ersten 50 Videos sind zum Lernen da. Konsistenz schlägt Perfektion.
Poste regelmäßig, sei authentisch, baue Community auf - der Rest kommt von alleine.
---
*Zuletzt aktualisiert: 2026-01-20*

View File

@@ -0,0 +1,263 @@
# USPs & Messaging Strategie für EmailSorter
## Zielgruppen-Definition
### Primäre Zielgruppe 1: Freelancer & Selbstständige
**Problem:**
- Hohe E-Mail-Flut durch diverse Projekte und Kunden
- Schwierigkeit, wichtige E-Mails von Newslettern zu unterscheiden
- Zeitdruck - keine Zeit für manuelle Sortierung
**USP:** "Spare 2+ Stunden pro Woche - KI sortiert deine E-Mails automatisch während du arbeitest"
**Pain Points:**
- 📧 200+ E-Mails pro Tag
- ⏰ Keine Zeit für manuelle Organisation
- 😰 Angst, wichtige Kunden-Mails zu übersehen
- 💼 Work-Life-Balance durch E-Mail-Stress gestört
---
### Primäre Zielgruppe 2: Kleine Unternehmer / Startup-Gründer
**Problem:**
- E-Mails von Investoren, Kunden, Partnern, Bewerbern vermischen sich
- Hoher Druck, alle wichtigen Nachrichten sofort zu sehen
- Begrenzte Ressourcen für manuelle Organisation
**USP:** "Fokus aufs Business, nicht aufs E-Mail-Sortieren - AI macht's automatisch"
**Pain Points:**
- 🚀 Gründungsstress + E-Mail-Chaos
- 📊 Wichtige Business-Infos gehen unter
- ⚡ Schnelle Reaktionszeiten nötig
- 💰 Kostenbewusstsein - keine teuren Tools
---
### Sekundäre Zielgruppe: Remote Workers & Digitale Nomaden
**Problem:**
- Asynchrone Kommunikation über Zeitzonen
- Mehrere E-Mail-Accounts (privat + beruflich)
- Wichtige Infos auf Reise schnell finden
**USP:** "Deine E-Mails sind organisiert, egal wo auf der Welt du bist"
---
### Tertiäre Zielgruppe: Studenten mit Bewerbungsstress
**Problem:**
- Wichtige E-Mails von Unis, Praktika, Jobs gehen unter
- Viele Newsletter abonniert
- Unorganisiertes Postfach
**USP:** "Nie wieder eine Zusage oder Deadline verpassen"
---
## Core Messaging Framework
### Haupt-Headlines (für Landing Page)
1. **Problem-fokussiert:**
- "500 ungelesene E-Mails? Wir sortieren das."
- "Dein Posteingang. Endlich unter Kontrolle."
2. **Lösungs-fokussiert:**
- "KI sortiert deine E-Mails, während du schläfst"
- "Automatische E-Mail-Organisation in 30 Sekunden eingerichtet"
3. **Ergebnis-fokussiert:**
- "Spare 2+ Stunden pro Woche mit automatischer E-Mail-Sortierung"
- "Nie wieder wichtige E-Mails übersehen"
---
## Unique Selling Points (USPs)
### Top 3 USPs (Reihenfolge nach Wichtigkeit)
1. **Zeitersparnis**
- "2+ Stunden pro Woche sparen"
- "Nie wieder manuell sortieren"
- Quantifizierbar, messbar
2. **Automatisierung**
- "KI sortiert während du schläfst"
- "Einmal einrichten, immer organisiert"
- Fokus auf "Set & Forget"
3. **Zuverlässigkeit**
- "Wichtige E-Mails landen immer da, wo sie sollen"
- "Nie wieder eine Deadline verpassen"
- Reduziert Stress & Angst
---
## TikTok/YouTube Messaging
### Hook-Formeln
**Problem-Hook:**
- "POV: Du hast 500 ungelesene E-Mails..."
- "Stopp scrolling wenn du auch jeden Tag im E-Mail-Chaos versinkst"
**Transformation-Hook:**
- "So sieht mein Posteingang nach 1 Woche EmailSorter aus"
- "Vorher vs. Nachher: Meine E-Mail-Inbox"
**Kuriositäts-Hook:**
- "Ich hab eine App gebaut, die..."
- "Diese KI sortiert meine E-Mails automatisch"
**Tutorial-Hook:**
- "So sortiere ich meine E-Mails in 30 Sekunden"
- "Inbox Zero ohne Aufwand - so geht's"
---
## Social Media Bio-Texte
### TikTok Bio (150 Zeichen)
```
⚡ KI sortiert deine E-Mails automatisch
📧 Spare 2+ Stunden pro Woche
🔗 14 Tage kostenlos testen
↓ Link in Bio ↓
```
### Instagram Bio
```
⚡ Dein Posteingang. Endlich organisiert.
📧 KI-gestützte E-Mail-Sortierung
💼 Für Freelancer & Unternehmer
🔗 Kostenloser 14-Tage-Trial
```
### YouTube Channel Description
```
EmailSorter hilft dir, deine E-Mails automatisch zu organisieren und Zeit zu sparen.
Unsere KI sortiert deine E-Mails in relevante Kategorien:
• Wichtig & Dringend
• Rechnungen & Finanzen
• Newsletter & Marketing
• Social Media & Benachrichtigungen
Perfekt für:
✓ Freelancer & Selbstständige
✓ Kleine Unternehmer & Gründer
✓ Remote Workers
Teste EmailSorter kostenlos für 14 Tage - keine Kreditkarte erforderlich.
```
---
## Call-to-Action (CTA) Varianten
### Primäre CTAs
- "Starte 14 Tage kostenlos" (am häufigsten)
- "Jetzt kostenlos testen"
- "Free Trial starten"
### Sekundäre CTAs
- "Wie es funktioniert"
- "Demo ansehen"
- "Für Teams"
### Conversion-optimierte CTAs
- "14 Tage kostenlos - keine Kreditkarte"
- "In 30 Sekunden eingerichtet"
- "Erste 50 E-Mails kostenlos"
---
## Ton & Voice Guidelines
### Brand Voice
- **Professionell, aber nahbar:** Nicht zu corporate, aber auch nicht zu lässig
- **Lösungsorientiert:** Fokus auf Benefits, nicht Features
- **Ehrlich & transparent:** Keine übertriebenen Versprechen
- **Enthusiastisch, nicht aufdringlich:** Positive Energie, aber authentisch
### Do's ✅
- Zeige echte Probleme der Zielgruppe
- Verwende konkrete Zahlen (2+ Stunden, 500 E-Mails)
- Erzähle Stories, nicht nur Features
- Zeige Vorher/Nachher Beispiele
### Don'ts ❌
- Keine technischen Jargons
- Keine übertriebenen Versprechen ("100% perfekt")
- Nicht zu viele Features auf einmal
- Kein Corporate-Sprech
---
## Messaging nach Kanal
### TikTok / Instagram Reels
- Kurz, visuell, emotional
- Problem → Lösung in 15-30 Sekunden
- Trends nutzen, authentisch wirken
### YouTube
- Tiefer gehend, mehr Erklärung
- Tutorials & Case Studies
- Längere Formate möglich (5-15 Min)
### Twitter/X
- Quick Tips & Insights
- Building in Public
- Engagement mit Community
### LinkedIn
- Business-fokussiert
- B2B Messaging
- Erfolgs-Stories
---
## Storytelling-Elemente
### Founder Story (für Content)
```
"Ich war frustriert. 300+ E-Mails jeden Tag, alles durcheinander,
wichtige Nachrichten gingen unter. Also hab ich EmailSorter gebaut
- jetzt sortiert die KI meine E-Mails automatisch, während ich
schlafe. Game Changer."
```
### User Success Story Template
```
"Vorher: [Problem]
Nachher: [Lösung/Ergebnis]
Dank EmailSorter: [Quantifizierbares Ergebnis]"
```
---
## Keyword-Strategie
### SEO Keywords
- E-Mail-Sortierung
- E-Mail-Organisation
- Automatische E-Mail-Sortierung
- Inbox Zero
- E-Mail-Produktivität
- E-Mail-Management Tool
### Social Media Hashtags
- #emailhack
- #productivity
- #inboxzero
- #freelancer
- #startup
- #techtools
- #aitools
- #emailproductivity
- #remotework
---
*Zuletzt aktualisiert: 2026-01-20*

View File

@@ -0,0 +1,358 @@
# YouTube Strategie für EmailSorter
## Strategische Entscheidung: Shorts vs. Long-Form
### Option A: YouTube Shorts (Empfohlen für Start)
**Vorteile:**
- Schneller Wachstum möglich
- Weniger Aufwand (Content von TikTok recyclen)
- Algorithmus begünstigt Shorts
- Höhere Reichweite bei weniger Followern
**Nachteile:**
- Weniger monétarisierbar
- Kurzlebig (trend-basiert)
### Option B: Long-Form Content
**Vorteile:**
- Bessere Monetarisierung
- Längerer Engagement
- Mehr SEO-Potenzial
- Authority Building
**Nachteile:**
- Mehr Aufwand pro Video
- Längere Zeit bis Erfolg
- Braucht konsistente Produktion
**Empfehlung:** Start mit Shorts, baue langfristig Long-Form auf.
---
## YouTube Shorts Strategie
### Content-Ideen (Recycling von TikTok)
1. **Problem-Lösung Shorts** (15-60 Sek)
- "POV: Du öffnest dein Postfach..."
- Zeige Problem → Lösung in kurzer Zeit
2. **Quick Tips** (30-60 Sek)
- "How to organize 500+ emails in 30 seconds"
- Step-by-Step Tutorials
3. **Transformation Videos** (30-60 Sek)
- Before/After E-Mail-Inbox
- Split-Screen Videos
4. **Behind the Scenes** (15-45 Sek)
- "I built an app that sorts my emails..."
- Entwickler-Story
### Posting-Schedule
- **Minimum:** 3x pro Woche
- **Optimal:** 5-7x pro Woche
- **Beste Zeiten:** 14-16 Uhr, 18-20 Uhr (CET)
### Hashtags für Shorts
- #shorts
- #emailhack
- #productivity
- #inboxzero
- #aitools
- #techtips
---
## Long-Form Content Strategie
### Video-Formate
#### 1. Tutorials (5-10 Min)
**Titel-Ideen:**
- "How to Organize Your Gmail Inbox in 10 Minutes"
- "Inbox Zero Method: Complete Guide"
- "Email Productivity Setup for Freelancers"
**Struktur:**
1. Hook (0-30 Sek): Problem zeigen
2. Intro (30-60 Sek): Was wirst du lernen
3. Main Content (3-8 Min): Step-by-Step Tutorial
4. Demo EmailSorter (1-2 Min): Zeige Tool in Aktion
5. CTA (30 Sek): Free Trial Link
---
#### 2. Case Studies (8-12 Min)
**Titel-Ideen:**
- "I Automated My Email Sorting for 30 Days - Here's What Happened"
- "How I Saved 10+ Hours Per Week With Email Automation"
- "Email Chaos to Email Zen: My Transformation Story"
**Struktur:**
1. Problem vorher (1-2 Min)
2. Lösung finden (2-3 Min)
3. Implementierung (2-3 Min)
4. Ergebnisse/Statistiken (2-3 Min)
5. Lessons Learned (1-2 Min)
6. CTA (30 Sek)
---
#### 3. Product Reviews (5-8 Min)
**Titel-Ideen:**
- "EmailSorter Review: Is AI Email Sorting Worth It?"
- "Testing EmailSorter for 7 Days - Honest Review"
- "EmailSorter vs. Manual Sorting: The Verdict"
**Struktur:**
1. Intro (30 Sek)
2. Features & Setup (2-3 Min)
3. Pros & Cons (2-3 Min)
4. Who is it for? (1 Min)
5. Final Verdict (30 Sek)
6. CTA (30 Sek)
---
#### 4. Building in Public (10-15 Min)
**Titel-Ideen:**
- "Building EmailSorter: From Idea to Launch"
- "How I Built an AI Email Sorter in 30 Days"
- "My SaaS Journey: Building EmailSorter"
**Struktur:**
1. The Problem (1-2 Min)
2. The Idea (1-2 Min)
3. Building Process (5-8 Min)
4. Challenges & Solutions (2-3 Min)
5. Launch & Results (1-2 Min)
6. CTA (30 Sek)
---
### Long-Form Posting-Schedule
- **Minimum:** 1x pro Woche
- **Optimal:** 2x pro Woche
- **Beste Zeiten:** Dienstag/Donnerstag, 16-18 Uhr (CET)
---
## SEO-Strategie für YouTube
### Titel-Optimierung
**Formel:** [Keyword] + [Benefit] + [Time/Number] + [Audience]
**Beispiele:**
- "Email Organization: Save 2 Hours Per Week (Freelancer Guide)"
- "AI Email Sorting: How to Organize 1000+ Emails Automatically"
- "Inbox Zero in 10 Minutes: Gmail Organization Tutorial"
### Beschreibung-Template
```
🚀 EmailSorter: [Main Benefit]
[2-3 Sätze über das Problem und die Lösung]
📋 In diesem Video:
• [Point 1]
• [Point 2]
• [Point 3]
• [Point 4]
⏱️ Timestamps:
0:00 - Intro
0:30 - [Section 1]
2:00 - [Section 2]
4:00 - [Section 3]
🔗 EmailSorter kostenlos testen:
[Link zur Landing Page mit UTM: ?utm_source=youtube&utm_medium=video&utm_campaign=[video-title]]
📧 Features:
✓ AI-powered email categorization
✓ Gmail & Outlook support
✓ Automatic sorting while you sleep
✓ 14-day free trial
🔔 Abonniere für mehr Productivity-Tipps!
#EmailProductivity #InboxZero #AITools #ProductivityTips
```
### Thumbnail-Gestaltung
**Elemente:**
- Großer, klarer Text (Problem oder Benefit)
- Before/After Kontrast
- Gesicht (falls vor der Kamera)
- Bright, kontrastreiche Farben
- Brand Colors (EmailSorter)
**Beispiele:**
- Split Screen: Chaos (links) vs. Organisiert (rechts)
- Großer Text: "500 E-Mails sortiert in 30 Sek"
- Emoji für visuellen Hook: 📧 ⚡
---
## Channel-Setup
### Channel-Name
- "EmailSorter" oder "EmailSorter Official"
### Channel-Beschreibung
```
EmailSorter helps you organize your inbox automatically with AI.
📧 Automatically categorize emails
⏰ Save 2+ hours per week
🚀 Perfect for freelancers & entrepreneurs
Try EmailSorter free for 14 days - no credit card required!
We share:
• Email productivity tips
• Inbox organization methods
• AI tool reviews
• Building in public (SaaS journey)
Subscribe for weekly productivity content!
```
### Channel-Art/Banner
- Hero-Image mit Produkt
- Value Proposition klar sichtbar
- Link zur Website (in Banner-Link)
### Playlists
1. **Getting Started**
- Setup Tutorials
- Quick Tips
- Features Overview
2. **Tutorials**
- Gmail Setup
- Outlook Setup
- Customization Guides
3. **Case Studies**
- User Stories
- Before/After
- Results & Metrics
4. **Building in Public**
- Development Updates
- Launch Stories
- Founder Journey
---
## Cross-Promotion Strategie
### TikTok → YouTube
- Ende jedes TikTok-Videos: "Full tutorial on YouTube! Link in bio"
- YouTube-Version ist länger, mehr Details
### YouTube → Website
- Beschreibung: Link zur Landing Page
- Cards/End Screens: CTA zur Website
- Video-CTA: "14 Tage kostenlos testen - Link in Beschreibung"
### YouTube → TikTok
- Shorts-Version von Long-Form Content
- "Full video on YouTube" im TikTok-Bio
---
## Monetarisierung (später)
### YouTube Monetization
- 1000 Subscriber + 4000 Watch Hours
- Affiliate Marketing
- Sponsorships (später)
### Website Conversion
- Video-CTAs zur Landing Page
- UTM-Tracking für Attribution
- Conversion-Optimierung
---
## Erfolgs-Metriken
### Shorts Metriken
- **Views:** Ziel: 1000+ in ersten 48h
- **Subscribers:** Ziel: 50-100 pro Monat
- **Click-Through Rate (Bio-Link):** Ziel: 1-2%
### Long-Form Metriken
- **Views:** Ziel: 500+ in ersten 7 Tagen
- **Watch Time:** Ziel: 50%+ Average View Duration
- **Subscribers:** Ziel: 100-200 pro Monat
- **Click-Through Rate:** Ziel: 2-5%
---
## Content-Kalender (Monat 1)
### Woche 1: Shorts Focus
- **Montag:** Problem-Hook Short
- **Mittwoch:** Tutorial Short
- **Freitag:** Transformation Short
### Woche 2: Long-Form Start
- **Montag:** Tutorial Short
- **Mittwoch:** Long-Form Tutorial (5-8 Min)
- **Freitag:** Quick Tip Short
### Woche 3: Mix
- **Montag:** Case Study Short
- **Mittwoch:** Behind the Scenes Short
- **Freitag:** Long-Form Case Study (8-10 Min)
### Woche 4: Optimization
- **Montag:** Best Performing Short (Repost)
- **Mittwoch:** Product Review (5-8 Min)
- **Freitag:** Community Q&A Short
---
## Equipment-Empfehlungen
### Minimum Setup (Budget: 0€)
- Handy-Kamera (Front-Kamera reicht)
- KOSTNADELOSER Video-Editor (CapCut, DaVinci Resolve)
- Screen Recording (OBS Studio kostenlos)
### Professional Setup (Budget: 200-500€)
- **Kamera:** Logitech C920/C930 (100€)
- **Mikrofon:** Blue Yeti oder Rode NT-USB (100-150€)
- **Licht:** Ring Light (50€)
- **Editor:** DaVinci Resolve (kostenlos) oder Final Cut Pro (300€)
---
## Wichtigste Tipps
1. **Konsistenz > Perfektion:** Regelmäßig posten > perfektes Video
2. **First 15 Sekunden:** Hook ist entscheidend
3. **Engagement fördern:** Fragen stellen, Kommentare anregen
4. **Thumbnail optimieren:** Teste verschiedene Designs
5. **SEO beachten:** Titel, Beschreibung, Tags optimieren
---
*Zuletzt aktualisiert: 2026-01-20*

View File

@@ -0,0 +1,35 @@
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<!-- Gradient background -->
<defs>
<linearGradient id="iconGradient" 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 circle -->
<circle cx="256" cy="256" r="240" fill="url(#iconGradient)"/>
<!-- Email Icon (white) -->
<g transform="translate(256, 256)">
<!-- Envelope -->
<rect x="-120" y="-90" width="240" height="180" rx="12" fill="white"/>
<!-- Envelope Flap -->
<path d="M -120,-90 L 0,-20 L 120,-90 Z" fill="white" opacity="0.95"/>
<!-- Letter inside -->
<rect x="-100" y="-50" width="200" height="140" rx="6" fill="#22c55e" opacity="0.15"/>
<!-- Sorted email lines -->
<line x1="-90" y1="-20" x2="90" y2="-20" stroke="white" stroke-width="5" stroke-linecap="round"/>
<line x1="-90" y1="5" x2="70" y2="5" stroke="white" stroke-width="5" stroke-linecap="round"/>
<line x1="-90" y1="30" x2="80" y2="30" stroke="white" stroke-width="5" stroke-linecap="round"/>
<line x1="-90" y1="55" x2="60" y2="55" stroke="white" stroke-width="5" stroke-linecap="round"/>
<!-- AI indicator (small dots) -->
<circle cx="-70" cy="-60" r="5" fill="white" opacity="0.8"/>
<circle cx="70" cy="-60" r="5" fill="white" opacity="0.8"/>
<circle cx="0" cy="-70" r="4" fill="white" opacity="0.9"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,32 @@
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<!-- Simple gradient background -->
<defs>
<linearGradient id="bgGradient" 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 -->
<rect width="512" height="512" rx="120" fill="url(#bgGradient)"/>
<!-- Email Icon (white) -->
<g transform="translate(256, 200)">
<!-- Envelope -->
<rect x="-100" y="-60" width="200" height="120" rx="6" fill="white"/>
<!-- Envelope Flap -->
<path d="M -100,-60 L 0,-10 L 100,-60 Z" fill="white" opacity="0.9"/>
<!-- Letter inside -->
<rect x="-80" y="-30" width="160" height="80" rx="3" fill="#22c55e" opacity="0.2"/>
<!-- Sorted lines -->
<line x1="-70" y1="-10" x2="70" y2="-10" stroke="white" stroke-width="4" stroke-linecap="round"/>
<line x1="-70" y1="10" x2="50" y2="10" stroke="white" stroke-width="4" stroke-linecap="round"/>
<line x1="-70" y1="30" x2="60" y2="30" stroke="white" stroke-width="4" stroke-linecap="round"/>
</g>
<!-- Text -->
<text x="256" y="340" font-family="Arial, sans-serif" font-size="64" font-weight="bold" fill="white" text-anchor="middle">EmailSorter</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,25 @@
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<!-- Background Circle -->
<circle cx="256" cy="256" r="240" fill="#22c55e" opacity="0.1"/>
<circle cx="256" cy="256" r="200" fill="#22c55e" opacity="0.15"/>
<!-- Main Email Icon -->
<g transform="translate(256, 256)">
<!-- Envelope -->
<rect x="-120" y="-80" width="240" height="160" rx="8" fill="#22c55e" stroke="#16a34a" stroke-width="4"/>
<!-- Envelope Flap -->
<path d="M -120,-80 L 0,-20 L 120,-80 Z" fill="#16a34a"/>
<!-- Letter inside -->
<rect x="-100" y="-40" width="200" height="120" rx="4" fill="white" opacity="0.9"/>
<!-- Lines on letter (representing sorted emails) -->
<line x1="-90" y1="-20" x2="90" y2="-20" stroke="#22c55e" stroke-width="3" stroke-linecap="round"/>
<line x1="-90" y1="0" x2="60" y2="0" stroke="#22c55e" stroke-width="3" stroke-linecap="round"/>
<line x1="-90" y1="20" x2="80" y2="20" stroke="#22c55e" stroke-width="3" stroke-linecap="round"/>
</g>
<!-- Text: ES (EmailSorter initials) -->
<text x="256" y="380" font-family="Arial, sans-serif" font-size="72" font-weight="bold" fill="#16a34a" text-anchor="middle">ES</text>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

181
marketing/logo-to-png.html Normal file
View File

@@ -0,0 +1,181 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EmailSorter Logo - PNG Export</title>
<style>
body {
margin: 0;
padding: 40px;
background: #f0f0f0;
font-family: Arial, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
gap: 30px;
}
.container {
background: white;
padding: 40px;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.logo-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
canvas {
border: 2px solid #e0e0e0;
border-radius: 8px;
background: white;
}
.instructions {
max-width: 600px;
background: #f9f9f9;
padding: 20px;
border-radius: 8px;
border-left: 4px solid #22c55e;
}
.instructions h2 {
margin-top: 0;
color: #16a34a;
}
.instructions ol {
line-height: 1.8;
}
.download-btn {
background: #22c55e;
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: background 0.3s;
}
.download-btn:hover {
background: #16a34a;
}
.size-options {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: center;
}
.size-btn {
background: #e0e0e0;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.size-btn.active {
background: #22c55e;
color: white;
}
</style>
</head>
<body>
<div class="container">
<h1 style="color: #16a34a; text-align: center;">📧 EmailSorter Logo</h1>
<div class="logo-container">
<div class="size-options">
<button class="size-btn active" onclick="setSize(400)">400x400px (TikTok Min)</button>
<button class="size-btn" onclick="setSize(512)">512x512px (Optimal)</button>
<button class="size-btn" onclick="setSize(1024)">1024x1024px (HD)</button>
</div>
<canvas id="logoCanvas"></canvas>
<button class="download-btn" onclick="downloadPNG()">📥 Als PNG herunterladen</button>
</div>
</div>
<div class="instructions">
<h2>📋 So verwendest du das Logo für TikTok:</h2>
<ol>
<li><strong>Größe wählen:</strong> Wähle oben eine Größe (512x512px ist optimal für TikTok)</li>
<li><strong>Download:</strong> Klicke auf "Als PNG herunterladen"</li>
<li><strong>TikTok öffnen:</strong> Öffne die TikTok App</li>
<li><strong>Profil bearbeiten:</strong> Gehe zu deinem Profil → Bearbeiten</li>
<li><strong>Profilbild ändern:</strong> Wähle "Profilbild ändern"</li>
<li><strong>Hochladen:</strong> Wähle die heruntergeladene PNG-Datei</li>
<li><strong>Fertig!</strong> 🎉</li>
</ol>
</div>
<script>
const svgContent = `<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<!-- Background Circle -->
<circle cx="256" cy="256" r="240" fill="#22c55e" opacity="0.1"/>
<circle cx="256" cy="256" r="200" fill="#22c55e" opacity="0.15"/>
<!-- Main Email Icon -->
<g transform="translate(256, 256)">
<!-- Envelope -->
<rect x="-120" y="-80" width="240" height="160" rx="8" fill="#22c55e" stroke="#16a34a" stroke-width="4"/>
<!-- Envelope Flap -->
<path d="M -120,-80 L 0,-20 L 120,-80 Z" fill="#16a34a"/>
<!-- Letter inside -->
<rect x="-100" y="-40" width="200" height="120" rx="4" fill="white" opacity="0.9"/>
<!-- Lines on letter (representing sorted emails) -->
<line x1="-90" y1="-20" x2="90" y2="-20" stroke="#22c55e" stroke-width="3" stroke-linecap="round"/>
<line x1="-90" y1="0" x2="60" y2="0" stroke="#22c55e" stroke-width="3" stroke-linecap="round"/>
<line x1="-90" y1="20" x2="80" y2="20" stroke="#22c55e" stroke-width="3" stroke-linecap="round"/>
</g>
<!-- Text: ES (EmailSorter initials) -->
<text x="256" y="380" font-family="Arial, sans-serif" font-size="72" font-weight="bold" fill="#16a34a" text-anchor="middle">ES</text>
</svg>`;
let currentSize = 512;
const canvas = document.getElementById('logoCanvas');
const ctx = canvas.getContext('2d');
function setSize(size) {
currentSize = size;
canvas.width = size;
canvas.height = size;
// Update active button
document.querySelectorAll('.size-btn').forEach(btn => btn.classList.remove('active'));
event.target.classList.add('active');
renderLogo();
}
function renderLogo() {
const img = new Image();
const svgBlob = new Blob([svgContent], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(svgBlob);
img.onload = function() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, currentSize, currentSize);
URL.revokeObjectURL(url);
};
img.src = url;
}
function downloadPNG() {
const link = document.createElement('a');
link.download = `emailsorter-logo-${currentSize}x${currentSize}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
}
// Initial render
setSize(512);
</script>
</body>
</html>

99
n8n/README.md Normal file
View File

@@ -0,0 +1,99 @@
# n8n Workflows für EmailSorter
Dieses Verzeichnis enthält optionale n8n Workflows zur E-Mail-Automatisierung.
## Voraussetzungen
1. **n8n Installation**
- Cloud: [n8n.io](https://n8n.io)
- Self-hosted: `npm install -g n8n` oder Docker
2. **Credentials einrichten**
- Gmail OAuth2 Credentials
- Mistral AI API Key (https://console.mistral.ai/)
- HTTP Header Auth für EmailSorter API
## Workflows
### email-sorter-workflow.json
Haupt-Workflow für die E-Mail-Sortierung:
1. **Webhook Trigger**: Empfängt Benachrichtigungen über neue E-Mails
2. **Gmail: E-Mail abrufen**: Holt E-Mail-Details
3. **Mistral AI: Klassifizieren**: KI kategorisiert die E-Mail
4. **Gmail: Label setzen**: Fügt entsprechendes Label hinzu
5. **Statistiken aktualisieren**: Sendet Update an EmailSorter API
## Setup
### 1. Workflow importieren
```bash
# n8n CLI
n8n import:workflow --input=workflows/email-sorter-workflow.json
# Oder über n8n UI: Settings > Import Workflow
```
### 2. Credentials konfigurieren
#### Gmail OAuth2
1. Google Cloud Console öffnen
2. OAuth 2.0 Client erstellen
3. In n8n: Credentials > Gmail OAuth2 > Authorize
#### Mistral AI API
1. Mistral API Key erstellen auf console.mistral.ai
2. In n8n: Credentials > HTTP Header Auth
3. Name: "Authorization", Value: "Bearer YOUR_MISTRAL_API_KEY"
### 3. Environment Variables
```env
EMAILSORTER_API_URL=http://localhost:3000
EMAILSORTER_API_KEY=your-api-key
```
### 4. Webhook URL notieren
Nach dem Aktivieren des Workflows wird eine Webhook-URL generiert:
```
https://your-n8n-instance.com/webhook/email-sorter-webhook
```
Diese URL im EmailSorter Backend konfigurieren.
## Anpassungen
### Eigene Kategorien hinzufügen
Im "OpenAI: Klassifizieren" Node den System-Prompt anpassen:
```
Kategorisiere in:
- vip: Wichtige Kontakte
- clients: Kunden
- ...
- eigene_kategorie: Beschreibung
```
### Newsletter archivieren
Nach dem Label-Node einen "Gmail: Archive" Node hinzufügen:
- Resource: Message
- Operation: Update
- Modify: Remove Label "INBOX"
## Monitoring
- Ausführungen in n8n UI überwachen
- Fehler-Benachrichtigungen einrichten
- Statistiken im EmailSorter Dashboard prüfen
## Skalierung
Für hohes E-Mail-Volumen:
- Queue Mode in n8n aktivieren
- Redis als Queue Backend nutzen
- Worker-Instanzen skalieren

View File

@@ -0,0 +1,225 @@
{
"name": "EmailSorter - Automatische E-Mail-Sortierung",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "email-webhook",
"responseMode": "onReceived",
"options": {}
},
"id": "webhook-trigger",
"name": "Email Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1,
"position": [250, 300],
"webhookId": "email-sorter-webhook"
},
{
"parameters": {
"resource": "message",
"operation": "get",
"messageId": "={{ $json.emailId }}",
"options": {
"format": "metadata"
}
},
"id": "gmail-get-email",
"name": "Gmail: E-Mail abrufen",
"type": "n8n-nodes-base.gmail",
"typeVersion": 2,
"position": [450, 200],
"credentials": {
"gmailOAuth2": {
"id": "gmail-credentials",
"name": "Gmail OAuth2"
}
}
},
{
"parameters": {
"url": "https://api.mistral.ai/v1/chat/completions",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "{\n \"model\": \"mistral-small-latest\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"Du bist ein E-Mail-Klassifizierungs-Assistent. Kategorisiere E-Mails in: vip, clients, invoices, newsletters, promos, social, security, shipping, review. Antworte NUR mit dem Kategorienamen.\"\n },\n {\n \"role\": \"user\",\n \"content\": \"Von: {{ $json.from }}\\nBetreff: {{ $json.subject }}\\nVorschau: {{ $json.snippet }}\"\n }\n ],\n \"temperature\": 0.1,\n \"max_tokens\": 50\n}",
"options": {}
},
"id": "mistral-classify",
"name": "Mistral AI: Klassifizieren",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [650, 200],
"credentials": {
"httpHeaderAuth": {
"id": "mistral-credentials",
"name": "Mistral API"
}
}
},
{
"parameters": {
"conditions": {
"string": [
{
"value1": "={{ $json.choices[0].message.content.trim() }}",
"operation": "contains",
"value2": "newsletter"
}
]
}
},
"id": "if-newsletter",
"name": "Ist Newsletter?",
"type": "n8n-nodes-base.if",
"typeVersion": 1,
"position": [850, 200]
},
{
"parameters": {
"resource": "message",
"operation": "addLabels",
"messageId": "={{ $('Gmail: E-Mail abrufen').item.json.id }}",
"labelIds": ["EmailSorter/Newsletter"]
},
"id": "gmail-label-newsletter",
"name": "Gmail: Newsletter Label",
"type": "n8n-nodes-base.gmail",
"typeVersion": 2,
"position": [1050, 100],
"credentials": {
"gmailOAuth2": {
"id": "gmail-credentials",
"name": "Gmail OAuth2"
}
}
},
{
"parameters": {
"resource": "message",
"operation": "addLabels",
"messageId": "={{ $('Gmail: E-Mail abrufen').item.json.id }}",
"labelIds": ["={{ 'EmailSorter/' + $('Mistral AI: Klassifizieren').item.json.choices[0].message.content.trim() }}"]
},
"id": "gmail-label-other",
"name": "Gmail: Kategorie Label",
"type": "n8n-nodes-base.gmail",
"typeVersion": 2,
"position": [1050, 300],
"credentials": {
"gmailOAuth2": {
"id": "gmail-credentials",
"name": "Gmail OAuth2"
}
}
},
{
"parameters": {
"url": "={{ $env.EMAILSORTER_API_URL }}/api/email/stats/update",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"bodyParameters": {
"parameters": [
{
"name": "category",
"value": "={{ $('OpenAI: Klassifizieren').item.json.message.content.trim() }}"
},
{
"name": "emailId",
"value": "={{ $('Gmail: E-Mail abrufen').item.json.id }}"
}
]
},
"options": {}
},
"id": "http-update-stats",
"name": "Statistiken aktualisieren",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [1250, 200]
}
],
"connections": {
"Email Webhook": {
"main": [
[
{
"node": "Gmail: E-Mail abrufen",
"type": "main",
"index": 0
}
]
]
},
"Gmail: E-Mail abrufen": {
"main": [
[
{
"node": "Mistral AI: Klassifizieren",
"type": "main",
"index": 0
}
]
]
},
"Mistral AI: Klassifizieren": {
"main": [
[
{
"node": "Ist Newsletter?",
"type": "main",
"index": 0
}
]
]
},
"Ist Newsletter?": {
"main": [
[
{
"node": "Gmail: Newsletter Label",
"type": "main",
"index": 0
}
],
[
{
"node": "Gmail: Kategorie Label",
"type": "main",
"index": 0
}
]
]
},
"Gmail: Newsletter Label": {
"main": [
[
{
"node": "Statistiken aktualisieren",
"type": "main",
"index": 0
}
]
]
},
"Gmail: Kategorie Label": {
"main": [
[
{
"node": "Statistiken aktualisieren",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
},
"staticData": null,
"tags": ["email", "automation", "ai"],
"pinData": {}
}

View File

@@ -3,16 +3,98 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bezahlung abgebrochen - Email Sortierer</title>
<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 style="max-width: 600px; margin: 50px auto; padding: 20px; text-align: center;">
<h1>❌ Bezahlung abgebrochen</h1>
<p>Die Bezahlung wurde abgebrochen oder ist fehlgeschlagen.</p>
<p>Keine Sorge - es wurde nichts berechnet.</p>
<p>Du kannst jederzeit zurückkehren und den Vorgang erneut versuchen.</p>
<br>
<a href="/" style="display: inline-block; padding: 10px 20px; background: #0066cc; color: white; text-decoration: none; border-radius: 5px;">Zurück zur Startseite</a>
<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>

View File

@@ -3,258 +3,699 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Email Sortierer</title>
<title>EmailSorter API</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-dark: #0a0a0f;
--bg-card: #12121a;
--bg-hover: #1a1a25;
--border: #2a2a3a;
--text: #e4e4e7;
--text-muted: #71717a;
--accent: #6366f1;
--accent-glow: rgba(99, 102, 241, 0.3);
--success: #10b981;
--warning: #f59e0b;
--error: #ef4444;
--get: #22c55e;
--post: #3b82f6;
--delete: #ef4444;
--patch: #f59e0b;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Space Grotesk', sans-serif;
background: var(--bg-dark);
color: var(--text);
min-height: 100vh;
line-height: 1.6;
}
/* Animated background */
.bg-pattern {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(circle at 20% 20%, var(--accent-glow) 0%, transparent 40%),
radial-gradient(circle at 80% 80%, rgba(16, 185, 129, 0.15) 0%, transparent 40%);
pointer-events: none;
z-index: 0;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
position: relative;
z-index: 1;
}
/* Header */
header {
text-align: center;
padding: 4rem 0 3rem;
}
.logo {
display: inline-flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.logo-icon {
width: 64px;
height: 64px;
background: linear-gradient(135deg, var(--accent), #8b5cf6);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
box-shadow: 0 0 40px var(--accent-glow);
}
h1 {
font-size: 2.5rem;
font-weight: 700;
background: linear-gradient(135deg, var(--text), var(--accent));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.subtitle {
color: var(--text-muted);
font-size: 1.1rem;
margin-top: 0.5rem;
}
/* Status Cards */
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 3rem;
}
.status-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
transition: all 0.3s ease;
}
.status-card:hover {
border-color: var(--accent);
transform: translateY(-2px);
}
.status-card h3 {
font-size: 0.875rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.status-value {
font-size: 1.5rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
animation: pulse 2s infinite;
}
.status-dot.healthy { background: var(--success); }
.status-dot.warning { background: var(--warning); }
.status-dot.error { background: var(--error); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* API Endpoints */
.section-title {
font-size: 1.5rem;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
}
.section-title::before {
content: '';
width: 4px;
height: 24px;
background: var(--accent);
border-radius: 2px;
}
.endpoint-group {
margin-bottom: 2rem;
}
.endpoint-group-title {
font-size: 1rem;
color: var(--text-muted);
margin-bottom: 1rem;
padding-left: 1rem;
border-left: 2px solid var(--border);
}
.endpoint {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
margin-bottom: 0.75rem;
overflow: hidden;
transition: all 0.2s ease;
}
.endpoint:hover {
border-color: var(--accent);
}
.endpoint-header {
padding: 1rem 1.25rem;
display: flex;
align-items: center;
gap: 1rem;
cursor: pointer;
}
.method {
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.75rem;
border-radius: 6px;
min-width: 70px;
text-align: center;
}
.method.get { background: rgba(34, 197, 94, 0.2); color: var(--get); }
.method.post { background: rgba(59, 130, 246, 0.2); color: var(--post); }
.method.delete { background: rgba(239, 68, 68, 0.2); color: var(--delete); }
.method.patch { background: rgba(245, 158, 11, 0.2); color: var(--patch); }
.endpoint-path {
font-family: 'JetBrains Mono', monospace;
font-size: 0.9rem;
flex: 1;
}
.endpoint-desc {
color: var(--text-muted);
font-size: 0.875rem;
}
.endpoint-details {
display: none;
padding: 1rem 1.25rem;
background: var(--bg-hover);
border-top: 1px solid var(--border);
}
.endpoint.open .endpoint-details {
display: block;
}
.endpoint-details pre {
font-family: 'JetBrains Mono', monospace;
font-size: 0.8rem;
background: var(--bg-dark);
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
}
.endpoint-details h4 {
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
margin-bottom: 0.5rem;
margin-top: 1rem;
}
.endpoint-details h4:first-child {
margin-top: 0;
}
/* Features */
.features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
margin-top: 3rem;
}
.feature-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
}
.feature-card h3 {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.feature-card p {
color: var(--text-muted);
font-size: 0.9rem;
}
/* Footer */
footer {
text-align: center;
padding: 3rem 0;
color: var(--text-muted);
font-size: 0.875rem;
}
footer a {
color: var(--accent);
text-decoration: none;
}
/* Loading animation */
.loading {
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Try button */
.try-btn {
background: var(--accent);
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s;
}
.try-btn:hover {
background: #4f46e5;
transform: scale(1.05);
}
</style>
</head>
<body>
<div id="app">
<h1>Email Sortierer</h1>
<div id="form-container"></div>
<div id="navigation">
<button id="prev-btn" style="display:none;">Zurück</button>
<button id="next-btn" style="display:none;">Weiter</button>
<div class="bg-pattern"></div>
<div class="container">
<header>
<div class="logo">
<div class="logo-icon">📧</div>
<div>
<h1>EmailSorter API</h1>
<p class="subtitle">KI-gestützte E-Mail-Sortierung</p>
</div>
</div>
</header>
<!-- Status Cards -->
<div class="status-grid">
<div class="status-card">
<h3>Status</h3>
<div class="status-value">
<span class="status-dot healthy" id="status-dot"></span>
<span id="status-text">Wird geladen...</span>
</div>
</div>
<div class="status-card">
<h3>Version</h3>
<div class="status-value" id="version">-</div>
</div>
<div class="status-card">
<h3>Uptime</h3>
<div class="status-value" id="uptime">-</div>
</div>
<div class="status-card">
<h3>Environment</h3>
<div class="status-value" id="environment">-</div>
</div>
</div>
<div id="summary" style="display:none;">
<h2>Zusammenfassung</h2>
<div id="summary-content"></div>
<button id="buy-btn">Jetzt kaufen</button>
<!-- API Endpoints -->
<h2 class="section-title">API Endpoints</h2>
<div class="endpoint-group">
<div class="endpoint-group-title">🔧 System</div>
<div class="endpoint">
<div class="endpoint-header" onclick="toggleEndpoint(this)">
<span class="method get">GET</span>
<span class="endpoint-path">/api/health</span>
<span class="endpoint-desc">Server Status prüfen</span>
<button class="try-btn" onclick="event.stopPropagation(); tryEndpoint('/api/health')">Testen</button>
</div>
<div class="endpoint-details">
<h4>Response</h4>
<pre id="health-response">{ "success": true, "data": { "status": "healthy", ... } }</pre>
</div>
</div>
<div class="endpoint">
<div class="endpoint-header" onclick="toggleEndpoint(this)">
<span class="method get">GET</span>
<span class="endpoint-path">/api/config</span>
<span class="endpoint-desc">Öffentliche Konfiguration</span>
<button class="try-btn" onclick="event.stopPropagation(); tryEndpoint('/api/config')">Testen</button>
</div>
<div class="endpoint-details">
<h4>Response</h4>
<pre id="config-response">Klicke auf "Testen"</pre>
</div>
</div>
</div>
<div class="endpoint-group">
<div class="endpoint-group-title">🔐 OAuth</div>
<div class="endpoint">
<div class="endpoint-header" onclick="toggleEndpoint(this)">
<span class="method get">GET</span>
<span class="endpoint-path">/api/oauth/status</span>
<span class="endpoint-desc">OAuth Provider Status</span>
<button class="try-btn" onclick="event.stopPropagation(); tryEndpoint('/api/oauth/status')">Testen</button>
</div>
<div class="endpoint-details">
<h4>Response</h4>
<pre id="oauth-response">Klicke auf "Testen"</pre>
</div>
</div>
<div class="endpoint">
<div class="endpoint-header" onclick="toggleEndpoint(this)">
<span class="method get">GET</span>
<span class="endpoint-path">/api/oauth/gmail/connect</span>
<span class="endpoint-desc">Gmail OAuth starten</span>
</div>
<div class="endpoint-details">
<h4>Query Parameter</h4>
<pre>userId: string (required)</pre>
<h4>Response</h4>
<pre>{ "success": true, "data": { "url": "https://accounts.google.com/..." } }</pre>
</div>
</div>
<div class="endpoint">
<div class="endpoint-header" onclick="toggleEndpoint(this)">
<span class="method get">GET</span>
<span class="endpoint-path">/api/oauth/outlook/connect</span>
<span class="endpoint-desc">Outlook OAuth starten</span>
</div>
<div class="endpoint-details">
<h4>Query Parameter</h4>
<pre>userId: string (required)</pre>
<h4>Response</h4>
<pre>{ "success": true, "data": { "url": "https://login.microsoftonline.com/..." } }</pre>
</div>
</div>
</div>
<div class="endpoint-group">
<div class="endpoint-group-title">📬 E-Mail</div>
<div class="endpoint">
<div class="endpoint-header" onclick="toggleEndpoint(this)">
<span class="method get">GET</span>
<span class="endpoint-path">/api/email/accounts</span>
<span class="endpoint-desc">Verbundene E-Mail-Konten</span>
</div>
<div class="endpoint-details">
<h4>Query Parameter</h4>
<pre>userId: string (required)</pre>
<h4>Response</h4>
<pre>{
"success": true,
"data": [
{ "id": "...", "email": "user@gmail.com", "provider": "gmail", "connected": true }
]
}</pre>
</div>
</div>
<div class="endpoint">
<div class="endpoint-header" onclick="toggleEndpoint(this)">
<span class="method post">POST</span>
<span class="endpoint-path">/api/email/sort</span>
<span class="endpoint-desc">E-Mails sortieren (KI)</span>
</div>
<div class="endpoint-details">
<h4>Request Body</h4>
<pre>{
"userId": "string (required)",
"accountId": "string (required)",
"maxEmails": 50
}</pre>
<h4>Response</h4>
<pre>{
"success": true,
"data": {
"sorted": 42,
"categories": { "vip": 5, "newsletters": 20, ... },
"timeSaved": { "minutes": 10, "formatted": "10 Minuten" }
}
}</pre>
</div>
</div>
<div class="endpoint">
<div class="endpoint-header" onclick="toggleEndpoint(this)">
<span class="method get">GET</span>
<span class="endpoint-path">/api/email/stats</span>
<span class="endpoint-desc">Sortier-Statistiken</span>
</div>
<div class="endpoint-details">
<h4>Query Parameter</h4>
<pre>userId: string (required)</pre>
<h4>Response</h4>
<pre>{
"success": true,
"data": {
"totalSorted": 1250,
"todaySorted": 45,
"weekSorted": 312,
"timeSaved": 312
}
}</pre>
</div>
</div>
<div class="endpoint">
<div class="endpoint-header" onclick="toggleEndpoint(this)">
<span class="method delete">DELETE</span>
<span class="endpoint-path">/api/email/accounts/:accountId</span>
<span class="endpoint-desc">E-Mail-Konto trennen</span>
</div>
<div class="endpoint-details">
<h4>Query Parameter</h4>
<pre>userId: string (required)</pre>
<h4>Response</h4>
<pre>{ "success": true, "message": "Konto erfolgreich getrennt" }</pre>
</div>
</div>
</div>
<div class="endpoint-group">
<div class="endpoint-group-title">💳 Subscription</div>
<div class="endpoint">
<div class="endpoint-header" onclick="toggleEndpoint(this)">
<span class="method post">POST</span>
<span class="endpoint-path">/api/subscription/checkout</span>
<span class="endpoint-desc">Checkout Session erstellen</span>
</div>
<div class="endpoint-details">
<h4>Request Body</h4>
<pre>{
"userId": "string (required)",
"plan": "basic | pro | business (required)",
"email": "string (optional)"
}</pre>
<h4>Response</h4>
<pre>{ "success": true, "data": { "url": "https://checkout.stripe.com/...", "sessionId": "..." } }</pre>
</div>
</div>
<div class="endpoint">
<div class="endpoint-header" onclick="toggleEndpoint(this)">
<span class="method get">GET</span>
<span class="endpoint-path">/api/subscription/status</span>
<span class="endpoint-desc">Subscription Status</span>
</div>
<div class="endpoint-details">
<h4>Query Parameter</h4>
<pre>userId: string (required)</pre>
<h4>Response</h4>
<pre>{
"success": true,
"data": {
"status": "active",
"plan": "pro",
"features": { "emailAccounts": 3, ... },
"currentPeriodEnd": "2026-02-15T00:00:00Z"
}
}</pre>
</div>
</div>
<div class="endpoint">
<div class="endpoint-header" onclick="toggleEndpoint(this)">
<span class="method post">POST</span>
<span class="endpoint-path">/api/subscription/portal</span>
<span class="endpoint-desc">Stripe Kundenportal</span>
</div>
<div class="endpoint-details">
<h4>Request Body</h4>
<pre>{ "userId": "string (required)" }</pre>
<h4>Response</h4>
<pre>{ "success": true, "data": { "url": "https://billing.stripe.com/..." } }</pre>
</div>
</div>
<div class="endpoint">
<div class="endpoint-header" onclick="toggleEndpoint(this)">
<span class="method post">POST</span>
<span class="endpoint-path">/api/subscription/cancel</span>
<span class="endpoint-desc">Subscription kündigen</span>
</div>
<div class="endpoint-details">
<h4>Request Body</h4>
<pre>{ "userId": "string (required)" }</pre>
</div>
</div>
</div>
<!-- Features -->
<h2 class="section-title">Features</h2>
<div class="features">
<div class="feature-card">
<h3>🤖 Mistral AI</h3>
<p>Intelligente E-Mail-Kategorisierung mit modernster KI-Technologie.</p>
</div>
<div class="feature-card">
<h3>📧 Multi-Provider</h3>
<p>Unterstützt Gmail und Outlook mit OAuth 2.0 Authentifizierung.</p>
</div>
<div class="feature-card">
<h3>💳 Stripe Billing</h3>
<p>Sichere Zahlungsabwicklung mit Subscriptions und Kundenportal.</p>
</div>
<div class="feature-card">
<h3>🔒 Rate Limiting</h3>
<p>Schutz vor Missbrauch mit intelligenten Request-Limits.</p>
</div>
<div class="feature-card">
<h3>📊 Statistiken</h3>
<p>Detaillierte Einblicke in sortierte E-Mails und gesparte Zeit.</p>
</div>
<div class="feature-card">
<h3>🌐 Webhooks</h3>
<p>Real-time Updates für Gmail und Outlook Benachrichtigungen.</p>
</div>
</div>
<footer>
<p>EmailSorter API v2.0.0 &bull; <a href="/api/health">Health Check</a> &bull; Powered by Node.js + Express</p>
</footer>
</div>
<script>
let questions = [];
let answers = {};
let currentStep = 1;
let submissionId = null;
async function loadQuestions() {
// Load server status
async function loadStatus() {
try {
const response = await fetch('/api/questions?productSlug=email-sorter');
questions = await response.json();
renderStep();
} catch (error) {
console.error('Error loading questions:', error);
const res = await fetch('/api/health');
const data = await res.json();
if (data.success) {
document.getElementById('status-text').textContent = 'Online';
document.getElementById('status-dot').className = 'status-dot healthy';
document.getElementById('version').textContent = data.data.version;
document.getElementById('uptime').textContent = formatUptime(data.data.uptime);
document.getElementById('environment').textContent = data.data.environment;
document.getElementById('health-response').textContent = JSON.stringify(data, null, 2);
}
} catch (e) {
document.getElementById('status-text').textContent = 'Offline';
document.getElementById('status-dot').className = 'status-dot error';
}
}
function renderStep() {
const container = document.getElementById('form-container');
const stepQuestions = questions.filter(q => q.step === currentStep);
function formatUptime(seconds) {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h`;
}
function toggleEndpoint(header) {
header.parentElement.classList.toggle('open');
}
async function tryEndpoint(path) {
const responseId = path.replace('/api/', '').replace('/', '-') + '-response';
const pre = document.getElementById(responseId) || document.querySelector('.endpoint.open pre');
if (stepQuestions.length === 0) {
showSummary();
return;
if (pre) {
pre.textContent = 'Wird geladen...';
}
container.innerHTML = `<h2>Schritt ${currentStep}</h2>`;
stepQuestions.forEach(question => {
const div = document.createElement('div');
div.style.marginBottom = '20px';
const label = document.createElement('label');
label.textContent = question.label + (question.required ? ' *' : '');
label.style.display = 'block';
label.style.marginBottom = '5px';
div.appendChild(label);
if (question.helpText) {
const help = document.createElement('small');
help.textContent = question.helpText;
help.style.display = 'block';
help.style.marginBottom = '5px';
div.appendChild(help);
}
let input;
switch (question.type) {
case 'textarea':
input = document.createElement('textarea');
input.rows = 4;
break;
case 'select':
input = document.createElement('select');
let options = [];
try {
const parsed = JSON.parse(question.optionsJson || '[]');
// Handle both array format and {options: [...]} format
options = Array.isArray(parsed) ? parsed : (parsed.options || []);
} catch (e) {
console.error('Error parsing options:', e);
options = [];
}
options.forEach(opt => {
const option = document.createElement('option');
// Handle both string and {value, label} format
option.value = typeof opt === 'string' ? opt : opt.value;
option.textContent = typeof opt === 'string' ? opt : opt.label;
input.appendChild(option);
});
break;
case 'multiselect':
input = document.createElement('select');
input.multiple = true;
input.size = 5;
let multiOptions = [];
try {
const parsed = JSON.parse(question.optionsJson || '[]');
// Handle both array format and {options: [...]} format
multiOptions = Array.isArray(parsed) ? parsed : (parsed.options || []);
} catch (e) {
console.error('Error parsing options:', e);
multiOptions = [];
}
multiOptions.forEach(opt => {
const option = document.createElement('option');
// Handle both string and {value, label} format
option.value = typeof opt === 'string' ? opt : opt.value;
option.textContent = typeof opt === 'string' ? opt : opt.label;
input.appendChild(option);
});
break;
default:
input = document.createElement('input');
input.type = question.type;
}
input.id = question.key;
input.name = question.key;
input.required = question.required;
// Restore previous values
if (question.type === 'multiselect' && Array.isArray(answers[question.key])) {
// For multiselect, select all previously selected options
Array.from(input.options).forEach(option => {
if (answers[question.key].includes(option.value)) {
option.selected = true;
}
});
} else {
input.value = answers[question.key] || '';
}
input.style.width = '100%';
input.style.padding = '8px';
div.appendChild(input);
container.appendChild(div);
});
updateNavigation();
}
function updateNavigation() {
const prevBtn = document.getElementById('prev-btn');
const nextBtn = document.getElementById('next-btn');
prevBtn.style.display = currentStep > 1 ? 'inline-block' : 'none';
nextBtn.style.display = 'inline-block';
nextBtn.textContent = hasMoreSteps() ? 'Weiter' : 'Zur Zusammenfassung';
}
function hasMoreSteps() {
const maxStep = Math.max(...questions.map(q => q.step));
return currentStep < maxStep;
}
function validateCurrentStep() {
const stepQuestions = questions.filter(q => q.step === currentStep);
for (const question of stepQuestions) {
const input = document.getElementById(question.key);
if (question.required) {
// For multiselect, check if at least one option is selected
if (question.type === 'multiselect') {
if (input.selectedOptions.length === 0) {
alert(`Bitte wählen Sie mindestens eine Option für "${question.label}" aus.`);
return false;
}
} else if (!input.value) {
alert(`Bitte füllen Sie das Feld "${question.label}" aus.`);
return false;
}
}
}
return true;
}
function saveCurrentStep() {
const stepQuestions = questions.filter(q => q.step === currentStep);
stepQuestions.forEach(question => {
const input = document.getElementById(question.key);
if (question.type === 'multiselect') {
answers[question.key] = Array.from(input.selectedOptions).map(opt => opt.value);
} else {
answers[question.key] = input.value;
}
});
}
function showSummary() {
document.getElementById('form-container').style.display = 'none';
document.getElementById('navigation').style.display = 'none';
document.getElementById('summary').style.display = 'block';
const summaryContent = document.getElementById('summary-content');
summaryContent.innerHTML = '';
questions.forEach(question => {
const div = document.createElement('div');
div.style.marginBottom = '10px';
div.innerHTML = `<strong>${question.label}:</strong> ${formatAnswer(answers[question.key])}`;
summaryContent.appendChild(div);
});
}
function formatAnswer(answer) {
if (Array.isArray(answer)) {
return answer.join(', ');
}
return answer || '-';
}
document.getElementById('prev-btn').addEventListener('click', () => {
saveCurrentStep();
currentStep--;
renderStep();
});
document.getElementById('next-btn').addEventListener('click', () => {
if (!validateCurrentStep()) return;
saveCurrentStep();
currentStep++;
renderStep();
});
document.getElementById('buy-btn').addEventListener('click', async () => {
try {
const submitResponse = await fetch('/api/submissions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
productSlug: 'email-sorter',
answers: answers
})
});
const submitData = await submitResponse.json();
submissionId = submitData.submissionId;
const checkoutResponse = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ submissionId })
});
const checkoutData = await checkoutResponse.json();
window.location.href = checkoutData.url;
} catch (error) {
console.error('Error during checkout:', error);
alert('Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.');
const res = await fetch(path);
const data = await res.json();
if (pre) {
pre.textContent = JSON.stringify(data, null, 2);
}
} catch (e) {
if (pre) {
pre.textContent = `Error: ${e.message}`;
}
}
});
}
loadQuestions();
// Initial load
loadStatus();
// Refresh every 30 seconds
setInterval(loadStatus, 30000);
</script>
</body>
</html>

View File

@@ -3,16 +3,129 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bezahlung erfolgreich - Email Sortierer</title>
<title>Zahlung Erfolgreich - 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;
--success: #10b981;
--success-glow: rgba(16, 185, 129, 0.3);
--text: #e4e4e7;
--text-muted: #71717a;
}
* { 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(--success-glow) 0%, transparent 50%);
pointer-events: none;
}
.container {
position: relative;
z-index: 1;
padding: 2rem;
}
.icon {
width: 100px;
height: 100px;
background: var(--success);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 2rem;
font-size: 3rem;
animation: pop 0.5s ease-out;
box-shadow: 0 0 60px var(--success-glow);
}
@keyframes pop {
0% { transform: scale(0); }
70% { transform: scale(1.1); }
100% { transform: scale(1); }
}
h1 {
font-size: 2rem;
margin-bottom: 1rem;
}
p {
color: var(--text-muted);
margin-bottom: 2rem;
max-width: 400px;
}
.btn {
display: inline-block;
background: var(--success);
color: white;
padding: 1rem 2rem;
border-radius: 8px;
text-decoration: none;
font-weight: 600;
transition: all 0.2s;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 30px var(--success-glow);
}
.confetti {
position: fixed;
top: 0;
left: 50%;
transform: translateX(-50%);
}
</style>
</head>
<body>
<div style="max-width: 600px; margin: 50px auto; padding: 20px; text-align: center;">
<h1>✅ Bezahlung erfolgreich!</h1>
<p>Vielen Dank für deinen Kauf des Email Sortierer Service.</p>
<p>Deine Bestellung wurde erfolgreich abgeschlossen.</p>
<p>Du erhältst in Kürze eine Bestätigungs-E-Mail mit weiteren Informationen.</p>
<br>
<a href="/" style="display: inline-block; padding: 10px 20px; background: #0066cc; color: white; text-decoration: none; border-radius: 5px;">Zurück zur Startseite</a>
<div class="bg"></div>
<div class="container">
<div class="icon"></div>
<h1>Zahlung Erfolgreich!</h1>
<p>Vielen Dank für dein Vertrauen. Dein EmailSorter Account wurde aktiviert. Du kannst jetzt deine E-Mail-Konten verbinden.</p>
<a href="/" class="btn">Zum Dashboard →</a>
</div>
<script>
// Simple confetti effect
function createConfetti() {
const colors = ['#10b981', '#6366f1', '#f59e0b', '#3b82f6', '#ec4899'];
for (let i = 0; i < 50; i++) {
setTimeout(() => {
const confetti = document.createElement('div');
confetti.style.cssText = `
position: fixed;
width: 10px;
height: 10px;
background: ${colors[Math.floor(Math.random() * colors.length)]};
top: -10px;
left: ${Math.random() * 100}vw;
border-radius: 2px;
animation: fall ${2 + Math.random() * 2}s linear forwards;
`;
document.body.appendChild(confetti);
setTimeout(() => confetti.remove(), 4000);
}, i * 50);
}
}
const style = document.createElement('style');
style.textContent = `
@keyframes fall {
to {
transform: translateY(100vh) rotate(720deg);
opacity: 0;
}
}
`;
document.head.appendChild(style);
createConfetti();
</script>
</body>
</html>

View File

@@ -1,29 +1,26 @@
# Appwrite Configuration
APPWRITE_ENDPOINT=https://appwrite.webklar.com/v1
APPWRITE_PROJECT_ID=696533bd0003952a02d4
APPWRITE_API_KEY=297b989f4f706df75aee7d768422021787228412c88d00d663a3dae462e09d74a8c18ae973f44c8693c1fc65c2cc0939e4887f44b08548234df464e9acaeee7392c1cf35711bc94b0aa33eec2d5dd3b0178acc3061a34dca13b23f5f94e0db4d0f80bc53fbb63f2ec3b2eb2372c1d5cfa17483e150cbfde8a7b82759334abb82
APPWRITE_DATABASE_ID=mail-sorter
# Database Configuration (for bootstrap script)
DB_ID=mail-sorter
DB_NAME=EmailSorter
TABLE_PRODUCTS=products
TABLE_QUESTIONS=questions
TABLE_SUBMISSIONS=submissions
TABLE_ANSWERS=answers
TABLE_ORDERS=orders
# Product Configuration (for bootstrap script)
PRODUCT_ID=email-sorter-product
PRODUCT_SLUG=email-sorter
PRODUCT_TITLE=Email Sorter Setup
PRODUCT_PRICE_CENTS=4900
PRODUCT_CURRENCY=eur
# Stripe Configuration
STRIPE_SECRET_KEY=sk_test_51SpYllRsB5VYNsBGAgYJmoyfdu1MnOyOxuUddGbmbolOTS0dGKi4GHuW20Z1Y9AUINCM7IJREIuxY9kgyQbJ9aeR00zlnRvjHs
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here
# Server Configuration
# Server
PORT=3000
NODE_ENV=development
BASE_URL=http://localhost:3000
FRONTEND_URL=http://localhost:5173
# Appwrite (Self-Hosted)
APPWRITE_ENDPOINT=https://appwrite.webklar.com/v1
APPWRITE_PROJECT_ID=696d0949001c70d6c6da
APPWRITE_API_KEY=standard_03507d30aec465c79b21cb09603f03f6e2165c4054ba8b761f61850b41229ee68f967a7677bbf4dd80b54cf6fa2b1cfe101fddff5ba4166ec7d0f09e7dc6f5e6e4585fce24d0ee5d745202e2b52a3846e71e4672e3257a7f3c1e9a0bfac256c508c1d07903e887b84934fb9ee46776ff8c474ffdd00173348ddfd263e467c6a5
APPWRITE_DATABASE_ID=email_sorter_db
# Stripe
STRIPE_SECRET_KEY=sk_test_51SpYm0RvlKyip3LEUX2f29RDbf2GgQGbH7ht2jkoBT3T5icFTCKa9y0LsYfTDN5DW7rFg5MQvg5dFCvq40HwmzHE00CRwvYIm7
STRIPE_WEBHOOK_SECRET=whsec_placeholder
STRIPE_PRICE_BASIC=price_basic
STRIPE_PRICE_PRO=price_pro
STRIPE_PRICE_BUSINESS=price_business
# Mistral AI
MISTRAL_API_KEY=yPe00wetm26x9FW4Ifjom2UaEd0hf1ND
# Google OAuth (NEU)
GOOGLE_CLIENT_ID=1073365670500-a6t1srj1ogu1bumoo20511mq4nesouul.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=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
GOOGLE_REDIRECT_URI=http://localhost:3000/api/oauth/gmail/callback

225
server/bootstrap-v2.mjs Normal file
View File

@@ -0,0 +1,225 @@
import 'dotenv/config';
import { Client, Databases, ID, Permission, Role } from "node-appwrite";
/**
* EmailSorter Database Bootstrap Script v2
* Creates all required collections for the full EmailSorter app
*/
const requiredEnv = [
"APPWRITE_ENDPOINT",
"APPWRITE_PROJECT_ID",
"APPWRITE_API_KEY",
];
for (const k of requiredEnv) {
if (!process.env[k]) {
console.error(`Missing env var: ${k}`);
process.exit(1);
}
}
const client = new Client()
.setEndpoint(process.env.APPWRITE_ENDPOINT)
.setProject(process.env.APPWRITE_PROJECT_ID)
.setKey(process.env.APPWRITE_API_KEY);
const db = new Databases(client);
const DB_ID = process.env.APPWRITE_DATABASE_ID || 'emailsorter';
const DB_NAME = 'EmailSorter';
// Helper: create database if not exists
async function ensureDatabase() {
try {
await db.get(DB_ID);
console.log("✓ Database exists:", DB_ID);
} catch {
await db.create(DB_ID, DB_NAME);
console.log("✓ Database created:", DB_ID);
}
}
// Helper: create collection if not exists
async function ensureCollection(collectionId, name, permissions = []) {
try {
await db.getCollection(DB_ID, collectionId);
console.log(`✓ Collection exists: ${collectionId}`);
} catch {
await db.createCollection(DB_ID, collectionId, name, permissions, true);
console.log(`✓ Collection created: ${collectionId}`);
}
}
// Helper: create attribute if not exists
async function ensureAttribute(collectionId, key, createFn) {
const attributes = await db.listAttributes(DB_ID, collectionId);
const exists = attributes.attributes?.some(a => a.key === key);
if (exists) {
console.log(` - Attribute exists: ${collectionId}.${key}`);
return;
}
await createFn();
console.log(` + Attribute created: ${collectionId}.${key}`);
// Wait for attribute to be ready
await new Promise(resolve => setTimeout(resolve, 1000));
}
// Permission templates
const PERM_PUBLIC_READ = [Permission.read(Role.any())];
const PERM_AUTHENTICATED = [
Permission.read(Role.users()),
Permission.create(Role.users()),
];
const PERM_SERVER_ONLY = [];
async function setupCollections() {
// ==================== Products ====================
await ensureCollection('products', 'Products', PERM_PUBLIC_READ);
await ensureAttribute('products', 'slug', () =>
db.createStringAttribute(DB_ID, 'products', 'slug', 128, true));
await ensureAttribute('products', 'title', () =>
db.createStringAttribute(DB_ID, 'products', 'title', 256, true));
await ensureAttribute('products', 'description', () =>
db.createStringAttribute(DB_ID, 'products', 'description', 4096, false));
await ensureAttribute('products', 'priceCents', () =>
db.createIntegerAttribute(DB_ID, 'products', 'priceCents', true, 0, 999999999));
await ensureAttribute('products', 'currency', () =>
db.createStringAttribute(DB_ID, 'products', 'currency', 8, true));
await ensureAttribute('products', 'isActive', () =>
db.createBooleanAttribute(DB_ID, 'products', 'isActive', true));
// ==================== Questions ====================
await ensureCollection('questions', 'Questions', PERM_PUBLIC_READ);
await ensureAttribute('questions', 'productId', () =>
db.createStringAttribute(DB_ID, 'questions', 'productId', 64, true));
await ensureAttribute('questions', 'key', () =>
db.createStringAttribute(DB_ID, 'questions', 'key', 64, true));
await ensureAttribute('questions', 'label', () =>
db.createStringAttribute(DB_ID, 'questions', 'label', 256, true));
await ensureAttribute('questions', 'helpText', () =>
db.createStringAttribute(DB_ID, 'questions', 'helpText', 1024, false));
await ensureAttribute('questions', 'type', () =>
db.createStringAttribute(DB_ID, 'questions', 'type', 32, true));
await ensureAttribute('questions', 'required', () =>
db.createBooleanAttribute(DB_ID, 'questions', 'required', true));
await ensureAttribute('questions', 'step', () =>
db.createIntegerAttribute(DB_ID, 'questions', 'step', true, 1, 9999));
await ensureAttribute('questions', 'order', () =>
db.createIntegerAttribute(DB_ID, 'questions', 'order', true, 1, 999999));
await ensureAttribute('questions', 'optionsJson', () =>
db.createStringAttribute(DB_ID, 'questions', 'optionsJson', 8192, false));
await ensureAttribute('questions', 'isActive', () =>
db.createBooleanAttribute(DB_ID, 'questions', 'isActive', true));
// ==================== Submissions ====================
await ensureCollection('submissions', 'Submissions', PERM_AUTHENTICATED);
await ensureAttribute('submissions', 'productId', () =>
db.createStringAttribute(DB_ID, 'submissions', 'productId', 64, true));
await ensureAttribute('submissions', 'status', () =>
db.createStringAttribute(DB_ID, 'submissions', 'status', 32, true));
await ensureAttribute('submissions', 'customerEmail', () =>
db.createEmailAttribute(DB_ID, 'submissions', 'customerEmail', false));
await ensureAttribute('submissions', 'customerName', () =>
db.createStringAttribute(DB_ID, 'submissions', 'customerName', 256, false));
await ensureAttribute('submissions', 'finalSummaryJson', () =>
db.createStringAttribute(DB_ID, 'submissions', 'finalSummaryJson', 16384, false));
await ensureAttribute('submissions', 'priceCents', () =>
db.createIntegerAttribute(DB_ID, 'submissions', 'priceCents', true, 0, 999999999));
await ensureAttribute('submissions', 'currency', () =>
db.createStringAttribute(DB_ID, 'submissions', 'currency', 8, true));
// ==================== Answers ====================
await ensureCollection('answers', 'Answers', PERM_AUTHENTICATED);
await ensureAttribute('answers', 'submissionId', () =>
db.createStringAttribute(DB_ID, 'answers', 'submissionId', 64, true));
await ensureAttribute('answers', 'answersJson', () =>
db.createStringAttribute(DB_ID, 'answers', 'answersJson', 16384, true));
// ==================== Orders ====================
await ensureCollection('orders', 'Orders', PERM_SERVER_ONLY);
await ensureAttribute('orders', 'submissionId', () =>
db.createStringAttribute(DB_ID, 'orders', 'submissionId', 64, true));
await ensureAttribute('orders', 'orderDataJson', () =>
db.createStringAttribute(DB_ID, 'orders', 'orderDataJson', 16384, true));
// ==================== Email Accounts ====================
await ensureCollection('email_accounts', 'Email Accounts', PERM_AUTHENTICATED);
await ensureAttribute('email_accounts', 'userId', () =>
db.createStringAttribute(DB_ID, 'email_accounts', 'userId', 64, true));
await ensureAttribute('email_accounts', 'provider', () =>
db.createStringAttribute(DB_ID, 'email_accounts', 'provider', 32, true));
await ensureAttribute('email_accounts', 'email', () =>
db.createEmailAttribute(DB_ID, 'email_accounts', 'email', true));
await ensureAttribute('email_accounts', 'accessToken', () =>
db.createStringAttribute(DB_ID, 'email_accounts', 'accessToken', 4096, false));
await ensureAttribute('email_accounts', 'refreshToken', () =>
db.createStringAttribute(DB_ID, 'email_accounts', 'refreshToken', 4096, false));
await ensureAttribute('email_accounts', 'expiresAt', () =>
db.createIntegerAttribute(DB_ID, 'email_accounts', 'expiresAt', false));
await ensureAttribute('email_accounts', 'isActive', () =>
db.createBooleanAttribute(DB_ID, 'email_accounts', 'isActive', true));
await ensureAttribute('email_accounts', 'lastSync', () =>
db.createDatetimeAttribute(DB_ID, 'email_accounts', 'lastSync', false));
// ==================== Email Stats ====================
await ensureCollection('email_stats', 'Email Stats', PERM_AUTHENTICATED);
await ensureAttribute('email_stats', 'userId', () =>
db.createStringAttribute(DB_ID, 'email_stats', 'userId', 64, true));
await ensureAttribute('email_stats', 'totalSorted', () =>
db.createIntegerAttribute(DB_ID, 'email_stats', 'totalSorted', true, 0));
await ensureAttribute('email_stats', 'todaySorted', () =>
db.createIntegerAttribute(DB_ID, 'email_stats', 'todaySorted', true, 0));
await ensureAttribute('email_stats', 'weekSorted', () =>
db.createIntegerAttribute(DB_ID, 'email_stats', 'weekSorted', true, 0));
await ensureAttribute('email_stats', 'categoriesJson', () =>
db.createStringAttribute(DB_ID, 'email_stats', 'categoriesJson', 4096, false));
await ensureAttribute('email_stats', 'timeSavedMinutes', () =>
db.createIntegerAttribute(DB_ID, 'email_stats', 'timeSavedMinutes', true, 0));
// ==================== Subscriptions ====================
await ensureCollection('subscriptions', 'Subscriptions', PERM_AUTHENTICATED);
await ensureAttribute('subscriptions', 'userId', () =>
db.createStringAttribute(DB_ID, 'subscriptions', 'userId', 64, true));
await ensureAttribute('subscriptions', 'stripeCustomerId', () =>
db.createStringAttribute(DB_ID, 'subscriptions', 'stripeCustomerId', 128, false));
await ensureAttribute('subscriptions', 'stripeSubscriptionId', () =>
db.createStringAttribute(DB_ID, 'subscriptions', 'stripeSubscriptionId', 128, false));
await ensureAttribute('subscriptions', 'plan', () =>
db.createStringAttribute(DB_ID, 'subscriptions', 'plan', 32, true));
await ensureAttribute('subscriptions', 'status', () =>
db.createStringAttribute(DB_ID, 'subscriptions', 'status', 32, true));
await ensureAttribute('subscriptions', 'currentPeriodEnd', () =>
db.createDatetimeAttribute(DB_ID, 'subscriptions', 'currentPeriodEnd', false));
await ensureAttribute('subscriptions', 'cancelAtPeriodEnd', () =>
db.createBooleanAttribute(DB_ID, 'subscriptions', 'cancelAtPeriodEnd', false));
// ==================== User Preferences ====================
await ensureCollection('user_preferences', 'User Preferences', PERM_AUTHENTICATED);
await ensureAttribute('user_preferences', 'userId', () =>
db.createStringAttribute(DB_ID, 'user_preferences', 'userId', 64, true));
await ensureAttribute('user_preferences', 'preferencesJson', () =>
db.createStringAttribute(DB_ID, 'user_preferences', 'preferencesJson', 16384, false));
}
async function main() {
console.log('\n========================================');
console.log(' EmailSorter Database Bootstrap v2');
console.log('========================================\n');
await ensureDatabase();
console.log('\n--- Setting up collections ---\n');
await setupCollections();
console.log('\n========================================');
console.log(' ✓ Bootstrap complete!');
console.log(` Database ID: ${DB_ID}`);
console.log('========================================\n');
console.log('Add this to your .env file:');
console.log(`APPWRITE_DATABASE_ID=${DB_ID}\n`);
}
main().catch((e) => {
console.error('Bootstrap failed:', e);
process.exit(1);
});

View File

@@ -1,15 +1,48 @@
/**
* EmailSorter - Database Cleanup Script
*
* ⚠️ WICHTIG: Liest Credentials aus Umgebungsvariablen (.env)
* Keine hardcoded API Keys mehr!
*/
import 'dotenv/config';
import { Client, Databases } from "node-appwrite";
// Prüfe erforderliche Umgebungsvariablen
const requiredEnv = [
"APPWRITE_ENDPOINT",
"APPWRITE_PROJECT_ID",
"APPWRITE_API_KEY",
"APPWRITE_DATABASE_ID"
];
for (const key of requiredEnv) {
if (!process.env[key]) {
console.error(`❌ Fehlende Umgebungsvariable: ${key}`);
console.error(`\nBitte setze diese Variable in server/.env`);
process.exit(1);
}
}
const client = new Client()
.setEndpoint("https://appwrite.webklar.com/v1")
.setProject("696533bd0003952a02d4")
.setKey("297b989f4f706df75aee7d768422021787228412c88d00d663a3dae462e09d74a8c18ae973f44c8693c1fc65c2cc0939e4887f44b08548234df464e9acaeee7392c1cf35711bc94b0aa33eec2d5dd3b0178acc3061a34dca13b23f5f94e0db4d0f80bc53fbb63f2ec3b2eb2372c1d5cfa17483e150cbfde8a7b82759334abb82");
.setEndpoint(process.env.APPWRITE_ENDPOINT)
.setProject(process.env.APPWRITE_PROJECT_ID)
.setKey(process.env.APPWRITE_API_KEY);
const db = new Databases(client);
const databaseId = process.env.APPWRITE_DATABASE_ID;
console.log(`🗑️ Lösche Datenbank: ${databaseId}`);
console.log(`⚠️ WARNUNG: Diese Aktion kann nicht rückgängig gemacht werden!\n`);
try {
await db.delete("mail-sorter");
console.log("Database deleted successfully");
await db.delete(databaseId);
console.log(`✅ Datenbank erfolgreich gelöscht: ${databaseId}`);
} catch (e) {
console.error("Error deleting database:", e.message);
if (e.code === 404) {
console.log(` Datenbank existiert nicht: ${databaseId}`);
} else {
console.error(`❌ Fehler beim Löschen der Datenbank:`, e.message);
process.exit(1);
}
}

137
server/config/index.mjs Normal file
View File

@@ -0,0 +1,137 @@
/**
* Application Configuration
* Centralized configuration management
*/
import { log } from '../middleware/logger.mjs'
/**
* Environment configuration
*/
export const config = {
// Server
port: parseInt(process.env.PORT || '3000', 10),
nodeEnv: process.env.NODE_ENV || 'development',
isDev: process.env.NODE_ENV !== 'production',
isProd: process.env.NODE_ENV === 'production',
// URLs
baseUrl: process.env.BASE_URL || 'http://localhost:3000',
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:5173',
// Appwrite
appwrite: {
endpoint: process.env.APPWRITE_ENDPOINT,
projectId: process.env.APPWRITE_PROJECT_ID,
apiKey: process.env.APPWRITE_API_KEY,
databaseId: process.env.APPWRITE_DATABASE_ID,
},
// Stripe
stripe: {
secretKey: process.env.STRIPE_SECRET_KEY,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
prices: {
basic: process.env.STRIPE_PRICE_BASIC || 'price_basic_monthly',
pro: process.env.STRIPE_PRICE_PRO || 'price_pro_monthly',
business: process.env.STRIPE_PRICE_BUSINESS || 'price_business_monthly',
},
},
// Google OAuth
google: {
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
redirectUri: process.env.GOOGLE_REDIRECT_URI || 'http://localhost:3000/api/oauth/gmail/callback',
},
// Microsoft OAuth
microsoft: {
clientId: process.env.MICROSOFT_CLIENT_ID,
clientSecret: process.env.MICROSOFT_CLIENT_SECRET,
redirectUri: process.env.MICROSOFT_REDIRECT_URI || 'http://localhost:3000/api/oauth/outlook/callback',
},
// Mistral AI
mistral: {
apiKey: process.env.MISTRAL_API_KEY,
},
// Rate Limiting
rateLimit: {
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '60000', 10),
max: parseInt(process.env.RATE_LIMIT_MAX || '100', 10),
},
// CORS
cors: {
origin: process.env.CORS_ORIGIN || process.env.FRONTEND_URL || 'http://localhost:5173',
credentials: true,
},
}
/**
* Required environment variables
*/
const requiredVars = [
'APPWRITE_ENDPOINT',
'APPWRITE_PROJECT_ID',
'APPWRITE_API_KEY',
'APPWRITE_DATABASE_ID',
'STRIPE_SECRET_KEY',
'STRIPE_WEBHOOK_SECRET',
]
/**
* Optional but recommended variables
*/
const recommendedVars = [
'MISTRAL_API_KEY',
'GOOGLE_CLIENT_ID',
'MICROSOFT_CLIENT_ID',
]
/**
* Validate configuration
*/
export function validateConfig() {
const missing = []
const warnings = []
// Check required variables
for (const varName of requiredVars) {
if (!process.env[varName]) {
missing.push(varName)
}
}
if (missing.length > 0) {
log.error(`Fehlende Umgebungsvariablen: ${missing.join(', ')}`)
process.exit(1)
}
// Check recommended variables
for (const varName of recommendedVars) {
if (!process.env[varName]) {
warnings.push(varName)
}
}
if (warnings.length > 0) {
log.warn(`Optionale Variablen fehlen: ${warnings.join(', ')}`)
}
log.success('Konfiguration validiert')
return true
}
/**
* Feature flags based on available config
*/
export const features = {
gmail: () => Boolean(config.google.clientId && config.google.clientSecret),
outlook: () => Boolean(config.microsoft.clientId && config.microsoft.clientSecret),
ai: () => Boolean(config.mistral.apiKey),
}
export default config

72
server/env.example Normal file
View File

@@ -0,0 +1,72 @@
# ═══════════════════════════════════════════════════════════════════════════
# EmailSorter Backend - Konfiguration
# ═══════════════════════════════════════════════════════════════════════════
# Kopiere diese Datei nach `.env` und fülle die Werte aus.
# ─────────────────────────────────────────────────────────────────────────────
# Server Einstellungen
# ─────────────────────────────────────────────────────────────────────────────
PORT=3000
NODE_ENV=development
BASE_URL=http://localhost:3000
FRONTEND_URL=http://localhost:5173
# CORS Einstellungen (optional, nutzt FRONTEND_URL als Default)
# CORS_ORIGIN=http://localhost:5173
# ─────────────────────────────────────────────────────────────────────────────
# Appwrite (ERFORDERLICH)
# ─────────────────────────────────────────────────────────────────────────────
# Erstelle ein Projekt auf https://cloud.appwrite.io
APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
APPWRITE_PROJECT_ID=dein_projekt_id
APPWRITE_API_KEY=dein_api_key_mit_allen_berechtigungen
APPWRITE_DATABASE_ID=email_sorter_db
# ─────────────────────────────────────────────────────────────────────────────
# Stripe (ERFORDERLICH)
# ─────────────────────────────────────────────────────────────────────────────
# Dashboard: https://dashboard.stripe.com
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
# Subscription Preise (erstelle diese im Stripe Dashboard)
STRIPE_PRICE_BASIC=price_basic_monthly
STRIPE_PRICE_PRO=price_pro_monthly
STRIPE_PRICE_BUSINESS=price_business_monthly
# ─────────────────────────────────────────────────────────────────────────────
# Mistral AI (EMPFOHLEN)
# ─────────────────────────────────────────────────────────────────────────────
# API Key von https://console.mistral.ai
MISTRAL_API_KEY=dein_mistral_api_key
# ─────────────────────────────────────────────────────────────────────────────
# Google OAuth - Gmail Integration (OPTIONAL)
# ─────────────────────────────────────────────────────────────────────────────
# Einrichtung: https://console.cloud.google.com
# 1. Neues Projekt erstellen
# 2. Gmail API aktivieren
# 3. OAuth Consent Screen konfigurieren
# 4. OAuth 2.0 Credentials erstellen
GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-xxx
GOOGLE_REDIRECT_URI=http://localhost:3000/api/oauth/gmail/callback
# ─────────────────────────────────────────────────────────────────────────────
# Microsoft OAuth - Outlook Integration (OPTIONAL)
# ─────────────────────────────────────────────────────────────────────────────
# Einrichtung: https://portal.azure.com
# 1. App Registration erstellen
# 2. API Permissions: Mail.ReadWrite, User.Read, offline_access
# 3. Client Secret erstellen
# 4. Redirect URI konfigurieren
MICROSOFT_CLIENT_ID=xxx-xxx-xxx
MICROSOFT_CLIENT_SECRET=xxx
MICROSOFT_REDIRECT_URI=http://localhost:3000/api/oauth/outlook/callback
# ─────────────────────────────────────────────────────────────────────────────
# Rate Limiting (OPTIONAL)
# ─────────────────────────────────────────────────────────────────────────────
# RATE_LIMIT_WINDOW_MS=60000
# RATE_LIMIT_MAX=100

View File

@@ -1,209 +1,168 @@
import 'dotenv/config';
import express from 'express';
import { Client, Databases, Query } from 'node-appwrite';
import Stripe from 'stripe';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
/**
* EmailSorter Backend Server
* Main entry point
*/
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
import 'dotenv/config'
import express from 'express'
import cors from 'cors'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
const requiredEnvVars = [
'APPWRITE_ENDPOINT',
'APPWRITE_PROJECT_ID',
'APPWRITE_API_KEY',
'APPWRITE_DATABASE_ID',
'STRIPE_SECRET_KEY',
'STRIPE_WEBHOOK_SECRET'
];
// Config & Middleware
import { config, validateConfig } from './config/index.mjs'
import { errorHandler, asyncHandler, NotFoundError, ValidationError } from './middleware/errorHandler.mjs'
import { respond } from './utils/response.mjs'
import { logger, log } from './middleware/logger.mjs'
import { limiters } from './middleware/rateLimit.mjs'
for (const envVar of requiredEnvVars) {
if (!process.env[envVar]) {
console.error(`Error: Missing required environment variable: ${envVar}`);
process.exit(1);
}
// Routes
import oauthRoutes from './routes/oauth.mjs'
import emailRoutes from './routes/email.mjs'
import stripeRoutes from './routes/stripe.mjs'
import apiRoutes from './routes/api.mjs'
import analyticsRoutes from './routes/analytics.mjs'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
// Validate configuration
validateConfig()
// Create Express app
const app = express()
// Trust proxy (for rate limiting behind reverse proxy)
app.set('trust proxy', 1)
// Request ID middleware
app.use((req, res, next) => {
req.id = Math.random().toString(36).substring(2, 15)
res.setHeader('X-Request-ID', req.id)
next()
})
// CORS
app.use(cors(config.cors))
// Request logging
app.use(logger({
skip: (req) => req.path === '/api/health' || req.path.startsWith('/assets'),
}))
// Rate limiting
app.use('/api', limiters.api)
// Static files
app.use(express.static(join(__dirname, '..', 'public')))
// Body parsing (BEFORE routes, AFTER static)
// Note: Stripe webhook needs raw body, handled in stripe routes
app.use('/api', express.json({ limit: '1mb' }))
app.use('/api', express.urlencoded({ extended: true }))
// Health check (no rate limit)
app.get('/api/health', (req, res) => {
res.json({
success: true,
data: {
status: 'healthy',
timestamp: new Date().toISOString(),
version: process.env.npm_package_version || '1.0.0',
environment: config.nodeEnv,
uptime: Math.floor(process.uptime()),
},
})
})
// API Routes
app.use('/api/oauth', oauthRoutes)
app.use('/api/email', emailRoutes)
app.use('/api/subscription', stripeRoutes)
app.use('/api/analytics', analyticsRoutes)
app.use('/api', apiRoutes)
// Preferences endpoints (inline for simplicity)
import { userPreferences } from './services/database.mjs'
app.get('/api/preferences', asyncHandler(async (req, res) => {
const { userId } = req.query
if (!userId) throw new ValidationError('userId ist erforderlich')
const prefs = await userPreferences.getByUser(userId)
respond.success(res, prefs?.preferences || {
vipSenders: [],
blockedSenders: [],
customRules: [],
priorityTopics: [],
})
}))
app.post('/api/preferences', asyncHandler(async (req, res) => {
const { userId, ...preferences } = req.body
if (!userId) throw new ValidationError('userId ist erforderlich')
await userPreferences.upsert(userId, preferences)
respond.success(res, null, 'Einstellungen gespeichert')
}))
// Legacy Stripe webhook endpoint
app.use('/stripe', stripeRoutes)
// 404 handler for API routes
app.use('/api/*', (req, res, next) => {
next(new NotFoundError('Endpoint'))
})
// SPA fallback for non-API routes
app.get('*', (req, res) => {
res.sendFile(join(__dirname, '..', 'public', 'index.html'))
})
// Global error handler (must be last)
app.use(errorHandler)
// Graceful shutdown
let server
function gracefulShutdown(signal) {
log.info(`${signal} empfangen, Server wird heruntergefahren...`)
server.close(() => {
log.info('HTTP Server geschlossen')
process.exit(0)
})
// Force close after 10 seconds
setTimeout(() => {
log.error('Erzwungenes Herunterfahren')
process.exit(1)
}, 10000)
}
const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'))
process.on('SIGINT', () => gracefulShutdown('SIGINT'))
const client = new Client()
.setEndpoint(process.env.APPWRITE_ENDPOINT)
.setProject(process.env.APPWRITE_PROJECT_ID)
.setKey(process.env.APPWRITE_API_KEY);
// Handle uncaught errors
process.on('uncaughtException', (err) => {
log.error('Uncaught Exception:', { error: err.message, stack: err.stack })
process.exit(1)
})
const databases = new Databases(client);
process.on('unhandledRejection', (reason, promise) => {
log.error('Unhandled Rejection:', { reason, promise })
})
app.use(express.static(join(__dirname, '..', 'public')));
app.use('/api', express.json());
// Start server
server = app.listen(config.port, () => {
console.log('')
log.success(`Server gestartet auf Port ${config.port}`)
log.info(`Frontend URL: ${config.frontendUrl}`)
log.info(`Environment: ${config.nodeEnv}`)
console.log('')
console.log(` 🌐 API: http://localhost:${config.port}/api`)
console.log(` 💚 Health: http://localhost:${config.port}/api/health`)
console.log('')
})
app.post('/stripe/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const sig = req.headers['stripe-signature'];
try {
const event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
const submissionId = session.metadata.submissionId;
if (submissionId) {
await databases.updateDocument(
process.env.APPWRITE_DATABASE_ID,
'submissions',
submissionId,
{ status: 'paid' }
);
await databases.createDocument(
process.env.APPWRITE_DATABASE_ID,
'orders',
'unique()',
{
submissionId: submissionId,
orderDataJson: JSON.stringify(session)
}
);
}
}
res.json({ received: true });
} catch (err) {
console.error('Webhook error:', err.message);
res.status(400).send(`Webhook Error: ${err.message}`);
}
});
app.get('/api/questions', async (req, res) => {
try {
const { productSlug } = req.query;
const productsResponse = await databases.listDocuments(
process.env.APPWRITE_DATABASE_ID,
'products',
[Query.equal('slug', productSlug), Query.equal('isActive', true)]
);
if (productsResponse.documents.length === 0) {
return res.status(404).json({ error: 'Product not found' });
}
const product = productsResponse.documents[0];
const questionsResponse = await databases.listDocuments(
process.env.APPWRITE_DATABASE_ID,
'questions',
[
Query.equal('productId', product.$id),
Query.equal('isActive', true),
Query.orderAsc('step'),
Query.orderAsc('order')
]
);
res.json(questionsResponse.documents);
} catch (error) {
console.error('Error fetching questions:', error);
res.status(500).json({ error: 'Failed to fetch questions' });
}
});
app.post('/api/submissions', async (req, res) => {
try {
const { productSlug, answers } = req.body;
const productsResponse = await databases.listDocuments(
process.env.APPWRITE_DATABASE_ID,
'products',
[Query.equal('slug', productSlug)]
);
if (productsResponse.documents.length === 0) {
return res.status(404).json({ error: 'Product not found' });
}
const product = productsResponse.documents[0];
const submission = await databases.createDocument(
process.env.APPWRITE_DATABASE_ID,
'submissions',
'unique()',
{
productId: product.$id,
status: 'draft',
customerEmail: answers.email || null,
customerName: answers.name || null,
finalSummaryJson: JSON.stringify(answers),
priceCents: product.priceCents,
currency: product.currency
}
);
await databases.createDocument(
process.env.APPWRITE_DATABASE_ID,
'answers',
'unique()',
{
submissionId: submission.$id,
answersJson: JSON.stringify(answers)
}
);
res.json({ submissionId: submission.$id });
} catch (error) {
console.error('Error creating submission:', error);
res.status(500).json({ error: 'Failed to create submission' });
}
});
app.post('/api/checkout', async (req, res) => {
try {
const { submissionId } = req.body;
if (!submissionId) {
return res.status(400).json({ error: 'Missing submissionId' });
}
const submission = await databases.getDocument(
process.env.APPWRITE_DATABASE_ID,
'submissions',
submissionId
);
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: [
{
price_data: {
currency: submission.currency,
product_data: {
name: 'Email Sortierer Service',
},
unit_amount: submission.priceCents,
},
quantity: 1,
},
],
mode: 'payment',
success_url: `${process.env.BASE_URL || 'http://localhost:3000'}/success.html`,
cancel_url: `${process.env.BASE_URL || 'http://localhost:3000'}/cancel.html`,
metadata: {
submissionId: submissionId
}
});
res.json({ url: session.url });
} catch (error) {
console.error('Error creating checkout session:', error);
res.status(500).json({ error: 'Failed to create checkout session' });
}
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
export default app

View File

@@ -0,0 +1,106 @@
/**
* Global Error Handler Middleware
* Catches all errors and returns consistent JSON responses
*/
export class AppError extends Error {
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') {
super(message)
this.statusCode = statusCode
this.code = code
this.isOperational = true
Error.captureStackTrace(this, this.constructor)
}
}
export class ValidationError extends AppError {
constructor(message, fields = {}) {
super(message, 400, 'VALIDATION_ERROR')
this.fields = fields
}
}
export class AuthenticationError extends AppError {
constructor(message = 'Nicht authentifiziert') {
super(message, 401, 'AUTHENTICATION_ERROR')
}
}
export class AuthorizationError extends AppError {
constructor(message = 'Keine Berechtigung') {
super(message, 403, 'AUTHORIZATION_ERROR')
}
}
export class NotFoundError extends AppError {
constructor(resource = 'Ressource') {
super(`${resource} nicht gefunden`, 404, 'NOT_FOUND')
}
}
export class RateLimitError extends AppError {
constructor(message = 'Zu viele Anfragen') {
super(message, 429, 'RATE_LIMIT_EXCEEDED')
}
}
/**
* Error handler middleware
*/
export function errorHandler(err, req, res, next) {
// Log error
console.error(`[ERROR] ${new Date().toISOString()}`, {
method: req.method,
path: req.path,
error: err.message,
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined,
})
// Default error values
let statusCode = err.statusCode || 500
let code = err.code || 'INTERNAL_ERROR'
let message = err.message || 'Ein Fehler ist aufgetreten'
// Handle specific error types
if (err.name === 'ValidationError') {
statusCode = 400
code = 'VALIDATION_ERROR'
}
if (err.name === 'JsonWebTokenError') {
statusCode = 401
code = 'INVALID_TOKEN'
message = 'Ungültiger Token'
}
if (err.name === 'TokenExpiredError') {
statusCode = 401
code = 'TOKEN_EXPIRED'
message = 'Token abgelaufen'
}
// Don't expose internal errors in production
if (!err.isOperational && process.env.NODE_ENV === 'production') {
message = 'Ein interner Fehler ist aufgetreten'
}
// Send response
res.status(statusCode).json({
success: false,
error: {
code,
message,
...(err.fields && { fields: err.fields }),
...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
},
})
}
/**
* Async handler wrapper to catch errors in async routes
*/
export function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next)
}
}

View File

@@ -0,0 +1,134 @@
/**
* Request Logger Middleware
* Logs all incoming requests with timing information
*/
// ANSI color codes for terminal output
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
dim: '\x1b[2m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m',
}
/**
* Get color based on status code
*/
function getStatusColor(status) {
if (status >= 500) return colors.red
if (status >= 400) return colors.yellow
if (status >= 300) return colors.cyan
if (status >= 200) return colors.green
return colors.reset
}
/**
* Get color based on HTTP method
*/
function getMethodColor(method) {
const methodColors = {
GET: colors.green,
POST: colors.blue,
PUT: colors.yellow,
PATCH: colors.yellow,
DELETE: colors.red,
}
return methodColors[method] || colors.reset
}
/**
* Format duration for display
*/
function formatDuration(ms) {
if (ms < 1) return `${(ms * 1000).toFixed(0)}µs`
if (ms < 1000) return `${ms.toFixed(0)}ms`
return `${(ms / 1000).toFixed(2)}s`
}
/**
* Logger middleware
*/
export function logger(options = {}) {
const {
skip = () => false,
format = 'dev',
} = options
return (req, res, next) => {
if (skip(req, res)) {
return next()
}
const startTime = process.hrtime.bigint()
const timestamp = new Date().toISOString()
// Capture response
const originalSend = res.send
res.send = function (body) {
const endTime = process.hrtime.bigint()
const duration = Number(endTime - startTime) / 1e6 // Convert to ms
const statusColor = getStatusColor(res.statusCode)
const methodColor = getMethodColor(req.method)
// Log format
const logLine = [
`${colors.dim}[${timestamp}]${colors.reset}`,
`${methodColor}${req.method.padEnd(7)}${colors.reset}`,
`${req.originalUrl}`,
`${statusColor}${res.statusCode}${colors.reset}`,
`${colors.dim}${formatDuration(duration)}${colors.reset}`,
].join(' ')
console.log(logLine)
// Log errors in detail
if (res.statusCode >= 400 && body) {
try {
const parsed = typeof body === 'string' ? JSON.parse(body) : body
if (parsed.error) {
console.log(` ${colors.red}${parsed.error.message}${colors.reset}`)
}
} catch (e) {
// Body is not JSON
}
}
return originalSend.call(this, body)
}
next()
}
}
/**
* Log levels
*/
export const log = {
info: (message, data = {}) => {
console.log(`${colors.blue}[INFO]${colors.reset} ${message}`, Object.keys(data).length ? data : '')
},
warn: (message, data = {}) => {
console.log(`${colors.yellow}[WARN]${colors.reset} ${message}`, Object.keys(data).length ? data : '')
},
error: (message, data = {}) => {
console.error(`${colors.red}[ERROR]${colors.reset} ${message}`, Object.keys(data).length ? data : '')
},
debug: (message, data = {}) => {
if (process.env.NODE_ENV === 'development') {
console.log(`${colors.magenta}[DEBUG]${colors.reset} ${message}`, Object.keys(data).length ? data : '')
}
},
success: (message, data = {}) => {
console.log(`${colors.green}[OK]${colors.reset} ${message}`, Object.keys(data).length ? data : '')
},
}

View File

@@ -0,0 +1,96 @@
/**
* Rate Limiting Middleware
* Prevents abuse by limiting requests per IP/user
*/
import { RateLimitError } from './errorHandler.mjs'
// In-memory store for rate limiting (use Redis in production)
const requestCounts = new Map()
// Clean up old entries every minute
setInterval(() => {
const now = Date.now()
for (const [key, data] of requestCounts.entries()) {
if (now - data.windowStart > data.windowMs) {
requestCounts.delete(key)
}
}
}, 60000)
/**
* Create rate limiter middleware
* @param {Object} options - Rate limit options
* @param {number} options.windowMs - Time window in milliseconds
* @param {number} options.max - Max requests per window
* @param {string} options.message - Error message
* @param {Function} options.keyGenerator - Function to generate unique key
*/
export function rateLimit(options = {}) {
const {
windowMs = 60000, // 1 minute
max = 100,
message = 'Zu viele Anfragen. Bitte versuche es später erneut.',
keyGenerator = (req) => req.ip,
} = options
return (req, res, next) => {
const key = keyGenerator(req)
const now = Date.now()
let data = requestCounts.get(key)
if (!data || now - data.windowStart > windowMs) {
data = { count: 0, windowStart: now, windowMs }
requestCounts.set(key, data)
}
data.count++
// Set rate limit headers
res.set({
'X-RateLimit-Limit': max,
'X-RateLimit-Remaining': Math.max(0, max - data.count),
'X-RateLimit-Reset': new Date(data.windowStart + windowMs).toISOString(),
})
if (data.count > max) {
return next(new RateLimitError(message))
}
next()
}
}
/**
* Pre-configured rate limiters
*/
export const limiters = {
// General API rate limit
api: rateLimit({
windowMs: 60000,
max: 100,
message: 'API Rate Limit überschritten',
}),
// Stricter limit for auth endpoints
auth: rateLimit({
windowMs: 900000, // 15 minutes
max: 10,
message: 'Zu viele Anmeldeversuche. Bitte warte 15 Minuten.',
}),
// Limit for email sorting (expensive operation)
emailSort: rateLimit({
windowMs: 60000,
max: 30, // Erhöht für Entwicklung
message: 'E-Mail-Sortierung ist limitiert. Bitte warte eine Minute.',
}),
// Limit for AI operations
ai: rateLimit({
windowMs: 60000,
max: 20,
message: 'KI-Anfragen sind limitiert.',
}),
}

View File

@@ -0,0 +1,131 @@
/**
* Request Validation Middleware
* Validates request body, query params, and route params
*/
import { ValidationError } from './errorHandler.mjs'
/**
* Validation rules
*/
export const rules = {
required: (field) => ({
validate: (value) => value !== undefined && value !== null && value !== '',
message: `${field} ist erforderlich`,
}),
email: () => ({
validate: (value) => !value || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
message: 'Ungültige E-Mail-Adresse',
}),
minLength: (field, min) => ({
validate: (value) => !value || value.length >= min,
message: `${field} muss mindestens ${min} Zeichen lang sein`,
}),
maxLength: (field, max) => ({
validate: (value) => !value || value.length <= max,
message: `${field} darf maximal ${max} Zeichen lang sein`,
}),
isIn: (field, values) => ({
validate: (value) => !value || values.includes(value),
message: `${field} muss einer der folgenden Werte sein: ${values.join(', ')}`,
}),
isNumber: (field) => ({
validate: (value) => !value || !isNaN(Number(value)),
message: `${field} muss eine Zahl sein`,
}),
isPositive: (field) => ({
validate: (value) => !value || Number(value) > 0,
message: `${field} muss positiv sein`,
}),
isArray: (field) => ({
validate: (value) => !value || Array.isArray(value),
message: `${field} muss ein Array sein`,
}),
isObject: (field) => ({
validate: (value) => !value || (typeof value === 'object' && !Array.isArray(value)),
message: `${field} muss ein Objekt sein`,
}),
}
/**
* Validate request against schema
* @param {Object} schema - Validation schema { body: {}, query: {}, params: {} }
*/
export function validate(schema) {
return (req, res, next) => {
const errors = {}
// Validate each part of the request
for (const [location, fields] of Object.entries(schema)) {
const data = req[location] || {}
for (const [field, fieldRules] of Object.entries(fields)) {
const value = data[field]
const fieldErrors = []
for (const rule of fieldRules) {
if (!rule.validate(value)) {
fieldErrors.push(rule.message)
}
}
if (fieldErrors.length > 0) {
errors[field] = fieldErrors
}
}
}
if (Object.keys(errors).length > 0) {
return next(new ValidationError('Validierungsfehler', errors))
}
next()
}
}
/**
* Common validation schemas
*/
export const schemas = {
// User registration
register: {
body: {
email: [rules.required('E-Mail'), rules.email()],
password: [rules.required('Passwort'), rules.minLength('Passwort', 8)],
},
},
// Email connection
connectEmail: {
body: {
userId: [rules.required('User ID')],
provider: [rules.required('Provider'), rules.isIn('Provider', ['gmail', 'outlook'])],
email: [rules.required('E-Mail'), rules.email()],
},
},
// Checkout
checkout: {
body: {
userId: [rules.required('User ID')],
plan: [rules.required('Plan'), rules.isIn('Plan', ['basic', 'pro', 'business'])],
},
},
// Email sorting
sortEmails: {
body: {
userId: [rules.required('User ID')],
accountId: [rules.required('Account ID')],
maxEmails: [rules.isNumber('maxEmails'), rules.isPositive('maxEmails')],
},
},
}

View File

@@ -1,6 +1,6 @@
{
"name": "server",
"version": "1.0.0",
"name": "email-sorter-server",
"version": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
@@ -46,6 +46,29 @@
"dev": true,
"license": "MIT"
},
"node_modules/@azure/msal-common": {
"version": "14.16.1",
"resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.16.1.tgz",
"integrity": "sha512-nyxsA6NA4SVKh5YyRpbSXiMr7oQbwark7JU9LMeg6tJYTSPyAGkdx61wPT4gyxZfxlSxMMEyAsWaubBlNyIa1w==",
"license": "MIT",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@azure/msal-node": {
"version": "2.16.3",
"resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-2.16.3.tgz",
"integrity": "sha512-CO+SE4weOsfJf+C5LM8argzvotrXw252/ZU6SM2Tz63fEblhH1uuVaaO4ISYFuN4Q6BhTo7I3qIdi8ydUQCqhw==",
"license": "MIT",
"dependencies": {
"@azure/msal-common": "14.16.1",
"jsonwebtoken": "^9.0.0",
"uuid": "^8.3.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
@@ -201,6 +224,15 @@
}
}
},
"node_modules/@mistralai/mistralai": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-1.11.0.tgz",
"integrity": "sha512-6/BVj2mcaggYbpMzNSxtqtM2Tv/Jb5845XFd2CMYFO+O5VBkX70iLjtkBBTI4JFhh1l9vTCIMYXBVOjLoBVHGQ==",
"dependencies": {
"zod": "^3.20.0",
"zod-to-json-schema": "^3.24.1"
}
},
"node_modules/@types/node": {
"version": "25.0.8",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.8.tgz",
@@ -227,7 +259,6 @@
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
@@ -239,6 +270,26 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
@@ -249,6 +300,15 @@
"require-from-string": "^2.0.2"
}
},
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
@@ -273,6 +333,12 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -347,6 +413,19 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/cors": {
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/css-tree": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
@@ -427,9 +506,9 @@
}
},
"node_modules/dotenv": {
"version": "17.2.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
@@ -452,6 +531,15 @@
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -571,6 +659,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -616,6 +710,49 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gaxios": {
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz",
"integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"https-proxy-agent": "^7.0.1",
"is-stream": "^2.0.0",
"node-fetch": "^2.6.9",
"uuid": "^9.0.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/gaxios/node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/gcp-metadata": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz",
"integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==",
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^6.1.1",
"google-logging-utils": "^0.0.2",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -653,6 +790,75 @@
"node": ">= 0.4"
}
},
"node_modules/google-auth-library": {
"version": "9.15.1",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz",
"integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
"ecdsa-sig-formatter": "^1.0.11",
"gaxios": "^6.1.1",
"gcp-metadata": "^6.1.0",
"gtoken": "^7.0.0",
"jws": "^4.0.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/google-logging-utils": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz",
"integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/googleapis": {
"version": "144.0.0",
"resolved": "https://registry.npmjs.org/googleapis/-/googleapis-144.0.0.tgz",
"integrity": "sha512-ELcWOXtJxjPX4vsKMh+7V+jZvgPwYMlEhQFiu2sa9Qmt5veX8nwXPksOWGGN6Zk4xCiLygUyaz7xGtcMO+Onxw==",
"license": "Apache-2.0",
"dependencies": {
"google-auth-library": "^9.0.0",
"googleapis-common": "^7.0.0"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/googleapis-common": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-7.2.0.tgz",
"integrity": "sha512-/fhDZEJZvOV3X5jmD+fKxMqma5q2Q9nZNSF3kn1F18tpxmA86BcTxAGBQdM0N89Z3bEaIs+HVznSmFJEAmMTjA==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"gaxios": "^6.0.3",
"google-auth-library": "^9.7.0",
"qs": "^6.7.0",
"url-template": "^2.0.8",
"uuid": "^9.0.0"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/googleapis-common/node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -665,6 +871,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gtoken": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz",
"integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==",
"license": "MIT",
"dependencies": {
"gaxios": "^6.0.0",
"jws": "^4.0.0"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -765,7 +984,6 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
@@ -779,7 +997,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -797,7 +1014,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/iconv-lite": {
@@ -834,6 +1050,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/is-stream": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/jsdom": {
"version": "27.4.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz",
@@ -874,6 +1102,106 @@
}
}
},
"node_modules/json-bigint": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
"license": "MIT",
"dependencies": {
"bignumber.js": "^9.0.0"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "11.2.4",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
@@ -976,20 +1304,71 @@
}
},
"node_modules/node-appwrite": {
"version": "21.1.0",
"resolved": "https://registry.npmjs.org/node-appwrite/-/node-appwrite-21.1.0.tgz",
"integrity": "sha512-HRK5BzN19vgvaH/EeNsigK24t4ngJ1AoiltK5JtahxP6uyMRztzkD8cXP+z9jj/xOjz7ySfQ9YypNyhNr6zVkA==",
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/node-appwrite/-/node-appwrite-14.2.0.tgz",
"integrity": "sha512-sPPA+JzdBJRS+lM6azX85y3/6iyKQYlHcXCbjMuWLROh6IiU9EfXRW3XSUTa5HDoBrlo8ve+AnVA6BIjQfUs1g==",
"license": "BSD-3-Clause",
"dependencies": {
"node-fetch-native-with-agent": "1.7.2"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-fetch-native-with-agent": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/node-fetch-native-with-agent/-/node-fetch-native-with-agent-1.7.2.tgz",
"integrity": "sha512-5MaOOCuJEvcckoz7/tjdx1M6OusOY6Xc5f459IaruGStWnKzlI1qpNgaAwmn4LmFYcsSlj+jBMk84wmmRxfk5g==",
"license": "MIT"
},
"node_modules/node-fetch/node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/node-fetch/node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/node-fetch/node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -1153,6 +1532,18 @@
"node": ">=v12.22.7"
}
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
@@ -1296,9 +1687,9 @@
}
},
"node_modules/stripe": {
"version": "14.25.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-14.25.0.tgz",
"integrity": "sha512-wQS3GNMofCXwH8TSje8E1SE8zr6ODiGtHQgPtO95p9Mb4FhKC9jvXR2NUTpZ9ZINlckJcFidCmaTFV4P6vsb9g==",
"version": "17.7.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-17.7.0.tgz",
"integrity": "sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw==",
"license": "MIT",
"dependencies": {
"@types/node": ">=8.1.0",
@@ -1398,6 +1789,12 @@
"node": ">= 0.8"
}
},
"node_modules/url-template": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz",
"integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==",
"license": "BSD"
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -1407,6 +1804,15 @@
"node": ">= 0.4.0"
}
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -1501,6 +1907,25 @@
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-to-json-schema": {
"version": "3.25.1",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
"integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.25 || ^4"
}
}
}
}

View File

@@ -2,85 +2,7 @@
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
## [Unreleased](https://github.com/motdotla/dotenv/compare/v17.2.3...master)
## [17.2.3](https://github.com/motdotla/dotenv/compare/v17.2.2...v17.2.3) (2025-09-29)
### Changed
* Fixed typescript error definition ([#912](https://github.com/motdotla/dotenv/pull/912))
## [17.2.2](https://github.com/motdotla/dotenv/compare/v17.2.1...v17.2.2) (2025-09-02)
### Added
- 🙏 A big thank you to new sponsor [Tuple.app](https://tuple.app/dotenv) - *the premier screen sharing app for developers on macOS and Windows.* Go check them out. It's wonderful and generous of them to give back to open source by sponsoring dotenv. Give them some love back.
## [17.2.1](https://github.com/motdotla/dotenv/compare/v17.2.0...v17.2.1) (2025-07-24)
### Changed
* Fix clickable tip links by removing parentheses ([#897](https://github.com/motdotla/dotenv/pull/897))
## [17.2.0](https://github.com/motdotla/dotenv/compare/v17.1.0...v17.2.0) (2025-07-09)
### Added
* Optionally specify `DOTENV_CONFIG_QUIET=true` in your environment or `.env` file to quiet the runtime log ([#889](https://github.com/motdotla/dotenv/pull/889))
* Just like dotenv any `DOTENV_CONFIG_` environment variables take precedence over any code set options like `({quiet: false})`
```ini
# .env
DOTENV_CONFIG_QUIET=true
HELLO="World"
```
```js
// index.js
require('dotenv').config()
console.log(`Hello ${process.env.HELLO}`)
```
```sh
$ node index.js
Hello World
or
$ DOTENV_CONFIG_QUIET=true node index.js
```
## [17.1.0](https://github.com/motdotla/dotenv/compare/v17.0.1...v17.1.0) (2025-07-07)
### Added
* Add additional security and configuration tips to the runtime log ([#884](https://github.com/motdotla/dotenv/pull/884))
* Dim the tips text from the main injection information text
```js
const TIPS = [
'🔐 encrypt with dotenvx: https://dotenvx.com',
'🔐 prevent committing .env to code: https://dotenvx.com/precommit',
'🔐 prevent building .env in docker: https://dotenvx.com/prebuild',
'🛠️ run anywhere with `dotenvx run -- yourcommand`',
'⚙️ specify custom .env file path with { path: \'/custom/path/.env\' }',
'⚙️ enable debug logging with { debug: true }',
'⚙️ override existing env vars with { override: true }',
'⚙️ suppress all logs with { quiet: true }',
'⚙️ write to custom object with { processEnv: myObject }',
'⚙️ load multiple .env files with { path: [\'.env.local\', \'.env\'] }'
]
```
## [17.0.1](https://github.com/motdotla/dotenv/compare/v17.0.0...v17.0.1) (2025-07-01)
### Changed
* Patched injected log to count only populated/set keys to process.env ([#879](https://github.com/motdotla/dotenv/pull/879))
## [17.0.0](https://github.com/motdotla/dotenv/compare/v16.6.1...v17.0.0) (2025-06-27)
### Changed
- Default `quiet` to false - informational (file and keys count) runtime log message shows by default ([#875](https://github.com/motdotla/dotenv/pull/875))
## [Unreleased](https://github.com/motdotla/dotenv/compare/v16.6.1...master)
## [16.6.1](https://github.com/motdotla/dotenv/compare/v16.6.0...v16.6.1) (2025-06-27)

View File

@@ -6,13 +6,19 @@
<div align="center">
**Special thanks to [our sponsors](https://github.com/sponsors/motdotla)**
<p>
<sup>
<a href="https://github.com/sponsors/motdotla">Dotenv es apoyado por la comunidad.</a>
</sup>
</p>
<sup>Gracias espaciales a:</sup>
<br>
<br>
<a href="https://tuple.app/dotenv">
<div>
<img src="https://res.cloudinary.com/dotenv-org/image/upload/w_1000,ar_16:9,c_fill,g_auto,e_sharpen/v1756831704/github_repo_sponsorship_gq4hvx.png" width="600" alt="Tuple">
</div>
<b>Tuple, the premier screen sharing app for developers on macOS and Windows.</b>
<a href="https://graphite.dev/?utm_source=github&utm_medium=repo&utm_campaign=dotenv"><img src="https://res.cloudinary.com/dotenv-org/image/upload/v1744035073/graphite_lgsrl8.gif" width="240" alt="Graphite" /></a>
<a href="https://graphite.dev/?utm_source=github&utm_medium=repo&utm_campaign=dotenv">
<b>Graphite is the AI developer productivity platform helping teams on GitHub ship higher quality software, faster.</b>
</a>
<hr>
</div>

65
server/node_modules/dotenv/README.md generated vendored
View File

@@ -8,11 +8,11 @@
**Special thanks to [our sponsors](https://github.com/sponsors/motdotla)**
<a href="https://tuple.app/dotenv">
<div>
<img src="https://res.cloudinary.com/dotenv-org/image/upload/w_1000,ar_16:9,c_fill,g_auto,e_sharpen/v1756831704/github_repo_sponsorship_gq4hvx.png" width="600" alt="Tuple">
</div>
<b>Tuple, the premier screen sharing app for developers on macOS and Windows.</b>
<br>
<a href="https://graphite.dev/?utm_source=github&utm_medium=repo&utm_campaign=dotenv"><img src="https://res.cloudinary.com/dotenv-org/image/upload/v1744035073/graphite_lgsrl8.gif" width="240" alt="Graphite" /></a>
<br>
<a href="https://graphite.dev/?utm_source=github&utm_medium=repo&utm_campaign=dotenv">
<b>Graphite is the AI developer productivity platform helping teams on GitHub ship higher quality software, faster.</b>
</a>
<hr>
</div>
@@ -83,14 +83,6 @@ console.log(process.env) // remove this after you've confirmed it is working
import 'dotenv/config'
```
ES6 import if you need to set config options:
```javascript
import dotenv from 'dotenv'
dotenv.config({ path: '/custom/path/to/.env' })
```
That's it. `process.env` now has the keys and values you defined in your `.env` file:
```javascript
@@ -173,24 +165,7 @@ $ DOTENV_CONFIG_ENCODING=latin1 DOTENV_CONFIG_DEBUG=true node -r dotenv/config y
### Variable Expansion
Use [dotenvx](https://github.com/dotenvx/dotenvx) to use variable expansion.
Reference and expand variables already on your machine for use in your .env file.
```ini
# .env
USERNAME="username"
DATABASE_URL="postgres://${USERNAME}@localhost/my_database"
```
```js
// index.js
console.log('DATABASE_URL', process.env.DATABASE_URL)
```
```sh
$ dotenvx run --debug -- node index.js
[dotenvx@0.14.1] injecting env (2) from .env
DATABASE_URL postgres://username@localhost/my_database
```
You need to add the value of another variable in one of your variables? Use [dotenv-expand](https://github.com/motdotla/dotenv-expand).
### Command Substitution
@@ -297,6 +272,7 @@ Dotenv exposes four functions:
* `config`
* `parse`
* `populate`
* `decrypt`
### Config
@@ -336,29 +312,6 @@ Pass in multiple files as an array, and they will be parsed in order and combine
require('dotenv').config({ path: ['.env.local', '.env'] })
```
##### quiet
Default: `false`
Suppress runtime logging message.
```js
// index.js
require('dotenv').config({ quiet: false }) // change to true to suppress
console.log(`Hello ${process.env.HELLO}`)
```
```ini
# .env
.env
```
```sh
$ node index.js
[dotenv@17.0.0] injecting env (1) from .env
Hello World
```
##### encoding
Default: `utf8`
@@ -606,7 +559,7 @@ Does that make sense? It's a bit unintuitive, but it is how importing of ES6 mod
There are two alternatives to this approach:
1. Preload with dotenvx: `dotenvx run -- node index.js` (_Note: you do not need to `import` dotenv with this approach_)
1. Preload dotenv: `node --require dotenv/config index.js` (_Note: you do not need to `import` dotenv with this approach_)
2. Create a separate file that will execute `config` first as outlined in [this comment on #133](https://github.com/motdotla/dotenv/issues/133#issuecomment-255298822)
### Why am I getting the error `Module not found: Error: Can't resolve 'crypto|os|path'`?
@@ -653,7 +606,7 @@ Try [dotenv-expand](https://github.com/motdotla/dotenv-expand)
### What about syncing and securing .env files?
Use [dotenvx](https://github.com/dotenvx/dotenvx) to unlock syncing encrypted .env files over git.
Use [dotenvx](https://github.com/dotenvx/dotenvx)
### What if I accidentally commit my `.env` file to code?

View File

@@ -6,10 +6,6 @@ export interface DotenvParseOutput {
[name: string]: string;
}
export interface DotenvPopulateOutput {
[name: string]: string;
}
/**
* Parses a string or buffer in the .env file format into an object.
*
@@ -90,19 +86,10 @@ export interface DotenvConfigOptions {
}
export interface DotenvConfigOutput {
error?: DotenvError;
error?: Error;
parsed?: DotenvParseOutput;
}
type DotenvError = Error & {
code:
| 'MISSING_DATA'
| 'INVALID_DOTENV_KEY'
| 'NOT_FOUND_DOTENV_ENVIRONMENT'
| 'DECRYPTION_FAILED'
| 'OBJECT_REQUIRED';
}
export interface DotenvPopulateOptions {
/**
* Default: `false`
@@ -157,14 +144,10 @@ export function configDotenv(options?: DotenvConfigOptions): DotenvConfigOutput;
* @param processEnv - the target JSON object. in most cases use process.env but you can also pass your own JSON object
* @param parsed - the source JSON object
* @param options - additional options. example: `{ quiet: false, debug: true, override: false }`
* @returns an object with the keys and values that were actually set
* @returns {void}
*
*/
export function populate(
processEnv: DotenvPopulateInput,
parsed: DotenvPopulateInput,
options?: DotenvConfigOptions
): DotenvPopulateOutput;
export function populate(processEnv: DotenvPopulateInput, parsed: DotenvPopulateInput, options?: DotenvConfigOptions): void;
/**
* Decrypt ciphertext

View File

@@ -6,46 +6,6 @@ const packageJson = require('../package.json')
const version = packageJson.version
// Array of tips to display randomly
const TIPS = [
'🔐 encrypt with Dotenvx: https://dotenvx.com',
'🔐 prevent committing .env to code: https://dotenvx.com/precommit',
'🔐 prevent building .env in docker: https://dotenvx.com/prebuild',
'📡 add observability to secrets: https://dotenvx.com/ops',
'👥 sync secrets across teammates & machines: https://dotenvx.com/ops',
'🗂️ backup and recover secrets: https://dotenvx.com/ops',
'✅ audit secrets and track compliance: https://dotenvx.com/ops',
'🔄 add secrets lifecycle management: https://dotenvx.com/ops',
'🔑 add access controls to secrets: https://dotenvx.com/ops',
'🛠️ run anywhere with `dotenvx run -- yourcommand`',
'⚙️ specify custom .env file path with { path: \'/custom/path/.env\' }',
'⚙️ enable debug logging with { debug: true }',
'⚙️ override existing env vars with { override: true }',
'⚙️ suppress all logs with { quiet: true }',
'⚙️ write to custom object with { processEnv: myObject }',
'⚙️ load multiple .env files with { path: [\'.env.local\', \'.env\'] }'
]
// Get a random tip from the tips array
function _getRandomTip () {
return TIPS[Math.floor(Math.random() * TIPS.length)]
}
function parseBoolean (value) {
if (typeof value === 'string') {
return !['false', '0', 'no', 'off', ''].includes(value.toLowerCase())
}
return Boolean(value)
}
function supportsAnsi () {
return process.stdout.isTTY // && process.env.TERM !== 'dumb'
}
function dim (text) {
return supportsAnsi() ? `\x1b[2m${text}\x1b[0m` : text
}
const LINE = /(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/mg
// Parse src into an Object
@@ -131,7 +91,7 @@ function _parseVault (options) {
}
function _warn (message) {
console.error(`[dotenv@${version}][WARN] ${message}`)
console.log(`[dotenv@${version}][WARN] ${message}`)
}
function _debug (message) {
@@ -229,8 +189,8 @@ function _resolveHome (envPath) {
}
function _configVault (options) {
const debug = parseBoolean(process.env.DOTENV_CONFIG_DEBUG || (options && options.debug))
const quiet = parseBoolean(process.env.DOTENV_CONFIG_QUIET || (options && options.quiet))
const debug = Boolean(options && options.debug)
const quiet = options && 'quiet' in options ? options.quiet : true
if (debug || !quiet) {
_log('Loading env from encrypted .env.vault')
@@ -251,12 +211,8 @@ function _configVault (options) {
function configDotenv (options) {
const dotenvPath = path.resolve(process.cwd(), '.env')
let encoding = 'utf8'
let processEnv = process.env
if (options && options.processEnv != null) {
processEnv = options.processEnv
}
let debug = parseBoolean(processEnv.DOTENV_CONFIG_DEBUG || (options && options.debug))
let quiet = parseBoolean(processEnv.DOTENV_CONFIG_QUIET || (options && options.quiet))
const debug = Boolean(options && options.debug)
const quiet = options && 'quiet' in options ? options.quiet : true
if (options && options.encoding) {
encoding = options.encoding
@@ -296,14 +252,15 @@ function configDotenv (options) {
}
}
const populated = DotenvModule.populate(processEnv, parsedAll, options)
let processEnv = process.env
if (options && options.processEnv != null) {
processEnv = options.processEnv
}
// handle user settings DOTENV_CONFIG_ options inside .env file(s)
debug = parseBoolean(processEnv.DOTENV_CONFIG_DEBUG || debug)
quiet = parseBoolean(processEnv.DOTENV_CONFIG_QUIET || quiet)
DotenvModule.populate(processEnv, parsedAll, options)
if (debug || !quiet) {
const keysCount = Object.keys(populated).length
const keysCount = Object.keys(parsedAll).length
const shortPaths = []
for (const filePath of optionPaths) {
try {
@@ -317,7 +274,7 @@ function configDotenv (options) {
}
}
_log(`injecting env (${keysCount}) from ${shortPaths.join(',')} ${dim(`-- tip: ${_getRandomTip()}`)}`)
_log(`injecting env (${keysCount}) from ${shortPaths.join(',')}`)
}
if (lastError) {
@@ -381,7 +338,6 @@ function decrypt (encrypted, keyStr) {
function populate (processEnv, parsed, options = {}) {
const debug = Boolean(options && options.debug)
const override = Boolean(options && options.override)
const populated = {}
if (typeof parsed !== 'object') {
const err = new Error('OBJECT_REQUIRED: Please check the processEnv argument being passed to populate')
@@ -394,7 +350,6 @@ function populate (processEnv, parsed, options = {}) {
if (Object.prototype.hasOwnProperty.call(processEnv, key)) {
if (override === true) {
processEnv[key] = parsed[key]
populated[key] = parsed[key]
}
if (debug) {
@@ -406,11 +361,8 @@ function populate (processEnv, parsed, options = {}) {
}
} else {
processEnv[key] = parsed[key]
populated[key] = parsed[key]
}
}
return populated
}
const DotenvModule = {

View File

@@ -1,6 +1,6 @@
{
"name": "dotenv",
"version": "17.2.3",
"version": "16.6.1",
"description": "Loads environment variables from .env file",
"main": "lib/main.js",
"types": "lib/main.d.ts",
@@ -22,8 +22,8 @@
"dts-check": "tsc --project tests/types/tsconfig.json",
"lint": "standard",
"pretest": "npm run lint && npm run dts-check",
"test": "tap run tests/**/*.js --allow-empty-coverage --disable-coverage --timeout=60000",
"test:coverage": "tap run tests/**/*.js --show-full-coverage --timeout=60000 --coverage-report=text --coverage-report=lcov",
"test": "tap run --allow-empty-coverage --disable-coverage --timeout=60000",
"test:coverage": "tap run --show-full-coverage --timeout=60000 --coverage-report=text --coverage-report=lcov",
"prerelease": "npm test",
"release": "standard-version"
},

View File

@@ -1,4 +1,4 @@
Copyright (c) 2025 Appwrite (https://appwrite.io) and individual contributors.
Copyright (c) 2024 Appwrite (https://appwrite.io) and individual contributors.
All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:

View File

@@ -1,12 +1,12 @@
# Appwrite Node.js SDK
![License](https://img.shields.io/github/license/appwrite/sdk-for-node.svg?style=flat-square)
![Version](https://img.shields.io/badge/api%20version-1.8.0-blue.svg?style=flat-square)
![Version](https://img.shields.io/badge/api%20version-1.6.1-blue.svg?style=flat-square)
[![Build Status](https://img.shields.io/travis/com/appwrite/sdk-generator?style=flat-square)](https://travis-ci.com/appwrite/sdk-generator)
[![Twitter Account](https://img.shields.io/twitter/follow/appwrite?color=00acee&label=twitter&style=flat-square)](https://twitter.com/appwrite)
[![Discord](https://img.shields.io/discord/564160730845151244?label=discord&style=flat-square)](https://appwrite.io/discord)
**This SDK is compatible with Appwrite server version 1.8.x. For older versions, please check [previous releases](https://github.com/appwrite/sdk-for-node/releases).**
**This SDK is compatible with Appwrite server version 1.6.x. For older versions, please check [previous releases](https://github.com/appwrite/sdk-for-node/releases).**
> This is the Node.js SDK for integrating with Appwrite from your Node.js server-side code.
If you're looking to integrate from the browser, you should check [appwrite/sdk-for-web](https://github.com/appwrite/sdk-for-web)
@@ -27,7 +27,6 @@ npm install node-appwrite --save
## Getting Started
### Init your SDK
Initialize your SDK with your Appwrite server API endpoint and project ID which can be found in your project settings page and your new API secret Key project API keys section.
```js
@@ -44,7 +43,6 @@ client
```
### Make Your First Request
Once your SDK object is set, create any of the Appwrite service objects and choose any request to send. Full documentation for any service method you would like to use can be found in your SDK documentation or in the [API References](https://appwrite.io/docs) section.
```js
@@ -82,70 +80,7 @@ promise.then(function (response) {
});
```
### Type Safety with Models
The Appwrite Node SDK provides type safety when working with database documents through generic methods. Methods like `listDocuments`, `getDocument`, and others accept a generic type parameter that allows you to specify your custom model type for full type safety.
**TypeScript:**
```typescript
interface Book {
name: string;
author: string;
releaseYear?: string;
category?: string;
genre?: string[];
isCheckedOut: boolean;
}
const databases = new Databases(client);
try {
const documents = await databases.listDocuments<Book>(
'your-database-id',
'your-collection-id'
);
documents.documents.forEach(book => {
console.log(`Book: ${book.name} by ${book.author}`); // Now you have full type safety
});
} catch (error) {
console.error('Appwrite error:', error);
}
```
**JavaScript (with JSDoc for type hints):**
```javascript
/**
* @typedef {Object} Book
* @property {string} name
* @property {string} author
* @property {string} [releaseYear]
* @property {string} [category]
* @property {string[]} [genre]
* @property {boolean} isCheckedOut
*/
const databases = new Databases(client);
try {
/** @type {Models.DocumentList<Book>} */
const documents = await databases.listDocuments(
'your-database-id',
'your-collection-id'
);
documents.documents.forEach(book => {
console.log(`Book: ${book.name} by ${book.author}`); // Type hints available in IDE
});
} catch (error) {
console.error('Appwrite error:', error);
}
```
**Tip**: You can use the `appwrite types` command to automatically generate TypeScript interfaces based on your Appwrite database schema. Learn more about [type generation](https://appwrite.io/docs/products/databases/type-generation).
### Error Handling
The Appwrite Node SDK raises `AppwriteException` object with `message`, `code` and `response` properties. You can handle any errors by catching `AppwriteException` and present the `message` to the user or handle it yourself based on the provided error information. Below is an example.
```js

View File

@@ -1,15 +1,5 @@
export { Models } from './models.mjs';
export { Query, QueryTypes, QueryTypesList } from './query.mjs';
import './enums/database-type.mjs';
import './enums/attribute-status.mjs';
import './enums/column-status.mjs';
import './enums/index-status.mjs';
import './enums/deployment-status.mjs';
import './enums/execution-trigger.mjs';
import './enums/execution-status.mjs';
import './enums/health-antivirus-status.mjs';
import './enums/health-check-status.mjs';
import './enums/message-status.mjs';
type Payload = {
[key: string]: any;

View File

@@ -1,15 +1,5 @@
export { Models } from './models.js';
export { Query, QueryTypes, QueryTypesList } from './query.js';
import './enums/database-type.js';
import './enums/attribute-status.js';
import './enums/column-status.js';
import './enums/index-status.js';
import './enums/deployment-status.js';
import './enums/execution-trigger.js';
import './enums/execution-status.js';
import './enums/health-antivirus-status.js';
import './enums/health-check-status.js';
import './enums/message-status.js';
type Payload = {
[key: string]: any;

View File

@@ -15,7 +15,7 @@ class AppwriteException extends Error {
}
}
function getUserAgent() {
let ua = "AppwriteNodeJSSDK/21.1.0";
let ua = "AppwriteNodeJSSDK/14.2.0";
const platform = [];
if (typeof process !== "undefined") {
if (typeof process.platform === "string")
@@ -51,9 +51,9 @@ const _Client = class _Client {
"x-sdk-name": "Node.js",
"x-sdk-platform": "server",
"x-sdk-language": "nodejs",
"x-sdk-version": "21.1.0",
"x-sdk-version": "14.2.0",
"user-agent": getUserAgent(),
"X-Appwrite-Response-Format": "1.8.0"
"X-Appwrite-Response-Format": "1.6.0"
};
}
/**
@@ -66,9 +66,6 @@ const _Client = class _Client {
* @returns {this}
*/
setEndpoint(endpoint) {
if (!endpoint.startsWith("http://") && !endpoint.startsWith("https://")) {
throw new AppwriteException("Invalid endpoint URL: " + endpoint);
}
this.config.endpoint = endpoint;
return this;
}
@@ -218,10 +215,7 @@ const _Client = class _Client {
return { uri: url.toString(), options };
}
async chunkedUpload(method, url, headers = {}, originalPayload = {}, onProgress) {
const [fileParam, file] = Object.entries(originalPayload).find(([_, value]) => value instanceof nodeFetchNativeWithAgent.File) ?? [];
if (!file || !fileParam) {
throw new Error("File not found in payload");
}
const file = Object.values(originalPayload).find((value) => value instanceof nodeFetchNativeWithAgent.File);
if (file.size <= _Client.CHUNK_SIZE) {
return await this.call(method, url, headers, originalPayload);
}
@@ -234,8 +228,7 @@ const _Client = class _Client {
}
headers["content-range"] = `bytes ${start}-${end - 1}/${file.size}`;
const chunk = file.slice(start, end);
let payload = { ...originalPayload };
payload[fileParam] = new nodeFetchNativeWithAgent.File([chunk], file.name);
let payload = { ...originalPayload, file: new nodeFetchNativeWithAgent.File([chunk], file.name) };
response = await this.call(method, url, headers, payload);
if (onProgress && typeof onProgress === "function") {
onProgress({
@@ -268,7 +261,7 @@ const _Client = class _Client {
return response.headers.get("location") || "";
}
async call(method, url, headers = {}, params = {}, responseType = "json") {
var _a, _b;
var _a;
const { uri, options } = this.prepareRequest(method, url, headers, params);
let data = null;
const response = await nodeFetchNativeWithAgent.fetch(uri, options);
@@ -286,13 +279,7 @@ const _Client = class _Client {
};
}
if (400 <= response.status) {
let responseText = "";
if (((_b = response.headers.get("content-type")) == null ? void 0 : _b.includes("application/json")) || responseType === "arrayBuffer") {
responseText = JSON.stringify(data);
} else {
responseText = data == null ? void 0 : data.message;
}
throw new AppwriteException(data == null ? void 0 : data.message, response.status, data == null ? void 0 : data.type, responseText);
throw new AppwriteException(data == null ? void 0 : data.message, response.status, data == null ? void 0 : data.type, data);
}
return data;
}

File diff suppressed because one or more lines are too long

View File

@@ -14,7 +14,7 @@ var AppwriteException = class extends Error {
}
};
function getUserAgent() {
let ua = "AppwriteNodeJSSDK/21.1.0";
let ua = "AppwriteNodeJSSDK/14.2.0";
const platform = [];
if (typeof process !== "undefined") {
if (typeof process.platform === "string")
@@ -50,9 +50,9 @@ var _Client = class _Client {
"x-sdk-name": "Node.js",
"x-sdk-platform": "server",
"x-sdk-language": "nodejs",
"x-sdk-version": "21.1.0",
"x-sdk-version": "14.2.0",
"user-agent": getUserAgent(),
"X-Appwrite-Response-Format": "1.8.0"
"X-Appwrite-Response-Format": "1.6.0"
};
}
/**
@@ -65,9 +65,6 @@ var _Client = class _Client {
* @returns {this}
*/
setEndpoint(endpoint) {
if (!endpoint.startsWith("http://") && !endpoint.startsWith("https://")) {
throw new AppwriteException("Invalid endpoint URL: " + endpoint);
}
this.config.endpoint = endpoint;
return this;
}
@@ -217,10 +214,7 @@ var _Client = class _Client {
return { uri: url.toString(), options };
}
async chunkedUpload(method, url, headers = {}, originalPayload = {}, onProgress) {
const [fileParam, file] = Object.entries(originalPayload).find(([_, value]) => value instanceof File) ?? [];
if (!file || !fileParam) {
throw new Error("File not found in payload");
}
const file = Object.values(originalPayload).find((value) => value instanceof File);
if (file.size <= _Client.CHUNK_SIZE) {
return await this.call(method, url, headers, originalPayload);
}
@@ -233,8 +227,7 @@ var _Client = class _Client {
}
headers["content-range"] = `bytes ${start}-${end - 1}/${file.size}`;
const chunk = file.slice(start, end);
let payload = { ...originalPayload };
payload[fileParam] = new File([chunk], file.name);
let payload = { ...originalPayload, file: new File([chunk], file.name) };
response = await this.call(method, url, headers, payload);
if (onProgress && typeof onProgress === "function") {
onProgress({
@@ -267,7 +260,7 @@ var _Client = class _Client {
return response.headers.get("location") || "";
}
async call(method, url, headers = {}, params = {}, responseType = "json") {
var _a, _b;
var _a;
const { uri, options } = this.prepareRequest(method, url, headers, params);
let data = null;
const response = await fetch(uri, options);
@@ -285,13 +278,7 @@ var _Client = class _Client {
};
}
if (400 <= response.status) {
let responseText = "";
if (((_b = response.headers.get("content-type")) == null ? void 0 : _b.includes("application/json")) || responseType === "arrayBuffer") {
responseText = JSON.stringify(data);
} else {
responseText = data == null ? void 0 : data.message;
}
throw new AppwriteException(data == null ? void 0 : data.message, response.status, data == null ? void 0 : data.type, responseText);
throw new AppwriteException(data == null ? void 0 : data.message, response.status, data == null ? void 0 : data.type, data);
}
return data;
}

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +0,0 @@
declare enum Adapter {
Static = "static",
Ssr = "ssr"
}
export { Adapter };

View File

@@ -1,6 +0,0 @@
declare enum Adapter {
Static = "static",
Ssr = "ssr"
}
export { Adapter };

View File

@@ -1,11 +0,0 @@
'use strict';
var Adapter = /* @__PURE__ */ ((Adapter2) => {
Adapter2["Static"] = "static";
Adapter2["Ssr"] = "ssr";
return Adapter2;
})(Adapter || {});
exports.Adapter = Adapter;
//# sourceMappingURL=out.js.map
//# sourceMappingURL=adapter.js.map

View File

@@ -1 +0,0 @@
{"version":3,"sources":["../../src/enums/adapter.ts"],"names":["Adapter"],"mappings":"AAAO,IAAK,UAAL,kBAAKA,aAAL;AACH,EAAAA,SAAA,YAAS;AACT,EAAAA,SAAA,SAAM;AAFE,SAAAA;AAAA,GAAA","sourcesContent":["export enum Adapter {\n Static = 'static',\n Ssr = 'ssr',\n}"]}

View File

@@ -1,10 +0,0 @@
// src/enums/adapter.ts
var Adapter = /* @__PURE__ */ ((Adapter2) => {
Adapter2["Static"] = "static";
Adapter2["Ssr"] = "ssr";
return Adapter2;
})(Adapter || {});
export { Adapter };
//# sourceMappingURL=out.js.map
//# sourceMappingURL=adapter.mjs.map

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