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:
24
client/.gitignore
vendored
Normal file
24
client/.gitignore
vendored
Normal 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
73
client/README.md
Normal 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
10
client/env.example
Normal 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
23
client/eslint.config.js
Normal 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
17
client/index.html
Normal 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
4954
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
client/package.json
Normal file
44
client/package.json
Normal 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
1
client/public/vite.svg
Normal 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
142
client/src/App.tsx
Normal 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
|
||||
1
client/src/assets/react.svg
Normal file
1
client/src/assets/react.svg
Normal 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 |
119
client/src/components/landing/FAQ.tsx
Normal file
119
client/src/components/landing/FAQ.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
137
client/src/components/landing/Features.tsx
Normal file
137
client/src/components/landing/Features.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
185
client/src/components/landing/Footer.tsx
Normal file
185
client/src/components/landing/Footer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
179
client/src/components/landing/Hero.tsx
Normal file
179
client/src/components/landing/Hero.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
111
client/src/components/landing/HowItWorks.tsx
Normal file
111
client/src/components/landing/HowItWorks.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
159
client/src/components/landing/Navbar.tsx
Normal file
159
client/src/components/landing/Navbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
193
client/src/components/landing/Pricing.tsx
Normal file
193
client/src/components/landing/Pricing.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
67
client/src/components/landing/Testimonials.tsx
Normal file
67
client/src/components/landing/Testimonials.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
client/src/components/ui/badge.tsx
Normal file
39
client/src/components/ui/badge.tsx
Normal 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 }
|
||||
59
client/src/components/ui/button.tsx
Normal file
59
client/src/components/ui/button.tsx
Normal 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 }
|
||||
78
client/src/components/ui/card.tsx
Normal file
78
client/src/components/ui/card.tsx
Normal 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 }
|
||||
32
client/src/components/ui/input.tsx
Normal file
32
client/src/components/ui/input.tsx
Normal 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 }
|
||||
23
client/src/components/ui/label.tsx
Normal file
23
client/src/components/ui/label.tsx
Normal 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 }
|
||||
74
client/src/context/AuthContext.tsx
Normal file
74
client/src/context/AuthContext.tsx
Normal 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
|
||||
}
|
||||
53
client/src/hooks/useAnalytics.ts
Normal file
53
client/src/hooks/useAnalytics.ts
Normal 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
144
client/src/index.css
Normal 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
314
client/src/lib/analytics.ts
Normal 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
329
client/src/lib/api.ts
Normal 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
|
||||
81
client/src/lib/appwrite.ts
Normal file
81
client/src/lib/appwrite.ts
Normal 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
6
client/src/lib/utils.ts
Normal 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
10
client/src/main.tsx
Normal 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>,
|
||||
)
|
||||
707
client/src/pages/Dashboard.tsx
Normal file
707
client/src/pages/Dashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
131
client/src/pages/ForgotPassword.tsx
Normal file
131
client/src/pages/ForgotPassword.tsx
Normal 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
23
client/src/pages/Home.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
156
client/src/pages/Imprint.tsx
Normal file
156
client/src/pages/Imprint.tsx
Normal 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
142
client/src/pages/Login.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
168
client/src/pages/Privacy.tsx
Normal file
168
client/src/pages/Privacy.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
226
client/src/pages/Register.tsx
Normal file
226
client/src/pages/Register.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
225
client/src/pages/ResetPassword.tsx
Normal file
225
client/src/pages/ResetPassword.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
593
client/src/pages/Settings.tsx
Normal file
593
client/src/pages/Settings.tsx
Normal 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
492
client/src/pages/Setup.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
154
client/src/pages/VerifyEmail.tsx
Normal file
154
client/src/pages/VerifyEmail.tsx
Normal 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
34
client/tsconfig.app.json
Normal 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
7
client/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
client/tsconfig.node.json
Normal file
26
client/tsconfig.node.json
Normal 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
27
client/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user