mirror of
http://88.130.71.182:3000/BlitTech/contexta_fe.git
synced 2026-06-12 23:23:22 +00:00
Initial commit
This commit is contained in:
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
.env
|
||||||
73
README.md
Normal file
73
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...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
23
eslint.config.js
Normal file
23
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
16
index.html
Normal file
16
index.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!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" />
|
||||||
|
<title>Contexta - AI Chatbot Platform</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3807
package-lock.json
generated
Normal file
3807
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
package.json
Normal file
38
package.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "contexta-fe",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
"axios": "^1.13.5",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.575.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-dropzone": "^15.0.0",
|
||||||
|
"react-router-dom": "^7.13.0",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"zustand": "^5.0.11"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.7",
|
||||||
|
"@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",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.48.0",
|
||||||
|
"vite": "^7.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
public/vite.svg
Normal file
1
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 |
42
src/App.css
Normal file
42
src/App.css
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.react:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
49
src/App.tsx
Normal file
49
src/App.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
import { AppLayout } from '@/components/Layout'
|
||||||
|
import { LandingPage } from '@/pages/LandingPage'
|
||||||
|
import { LoginPage, SignupPage } from '@/pages/AuthPages'
|
||||||
|
import { DashboardPage } from '@/pages/DashboardPage'
|
||||||
|
import { ChatbotBuilderPage } from '@/pages/ChatbotBuilderPage'
|
||||||
|
import { MarketplacePage, ChatbotDetailPage } from '@/pages/MarketplacePage'
|
||||||
|
import { PricingPage } from '@/pages/PricingPage'
|
||||||
|
import { SettingsPage } from '@/pages/SettingsPage'
|
||||||
|
import './App.css'
|
||||||
|
|
||||||
|
const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const { isAuthenticated } = useAuthStore()
|
||||||
|
if (!isAuthenticated) return <Navigate to="/login" replace />
|
||||||
|
return <AppLayout>{children}</AppLayout>
|
||||||
|
}
|
||||||
|
|
||||||
|
const PublicOnlyRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const { isAuthenticated } = useAuthStore()
|
||||||
|
if (isAuthenticated) return <Navigate to="/dashboard" replace />
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const App: React.FC = () => (
|
||||||
|
<Routes>
|
||||||
|
{/* Public */}
|
||||||
|
<Route path="/" element={<LandingPage />} />
|
||||||
|
<Route path="/pricing" element={<PricingPage />} />
|
||||||
|
<Route path="/marketplace" element={<MarketplacePage />} />
|
||||||
|
<Route path="/marketplace/:id" element={<ChatbotDetailPage />} />
|
||||||
|
|
||||||
|
{/* Auth */}
|
||||||
|
<Route path="/login" element={<PublicOnlyRoute><LoginPage /></PublicOnlyRoute>} />
|
||||||
|
<Route path="/signup" element={<PublicOnlyRoute><SignupPage /></PublicOnlyRoute>} />
|
||||||
|
|
||||||
|
{/* Protected */}
|
||||||
|
<Route path="/dashboard" element={<PrivateRoute><DashboardPage /></PrivateRoute>} />
|
||||||
|
<Route path="/chatbots/new" element={<PrivateRoute><ChatbotBuilderPage /></PrivateRoute>} />
|
||||||
|
<Route path="/chatbots/:id/edit" element={<PrivateRoute><ChatbotBuilderPage /></PrivateRoute>} />
|
||||||
|
<Route path="/chatbots/:id/preview" element={<PrivateRoute><ChatbotBuilderPage /></PrivateRoute>} />
|
||||||
|
<Route path="/settings" element={<PrivateRoute><SettingsPage /></PrivateRoute>} />
|
||||||
|
<Route path="/settings/billing" element={<PrivateRoute><SettingsPage /></PrivateRoute>} />
|
||||||
|
|
||||||
|
{/* Fallback */}
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
1
src/assets/react.svg
Normal file
1
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 |
197
src/components/ChatInterface.tsx
Normal file
197
src/components/ChatInterface.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { chatAPI } from '@/services/api'
|
||||||
|
import type { ChatMessage, SourceDocument } from '@/types'
|
||||||
|
import { Send, Bot, User, FileText, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
|
|
||||||
|
interface ChatInterfaceProps {
|
||||||
|
chatbotId: string
|
||||||
|
chatbotName: string
|
||||||
|
welcomeMessage: string
|
||||||
|
primaryColor: string
|
||||||
|
isPreview?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChatInterface: React.FC<ChatInterfaceProps> = ({
|
||||||
|
chatbotId, chatbotName, welcomeMessage, primaryColor, isPreview = false
|
||||||
|
}) => {
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([
|
||||||
|
{ id: '0', role: 'assistant', content: welcomeMessage }
|
||||||
|
])
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [sessionId] = useState(() => crypto.randomUUID())
|
||||||
|
const [expandedSources, setExpandedSources] = useState<Set<string>>(new Set())
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
const send = async () => {
|
||||||
|
const text = input.trim()
|
||||||
|
if (!text || loading) return
|
||||||
|
setInput('')
|
||||||
|
|
||||||
|
const userMsg: ChatMessage = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
role: 'user',
|
||||||
|
content: text,
|
||||||
|
}
|
||||||
|
setMessages(prev => [...prev, userMsg])
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await chatAPI.send(chatbotId, {
|
||||||
|
message: text,
|
||||||
|
session_id: sessionId,
|
||||||
|
language: navigator.language.split('-')[0] || 'en',
|
||||||
|
})
|
||||||
|
|
||||||
|
const assistantMsg: ChatMessage = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
role: 'assistant',
|
||||||
|
content: response.response,
|
||||||
|
sources: response.sources,
|
||||||
|
}
|
||||||
|
setMessages(prev => [...prev, assistantMsg])
|
||||||
|
} catch (err: any) {
|
||||||
|
const errMsg: ChatMessage = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
role: 'assistant',
|
||||||
|
content: err.response?.data?.detail || 'Sorry, I encountered an error. Please try again.',
|
||||||
|
}
|
||||||
|
setMessages(prev => [...prev, errMsg])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
inputRef.current?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSources = (msgId: string) => {
|
||||||
|
setExpandedSources(prev => {
|
||||||
|
const n = new Set(prev)
|
||||||
|
n.has(msgId) ? n.delete(msgId) : n.add(msgId)
|
||||||
|
return n
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-white rounded-xl overflow-hidden border border-gray-200">
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 px-4 py-3 text-white"
|
||||||
|
style={{ background: primaryColor }}
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center">
|
||||||
|
<Bot className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-sm">{chatbotName}</p>
|
||||||
|
<p className="text-xs opacity-80">Online</p>
|
||||||
|
</div>
|
||||||
|
{isPreview && (
|
||||||
|
<span className="ml-auto bg-white/20 text-white text-xs px-2 py-0.5 rounded-full">
|
||||||
|
Preview
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<div key={msg.id} className={cn('flex gap-3', msg.role === 'user' && 'flex-row-reverse')}>
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className={cn(
|
||||||
|
'w-7 h-7 rounded-full flex-shrink-0 flex items-center justify-center text-white text-xs',
|
||||||
|
msg.role === 'assistant' ? 'bg-primary-600' : 'bg-gray-500'
|
||||||
|
)}
|
||||||
|
style={msg.role === 'assistant' ? { background: primaryColor } : {}}
|
||||||
|
>
|
||||||
|
{msg.role === 'assistant' ? <Bot className="w-3.5 h-3.5" /> : <User className="w-3.5 h-3.5" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn('max-w-[80%] space-y-1', msg.role === 'user' && 'items-end')}>
|
||||||
|
<div className={cn(
|
||||||
|
'px-4 py-2.5 rounded-2xl text-sm leading-relaxed',
|
||||||
|
msg.role === 'assistant'
|
||||||
|
? 'bg-gray-100 text-gray-800 rounded-tl-sm'
|
||||||
|
: 'text-white rounded-tr-sm'
|
||||||
|
)}
|
||||||
|
style={msg.role === 'user' ? { background: primaryColor } : {}}
|
||||||
|
>
|
||||||
|
{msg.content}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sources */}
|
||||||
|
{msg.role === 'assistant' && msg.sources && msg.sources.length > 0 && (
|
||||||
|
<div className="mt-1">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleSources(msg.id)}
|
||||||
|
className="flex items-center gap-1 text-xs text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<FileText className="w-3 h-3" />
|
||||||
|
{msg.sources.length} source{msg.sources.length > 1 ? 's' : ''}
|
||||||
|
{expandedSources.has(msg.id) ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
||||||
|
</button>
|
||||||
|
{expandedSources.has(msg.id) && (
|
||||||
|
<div className="mt-2 space-y-1.5">
|
||||||
|
{msg.sources.map((s, i) => (
|
||||||
|
<div key={i} className="bg-gray-50 border border-gray-200 rounded-lg p-2.5 text-xs">
|
||||||
|
<p className="font-medium text-gray-700 mb-1">📄 {s.document_name}</p>
|
||||||
|
<p className="text-gray-500 line-clamp-2">{s.chunk_text}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="w-7 h-7 rounded-full flex-shrink-0 flex items-center justify-center text-white"
|
||||||
|
style={{ background: primaryColor }}>
|
||||||
|
<Bot className="w-3.5 h-3.5" />
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-100 rounded-2xl rounded-tl-sm px-4 py-3">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<span className="w-1.5 h-1.5 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||||
|
<span className="w-1.5 h-1.5 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||||
|
<span className="w-1.5 h-1.5 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div className="px-4 py-3 border-t border-gray-100">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && send()}
|
||||||
|
placeholder="Type a message..."
|
||||||
|
className="flex-1 px-3 py-2 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={send}
|
||||||
|
disabled={!input.trim() || loading}
|
||||||
|
className="p-2 rounded-xl text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
style={{ background: primaryColor }}
|
||||||
|
>
|
||||||
|
<Send className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-center text-xs text-gray-400 mt-2">Powered by Contexta</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
123
src/components/Layout.tsx
Normal file
123
src/components/Layout.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
import { authAPI } from '@/services/api'
|
||||||
|
import { getPlanColor } from '@/lib/utils'
|
||||||
|
import {
|
||||||
|
Bot, LayoutDashboard, ShoppingBag, Settings,
|
||||||
|
LogOut, Menu, X, Sparkles, ChevronDown
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||||
|
{ label: 'Marketplace', href: '/marketplace', icon: ShoppingBag },
|
||||||
|
{ label: 'Settings', href: '/settings', icon: Settings },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const { user, logout } = useAuthStore()
|
||||||
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try { await authAPI.logout() } catch {}
|
||||||
|
logout()
|
||||||
|
navigate('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-gray-50 overflow-hidden">
|
||||||
|
{/* Mobile backdrop */}
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div className="fixed inset-0 z-20 bg-black/40 lg:hidden" onClick={() => setSidebarOpen(false)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className={cn(
|
||||||
|
'fixed lg:relative z-30 flex flex-col w-64 h-full bg-white border-r border-gray-200 transition-transform duration-200',
|
||||||
|
sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||||
|
)}>
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex items-center gap-2 px-6 py-5 border-b border-gray-100">
|
||||||
|
<div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
|
||||||
|
<Sparkles className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-gray-900 text-lg">Contexta</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav */}
|
||||||
|
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
|
||||||
|
{NAV_ITEMS.map(({ label, href, icon: Icon }) => {
|
||||||
|
const active = location.pathname.startsWith(href)
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={href}
|
||||||
|
to={href}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors',
|
||||||
|
active
|
||||||
|
? 'bg-primary-50 text-primary-700'
|
||||||
|
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
|
||||||
|
)}
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User profile */}
|
||||||
|
<div className="px-4 py-4 border-t border-gray-100">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="w-9 h-9 rounded-full bg-primary-100 flex items-center justify-center text-primary-700 font-semibold text-sm">
|
||||||
|
{user?.email?.[0]?.toUpperCase() || 'U'}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-900 truncate">{user?.company_name || 'My Company'}</p>
|
||||||
|
<p className="text-xs text-gray-500 truncate">{user?.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn('px-2 py-0.5 text-xs font-medium rounded-full capitalize', getPlanColor(user?.plan || 'free'))}>
|
||||||
|
{user?.plan || 'free'}
|
||||||
|
</span>
|
||||||
|
<Link to="/settings/billing" className="text-xs text-primary-600 hover:underline ml-auto">
|
||||||
|
Upgrade
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex items-center gap-2 mt-3 w-full px-3 py-2 text-sm text-gray-500 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
|
{/* Mobile header */}
|
||||||
|
<header className="flex items-center gap-4 px-4 py-3 bg-white border-b border-gray-200 lg:hidden">
|
||||||
|
<button onClick={() => setSidebarOpen(true)} className="p-1 rounded-lg hover:bg-gray-100">
|
||||||
|
<Menu className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-6 h-6 bg-primary-600 rounded flex items-center justify-center">
|
||||||
|
<Sparkles className="w-3 h-3 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-gray-900">Contexta</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex-1 overflow-y-auto">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
251
src/components/ui.tsx
Normal file
251
src/components/ui.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
// ─── Button ────────────────────────────────────────────────────────────────────
|
||||||
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
|
loading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button: React.FC<ButtonProps> = ({
|
||||||
|
variant = 'primary', size = 'md', loading, disabled, children, className, ...props
|
||||||
|
}) => {
|
||||||
|
const base = 'inline-flex items-center justify-center gap-2 font-medium rounded-lg transition-all focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed'
|
||||||
|
const variants = {
|
||||||
|
primary: 'bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500',
|
||||||
|
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-400',
|
||||||
|
outline: 'border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 focus:ring-primary-500',
|
||||||
|
ghost: 'text-gray-600 hover:bg-gray-100 focus:ring-gray-400',
|
||||||
|
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
|
||||||
|
}
|
||||||
|
const sizes = { sm: 'px-3 py-1.5 text-sm', md: 'px-4 py-2 text-sm', lg: 'px-6 py-3 text-base' }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(base, variants[variant], sizes[size], className)}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{loading && (
|
||||||
|
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Input ─────────────────────────────────────────────────────────────────────
|
||||||
|
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label?: string
|
||||||
|
error?: string
|
||||||
|
hint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Input: React.FC<InputProps> = ({ label, error, hint, className, id, ...props }) => {
|
||||||
|
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{label && (
|
||||||
|
<label htmlFor={inputId} className="text-sm font-medium text-gray-700">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors',
|
||||||
|
error ? 'border-red-400 bg-red-50' : 'border-gray-300 bg-white hover:border-gray-400',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||||
|
{hint && !error && <p className="text-xs text-gray-500">{hint}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Textarea ─────────────────────────────────────────────────────────────────
|
||||||
|
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||||
|
label?: string
|
||||||
|
error?: string
|
||||||
|
hint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Textarea: React.FC<TextareaProps> = ({ label, error, hint, className, id, ...props }) => {
|
||||||
|
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{label && <label htmlFor={inputId} className="text-sm font-medium text-gray-700">{label}</label>}
|
||||||
|
<textarea
|
||||||
|
id={inputId}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none transition-colors',
|
||||||
|
error ? 'border-red-400 bg-red-50' : 'border-gray-300 bg-white hover:border-gray-400',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||||
|
{hint && !error && <p className="text-xs text-gray-500">{hint}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Select ────────────────────────────────────────────────────────────────────
|
||||||
|
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||||
|
label?: string
|
||||||
|
error?: string
|
||||||
|
options: { value: string; label: string; disabled?: boolean }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Select: React.FC<SelectProps> = ({ label, error, options, className, id, ...props }) => {
|
||||||
|
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{label && <label htmlFor={inputId} className="text-sm font-medium text-gray-700">{label}</label>}
|
||||||
|
<select
|
||||||
|
id={inputId}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white',
|
||||||
|
error ? 'border-red-400' : 'border-gray-300',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{options.map(o => (
|
||||||
|
<option key={o.value} value={o.value} disabled={o.disabled}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Card ──────────────────────────────────────────────────────────────────────
|
||||||
|
export const Card: React.FC<{ children: React.ReactNode; className?: string; onClick?: () => void }> = ({
|
||||||
|
children, className, onClick
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
className={cn('bg-white rounded-xl border border-gray-200 shadow-sm', onClick && 'cursor-pointer hover:shadow-md transition-shadow', className)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Badge ─────────────────────────────────────────────────────────────────────
|
||||||
|
interface BadgeProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
variant?: 'default' | 'success' | 'warning' | 'error' | 'info' | 'purple'
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Badge: React.FC<BadgeProps> = ({ children, variant = 'default', className }) => {
|
||||||
|
const variants = {
|
||||||
|
default: 'bg-gray-100 text-gray-700',
|
||||||
|
success: 'bg-green-100 text-green-700',
|
||||||
|
warning: 'bg-yellow-100 text-yellow-700',
|
||||||
|
error: 'bg-red-100 text-red-700',
|
||||||
|
info: 'bg-blue-100 text-blue-700',
|
||||||
|
purple: 'bg-purple-100 text-purple-700',
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className={cn('inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full', variants[variant], className)}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Spinner ───────────────────────────────────────────────────────────────────
|
||||||
|
export const Spinner: React.FC<{ className?: string }> = ({ className }) => (
|
||||||
|
<svg className={cn('animate-spin h-5 w-5', className)} viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Empty State ───────────────────────────────────────────────────────────────
|
||||||
|
export const EmptyState: React.FC<{
|
||||||
|
icon: React.ReactNode; title: string; description: string; action?: React.ReactNode
|
||||||
|
}> = ({ icon, title, description, action }) => (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center mb-4 text-gray-400">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
|
||||||
|
<p className="text-sm text-gray-500 max-w-sm mb-6">{description}</p>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Modal ─────────────────────────────────────────────────────────────────────
|
||||||
|
interface ModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
title?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, className }) => {
|
||||||
|
if (!isOpen) return null
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||||
|
<div className={cn('relative bg-white rounded-2xl shadow-xl w-full max-w-lg', className)}>
|
||||||
|
{title && (
|
||||||
|
<div className="flex items-center justify-between p-6 border-b">
|
||||||
|
<h2 className="text-lg font-semibold">{title}</h2>
|
||||||
|
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded-lg text-gray-500">
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="p-6">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Toast ─────────────────────────────────────────────────────────────────────
|
||||||
|
interface ToastProps { message: string; type?: 'success' | 'error' | 'info'; onClose: () => void }
|
||||||
|
|
||||||
|
export const Toast: React.FC<ToastProps> = ({ message, type = 'info', onClose }) => {
|
||||||
|
React.useEffect(() => {
|
||||||
|
const t = setTimeout(onClose, 4000)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
success: 'bg-green-50 border-green-200 text-green-800',
|
||||||
|
error: 'bg-red-50 border-red-200 text-red-800',
|
||||||
|
info: 'bg-blue-50 border-blue-200 text-blue-800',
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={cn('fixed bottom-4 right-4 z-50 max-w-sm p-4 rounded-xl border shadow-lg flex items-start gap-3 animate-in slide-in-from-bottom-2', styles[type])}>
|
||||||
|
<span className="flex-1 text-sm font-medium">{message}</span>
|
||||||
|
<button onClick={onClose} className="text-current opacity-60 hover:opacity-100">×</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Status Dot ────────────────────────────────────────────────────────────────
|
||||||
|
export const StatusDot: React.FC<{ status: string }> = ({ status }) => {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
completed: 'bg-green-500',
|
||||||
|
processing: 'bg-yellow-500 animate-pulse',
|
||||||
|
pending: 'bg-gray-400',
|
||||||
|
failed: 'bg-red-500',
|
||||||
|
active: 'bg-green-500',
|
||||||
|
published: 'bg-green-500',
|
||||||
|
preview: 'bg-gray-400',
|
||||||
|
}
|
||||||
|
return <span className={cn('w-2 h-2 rounded-full inline-block', colors[status] || 'bg-gray-400')} />
|
||||||
|
}
|
||||||
72
src/index.css
Normal file
72
src/index.css
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-gray-50 text-gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
@apply bg-transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
@apply bg-gray-300 rounded-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
@apply bg-gray-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.line-clamp-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-clamp-3 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-in {
|
||||||
|
animation-fill-mode: both;
|
||||||
|
animation-duration: 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-in-from-bottom-2 {
|
||||||
|
animation-name: slideInFromBottom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInFromBottom {
|
||||||
|
from {
|
||||||
|
transform: translateY(8px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
136
src/lib/utils.ts
Normal file
136
src/lib/utils.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { type ClassValue, clsx } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(date: string | Date): string {
|
||||||
|
return new Intl.DateTimeFormat('en-US', {
|
||||||
|
month: 'short', day: 'numeric', year: 'numeric'
|
||||||
|
}).format(new Date(date))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRelativeTime(date: string | Date): string {
|
||||||
|
const now = new Date()
|
||||||
|
const d = new Date(date)
|
||||||
|
const diff = now.getTime() - d.getTime()
|
||||||
|
const days = Math.floor(diff / 86400000)
|
||||||
|
if (days === 0) return 'Today'
|
||||||
|
if (days === 1) return 'Yesterday'
|
||||||
|
if (days < 7) return `${days}d ago`
|
||||||
|
if (days < 30) return `${Math.floor(days / 7)}w ago`
|
||||||
|
return formatDate(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncate(str: string, length: number): string {
|
||||||
|
if (str.length <= length) return str
|
||||||
|
return str.slice(0, length) + '...'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileIcon(type: string): string {
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
'.pdf': '📄',
|
||||||
|
'.docx': '📝',
|
||||||
|
'.csv': '📊',
|
||||||
|
'.xlsx': '📊',
|
||||||
|
'.txt': '📃',
|
||||||
|
'.md': '📃',
|
||||||
|
}
|
||||||
|
return icons[type] || '📄'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlanColor(plan: string): string {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
free: 'text-gray-600 bg-gray-100',
|
||||||
|
starter: 'text-blue-600 bg-blue-100',
|
||||||
|
pro: 'text-purple-600 bg-purple-100',
|
||||||
|
enterprise: 'text-orange-600 bg-orange-100',
|
||||||
|
}
|
||||||
|
return colors[plan] || colors.free
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AVAILABLE_MODELS = [
|
||||||
|
{
|
||||||
|
id: 'accounts/fireworks/models/llama-v3p1-70b-instruct',
|
||||||
|
name: 'Llama 3.1 70B',
|
||||||
|
provider: 'Fireworks AI',
|
||||||
|
plans: ['starter', 'pro', 'enterprise'],
|
||||||
|
badge: 'Fast',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'accounts/fireworks/models/mixtral-8x7b-instruct',
|
||||||
|
name: 'Mixtral 8x7B',
|
||||||
|
provider: 'Fireworks AI',
|
||||||
|
plans: ['starter', 'pro', 'enterprise'],
|
||||||
|
badge: 'Balanced',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gpt-4o',
|
||||||
|
name: 'GPT-4o',
|
||||||
|
provider: 'OpenAI',
|
||||||
|
plans: ['pro', 'enterprise'],
|
||||||
|
badge: 'Powerful',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gpt-4-turbo',
|
||||||
|
name: 'GPT-4 Turbo',
|
||||||
|
provider: 'OpenAI',
|
||||||
|
plans: ['pro', 'enterprise'],
|
||||||
|
badge: 'Smart',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gpt-3.5-turbo',
|
||||||
|
name: 'GPT-3.5 Turbo',
|
||||||
|
provider: 'OpenAI',
|
||||||
|
plans: ['pro', 'enterprise'],
|
||||||
|
badge: 'Efficient',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'claude-3-5-sonnet-20241022',
|
||||||
|
name: 'Claude 3.5 Sonnet',
|
||||||
|
provider: 'Anthropic',
|
||||||
|
plans: ['pro', 'enterprise'],
|
||||||
|
badge: 'Reasoning',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'claude-3-opus-20240229',
|
||||||
|
name: 'Claude 3 Opus',
|
||||||
|
provider: 'Anthropic',
|
||||||
|
plans: ['pro', 'enterprise'],
|
||||||
|
badge: 'Advanced',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gemini-1.5-pro',
|
||||||
|
name: 'Gemini 1.5 Pro',
|
||||||
|
provider: 'Google',
|
||||||
|
plans: ['pro', 'enterprise'],
|
||||||
|
badge: 'Long Context',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const CATEGORIES = [
|
||||||
|
'Customer Support', 'Sales', 'FAQ', 'E-commerce',
|
||||||
|
'Healthcare', 'Finance', 'Education', 'HR', 'Legal', 'Other'
|
||||||
|
]
|
||||||
|
|
||||||
|
export const INDUSTRIES = [
|
||||||
|
'Technology', 'E-commerce', 'Healthcare', 'Finance',
|
||||||
|
'Education', 'Legal', 'Real Estate', 'Hospitality', 'Retail', 'Other'
|
||||||
|
]
|
||||||
|
|
||||||
|
export const LANGUAGES = [
|
||||||
|
{ code: 'en', name: 'English' }, { code: 'fr', name: 'French' },
|
||||||
|
{ code: 'es', name: 'Spanish' }, { code: 'de', name: 'German' },
|
||||||
|
{ code: 'it', name: 'Italian' }, { code: 'pt', name: 'Portuguese' },
|
||||||
|
{ code: 'ja', name: 'Japanese' }, { code: 'ko', name: 'Korean' },
|
||||||
|
{ code: 'zh', name: 'Chinese' }, { code: 'ar', name: 'Arabic' },
|
||||||
|
]
|
||||||
25
src/main.tsx
Normal file
25
src/main.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { App } from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 1,
|
||||||
|
staleTime: 30_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
207
src/pages/AuthPages.tsx
Normal file
207
src/pages/AuthPages.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
import { authAPI } from '@/services/api'
|
||||||
|
import { Button, Input } from '@/components/ui'
|
||||||
|
import { Sparkles, Eye, EyeOff } from 'lucide-react'
|
||||||
|
|
||||||
|
export const LoginPage: React.FC = () => {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [showPass, setShowPass] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const { setAuth } = useAuthStore()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await authAPI.login({ email, password })
|
||||||
|
setAuth(data.user, data.access_token)
|
||||||
|
navigate('/dashboard')
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.detail || 'Login failed. Please check your credentials.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-primary-50 via-white to-purple-50 flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="inline-flex w-12 h-12 bg-primary-600 rounded-2xl items-center justify-center mb-4">
|
||||||
|
<Sparkles className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Welcome back</h1>
|
||||||
|
<p className="text-gray-500 mt-1 text-sm">Sign in to your Contexta account</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-8">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
placeholder="you@company.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
label="Password"
|
||||||
|
type={showPass ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPass(!showPass)}
|
||||||
|
className="absolute right-3 top-8 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
{showPass ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" loading={loading} className="w-full" size="lg">
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center text-sm text-gray-500">
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<Link to="/signup" className="text-primary-600 font-medium hover:underline">
|
||||||
|
Create one free
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SignupPage: React.FC = () => {
|
||||||
|
const [form, setForm] = useState({ email: '', password: '', company_name: '' })
|
||||||
|
const [showPass, setShowPass] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const { setAuth } = useAuthStore()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
if (form.password.length < 8) {
|
||||||
|
setError('Password must be at least 8 characters')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await authAPI.signup(form)
|
||||||
|
setAuth(data.user, data.access_token)
|
||||||
|
navigate('/dashboard')
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.detail || 'Signup failed. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-primary-50 via-white to-purple-50 flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="inline-flex w-12 h-12 bg-primary-600 rounded-2xl items-center justify-center mb-4">
|
||||||
|
<Sparkles className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Create your account</h1>
|
||||||
|
<p className="text-gray-500 mt-1 text-sm">Start building AI chatbots for free</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-8">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="Company Name"
|
||||||
|
type="text"
|
||||||
|
value={form.company_name}
|
||||||
|
onChange={e => setForm(f => ({ ...f, company_name: e.target.value }))}
|
||||||
|
placeholder="Acme Corp"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
|
||||||
|
placeholder="you@company.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
label="Password"
|
||||||
|
type={showPass ? 'text' : 'password'}
|
||||||
|
value={form.password}
|
||||||
|
onChange={e => setForm(f => ({ ...f, password: e.target.value }))}
|
||||||
|
placeholder="Min 8 characters"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPass(!showPass)}
|
||||||
|
className="absolute right-3 top-8 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
{showPass ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" loading={loading} className="w-full" size="lg">
|
||||||
|
Create free account
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-xs text-center text-gray-400">
|
||||||
|
By signing up, you agree to our Terms of Service and Privacy Policy
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center text-sm text-gray-500">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link to="/login" className="text-primary-600 font-medium hover:underline">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<div className="mt-8 grid grid-cols-3 gap-4">
|
||||||
|
{[
|
||||||
|
{ emoji: '🤖', text: 'Build unlimited chatbots free' },
|
||||||
|
{ emoji: '📄', text: 'Upload PDF, DOCX, CSV files' },
|
||||||
|
{ emoji: '🏪', text: 'Publish to marketplace' },
|
||||||
|
].map(({ emoji, text }) => (
|
||||||
|
<div key={text} className="text-center">
|
||||||
|
<div className="text-2xl mb-1">{emoji}</div>
|
||||||
|
<p className="text-xs text-gray-500">{text}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
446
src/pages/ChatbotBuilderPage.tsx
Normal file
446
src/pages/ChatbotBuilderPage.tsx
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { chatbotsAPI, documentsAPI } from '@/services/api'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
import { Button, Input, Textarea, Select, Card, Badge, StatusDot, Spinner } from '@/components/ui'
|
||||||
|
import { AVAILABLE_MODELS, CATEGORIES, INDUSTRIES, LANGUAGES, formatBytes, getFileIcon } from '@/lib/utils'
|
||||||
|
import { ChatInterface } from '@/components/ChatInterface'
|
||||||
|
import { useDropzone } from 'react-dropzone'
|
||||||
|
import type { ChatbotFormData } from '@/types'
|
||||||
|
import {
|
||||||
|
ArrowLeft, Save, Eye, Upload, Trash2, FileText,
|
||||||
|
Bot, Sliders, Globe, Code, AlertCircle, CheckCircle
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
const DEFAULT_FORM: ChatbotFormData = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
system_prompt: '',
|
||||||
|
model: 'accounts/fireworks/models/llama-v3p1-70b-instruct',
|
||||||
|
temperature: 0.7,
|
||||||
|
max_tokens: 1000,
|
||||||
|
primary_color: '#6366f1',
|
||||||
|
welcome_message: 'Hello! How can I help you today?',
|
||||||
|
category: '',
|
||||||
|
industry: '',
|
||||||
|
languages: ['en'],
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tab = 'settings' | 'documents' | 'preview'
|
||||||
|
|
||||||
|
export const ChatbotBuilderPage: React.FC = () => {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const isNew = !id || id === 'new'
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { user } = useAuthStore()
|
||||||
|
const [tab, setTab] = useState<Tab>('settings')
|
||||||
|
const [form, setForm] = useState<ChatbotFormData>(DEFAULT_FORM)
|
||||||
|
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null)
|
||||||
|
const [chatbotId, setChatbotId] = useState<string | null>(isNew ? null : id || null)
|
||||||
|
|
||||||
|
// Load existing chatbot
|
||||||
|
const { data: existingChatbot, isLoading: loadingChatbot } = useQuery({
|
||||||
|
queryKey: ['chatbot', id],
|
||||||
|
queryFn: () => chatbotsAPI.get(id!),
|
||||||
|
enabled: !isNew,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (existingChatbot) {
|
||||||
|
setForm({
|
||||||
|
name: existingChatbot.name,
|
||||||
|
description: existingChatbot.description || '',
|
||||||
|
system_prompt: existingChatbot.system_prompt || '',
|
||||||
|
model: existingChatbot.model,
|
||||||
|
temperature: existingChatbot.temperature,
|
||||||
|
max_tokens: existingChatbot.max_tokens,
|
||||||
|
primary_color: existingChatbot.primary_color,
|
||||||
|
welcome_message: existingChatbot.welcome_message,
|
||||||
|
category: existingChatbot.category || '',
|
||||||
|
industry: existingChatbot.industry || '',
|
||||||
|
languages: existingChatbot.languages,
|
||||||
|
})
|
||||||
|
setChatbotId(existingChatbot.id)
|
||||||
|
}
|
||||||
|
}, [existingChatbot])
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: chatbotsAPI.create,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setChatbotId(data.id)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['chatbots'] })
|
||||||
|
navigate(`/chatbots/${data.id}/edit`, { replace: true })
|
||||||
|
showToast('Chatbot created!', 'success')
|
||||||
|
},
|
||||||
|
onError: (err: any) => showToast(err.response?.data?.detail || 'Failed to create', 'error'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (data: Partial<ChatbotFormData>) => chatbotsAPI.update(chatbotId!, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['chatbot', chatbotId] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['chatbots'] })
|
||||||
|
showToast('Settings saved!', 'success')
|
||||||
|
},
|
||||||
|
onError: (err: any) => showToast(err.response?.data?.detail || 'Save failed', 'error'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!form.name.trim()) { showToast('Chatbot name is required', 'error'); return }
|
||||||
|
if (isNew || !chatbotId) {
|
||||||
|
createMutation.mutate(form)
|
||||||
|
} else {
|
||||||
|
updateMutation.mutate(form)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showToast = (msg: string, type: 'success' | 'error') => {
|
||||||
|
setToast({ msg, type })
|
||||||
|
setTimeout(() => setToast(null), 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableModels = AVAILABLE_MODELS.filter(m =>
|
||||||
|
m.plans.includes(user?.plan || 'free') || m.plans.includes('starter')
|
||||||
|
)
|
||||||
|
|
||||||
|
if (loadingChatbot) {
|
||||||
|
return <div className="flex items-center justify-center h-full"><Spinner className="text-primary-600" /></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Top bar */}
|
||||||
|
<div className="flex items-center gap-4 px-6 py-4 bg-white border-b border-gray-200">
|
||||||
|
<Link to="/dashboard" className="p-1.5 hover:bg-gray-100 rounded-lg">
|
||||||
|
<ArrowLeft className="w-4 h-4 text-gray-600" />
|
||||||
|
</Link>
|
||||||
|
<h1 className="font-semibold text-gray-900 flex-1">
|
||||||
|
{isNew ? 'Create Chatbot' : form.name || 'Edit Chatbot'}
|
||||||
|
</h1>
|
||||||
|
{existingChatbot && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<StatusDot status={existingChatbot.is_published ? 'published' : 'preview'} />
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{existingChatbot.is_published ? 'Published' : 'Preview only'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
loading={createMutation.isPending || updateMutation.isPending}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Save className="w-3.5 h-3.5" />
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-1 px-6 py-2 bg-white border-b border-gray-100">
|
||||||
|
{([
|
||||||
|
{ id: 'settings', label: 'Settings', icon: Sliders },
|
||||||
|
{ id: 'documents', label: 'Documents', icon: FileText, disabled: !chatbotId },
|
||||||
|
{ id: 'preview', label: 'Preview', icon: Eye, disabled: !chatbotId },
|
||||||
|
] as const).map(({ id: tid, label, icon: Icon, disabled }) => (
|
||||||
|
<button
|
||||||
|
key={tid}
|
||||||
|
onClick={() => !disabled && setTab(tid as Tab)}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
tab === tid
|
||||||
|
? 'bg-primary-50 text-primary-700'
|
||||||
|
: disabled
|
||||||
|
? 'text-gray-300 cursor-not-allowed'
|
||||||
|
: 'text-gray-600 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-3.5 h-3.5" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{tab === 'settings' && (
|
||||||
|
<SettingsTab form={form} setForm={setForm} availableModels={availableModels} userPlan={user?.plan || 'free'} />
|
||||||
|
)}
|
||||||
|
{tab === 'documents' && chatbotId && (
|
||||||
|
<DocumentsTab chatbotId={chatbotId} />
|
||||||
|
)}
|
||||||
|
{tab === 'preview' && chatbotId && existingChatbot && (
|
||||||
|
<div className="p-6 max-w-2xl mx-auto">
|
||||||
|
<div className="h-[600px]">
|
||||||
|
<ChatInterface
|
||||||
|
chatbotId={chatbotId}
|
||||||
|
chatbotName={existingChatbot.name}
|
||||||
|
welcomeMessage={existingChatbot.welcome_message}
|
||||||
|
primaryColor={existingChatbot.primary_color}
|
||||||
|
isPreview
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toast */}
|
||||||
|
{toast && (
|
||||||
|
<div className={`fixed bottom-4 right-4 px-4 py-2 rounded-lg text-sm shadow-lg z-50 text-white flex items-center gap-2 ${
|
||||||
|
toast.type === 'success' ? 'bg-green-600' : 'bg-red-600'
|
||||||
|
}`}>
|
||||||
|
{toast.type === 'success' ? <CheckCircle className="w-4 h-4" /> : <AlertCircle className="w-4 h-4" />}
|
||||||
|
{toast.msg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const SettingsTab: React.FC<{
|
||||||
|
form: ChatbotFormData
|
||||||
|
setForm: React.Dispatch<React.SetStateAction<ChatbotFormData>>
|
||||||
|
availableModels: typeof AVAILABLE_MODELS
|
||||||
|
userPlan: string
|
||||||
|
}> = ({ form, setForm, availableModels, userPlan }) => {
|
||||||
|
const set = (key: keyof ChatbotFormData) => (val: any) => setForm(f => ({ ...f, [key]: val }))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-2xl mx-auto space-y-6">
|
||||||
|
{/* Basic Info */}
|
||||||
|
<Card className="p-6 space-y-4">
|
||||||
|
<h2 className="font-semibold text-gray-900 text-sm uppercase tracking-wide text-gray-500">Basic Info</h2>
|
||||||
|
<Input label="Chatbot Name *" value={form.name} onChange={e => set('name')(e.target.value)} placeholder="My Support Bot" />
|
||||||
|
<Textarea label="Description" value={form.description} onChange={e => set('description')(e.target.value)} placeholder="What does this chatbot help with?" rows={2} />
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">Welcome Message</label>
|
||||||
|
<Textarea value={form.welcome_message} onChange={e => set('welcome_message')(e.target.value)} rows={2} />
|
||||||
|
</div>
|
||||||
|
<Textarea label="System Prompt (optional)" value={form.system_prompt} onChange={e => set('system_prompt')(e.target.value)} placeholder="You are a helpful assistant for..." rows={3} hint="Custom instructions for the AI's behavior and personality" />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Appearance */}
|
||||||
|
<Card className="p-6 space-y-4">
|
||||||
|
<h2 className="font-semibold text-gray-900 text-sm uppercase tracking-wide text-gray-500">Appearance</h2>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">Brand Color</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input type="color" value={form.primary_color} onChange={e => set('primary_color')(e.target.value)}
|
||||||
|
className="w-10 h-10 rounded-lg border border-gray-200 cursor-pointer" />
|
||||||
|
<Input value={form.primary_color} onChange={e => set('primary_color')(e.target.value)} className="w-32" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'].map(c => (
|
||||||
|
<button key={c} onClick={() => set('primary_color')(c)}
|
||||||
|
className="w-6 h-6 rounded-full border-2 border-white shadow-sm"
|
||||||
|
style={{ background: c }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* AI Model */}
|
||||||
|
<Card className="p-6 space-y-4">
|
||||||
|
<h2 className="font-semibold text-gray-900 text-sm uppercase tracking-wide text-gray-500">AI Model</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{availableModels.map(m => (
|
||||||
|
<label key={m.id} className={`flex items-center gap-3 p-3 rounded-xl border cursor-pointer transition-colors ${
|
||||||
|
form.model === m.id ? 'border-primary-400 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
|
||||||
|
}`}>
|
||||||
|
<input type="radio" name="model" value={m.id} checked={form.model === m.id} onChange={() => set('model')(m.id)} className="sr-only" />
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center text-xs font-bold text-gray-600">
|
||||||
|
{m.provider[0]}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-900">{m.name}</p>
|
||||||
|
<p className="text-xs text-gray-500">{m.provider}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant={m.badge === 'Powerful' ? 'purple' : 'default'}>{m.badge}</Badge>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{userPlan === 'free' || userPlan === 'starter' ? (
|
||||||
|
<div className="p-3 bg-gray-50 rounded-xl border border-dashed border-gray-300 text-center">
|
||||||
|
<p className="text-xs text-gray-500">🔒 <Link to="/pricing" className="text-primary-600 font-medium">Upgrade to Pro</Link> for GPT-4o, Claude, Gemini</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">Temperature</label>
|
||||||
|
<input type="range" min="0" max="1" step="0.1" value={form.temperature}
|
||||||
|
onChange={e => set('temperature')(parseFloat(e.target.value))}
|
||||||
|
className="w-full accent-primary-600" />
|
||||||
|
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||||
|
<span>Precise ({form.temperature})</span>
|
||||||
|
<span>Creative</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
label="Max Tokens"
|
||||||
|
type="number"
|
||||||
|
value={form.max_tokens}
|
||||||
|
onChange={e => set('max_tokens')(parseInt(e.target.value))}
|
||||||
|
min={100} max={8000}
|
||||||
|
hint="Max response length"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Classification */}
|
||||||
|
<Card className="p-6 space-y-4">
|
||||||
|
<h2 className="font-semibold text-gray-900 text-sm uppercase tracking-wide text-gray-500">Classification</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Select
|
||||||
|
label="Category"
|
||||||
|
value={form.category}
|
||||||
|
onChange={e => set('category')(e.target.value)}
|
||||||
|
options={[{ value: '', label: 'Select category' }, ...CATEGORIES.map(c => ({ value: c, label: c }))]}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Industry"
|
||||||
|
value={form.industry}
|
||||||
|
onChange={e => set('industry')(e.target.value)}
|
||||||
|
options={[{ value: '', label: 'Select industry' }, ...INDUSTRIES.map(i => ({ value: i, label: i }))]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const DocumentsTab: React.FC<{ chatbotId: string }> = ({ chatbotId }) => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0)
|
||||||
|
const [toast, setToast] = useState('')
|
||||||
|
|
||||||
|
const { data: documents = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['documents', chatbotId],
|
||||||
|
queryFn: () => documentsAPI.list(chatbotId),
|
||||||
|
refetchInterval: (data) => {
|
||||||
|
if (data && Array.isArray(data)) {
|
||||||
|
const hasProcessing = data.some(d => d.status === 'processing' || d.status === 'pending')
|
||||||
|
return hasProcessing ? 3000 : false
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (docId: string) => documentsAPI.delete(chatbotId, docId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['documents', chatbotId] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['chatbots'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
|
accept: {
|
||||||
|
'application/pdf': ['.pdf'],
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
|
||||||
|
'text/csv': ['.csv'],
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
|
||||||
|
'text/plain': ['.txt'],
|
||||||
|
},
|
||||||
|
multiple: true,
|
||||||
|
onDrop: async (files) => {
|
||||||
|
for (const file of files) {
|
||||||
|
setUploading(true)
|
||||||
|
setUploadProgress(0)
|
||||||
|
try {
|
||||||
|
await documentsAPI.upload(chatbotId, file, setUploadProgress)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['documents', chatbotId] })
|
||||||
|
setToast(`${file.name} uploaded successfully`)
|
||||||
|
} catch (err: any) {
|
||||||
|
setToast(err.response?.data?.detail || `Failed to upload ${file.name}`)
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-2xl mx-auto space-y-4">
|
||||||
|
{/* Dropzone */}
|
||||||
|
<div
|
||||||
|
{...getRootProps()}
|
||||||
|
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors ${
|
||||||
|
isDragActive ? 'border-primary-400 bg-primary-50' : 'border-gray-300 hover:border-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
{uploading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Spinner className="mx-auto text-primary-600" />
|
||||||
|
<p className="text-sm text-gray-600">Uploading & processing... {uploadProgress}%</p>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-1.5">
|
||||||
|
<div className="bg-primary-600 h-1.5 rounded-full transition-all" style={{ width: `${uploadProgress}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload className="w-8 h-8 text-gray-400 mx-auto mb-3" />
|
||||||
|
<p className="text-sm font-medium text-gray-700">
|
||||||
|
{isDragActive ? 'Drop files here' : 'Drag & drop files or click to browse'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Supports PDF, DOCX, CSV, XLSX, TXT • Max 50MB</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Documents list */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-8"><Spinner /></div>
|
||||||
|
) : documents.length === 0 ? (
|
||||||
|
<Card className="p-8 text-center">
|
||||||
|
<FileText className="w-8 h-8 text-gray-300 mx-auto mb-3" />
|
||||||
|
<p className="text-sm font-medium text-gray-500">No documents yet</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">Upload files to give your chatbot knowledge</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{documents.map((doc) => (
|
||||||
|
<Card key={doc.id} className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xl">{getFileIcon(doc.file_type)}</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-900 truncate">{doc.file_name}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${
|
||||||
|
doc.status === 'completed' ? 'bg-green-500' :
|
||||||
|
doc.status === 'failed' ? 'bg-red-500' : 'bg-yellow-500 animate-pulse'
|
||||||
|
}`} />
|
||||||
|
<p className="text-xs text-gray-400 capitalize">
|
||||||
|
{doc.status === 'completed' ? `${doc.chunk_count} chunks indexed` :
|
||||||
|
doc.status === 'failed' ? doc.error_message || 'Processing failed' :
|
||||||
|
'Processing...'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteMutation.mutate(doc.id)}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{toast && (
|
||||||
|
<div className="fixed bottom-4 right-4 bg-gray-900 text-white px-4 py-2 rounded-lg text-sm shadow-lg z-50">
|
||||||
|
{toast}
|
||||||
|
<button onClick={() => setToast('')} className="ml-3 opacity-60 hover:opacity-100">×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
299
src/pages/DashboardPage.tsx
Normal file
299
src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { chatbotsAPI } from '@/services/api'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
import { Button, Card, Badge, StatusDot, EmptyState, Modal, Spinner } from '@/components/ui'
|
||||||
|
import { formatDate, getFileIcon, cn } from '@/lib/utils'
|
||||||
|
import type { Chatbot } from '@/types'
|
||||||
|
import {
|
||||||
|
Bot, Plus, MoreHorizontal, Globe, Lock, Trash2,
|
||||||
|
Settings, Upload, Eye, ExternalLink, Download, BarChart2
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
export const DashboardPage: React.FC = () => {
|
||||||
|
const { user } = useAuthStore()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||||
|
const [toast, setToast] = useState<string>('')
|
||||||
|
|
||||||
|
const { data: chatbots = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['chatbots'],
|
||||||
|
queryFn: chatbotsAPI.list,
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: chatbotsAPI.delete,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['chatbots'] })
|
||||||
|
setDeleteId(null)
|
||||||
|
setToast('Chatbot deleted')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const publishMutation = useMutation({
|
||||||
|
mutationFn: chatbotsAPI.publish,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['chatbots'] })
|
||||||
|
setToast('Chatbot published to marketplace!')
|
||||||
|
},
|
||||||
|
onError: (err: any) => setToast(err.response?.data?.detail || 'Failed to publish'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const unpublishMutation = useMutation({
|
||||||
|
mutationFn: chatbotsAPI.unpublish,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['chatbots'] })
|
||||||
|
setToast('Chatbot unpublished')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-6xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
|
Good {getGreeting()}, {user?.company_name || 'there'} 👋
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 text-sm mt-1">Manage your AI chatbots</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => navigate('/chatbots/new')} size="lg">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
New Chatbot
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||||
|
{[
|
||||||
|
{ label: 'Total Chatbots', value: chatbots.length, icon: '🤖' },
|
||||||
|
{ label: 'Published', value: chatbots.filter(c => c.is_published).length, icon: '🌐' },
|
||||||
|
{ label: 'Documents', value: chatbots.reduce((sum, c) => sum + c.document_count, 0), icon: '📄' },
|
||||||
|
{ label: 'Conversations', value: chatbots.reduce((sum, c) => sum + c.conversation_count, 0), icon: '💬' },
|
||||||
|
].map(({ label, value, icon }) => (
|
||||||
|
<Card key={label} className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-2xl">{icon}</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{value}</p>
|
||||||
|
<p className="text-xs text-gray-500">{label}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Plan notice */}
|
||||||
|
{user?.plan === 'free' && (
|
||||||
|
<div className="mb-6 p-4 bg-gradient-to-r from-primary-50 to-purple-50 border border-primary-200 rounded-xl flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-primary-900">You're on the Free plan</p>
|
||||||
|
<p className="text-xs text-primary-700 mt-0.5">
|
||||||
|
Upgrade to publish chatbots to the marketplace and unlock premium AI models
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link to="/pricing">
|
||||||
|
<Button size="sm">Upgrade</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Chatbots Grid */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Spinner className="text-primary-600" />
|
||||||
|
</div>
|
||||||
|
) : chatbots.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Bot className="w-8 h-8" />}
|
||||||
|
title="No chatbots yet"
|
||||||
|
description="Create your first AI chatbot powered by your documents. It's free to build and test."
|
||||||
|
action={
|
||||||
|
<Button onClick={() => navigate('/chatbots/new')} size="lg">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Create your first chatbot
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
|
{chatbots.map((chatbot) => (
|
||||||
|
<ChatbotCard
|
||||||
|
key={chatbot.id}
|
||||||
|
chatbot={chatbot}
|
||||||
|
onEdit={() => navigate(`/chatbots/${chatbot.id}/edit`)}
|
||||||
|
onPreview={() => navigate(`/chatbots/${chatbot.id}/preview`)}
|
||||||
|
onPublish={() => publishMutation.mutate(chatbot.id)}
|
||||||
|
onUnpublish={() => unpublishMutation.mutate(chatbot.id)}
|
||||||
|
onDelete={() => setDeleteId(chatbot.id)}
|
||||||
|
onAnalytics={() => navigate(`/chatbots/${chatbot.id}/analytics`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{/* Add new card */}
|
||||||
|
<Card
|
||||||
|
className="border-dashed hover:border-primary-400 hover:bg-primary-50 flex items-center justify-center min-h-[200px] cursor-pointer transition-colors"
|
||||||
|
onClick={() => navigate('/chatbots/new')}
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||||
|
<Plus className="w-6 h-6 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-gray-500">New Chatbot</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
<Modal
|
||||||
|
isOpen={!!deleteId}
|
||||||
|
onClose={() => setDeleteId(null)}
|
||||||
|
title="Delete Chatbot"
|
||||||
|
>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
Are you sure you want to delete this chatbot? All documents and conversation history will be permanently removed.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button variant="outline" onClick={() => setDeleteId(null)} className="flex-1">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => deleteId && deleteMutation.mutate(deleteId)}
|
||||||
|
loading={deleteMutation.isPending}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Toast */}
|
||||||
|
{toast && (
|
||||||
|
<div className="fixed bottom-4 right-4 bg-gray-900 text-white px-4 py-2 rounded-lg text-sm shadow-lg z-50">
|
||||||
|
{toast}
|
||||||
|
<button onClick={() => setToast('')} className="ml-3 opacity-60 hover:opacity-100">×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const ChatbotCard: React.FC<{
|
||||||
|
chatbot: Chatbot
|
||||||
|
onEdit: () => void
|
||||||
|
onPreview: () => void
|
||||||
|
onPublish: () => void
|
||||||
|
onUnpublish: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
onAnalytics: () => void
|
||||||
|
}> = ({ chatbot, onEdit, onPreview, onPublish, onUnpublish, onDelete, onAnalytics }) => {
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="p-5">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-xl flex items-center justify-center text-white text-lg"
|
||||||
|
style={{ background: chatbot.primary_color }}
|
||||||
|
>
|
||||||
|
<Bot className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900 text-sm">{chatbot.name}</h3>
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
<StatusDot status={chatbot.is_published ? 'published' : 'preview'} />
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{chatbot.is_published ? 'Published' : 'Preview'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setMenuOpen(!menuOpen)}
|
||||||
|
className="p-1.5 hover:bg-gray-100 rounded-lg text-gray-400"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
{menuOpen && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} />
|
||||||
|
<div className="absolute right-0 mt-1 w-44 bg-white border border-gray-200 rounded-xl shadow-lg z-20 overflow-hidden text-sm">
|
||||||
|
<button onClick={() => { onEdit(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2 hover:bg-gray-50 text-left">
|
||||||
|
<Settings className="w-3.5 h-3.5" /> Edit Settings
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { onPreview(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2 hover:bg-gray-50 text-left">
|
||||||
|
<Eye className="w-3.5 h-3.5" /> Preview
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { onAnalytics(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2 hover:bg-gray-50 text-left">
|
||||||
|
<BarChart2 className="w-3.5 h-3.5" /> Analytics
|
||||||
|
</button>
|
||||||
|
<div className="h-px bg-gray-100" />
|
||||||
|
<button onClick={() => { onDelete(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2 hover:bg-red-50 text-red-600 text-left">
|
||||||
|
<Trash2 className="w-3.5 h-3.5" /> Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{chatbot.description && (
|
||||||
|
<p className="text-xs text-gray-500 mb-3 line-clamp-2">{chatbot.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="flex gap-3 mb-4 text-xs text-gray-500">
|
||||||
|
<span>📄 {chatbot.document_count} docs</span>
|
||||||
|
<span>💬 {chatbot.conversation_count} chats</span>
|
||||||
|
{chatbot.category && <span>🏷 {chatbot.category}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onPreview}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<Eye className="w-3.5 h-3.5" />
|
||||||
|
Preview
|
||||||
|
</Button>
|
||||||
|
{chatbot.is_published ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onUnpublish}
|
||||||
|
className="flex-1 text-orange-600 hover:bg-orange-50"
|
||||||
|
>
|
||||||
|
<Lock className="w-3.5 h-3.5" />
|
||||||
|
Unpublish
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={onPublish}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<Globe className="w-3.5 h-3.5" />
|
||||||
|
Publish
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGreeting() {
|
||||||
|
const h = new Date().getHours()
|
||||||
|
if (h < 12) return 'morning'
|
||||||
|
if (h < 17) return 'afternoon'
|
||||||
|
return 'evening'
|
||||||
|
}
|
||||||
148
src/pages/LandingPage.tsx
Normal file
148
src/pages/LandingPage.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { Sparkles, Bot, Globe, Code, Database, Shield, Zap, ArrowRight, Check } from 'lucide-react'
|
||||||
|
|
||||||
|
export const LandingPage: React.FC = () => (
|
||||||
|
<div className="min-h-screen bg-white">
|
||||||
|
{/* Nav */}
|
||||||
|
<nav className="border-b border-gray-100 sticky top-0 bg-white/80 backdrop-blur-sm z-50">
|
||||||
|
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
|
||||||
|
<Sparkles className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-gray-900 text-lg">Contexta</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link to="/marketplace" className="text-sm text-gray-600 hover:text-gray-900">Marketplace</Link>
|
||||||
|
<Link to="/pricing" className="text-sm text-gray-600 hover:text-gray-900">Pricing</Link>
|
||||||
|
<Link to="/login" className="text-sm text-gray-600 hover:text-gray-900">Sign in</Link>
|
||||||
|
<Link to="/signup" className="bg-primary-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-primary-700 font-medium transition-colors">
|
||||||
|
Get started free
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Hero */}
|
||||||
|
<section className="max-w-6xl mx-auto px-6 pt-20 pb-16 text-center">
|
||||||
|
<div className="inline-flex items-center gap-2 bg-primary-50 text-primary-700 text-sm px-3 py-1 rounded-full mb-6 font-medium">
|
||||||
|
<Zap className="w-3.5 h-3.5" />
|
||||||
|
Build AI chatbots powered by your data
|
||||||
|
</div>
|
||||||
|
<h1 className="text-5xl font-bold text-gray-900 mb-6 leading-tight">
|
||||||
|
Turn your documents into<br />
|
||||||
|
<span className="text-primary-600">intelligent chatbots</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-gray-500 max-w-2xl mx-auto mb-10">
|
||||||
|
Upload PDFs, DOCX, and CSV files to create RAG-powered chatbots in minutes.
|
||||||
|
Publish to our marketplace or export production-ready code.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-center gap-4">
|
||||||
|
<Link to="/signup" className="bg-primary-600 text-white px-6 py-3 rounded-xl font-semibold hover:bg-primary-700 transition-colors flex items-center gap-2">
|
||||||
|
Start for free <ArrowRight className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
<Link to="/marketplace" className="border border-gray-300 text-gray-700 px-6 py-3 rounded-xl font-semibold hover:bg-gray-50 transition-colors">
|
||||||
|
Explore marketplace
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-400 mt-4">No credit card required • Free forever</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<section className="bg-gray-50 py-20">
|
||||||
|
<div className="max-w-6xl mx-auto px-6">
|
||||||
|
<h2 className="text-3xl font-bold text-gray-900 text-center mb-12">Everything you need</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
icon: <Bot className="w-6 h-6" />,
|
||||||
|
title: 'RAG-Powered Chatbots',
|
||||||
|
desc: 'Upload your documents and let AI answer questions based on your actual content with citations.',
|
||||||
|
color: 'bg-blue-100 text-blue-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Globe className="w-6 h-6" />,
|
||||||
|
title: 'Public Marketplace',
|
||||||
|
desc: 'Publish your chatbots to our marketplace so customers can discover and chat with them.',
|
||||||
|
color: 'bg-green-100 text-green-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Code className="w-6 h-6" />,
|
||||||
|
title: 'Code Export',
|
||||||
|
desc: 'Export a complete FastAPI backend + React widget. Deploy anywhere. No vendor lock-in.',
|
||||||
|
color: 'bg-purple-100 text-purple-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Database className="w-6 h-6" />,
|
||||||
|
title: 'Multi-Format Support',
|
||||||
|
desc: 'PDF, Word, Excel, CSV, and plain text files — all processed and indexed automatically.',
|
||||||
|
color: 'bg-orange-100 text-orange-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Sparkles className="w-6 h-6" />,
|
||||||
|
title: 'Premium AI Models',
|
||||||
|
desc: 'Access GPT-4o, Claude 3.5 Sonnet, Gemini 1.5 Pro, and open-source models.',
|
||||||
|
color: 'bg-yellow-100 text-yellow-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Shield className="w-6 h-6" />,
|
||||||
|
title: 'Data Isolation',
|
||||||
|
desc: 'Each company gets its own isolated vector database. Your data is never mixed with others.',
|
||||||
|
color: 'bg-red-100 text-red-600',
|
||||||
|
},
|
||||||
|
].map(({ icon, title, desc, color }) => (
|
||||||
|
<div key={title} className="bg-white rounded-2xl p-6 border border-gray-200">
|
||||||
|
<div className={`w-12 h-12 rounded-xl flex items-center justify-center mb-4 ${color}`}>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-2">{title}</h3>
|
||||||
|
<p className="text-sm text-gray-500 leading-relaxed">{desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Pricing teaser */}
|
||||||
|
<section className="max-w-4xl mx-auto px-6 py-20 text-center">
|
||||||
|
<h2 className="text-3xl font-bold text-gray-900 mb-4">Start free, scale as you grow</h2>
|
||||||
|
<p className="text-gray-500 mb-8">Build unlimited chatbots for free. Upgrade to publish and unlock premium features.</p>
|
||||||
|
<div className="flex justify-center gap-4 mb-8">
|
||||||
|
{[
|
||||||
|
{ feature: 'Free forever plan', included: true },
|
||||||
|
{ feature: 'Unlimited chatbot creation', included: true },
|
||||||
|
{ feature: 'Publish to marketplace', included: false, note: 'Starter+' },
|
||||||
|
{ feature: 'Code export', included: false, note: 'Pro+' },
|
||||||
|
].map(({ feature, included, note }) => (
|
||||||
|
<div key={feature} className="flex items-center gap-2 text-sm">
|
||||||
|
<div className={`w-5 h-5 rounded-full flex items-center justify-center ${included ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-400'}`}>
|
||||||
|
{included ? <Check className="w-3 h-3" /> : <span className="text-xs">–</span>}
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-700">{feature}</span>
|
||||||
|
{note && <span className="text-xs text-primary-600 bg-primary-50 px-2 py-0.5 rounded-full">{note}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Link to="/pricing" className="text-primary-600 font-medium hover:underline text-sm">
|
||||||
|
View full pricing →
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<section className="bg-primary-600 py-16 text-center">
|
||||||
|
<div className="max-w-2xl mx-auto px-6">
|
||||||
|
<h2 className="text-3xl font-bold text-white mb-4">Ready to build your first chatbot?</h2>
|
||||||
|
<p className="text-primary-100 mb-8">Join thousands of companies using Contexta to power their AI experiences.</p>
|
||||||
|
<Link to="/signup" className="bg-white text-primary-600 px-8 py-3 rounded-xl font-semibold hover:bg-primary-50 transition-colors inline-flex items-center gap-2">
|
||||||
|
Get started for free <ArrowRight className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="border-t border-gray-100 py-8 text-center text-sm text-gray-400">
|
||||||
|
<p>© 2025 Contexta. Built with ❤️ for builders.</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
237
src/pages/MarketplacePage.tsx
Normal file
237
src/pages/MarketplacePage.tsx
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { marketplaceAPI } from '@/services/api'
|
||||||
|
import { Card, Spinner, EmptyState, Button } from '@/components/ui'
|
||||||
|
import { CATEGORIES, INDUSTRIES } from '@/lib/utils'
|
||||||
|
import { Search, Bot, Star, MessageSquare } from 'lucide-react'
|
||||||
|
import type { ChatbotPublic } from '@/types'
|
||||||
|
|
||||||
|
export const MarketplacePage: React.FC = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [category, setCategory] = useState('')
|
||||||
|
const [industry, setIndustry] = useState('')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['marketplace', search, category, industry, page],
|
||||||
|
queryFn: () => marketplaceAPI.list({ search, category, industry, page, limit: 20 }),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-6xl mx-auto">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">AI Chatbot Marketplace</h1>
|
||||||
|
<p className="text-gray-500 mt-1 text-sm">Discover and interact with AI chatbots from companies worldwide</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search & Filters */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={e => { setSearch(e.target.value); setPage(1) }}
|
||||||
|
placeholder="Search chatbots..."
|
||||||
|
className="w-full pl-9 pr-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={category}
|
||||||
|
onChange={e => { setCategory(e.target.value); setPage(1) }}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white"
|
||||||
|
>
|
||||||
|
<option value="">All Categories</option>
|
||||||
|
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={industry}
|
||||||
|
onChange={e => { setIndustry(e.target.value); setPage(1) }}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white"
|
||||||
|
>
|
||||||
|
<option value="">All Industries</option>
|
||||||
|
{INDUSTRIES.map(i => <option key={i} value={i}>{i}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-20"><Spinner className="text-primary-600" /></div>
|
||||||
|
) : !data?.chatbots?.length ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Bot className="w-8 h-8" />}
|
||||||
|
title="No chatbots found"
|
||||||
|
description="Be the first to publish your AI chatbot to the marketplace!"
|
||||||
|
action={<Button onClick={() => navigate('/chatbots/new')}>Create Chatbot</Button>}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mb-4 text-sm text-gray-500">{data.total} chatbot{data.total !== 1 ? 's' : ''} available</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
|
||||||
|
{data.chatbots.map(chatbot => (
|
||||||
|
<ChatbotMarketplaceCard
|
||||||
|
key={chatbot.id}
|
||||||
|
chatbot={chatbot}
|
||||||
|
onClick={() => navigate(`/marketplace/${chatbot.id}`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{data.total > 20 && (
|
||||||
|
<div className="flex justify-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="flex items-center px-3 text-sm text-gray-600">
|
||||||
|
Page {page} of {Math.ceil(data.total / 20)}
|
||||||
|
</span>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setPage(p => p + 1)} disabled={!data.has_more}>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChatbotMarketplaceCard: React.FC<{ chatbot: ChatbotPublic; onClick: () => void }> = ({ chatbot, onClick }) => (
|
||||||
|
<Card className="p-5 cursor-pointer hover:shadow-md transition-shadow" onClick={onClick}>
|
||||||
|
<div className="flex items-start gap-3 mb-3">
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-xl flex items-center justify-center text-white flex-shrink-0"
|
||||||
|
style={{ background: chatbot.primary_color }}
|
||||||
|
>
|
||||||
|
<Bot className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-gray-900 text-sm truncate">{chatbot.name}</h3>
|
||||||
|
{chatbot.company_name && (
|
||||||
|
<p className="text-xs text-gray-500 truncate">by {chatbot.company_name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{chatbot.description && (
|
||||||
|
<p className="text-xs text-gray-600 mb-3 line-clamp-2">{chatbot.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||||
|
{chatbot.category && (
|
||||||
|
<span className="text-xs bg-primary-50 text-primary-700 px-2 py-0.5 rounded-full">
|
||||||
|
{chatbot.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{chatbot.industry && (
|
||||||
|
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">
|
||||||
|
{chatbot.industry}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{chatbot.average_rating && (
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<Star className="w-3 h-3 fill-yellow-400 text-yellow-400" />
|
||||||
|
{chatbot.average_rating.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<MessageSquare className="w-3 h-3" />
|
||||||
|
{chatbot.total_conversations}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-primary-600 font-medium">Chat →</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Chatbot Detail / Chat Page ────────────────────────────────────────────────
|
||||||
|
export const ChatbotDetailPage: React.FC = () => {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const { data: chatbot, isLoading } = useQuery({
|
||||||
|
queryKey: ['marketplace-chatbot', id],
|
||||||
|
queryFn: () => marketplaceAPI.get(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) return <div className="flex justify-center py-20"><Spinner className="text-primary-600" /></div>
|
||||||
|
if (!chatbot) return <div className="text-center py-20 text-gray-500">Chatbot not found</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-4xl mx-auto">
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<button onClick={() => navigate('/marketplace')} className="p-1.5 hover:bg-gray-100 rounded-lg">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">{chatbot.name}</h1>
|
||||||
|
{chatbot.company_name && <p className="text-sm text-gray-500">by {chatbot.company_name}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Chat */}
|
||||||
|
<div className="lg:col-span-2 h-[600px]">
|
||||||
|
<ChatInterface
|
||||||
|
chatbotId={chatbot.id}
|
||||||
|
chatbotName={chatbot.name}
|
||||||
|
welcomeMessage={chatbot.welcome_message}
|
||||||
|
primaryColor={chatbot.primary_color}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info sidebar */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card className="p-4">
|
||||||
|
<h3 className="font-semibold text-gray-900 text-sm mb-3">About</h3>
|
||||||
|
{chatbot.description && <p className="text-sm text-gray-600 mb-3">{chatbot.description}</p>}
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
{chatbot.category && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Category</span>
|
||||||
|
<span className="font-medium">{chatbot.category}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{chatbot.industry && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Industry</span>
|
||||||
|
<span className="font-medium">{chatbot.industry}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Languages</span>
|
||||||
|
<span className="font-medium uppercase">{chatbot.languages?.join(', ')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Conversations</span>
|
||||||
|
<span className="font-medium">{chatbot.total_conversations}</span>
|
||||||
|
</div>
|
||||||
|
{chatbot.average_rating && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Rating</span>
|
||||||
|
<span className="font-medium flex items-center gap-1">
|
||||||
|
<Star className="w-3.5 h-3.5 fill-yellow-400 text-yellow-400" />
|
||||||
|
{chatbot.average_rating.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import needed for ChatbotDetailPage
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
import { ChatInterface } from '@/components/ChatInterface'
|
||||||
226
src/pages/PricingPage.tsx
Normal file
226
src/pages/PricingPage.tsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import { billingAPI } from '@/services/api'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
import { Button, Card } from '@/components/ui'
|
||||||
|
import { Check, Zap, Building2, Star } from 'lucide-react'
|
||||||
|
|
||||||
|
const PLANS = [
|
||||||
|
{
|
||||||
|
id: 'free',
|
||||||
|
name: 'Free',
|
||||||
|
price: 0,
|
||||||
|
description: 'Perfect for testing and development',
|
||||||
|
icon: '🆓',
|
||||||
|
color: 'gray',
|
||||||
|
features: [
|
||||||
|
{ text: 'Unlimited chatbot creation', included: true },
|
||||||
|
{ text: 'Upload PDF, DOCX, CSV, XLSX', included: true },
|
||||||
|
{ text: 'Unlimited preview testing', included: true },
|
||||||
|
{ text: 'Shareable preview links', included: true },
|
||||||
|
{ text: 'Publish to marketplace', included: false },
|
||||||
|
{ text: 'Premium AI models', included: false },
|
||||||
|
{ text: 'Code export', included: false },
|
||||||
|
{ text: 'Analytics dashboard', included: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'starter',
|
||||||
|
name: 'Starter',
|
||||||
|
price: 39,
|
||||||
|
description: 'For small businesses launching their first chatbot',
|
||||||
|
icon: '🚀',
|
||||||
|
color: 'blue',
|
||||||
|
badge: 'Popular',
|
||||||
|
features: [
|
||||||
|
{ text: 'Everything in Free', included: true },
|
||||||
|
{ text: 'Publish 1 chatbot to marketplace', included: true },
|
||||||
|
{ text: 'Fireworks AI models (Llama, Mixtral)', included: true },
|
||||||
|
{ text: '5,000 conversations/month', included: true },
|
||||||
|
{ text: 'Analytics dashboard', included: true },
|
||||||
|
{ text: 'Custom branding', included: true },
|
||||||
|
{ text: 'Email support', included: true },
|
||||||
|
{ text: 'Premium AI models (GPT-4, Claude)', included: false },
|
||||||
|
{ text: 'Code export', included: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pro',
|
||||||
|
name: 'Pro',
|
||||||
|
price: 119,
|
||||||
|
description: 'For growing businesses with multiple products',
|
||||||
|
icon: '⚡',
|
||||||
|
color: 'purple',
|
||||||
|
highlighted: true,
|
||||||
|
features: [
|
||||||
|
{ text: 'Everything in Starter', included: true },
|
||||||
|
{ text: 'Build & publish 3 chatbots', included: true },
|
||||||
|
{ text: 'GPT-4o, Claude 3.5, Gemini 1.5', included: true },
|
||||||
|
{ text: '20,000 conversations/month', included: true },
|
||||||
|
{ text: 'Code export (FastAPI + React widget)', included: true },
|
||||||
|
{ text: 'Advanced analytics', included: true },
|
||||||
|
{ text: 'Remove "Powered by" badge', included: true },
|
||||||
|
{ text: 'Priority support', included: true },
|
||||||
|
{ text: 'Custom domain', included: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'enterprise',
|
||||||
|
name: 'Enterprise',
|
||||||
|
price: null,
|
||||||
|
description: 'For large organizations with custom needs',
|
||||||
|
icon: '🏢',
|
||||||
|
color: 'orange',
|
||||||
|
features: [
|
||||||
|
{ text: 'Everything in Pro', included: true },
|
||||||
|
{ text: 'Unlimited chatbots', included: true },
|
||||||
|
{ text: 'Unlimited conversations', included: true },
|
||||||
|
{ text: 'Custom model fine-tuning', included: true },
|
||||||
|
{ text: 'White-label platform', included: true },
|
||||||
|
{ text: 'SSO (SAML)', included: true },
|
||||||
|
{ text: 'SLA guarantees', included: true },
|
||||||
|
{ text: 'Dedicated account manager', included: true },
|
||||||
|
{ text: '24/7 phone support', included: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const PricingPage: React.FC = () => {
|
||||||
|
const { user } = useAuthStore()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [loading, setLoading] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleSubscribe = async (planId: string) => {
|
||||||
|
if (!user) { navigate('/login'); return }
|
||||||
|
if (planId === 'enterprise') {
|
||||||
|
window.open('mailto:enterprise@contexta.ai?subject=Enterprise Inquiry', '_blank')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (planId === 'free') {
|
||||||
|
navigate('/dashboard')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(planId)
|
||||||
|
try {
|
||||||
|
const { checkout_url } = await billingAPI.createCheckout(
|
||||||
|
planId,
|
||||||
|
`${window.location.origin}/settings/billing?success=true`,
|
||||||
|
`${window.location.origin}/pricing`
|
||||||
|
)
|
||||||
|
window.location.href = checkout_url
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.response?.data?.detail || 'Failed to create checkout session')
|
||||||
|
} finally {
|
||||||
|
setLoading(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-6xl mx-auto">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-3">Simple, transparent pricing</h1>
|
||||||
|
<p className="text-gray-500 max-w-xl mx-auto">
|
||||||
|
Start free and build as many chatbots as you want. Upgrade when you're ready to publish and go live.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6">
|
||||||
|
{PLANS.map((plan) => (
|
||||||
|
<div
|
||||||
|
key={plan.id}
|
||||||
|
className={`relative rounded-2xl border p-6 flex flex-col ${
|
||||||
|
plan.highlighted
|
||||||
|
? 'border-primary-400 bg-gradient-to-b from-primary-50 to-white shadow-lg shadow-primary-100'
|
||||||
|
: 'border-gray-200 bg-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{plan.badge && (
|
||||||
|
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||||
|
<span className="bg-primary-600 text-white text-xs font-semibold px-3 py-1 rounded-full">
|
||||||
|
{plan.badge}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="text-3xl mb-2">{plan.icon}</div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">{plan.name}</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1 min-h-[40px]">{plan.description}</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
{plan.price !== null ? (
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className="text-3xl font-bold text-gray-900">${plan.price}</span>
|
||||||
|
<span className="text-gray-500 text-sm">/month</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-2xl font-bold text-gray-900">Custom</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-2.5 flex-1 mb-6">
|
||||||
|
{plan.features.map(({ text, included }) => (
|
||||||
|
<li key={text} className="flex items-start gap-2.5">
|
||||||
|
<div className={`w-4 h-4 rounded-full flex-shrink-0 mt-0.5 flex items-center justify-center ${
|
||||||
|
included ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-400'
|
||||||
|
}`}>
|
||||||
|
{included ? <Check className="w-2.5 h-2.5" /> : <span className="text-xs">–</span>}
|
||||||
|
</div>
|
||||||
|
<span className={`text-sm ${included ? 'text-gray-700' : 'text-gray-400'}`}>{text}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={plan.highlighted ? 'primary' : 'outline'}
|
||||||
|
className="w-full"
|
||||||
|
loading={loading === plan.id}
|
||||||
|
onClick={() => handleSubscribe(plan.id)}
|
||||||
|
disabled={user?.plan === plan.id}
|
||||||
|
>
|
||||||
|
{user?.plan === plan.id
|
||||||
|
? 'Current Plan'
|
||||||
|
: plan.price === null
|
||||||
|
? 'Contact Sales'
|
||||||
|
: plan.price === 0
|
||||||
|
? 'Get Started Free'
|
||||||
|
: `Subscribe – $${plan.price}/mo`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* FAQ */}
|
||||||
|
<div className="mt-16 max-w-2xl mx-auto">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 mb-6 text-center">Frequently Asked Questions</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
q: 'What is preview mode?',
|
||||||
|
a: 'Preview mode lets you build and test your chatbot for free with unlimited conversations. Only you (and people you share the link with) can access it until you publish.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: 'Can I cancel anytime?',
|
||||||
|
a: 'Yes, you can cancel anytime. Your chatbots will remain in preview mode but will be removed from the marketplace.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: 'What is code export?',
|
||||||
|
a: 'Pro plan users can export their chatbot as a complete, production-ready package including a FastAPI backend and React TypeScript widget — giving you full control to self-host.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: 'Do I need my own API keys?',
|
||||||
|
a: 'No! Your API keys are handled by Contexta. However, if you export the code, you\'ll need your own API keys for self-hosted deployment.'
|
||||||
|
},
|
||||||
|
].map(({ q, a }) => (
|
||||||
|
<div key={q} className="bg-white border border-gray-200 rounded-xl p-4">
|
||||||
|
<h3 className="font-semibold text-gray-900 text-sm mb-1">{q}</h3>
|
||||||
|
<p className="text-sm text-gray-500">{a}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
159
src/pages/SettingsPage.tsx
Normal file
159
src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useQuery, useMutation } from '@tanstack/react-query'
|
||||||
|
import { useNavigate, Link } from 'react-router-dom'
|
||||||
|
import { billingAPI } from '@/services/api'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
import { Button, Card, Input, Badge } from '@/components/ui'
|
||||||
|
import { getPlanColor, formatDate } from '@/lib/utils'
|
||||||
|
import { CreditCard, User, Shield, Download, ExternalLink } from 'lucide-react'
|
||||||
|
|
||||||
|
export const SettingsPage: React.FC = () => {
|
||||||
|
const { user, updateUser } = useAuthStore()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [tab, setTab] = useState<'profile' | 'billing' | 'export'>('profile')
|
||||||
|
const [toast, setToast] = useState('')
|
||||||
|
|
||||||
|
const showToast = (msg: string) => {
|
||||||
|
setToast(msg)
|
||||||
|
setTimeout(() => setToast(''), 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-3xl mx-auto">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-6">Settings</h1>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-1 mb-6 bg-gray-100 p-1 rounded-xl w-fit">
|
||||||
|
{[
|
||||||
|
{ id: 'profile', label: 'Profile', icon: User },
|
||||||
|
{ id: 'billing', label: 'Billing', icon: CreditCard },
|
||||||
|
].map(({ id, label, icon: Icon }) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => setTab(id as any)}
|
||||||
|
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
tab === id ? 'bg-white shadow-sm text-gray-900' : 'text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-3.5 h-3.5" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'profile' && <ProfileSettings onToast={showToast} />}
|
||||||
|
{tab === 'billing' && <BillingSettings onToast={showToast} />}
|
||||||
|
|
||||||
|
{toast && (
|
||||||
|
<div className="fixed bottom-4 right-4 bg-gray-900 text-white px-4 py-2 rounded-lg text-sm shadow-lg z-50">
|
||||||
|
{toast}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProfileSettings: React.FC<{ onToast: (msg: string) => void }> = ({ onToast }) => {
|
||||||
|
const { user } = useAuthStore()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="p-6 space-y-4">
|
||||||
|
<h2 className="font-semibold text-gray-900">Profile Information</h2>
|
||||||
|
<Input label="Email" value={user?.email || ''} disabled />
|
||||||
|
<Input label="Company Name" value={user?.company_name || ''} disabled hint="Contact support to change company name" />
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">Plan</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`px-3 py-1 text-sm font-medium rounded-full capitalize ${getPlanColor(user?.plan || 'free')}`}>
|
||||||
|
{user?.plan || 'free'}
|
||||||
|
</span>
|
||||||
|
<Link to="/pricing" className="text-sm text-primary-600 hover:underline">
|
||||||
|
Manage plan
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const BillingSettings: React.FC<{ onToast: (msg: string) => void }> = ({ onToast }) => {
|
||||||
|
const { user } = useAuthStore()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const { data: subscription } = useQuery({
|
||||||
|
queryKey: ['subscription'],
|
||||||
|
queryFn: billingAPI.getSubscription,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handlePortal = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const { url } = await billingAPI.createPortal(window.location.href)
|
||||||
|
window.location.href = url
|
||||||
|
} catch (err: any) {
|
||||||
|
onToast(err.response?.data?.detail || 'Failed to open billing portal')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPaid = subscription?.plan && subscription.plan !== 'free'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card className="p-6">
|
||||||
|
<h2 className="font-semibold text-gray-900 mb-4">Current Plan</h2>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<span className={`px-3 py-1 text-sm font-semibold rounded-full capitalize ${getPlanColor(subscription?.plan || 'free')}`}>
|
||||||
|
{subscription?.plan || 'free'}
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Status: <span className={subscription?.status === 'active' ? 'text-green-600' : 'text-red-600'}>
|
||||||
|
{subscription?.status || 'active'}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{isPaid && subscription?.current_period_end && (
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xs text-gray-500">Renews on</p>
|
||||||
|
<p className="text-sm font-medium">{formatDate(subscription.current_period_end)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{!isPaid ? (
|
||||||
|
<Button onClick={() => navigate('/pricing')} className="flex-1">
|
||||||
|
Upgrade Plan
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" onClick={handlePortal} loading={loading} className="flex-1">
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
|
Manage Billing
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Plan features */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-4">Plan Features</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[
|
||||||
|
{ label: 'Chatbots created', value: 'Unlimited', always: true },
|
||||||
|
{ label: 'Chatbots published', value: subscription?.plan === 'pro' ? '3' : subscription?.plan === 'starter' ? '1' : '0 (upgrade to publish)', always: true },
|
||||||
|
{ label: 'Conversations/month', value: subscription?.plan === 'pro' ? '20,000' : subscription?.plan === 'starter' ? '5,000' : 'Unlimited (preview)', always: true },
|
||||||
|
{ label: 'Code export', value: subscription?.plan === 'pro' || subscription?.plan === 'enterprise' ? '✅ Included' : '❌ Pro+ only', always: true },
|
||||||
|
].map(({ label, value }) => (
|
||||||
|
<div key={label} className="flex justify-between py-2 border-b border-gray-100 last:border-0">
|
||||||
|
<span className="text-sm text-gray-600">{label}</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900">{value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
137
src/services/api.ts
Normal file
137
src/services/api.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import type {
|
||||||
|
AuthResponse, User, Chatbot, ChatbotFormData, Document,
|
||||||
|
ChatResponse, Subscription, MarketplaceResponse, Analytics, ChatbotPublic
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||||
|
|
||||||
|
export const api = axios.create({
|
||||||
|
baseURL: `${API_URL}/api/v1`,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Request interceptor - attach token
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('access_token')
|
||||||
|
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
// Response interceptor - handle 401
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(res) => res,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem('access_token')
|
||||||
|
localStorage.removeItem('user')
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Auth ──────────────────────────────────────────────────────────────────────
|
||||||
|
export const authAPI = {
|
||||||
|
signup: (data: { email: string; password: string; company_name: string }) =>
|
||||||
|
api.post<AuthResponse>('/auth/signup', data).then(r => r.data),
|
||||||
|
|
||||||
|
login: (data: { email: string; password: string }) =>
|
||||||
|
api.post<AuthResponse>('/auth/login', data).then(r => r.data),
|
||||||
|
|
||||||
|
logout: () => api.post('/auth/logout').then(r => r.data),
|
||||||
|
|
||||||
|
me: () => api.get<User>('/auth/me').then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Chatbots ─────────────────────────────────────────────────────────────────
|
||||||
|
export const chatbotsAPI = {
|
||||||
|
list: () => api.get<Chatbot[]>('/chatbots').then(r => r.data),
|
||||||
|
|
||||||
|
get: (id: string) => api.get<Chatbot>(`/chatbots/${id}`).then(r => r.data),
|
||||||
|
|
||||||
|
create: (data: ChatbotFormData) =>
|
||||||
|
api.post<Chatbot>('/chatbots', data).then(r => r.data),
|
||||||
|
|
||||||
|
update: (id: string, data: Partial<ChatbotFormData>) =>
|
||||||
|
api.put<Chatbot>(`/chatbots/${id}`, data).then(r => r.data),
|
||||||
|
|
||||||
|
delete: (id: string) =>
|
||||||
|
api.delete(`/chatbots/${id}`).then(r => r.data),
|
||||||
|
|
||||||
|
publish: (id: string) =>
|
||||||
|
api.post<Chatbot>(`/chatbots/${id}/publish`).then(r => r.data),
|
||||||
|
|
||||||
|
unpublish: (id: string) =>
|
||||||
|
api.post<Chatbot>(`/chatbots/${id}/unpublish`).then(r => r.data),
|
||||||
|
|
||||||
|
export: (id: string) =>
|
||||||
|
api.post(`/chatbots/${id}/export`, {}, { responseType: 'blob' }).then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Documents ────────────────────────────────────────────────────────────────
|
||||||
|
export const documentsAPI = {
|
||||||
|
list: (chatbotId: string) =>
|
||||||
|
api.get<Document[]>(`/chatbots/${chatbotId}/documents`).then(r => r.data),
|
||||||
|
|
||||||
|
upload: (chatbotId: string, file: File, onProgress?: (p: number) => void) => {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
return api.post<Document>(
|
||||||
|
`/chatbots/${chatbotId}/documents`,
|
||||||
|
form,
|
||||||
|
{
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
onUploadProgress: (e) => {
|
||||||
|
if (onProgress && e.total) onProgress(Math.round((e.loaded / e.total) * 100))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
).then(r => r.data)
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: (chatbotId: string, docId: string) =>
|
||||||
|
api.delete(`/chatbots/${chatbotId}/documents/${docId}`).then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Chat ─────────────────────────────────────────────────────────────────────
|
||||||
|
export const chatAPI = {
|
||||||
|
send: (chatbotId: string, data: { message: string; session_id?: string; language?: string }) =>
|
||||||
|
api.post<ChatResponse>(`/chat/${chatbotId}`, data).then(r => r.data),
|
||||||
|
|
||||||
|
getHistory: (chatbotId: string, sessionId: string) =>
|
||||||
|
api.get(`/chat/${chatbotId}/history/${sessionId}`).then(r => r.data),
|
||||||
|
|
||||||
|
getAnalytics: (chatbotId: string) =>
|
||||||
|
api.get<Analytics>(`/analytics/${chatbotId}`).then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Marketplace ──────────────────────────────────────────────────────────────
|
||||||
|
export const marketplaceAPI = {
|
||||||
|
list: (params?: {
|
||||||
|
category?: string; industry?: string; language?: string;
|
||||||
|
search?: string; page?: number; limit?: number
|
||||||
|
}) => api.get<MarketplaceResponse>('/marketplace/chatbots', { params }).then(r => r.data),
|
||||||
|
|
||||||
|
get: (id: string) =>
|
||||||
|
api.get<ChatbotPublic>(`/marketplace/chatbots/${id}`).then(r => r.data),
|
||||||
|
|
||||||
|
categories: () =>
|
||||||
|
api.get<{ categories: string[]; industries: string[] }>('/marketplace/categories').then(r => r.data),
|
||||||
|
|
||||||
|
rate: (id: string, rating: number, feedback?: string) =>
|
||||||
|
api.post(`/marketplace/chatbots/${id}/rate`, { rating, feedback }).then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Billing ──────────────────────────────────────────────────────────────────
|
||||||
|
export const billingAPI = {
|
||||||
|
getSubscription: () =>
|
||||||
|
api.get<Subscription>('/billing/subscription').then(r => r.data),
|
||||||
|
|
||||||
|
createCheckout: (plan: string, success_url: string, cancel_url: string) =>
|
||||||
|
api.post<{ checkout_url: string; session_id: string }>(
|
||||||
|
'/billing/checkout', { plan, success_url, cancel_url }
|
||||||
|
).then(r => r.data),
|
||||||
|
|
||||||
|
createPortal: (return_url: string) =>
|
||||||
|
api.post<{ url: string }>('/billing/portal', { return_url }).then(r => r.data),
|
||||||
|
}
|
||||||
46
src/store/authStore.ts
Normal file
46
src/store/authStore.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { persist } from 'zustand/middleware'
|
||||||
|
import type { User } from '@/types'
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: User | null
|
||||||
|
token: string | null
|
||||||
|
isAuthenticated: boolean
|
||||||
|
setAuth: (user: User, token: string) => void
|
||||||
|
logout: () => void
|
||||||
|
updateUser: (user: Partial<User>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
|
||||||
|
setAuth: (user, token) => {
|
||||||
|
localStorage.setItem('access_token', token)
|
||||||
|
set({ user, token, isAuthenticated: true })
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: () => {
|
||||||
|
localStorage.removeItem('access_token')
|
||||||
|
localStorage.removeItem('user')
|
||||||
|
set({ user: null, token: null, isAuthenticated: false })
|
||||||
|
},
|
||||||
|
|
||||||
|
updateUser: (updates) =>
|
||||||
|
set((state) => ({
|
||||||
|
user: state.user ? { ...state.user, ...updates } : null,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'contexta-auth',
|
||||||
|
partialize: (state) => ({
|
||||||
|
user: state.user,
|
||||||
|
token: state.token,
|
||||||
|
isAuthenticated: state.isAuthenticated,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
156
src/types/index.ts
Normal file
156
src/types/index.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
// ─── Auth ──────────────────────────────────────────────────────────────────────
|
||||||
|
export interface User {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
company_name?: string
|
||||||
|
plan: 'free' | 'starter' | 'pro' | 'enterprise'
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
access_token: string
|
||||||
|
token_type: string
|
||||||
|
user: User
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Chatbot ───────────────────────────────────────────────────────────────────
|
||||||
|
export interface Chatbot {
|
||||||
|
id: string
|
||||||
|
company_id: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
system_prompt?: string
|
||||||
|
model: string
|
||||||
|
temperature: number
|
||||||
|
max_tokens: number
|
||||||
|
primary_color: string
|
||||||
|
welcome_message: string
|
||||||
|
category?: string
|
||||||
|
industry?: string
|
||||||
|
languages: string[]
|
||||||
|
visibility: 'preview' | 'published'
|
||||||
|
is_published: boolean
|
||||||
|
qdrant_collection_name?: string
|
||||||
|
document_count: number
|
||||||
|
conversation_count: number
|
||||||
|
average_rating?: number
|
||||||
|
created_at?: string
|
||||||
|
published_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatbotPublic {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
category?: string
|
||||||
|
industry?: string
|
||||||
|
languages: string[]
|
||||||
|
primary_color: string
|
||||||
|
welcome_message: string
|
||||||
|
average_rating?: number
|
||||||
|
total_conversations: number
|
||||||
|
company_name?: string
|
||||||
|
company_logo?: string
|
||||||
|
created_at?: string
|
||||||
|
published_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatbotFormData {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
system_prompt: string
|
||||||
|
model: string
|
||||||
|
temperature: number
|
||||||
|
max_tokens: number
|
||||||
|
primary_color: string
|
||||||
|
welcome_message: string
|
||||||
|
category: string
|
||||||
|
industry: string
|
||||||
|
languages: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Document ─────────────────────────────────────────────────────────────────
|
||||||
|
export interface Document {
|
||||||
|
id: string
|
||||||
|
chatbot_id: string
|
||||||
|
file_name: string
|
||||||
|
file_type: string
|
||||||
|
file_size: number
|
||||||
|
chunk_count: number
|
||||||
|
status: 'pending' | 'processing' | 'completed' | 'failed'
|
||||||
|
error_message?: string
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Chat ─────────────────────────────────────────────────────────────────────
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: string
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
sources?: SourceDocument[]
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SourceDocument {
|
||||||
|
document_name: string
|
||||||
|
chunk_text: string
|
||||||
|
score: number
|
||||||
|
page_number?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatResponse {
|
||||||
|
response: string
|
||||||
|
session_id: string
|
||||||
|
sources: SourceDocument[]
|
||||||
|
model_used: string
|
||||||
|
tokens_used: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Subscription ─────────────────────────────────────────────────────────────
|
||||||
|
export interface Subscription {
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
plan: string
|
||||||
|
status: string
|
||||||
|
stripe_customer_id?: string
|
||||||
|
current_period_start?: string
|
||||||
|
current_period_end?: string
|
||||||
|
chatbots_published: number
|
||||||
|
conversations_used: number
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Marketplace ──────────────────────────────────────────────────────────────
|
||||||
|
export interface MarketplaceResponse {
|
||||||
|
chatbots: ChatbotPublic[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
limit: number
|
||||||
|
has_more: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Plan ─────────────────────────────────────────────────────────────────────
|
||||||
|
export interface PlanFeature {
|
||||||
|
text: string
|
||||||
|
included: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Plan {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
price: number
|
||||||
|
description: string
|
||||||
|
features: PlanFeature[]
|
||||||
|
highlighted?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Analytics ────────────────────────────────────────────────────────────────
|
||||||
|
export interface Analytics {
|
||||||
|
chatbot_id: string
|
||||||
|
total_conversations: number
|
||||||
|
unique_users: number
|
||||||
|
average_conversation_length: number
|
||||||
|
total_messages: number
|
||||||
|
average_rating: number
|
||||||
|
conversations_last_30_days: number
|
||||||
|
}
|
||||||
48
tailwind.config.js
Normal file
48
tailwind.config.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
400: '#33f3ff',
|
||||||
|
500: '#00f0ff',
|
||||||
|
600: '#00b8cc',
|
||||||
|
700: '#009fb3',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
500: '#ff0080',
|
||||||
|
600: '#cc0066',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'float': 'float 3s ease-in-out infinite',
|
||||||
|
'float-slow': 'floatSlow 6s ease-in-out infinite',
|
||||||
|
'glow': 'glow 2s ease-in-out infinite',
|
||||||
|
'pulse-slow': 'pulse 3s ease-in-out infinite',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
float: {
|
||||||
|
'0%, 100%': { transform: 'translateY(0px)' },
|
||||||
|
'50%': { transform: 'translateY(-15px)' },
|
||||||
|
},
|
||||||
|
floatSlow: {
|
||||||
|
'0%, 100%': { transform: 'translateY(0px) rotate(0deg)' },
|
||||||
|
'50%': { transform: 'translateY(-10px) rotate(5deg)' },
|
||||||
|
},
|
||||||
|
glow: {
|
||||||
|
'0%, 100%': {
|
||||||
|
boxShadow: '0 0 20px rgba(0, 240, 255, 0.5), 0 0 40px rgba(0, 240, 255, 0.3)',
|
||||||
|
},
|
||||||
|
'50%': {
|
||||||
|
boxShadow: '0 0 30px rgba(0, 240, 255, 0.8), 0 0 60px rgba(0, 240, 255, 0.5)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
28
tsconfig.app.json
Normal file
28
tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
tsconfig.node.json
Normal file
26
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"]
|
||||||
|
}
|
||||||
1
vite-env.d.ts
vendored
Normal file
1
vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
18
vite.config.ts
Normal file
18
vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
allowedHosts: ['gripple-delena-triserial.ngrok-free.dev', '127.0.0.1', 'localhost'],
|
||||||
|
host: true
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user