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

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