Spaces:
Running
Running
Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +23 -0
- .gitignore +16 -1
- Dockerfile +23 -19
- Frontend/eslint.config.js +21 -0
- Frontend/jsconfig.json +1 -0
- Frontend/package-lock.json +0 -0
- Frontend/package.json +18 -4
- Frontend/src/App.jsx +122 -152
- Frontend/src/admin/components/AdminSidebar.jsx +3 -1
- Frontend/src/admin/components/TicketTable.jsx +13 -3
- Frontend/src/admin/pages/AdminDashboard.jsx +143 -19
- Frontend/src/admin/pages/AdminSettings.jsx +127 -3
- Frontend/src/admin/pages/AdminTicketDetail.jsx +32 -2
- Frontend/src/admin/pages/AdminTickets.jsx +38 -37
- Frontend/src/components/shared/BugReportWidget.jsx +3 -15
- Frontend/src/components/shared/ProtectedRoute.jsx +3 -26
- Frontend/src/components/shared/TicketChat.jsx +341 -24
- Frontend/src/components/shared/WebhookSettings.jsx +227 -0
- Frontend/src/config.js +3 -6
- Frontend/src/index.css +48 -8
- Frontend/src/legacy_ui/Dashboard.jsx +2 -2
- Frontend/src/pages/AboutUs.jsx +382 -60
- Frontend/src/pages/AdminSignup.jsx +23 -32
- Frontend/src/pages/Changelog.jsx +292 -111
- Frontend/src/pages/LandingPage.jsx +199 -112
- Frontend/src/pages/Login.jsx +68 -25
- Frontend/src/pages/Signup.jsx +18 -30
- Frontend/src/services/aiAssistant.js +110 -81
- Frontend/src/services/api.js +67 -22
- Frontend/src/store/authStore.js +152 -48
- Frontend/src/store/ticketStore.js +60 -14
- Frontend/src/user/components/QuickActions.jsx +6 -3
- Frontend/src/user/components/RecentTickets.jsx +5 -0
- Frontend/src/user/components/TopNav.jsx +46 -42
- Frontend/src/user/pages/AIProcessing.jsx +14 -8
- Frontend/src/user/pages/AutoResolveChat.jsx +56 -2
- Frontend/src/user/pages/CreateTicket.jsx +236 -87
- Frontend/src/user/pages/Dashboard.jsx +2 -1
- Frontend/src/user/pages/DuplicateDetection.jsx +47 -30
- Frontend/src/user/pages/MyTickets.jsx +13 -0
- Frontend/src/user/pages/TicketDetail.jsx +27 -1
- Frontend/src/user/pages/TicketTracking.jsx +15 -6
- Frontend/vite.config.js +1 -1
- MobileApp/App.js +207 -35
- MobileApp/app.json +5 -3
- MobileApp/assets/adaptive-icon.png +2 -2
- MobileApp/assets/icon.png +2 -2
- MobileApp/assets/logo_v1.png +2 -2
- MobileApp/assets/splash-icon.png +2 -2
- MobileApp/package-lock.json +27 -18
.gitattributes
CHANGED
|
@@ -2,6 +2,29 @@
|
|
| 2 |
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 3 |
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
*.pkl filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
Frontend/public/favicon.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 6 |
Frontend/public/favicon.png filter=lfs diff=lfs merge=lfs -text
|
| 7 |
Frontend/public/team/pragati_tiwari.jpg filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 2 |
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 3 |
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
.tmp-ci-venv/Lib/site-packages/dateutil/zoneinfo/dateutil-zoneinfo.tar.gz filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
.tmp-ci-venv/Lib/site-packages/pip/_vendor/distlib/t64-arm.exe filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
.tmp-ci-venv/Lib/site-packages/pip/_vendor/distlib/t64.exe filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
.tmp-ci-venv/Lib/site-packages/pip/_vendor/distlib/w64-arm.exe filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
.tmp-ci-venv/Lib/site-packages/pip/_vendor/distlib/w64.exe filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
.tmp-ci-venv/Lib/site-packages/setuptools/cli-arm64.exe filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
.tmp-ci-venv/Lib/site-packages/setuptools/gui-arm64.exe filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
.tmp-ci-venv/Scripts/dotenv.exe filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
.tmp-ci-venv/Scripts/fastapi.exe filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
.tmp-ci-venv/Scripts/httpx.exe filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
.tmp-ci-venv/Scripts/idna.exe filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
.tmp-ci-venv/Scripts/markdown-it.exe filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
.tmp-ci-venv/Scripts/normalizer.exe filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
.tmp-ci-venv/Scripts/pip.exe filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
.tmp-ci-venv/Scripts/pip3.11.exe filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
.tmp-ci-venv/Scripts/pip3.exe filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
.tmp-ci-venv/Scripts/pygmentize.exe filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
.tmp-ci-venv/Scripts/pyiceberg.exe filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
.tmp-ci-venv/Scripts/python.exe filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
.tmp-ci-venv/Scripts/pythonw.exe filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
.tmp-ci-venv/Scripts/uvicorn.exe filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
.tmp-ci-venv/Scripts/watchfiles.exe filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
.tmp-ci-venv/Scripts/websockets.exe filter=lfs diff=lfs merge=lfs -text
|
| 28 |
Frontend/public/favicon.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 29 |
Frontend/public/favicon.png filter=lfs diff=lfs merge=lfs -text
|
| 30 |
Frontend/public/team/pragati_tiwari.jpg filter=lfs diff=lfs merge=lfs -text
|
.gitignore
CHANGED
|
@@ -4,7 +4,10 @@ __pycache__/
|
|
| 4 |
*$py.class
|
| 5 |
venv/
|
| 6 |
env/
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
# Models (Keep them if they are small, but here they are large.
|
| 10 |
# We should probably only keep the ones we actually use if possible,
|
|
@@ -29,3 +32,15 @@ dist/
|
|
| 29 |
docs/helpdesk*.pdf
|
| 30 |
docs/logs/
|
| 31 |
*.log
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
*$py.class
|
| 5 |
venv/
|
| 6 |
env/
|
| 7 |
+
# Ignore all env files anywhere in the repo
|
| 8 |
+
**/.env
|
| 9 |
+
**/.env.*
|
| 10 |
+
|
| 11 |
|
| 12 |
# Models (Keep them if they are small, but here they are large.
|
| 13 |
# We should probably only keep the ones we actually use if possible,
|
|
|
|
| 32 |
docs/helpdesk*.pdf
|
| 33 |
docs/logs/
|
| 34 |
*.log
|
| 35 |
+
|
| 36 |
+
# Scratch and local cache
|
| 37 |
+
scratch/
|
| 38 |
+
.npm-cache/
|
| 39 |
+
|
| 40 |
+
# GSSoC Score Tracking and Temporary Scripts
|
| 41 |
+
scratch/
|
| 42 |
+
.npm-cache/
|
| 43 |
+
*.tmp
|
| 44 |
+
../MOBILE_SETUP_TEMP.md
|
| 45 |
+
../CI_YML_TEMP.yml
|
| 46 |
+
../ENV_EXAMPLE_TEMP.example
|
Dockerfile
CHANGED
|
@@ -1,36 +1,40 @@
|
|
| 1 |
-
#
|
| 2 |
-
FROM python:3.10-slim
|
| 3 |
|
| 4 |
-
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
# Install system dependencies required for EasyOCR and OpenCV
|
| 10 |
-
RUN apt-get update && apt-get install -y \
|
| 11 |
-
libgl1 \
|
| 12 |
-
libglib2.0-0 \
|
| 13 |
git \
|
| 14 |
&& rm -rf /var/lib/apt/lists/*
|
| 15 |
|
| 16 |
-
# Copy the requirements file into the container
|
| 17 |
COPY requirements.txt .
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
COPY . /app/backend
|
| 25 |
|
| 26 |
-
|
| 27 |
-
|
|
|
|
| 28 |
|
| 29 |
-
# Expose port 7860 (Hugging Face Spaces default)
|
| 30 |
EXPOSE 7860
|
| 31 |
|
| 32 |
HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \
|
| 33 |
CMD ["python", "backend/healthcheck.py"]
|
| 34 |
|
| 35 |
-
|
|
|
|
| 36 |
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
|
|
|
| 1 |
+
# syntax=docker/dockerfile:1
|
|
|
|
| 2 |
|
| 3 |
+
FROM python:3.10-slim AS builder
|
| 4 |
|
| 5 |
+
WORKDIR /build
|
| 6 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
git \
|
| 8 |
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
|
|
|
|
| 10 |
COPY requirements.txt .
|
| 11 |
+
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
|
| 12 |
+
|
| 13 |
+
FROM python:3.10-slim AS runtime
|
| 14 |
|
| 15 |
+
LABEL org.opencontainers.image.title="helpdesk-backend"
|
| 16 |
+
LABEL org.opencontainers.image.description="HELPDESK.AI FastAPI backend (multi-stage)"
|
| 17 |
|
| 18 |
+
WORKDIR /app
|
| 19 |
+
|
| 20 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 21 |
+
libgl1 \
|
| 22 |
+
libglib2.0-0 \
|
| 23 |
+
&& rm -rf /var/lib/apt/lists/* \
|
| 24 |
+
&& useradd --create-home --shell /usr/sbin/nologin appuser
|
| 25 |
+
|
| 26 |
+
COPY --from=builder /install /usr/local
|
| 27 |
COPY . /app/backend
|
| 28 |
|
| 29 |
+
ENV PYTHONPATH=/app \
|
| 30 |
+
PYTHONDONTWRITEBYTECODE=1 \
|
| 31 |
+
PYTHONUNBUFFERED=1
|
| 32 |
|
|
|
|
| 33 |
EXPOSE 7860
|
| 34 |
|
| 35 |
HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \
|
| 36 |
CMD ["python", "backend/healthcheck.py"]
|
| 37 |
|
| 38 |
+
USER appuser
|
| 39 |
+
|
| 40 |
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
Frontend/eslint.config.js
CHANGED
|
@@ -35,4 +35,25 @@ export default defineConfig([
|
|
| 35 |
'react-refresh/only-export-components': 'off',
|
| 36 |
},
|
| 37 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
])
|
|
|
|
| 35 |
'react-refresh/only-export-components': 'off',
|
| 36 |
},
|
| 37 |
},
|
| 38 |
+
{
|
| 39 |
+
files: [
|
| 40 |
+
'**/*.{test,spec}.{js,jsx}',
|
| 41 |
+
'**/__tests__/**/*.{js,jsx}',
|
| 42 |
+
'jest.setup.js',
|
| 43 |
+
'jest.fileMock.js',
|
| 44 |
+
],
|
| 45 |
+
languageOptions: {
|
| 46 |
+
globals: {
|
| 47 |
+
...globals.browser,
|
| 48 |
+
...globals.node,
|
| 49 |
+
...globals.jest,
|
| 50 |
+
},
|
| 51 |
+
},
|
| 52 |
+
},
|
| 53 |
+
{
|
| 54 |
+
files: ['jest.fileMock.js'],
|
| 55 |
+
languageOptions: {
|
| 56 |
+
sourceType: 'commonjs',
|
| 57 |
+
},
|
| 58 |
+
},
|
| 59 |
])
|
Frontend/jsconfig.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
{
|
| 2 |
"compilerOptions": {
|
| 3 |
"baseUrl": ".",
|
|
|
|
| 4 |
"paths": {
|
| 5 |
"@/*": [
|
| 6 |
"./src/*"
|
|
|
|
| 1 |
{
|
| 2 |
"compilerOptions": {
|
| 3 |
"baseUrl": ".",
|
| 4 |
+
"ignoreDeprecations": "6.0",
|
| 5 |
"paths": {
|
| 6 |
"@/*": [
|
| 7 |
"./src/*"
|
Frontend/package-lock.json
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
Frontend/package.json
CHANGED
|
@@ -6,10 +6,12 @@
|
|
| 6 |
"scripts": {
|
| 7 |
"dev": "vite",
|
| 8 |
"build": "vite build",
|
| 9 |
-
"lint": "eslint . --
|
|
|
|
| 10 |
"preview": "vite preview",
|
| 11 |
"prepare": "husky install",
|
| 12 |
-
"format": "prettier --write ."
|
|
|
|
| 13 |
},
|
| 14 |
"dependencies": {
|
| 15 |
"@google/generative-ai": "^0.24.1",
|
|
@@ -23,7 +25,7 @@
|
|
| 23 |
"class-variance-authority": "^0.7.1",
|
| 24 |
"clsx": "^2.1.1",
|
| 25 |
"date-fns": "^4.1.0",
|
| 26 |
-
"framer-motion": "^12.
|
| 27 |
"html2canvas": "^1.4.1",
|
| 28 |
"lucide-react": "^0.574.0",
|
| 29 |
"react": "^19.2.0",
|
|
@@ -42,11 +44,18 @@
|
|
| 42 |
"zustand": "^5.0.11"
|
| 43 |
},
|
| 44 |
"devDependencies": {
|
|
|
|
|
|
|
|
|
|
| 45 |
"@eslint/js": "^9.39.1",
|
|
|
|
|
|
|
|
|
|
| 46 |
"@types/react": "^19.2.7",
|
| 47 |
"@types/react-dom": "^19.2.3",
|
| 48 |
"@vitejs/plugin-react": "^5.1.1",
|
| 49 |
"autoprefixer": "^10.4.24",
|
|
|
|
| 50 |
"eslint": "^9.39.2",
|
| 51 |
"eslint-config-prettier": "^10.1.8",
|
| 52 |
"eslint-plugin-react": "^7.37.5",
|
|
@@ -54,11 +63,16 @@
|
|
| 54 |
"eslint-plugin-react-refresh": "^0.4.24",
|
| 55 |
"globals": "^16.5.0",
|
| 56 |
"husky": "^9.1.7",
|
|
|
|
|
|
|
|
|
|
| 57 |
"lint-staged": "^16.2.7",
|
| 58 |
"postcss": "^8.5.6",
|
| 59 |
"prettier": "^3.8.1",
|
| 60 |
"tailwindcss": "^3.4.19",
|
| 61 |
"vite": "^7.3.1"
|
| 62 |
},
|
| 63 |
-
"lint-staged":
|
|
|
|
|
|
|
| 64 |
}
|
|
|
|
| 6 |
"scripts": {
|
| 7 |
"dev": "vite",
|
| 8 |
"build": "vite build",
|
| 9 |
+
"lint": "eslint . --report-unused-disable-directives --max-warnings 0",
|
| 10 |
+
"check:ai-secrets": "node ../scripts/check-frontend-secrets.mjs",
|
| 11 |
"preview": "vite preview",
|
| 12 |
"prepare": "husky install",
|
| 13 |
+
"format": "prettier --write .",
|
| 14 |
+
"test": "jest"
|
| 15 |
},
|
| 16 |
"dependencies": {
|
| 17 |
"@google/generative-ai": "^0.24.1",
|
|
|
|
| 25 |
"class-variance-authority": "^0.7.1",
|
| 26 |
"clsx": "^2.1.1",
|
| 27 |
"date-fns": "^4.1.0",
|
| 28 |
+
"framer-motion": "^12.39.0",
|
| 29 |
"html2canvas": "^1.4.1",
|
| 30 |
"lucide-react": "^0.574.0",
|
| 31 |
"react": "^19.2.0",
|
|
|
|
| 44 |
"zustand": "^5.0.11"
|
| 45 |
},
|
| 46 |
"devDependencies": {
|
| 47 |
+
"@babel/core": "^7.24.0",
|
| 48 |
+
"@babel/preset-env": "^7.24.0",
|
| 49 |
+
"@babel/preset-react": "^7.24.0",
|
| 50 |
"@eslint/js": "^9.39.1",
|
| 51 |
+
"@testing-library/dom": "^10.4.1",
|
| 52 |
+
"@testing-library/jest-dom": "^6.6.3",
|
| 53 |
+
"@testing-library/react": "^16.2.0",
|
| 54 |
"@types/react": "^19.2.7",
|
| 55 |
"@types/react-dom": "^19.2.3",
|
| 56 |
"@vitejs/plugin-react": "^5.1.1",
|
| 57 |
"autoprefixer": "^10.4.24",
|
| 58 |
+
"babel-jest": "^29.7.0",
|
| 59 |
"eslint": "^9.39.2",
|
| 60 |
"eslint-config-prettier": "^10.1.8",
|
| 61 |
"eslint-plugin-react": "^7.37.5",
|
|
|
|
| 63 |
"eslint-plugin-react-refresh": "^0.4.24",
|
| 64 |
"globals": "^16.5.0",
|
| 65 |
"husky": "^9.1.7",
|
| 66 |
+
"identity-obj-proxy": "^3.0.0",
|
| 67 |
+
"jest": "^29.7.0",
|
| 68 |
+
"jest-environment-jsdom": "^29.7.0",
|
| 69 |
"lint-staged": "^16.2.7",
|
| 70 |
"postcss": "^8.5.6",
|
| 71 |
"prettier": "^3.8.1",
|
| 72 |
"tailwindcss": "^3.4.19",
|
| 73 |
"vite": "^7.3.1"
|
| 74 |
},
|
| 75 |
+
"lint-staged": {
|
| 76 |
+
"*.{js,jsx}": ["eslint --fix", "prettier --write"]
|
| 77 |
+
}
|
| 78 |
}
|
Frontend/src/App.jsx
CHANGED
|
@@ -5,88 +5,83 @@ import {
|
|
| 5 |
Navigate,
|
| 6 |
useLocation
|
| 7 |
} from "react-router-dom";
|
| 8 |
-
import React, { useEffect } from "react";
|
| 9 |
-
import { AnimatePresence } from "framer-motion";
|
| 10 |
-
import { NotFound } from "./components/ui/not-found-2";
|
| 11 |
import useTicketStore from "./store/ticketStore";
|
| 12 |
-
import Toaster from "./components/shared/Toaster";
|
| 13 |
-
import BugReportWidget from "./components/shared/BugReportWidget";
|
| 14 |
import useRealtimeNotifications from "./hooks/useRealtimeNotifications";
|
| 15 |
-
|
| 16 |
-
// Auth Components
|
| 17 |
-
import Login from "./pages/Login";
|
| 18 |
-
import ForgotPassword from "./pages/ForgotPassword";
|
| 19 |
-
import ResetPassword from "./pages/ResetPassword";
|
| 20 |
-
import Signup from "./pages/Signup";
|
| 21 |
-
import AdminSignup from "./pages/AdminSignup";
|
| 22 |
-
import AdminLobby from "./pages/AdminLobby";
|
| 23 |
-
import UserLobby from "./pages/UserLobby";
|
| 24 |
-
import LandingPage from "./pages/LandingPage";
|
| 25 |
-
import ContactSales from "./pages/ContactSales";
|
| 26 |
-
|
| 27 |
-
// Legacy components
|
| 28 |
-
import DuplicateDetection from "./user/pages/DuplicateDetection";
|
| 29 |
-
import AutoResolveChat from "./user/pages/AutoResolveChat";
|
| 30 |
-
import Resolved from "./user/pages/Resolved";
|
| 31 |
-
import TicketTracking from "./user/pages/TicketTracking";
|
| 32 |
-
// Layouts
|
| 33 |
-
import UserLayout from "./user/UserLayout";
|
| 34 |
-
import AdminLayout from "./admin/layout/AdminLayout";
|
| 35 |
-
|
| 36 |
-
// User Pages
|
| 37 |
-
import Dashboard from "./user/pages/Dashboard";
|
| 38 |
-
import CreateTicket from "./user/pages/CreateTicket";
|
| 39 |
-
import MyTickets from "./user/pages/MyTickets";
|
| 40 |
-
import TicketResult from "./user/pages/TicketResult";
|
| 41 |
-
import Profile from "./user/pages/Profile";
|
| 42 |
-
import TicketDetail from "./user/pages/TicketDetail";
|
| 43 |
-
import TicketProcessing from "./user/pages/AIProcessing"; // Renamed generic import just in case, but keeping AIProcessing
|
| 44 |
-
import AIProcessing from "./user/pages/AIProcessing";
|
| 45 |
-
import AIUnderstanding from "./user/pages/AIUnderstanding";
|
| 46 |
-
import Notifications from "./user/pages/Notifications";
|
| 47 |
-
import Help from "./user/pages/Help";
|
| 48 |
-
import DocsPortal from "./docs/pages/DocsPortal";
|
| 49 |
-
|
| 50 |
-
// New Showcase Pages
|
| 51 |
-
import ApiReference from "./pages/ApiReference";
|
| 52 |
-
import Changelog from "./pages/Changelog";
|
| 53 |
-
import StatusPage from "./pages/StatusPage";
|
| 54 |
-
import AboutUs from "./pages/AboutUs";
|
| 55 |
-
import Careers from "./pages/Careers";
|
| 56 |
-
import CookiePolicy from "./pages/legal/CookiePolicy";
|
| 57 |
-
|
| 58 |
-
// NEW Admin Pages (Refactored)
|
| 59 |
-
import AdminDashboard from "./admin/pages/AdminDashboard";
|
| 60 |
-
import AdminTickets from "./admin/pages/AdminTickets";
|
| 61 |
-
import AdminTicketDetail from "./admin/pages/AdminTicketDetail";
|
| 62 |
-
import AdminUsers from "./admin/pages/AdminUsers";
|
| 63 |
-
import AdminAnalytics from "./admin/pages/AdminAnalytics";
|
| 64 |
-
import AdminProfile from "./admin/pages/AdminProfile";
|
| 65 |
-
import AdminSettings from "./admin/pages/AdminSettings";
|
| 66 |
-
import MasterBugReports from "./master-admin/pages/MasterBugReports";
|
| 67 |
-
|
| 68 |
-
// Feature Pages
|
| 69 |
-
import AutoCategorizationFeature from "./pages/features/AutoCategorizationFeature";
|
| 70 |
-
import PriorityDetectionFeature from "./pages/features/PriorityDetectionFeature";
|
| 71 |
-
import SmartResolutionFeature from "./pages/features/SmartResolutionFeature";
|
| 72 |
-
|
| 73 |
-
// Legal Pages
|
| 74 |
-
import TermsOfService from "./pages/legal/TermsOfService";
|
| 75 |
-
import PrivacyPolicy from "./pages/legal/PrivacyPolicy";
|
| 76 |
-
import Security from "./pages/legal/Security";
|
| 77 |
import AdminProtectedRoute from "./components/shared/AdminProtectedRoute";
|
| 78 |
import MasterAdminProtectedRoute from "./components/shared/MasterAdminProtectedRoute";
|
| 79 |
import ProtectedRoute from "./components/shared/ProtectedRoute";
|
| 80 |
import useAuthStore from "./store/authStore";
|
| 81 |
import NotApproved from "./pages/NotApproved";
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
|
| 92 |
function TitleUpdater() {
|
|
@@ -104,6 +99,7 @@ function TitleUpdater() {
|
|
| 104 |
else if (path.startsWith('/admin/analytics')) title = 'Analytics | Admin';
|
| 105 |
else if (path.startsWith('/admin/profile')) title = 'Admin Profile';
|
| 106 |
else if (path.startsWith('/admin/settings')) title = 'Settings | Admin';
|
|
|
|
| 107 |
// Master Admin Routes
|
| 108 |
else if (path.startsWith('/master-admin/dashboard')) title = 'Master Dashboard';
|
| 109 |
else if (path.startsWith('/master-admin/admin-requests')) title = 'Pending Requests | Master Admin';
|
|
@@ -119,19 +115,13 @@ function TitleUpdater() {
|
|
| 119 |
else if (path === '/my-tickets') title = 'My Tickets';
|
| 120 |
else if (path === '/profile') title = 'User Profile';
|
| 121 |
else if (path === '/notifications') title = 'Notifications';
|
| 122 |
-
else if (path === '/docs') title = 'Documentation';
|
| 123 |
-
else if (path === '/api-reference') title = 'API Reference';
|
| 124 |
-
else if (path === '/changelog') title = 'Changelog';
|
| 125 |
-
else if (path === '/status') title = 'Status';
|
| 126 |
-
else if (path === '/about') title = 'About Us';
|
| 127 |
-
else if (path === '/careers') title = 'Careers';
|
| 128 |
-
else if (path === '/cookie-policy') title = 'Cookie Policy';
|
| 129 |
// Public / Lobby Routes
|
| 130 |
else if (path === '/login') title = 'Login';
|
| 131 |
else if (path === '/signup') title = 'Create Account';
|
| 132 |
else if (path === '/admin-signup') title = 'Admin Signup';
|
| 133 |
else if (path === '/user-lobby') title = 'User Lobby';
|
| 134 |
else if (path === '/admin-lobby') title = 'Admin Lobby';
|
|
|
|
| 135 |
else if (path === '/') title = 'Welcome';
|
| 136 |
|
| 137 |
document.title = title === 'HELPDESK.AI' ? title : `${title} | HELPDESK.AI`;
|
|
@@ -206,10 +196,11 @@ function AppLayout() {
|
|
| 206 |
<Route path="/admin/analytics" element={<AdminAnalytics />} />
|
| 207 |
<Route path="/admin/profile" element={<AdminProfile />} />
|
| 208 |
<Route path="/admin/settings" element={<AdminSettings />} />
|
|
|
|
| 209 |
</Route>
|
| 210 |
</Route>
|
| 211 |
|
| 212 |
-
<Route path="*" element={<
|
| 213 |
</Routes>
|
| 214 |
</>
|
| 215 |
);
|
|
@@ -223,81 +214,60 @@ function App() {
|
|
| 223 |
initialize();
|
| 224 |
}, [initialize]);
|
| 225 |
|
| 226 |
-
const isDocsSubdomain = window.location.hostname.startsWith('docs.');
|
| 227 |
-
|
| 228 |
-
if (isDocsSubdomain) {
|
| 229 |
-
return (
|
| 230 |
-
<BrowserRouter>
|
| 231 |
-
<TitleUpdater />
|
| 232 |
-
<ScrollToTop />
|
| 233 |
-
<Toaster />
|
| 234 |
-
<BugReportWidget />
|
| 235 |
-
<Routes>
|
| 236 |
-
<Route path="/" element={<DocsPortal />} />
|
| 237 |
-
<Route path="/docs" element={<Navigate to="/" replace />} />
|
| 238 |
-
<Route path="/api-reference" element={<ApiReference />} />
|
| 239 |
-
<Route path="/changelog" element={<Changelog />} />
|
| 240 |
-
<Route path="/status" element={<StatusPage />} />
|
| 241 |
-
<Route path="*" element={<DocsPortal />} />
|
| 242 |
-
</Routes>
|
| 243 |
-
</BrowserRouter>
|
| 244 |
-
);
|
| 245 |
-
}
|
| 246 |
-
|
| 247 |
return (
|
| 248 |
<BrowserRouter>
|
| 249 |
<TitleUpdater />
|
| 250 |
<ScrollToTop />
|
| 251 |
-
<
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
<
|
| 293 |
</Route>
|
| 294 |
-
</Route>
|
| 295 |
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
|
|
|
| 301 |
</BrowserRouter>
|
| 302 |
);
|
| 303 |
}
|
|
|
|
| 5 |
Navigate,
|
| 6 |
useLocation
|
| 7 |
} from "react-router-dom";
|
| 8 |
+
import React, { Suspense, useEffect, lazy } from "react";
|
|
|
|
|
|
|
| 9 |
import useTicketStore from "./store/ticketStore";
|
|
|
|
|
|
|
| 10 |
import useRealtimeNotifications from "./hooks/useRealtimeNotifications";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
import AdminProtectedRoute from "./components/shared/AdminProtectedRoute";
|
| 12 |
import MasterAdminProtectedRoute from "./components/shared/MasterAdminProtectedRoute";
|
| 13 |
import ProtectedRoute from "./components/shared/ProtectedRoute";
|
| 14 |
import useAuthStore from "./store/authStore";
|
| 15 |
import NotApproved from "./pages/NotApproved";
|
| 16 |
+
const Login = lazy(() => import("./pages/Login"));
|
| 17 |
+
const AuthCallback = lazy(() => import("./pages/AuthCallback"));
|
| 18 |
+
const ForgotPassword = lazy(() => import("./pages/ForgotPassword"));
|
| 19 |
+
const ResetPassword = lazy(() => import("./pages/ResetPassword"));
|
| 20 |
+
const Signup = lazy(() => import("./pages/Signup"));
|
| 21 |
+
const AdminSignup = lazy(() => import("./pages/AdminSignup"));
|
| 22 |
+
const AdminLobby = lazy(() => import("./pages/AdminLobby"));
|
| 23 |
+
const UserLobby = lazy(() => import("./pages/UserLobby"));
|
| 24 |
+
const LandingPage = lazy(() => import("./pages/LandingPage"));
|
| 25 |
+
const ContactSales = lazy(() => import("./pages/ContactSales"));
|
| 26 |
+
|
| 27 |
+
const DuplicateDetection = lazy(() => import("./user/pages/DuplicateDetection"));
|
| 28 |
+
const AutoResolveChat = lazy(() => import("./user/pages/AutoResolveChat"));
|
| 29 |
+
const Resolved = lazy(() => import("./user/pages/Resolved"));
|
| 30 |
+
const TicketTracking = lazy(() => import("./user/pages/TicketTracking"));
|
| 31 |
+
|
| 32 |
+
const UserLayout = lazy(() => import("./user/UserLayout"));
|
| 33 |
+
const AdminLayout = lazy(() => import("./admin/layout/AdminLayout"));
|
| 34 |
+
|
| 35 |
+
const Dashboard = lazy(() => import("./user/pages/Dashboard"));
|
| 36 |
+
const CreateTicket = lazy(() => import("./user/pages/CreateTicket"));
|
| 37 |
+
const MyTickets = lazy(() => import("./user/pages/MyTickets"));
|
| 38 |
+
const TicketResult = lazy(() => import("./user/pages/TicketResult"));
|
| 39 |
+
const Profile = lazy(() => import("./user/pages/Profile"));
|
| 40 |
+
const TicketDetail = lazy(() => import("./user/pages/TicketDetail"));
|
| 41 |
+
const AIProcessing = lazy(() => import("./user/pages/AIProcessing"));
|
| 42 |
+
const AIUnderstanding = lazy(() => import("./user/pages/AIUnderstanding"));
|
| 43 |
+
const Notifications = lazy(() => import("./user/pages/Notifications"));
|
| 44 |
+
const Help = lazy(() => import("./user/pages/Help"));
|
| 45 |
+
|
| 46 |
+
const AdminDashboard = lazy(() => import("./admin/pages/AdminDashboard"));
|
| 47 |
+
const AdminTickets = lazy(() => import("./admin/pages/AdminTickets"));
|
| 48 |
+
const AdminTicketDetail = lazy(() => import("./admin/pages/AdminTicketDetail"));
|
| 49 |
+
const AdminUsers = lazy(() => import("./admin/pages/AdminUsers"));
|
| 50 |
+
const AdminAnalytics = lazy(() => import("./admin/pages/AdminAnalytics"));
|
| 51 |
+
const AdminProfile = lazy(() => import("./admin/pages/AdminProfile"));
|
| 52 |
+
const AdminSettings = lazy(() => import("./admin/pages/AdminSettings"));
|
| 53 |
+
const SLAPage = lazy(() => import("./admin/pages/SLAPage"));
|
| 54 |
+
const MasterBugReports = lazy(() => import("./master-admin/pages/MasterBugReports"));
|
| 55 |
+
|
| 56 |
+
const AutoCategorizationFeature = lazy(() => import("./pages/features/AutoCategorizationFeature"));
|
| 57 |
+
const PriorityDetectionFeature = lazy(() => import("./pages/features/PriorityDetectionFeature"));
|
| 58 |
+
const SmartResolutionFeature = lazy(() => import("./pages/features/SmartResolutionFeature"));
|
| 59 |
+
|
| 60 |
+
const TermsOfService = lazy(() => import("./pages/legal/TermsOfService"));
|
| 61 |
+
const PrivacyPolicy = lazy(() => import("./pages/legal/PrivacyPolicy"));
|
| 62 |
+
const Security = lazy(() => import("./pages/legal/Security"));
|
| 63 |
+
|
| 64 |
+
const MasterAdminLogin = lazy(() => import("./pages/MasterAdminLogin"));
|
| 65 |
+
const MasterAdminLayout = lazy(() => import("./master-admin/layout/MasterAdminLayout"));
|
| 66 |
+
const MasterAdminDashboard = lazy(() => import("./master-admin/pages/MasterAdminDashboard"));
|
| 67 |
+
const PendingAdminRequests = lazy(() => import("./master-admin/pages/PendingAdminRequests"));
|
| 68 |
+
const AllCompanies = lazy(() => import("./master-admin/pages/AllCompanies"));
|
| 69 |
+
const AllAdmins = lazy(() => import("./master-admin/pages/AllAdmins"));
|
| 70 |
+
const Changelog = lazy(() => import("./pages/Changelog"));
|
| 71 |
+
const AboutUs = lazy(() => import("./pages/AboutUs"));
|
| 72 |
+
const NotFoundPage = lazy(() => import("./components/ui/not-found-2").then((module) => ({ default: module.NotFound })));
|
| 73 |
+
const Toaster = lazy(() => import("./components/shared/Toaster"));
|
| 74 |
+
const BugReportWidget = lazy(() => import("./components/shared/BugReportWidget"));
|
| 75 |
+
|
| 76 |
+
function RouteFallback() {
|
| 77 |
+
return (
|
| 78 |
+
<div className="flex min-h-[40vh] items-center justify-center px-6 py-16">
|
| 79 |
+
<div className="rounded-2xl border border-slate-200 bg-white px-6 py-4 text-sm font-semibold text-slate-500 shadow-sm">
|
| 80 |
+
Loading...
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
);
|
| 84 |
+
}
|
| 85 |
|
| 86 |
|
| 87 |
function TitleUpdater() {
|
|
|
|
| 99 |
else if (path.startsWith('/admin/analytics')) title = 'Analytics | Admin';
|
| 100 |
else if (path.startsWith('/admin/profile')) title = 'Admin Profile';
|
| 101 |
else if (path.startsWith('/admin/settings')) title = 'Settings | Admin';
|
| 102 |
+
else if (path.startsWith('/admin/sla')) title = 'SLA Monitor | Admin';
|
| 103 |
// Master Admin Routes
|
| 104 |
else if (path.startsWith('/master-admin/dashboard')) title = 'Master Dashboard';
|
| 105 |
else if (path.startsWith('/master-admin/admin-requests')) title = 'Pending Requests | Master Admin';
|
|
|
|
| 115 |
else if (path === '/my-tickets') title = 'My Tickets';
|
| 116 |
else if (path === '/profile') title = 'User Profile';
|
| 117 |
else if (path === '/notifications') title = 'Notifications';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
// Public / Lobby Routes
|
| 119 |
else if (path === '/login') title = 'Login';
|
| 120 |
else if (path === '/signup') title = 'Create Account';
|
| 121 |
else if (path === '/admin-signup') title = 'Admin Signup';
|
| 122 |
else if (path === '/user-lobby') title = 'User Lobby';
|
| 123 |
else if (path === '/admin-lobby') title = 'Admin Lobby';
|
| 124 |
+
else if (path === '/about-us') title = 'About Us';
|
| 125 |
else if (path === '/') title = 'Welcome';
|
| 126 |
|
| 127 |
document.title = title === 'HELPDESK.AI' ? title : `${title} | HELPDESK.AI`;
|
|
|
|
| 196 |
<Route path="/admin/analytics" element={<AdminAnalytics />} />
|
| 197 |
<Route path="/admin/profile" element={<AdminProfile />} />
|
| 198 |
<Route path="/admin/settings" element={<AdminSettings />} />
|
| 199 |
+
<Route path="/admin/sla" element={<SLAPage />} />
|
| 200 |
</Route>
|
| 201 |
</Route>
|
| 202 |
|
| 203 |
+
<Route path="*" element={<NotFoundPage />} />
|
| 204 |
</Routes>
|
| 205 |
</>
|
| 206 |
);
|
|
|
|
| 214 |
initialize();
|
| 215 |
}, [initialize]);
|
| 216 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
return (
|
| 218 |
<BrowserRouter>
|
| 219 |
<TitleUpdater />
|
| 220 |
<ScrollToTop />
|
| 221 |
+
<Suspense fallback={<RouteFallback />}>
|
| 222 |
+
<Toaster />
|
| 223 |
+
<BugReportWidget />
|
| 224 |
+
<Routes>
|
| 225 |
+
{/* Public */}
|
| 226 |
+
<Route path="/" element={<LandingPage />} />
|
| 227 |
+
<Route path="/login" element={<Login />} />
|
| 228 |
+
<Route path="/auth/callback" element={<AuthCallback />} />
|
| 229 |
+
<Route path="/forgot-password" element={<ForgotPassword />} />
|
| 230 |
+
<Route path="/reset-password" element={<ResetPassword />} />
|
| 231 |
+
<Route path="/signup" element={<Signup />} />
|
| 232 |
+
<Route path="/admin-signup" element={<AdminSignup />} />
|
| 233 |
+
<Route path="/admin-lobby" element={<AdminLobby />} />
|
| 234 |
+
<Route path="/user-lobby" element={<UserLobby />} />
|
| 235 |
+
<Route path="/not-approved" element={<NotApproved />} />
|
| 236 |
+
<Route path="/contact-sales" element={<ContactSales />} />
|
| 237 |
+
|
| 238 |
+
{/* Feature Pages */}
|
| 239 |
+
<Route path="/features/categorization" element={<AutoCategorizationFeature />} />
|
| 240 |
+
<Route path="/features/priority" element={<PriorityDetectionFeature />} />
|
| 241 |
+
<Route path="/features/resolution" element={<SmartResolutionFeature />} />
|
| 242 |
+
|
| 243 |
+
{/* Resources Pages */}
|
| 244 |
+
<Route path="/changelog" element={<Changelog />} />
|
| 245 |
+
<Route path="/about-us" element={<AboutUs />} />
|
| 246 |
+
|
| 247 |
+
{/* Legal Pages */}
|
| 248 |
+
<Route path="/terms" element={<TermsOfService />} />
|
| 249 |
+
<Route path="/privacy" element={<PrivacyPolicy />} />
|
| 250 |
+
<Route path="/security" element={<Security />} />
|
| 251 |
+
|
| 252 |
+
{/* Master Admin Portal */}
|
| 253 |
+
<Route path="/master-admin-login" element={<MasterAdminLogin />} />
|
| 254 |
+
|
| 255 |
+
<Route element={<MasterAdminProtectedRoute />}>
|
| 256 |
+
<Route element={<MasterAdminLayout />}>
|
| 257 |
+
<Route path="/master-admin/dashboard" element={<MasterAdminDashboard />} />
|
| 258 |
+
<Route path="/master-admin/admin-requests" element={<PendingAdminRequests />} />
|
| 259 |
+
<Route path="/master-admin/companies" element={<AllCompanies />} />
|
| 260 |
+
<Route path="/master-admin/all-admins" element={<AllAdmins />} />
|
| 261 |
+
<Route path="/master-admin/bug-reports" element={<MasterBugReports />} />
|
| 262 |
+
</Route>
|
| 263 |
</Route>
|
|
|
|
| 264 |
|
| 265 |
+
{/* Protected */}
|
| 266 |
+
<Route element={<ProtectedRoute />}>
|
| 267 |
+
<Route path="/*" element={<AppLayout />} />
|
| 268 |
+
</Route>
|
| 269 |
+
</Routes>
|
| 270 |
+
</Suspense>
|
| 271 |
</BrowserRouter>
|
| 272 |
);
|
| 273 |
}
|
Frontend/src/admin/components/AdminSidebar.jsx
CHANGED
|
@@ -10,7 +10,8 @@ import {
|
|
| 10 |
LogOut,
|
| 11 |
Activity,
|
| 12 |
ChevronLeft,
|
| 13 |
-
ChevronRight
|
|
|
|
| 14 |
} from 'lucide-react';
|
| 15 |
import useAuthStore from '../../store/authStore';
|
| 16 |
|
|
@@ -21,6 +22,7 @@ const AdminSidebar = ({ isMobile, onClose, isCollapsed, onToggleCollapse }) => {
|
|
| 21 |
{ label: 'Users', path: '/admin/users', icon: Users },
|
| 22 |
{ label: 'Analytics', path: '/admin/analytics', icon: BarChart3 },
|
| 23 |
{ label: 'Profile', path: '/admin/profile', icon: UserCircle },
|
|
|
|
| 24 |
];
|
| 25 |
|
| 26 |
const { logout } = useAuthStore();
|
|
|
|
| 10 |
LogOut,
|
| 11 |
Activity,
|
| 12 |
ChevronLeft,
|
| 13 |
+
ChevronRight,
|
| 14 |
+
Clock
|
| 15 |
} from 'lucide-react';
|
| 16 |
import useAuthStore from '../../store/authStore';
|
| 17 |
|
|
|
|
| 22 |
{ label: 'Users', path: '/admin/users', icon: Users },
|
| 23 |
{ label: 'Analytics', path: '/admin/analytics', icon: BarChart3 },
|
| 24 |
{ label: 'Profile', path: '/admin/profile', icon: UserCircle },
|
| 25 |
+
{ label: 'SLA Monitor', path: '/admin/sla', icon: Clock },
|
| 26 |
];
|
| 27 |
|
| 28 |
const { logout } = useAuthStore();
|
Frontend/src/admin/components/TicketTable.jsx
CHANGED
|
@@ -73,6 +73,9 @@ const TicketTable = ({ tickets = [], isLoading = false, limit = null }) => {
|
|
| 73 |
? ticket.assigned_team
|
| 74 |
: (teamMap[effectiveCategory] || ticket.assigned_team || 'L1 Helpdesk');
|
| 75 |
const statusSt = getStatusStyle(ticket.status);
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
// Truncated subject
|
| 78 |
const subject = ticket.subject || ticket.summary || 'Untitled ticket';
|
|
@@ -129,9 +132,16 @@ const TicketTable = ({ tickets = [], isLoading = false, limit = null }) => {
|
|
| 129 |
<span style={{ fontSize: '13px', fontWeight: 500, color: '#111827', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
| 130 |
{truncSubject}
|
| 131 |
</span>
|
| 132 |
-
<
|
| 133 |
-
{
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
</div>
|
| 136 |
</div>
|
| 137 |
</td>
|
|
|
|
| 73 |
? ticket.assigned_team
|
| 74 |
: (teamMap[effectiveCategory] || ticket.assigned_team || 'L1 Helpdesk');
|
| 75 |
const statusSt = getStatusStyle(ticket.status);
|
| 76 |
+
const translationMeta = ticket?.metadata?.translation;
|
| 77 |
+
const isTranslated = Boolean(translationMeta?.translated);
|
| 78 |
+
const sourceLanguageName = translationMeta?.source_language_name || translationMeta?.source_language || 'Unknown';
|
| 79 |
|
| 80 |
// Truncated subject
|
| 81 |
const subject = ticket.subject || ticket.summary || 'Untitled ticket';
|
|
|
|
| 132 |
<span style={{ fontSize: '13px', fontWeight: 500, color: '#111827', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
| 133 |
{truncSubject}
|
| 134 |
</span>
|
| 135 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
| 136 |
+
<span style={{ fontSize: '11px', color: '#6b7280' }}>
|
| 137 |
+
{effectiveCategory || 'General'}
|
| 138 |
+
</span>
|
| 139 |
+
{isTranslated && (
|
| 140 |
+
<span style={{ fontSize: '10px', color: '#0369a1' }}>
|
| 141 |
+
Translated from {sourceLanguageName}
|
| 142 |
+
</span>
|
| 143 |
+
)}
|
| 144 |
+
</div>
|
| 145 |
</div>
|
| 146 |
</div>
|
| 147 |
</td>
|
Frontend/src/admin/pages/AdminDashboard.jsx
CHANGED
|
@@ -1,12 +1,13 @@
|
|
| 1 |
import React, { useMemo } from 'react';
|
| 2 |
import { useNavigate } from 'react-router-dom';
|
| 3 |
-
import { Activity } from 'lucide-react';
|
| 4 |
|
| 5 |
import useAuthStore from "../../store/authStore";
|
|
|
|
|
|
|
| 6 |
import { supabase } from "../../lib/supabaseClient";
|
| 7 |
import StatCard from "../components/StatCard";
|
| 8 |
import TicketTable from "../components/TicketTable";
|
| 9 |
-
import { formatTimelineDate } from "../../utils/dateUtils";
|
| 10 |
|
| 11 |
// Inline SVG icon components
|
| 12 |
const TicketIcon = () => (
|
|
@@ -67,12 +68,67 @@ const aiIconMap = [
|
|
| 67 |
{ icon: <DuplicateIcon />, bg: '#FFF7ED', color: '#f97316' },
|
| 68 |
];
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
const AdminDashboard = () => {
|
| 71 |
const navigate = useNavigate();
|
| 72 |
const { profile } = useAuthStore();
|
| 73 |
-
const [tickets, setTickets] = React.useState([]);
|
| 74 |
const [isLoading, setIsLoading] = React.useState(true);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
React.useEffect(() => {
|
| 77 |
if (profile) {
|
| 78 |
const fetchStats = async () => {
|
|
@@ -92,19 +148,27 @@ const AdminDashboard = () => {
|
|
| 92 |
console.warn("Retrying dashboard fetch without relation...", error);
|
| 93 |
const { data: basicData, error: basicError } = await supabase.from('tickets').select('*').eq('company', profile?.company).order('created_at', { ascending: false });
|
| 94 |
if (basicError) throw basicError;
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
| 96 |
} else {
|
| 97 |
-
|
|
|
|
|
|
|
| 98 |
}
|
| 99 |
} catch (err) { console.error("Dashboard fetch error:", err); }
|
| 100 |
finally { setIsLoading(false); }
|
| 101 |
};
|
| 102 |
|
| 103 |
fetchStats();
|
| 104 |
-
const interval = setInterval(fetchStats, 30000);
|
| 105 |
-
return () => clearInterval(interval);
|
| 106 |
}
|
| 107 |
-
}, [profile]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
const metrics = useMemo(() => {
|
| 110 |
const total = tickets.length;
|
|
@@ -126,6 +190,26 @@ const AdminDashboard = () => {
|
|
| 126 |
];
|
| 127 |
}, [tickets]);
|
| 128 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
return (
|
| 130 |
<div style={{ background: '#f8faf9', minHeight: '100vh', paddingBottom: '40px' }} className="space-y-10 -m-6 p-6 md:-m-10 md:p-10">
|
| 131 |
{/* Header */}
|
|
@@ -135,13 +219,17 @@ const AdminDashboard = () => {
|
|
| 135 |
Dashboard
|
| 136 |
</h1>
|
| 137 |
<p style={{ color: '#6b7280', fontSize: '13px', marginTop: '4px', display: 'flex', alignItems: 'center', gap: '8px', fontWeight: 500 }}>
|
| 138 |
-
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
</p>
|
| 141 |
</div>
|
| 142 |
-
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '6px 16px', background: '#F0FDF4', border: '1.5px solid #BBF7D0', borderRadius: '100px' }}>
|
| 143 |
-
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#22c55e', display: 'inline-block', animation: 'pulse-dot 2s infinite' }}></span>
|
| 144 |
-
<span style={{ fontSize: '11px', fontWeight: 700, color: '#15803d', letterSpacing: '0.08em', textTransform: 'uppercase' }}>
|
| 145 |
</div>
|
| 146 |
</div>
|
| 147 |
|
|
@@ -161,6 +249,42 @@ const AdminDashboard = () => {
|
|
| 161 |
</button>
|
| 162 |
</div>
|
| 163 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10">
|
| 165 |
{/* Recent Activity */}
|
| 166 |
<div className="lg:col-span-8 space-y-6">
|
|
@@ -182,9 +306,9 @@ const AdminDashboard = () => {
|
|
| 182 |
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#16a34a" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>
|
| 183 |
AI Status
|
| 184 |
</h2>
|
| 185 |
-
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', background: '#F0FDF4', border: '1px solid #BBF7D0', borderRadius: '100px', padding: '3px 10px' }}>
|
| 186 |
-
<span style={{ width: 5, height: 5, borderRadius: '50%', background: '#22c55e', display: 'inline-block', animation: 'pulse-dot 2s infinite' }}></span>
|
| 187 |
-
<span style={{ fontSize: '10px', fontWeight: 700, color: '#15803d' }}>
|
| 188 |
</div>
|
| 189 |
</div>
|
| 190 |
<div style={{ background: '#fff', borderRadius: '20px', border: '1px solid #f0fdf4', padding: '24px' }}>
|
|
@@ -209,9 +333,9 @@ const AdminDashboard = () => {
|
|
| 209 |
<div className="pt-4 mt-4 border-t border-gray-100 flex flex-col items-center gap-2">
|
| 210 |
<p style={{ fontSize: '10px', color: '#9ca3af', letterSpacing: '0.14em', fontWeight: 600, textTransform: 'uppercase' }}>All systems operating normally</p>
|
| 211 |
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', padding: '4px 10px', background: '#f8faf9', borderRadius: '100px', border: '1px solid #e5e7eb' }}>
|
| 212 |
-
<Activity size={10} color="#
|
| 213 |
-
<span style={{ fontSize: '9px', fontWeight: 600, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
| 214 |
-
|
| 215 |
</span>
|
| 216 |
</div>
|
| 217 |
</div>
|
|
|
|
| 1 |
import React, { useMemo } from 'react';
|
| 2 |
import { useNavigate } from 'react-router-dom';
|
| 3 |
+
import { Activity, AlertTriangle, Clock, ShieldCheck, Wifi, WifiOff } from 'lucide-react';
|
| 4 |
|
| 5 |
import useAuthStore from "../../store/authStore";
|
| 6 |
+
import useTicketStore from "../../store/ticketStore";
|
| 7 |
+
import useWebSocket from "../../hooks/useWebSocket";
|
| 8 |
import { supabase } from "../../lib/supabaseClient";
|
| 9 |
import StatCard from "../components/StatCard";
|
| 10 |
import TicketTable from "../components/TicketTable";
|
|
|
|
| 11 |
|
| 12 |
// Inline SVG icon components
|
| 13 |
const TicketIcon = () => (
|
|
|
|
| 68 |
{ icon: <DuplicateIcon />, bg: '#FFF7ED', color: '#f97316' },
|
| 69 |
];
|
| 70 |
|
| 71 |
+
function isTerminalTicket(ticket) {
|
| 72 |
+
const status = String(ticket?.status || '').toLowerCase();
|
| 73 |
+
return status.includes('resolv') || status.includes('closed');
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
function getSlaDeadline(ticket) {
|
| 77 |
+
const source = ticket?.sla_breach_at || ticket?.slaBreachAt;
|
| 78 |
+
const value = source ? new Date(source).getTime() : NaN;
|
| 79 |
+
return Number.isFinite(value) ? value : null;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
function getSlaState(ticket, nowMs) {
|
| 83 |
+
if (isTerminalTicket(ticket)) return 'met';
|
| 84 |
+
if (String(ticket?.sla_status || '').toUpperCase() === 'BREACHED') return 'breached';
|
| 85 |
+
const deadline = getSlaDeadline(ticket);
|
| 86 |
+
if (!deadline) return 'active';
|
| 87 |
+
const remaining = deadline - nowMs;
|
| 88 |
+
if (remaining <= 0) return 'breached';
|
| 89 |
+
if (remaining <= 60 * 60 * 1000) return 'warning';
|
| 90 |
+
return 'active';
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
function formatSlaCountdown(deadlineMs, nowMs) {
|
| 94 |
+
if (!deadlineMs) return 'No deadline';
|
| 95 |
+
const remaining = deadlineMs - nowMs;
|
| 96 |
+
if (remaining <= 0) return 'Breached';
|
| 97 |
+
const totalMinutes = Math.ceil(remaining / 60000);
|
| 98 |
+
const hours = Math.floor(totalMinutes / 60);
|
| 99 |
+
const minutes = totalMinutes % 60;
|
| 100 |
+
if (hours > 0) return `${hours}h ${minutes}m`;
|
| 101 |
+
return `${minutes}m`;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
const AdminDashboard = () => {
|
| 105 |
const navigate = useNavigate();
|
| 106 |
const { profile } = useAuthStore();
|
|
|
|
| 107 |
const [isLoading, setIsLoading] = React.useState(true);
|
| 108 |
+
const [nowMs, setNowMs] = React.useState(() => Date.now());
|
| 109 |
+
|
| 110 |
+
// WebSocket connection for real-time ticket updates
|
| 111 |
+
const { isConnected: wsConnected, lastMessage } = useWebSocket(profile?.company);
|
| 112 |
+
|
| 113 |
+
// Read tickets from the Zustand store (populated below and updated by WS)
|
| 114 |
+
const tickets = useTicketStore((s) => s.tickets);
|
| 115 |
+
const handleWsMessage = useTicketStore((s) => s.handleWsMessage);
|
| 116 |
+
const setWsConnected = useTicketStore((s) => s.setWsConnected);
|
| 117 |
+
const upsertTicket = useTicketStore((s) => s.upsertTicket);
|
| 118 |
|
| 119 |
+
// Sync WebSocket connection status to store
|
| 120 |
+
React.useEffect(() => {
|
| 121 |
+
setWsConnected(wsConnected);
|
| 122 |
+
}, [wsConnected, setWsConnected]);
|
| 123 |
+
|
| 124 |
+
// Route incoming WebSocket messages into the ticket store
|
| 125 |
+
React.useEffect(() => {
|
| 126 |
+
if (lastMessage) {
|
| 127 |
+
handleWsMessage(lastMessage);
|
| 128 |
+
}
|
| 129 |
+
}, [lastMessage, handleWsMessage]);
|
| 130 |
+
|
| 131 |
+
// Initial fetch — populate store from Supabase on mount
|
| 132 |
React.useEffect(() => {
|
| 133 |
if (profile) {
|
| 134 |
const fetchStats = async () => {
|
|
|
|
| 148 |
console.warn("Retrying dashboard fetch without relation...", error);
|
| 149 |
const { data: basicData, error: basicError } = await supabase.from('tickets').select('*').eq('company', profile?.company).order('created_at', { ascending: false });
|
| 150 |
if (basicError) throw basicError;
|
| 151 |
+
// Bulk-load into store (avoid duplicates)
|
| 152 |
+
for (const t of basicData || []) {
|
| 153 |
+
upsertTicket(t);
|
| 154 |
+
}
|
| 155 |
} else {
|
| 156 |
+
for (const t of data || []) {
|
| 157 |
+
upsertTicket(t);
|
| 158 |
+
}
|
| 159 |
}
|
| 160 |
} catch (err) { console.error("Dashboard fetch error:", err); }
|
| 161 |
finally { setIsLoading(false); }
|
| 162 |
};
|
| 163 |
|
| 164 |
fetchStats();
|
|
|
|
|
|
|
| 165 |
}
|
| 166 |
+
}, [profile, upsertTicket]);
|
| 167 |
+
|
| 168 |
+
React.useEffect(() => {
|
| 169 |
+
const timer = setInterval(() => setNowMs(Date.now()), 60 * 1000);
|
| 170 |
+
return () => clearInterval(timer);
|
| 171 |
+
}, []);
|
| 172 |
|
| 173 |
const metrics = useMemo(() => {
|
| 174 |
const total = tickets.length;
|
|
|
|
| 190 |
];
|
| 191 |
}, [tickets]);
|
| 192 |
|
| 193 |
+
const slaBoard = useMemo(() => {
|
| 194 |
+
const actionable = tickets.filter(t => !isTerminalTicket(t));
|
| 195 |
+
const breached = actionable.filter(t => getSlaState(t, nowMs) === 'breached');
|
| 196 |
+
const warning = actionable.filter(t => getSlaState(t, nowMs) === 'warning');
|
| 197 |
+
const active = actionable.filter(t => getSlaState(t, nowMs) === 'active');
|
| 198 |
+
const critical = actionable.filter(t => String(t.priority || '').toLowerCase() === 'critical');
|
| 199 |
+
const nextTicket = actionable
|
| 200 |
+
.map(ticket => ({ ticket, deadline: getSlaDeadline(ticket) }))
|
| 201 |
+
.filter(item => item.deadline)
|
| 202 |
+
.sort((a, b) => a.deadline - b.deadline)[0];
|
| 203 |
+
|
| 204 |
+
return {
|
| 205 |
+
breached,
|
| 206 |
+
warning,
|
| 207 |
+
active,
|
| 208 |
+
critical,
|
| 209 |
+
nextTicket,
|
| 210 |
+
};
|
| 211 |
+
}, [tickets, nowMs]);
|
| 212 |
+
|
| 213 |
return (
|
| 214 |
<div style={{ background: '#f8faf9', minHeight: '100vh', paddingBottom: '40px' }} className="space-y-10 -m-6 p-6 md:-m-10 md:p-10">
|
| 215 |
{/* Header */}
|
|
|
|
| 219 |
Dashboard
|
| 220 |
</h1>
|
| 221 |
<p style={{ color: '#6b7280', fontSize: '13px', marginTop: '4px', display: 'flex', alignItems: 'center', gap: '8px', fontWeight: 500 }}>
|
| 222 |
+
{wsConnected ? (
|
| 223 |
+
<Wifi size={14} color="#22c55e" />
|
| 224 |
+
) : (
|
| 225 |
+
<WifiOff size={14} color="#f97316" />
|
| 226 |
+
)}
|
| 227 |
+
{wsConnected ? 'WebSocket connected' : 'Reconnecting...'}
|
| 228 |
</p>
|
| 229 |
</div>
|
| 230 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '6px 16px', background: wsConnected ? '#F0FDF4' : '#FFF7ED', border: wsConnected ? '1.5px solid #BBF7D0' : '1.5px solid #FED7AA', borderRadius: '100px' }}>
|
| 231 |
+
<span style={{ width: 6, height: 6, borderRadius: '50%', background: wsConnected ? '#22c55e' : '#f97316', display: 'inline-block', animation: 'pulse-dot 2s infinite' }}></span>
|
| 232 |
+
<span style={{ fontSize: '11px', fontWeight: 700, color: wsConnected ? '#15803d' : '#c2410c', letterSpacing: '0.08em', textTransform: 'uppercase' }}>{wsConnected ? 'Live' : 'Reconnecting'}</span>
|
| 233 |
</div>
|
| 234 |
</div>
|
| 235 |
|
|
|
|
| 249 |
</button>
|
| 250 |
</div>
|
| 251 |
|
| 252 |
+
<div style={{ background: '#ffffff', borderRadius: '20px', border: '1px solid #fee2e2', boxShadow: '0 2px 16px rgba(0,0,0,0.05)', padding: '24px' }}>
|
| 253 |
+
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6">
|
| 254 |
+
<div>
|
| 255 |
+
<h2 style={{ fontFamily: 'Syne, sans-serif', fontSize: '16px', fontWeight: 800, color: '#0f1f12', display: 'flex', alignItems: 'center', gap: '8px', margin: 0 }}>
|
| 256 |
+
<Clock size={18} color="#dc2626" /> SLA Compliance
|
| 257 |
+
</h2>
|
| 258 |
+
<p style={{ fontSize: '11px', color: '#9ca3af', marginTop: '6px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
| 259 |
+
{slaBoard.nextTicket ? `Next deadline ${formatSlaCountdown(slaBoard.nextTicket.deadline, nowMs)}` : 'No active deadlines'}
|
| 260 |
+
</p>
|
| 261 |
+
</div>
|
| 262 |
+
<button
|
| 263 |
+
onClick={() => navigate('/admin/tickets')}
|
| 264 |
+
className="px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest border border-slate-200 text-slate-600 hover:border-red-200 hover:text-red-600 transition-colors"
|
| 265 |
+
>
|
| 266 |
+
Open Queue
|
| 267 |
+
</button>
|
| 268 |
+
</div>
|
| 269 |
+
|
| 270 |
+
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
| 271 |
+
{[
|
| 272 |
+
{ label: 'Breached', value: slaBoard.breached.length, icon: <AlertTriangle size={18} />, bg: '#fef2f2', color: '#dc2626', border: '#fecaca' },
|
| 273 |
+
{ label: 'Warning', value: slaBoard.warning.length, icon: <Clock size={18} />, bg: '#fffbeb', color: '#d97706', border: '#fde68a' },
|
| 274 |
+
{ label: 'Critical Open', value: slaBoard.critical.length, icon: <Activity size={18} />, bg: '#fff7ed', color: '#ea580c', border: '#fed7aa' },
|
| 275 |
+
{ label: 'Healthy', value: slaBoard.active.length, icon: <ShieldCheck size={18} />, bg: '#f0fdf4', color: '#16a34a', border: '#bbf7d0' },
|
| 276 |
+
].map(item => (
|
| 277 |
+
<div key={item.label} style={{ background: item.bg, border: `1px solid ${item.border}`, borderRadius: '16px', padding: '18px' }}>
|
| 278 |
+
<div className="flex items-center justify-between">
|
| 279 |
+
<span style={{ color: item.color }}>{item.icon}</span>
|
| 280 |
+
<span style={{ color: item.color, fontSize: '28px', fontWeight: 900, lineHeight: 1 }}>{item.value}</span>
|
| 281 |
+
</div>
|
| 282 |
+
<p style={{ margin: '12px 0 0', fontSize: '10px', fontWeight: 800, letterSpacing: '0.12em', textTransform: 'uppercase', color: item.color }}>{item.label}</p>
|
| 283 |
+
</div>
|
| 284 |
+
))}
|
| 285 |
+
</div>
|
| 286 |
+
</div>
|
| 287 |
+
|
| 288 |
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10">
|
| 289 |
{/* Recent Activity */}
|
| 290 |
<div className="lg:col-span-8 space-y-6">
|
|
|
|
| 306 |
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#16a34a" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>
|
| 307 |
AI Status
|
| 308 |
</h2>
|
| 309 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', background: wsConnected ? '#F0FDF4' : '#FFF7ED', border: wsConnected ? '1px solid #BBF7D0' : '1px solid #FED7AA', borderRadius: '100px', padding: '3px 10px' }}>
|
| 310 |
+
<span style={{ width: 5, height: 5, borderRadius: '50%', background: wsConnected ? '#22c55e' : '#f97316', display: 'inline-block', animation: 'pulse-dot 2s infinite' }}></span>
|
| 311 |
+
<span style={{ fontSize: '10px', fontWeight: 700, color: wsConnected ? '#15803d' : '#c2410c' }}>{wsConnected ? 'WS CONNECTED' : 'RECONNECTING'}</span>
|
| 312 |
</div>
|
| 313 |
</div>
|
| 314 |
<div style={{ background: '#fff', borderRadius: '20px', border: '1px solid #f0fdf4', padding: '24px' }}>
|
|
|
|
| 333 |
<div className="pt-4 mt-4 border-t border-gray-100 flex flex-col items-center gap-2">
|
| 334 |
<p style={{ fontSize: '10px', color: '#9ca3af', letterSpacing: '0.14em', fontWeight: 600, textTransform: 'uppercase' }}>All systems operating normally</p>
|
| 335 |
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', padding: '4px 10px', background: '#f8faf9', borderRadius: '100px', border: '1px solid #e5e7eb' }}>
|
| 336 |
+
{wsConnected ? <Activity size={10} color="#22c55e" /> : <Activity size={10} color="#f97316" />}
|
| 337 |
+
<span style={{ fontSize: '9px', fontWeight: 600, color: wsConnected ? '#16a34a' : '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
| 338 |
+
{wsConnected ? 'Live via WebSocket' : 'Reconnecting via WebSocket...'}
|
| 339 |
</span>
|
| 340 |
</div>
|
| 341 |
</div>
|
Frontend/src/admin/pages/AdminSettings.jsx
CHANGED
|
@@ -1,4 +1,6 @@
|
|
| 1 |
-
import
|
|
|
|
|
|
|
| 2 |
import {
|
| 3 |
Settings,
|
| 4 |
Cpu,
|
|
@@ -8,7 +10,15 @@ import {
|
|
| 8 |
ShieldCheck
|
| 9 |
} from 'lucide-react';
|
| 10 |
import useAdminStore from '../store/adminStore';
|
| 11 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
import { Select } from "../../components/ui/select";
|
| 13 |
|
| 14 |
/**
|
|
@@ -17,12 +27,97 @@ import { Select } from "../../components/ui/select";
|
|
| 17 |
*/
|
| 18 |
const AdminSettings = () => {
|
| 19 |
const { settings, updateSettings } = useAdminStore();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
-
// Handlers
|
| 22 |
const handleChange = (key, value) => {
|
| 23 |
updateSettings({ [key]: value });
|
|
|
|
|
|
|
| 24 |
};
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
return (
|
| 27 |
<div className="max-w-4xl mx-auto py-6 space-y-10 pb-20 animate-in fade-in duration-700">
|
| 28 |
{/* 1. Header Area */}
|
|
@@ -35,6 +130,22 @@ const AdminSettings = () => {
|
|
| 35 |
<ShieldCheck size={14} className="text-emerald-500" /> Administrator Account
|
| 36 |
</p>
|
| 37 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
</div>
|
| 39 |
|
| 40 |
<div className="space-y-8">
|
|
@@ -167,8 +278,21 @@ const AdminSettings = () => {
|
|
| 167 |
<div className={`absolute top-1 w-6 h-6 bg-white rounded-full transition-all duration-300 shadow-md ${settings.adminAlerts ? 'right-1' : 'left-1'}`}></div>
|
| 168 |
</button>
|
| 169 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
</CardContent>
|
| 171 |
</Card>
|
|
|
|
| 172 |
</div>
|
| 173 |
</div>
|
| 174 |
);
|
|
|
|
| 1 |
+
import { Card, CardContent } from "../../components/ui/card";
|
| 2 |
+
import WebhookSettings from "../../components/shared/WebhookSettings";
|
| 3 |
+
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
| 4 |
import {
|
| 5 |
Settings,
|
| 6 |
Cpu,
|
|
|
|
| 10 |
ShieldCheck
|
| 11 |
} from 'lucide-react';
|
| 12 |
import useAdminStore from '../store/adminStore';
|
| 13 |
+
import useAuthStore from '../../store/authStore';
|
| 14 |
+
import { supabase } from '../../lib/supabaseClient';
|
| 15 |
+
import {
|
| 16 |
+
DEFAULT_ADMIN_SETTINGS,
|
| 17 |
+
resolveCompanyId,
|
| 18 |
+
settingsFromSystemSettingsRow,
|
| 19 |
+
settingsToSystemSettingsRow
|
| 20 |
+
} from '../../utils/adminSettingsPersistence';
|
| 21 |
+
|
| 22 |
import { Select } from "../../components/ui/select";
|
| 23 |
|
| 24 |
/**
|
|
|
|
| 27 |
*/
|
| 28 |
const AdminSettings = () => {
|
| 29 |
const { settings, updateSettings } = useAdminStore();
|
| 30 |
+
const { user, profile } = useAuthStore();
|
| 31 |
+
const [isLoadingSettings, setIsLoadingSettings] = useState(false);
|
| 32 |
+
const [isSavingSettings, setIsSavingSettings] = useState(false);
|
| 33 |
+
const [statusMessage, setStatusMessage] = useState('');
|
| 34 |
+
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
| 35 |
+
|
| 36 |
+
const companyId = useMemo(() => resolveCompanyId(profile, user), [profile, user]);
|
| 37 |
+
|
| 38 |
+
useEffect(() => {
|
| 39 |
+
let isMounted = true;
|
| 40 |
+
|
| 41 |
+
const loadCompanySettings = async () => {
|
| 42 |
+
if (!companyId) {
|
| 43 |
+
setStatusMessage('Company profile is required before settings can be synced.');
|
| 44 |
+
return;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
setIsLoadingSettings(true);
|
| 48 |
+
setStatusMessage('');
|
| 49 |
+
|
| 50 |
+
const { data, error } = await supabase
|
| 51 |
+
.from('system_settings')
|
| 52 |
+
.select('ai_confidence_threshold, duplicate_sensitivity, enable_auto_resolve, auto_close_days, email_notifications, admin_alerts')
|
| 53 |
+
.eq('company_id', companyId)
|
| 54 |
+
.maybeSingle();
|
| 55 |
+
|
| 56 |
+
if (!isMounted) return;
|
| 57 |
+
|
| 58 |
+
if (error) {
|
| 59 |
+
setStatusMessage(`Unable to load saved settings: ${error.message}`);
|
| 60 |
+
} else if (data) {
|
| 61 |
+
updateSettings(settingsFromSystemSettingsRow(data, DEFAULT_ADMIN_SETTINGS));
|
| 62 |
+
setHasUnsavedChanges(false);
|
| 63 |
+
setStatusMessage('Saved company settings loaded.');
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
setIsLoadingSettings(false);
|
| 67 |
+
};
|
| 68 |
+
|
| 69 |
+
loadCompanySettings();
|
| 70 |
+
|
| 71 |
+
return () => {
|
| 72 |
+
isMounted = false;
|
| 73 |
+
};
|
| 74 |
+
}, [companyId, updateSettings]);
|
| 75 |
+
|
| 76 |
+
const saveCompanySettings = useCallback(async (nextSettings, { silent = false } = {}) => {
|
| 77 |
+
if (!companyId) {
|
| 78 |
+
setStatusMessage('Company profile is required before settings can be saved.');
|
| 79 |
+
return;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
setIsSavingSettings(true);
|
| 83 |
+
if (!silent) {
|
| 84 |
+
setStatusMessage('');
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
const { error } = await supabase
|
| 88 |
+
.from('system_settings')
|
| 89 |
+
.upsert(settingsToSystemSettingsRow(nextSettings, companyId), { onConflict: 'company_id' });
|
| 90 |
+
|
| 91 |
+
if (error) {
|
| 92 |
+
setStatusMessage(`Unable to save settings: ${error.message}`);
|
| 93 |
+
} else {
|
| 94 |
+
setHasUnsavedChanges(false);
|
| 95 |
+
setStatusMessage('Settings saved for this company.');
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
setIsSavingSettings(false);
|
| 99 |
+
}, [companyId]);
|
| 100 |
|
|
|
|
| 101 |
const handleChange = (key, value) => {
|
| 102 |
updateSettings({ [key]: value });
|
| 103 |
+
setHasUnsavedChanges(true);
|
| 104 |
+
setStatusMessage('Saving changes...');
|
| 105 |
};
|
| 106 |
|
| 107 |
+
const handleSaveSettings = useCallback(() => {
|
| 108 |
+
saveCompanySettings(settings);
|
| 109 |
+
}, [saveCompanySettings, settings]);
|
| 110 |
+
|
| 111 |
+
useEffect(() => {
|
| 112 |
+
if (!hasUnsavedChanges || isLoadingSettings || isSavingSettings) return undefined;
|
| 113 |
+
|
| 114 |
+
const saveTimer = window.setTimeout(() => {
|
| 115 |
+
saveCompanySettings(settings, { silent: true });
|
| 116 |
+
}, 800);
|
| 117 |
+
|
| 118 |
+
return () => window.clearTimeout(saveTimer);
|
| 119 |
+
}, [hasUnsavedChanges, isLoadingSettings, isSavingSettings, saveCompanySettings, settings]);
|
| 120 |
+
|
| 121 |
return (
|
| 122 |
<div className="max-w-4xl mx-auto py-6 space-y-10 pb-20 animate-in fade-in duration-700">
|
| 123 |
{/* 1. Header Area */}
|
|
|
|
| 130 |
<ShieldCheck size={14} className="text-emerald-500" /> Administrator Account
|
| 131 |
</p>
|
| 132 |
</div>
|
| 133 |
+
<div className="flex flex-col items-start md:items-end gap-2">
|
| 134 |
+
<button
|
| 135 |
+
type="button"
|
| 136 |
+
onClick={handleSaveSettings}
|
| 137 |
+
disabled={!companyId || isLoadingSettings || isSavingSettings || !hasUnsavedChanges}
|
| 138 |
+
className="inline-flex items-center gap-2 rounded-xl bg-slate-900 px-5 py-3 text-xs font-black uppercase tracking-widest text-white shadow-xl shadow-slate-200 transition-all hover:bg-indigo-600 disabled:cursor-not-allowed disabled:bg-slate-300 disabled:shadow-none"
|
| 139 |
+
>
|
| 140 |
+
<Save size={16} />
|
| 141 |
+
{isSavingSettings ? 'Saving...' : 'Save Now'}
|
| 142 |
+
</button>
|
| 143 |
+
{statusMessage && (
|
| 144 |
+
<p className="max-w-xs text-left md:text-right text-[10px] font-bold uppercase tracking-widest text-slate-400">
|
| 145 |
+
{statusMessage}
|
| 146 |
+
</p>
|
| 147 |
+
)}
|
| 148 |
+
</div>
|
| 149 |
</div>
|
| 150 |
|
| 151 |
<div className="space-y-8">
|
|
|
|
| 278 |
<div className={`absolute top-1 w-6 h-6 bg-white rounded-full transition-all duration-300 shadow-md ${settings.adminAlerts ? 'right-1' : 'left-1'}`}></div>
|
| 279 |
</button>
|
| 280 |
</div>
|
| 281 |
+
</CardContent>
|
| 282 |
+
</Card>
|
| 283 |
+
|
| 284 |
+
{/* 5. Webhook Notification Settings */}
|
| 285 |
+
<Card className="border-none shadow-2xl shadow-slate-200/40 rounded-[2rem] overflow-hidden bg-white">
|
| 286 |
+
<div className="px-8 py-6 bg-slate-50 border-b border-slate-100 flex items-center justify-between">
|
| 287 |
+
<h3 className="text-sm font-black text-slate-900 uppercase italic tracking-tight flex items-center gap-3">
|
| 288 |
+
<Bell size={18} className="text-blue-500" /> Slack & Teams Webhooks
|
| 289 |
+
</h3>
|
| 290 |
+
</div>
|
| 291 |
+
<CardContent className="p-8">
|
| 292 |
+
<WebhookSettings />
|
| 293 |
</CardContent>
|
| 294 |
</Card>
|
| 295 |
+
|
| 296 |
</div>
|
| 297 |
</div>
|
| 298 |
);
|
Frontend/src/admin/pages/AdminTicketDetail.jsx
CHANGED
|
@@ -16,6 +16,7 @@ import { formatTicketId } from "../../utils/format";
|
|
| 16 |
import SLABadge from "../components/SLABadge";
|
| 17 |
import { formatFullTimestamp } from "../../utils/dateUtils";
|
| 18 |
import TicketTimeline from "../../user/components/TicketTimeline";
|
|
|
|
| 19 |
|
| 20 |
const AdminTicketDetail = () => {
|
| 21 |
const { ticket_id } = useParams();
|
|
@@ -34,6 +35,7 @@ const AdminTicketDetail = () => {
|
|
| 34 |
const [imageUrl, setImageUrl] = useState(null);
|
| 35 |
const [isUpdating, setIsUpdating] = useState(null);
|
| 36 |
const [isLive, setIsLive] = useState(false);
|
|
|
|
| 37 |
|
| 38 |
const [correctionForm, setCorrectionForm] = useState({
|
| 39 |
category: '',
|
|
@@ -197,6 +199,11 @@ const AdminTicketDetail = () => {
|
|
| 197 |
const displayPriority = ticket.priority || 'Medium';
|
| 198 |
const displaySummary = ticket.summary || ticket.subject || 'No Summary';
|
| 199 |
const displayText = ticket.description || ticket.text || displaySummary;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
|
| 201 |
return (
|
| 202 |
<div style={{ background: '#f8faf9', minHeight: '100vh', paddingBottom: '80px' }} className="-m-6 p-6 md:-m-10 md:p-10 space-y-6 animate-in fade-in duration-700">
|
|
@@ -237,7 +244,14 @@ const AdminTicketDetail = () => {
|
|
| 237 |
<span style={{ fontSize: '10px', fontWeight: 700, color: '#475569', background: '#f1f5f9', padding: '2px 8px', borderRadius: '100px', textTransform: 'uppercase' }}>
|
| 238 |
{ticket.assignee?.full_name || 'UNASSIGNED'}
|
| 239 |
</span>
|
| 240 |
-
<SLABadge
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
</div>
|
| 242 |
</div>
|
| 243 |
</div>
|
|
@@ -305,8 +319,22 @@ const AdminTicketDetail = () => {
|
|
| 305 |
<span style={{ fontSize: '10px', color: '#9ca3af', fontWeight: 600, textTransform: 'uppercase' }}>{formatFullTimestamp(ticket.created_at)}</span>
|
| 306 |
</div>
|
| 307 |
<div style={{ padding: '28px' }}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
<div style={{ background: 'linear-gradient(135deg, #0f1f12, #1a3320)', color: '#ffffff', borderRadius: '16px', padding: '24px 28px', fontSize: '15px', fontStyle: 'italic', lineHeight: 1.7 }}>
|
| 309 |
-
"{
|
| 310 |
</div>
|
| 311 |
|
| 312 |
{imageUrl && (
|
|
@@ -335,6 +363,8 @@ const AdminTicketDetail = () => {
|
|
| 335 |
</div>
|
| 336 |
<TicketTimeline ticket={ticket} />
|
| 337 |
</div>
|
|
|
|
|
|
|
| 338 |
</div>
|
| 339 |
|
| 340 |
{/* AI Column */}
|
|
|
|
| 16 |
import SLABadge from "../components/SLABadge";
|
| 17 |
import { formatFullTimestamp } from "../../utils/dateUtils";
|
| 18 |
import TicketTimeline from "../../user/components/TicketTimeline";
|
| 19 |
+
import TicketAuditTimeline from "../components/TicketAuditTimeline";
|
| 20 |
|
| 21 |
const AdminTicketDetail = () => {
|
| 22 |
const { ticket_id } = useParams();
|
|
|
|
| 35 |
const [imageUrl, setImageUrl] = useState(null);
|
| 36 |
const [isUpdating, setIsUpdating] = useState(null);
|
| 37 |
const [isLive, setIsLive] = useState(false);
|
| 38 |
+
const [showOriginalText, setShowOriginalText] = useState(false);
|
| 39 |
|
| 40 |
const [correctionForm, setCorrectionForm] = useState({
|
| 41 |
category: '',
|
|
|
|
| 199 |
const displayPriority = ticket.priority || 'Medium';
|
| 200 |
const displaySummary = ticket.summary || ticket.subject || 'No Summary';
|
| 201 |
const displayText = ticket.description || ticket.text || displaySummary;
|
| 202 |
+
const translationMeta = ticket.metadata?.translation;
|
| 203 |
+
const originalTextMeta = ticket.metadata?.original_text;
|
| 204 |
+
const isTranslated = Boolean(translationMeta?.translated && originalTextMeta?.description);
|
| 205 |
+
const sourceLanguageName = translationMeta?.source_language_name || translationMeta?.source_language || 'Unknown';
|
| 206 |
+
const renderedText = showOriginalText && isTranslated ? originalTextMeta.description : displayText;
|
| 207 |
|
| 208 |
return (
|
| 209 |
<div style={{ background: '#f8faf9', minHeight: '100vh', paddingBottom: '80px' }} className="-m-6 p-6 md:-m-10 md:p-10 space-y-6 animate-in fade-in duration-700">
|
|
|
|
| 244 |
<span style={{ fontSize: '10px', fontWeight: 700, color: '#475569', background: '#f1f5f9', padding: '2px 8px', borderRadius: '100px', textTransform: 'uppercase' }}>
|
| 245 |
{ticket.assignee?.full_name || 'UNASSIGNED'}
|
| 246 |
</span>
|
| 247 |
+
<SLABadge
|
| 248 |
+
priority={displayPriority}
|
| 249 |
+
createdAt={ticket.created_at}
|
| 250 |
+
slaBreachAt={ticket.sla_breach_at}
|
| 251 |
+
slaStatus={ticket.sla_status}
|
| 252 |
+
status={displayStatus}
|
| 253 |
+
compact
|
| 254 |
+
/>
|
| 255 |
</div>
|
| 256 |
</div>
|
| 257 |
</div>
|
|
|
|
| 319 |
<span style={{ fontSize: '10px', color: '#9ca3af', fontWeight: 600, textTransform: 'uppercase' }}>{formatFullTimestamp(ticket.created_at)}</span>
|
| 320 |
</div>
|
| 321 |
<div style={{ padding: '28px' }}>
|
| 322 |
+
{isTranslated && (
|
| 323 |
+
<div style={{ marginBottom: '16px', border: '1px solid #bae6fd', background: '#f0f9ff', borderRadius: '12px', padding: '10px 12px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px' }}>
|
| 324 |
+
<span style={{ fontSize: '12px', fontWeight: 600, color: '#0c4a6e' }}>
|
| 325 |
+
Translated from {sourceLanguageName}
|
| 326 |
+
</span>
|
| 327 |
+
<button
|
| 328 |
+
type="button"
|
| 329 |
+
onClick={() => setShowOriginalText(prev => !prev)}
|
| 330 |
+
style={{ fontSize: '11px', fontWeight: 700, color: '#0369a1', background: 'transparent', border: 'none', cursor: 'pointer' }}
|
| 331 |
+
>
|
| 332 |
+
{showOriginalText ? 'View English' : 'View Original'}
|
| 333 |
+
</button>
|
| 334 |
+
</div>
|
| 335 |
+
)}
|
| 336 |
<div style={{ background: 'linear-gradient(135deg, #0f1f12, #1a3320)', color: '#ffffff', borderRadius: '16px', padding: '24px 28px', fontSize: '15px', fontStyle: 'italic', lineHeight: 1.7 }}>
|
| 337 |
+
"{renderedText}"
|
| 338 |
</div>
|
| 339 |
|
| 340 |
{imageUrl && (
|
|
|
|
| 363 |
</div>
|
| 364 |
<TicketTimeline ticket={ticket} />
|
| 365 |
</div>
|
| 366 |
+
|
| 367 |
+
<TicketAuditTimeline ticketId={ticket.id} companyId={ticket.company_id} />
|
| 368 |
</div>
|
| 369 |
|
| 370 |
{/* AI Column */}
|
Frontend/src/admin/pages/AdminTickets.jsx
CHANGED
|
@@ -1,8 +1,9 @@
|
|
| 1 |
-
import React, { useState, useMemo, useEffect } from 'react';
|
| 2 |
import { useNavigate, useLocation } from 'react-router-dom';
|
| 3 |
import useAuthStore from "../../store/authStore";
|
| 4 |
import useToastStore from "../../store/toastStore";
|
| 5 |
import { supabase } from "../../lib/supabaseClient";
|
|
|
|
| 6 |
import {
|
| 7 |
Search,
|
| 8 |
Filter,
|
|
@@ -29,7 +30,7 @@ import { formatTimelineDate } from "../../utils/dateUtils";
|
|
| 29 |
const AdminTickets = () => {
|
| 30 |
const navigate = useNavigate();
|
| 31 |
const location = useLocation();
|
| 32 |
-
const { user } = useAuthStore();
|
| 33 |
const { showToast } = useToastStore();
|
| 34 |
|
| 35 |
// Data State
|
|
@@ -46,6 +47,27 @@ const AdminTickets = () => {
|
|
| 46 |
const [teamFilter, setTeamFilter] = useState('All');
|
| 47 |
const [agents, setAgents] = useState([]); // All staff/admins in the company
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
const fetchInitialData = async () => {
|
| 50 |
setLoading(true);
|
| 51 |
try {
|
|
@@ -113,38 +135,6 @@ const AdminTickets = () => {
|
|
| 113 |
|
| 114 |
useEffect(() => {
|
| 115 |
fetchInitialData();
|
| 116 |
-
|
| 117 |
-
// 4. Real-time subscription to ticket changes
|
| 118 |
-
const { profile } = useAuthStore.getState();
|
| 119 |
-
const channel = supabase
|
| 120 |
-
.channel('admin_tickets_realtime')
|
| 121 |
-
.on(
|
| 122 |
-
'postgres_changes',
|
| 123 |
-
{
|
| 124 |
-
event: '*',
|
| 125 |
-
schema: 'public',
|
| 126 |
-
table: 'tickets',
|
| 127 |
-
filter: profile?.company ? `company=eq.${profile.company}` : undefined
|
| 128 |
-
},
|
| 129 |
-
(payload) => {
|
| 130 |
-
console.log("Admin tickets sync event:", payload.eventType, payload.new);
|
| 131 |
-
if (payload.eventType === 'INSERT') {
|
| 132 |
-
setTickets(prev => [payload.new, ...prev]);
|
| 133 |
-
showToast(`New Incident Reported: #${payload.new.id}`, "success");
|
| 134 |
-
// Play a subtle sound or visual cue if needed
|
| 135 |
-
} else if (payload.eventType === 'UPDATE') {
|
| 136 |
-
setTickets(prev => prev.map(t => t.id === payload.new.id ? { ...t, ...payload.new } : t));
|
| 137 |
-
} else if (payload.eventType === 'DELETE') {
|
| 138 |
-
setTickets(prev => prev.filter(t => t.id === payload.old.id));
|
| 139 |
-
}
|
| 140 |
-
}
|
| 141 |
-
)
|
| 142 |
-
.subscribe();
|
| 143 |
-
|
| 144 |
-
return () => {
|
| 145 |
-
supabase.removeChannel(channel);
|
| 146 |
-
};
|
| 147 |
-
|
| 148 |
}, [statusFilter, categoryFilter, priorityFilter, teamFilter]);
|
| 149 |
|
| 150 |
// Seed search from URL
|
|
@@ -305,8 +295,11 @@ const AdminTickets = () => {
|
|
| 305 |
</tr>
|
| 306 |
</thead>
|
| 307 |
<tbody className="divide-y divide-slate-50">
|
| 308 |
-
{filteredTickets.map((ticket) =>
|
| 309 |
-
|
|
|
|
|
|
|
|
|
|
| 310 |
{/* Ticket ID */}
|
| 311 |
<td className="px-6 py-6">
|
| 312 |
<span className="font-mono text-xs font-black text-emerald-600">#{formatTicketId(ticket.id)}</span>
|
|
@@ -347,6 +340,11 @@ const AdminTickets = () => {
|
|
| 347 |
{ticket.category}
|
| 348 |
<span className="text-[9px] font-medium text-slate-300">• {formatTimelineDate(ticket.created_at)}</span>
|
| 349 |
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
</div>
|
| 351 |
</td>
|
| 352 |
|
|
@@ -437,6 +435,8 @@ const AdminTickets = () => {
|
|
| 437 |
<SLABadge
|
| 438 |
priority={ticket.priority}
|
| 439 |
createdAt={ticket.created_at}
|
|
|
|
|
|
|
| 440 |
status={ticket.status}
|
| 441 |
/>
|
| 442 |
</td>
|
|
@@ -454,7 +454,8 @@ const AdminTickets = () => {
|
|
| 454 |
</div>
|
| 455 |
</td>
|
| 456 |
</tr>
|
| 457 |
-
|
|
|
|
| 458 |
</tbody>
|
| 459 |
</table>
|
| 460 |
</div>
|
|
|
|
| 1 |
+
import React, { useCallback, useState, useMemo, useEffect } from 'react';
|
| 2 |
import { useNavigate, useLocation } from 'react-router-dom';
|
| 3 |
import useAuthStore from "../../store/authStore";
|
| 4 |
import useToastStore from "../../store/toastStore";
|
| 5 |
import { supabase } from "../../lib/supabaseClient";
|
| 6 |
+
import useTicketsRealtime from "../../hooks/useTicketsRealtime";
|
| 7 |
import {
|
| 8 |
Search,
|
| 9 |
Filter,
|
|
|
|
| 30 |
const AdminTickets = () => {
|
| 31 |
const navigate = useNavigate();
|
| 32 |
const location = useLocation();
|
| 33 |
+
const { user, profile } = useAuthStore();
|
| 34 |
const { showToast } = useToastStore();
|
| 35 |
|
| 36 |
// Data State
|
|
|
|
| 47 |
const [teamFilter, setTeamFilter] = useState('All');
|
| 48 |
const [agents, setAgents] = useState([]); // All staff/admins in the company
|
| 49 |
|
| 50 |
+
const ticketMatchesFilters = useCallback((ticket) => {
|
| 51 |
+
if (statusFilter !== 'All' && String(ticket.status || '').toLowerCase() !== statusFilter.toLowerCase()) return false;
|
| 52 |
+
if (categoryFilter !== 'All' && ticket.category !== categoryFilter) return false;
|
| 53 |
+
if (priorityFilter !== 'All' && String(ticket.priority || '').toLowerCase() !== priorityFilter.toLowerCase()) return false;
|
| 54 |
+
if (teamFilter !== 'All' && ticket.assigned_team !== teamFilter) return false;
|
| 55 |
+
return true;
|
| 56 |
+
}, [categoryFilter, priorityFilter, statusFilter, teamFilter]);
|
| 57 |
+
|
| 58 |
+
const handleRealtimeInsert = useCallback((ticket) => {
|
| 59 |
+
showToast(`New Incident Reported: #${formatTicketId(ticket.id)}`, "success");
|
| 60 |
+
}, [showToast]);
|
| 61 |
+
|
| 62 |
+
const { lastChangedTicketId } = useTicketsRealtime({
|
| 63 |
+
company: profile?.company,
|
| 64 |
+
enabled: Boolean(profile),
|
| 65 |
+
onTicketsChange: setTickets,
|
| 66 |
+
onInsert: handleRealtimeInsert,
|
| 67 |
+
shouldInclude: ticketMatchesFilters,
|
| 68 |
+
channelName: 'admin_tickets_realtime',
|
| 69 |
+
});
|
| 70 |
+
|
| 71 |
const fetchInitialData = async () => {
|
| 72 |
setLoading(true);
|
| 73 |
try {
|
|
|
|
| 135 |
|
| 136 |
useEffect(() => {
|
| 137 |
fetchInitialData();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
}, [statusFilter, categoryFilter, priorityFilter, teamFilter]);
|
| 139 |
|
| 140 |
// Seed search from URL
|
|
|
|
| 295 |
</tr>
|
| 296 |
</thead>
|
| 297 |
<tbody className="divide-y divide-slate-50">
|
| 298 |
+
{filteredTickets.map((ticket) => {
|
| 299 |
+
const wasLiveChanged = String(lastChangedTicketId) === String(ticket.id);
|
| 300 |
+
|
| 301 |
+
return (
|
| 302 |
+
<tr key={ticket.id} className={`hover:bg-slate-50/50 transition-colors group ${wasLiveChanged ? 'bg-emerald-50/70 ring-1 ring-emerald-100' : ''} ${isUpdating === ticket.id ? 'opacity-50 pointer-events-none' : ''}`}>
|
| 303 |
{/* Ticket ID */}
|
| 304 |
<td className="px-6 py-6">
|
| 305 |
<span className="font-mono text-xs font-black text-emerald-600">#{formatTicketId(ticket.id)}</span>
|
|
|
|
| 340 |
{ticket.category}
|
| 341 |
<span className="text-[9px] font-medium text-slate-300">• {formatTimelineDate(ticket.created_at)}</span>
|
| 342 |
</span>
|
| 343 |
+
{ticket?.metadata?.translation?.translated && (
|
| 344 |
+
<span className="text-[10px] text-sky-700 mt-1">
|
| 345 |
+
Translated from {ticket.metadata.translation.source_language_name || ticket.metadata.translation.source_language || 'Unknown'}
|
| 346 |
+
</span>
|
| 347 |
+
)}
|
| 348 |
</div>
|
| 349 |
</td>
|
| 350 |
|
|
|
|
| 435 |
<SLABadge
|
| 436 |
priority={ticket.priority}
|
| 437 |
createdAt={ticket.created_at}
|
| 438 |
+
slaBreachAt={ticket.sla_breach_at}
|
| 439 |
+
slaStatus={ticket.sla_status}
|
| 440 |
status={ticket.status}
|
| 441 |
/>
|
| 442 |
</td>
|
|
|
|
| 454 |
</div>
|
| 455 |
</td>
|
| 456 |
</tr>
|
| 457 |
+
);
|
| 458 |
+
})}
|
| 459 |
</tbody>
|
| 460 |
</table>
|
| 461 |
</div>
|
Frontend/src/components/shared/BugReportWidget.jsx
CHANGED
|
@@ -23,7 +23,6 @@ function useDiagnostics() {
|
|
| 23 |
const browserInfo = navigator.userAgent;
|
| 24 |
const screenInfo = `${window.innerWidth}x${window.innerHeight}`;
|
| 25 |
|
| 26 |
-
|
| 27 |
setDiagnostics(prev => ({
|
| 28 |
...prev,
|
| 29 |
url: window.location.href,
|
|
@@ -31,16 +30,6 @@ function useDiagnostics() {
|
|
| 31 |
screen: screenInfo
|
| 32 |
}));
|
| 33 |
|
| 34 |
-
// Intercept console.error
|
| 35 |
-
const originalConsoleError = console.error;
|
| 36 |
-
console.error = function (...args) {
|
| 37 |
-
setDiagnostics(prev => ({
|
| 38 |
-
...prev,
|
| 39 |
-
consoleErrors: [...prev.consoleErrors, args.join(' ')].slice(-10) // keep last 10
|
| 40 |
-
}));
|
| 41 |
-
originalConsoleError.apply(console, args);
|
| 42 |
-
};
|
| 43 |
-
|
| 44 |
// Global Error Listener
|
| 45 |
const handleError = (e) => {
|
| 46 |
setDiagnostics(prev => ({
|
|
@@ -51,7 +40,6 @@ function useDiagnostics() {
|
|
| 51 |
window.addEventListener('error', handleError);
|
| 52 |
|
| 53 |
return () => {
|
| 54 |
-
console.error = originalConsoleError;
|
| 55 |
window.removeEventListener('error', handleError);
|
| 56 |
};
|
| 57 |
}, []);
|
|
@@ -417,7 +405,7 @@ const BugReportWidget = ({ advanced = false, customTrigger = null }) => {
|
|
| 417 |
animate={{ opacity: 1, y: 0, scale: 1 }}
|
| 418 |
exit={{ opacity: 0, y: 10, scale: 0.98 }}
|
| 419 |
transition={{ duration: 0.2, ease: "easeOut" }}
|
| 420 |
-
className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl relative z-50 my-auto border border-slate-100 flex flex-col max-h-[90vh]"
|
| 421 |
>
|
| 422 |
{/* Header */}
|
| 423 |
<div className="flex items-center justify-between p-5 border-b border-slate-100 shrink-0">
|
|
@@ -425,7 +413,7 @@ const BugReportWidget = ({ advanced = false, customTrigger = null }) => {
|
|
| 425 |
<div className="p-2 bg-[#13ec80]/10 rounded-lg">
|
| 426 |
<Bug className="w-6 h-6" />
|
| 427 |
</div>
|
| 428 |
-
<h2 className="text-xl font-bold text-slate-800">Report a Bug</h2>
|
| 429 |
</div>
|
| 430 |
<button
|
| 431 |
onClick={handleClose}
|
|
@@ -650,7 +638,7 @@ const BugReportWidget = ({ advanced = false, customTrigger = null }) => {
|
|
| 650 |
</div>
|
| 651 |
|
| 652 |
{/* Footer */}
|
| 653 |
-
<div className="p-5 border-t border-slate-100 bg-slate-50 rounded-b-2xl shrink-0 flex justify-end gap-3">
|
| 654 |
<button
|
| 655 |
type="button"
|
| 656 |
onClick={handleClose}
|
|
|
|
| 23 |
const browserInfo = navigator.userAgent;
|
| 24 |
const screenInfo = `${window.innerWidth}x${window.innerHeight}`;
|
| 25 |
|
|
|
|
| 26 |
setDiagnostics(prev => ({
|
| 27 |
...prev,
|
| 28 |
url: window.location.href,
|
|
|
|
| 30 |
screen: screenInfo
|
| 31 |
}));
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
// Global Error Listener
|
| 34 |
const handleError = (e) => {
|
| 35 |
setDiagnostics(prev => ({
|
|
|
|
| 40 |
window.addEventListener('error', handleError);
|
| 41 |
|
| 42 |
return () => {
|
|
|
|
| 43 |
window.removeEventListener('error', handleError);
|
| 44 |
};
|
| 45 |
}, []);
|
|
|
|
| 405 |
animate={{ opacity: 1, y: 0, scale: 1 }}
|
| 406 |
exit={{ opacity: 0, y: 10, scale: 0.98 }}
|
| 407 |
transition={{ duration: 0.2, ease: "easeOut" }}
|
| 408 |
+
className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-2xl relative z-50 my-auto border border-slate-100 dark:border-gray-700 flex flex-col max-h-[90vh]"
|
| 409 |
>
|
| 410 |
{/* Header */}
|
| 411 |
<div className="flex items-center justify-between p-5 border-b border-slate-100 shrink-0">
|
|
|
|
| 413 |
<div className="p-2 bg-[#13ec80]/10 rounded-lg">
|
| 414 |
<Bug className="w-6 h-6" />
|
| 415 |
</div>
|
| 416 |
+
<h2 className="text-xl font-bold text-slate-800 dark:text-white">Report a Bug</h2>
|
| 417 |
</div>
|
| 418 |
<button
|
| 419 |
onClick={handleClose}
|
|
|
|
| 638 |
</div>
|
| 639 |
|
| 640 |
{/* Footer */}
|
| 641 |
+
<div className="p-5 border-t border-slate-100 dark:border-gray-700 bg-slate-50 dark:bg-gray-900 rounded-b-2xl shrink-0 flex justify-end gap-3">
|
| 642 |
<button
|
| 643 |
type="button"
|
| 644 |
onClick={handleClose}
|
Frontend/src/components/shared/ProtectedRoute.jsx
CHANGED
|
@@ -1,6 +1,5 @@
|
|
| 1 |
-
import React
|
| 2 |
import { Navigate, Outlet } from 'react-router-dom';
|
| 3 |
-
import { supabase } from '../../lib/supabaseClient';
|
| 4 |
import useAuthStore from '../../store/authStore';
|
| 5 |
|
| 6 |
/**
|
|
@@ -9,31 +8,9 @@ import useAuthStore from '../../store/authStore';
|
|
| 9 |
* Redirects to the login page if not authenticated.
|
| 10 |
*/
|
| 11 |
const ProtectedRoute = () => {
|
| 12 |
-
const { user, profile,
|
| 13 |
-
const [isChecking, setIsChecking] = useState(true);
|
| 14 |
|
| 15 |
-
|
| 16 |
-
const checkSession = async () => {
|
| 17 |
-
// Re-verify the session via Supabase as requested
|
| 18 |
-
const { data: { session } } = await supabase.auth.getSession();
|
| 19 |
-
|
| 20 |
-
if (!session) {
|
| 21 |
-
// If no session found, ensure our store also knows there's no user
|
| 22 |
-
setIsChecking(false);
|
| 23 |
-
return;
|
| 24 |
-
}
|
| 25 |
-
|
| 26 |
-
// If session exists, also sync the store if needed
|
| 27 |
-
if (!user) {
|
| 28 |
-
await getCurrentUser();
|
| 29 |
-
}
|
| 30 |
-
setIsChecking(false);
|
| 31 |
-
};
|
| 32 |
-
|
| 33 |
-
checkSession();
|
| 34 |
-
}, [user, getCurrentUser]);
|
| 35 |
-
|
| 36 |
-
if (loading || isChecking) {
|
| 37 |
return (
|
| 38 |
<div className="flex h-screen w-screen items-center justify-center bg-white">
|
| 39 |
<div className="h-12 w-12 animate-spin rounded-full border-4 border-emerald-600 border-t-transparent"></div>
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
import { Navigate, Outlet } from 'react-router-dom';
|
|
|
|
| 3 |
import useAuthStore from '../../store/authStore';
|
| 4 |
|
| 5 |
/**
|
|
|
|
| 8 |
* Redirects to the login page if not authenticated.
|
| 9 |
*/
|
| 10 |
const ProtectedRoute = () => {
|
| 11 |
+
const { user, profile, isCheckingSession } = useAuthStore();
|
|
|
|
| 12 |
|
| 13 |
+
if (isCheckingSession) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
return (
|
| 15 |
<div className="flex h-screen w-screen items-center justify-center bg-white">
|
| 16 |
<div className="h-12 w-12 animate-spin rounded-full border-4 border-emerald-600 border-t-transparent"></div>
|
Frontend/src/components/shared/TicketChat.jsx
CHANGED
|
@@ -1,5 +1,8 @@
|
|
| 1 |
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
| 2 |
-
import {
|
|
|
|
|
|
|
|
|
|
| 3 |
import { supabase } from "../../lib/supabaseClient";
|
| 4 |
import useAuthStore from "../../store/authStore";
|
| 5 |
|
|
@@ -15,6 +18,24 @@ const TicketChat = ({ ticketId, currentUserRole = 'user' }) => {
|
|
| 15 |
const [isInternal, setIsInternal] = useState(false);
|
| 16 |
const [isStaff, setIsStaff] = useState(false);
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
const { user, profile } = useAuthStore();
|
| 19 |
const messagesEndRef = useRef(null);
|
| 20 |
const scrollContainerRef = useRef(null);
|
|
@@ -128,6 +149,9 @@ const TicketChat = ({ ticketId, currentUserRole = 'user' }) => {
|
|
| 128 |
|
| 129 |
return () => {
|
| 130 |
supabase.removeChannel(channel);
|
|
|
|
|
|
|
|
|
|
| 131 |
};
|
| 132 |
|
| 133 |
|
|
@@ -233,6 +257,125 @@ const TicketChat = ({ ticketId, currentUserRole = 'user' }) => {
|
|
| 233 |
} catch { return ''; }
|
| 234 |
};
|
| 235 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
const grouped = [];
|
| 237 |
let lastDate = null;
|
| 238 |
messages.forEach((msg) => {
|
|
@@ -245,7 +388,20 @@ const TicketChat = ({ ticketId, currentUserRole = 'user' }) => {
|
|
| 245 |
});
|
| 246 |
|
| 247 |
return (
|
| 248 |
-
<div style={{ background: '#ffffff', borderRadius: '20px', border: '1px solid #f0fdf4', height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
{/* Header */}
|
| 250 |
<div style={{ padding: '12px 20px', borderBottom: '1px solid #f0fdf4', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
| 251 |
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
@@ -254,16 +410,18 @@ const TicketChat = ({ ticketId, currentUserRole = 'user' }) => {
|
|
| 254 |
</h2>
|
| 255 |
</div>
|
| 256 |
|
| 257 |
-
<div className="flex items-center gap-4">
|
| 258 |
{isStaff && (
|
| 259 |
<div style={{ display: 'flex', alignItems: 'center', background: 'transparent', gap: '4px' }}>
|
| 260 |
<button
|
|
|
|
| 261 |
onClick={() => setIsInternal(false)}
|
| 262 |
style={{ padding: '4px 12px', fontSize: '10px', fontWeight: 700, borderRadius: '8px', cursor: 'pointer', border: 'none', transition: 'all 0.2s', ...( !isInternal ? { background: '#0f1f12', color: '#ffffff' } : { background: 'transparent', color: '#6b7280' } ) }}
|
| 263 |
>
|
| 264 |
PUBLIC
|
| 265 |
</button>
|
| 266 |
<button
|
|
|
|
| 267 |
onClick={() => setIsInternal(true)}
|
| 268 |
style={{ padding: '4px 12px', fontSize: '10px', fontWeight: 700, borderRadius: '8px', cursor: 'pointer', border: 'none', transition: 'all 0.2s', ...( isInternal ? { background: '#0f1f12', color: '#ffffff' } : { background: 'transparent', color: '#6b7280' } ) }}
|
| 269 |
>
|
|
@@ -271,6 +429,26 @@ const TicketChat = ({ ticketId, currentUserRole = 'user' }) => {
|
|
| 271 |
</button>
|
| 272 |
</div>
|
| 273 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
</div>
|
| 275 |
</div>
|
| 276 |
|
|
@@ -304,6 +482,12 @@ const TicketChat = ({ ticketId, currentUserRole = 'user' }) => {
|
|
| 304 |
const msg = item.data;
|
| 305 |
const isMe = msg.sender_id === user?.id;
|
| 306 |
const isAdmin = msg.sender_role === 'admin' || msg.sender_role === 'super_admin';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
|
| 308 |
return (
|
| 309 |
<div key={msg.id || i} className={`flex gap-2.5 ${isMe ? 'justify-end' : 'justify-start'} group py-1`}>
|
|
@@ -322,6 +506,15 @@ const TicketChat = ({ ticketId, currentUserRole = 'user' }) => {
|
|
| 322 |
<span className="text-[8px] font-bold text-slate-300">
|
| 323 |
{formatTime(msg.created_at)}
|
| 324 |
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
</div>
|
| 326 |
|
| 327 |
<div style={{
|
|
@@ -334,7 +527,47 @@ const TicketChat = ({ ticketId, currentUserRole = 'user' }) => {
|
|
| 334 |
background: '#0f1f12', color: '#ffffff', borderRadius: '14px'
|
| 335 |
})
|
| 336 |
}}>
|
| 337 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
</div>
|
| 339 |
</div>
|
| 340 |
|
|
@@ -358,27 +591,111 @@ const TicketChat = ({ ticketId, currentUserRole = 'user' }) => {
|
|
| 358 |
|
| 359 |
{/* Input */}
|
| 360 |
<div style={{ padding: '16px 20px', borderTop: '1px solid #f0fdf4', background: '#ffffff' }}>
|
| 361 |
-
|
| 362 |
-
<
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
</div>
|
| 383 |
);
|
| 384 |
};
|
|
|
|
| 1 |
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
| 2 |
+
import {
|
| 3 |
+
Send, User, ShieldCheck, Bot, MessageSquare, Circle, Loader2,
|
| 4 |
+
Phone, Video, Mic, Smile, Paperclip, Play, Pause, X, Check, CheckCheck, MicOff, Volume2, Shield
|
| 5 |
+
} from 'lucide-react';
|
| 6 |
import { supabase } from "../../lib/supabaseClient";
|
| 7 |
import useAuthStore from "../../store/authStore";
|
| 8 |
|
|
|
|
| 18 |
const [isInternal, setIsInternal] = useState(false);
|
| 19 |
const [isStaff, setIsStaff] = useState(false);
|
| 20 |
|
| 21 |
+
// Simulated calling states
|
| 22 |
+
const [activeCall, setActiveCall] = useState(null); // 'Audio' | 'Video' | null
|
| 23 |
+
const [callDuration, setCallDuration] = useState(0);
|
| 24 |
+
const [callStatus, setCallStatus] = useState('Ringing...'); // 'Ringing...' | 'Connected'
|
| 25 |
+
const callTimerRef = useRef(null);
|
| 26 |
+
const [isMuted, setIsMuted] = useState(false);
|
| 27 |
+
const [isSpeakerOn, setIsSpeakerOn] = useState(true);
|
| 28 |
+
|
| 29 |
+
// Voice note simulation states
|
| 30 |
+
const [isRecording, setIsRecording] = useState(false);
|
| 31 |
+
const [recordDuration, setRecordDuration] = useState(0);
|
| 32 |
+
const recordTimerRef = useRef(null);
|
| 33 |
+
|
| 34 |
+
// Voice playback states
|
| 35 |
+
const [playingVoiceId, setPlayingVoiceId] = useState(null);
|
| 36 |
+
const [voiceProgress, setVoiceProgress] = useState({});
|
| 37 |
+
const voicePlayTimerRef = useRef(null);
|
| 38 |
+
|
| 39 |
const { user, profile } = useAuthStore();
|
| 40 |
const messagesEndRef = useRef(null);
|
| 41 |
const scrollContainerRef = useRef(null);
|
|
|
|
| 149 |
|
| 150 |
return () => {
|
| 151 |
supabase.removeChannel(channel);
|
| 152 |
+
if (callTimerRef.current) clearInterval(callTimerRef.current);
|
| 153 |
+
if (recordTimerRef.current) clearInterval(recordTimerRef.current);
|
| 154 |
+
if (voicePlayTimerRef.current) clearInterval(voicePlayTimerRef.current);
|
| 155 |
};
|
| 156 |
|
| 157 |
|
|
|
|
| 257 |
} catch { return ''; }
|
| 258 |
};
|
| 259 |
|
| 260 |
+
// Voice note simulation helpers
|
| 261 |
+
const startRecording = () => {
|
| 262 |
+
setIsRecording(true);
|
| 263 |
+
setRecordDuration(0);
|
| 264 |
+
recordTimerRef.current = setInterval(() => {
|
| 265 |
+
setRecordDuration(prev => prev + 1);
|
| 266 |
+
}, 1000);
|
| 267 |
+
};
|
| 268 |
+
|
| 269 |
+
const stopRecording = async (shouldSave = true) => {
|
| 270 |
+
setIsRecording(false);
|
| 271 |
+
if (recordTimerRef.current) clearInterval(recordTimerRef.current);
|
| 272 |
+
if (shouldSave && recordDuration > 0) {
|
| 273 |
+
const durationStr = formatDuration(recordDuration);
|
| 274 |
+
const content = `🎤 Voice message (${durationStr})`;
|
| 275 |
+
const tempMessage = {
|
| 276 |
+
id: `temp-${Date.now()}`,
|
| 277 |
+
ticket_id: ticketId,
|
| 278 |
+
sender_id: user.id,
|
| 279 |
+
sender_name: profile?.full_name || user.email,
|
| 280 |
+
sender_role: profile?.role || 'user',
|
| 281 |
+
message: content,
|
| 282 |
+
is_internal: false,
|
| 283 |
+
created_at: new Date().toISOString()
|
| 284 |
+
};
|
| 285 |
+
|
| 286 |
+
setMessages(prev => [...prev, tempMessage]);
|
| 287 |
+
setTimeout(() => scrollToBottom(), 50);
|
| 288 |
+
|
| 289 |
+
try {
|
| 290 |
+
const { error } = await supabase
|
| 291 |
+
.from('ticket_messages')
|
| 292 |
+
.insert([{
|
| 293 |
+
ticket_id: ticketId,
|
| 294 |
+
sender_id: user.id,
|
| 295 |
+
sender_name: profile?.full_name || user.email,
|
| 296 |
+
sender_role: profile?.role || 'user',
|
| 297 |
+
message: content
|
| 298 |
+
}]);
|
| 299 |
+
if (error) throw error;
|
| 300 |
+
} catch (err) {
|
| 301 |
+
console.error("Error sending voice message:", err);
|
| 302 |
+
setMessages(prev => prev.filter(m => m.id !== tempMessage.id));
|
| 303 |
+
}
|
| 304 |
+
}
|
| 305 |
+
};
|
| 306 |
+
|
| 307 |
+
const playVoiceNote = (msgId, durationStr) => {
|
| 308 |
+
if (playingVoiceId === msgId) {
|
| 309 |
+
setPlayingVoiceId(null);
|
| 310 |
+
if (voicePlayTimerRef.current) clearInterval(voicePlayTimerRef.current);
|
| 311 |
+
return;
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
if (voicePlayTimerRef.current) clearInterval(voicePlayTimerRef.current);
|
| 315 |
+
setPlayingVoiceId(msgId);
|
| 316 |
+
|
| 317 |
+
const parts = durationStr.split(':');
|
| 318 |
+
const totalSecs = parseInt(parts[0] || '0') * 60 + parseInt(parts[1] || '0');
|
| 319 |
+
let currentSecs = voiceProgress[msgId] ? (voiceProgress[msgId] / 100) * totalSecs : 0;
|
| 320 |
+
|
| 321 |
+
voicePlayTimerRef.current = setInterval(() => {
|
| 322 |
+
currentSecs += 0.25;
|
| 323 |
+
const percentage = Math.min((currentSecs / totalSecs) * 100, 100);
|
| 324 |
+
|
| 325 |
+
setVoiceProgress(prev => ({
|
| 326 |
+
...prev,
|
| 327 |
+
[msgId]: percentage
|
| 328 |
+
}));
|
| 329 |
+
|
| 330 |
+
if (percentage >= 100) {
|
| 331 |
+
setPlayingVoiceId(null);
|
| 332 |
+
clearInterval(voicePlayTimerRef.current);
|
| 333 |
+
setVoiceProgress(prev => ({
|
| 334 |
+
...prev,
|
| 335 |
+
[msgId]: 0
|
| 336 |
+
}));
|
| 337 |
+
}
|
| 338 |
+
}, 250);
|
| 339 |
+
};
|
| 340 |
+
|
| 341 |
+
const handleCallPress = (type) => {
|
| 342 |
+
setActiveCall(type);
|
| 343 |
+
setCallStatus('Ringing...');
|
| 344 |
+
setCallDuration(0);
|
| 345 |
+
setIsMuted(false);
|
| 346 |
+
setIsSpeakerOn(true);
|
| 347 |
+
|
| 348 |
+
setTimeout(() => {
|
| 349 |
+
setCallStatus('Connected');
|
| 350 |
+
callTimerRef.current = setInterval(() => {
|
| 351 |
+
setCallDuration(prev => prev + 1);
|
| 352 |
+
}, 1000);
|
| 353 |
+
}, 2000);
|
| 354 |
+
};
|
| 355 |
+
|
| 356 |
+
const endCall = () => {
|
| 357 |
+
setActiveCall(null);
|
| 358 |
+
if (callTimerRef.current) clearInterval(callTimerRef.current);
|
| 359 |
+
};
|
| 360 |
+
|
| 361 |
+
const formatDuration = (sec) => {
|
| 362 |
+
const mins = Math.floor(sec / 60);
|
| 363 |
+
const secs = sec % 60;
|
| 364 |
+
return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
|
| 365 |
+
};
|
| 366 |
+
|
| 367 |
+
const getTickColor = (msg) => {
|
| 368 |
+
if (String(msg.id).startsWith('temp-')) return '#9ca3af'; // sending / unsaved
|
| 369 |
+
// Double blue ticks if any admin/AI replied after this message
|
| 370 |
+
const hasAdminReply = messages.some(
|
| 371 |
+
m => m.created_at &&
|
| 372 |
+
new Date(m.created_at) > new Date(msg.created_at) &&
|
| 373 |
+
(m.sender_role === 'admin' || m.sender_role === 'super_admin' || m.sender_role === 'ai')
|
| 374 |
+
);
|
| 375 |
+
if (hasAdminReply) return '#38bdf8'; // WhatsApp active blue ticks!
|
| 376 |
+
return '#9ca3af'; // Grey double-ticks!
|
| 377 |
+
};
|
| 378 |
+
|
| 379 |
const grouped = [];
|
| 380 |
let lastDate = null;
|
| 381 |
messages.forEach((msg) => {
|
|
|
|
| 388 |
});
|
| 389 |
|
| 390 |
return (
|
| 391 |
+
<div style={{ background: '#ffffff', borderRadius: '20px', border: '1px solid #f0fdf4', height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden', position: 'relative' }}>
|
| 392 |
+
|
| 393 |
+
{/* Pulsing Dot Styles */}
|
| 394 |
+
<style>{`
|
| 395 |
+
@keyframes pulse {
|
| 396 |
+
0% { opacity: 0.3; }
|
| 397 |
+
50% { opacity: 1; }
|
| 398 |
+
100% { opacity: 0.3; }
|
| 399 |
+
}
|
| 400 |
+
.pulse-dot {
|
| 401 |
+
animation: pulse 1.2s infinite;
|
| 402 |
+
}
|
| 403 |
+
`}</style>
|
| 404 |
+
|
| 405 |
{/* Header */}
|
| 406 |
<div style={{ padding: '12px 20px', borderBottom: '1px solid #f0fdf4', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
| 407 |
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
|
|
| 410 |
</h2>
|
| 411 |
</div>
|
| 412 |
|
| 413 |
+
<div className="flex items-center gap-4" style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
| 414 |
{isStaff && (
|
| 415 |
<div style={{ display: 'flex', alignItems: 'center', background: 'transparent', gap: '4px' }}>
|
| 416 |
<button
|
| 417 |
+
type="button"
|
| 418 |
onClick={() => setIsInternal(false)}
|
| 419 |
style={{ padding: '4px 12px', fontSize: '10px', fontWeight: 700, borderRadius: '8px', cursor: 'pointer', border: 'none', transition: 'all 0.2s', ...( !isInternal ? { background: '#0f1f12', color: '#ffffff' } : { background: 'transparent', color: '#6b7280' } ) }}
|
| 420 |
>
|
| 421 |
PUBLIC
|
| 422 |
</button>
|
| 423 |
<button
|
| 424 |
+
type="button"
|
| 425 |
onClick={() => setIsInternal(true)}
|
| 426 |
style={{ padding: '4px 12px', fontSize: '10px', fontWeight: 700, borderRadius: '8px', cursor: 'pointer', border: 'none', transition: 'all 0.2s', ...( isInternal ? { background: '#0f1f12', color: '#ffffff' } : { background: 'transparent', color: '#6b7280' } ) }}
|
| 427 |
>
|
|
|
|
| 429 |
</button>
|
| 430 |
</div>
|
| 431 |
)}
|
| 432 |
+
|
| 433 |
+
{/* WhatsApp Call Header Buttons */}
|
| 434 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
| 435 |
+
<button
|
| 436 |
+
type="button"
|
| 437 |
+
onClick={() => handleCallPress('Audio')}
|
| 438 |
+
style={{ background: 'transparent', border: 'none', cursor: 'pointer', padding: '6px', color: '#374151' }}
|
| 439 |
+
className="hover:text-emerald-600 transition-colors"
|
| 440 |
+
>
|
| 441 |
+
<Phone size={18} />
|
| 442 |
+
</button>
|
| 443 |
+
<button
|
| 444 |
+
type="button"
|
| 445 |
+
onClick={() => handleCallPress('Video')}
|
| 446 |
+
style={{ background: 'transparent', border: 'none', cursor: 'pointer', padding: '6px', color: '#374151' }}
|
| 447 |
+
className="hover:text-emerald-600 transition-colors"
|
| 448 |
+
>
|
| 449 |
+
<Video size={18} />
|
| 450 |
+
</button>
|
| 451 |
+
</div>
|
| 452 |
</div>
|
| 453 |
</div>
|
| 454 |
|
|
|
|
| 482 |
const msg = item.data;
|
| 483 |
const isMe = msg.sender_id === user?.id;
|
| 484 |
const isAdmin = msg.sender_role === 'admin' || msg.sender_role === 'super_admin';
|
| 485 |
+
const isVoiceNote = msg.message && msg.message.startsWith('🎤 Voice message');
|
| 486 |
+
let voiceDuration = "0:00";
|
| 487 |
+
if (isVoiceNote) {
|
| 488 |
+
const match = msg.message.match(/\((.*?)\)/);
|
| 489 |
+
if (match) voiceDuration = match[1];
|
| 490 |
+
}
|
| 491 |
|
| 492 |
return (
|
| 493 |
<div key={msg.id || i} className={`flex gap-2.5 ${isMe ? 'justify-end' : 'justify-start'} group py-1`}>
|
|
|
|
| 506 |
<span className="text-[8px] font-bold text-slate-300">
|
| 507 |
{formatTime(msg.created_at)}
|
| 508 |
</span>
|
| 509 |
+
{isMe && (
|
| 510 |
+
<span style={{ marginLeft: '4px' }}>
|
| 511 |
+
{String(msg.id).startsWith('temp-') ? (
|
| 512 |
+
<Check size={11} className="text-slate-300" />
|
| 513 |
+
) : (
|
| 514 |
+
<CheckCheck size={11} style={{ color: getTickColor(msg) }} />
|
| 515 |
+
)}
|
| 516 |
+
</span>
|
| 517 |
+
)}
|
| 518 |
</div>
|
| 519 |
|
| 520 |
<div style={{
|
|
|
|
| 527 |
background: '#0f1f12', color: '#ffffff', borderRadius: '14px'
|
| 528 |
})
|
| 529 |
}}>
|
| 530 |
+
{isVoiceNote ? (
|
| 531 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', width: '220px' }}>
|
| 532 |
+
<button
|
| 533 |
+
type="button"
|
| 534 |
+
onClick={() => playVoiceNote(msg.id, voiceDuration)}
|
| 535 |
+
style={{ width: '32px', height: '32px', borderRadius: '50%', border: 'none', background: 'rgba(255,255,255,0.15)', color: isMe ? '#ffffff' : '#0f1f12', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}
|
| 536 |
+
>
|
| 537 |
+
{playingVoiceId === msg.id ? <Pause size={14} fill="currentColor" /> : <Play size={14} fill="currentColor" style={{ marginLeft: '2px' }} />}
|
| 538 |
+
</button>
|
| 539 |
+
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
| 540 |
+
{/* Waveform bars */}
|
| 541 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '3px', height: '18px' }}>
|
| 542 |
+
{[8, 14, 18, 10, 6, 12, 16, 10, 14, 20, 12, 8, 14, 18, 10, 8, 12, 6, 14, 8].map((barHeight, idx) => {
|
| 543 |
+
const barProgress = (idx / 20) * 100;
|
| 544 |
+
const isPlayed = (voiceProgress[msg.id] || 0) >= barProgress;
|
| 545 |
+
return (
|
| 546 |
+
<div
|
| 547 |
+
key={idx}
|
| 548 |
+
style={{
|
| 549 |
+
width: '2px',
|
| 550 |
+
borderRadius: '1px',
|
| 551 |
+
height: `${barHeight}px`,
|
| 552 |
+
background: isPlayed
|
| 553 |
+
? (isMe ? '#34d399' : '#0f1f12')
|
| 554 |
+
: (isMe ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.15)')
|
| 555 |
+
}}
|
| 556 |
+
/>
|
| 557 |
+
);
|
| 558 |
+
})}
|
| 559 |
+
</div>
|
| 560 |
+
<span style={{ fontSize: '9px', fontWeight: 600, opacity: 0.8 }}>
|
| 561 |
+
{playingVoiceId === msg.id
|
| 562 |
+
? formatDuration(Math.round(((voiceProgress[msg.id] || 0) / 100) * (parseInt(voiceDuration.split(':')[0] || '0') * 60 + parseInt(voiceDuration.split(':')[1] || '0'))))
|
| 563 |
+
: voiceDuration
|
| 564 |
+
}
|
| 565 |
+
</span>
|
| 566 |
+
</div>
|
| 567 |
+
</div>
|
| 568 |
+
) : (
|
| 569 |
+
msg.message
|
| 570 |
+
)}
|
| 571 |
</div>
|
| 572 |
</div>
|
| 573 |
|
|
|
|
| 591 |
|
| 592 |
{/* Input */}
|
| 593 |
<div style={{ padding: '16px 20px', borderTop: '1px solid #f0fdf4', background: '#ffffff' }}>
|
| 594 |
+
{isRecording ? (
|
| 595 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', background: '#f9fafb', border: '1.5px dashed #f87171', borderRadius: '12px', padding: '10px 16px', fontSize: '13px' }}>
|
| 596 |
+
<div className="pulse-dot" style={{ width: '8px', height: '8px', borderRadius: '50%', background: '#ef4444' }} />
|
| 597 |
+
<span style={{ flex: 1, color: '#ef4444', fontWeight: 700 }}>Recording {formatDuration(recordDuration)}</span>
|
| 598 |
+
<button
|
| 599 |
+
type="button"
|
| 600 |
+
onClick={() => stopRecording(false)}
|
| 601 |
+
style={{ background: 'transparent', border: 'none', color: '#9ca3af', cursor: 'pointer', fontSize: '11px', fontWeight: 700 }}
|
| 602 |
+
>
|
| 603 |
+
Cancel
|
| 604 |
+
</button>
|
| 605 |
+
<button
|
| 606 |
+
type="button"
|
| 607 |
+
onClick={() => stopRecording(true)}
|
| 608 |
+
style={{ background: '#ef4444', border: 'none', color: '#ffffff', borderRadius: '8px', padding: '6px 12px', cursor: 'pointer', fontSize: '11px', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '4px' }}
|
| 609 |
+
>
|
| 610 |
+
<Send size={10} />
|
| 611 |
+
Send
|
| 612 |
+
</button>
|
| 613 |
+
</div>
|
| 614 |
+
) : (
|
| 615 |
+
<form onSubmit={handleSend} className="flex gap-3">
|
| 616 |
+
<input
|
| 617 |
+
ref={inputRef}
|
| 618 |
+
type="text"
|
| 619 |
+
value={inputValue}
|
| 620 |
+
onChange={handleInputChange}
|
| 621 |
+
placeholder="Type your message..."
|
| 622 |
+
style={{ flex: 1, background: '#f9fafb', border: '1.5px solid #e5e7eb', borderRadius: '12px', padding: '10px 16px', fontSize: '13px', outline: 'none' }}
|
| 623 |
+
className="focus:border-emerald-500 transition-colors"
|
| 624 |
+
/>
|
| 625 |
+
<button
|
| 626 |
+
type="button"
|
| 627 |
+
onClick={startRecording}
|
| 628 |
+
style={{ padding: '10px', background: 'rgba(0,0,0,0.05)', color: '#374151', borderRadius: '10px', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
| 629 |
+
className="active:scale-95 transition-transform"
|
| 630 |
+
>
|
| 631 |
+
<Mic size={16} />
|
| 632 |
+
</button>
|
| 633 |
+
<button
|
| 634 |
+
type="submit"
|
| 635 |
+
disabled={!inputValue.trim() || !user}
|
| 636 |
+
style={{ padding: '10px 20px', background: '#16a34a', color: '#ffffff', borderRadius: '10px', fontWeight: 600, fontSize: '12px', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px' }}
|
| 637 |
+
className="active:scale-95 transition-transform disabled:opacity-50"
|
| 638 |
+
>
|
| 639 |
+
{isInternal ? <ShieldCheck size={14} /> : <Send size={14} />}
|
| 640 |
+
<span className="hidden sm:inline">{isInternal ? 'Note' : 'Send'}</span>
|
| 641 |
+
</button>
|
| 642 |
+
</form>
|
| 643 |
+
)}
|
| 644 |
</div>
|
| 645 |
+
|
| 646 |
+
{/* High-Fidelity Call Overlay Modal */}
|
| 647 |
+
{activeCall && (
|
| 648 |
+
<div style={{ position: 'absolute', top: 0, bottom: 0, left: 0, right: 0, background: '#0b132b', color: '#ffffff', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: '40px 20px', zIndex: 99999 }}>
|
| 649 |
+
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '6px', opacity: 0.6 }}>
|
| 650 |
+
<Shield size={12} className="text-emerald-500" />
|
| 651 |
+
<span style={{ fontSize: '9px', fontWeight: 800, letterSpacing: '0.1em' }}>SECURE END-TO-END ENCRYPTED</span>
|
| 652 |
+
</div>
|
| 653 |
+
|
| 654 |
+
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '14px' }}>
|
| 655 |
+
<div style={{ width: '90px', height: '90px', borderRadius: '50%', background: '#16a34a', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '32px', fontWeight: 900, boxShadow: '0 10px 25px rgba(22,163,74,0.3)' }}>
|
| 656 |
+
{(ticketId || 'A')[0].toUpperCase()}
|
| 657 |
+
</div>
|
| 658 |
+
<h3 style={{ fontSize: '20px', fontWeight: 800, margin: 0 }}>Support Desk Call</h3>
|
| 659 |
+
<span style={{ fontSize: '13px', opacity: 0.7 }}>
|
| 660 |
+
{callStatus === 'Connected' ? formatDuration(callDuration) : callStatus}
|
| 661 |
+
</span>
|
| 662 |
+
</div>
|
| 663 |
+
|
| 664 |
+
{/* Web Video call screen camera mock */}
|
| 665 |
+
{activeCall === 'Video' && (
|
| 666 |
+
<div style={{ width: '100%', height: '140px', background: 'rgba(255,255,255,0.03)', borderRadius: '16px', border: '1px solid rgba(255,255,255,0.08)', position: 'relative', overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
| 667 |
+
<div style={{ width: '40px', height: '60px', borderRadius: '8px', background: '#1d2d44', border: '1px solid rgba(255,255,255,0.1)', position: 'absolute', bottom: '12px', right: '12px' }} />
|
| 668 |
+
<span style={{ fontSize: '10px', opacity: 0.4 }}>Camera active stream</span>
|
| 669 |
+
</div>
|
| 670 |
+
)}
|
| 671 |
+
|
| 672 |
+
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '20px' }}>
|
| 673 |
+
<button
|
| 674 |
+
type="button"
|
| 675 |
+
onClick={() => setIsMuted(!isMuted)}
|
| 676 |
+
style={{ width: '48px', height: '48px', borderRadius: '50%', border: 'none', background: isMuted ? '#ffffff' : 'rgba(255,255,255,0.08)', color: isMuted ? '#0b132b' : '#ffffff', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
| 677 |
+
>
|
| 678 |
+
{isMuted ? <MicOff size={18} /> : <Mic size={18} />}
|
| 679 |
+
</button>
|
| 680 |
+
|
| 681 |
+
<button
|
| 682 |
+
type="button"
|
| 683 |
+
onClick={endCall}
|
| 684 |
+
style={{ width: '48px', height: '48px', borderRadius: '50%', border: 'none', background: '#ef4444', color: '#ffffff', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
| 685 |
+
>
|
| 686 |
+
<Phone size={18} style={{ transform: 'rotate(135deg)' }} />
|
| 687 |
+
</button>
|
| 688 |
+
|
| 689 |
+
<button
|
| 690 |
+
type="button"
|
| 691 |
+
onClick={() => setIsSpeakerOn(!isSpeakerOn)}
|
| 692 |
+
style={{ width: '48px', height: '48px', borderRadius: '50%', border: 'none', background: isSpeakerOn ? '#ffffff' : 'rgba(255,255,255,0.08)', color: isSpeakerOn ? '#16a34a' : '#ffffff', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
| 693 |
+
>
|
| 694 |
+
<Volume2 size={18} />
|
| 695 |
+
</button>
|
| 696 |
+
</div>
|
| 697 |
+
</div>
|
| 698 |
+
)}
|
| 699 |
</div>
|
| 700 |
);
|
| 701 |
};
|
Frontend/src/components/shared/WebhookSettings.jsx
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from "react";
|
| 2 |
+
import { supabase } from "../../lib/supabaseClient";
|
| 3 |
+
import useAuthStore from "../../store/authStore";
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* WebhookSettings — Admin component to configure Slack/Teams webhook URL.
|
| 7 |
+
* Stores webhook URL per company tenant in Supabase.
|
| 8 |
+
*/
|
| 9 |
+
const WebhookSettings = () => {
|
| 10 |
+
const { profile } = useAuthStore();
|
| 11 |
+
const [webhookUrl, setWebhookUrl] = useState("");
|
| 12 |
+
const [isEnabled, setIsEnabled] = useState(false);
|
| 13 |
+
const [loading, setLoading] = useState(false);
|
| 14 |
+
const [fetching, setFetching] = useState(true);
|
| 15 |
+
const [message, setMessage] = useState({ text: "", type: "" });
|
| 16 |
+
|
| 17 |
+
const companyId = profile?.company_id;
|
| 18 |
+
|
| 19 |
+
useEffect(() => {
|
| 20 |
+
const fetchSettings = async () => {
|
| 21 |
+
if (!companyId) return;
|
| 22 |
+
setFetching(true);
|
| 23 |
+
try {
|
| 24 |
+
const { data, error } = await supabase
|
| 25 |
+
.from("webhook_settings")
|
| 26 |
+
.select("webhook_url, is_enabled")
|
| 27 |
+
.eq("company_id", companyId)
|
| 28 |
+
.single();
|
| 29 |
+
|
| 30 |
+
if (data) {
|
| 31 |
+
setWebhookUrl(data.webhook_url || "");
|
| 32 |
+
setIsEnabled(data.is_enabled || false);
|
| 33 |
+
}
|
| 34 |
+
} catch (err) {
|
| 35 |
+
console.log("No webhook settings found — first time setup");
|
| 36 |
+
} finally {
|
| 37 |
+
setFetching(false);
|
| 38 |
+
}
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
fetchSettings();
|
| 42 |
+
}, [companyId]);
|
| 43 |
+
|
| 44 |
+
const detectPlatform = (url) => {
|
| 45 |
+
if (url.includes("hooks.slack.com")) return "Slack";
|
| 46 |
+
if (url.includes("webhook.office.com") || url.includes("outlook.office.com"))
|
| 47 |
+
return "Microsoft Teams";
|
| 48 |
+
return null;
|
| 49 |
+
};
|
| 50 |
+
|
| 51 |
+
const validateUrl = (url) => {
|
| 52 |
+
if (!url) return false;
|
| 53 |
+
return url.startsWith("https://") && detectPlatform(url) !== null;
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
const handleSave = async () => {
|
| 57 |
+
if (!validateUrl(webhookUrl)) {
|
| 58 |
+
setMessage({
|
| 59 |
+
text: "❌ Please enter a valid Slack or Microsoft Teams webhook URL.",
|
| 60 |
+
type: "error",
|
| 61 |
+
});
|
| 62 |
+
return;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
setLoading(true);
|
| 66 |
+
setMessage({ text: "", type: "" });
|
| 67 |
+
|
| 68 |
+
try {
|
| 69 |
+
const { error } = await supabase.from("webhook_settings").upsert(
|
| 70 |
+
{
|
| 71 |
+
company_id: companyId,
|
| 72 |
+
webhook_url: webhookUrl,
|
| 73 |
+
is_enabled: isEnabled,
|
| 74 |
+
updated_at: new Date().toISOString(),
|
| 75 |
+
},
|
| 76 |
+
{ onConflict: "company_id" }
|
| 77 |
+
);
|
| 78 |
+
|
| 79 |
+
if (error) throw error;
|
| 80 |
+
|
| 81 |
+
setMessage({
|
| 82 |
+
text: `✅ Webhook saved successfully for ${detectPlatform(webhookUrl)}!`,
|
| 83 |
+
type: "success",
|
| 84 |
+
});
|
| 85 |
+
} catch (err) {
|
| 86 |
+
setMessage({
|
| 87 |
+
text: `❌ Failed to save: ${err.message}`,
|
| 88 |
+
type: "error",
|
| 89 |
+
});
|
| 90 |
+
} finally {
|
| 91 |
+
setLoading(false);
|
| 92 |
+
}
|
| 93 |
+
};
|
| 94 |
+
|
| 95 |
+
const handleToggle = async () => {
|
| 96 |
+
const newValue = !isEnabled;
|
| 97 |
+
setIsEnabled(newValue);
|
| 98 |
+
|
| 99 |
+
if (webhookUrl) {
|
| 100 |
+
await supabase.from("webhook_settings").upsert(
|
| 101 |
+
{
|
| 102 |
+
company_id: companyId,
|
| 103 |
+
webhook_url: webhookUrl,
|
| 104 |
+
is_enabled: newValue,
|
| 105 |
+
updated_at: new Date().toISOString(),
|
| 106 |
+
},
|
| 107 |
+
{ onConflict: "company_id" }
|
| 108 |
+
);
|
| 109 |
+
}
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
if (fetching) {
|
| 113 |
+
return (
|
| 114 |
+
<div className="flex items-center justify-center p-8">
|
| 115 |
+
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
|
| 116 |
+
</div>
|
| 117 |
+
);
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
const platform = detectPlatform(webhookUrl);
|
| 121 |
+
|
| 122 |
+
return (
|
| 123 |
+
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 max-w-2xl">
|
| 124 |
+
<div className="flex items-center gap-3 mb-6">
|
| 125 |
+
<div className="bg-blue-600 p-2 rounded-lg">
|
| 126 |
+
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 127 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
| 128 |
+
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
| 129 |
+
</svg>
|
| 130 |
+
</div>
|
| 131 |
+
<div>
|
| 132 |
+
<h3 className="text-white font-semibold text-lg">Webhook Notifications</h3>
|
| 133 |
+
<p className="text-gray-400 text-sm">Get critical ticket alerts in Slack or Microsoft Teams</p>
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
|
| 137 |
+
{/* Webhook URL Input */}
|
| 138 |
+
<div className="mb-4">
|
| 139 |
+
<label className="block text-gray-300 text-sm font-medium mb-2">
|
| 140 |
+
Webhook URL
|
| 141 |
+
</label>
|
| 142 |
+
<input
|
| 143 |
+
type="url"
|
| 144 |
+
value={webhookUrl}
|
| 145 |
+
onChange={(e) => {
|
| 146 |
+
setWebhookUrl(e.target.value);
|
| 147 |
+
setMessage({ text: "", type: "" });
|
| 148 |
+
}}
|
| 149 |
+
placeholder="https://hooks.slack.com/... or https://outlook.office.com/webhook/..."
|
| 150 |
+
className="w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-3 text-white text-sm placeholder-gray-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-colors"
|
| 151 |
+
/>
|
| 152 |
+
{/* Platform Detection Badge */}
|
| 153 |
+
{platform && (
|
| 154 |
+
<div className="mt-2 flex items-center gap-2">
|
| 155 |
+
<span className={`text-xs px-2 py-1 rounded-full font-medium ${
|
| 156 |
+
platform === "Slack"
|
| 157 |
+
? "bg-green-900 text-green-300"
|
| 158 |
+
: "bg-blue-900 text-blue-300"
|
| 159 |
+
}`}>
|
| 160 |
+
✓ {platform} webhook detected
|
| 161 |
+
</span>
|
| 162 |
+
</div>
|
| 163 |
+
)}
|
| 164 |
+
</div>
|
| 165 |
+
|
| 166 |
+
{/* Toggle */}
|
| 167 |
+
<div className="flex items-center justify-between mb-6 p-4 bg-gray-800 rounded-lg">
|
| 168 |
+
<div>
|
| 169 |
+
<p className="text-white text-sm font-medium">Enable Notifications</p>
|
| 170 |
+
<p className="text-gray-400 text-xs">
|
| 171 |
+
Send alerts for Critical/High priority tickets
|
| 172 |
+
</p>
|
| 173 |
+
</div>
|
| 174 |
+
<button
|
| 175 |
+
onClick={handleToggle}
|
| 176 |
+
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${
|
| 177 |
+
isEnabled ? "bg-blue-600" : "bg-gray-600"
|
| 178 |
+
}`}
|
| 179 |
+
>
|
| 180 |
+
<span
|
| 181 |
+
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
| 182 |
+
isEnabled ? "translate-x-6" : "translate-x-1"
|
| 183 |
+
}`}
|
| 184 |
+
/>
|
| 185 |
+
</button>
|
| 186 |
+
</div>
|
| 187 |
+
|
| 188 |
+
{/* Message */}
|
| 189 |
+
{message.text && (
|
| 190 |
+
<div className={`mb-4 p-3 rounded-lg text-sm ${
|
| 191 |
+
message.type === "success"
|
| 192 |
+
? "bg-green-900/50 text-green-300 border border-green-700"
|
| 193 |
+
: "bg-red-900/50 text-red-300 border border-red-700"
|
| 194 |
+
}`}>
|
| 195 |
+
{message.text}
|
| 196 |
+
</div>
|
| 197 |
+
)}
|
| 198 |
+
|
| 199 |
+
{/* Save Button */}
|
| 200 |
+
<button
|
| 201 |
+
onClick={handleSave}
|
| 202 |
+
disabled={loading || !webhookUrl}
|
| 203 |
+
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white font-medium py-3 px-4 rounded-lg transition-colors text-sm"
|
| 204 |
+
>
|
| 205 |
+
{loading ? (
|
| 206 |
+
<span className="flex items-center justify-center gap-2">
|
| 207 |
+
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
| 208 |
+
Saving...
|
| 209 |
+
</span>
|
| 210 |
+
) : (
|
| 211 |
+
"Save Webhook Settings"
|
| 212 |
+
)}
|
| 213 |
+
</button>
|
| 214 |
+
|
| 215 |
+
{/* Info */}
|
| 216 |
+
<div className="mt-4 p-3 bg-gray-800 rounded-lg">
|
| 217 |
+
<p className="text-gray-400 text-xs leading-relaxed">
|
| 218 |
+
<span className="text-yellow-400 font-medium">ℹ️ How to get webhook URL: </span>
|
| 219 |
+
For Slack: Create an Incoming Webhook in your Slack App settings.
|
| 220 |
+
For Teams: Go to channel → Connectors → Incoming Webhook.
|
| 221 |
+
</p>
|
| 222 |
+
</div>
|
| 223 |
+
</div>
|
| 224 |
+
);
|
| 225 |
+
};
|
| 226 |
+
|
| 227 |
+
export default WebhookSettings;
|
Frontend/src/config.js
CHANGED
|
@@ -6,16 +6,13 @@ const getBackendUrl = () => {
|
|
| 6 |
const envUrl = import.meta.env.VITE_BACKEND_URL;
|
| 7 |
if (envUrl) return envUrl.trim().replace(/\/$/, '');
|
| 8 |
|
| 9 |
-
//
|
| 10 |
-
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
|
| 11 |
-
return 'http://localhost:8000';
|
| 12 |
-
}
|
| 13 |
-
// Default to the live Hugging Face Space for stability in production deployment
|
| 14 |
return 'https://ritesh19180-ai-helpdesk-api.hf.space';
|
| 15 |
};
|
| 16 |
|
| 17 |
export const API_CONFIG = {
|
| 18 |
BACKEND_URL: getBackendUrl(),
|
| 19 |
FRONTEND_URL: window.location.origin,
|
| 20 |
-
IS_PROD: import.meta.env.PROD
|
|
|
|
| 21 |
};
|
|
|
|
| 6 |
const envUrl = import.meta.env.VITE_BACKEND_URL;
|
| 7 |
if (envUrl) return envUrl.trim().replace(/\/$/, '');
|
| 8 |
|
| 9 |
+
// Default to the live Hugging Face Space for stability
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
return 'https://ritesh19180-ai-helpdesk-api.hf.space';
|
| 11 |
};
|
| 12 |
|
| 13 |
export const API_CONFIG = {
|
| 14 |
BACKEND_URL: getBackendUrl(),
|
| 15 |
FRONTEND_URL: window.location.origin,
|
| 16 |
+
IS_PROD: import.meta.env.PROD,
|
| 17 |
+
USE_MOCK: import.meta.env.VITE_USE_MOCK !== 'false' // default true
|
| 18 |
};
|
Frontend/src/index.css
CHANGED
|
@@ -43,10 +43,12 @@
|
|
| 43 |
}
|
| 44 |
|
| 45 |
body {
|
| 46 |
-
@apply bg-[#f6f8f7] text-slate-900;
|
| 47 |
}
|
| 48 |
|
| 49 |
.dark {
|
|
|
|
|
|
|
| 50 |
--background: 224 71.4% 4.1%;
|
| 51 |
--foreground: 210 20% 98%;
|
| 52 |
--card: 224 71.4% 4.1%;
|
|
@@ -91,16 +93,54 @@
|
|
| 91 |
}
|
| 92 |
}
|
| 93 |
|
| 94 |
-
html{
|
| 95 |
overflow-y: scroll;
|
| 96 |
-
scrollbar-width: none; /* Firefox */
|
| 97 |
-
}
|
| 98 |
-
|
| 99 |
-
html::-webkit-scrollbar{
|
| 100 |
-
width: 0;
|
| 101 |
-
height: 0;
|
| 102 |
}
|
| 103 |
|
| 104 |
body{
|
| 105 |
overflow-x: hidden;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
}
|
|
|
|
| 43 |
}
|
| 44 |
|
| 45 |
body {
|
| 46 |
+
@apply bg-[#f6f8f7] text-slate-900 dark:bg-gray-900 dark:text-white;
|
| 47 |
}
|
| 48 |
|
| 49 |
.dark {
|
| 50 |
+
background-color: #111827;
|
| 51 |
+
color: #f9fafb;
|
| 52 |
--background: 224 71.4% 4.1%;
|
| 53 |
--foreground: 210 20% 98%;
|
| 54 |
--card: 224 71.4% 4.1%;
|
|
|
|
| 93 |
}
|
| 94 |
}
|
| 95 |
|
| 96 |
+
html {
|
| 97 |
overflow-y: scroll;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
}
|
| 99 |
|
| 100 |
body{
|
| 101 |
overflow-x: hidden;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
/* Dark Mode Global Overrides */
|
| 105 |
+
.dark .bg-white {
|
| 106 |
+
background-color: #111827 !important;
|
| 107 |
+
}
|
| 108 |
+
.dark .bg-gray-50 {
|
| 109 |
+
background-color: #1f2937 !important;
|
| 110 |
+
}
|
| 111 |
+
.dark .bg-gray-100 {
|
| 112 |
+
background-color: #374151 !important;
|
| 113 |
+
}
|
| 114 |
+
.dark .text-gray-900 {
|
| 115 |
+
color: #f9fafb !important;
|
| 116 |
+
}
|
| 117 |
+
.dark .text-gray-800 {
|
| 118 |
+
color: #f3f4f6 !important;
|
| 119 |
+
}
|
| 120 |
+
.dark .text-gray-700 {
|
| 121 |
+
color: #e5e7eb !important;
|
| 122 |
+
}
|
| 123 |
+
.dark .text-gray-600 {
|
| 124 |
+
color: #d1d5db !important;
|
| 125 |
+
}
|
| 126 |
+
.dark .text-gray-500 {
|
| 127 |
+
color: #9ca3af !important;
|
| 128 |
+
}
|
| 129 |
+
.dark .border-gray-100 {
|
| 130 |
+
border-color: #374151 !important;
|
| 131 |
+
}
|
| 132 |
+
.dark .border-gray-200 {
|
| 133 |
+
border-color: #4b5563 !important;
|
| 134 |
+
}
|
| 135 |
+
.dark .shadow-sm {
|
| 136 |
+
box-shadow: 0 1px 2px rgba(0,0,0,0.5) !important;
|
| 137 |
+
}
|
| 138 |
+
.dark nav {
|
| 139 |
+
background-color: #1f2937 !important;
|
| 140 |
+
}
|
| 141 |
+
.dark nav a {
|
| 142 |
+
color: #e5e7eb !important;
|
| 143 |
+
}
|
| 144 |
+
.dark nav span {
|
| 145 |
+
color: #f9fafb !important;
|
| 146 |
}
|
Frontend/src/legacy_ui/Dashboard.jsx
CHANGED
|
@@ -63,7 +63,7 @@ const Dashboard = () => {
|
|
| 63 |
}
|
| 64 |
};
|
| 65 |
fetchTickets();
|
| 66 |
-
|
| 67 |
}, []);
|
| 68 |
|
| 69 |
// Detect newly inserted or updated tickets for highlight animation
|
|
@@ -84,7 +84,7 @@ const Dashboard = () => {
|
|
| 84 |
});
|
| 85 |
|
| 86 |
prevTicketsRef.current = tickets;
|
| 87 |
-
|
| 88 |
}, [tickets]);
|
| 89 |
|
| 90 |
// Summary Counts
|
|
|
|
| 63 |
}
|
| 64 |
};
|
| 65 |
fetchTickets();
|
| 66 |
+
|
| 67 |
}, []);
|
| 68 |
|
| 69 |
// Detect newly inserted or updated tickets for highlight animation
|
|
|
|
| 84 |
});
|
| 85 |
|
| 86 |
prevTicketsRef.current = tickets;
|
| 87 |
+
|
| 88 |
}, [tickets]);
|
| 89 |
|
| 90 |
// Summary Counts
|
Frontend/src/pages/AboutUs.jsx
CHANGED
|
@@ -1,65 +1,387 @@
|
|
| 1 |
-
import React from
|
| 2 |
-
import { useNavigate } from
|
| 3 |
-
import {
|
| 4 |
-
import {
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
</div>
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
<div className="space-y-4">
|
| 32 |
-
<div className="inline-flex items-center gap-2 px-3 py-1 bg-emerald-500/10 border border-emerald-500/20 rounded-full text-emerald-700 text-xs font-bold">
|
| 33 |
-
<Heart size={14} /> Our Mission
|
| 34 |
-
</div>
|
| 35 |
-
<h1 className="text-4xl font-black text-slate-900 tracking-tight">Pioneering Intelligent Triage</h1>
|
| 36 |
-
<p className="text-slate-600 text-base leading-relaxed">
|
| 37 |
-
At HELPDESK.AI, we strive to build local machine learning workflows that eliminate manual ticket tagging, priority guessing, and routing bottlenecks for modern Indian businesses.
|
| 38 |
-
</p>
|
| 39 |
</div>
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
</p>
|
| 50 |
-
</Card>
|
| 51 |
-
|
| 52 |
-
<Card className="p-6 rounded-[2rem] border border-slate-200 bg-white space-y-3">
|
| 53 |
-
<div className="w-8 h-8 rounded-xl bg-blue-50 text-blue-600 flex items-center justify-center">
|
| 54 |
-
<Award size={16} />
|
| 55 |
-
</div>
|
| 56 |
-
<h4 className="font-extrabold text-slate-800 text-sm">100% Indian Data Sovereignty</h4>
|
| 57 |
-
<p className="text-xs text-slate-500 leading-relaxed font-semibold">
|
| 58 |
-
All ticket summaries, OCR attachments, and database timelines remain securely locked under regional cloud networks.
|
| 59 |
-
</p>
|
| 60 |
-
</Card>
|
| 61 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
</div>
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import { useNavigate } from "react-router-dom";
|
| 3 |
+
import { motion } from "framer-motion";
|
| 4 |
+
import {
|
| 5 |
+
ArrowLeft,
|
| 6 |
+
FolderOpen,
|
| 7 |
+
Zap,
|
| 8 |
+
Layers,
|
| 9 |
+
ShieldCheck,
|
| 10 |
+
Database,
|
| 11 |
+
Server,
|
| 12 |
+
Laptop,
|
| 13 |
+
Github,
|
| 14 |
+
ExternalLink,
|
| 15 |
+
Users,
|
| 16 |
+
BookOpen,
|
| 17 |
+
HelpCircle,
|
| 18 |
+
Code
|
| 19 |
+
} from "lucide-react";
|
| 20 |
+
|
| 21 |
+
// Framer Motion Animation Variants
|
| 22 |
+
const fadeInUp = {
|
| 23 |
+
hidden: { opacity: 0, y: 30 },
|
| 24 |
+
visible: { opacity: 1, y: 0, transition: { duration: 0.6, ease: "easeOut" } }
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
const staggerContainer = {
|
| 28 |
+
hidden: { opacity: 0 },
|
| 29 |
+
visible: {
|
| 30 |
+
opacity: 1,
|
| 31 |
+
transition: {
|
| 32 |
+
staggerChildren: 0.1
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
};
|
| 36 |
+
|
| 37 |
+
const AboutUs = () => {
|
| 38 |
+
const navigate = useNavigate();
|
| 39 |
+
|
| 40 |
+
const capabilities = [
|
| 41 |
+
{
|
| 42 |
+
icon: <FolderOpen className="w-6 h-6 text-emerald-400" />,
|
| 43 |
+
title: "AI Classification",
|
| 44 |
+
desc: "Instantly categorizes requests into Network, Hardware, Software, or Access categories using natural language understanding."
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
icon: <Zap className="w-6 h-6 text-emerald-400" />,
|
| 48 |
+
title: "Smart Resolution",
|
| 49 |
+
desc: "Leverages historical ticket resolutions to automatically generate accurate solution drafts or self-solve simple issues."
|
| 50 |
+
},
|
| 51 |
+
{
|
| 52 |
+
icon: <Layers className="w-6 h-6 text-emerald-400" />,
|
| 53 |
+
title: "Duplicate Detection",
|
| 54 |
+
desc: "Uses vector similarity search to identify and group duplicate incident reports in real time, preventing agent alert fatigue."
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
icon: <ShieldCheck className="w-6 h-6 text-emerald-400" />,
|
| 58 |
+
title: "Multi-Tenant Isolation",
|
| 59 |
+
desc: "Ensures strict enterprise-grade security isolation and customized configuration profiles for each registered organization."
|
| 60 |
+
}
|
| 61 |
+
];
|
| 62 |
+
|
| 63 |
+
const pipelineStages = [
|
| 64 |
+
{
|
| 65 |
+
step: "01",
|
| 66 |
+
title: "Ingestion",
|
| 67 |
+
desc: "Support tickets are ingested seamlessly via email parsers, customer web widgets, or direct dashboard submission."
|
| 68 |
+
},
|
| 69 |
+
{
|
| 70 |
+
step: "02",
|
| 71 |
+
title: "Extraction",
|
| 72 |
+
desc: "System runs OCR to extract text from screenshots and applies Named Entity Recognition (NER) to pull system names and keywords."
|
| 73 |
+
},
|
| 74 |
+
{
|
| 75 |
+
step: "03",
|
| 76 |
+
title: "Classification",
|
| 77 |
+
desc: "Advanced NLP models predict the correct ticket category and calculate priority based on urgency cues."
|
| 78 |
+
},
|
| 79 |
+
{
|
| 80 |
+
step: "04",
|
| 81 |
+
title: "Similarity Scan",
|
| 82 |
+
desc: "A vector similarity scan determines if this incident is already being resolved, linking duplicate cases automatically."
|
| 83 |
+
},
|
| 84 |
+
{
|
| 85 |
+
step: "05",
|
| 86 |
+
title: "Auto-Resolution",
|
| 87 |
+
desc: "Matched resolutions are evaluated; if confidence thresholds are met, the system auto-resolves the ticket and emails the user."
|
| 88 |
+
},
|
| 89 |
+
{
|
| 90 |
+
step: "06",
|
| 91 |
+
title: "Smart Triage",
|
| 92 |
+
desc: "Unresolved tickets are automatically routed to the precise engineering team queues with complete AI logs attached."
|
| 93 |
+
}
|
| 94 |
+
];
|
| 95 |
+
|
| 96 |
+
const techStack = [
|
| 97 |
+
{
|
| 98 |
+
category: "Frontend Experience",
|
| 99 |
+
icon: <Laptop className="w-5 h-5 text-emerald-400" />,
|
| 100 |
+
techs: ["React (Vite)", "Tailwind CSS", "Framer Motion", "Lucide React"]
|
| 101 |
+
},
|
| 102 |
+
{
|
| 103 |
+
category: "Intelligence & Backend",
|
| 104 |
+
icon: <Server className="w-5 h-5 text-emerald-400" />,
|
| 105 |
+
techs: ["FastAPI (Python)", "Hugging Face Hub", "Transformers & PyTorch", "OCR Engine"]
|
| 106 |
+
},
|
| 107 |
+
{
|
| 108 |
+
category: "Database & Services",
|
| 109 |
+
icon: <Database className="w-5 h-5 text-emerald-400" />,
|
| 110 |
+
techs: ["Supabase", "PostgreSQL", "pgvector (Similarity)", "Realtime Webhooks"]
|
| 111 |
+
}
|
| 112 |
+
];
|
| 113 |
+
|
| 114 |
+
return (
|
| 115 |
+
<div className="min-h-screen bg-[#021510] text-white relative overflow-hidden font-sans">
|
| 116 |
+
{/* Background Ambient Glows */}
|
| 117 |
+
<div className="absolute top-0 left-0 w-[500px] h-[500px] bg-emerald-500/10 blur-[130px] rounded-full pointer-events-none" />
|
| 118 |
+
<div className="absolute bottom-0 right-0 w-[500px] h-[500px] bg-teal-400/10 blur-[130px] rounded-full pointer-events-none" />
|
| 119 |
+
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[350px] h-[350px] bg-green-500/5 blur-[120px] rounded-full pointer-events-none" />
|
| 120 |
+
|
| 121 |
+
{/* Main Container */}
|
| 122 |
+
<div className="max-w-6xl mx-auto px-6 py-12 relative z-10">
|
| 123 |
+
|
| 124 |
+
{/* Navigation & Header */}
|
| 125 |
+
<motion.div
|
| 126 |
+
initial={{ opacity: 0, y: -10 }}
|
| 127 |
+
animate={{ opacity: 1, y: 0 }}
|
| 128 |
+
transition={{ duration: 0.5 }}
|
| 129 |
+
className="mb-12 flex justify-between items-center"
|
| 130 |
+
>
|
| 131 |
+
<button
|
| 132 |
+
onClick={() => navigate("/")}
|
| 133 |
+
className="group inline-flex items-center gap-2 bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 hover:bg-emerald-500/20 hover:border-emerald-500/30 px-4 py-2 rounded-full text-sm font-semibold tracking-wide transition-all shadow-md"
|
| 134 |
+
>
|
| 135 |
+
<ArrowLeft size={16} className="group-hover:-translate-x-1 transition-transform" />
|
| 136 |
+
Back to Home
|
| 137 |
+
</button>
|
| 138 |
+
|
| 139 |
+
<div className="flex items-center gap-2">
|
| 140 |
+
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500 animate-pulse" />
|
| 141 |
+
<span className="text-xs font-bold text-emerald-400 uppercase tracking-widest">GSSoC 2026 Compliant</span>
|
| 142 |
+
</div>
|
| 143 |
+
</motion.div>
|
| 144 |
+
|
| 145 |
+
{/* Hero Section */}
|
| 146 |
+
<section className="text-center mb-24">
|
| 147 |
+
<motion.div
|
| 148 |
+
initial="hidden"
|
| 149 |
+
animate="visible"
|
| 150 |
+
variants={fadeInUp}
|
| 151 |
+
className="max-w-3xl mx-auto"
|
| 152 |
+
>
|
| 153 |
+
<h1 className="text-4xl md:text-6xl font-extrabold mb-6 tracking-tight leading-tight">
|
| 154 |
+
About
|
| 155 |
+
<span className="bg-gradient-to-r from-emerald-400 to-teal-300 bg-clip-text text-transparent">
|
| 156 |
+
{" "}HELPDESK.AI
|
| 157 |
+
</span>
|
| 158 |
+
</h1>
|
| 159 |
+
<p className="text-gray-400 text-lg md:text-xl leading-relaxed mb-8">
|
| 160 |
+
A modern, intelligent, and autonomous helpdesk platform designed to end manual ticket triage. By utilizing advanced Natural Language Processing and Vector similarity, we streamline support workflows from chaos to clarity.
|
| 161 |
+
</p>
|
| 162 |
+
</motion.div>
|
| 163 |
+
</section>
|
| 164 |
+
|
| 165 |
+
{/* Mission Statement */}
|
| 166 |
+
<section className="mb-28">
|
| 167 |
+
<motion.div
|
| 168 |
+
initial="hidden"
|
| 169 |
+
whileInView="visible"
|
| 170 |
+
viewport={{ once: true, margin: "-100px" }}
|
| 171 |
+
variants={fadeInUp}
|
| 172 |
+
className="bg-white/5 border border-white/10 rounded-3xl p-8 md:p-12 relative overflow-hidden backdrop-blur-xl"
|
| 173 |
+
>
|
| 174 |
+
<div className="absolute inset-0 bg-gradient-to-br from-emerald-500/5 to-transparent pointer-events-none" />
|
| 175 |
+
<div className="relative z-10 max-w-4xl mx-auto text-center">
|
| 176 |
+
<span className="text-xs font-bold uppercase tracking-widest text-emerald-400 mb-4 block">Our Vision</span>
|
| 177 |
+
<h2 className="text-2xl md:text-3xl font-bold mb-6 text-white tracking-tight">
|
| 178 |
+
"Eliminating manual ticket routing and solving repeating support incidents instantly."
|
| 179 |
+
</h2>
|
| 180 |
+
<p className="text-gray-400 leading-relaxed max-w-2xl mx-auto">
|
| 181 |
+
We believe engineering and IT support teams shouldn't spend their valuable hours reviewing, tagging, and assigning tickets. Our vision is a self-driving support ecosystem that analyzes context, remembers historical resolutions, and automates resolution at scale.
|
| 182 |
+
</p>
|
| 183 |
+
</div>
|
| 184 |
+
</motion.div>
|
| 185 |
+
</section>
|
| 186 |
+
|
| 187 |
+
{/* Core Capabilities */}
|
| 188 |
+
<section className="mb-28">
|
| 189 |
+
<div className="text-center mb-16">
|
| 190 |
+
<span className="text-xs font-bold uppercase tracking-widest text-emerald-400 mb-3 block">Platform Intelligence</span>
|
| 191 |
+
<h2 className="text-3xl font-extrabold tracking-tight">Core Capabilities</h2>
|
| 192 |
+
</div>
|
| 193 |
+
|
| 194 |
+
<motion.div
|
| 195 |
+
initial="hidden"
|
| 196 |
+
whileInView="visible"
|
| 197 |
+
viewport={{ once: true, margin: "-50px" }}
|
| 198 |
+
variants={staggerContainer}
|
| 199 |
+
className="grid grid-cols-1 md:grid-cols-2 gap-8"
|
| 200 |
+
>
|
| 201 |
+
{capabilities.map((cap, idx) => (
|
| 202 |
+
<motion.div
|
| 203 |
+
key={idx}
|
| 204 |
+
variants={fadeInUp}
|
| 205 |
+
whileHover={{ scale: 1.01, borderColor: "rgba(16,185,129,0.3)" }}
|
| 206 |
+
className="bg-white/5 border border-white/10 p-8 rounded-3xl backdrop-blur-xl transition-colors duration-300 group"
|
| 207 |
+
>
|
| 208 |
+
<div className="w-12 h-12 rounded-2xl bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center mb-6 group-hover:bg-emerald-500/20 transition-all shadow-inner">
|
| 209 |
+
{cap.icon}
|
| 210 |
+
</div>
|
| 211 |
+
<h3 className="text-xl font-bold mb-3 text-white">{cap.title}</h3>
|
| 212 |
+
<p className="text-gray-400 text-sm leading-relaxed">{cap.desc}</p>
|
| 213 |
+
</motion.div>
|
| 214 |
+
))}
|
| 215 |
+
</motion.div>
|
| 216 |
+
</section>
|
| 217 |
+
|
| 218 |
+
{/* AI Pipeline Architecture */}
|
| 219 |
+
<section className="mb-28">
|
| 220 |
+
<div className="text-center mb-16">
|
| 221 |
+
<span className="text-xs font-bold uppercase tracking-widest text-emerald-400 mb-3 block">System Workflow</span>
|
| 222 |
+
<h2 className="text-3xl font-extrabold tracking-tight">AI Pipeline Architecture</h2>
|
| 223 |
+
<p className="text-gray-400 mt-2 text-sm">How HELPDESK.AI processes and resolves issues in milliseconds</p>
|
| 224 |
+
</div>
|
| 225 |
+
|
| 226 |
+
<motion.div
|
| 227 |
+
initial="hidden"
|
| 228 |
+
whileInView="visible"
|
| 229 |
+
viewport={{ once: true, margin: "-50px" }}
|
| 230 |
+
variants={staggerContainer}
|
| 231 |
+
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
|
| 232 |
+
>
|
| 233 |
+
{pipelineStages.map((stage, idx) => (
|
| 234 |
+
<motion.div
|
| 235 |
+
key={idx}
|
| 236 |
+
variants={fadeInUp}
|
| 237 |
+
className="bg-white/5 border border-white/10 p-6 rounded-2xl relative overflow-hidden backdrop-blur-xl hover:border-emerald-500/20 transition-colors"
|
| 238 |
+
>
|
| 239 |
+
<div className="absolute top-4 right-4 text-4xl font-extrabold text-emerald-500/10 select-none">
|
| 240 |
+
{stage.step}
|
| 241 |
+
</div>
|
| 242 |
+
<div className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-emerald-500/10 text-emerald-400 font-bold text-xs mb-4 border border-emerald-500/25">
|
| 243 |
+
{stage.step}
|
| 244 |
+
</div>
|
| 245 |
+
<h3 className="text-lg font-bold mb-2 text-white">{stage.title}</h3>
|
| 246 |
+
<p className="text-gray-400 text-xs leading-relaxed">{stage.desc}</p>
|
| 247 |
+
</motion.div>
|
| 248 |
+
))}
|
| 249 |
+
</motion.div>
|
| 250 |
+
</section>
|
| 251 |
+
|
| 252 |
+
{/* Tech Stack Grid */}
|
| 253 |
+
<section className="mb-28">
|
| 254 |
+
<div className="text-center mb-16">
|
| 255 |
+
<span className="text-xs font-bold uppercase tracking-widest text-emerald-400 mb-3 block">Technology Stack</span>
|
| 256 |
+
<h2 className="text-3xl font-extrabold tracking-tight">Built with Modern Tech</h2>
|
| 257 |
+
</div>
|
| 258 |
+
|
| 259 |
+
<motion.div
|
| 260 |
+
initial="hidden"
|
| 261 |
+
whileInView="visible"
|
| 262 |
+
viewport={{ once: true, margin: "-50px" }}
|
| 263 |
+
variants={staggerContainer}
|
| 264 |
+
className="grid grid-cols-1 md:grid-cols-3 gap-8"
|
| 265 |
+
>
|
| 266 |
+
{techStack.map((stack, idx) => (
|
| 267 |
+
<motion.div
|
| 268 |
+
key={idx}
|
| 269 |
+
variants={fadeInUp}
|
| 270 |
+
className="bg-white/5 border border-white/10 p-8 rounded-3xl backdrop-blur-xl relative overflow-hidden"
|
| 271 |
+
>
|
| 272 |
+
<div className="flex items-center gap-3 mb-6">
|
| 273 |
+
<div className="p-2 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
|
| 274 |
+
{stack.icon}
|
| 275 |
+
</div>
|
| 276 |
+
<h3 className="text-lg font-bold text-white leading-tight">{stack.category}</h3>
|
| 277 |
+
</div>
|
| 278 |
+
<ul className="space-y-3">
|
| 279 |
+
{stack.techs.map((tech, techIdx) => (
|
| 280 |
+
<li key={techIdx} className="flex items-center gap-2.5 text-gray-400 text-sm">
|
| 281 |
+
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
| 282 |
+
{tech}
|
| 283 |
+
</li>
|
| 284 |
+
))}
|
| 285 |
+
</ul>
|
| 286 |
+
</motion.div>
|
| 287 |
+
))}
|
| 288 |
+
</motion.div>
|
| 289 |
+
</section>
|
| 290 |
+
|
| 291 |
+
{/* Resource Links */}
|
| 292 |
+
<section className="mb-28">
|
| 293 |
+
<div className="text-center mb-12">
|
| 294 |
+
<span className="text-xs font-bold uppercase tracking-widest text-emerald-400 mb-3 block">External Resources</span>
|
| 295 |
+
<h2 className="text-3xl font-extrabold tracking-tight">Project Documentation & Links</h2>
|
| 296 |
+
</div>
|
| 297 |
+
|
| 298 |
+
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
|
| 299 |
+
<motion.a
|
| 300 |
+
href="https://github.com/ritesh-1918/HELPDESK.AI"
|
| 301 |
+
target="_blank"
|
| 302 |
+
rel="noopener noreferrer"
|
| 303 |
+
whileHover={{ y: -4, borderColor: "rgba(16,185,129,0.3)" }}
|
| 304 |
+
className="bg-white/5 border border-white/10 p-6 rounded-2xl flex items-center justify-between group backdrop-blur-xl transition-all"
|
| 305 |
+
>
|
| 306 |
+
<div className="flex items-center gap-4">
|
| 307 |
+
<div className="p-3 bg-emerald-500/10 rounded-xl text-emerald-400 group-hover:bg-emerald-500/20 transition-all">
|
| 308 |
+
<Github size={22} />
|
| 309 |
</div>
|
| 310 |
+
<div>
|
| 311 |
+
<h4 className="font-bold text-white">GitHub Repository</h4>
|
| 312 |
+
<p className="text-xs text-gray-500">Source code & issues</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
</div>
|
| 314 |
+
</div>
|
| 315 |
+
<ExternalLink size={16} className="text-gray-500 group-hover:text-emerald-400 transition-colors" />
|
| 316 |
+
</motion.a>
|
| 317 |
|
| 318 |
+
<motion.a
|
| 319 |
+
href="#"
|
| 320 |
+
whileHover={{ y: -4, borderColor: "rgba(16,185,129,0.3)" }}
|
| 321 |
+
className="bg-white/5 border border-white/10 p-6 rounded-2xl flex items-center justify-between group backdrop-blur-xl transition-all"
|
| 322 |
+
>
|
| 323 |
+
<div className="flex items-center gap-4">
|
| 324 |
+
<div className="p-3 bg-emerald-500/10 rounded-xl text-emerald-400 group-hover:bg-emerald-500/20 transition-all">
|
| 325 |
+
<BookOpen size={22} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
</div>
|
| 327 |
+
<div>
|
| 328 |
+
<h4 className="font-bold text-white">API Reference</h4>
|
| 329 |
+
<p className="text-xs text-gray-500">Swagger & system docs</p>
|
| 330 |
+
</div>
|
| 331 |
+
</div>
|
| 332 |
+
<ExternalLink size={16} className="text-gray-500 group-hover:text-emerald-400 transition-colors" />
|
| 333 |
+
</motion.a>
|
| 334 |
+
|
| 335 |
+
<motion.a
|
| 336 |
+
href="#"
|
| 337 |
+
whileHover={{ y: -4, borderColor: "rgba(16,185,129,0.3)" }}
|
| 338 |
+
className="bg-white/5 border border-white/10 p-6 rounded-2xl flex items-center justify-between group backdrop-blur-xl transition-all"
|
| 339 |
+
>
|
| 340 |
+
<div className="flex items-center gap-4">
|
| 341 |
+
<div className="p-3 bg-emerald-500/10 rounded-xl text-emerald-400 group-hover:bg-emerald-500/20 transition-all">
|
| 342 |
+
<HelpCircle size={22} />
|
| 343 |
+
</div>
|
| 344 |
+
<div>
|
| 345 |
+
<h4 className="font-bold text-white">Platform Demo</h4>
|
| 346 |
+
<p className="text-xs text-gray-500">Video & Presentation</p>
|
| 347 |
+
</div>
|
| 348 |
+
</div>
|
| 349 |
+
<ExternalLink size={16} className="text-gray-500 group-hover:text-emerald-400 transition-colors" />
|
| 350 |
+
</motion.a>
|
| 351 |
+
</div>
|
| 352 |
+
</section>
|
| 353 |
+
|
| 354 |
+
{/* Community & GSSoC Section */}
|
| 355 |
+
<section>
|
| 356 |
+
<motion.div
|
| 357 |
+
initial="hidden"
|
| 358 |
+
whileInView="visible"
|
| 359 |
+
viewport={{ once: true }}
|
| 360 |
+
variants={fadeInUp}
|
| 361 |
+
className="bg-gradient-to-br from-emerald-950/80 to-emerald-900/50 border border-emerald-500/25 rounded-3xl p-8 md:p-12 text-center backdrop-blur-xl"
|
| 362 |
+
>
|
| 363 |
+
<div className="max-w-2xl mx-auto">
|
| 364 |
+
<Users size={40} className="text-emerald-400 mx-auto mb-6" />
|
| 365 |
+
<h2 className="text-2xl md:text-3xl font-extrabold text-white mb-4">Join our Community</h2>
|
| 366 |
+
<p className="text-gray-300 text-sm leading-relaxed mb-8">
|
| 367 |
+
HELPDESK.AI is proudly participating in GSSoC 2026. We are built by and for the open source community. Explore our issues, raise a pull request, and help us design the future of autonomous helpdesks!
|
| 368 |
+
</p>
|
| 369 |
+
<a
|
| 370 |
+
href="https://github.com/ritesh-1918/HELPDESK.AI"
|
| 371 |
+
target="_blank"
|
| 372 |
+
rel="noopener noreferrer"
|
| 373 |
+
className="inline-flex items-center gap-2 bg-emerald-500 hover:bg-emerald-600 text-black font-bold px-6 py-3.5 rounded-xl transition-all shadow-lg hover:shadow-emerald-500/20"
|
| 374 |
+
>
|
| 375 |
+
<Code size={18} />
|
| 376 |
+
Start Contributing
|
| 377 |
+
</a>
|
| 378 |
</div>
|
| 379 |
+
</motion.div>
|
| 380 |
+
</section>
|
| 381 |
+
|
| 382 |
+
</div>
|
| 383 |
+
</div>
|
| 384 |
+
);
|
| 385 |
+
};
|
| 386 |
+
|
| 387 |
+
export default AboutUs;
|
Frontend/src/pages/AdminSignup.jsx
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
| 10 |
} from "lucide-react";
|
| 11 |
import useAuthStore from "../store/authStore";
|
| 12 |
import { Select } from "../components/ui/select";
|
|
|
|
| 13 |
|
| 14 |
/**
|
| 15 |
* AdminSignup — Premium Multi-step Company Registration
|
|
@@ -44,6 +45,14 @@ function AdminSignup() {
|
|
| 44 |
|
| 45 |
const navigate = useNavigate();
|
| 46 |
const { signup, loading, user, profile } = useAuthStore();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
// Redirect if already logged in and verified
|
| 49 |
useEffect(() => {
|
|
@@ -52,15 +61,6 @@ function AdminSignup() {
|
|
| 52 |
}
|
| 53 |
}, [user, profile, navigate]);
|
| 54 |
|
| 55 |
-
// Password complexity validator — mirrors Supabase's policy
|
| 56 |
-
const validatePassword = (pw) => {
|
| 57 |
-
if (pw.length < 8) return 'Password must be at least 8 characters long.';
|
| 58 |
-
if (!/[a-z]/.test(pw)) return 'Password must contain at least one lowercase letter (a-z).';
|
| 59 |
-
if (!/[A-Z]/.test(pw)) return 'Password must contain at least one uppercase letter (A-Z).';
|
| 60 |
-
if (!/[0-9]/.test(pw)) return 'Password must contain at least one number (0-9).';
|
| 61 |
-
return null; // valid
|
| 62 |
-
};
|
| 63 |
-
|
| 64 |
// Password strength calculation
|
| 65 |
useEffect(() => {
|
| 66 |
const pw = formData.password;
|
|
@@ -69,7 +69,9 @@ function AdminSignup() {
|
|
| 69 |
if (/[A-Z]/.test(pw)) strength += 25;
|
| 70 |
if (/[0-9]/.test(pw)) strength += 25;
|
| 71 |
if (/[^A-Za-z0-9]/.test(pw)) strength += 25;
|
|
|
|
| 72 |
setPasswordStrength(strength);
|
|
|
|
| 73 |
}, [formData.password]);
|
| 74 |
|
| 75 |
const handleChange = (e) => {
|
|
@@ -87,9 +89,8 @@ function AdminSignup() {
|
|
| 87 |
setError("Please fill in all required personal information.");
|
| 88 |
return;
|
| 89 |
}
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
setError(pwError);
|
| 93 |
return;
|
| 94 |
}
|
| 95 |
if (formData.password !== formData.confirmPassword) {
|
|
@@ -428,15 +429,13 @@ function AdminSignup() {
|
|
| 428 |
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
| 429 |
</button>
|
| 430 |
</div>
|
| 431 |
-
{/*
|
| 432 |
-
|
| 433 |
-
|
| 434 |
<div className="flex justify-between items-center text-[10px] font-bold uppercase tracking-widest text-gray-400">
|
| 435 |
<span>Strength: {getStrengthText()}</span>
|
| 436 |
<span>{passwordStrength}%</span>
|
| 437 |
</div>
|
| 438 |
-
)}
|
| 439 |
-
{formData.password && (
|
| 440 |
<div className="h-1 w-full bg-gray-100 rounded-full overflow-hidden">
|
| 441 |
<motion.div
|
| 442 |
className={`h-full ${getStrengthColor()}`}
|
|
@@ -444,22 +443,14 @@ function AdminSignup() {
|
|
| 444 |
animate={{ width: `${passwordStrength}%` }}
|
| 445 |
/>
|
| 446 |
</div>
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
{
|
| 452 |
-
|
| 453 |
-
{ label: 'Number (0-9)', ok: /[0-9]/.test(formData.password) },
|
| 454 |
-
].map(({ label, ok }) => (
|
| 455 |
-
<span key={label} className={`text-[10px] font-semibold flex items-center gap-1 transition-colors ${
|
| 456 |
-
formData.password ? (ok ? 'text-emerald-600' : 'text-red-400') : 'text-gray-300'
|
| 457 |
-
}`}>
|
| 458 |
-
<span>{ok ? '✓' : '○'}</span> {label}
|
| 459 |
-
</span>
|
| 460 |
-
))}
|
| 461 |
</div>
|
| 462 |
-
|
| 463 |
</div>
|
| 464 |
<div className="space-y-2">
|
| 465 |
<label className="text-xs font-bold text-gray-400 uppercase tracking-wider flex items-center gap-2">
|
|
|
|
| 10 |
} from "lucide-react";
|
| 11 |
import useAuthStore from "../store/authStore";
|
| 12 |
import { Select } from "../components/ui/select";
|
| 13 |
+
import { getPasswordValidation, getPasswordValidationMessage } from "../utils/passwordValidation";
|
| 14 |
|
| 15 |
/**
|
| 16 |
* AdminSignup — Premium Multi-step Company Registration
|
|
|
|
| 45 |
|
| 46 |
const navigate = useNavigate();
|
| 47 |
const { signup, loading, user, profile } = useAuthStore();
|
| 48 |
+
const passwordRules = {
|
| 49 |
+
minLength: 8,
|
| 50 |
+
requireUppercase: true,
|
| 51 |
+
requireNumber: true,
|
| 52 |
+
requireSpecial: true,
|
| 53 |
+
};
|
| 54 |
+
const passwordChecks = getPasswordValidation(formData.password, passwordRules);
|
| 55 |
+
const passwordWarning = getPasswordValidationMessage(passwordChecks, passwordRules);
|
| 56 |
|
| 57 |
// Redirect if already logged in and verified
|
| 58 |
useEffect(() => {
|
|
|
|
| 61 |
}
|
| 62 |
}, [user, profile, navigate]);
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
// Password strength calculation
|
| 65 |
useEffect(() => {
|
| 66 |
const pw = formData.password;
|
|
|
|
| 69 |
if (/[A-Z]/.test(pw)) strength += 25;
|
| 70 |
if (/[0-9]/.test(pw)) strength += 25;
|
| 71 |
if (/[^A-Za-z0-9]/.test(pw)) strength += 25;
|
| 72 |
+
|
| 73 |
setPasswordStrength(strength);
|
| 74 |
+
|
| 75 |
}, [formData.password]);
|
| 76 |
|
| 77 |
const handleChange = (e) => {
|
|
|
|
| 89 |
setError("Please fill in all required personal information.");
|
| 90 |
return;
|
| 91 |
}
|
| 92 |
+
if (passwordWarning) {
|
| 93 |
+
setError(passwordWarning);
|
|
|
|
| 94 |
return;
|
| 95 |
}
|
| 96 |
if (formData.password !== formData.confirmPassword) {
|
|
|
|
| 429 |
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
| 430 |
</button>
|
| 431 |
</div>
|
| 432 |
+
{/* Strength Meter */}
|
| 433 |
+
{formData.password && (
|
| 434 |
+
<div className="mt-2 space-y-2">
|
| 435 |
<div className="flex justify-between items-center text-[10px] font-bold uppercase tracking-widest text-gray-400">
|
| 436 |
<span>Strength: {getStrengthText()}</span>
|
| 437 |
<span>{passwordStrength}%</span>
|
| 438 |
</div>
|
|
|
|
|
|
|
| 439 |
<div className="h-1 w-full bg-gray-100 rounded-full overflow-hidden">
|
| 440 |
<motion.div
|
| 441 |
className={`h-full ${getStrengthColor()}`}
|
|
|
|
| 443 |
animate={{ width: `${passwordStrength}%` }}
|
| 444 |
/>
|
| 445 |
</div>
|
| 446 |
+
<div
|
| 447 |
+
aria-live="polite"
|
| 448 |
+
className={`text-[11px] font-semibold ${passwordWarning ? "text-red-600" : "text-emerald-700"}`}
|
| 449 |
+
>
|
| 450 |
+
{passwordWarning || "Password requirements met."}
|
| 451 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 452 |
</div>
|
| 453 |
+
)}
|
| 454 |
</div>
|
| 455 |
<div className="space-y-2">
|
| 456 |
<label className="text-xs font-bold text-gray-400 uppercase tracking-wider flex items-center gap-2">
|
Frontend/src/pages/Changelog.jsx
CHANGED
|
@@ -1,116 +1,297 @@
|
|
| 1 |
-
import React from
|
| 2 |
-
import {
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
</div>
|
| 66 |
-
</header>
|
| 67 |
|
| 68 |
-
|
| 69 |
-
<div
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
</div>
|
| 73 |
-
<h1 className="text-4xl font-black text-slate-900 tracking-tight">Product Updates & Features</h1>
|
| 74 |
-
<p className="text-slate-600 text-base leading-relaxed">
|
| 75 |
-
Stay informed about system features, framework optimizations, and local AI training models introduced into HELPDESK.AI.
|
| 76 |
-
</p>
|
| 77 |
-
</div>
|
| 78 |
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
</div>
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import {
|
| 3 |
+
Rocket,
|
| 4 |
+
Sparkles,
|
| 5 |
+
Bug,
|
| 6 |
+
ShieldCheck,
|
| 7 |
+
CalendarDays,
|
| 8 |
+
GitCommitHorizontal,
|
| 9 |
+
} from "lucide-react";
|
| 10 |
+
|
| 11 |
+
import { motion } from "framer-motion";
|
| 12 |
+
|
| 13 |
+
const releases = [
|
| 14 |
+
{
|
| 15 |
+
version: "v2.1.0",
|
| 16 |
+
date: "May 20, 2026",
|
| 17 |
+
type: "Major Release",
|
| 18 |
+
status: "Live",
|
| 19 |
+
features: [
|
| 20 |
+
"Introduced AI-powered smart ticket routing engine",
|
| 21 |
+
"Added enterprise analytics dashboard with live metrics",
|
| 22 |
+
"Integrated multilingual support for global organizations",
|
| 23 |
+
],
|
| 24 |
+
enhancements: [
|
| 25 |
+
"Improved dashboard loading performance by 35%",
|
| 26 |
+
"Enhanced accessibility contrast and readability",
|
| 27 |
+
"Optimized backend request caching for faster responses",
|
| 28 |
+
],
|
| 29 |
+
fixes: [
|
| 30 |
+
"Fixed session timeout issue after inactivity",
|
| 31 |
+
"Resolved notification synchronization bug",
|
| 32 |
+
"Fixed mobile sidebar overlap on smaller devices",
|
| 33 |
+
],
|
| 34 |
+
},
|
| 35 |
+
|
| 36 |
+
{
|
| 37 |
+
version: "v2.0.2",
|
| 38 |
+
date: "May 10, 2026",
|
| 39 |
+
type: "Patch Update",
|
| 40 |
+
status: "Stable",
|
| 41 |
+
features: [
|
| 42 |
+
"Added bulk export functionality for support tickets",
|
| 43 |
+
"Introduced advanced ticket filtering system",
|
| 44 |
+
],
|
| 45 |
+
enhancements: [
|
| 46 |
+
"Improved authentication flow security",
|
| 47 |
+
"Optimized API request handling",
|
| 48 |
+
],
|
| 49 |
+
fixes: [
|
| 50 |
+
"Resolved login redirect issue",
|
| 51 |
+
"Fixed navbar responsiveness on tablets",
|
| 52 |
+
],
|
| 53 |
+
},
|
| 54 |
+
|
| 55 |
+
{
|
| 56 |
+
version: "v2.0.0",
|
| 57 |
+
date: "April 28, 2026",
|
| 58 |
+
type: "Platform Launch",
|
| 59 |
+
status: "Released",
|
| 60 |
+
features: [
|
| 61 |
+
"Official launch of HelpDesk.AI platform",
|
| 62 |
+
"AI-based issue auto-categorization system",
|
| 63 |
+
"Role-based admin management implementation",
|
| 64 |
+
],
|
| 65 |
+
enhancements: [
|
| 66 |
+
"Modernized UI architecture using Tailwind CSS",
|
| 67 |
+
"Improved dark mode consistency",
|
| 68 |
+
],
|
| 69 |
+
fixes: [
|
| 70 |
+
"Initial stability improvements",
|
| 71 |
+
"Security hardening patches",
|
| 72 |
+
],
|
| 73 |
+
},
|
| 74 |
+
];
|
| 75 |
+
|
| 76 |
+
const FadeIn = {
|
| 77 |
+
hidden: {
|
| 78 |
+
opacity: 0,
|
| 79 |
+
y: 40,
|
| 80 |
+
},
|
| 81 |
+
visible: {
|
| 82 |
+
opacity: 1,
|
| 83 |
+
y: 0,
|
| 84 |
+
},
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
const Section = ({
|
| 88 |
+
title,
|
| 89 |
+
icon,
|
| 90 |
+
items,
|
| 91 |
+
glow,
|
| 92 |
+
}) => {
|
| 93 |
+
return (
|
| 94 |
+
<div className="mb-8">
|
| 95 |
+
|
| 96 |
+
<div className="flex items-center gap-3 mb-4">
|
| 97 |
+
<div
|
| 98 |
+
className={`p-2 rounded-xl ${glow}`}
|
| 99 |
+
>
|
| 100 |
+
{icon}
|
| 101 |
+
</div>
|
| 102 |
+
|
| 103 |
+
<h3 className="text-2xl font-semibold text-white">
|
| 104 |
+
{title}
|
| 105 |
+
</h3>
|
| 106 |
+
</div>
|
| 107 |
+
|
| 108 |
+
<div className="space-y-3">
|
| 109 |
+
{items.map((item, index) => (
|
| 110 |
+
<motion.div
|
| 111 |
+
key={index}
|
| 112 |
+
whileHover={{ x: 6 }}
|
| 113 |
+
className="flex gap-3 text-gray-300 leading-relaxed"
|
| 114 |
+
>
|
| 115 |
+
<span className="text-green-400 mt-1">
|
| 116 |
+
•
|
| 117 |
+
</span>
|
| 118 |
+
|
| 119 |
+
<p>{item}</p>
|
| 120 |
+
</motion.div>
|
| 121 |
+
))}
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
);
|
| 125 |
+
};
|
| 126 |
+
|
| 127 |
+
const Changelog = () => {
|
| 128 |
+
return (
|
| 129 |
+
<div className="min-h-screen bg-[#021510] overflow-hidden text-white relative">
|
| 130 |
+
|
| 131 |
+
{/* Background Glow Effects */}
|
| 132 |
+
<div className="absolute top-0 left-0 w-[400px] h-[400px] bg-green-500/10 blur-[120px]" />
|
| 133 |
+
|
| 134 |
+
<div className="absolute bottom-0 right-0 w-[400px] h-[400px] bg-emerald-400/10 blur-[120px]" />
|
| 135 |
+
|
| 136 |
+
{/* Hero Section */}
|
| 137 |
+
<section className="relative z-10 px-6 pt-24 pb-16">
|
| 138 |
+
|
| 139 |
+
<motion.div
|
| 140 |
+
initial="hidden"
|
| 141 |
+
animate="visible"
|
| 142 |
+
variants={FadeIn}
|
| 143 |
+
transition={{ duration: 0.7 }}
|
| 144 |
+
className="max-w-6xl mx-auto text-center"
|
| 145 |
+
>
|
| 146 |
+
|
| 147 |
+
{/* Badge */}
|
| 148 |
+
<div className="inline-flex items-center gap-2 bg-green-500/10 border border-green-500/20 px-5 py-2 rounded-full text-green-400 mb-8 backdrop-blur-md">
|
| 149 |
+
|
| 150 |
+
<GitCommitHorizontal size={16} />
|
| 151 |
+
|
| 152 |
+
<span className="text-sm font-medium tracking-wide">
|
| 153 |
+
PRODUCT RELEASE NOTES
|
| 154 |
+
</span>
|
| 155 |
+
</div>
|
| 156 |
+
|
| 157 |
+
{/* Heading */}
|
| 158 |
+
<h1 className="text-5xl md:text-7xl font-bold mb-6 tracking-tight">
|
| 159 |
+
|
| 160 |
+
Platform
|
| 161 |
+
<span className="text-green-400">
|
| 162 |
+
{" "}Changelog
|
| 163 |
+
</span>
|
| 164 |
+
</h1>
|
| 165 |
+
|
| 166 |
+
{/* Description */}
|
| 167 |
+
<p className="text-gray-400 text-lg md:text-xl max-w-3xl mx-auto leading-relaxed">
|
| 168 |
+
|
| 169 |
+
Stay updated with the latest platform improvements,
|
| 170 |
+
AI enhancements, security patches, and feature
|
| 171 |
+
releases across HelpDesk.AI.
|
| 172 |
+
</p>
|
| 173 |
+
|
| 174 |
+
</motion.div>
|
| 175 |
+
</section>
|
| 176 |
+
|
| 177 |
+
{/* Timeline */}
|
| 178 |
+
<section className="relative z-10 px-6 pb-24">
|
| 179 |
+
|
| 180 |
+
<div className="max-w-5xl mx-auto relative">
|
| 181 |
+
|
| 182 |
+
{/* Vertical Line */}
|
| 183 |
+
<div className="absolute left-4 top-0 w-[2px] h-full bg-gradient-to-b from-green-500/70 via-green-500/20 to-transparent" />
|
| 184 |
+
|
| 185 |
+
{/* Release Cards */}
|
| 186 |
+
<div className="space-y-16">
|
| 187 |
+
|
| 188 |
+
{releases.map((release, index) => (
|
| 189 |
+
<motion.div
|
| 190 |
+
key={index}
|
| 191 |
+
initial="hidden"
|
| 192 |
+
whileInView="visible"
|
| 193 |
+
viewport={{ once: true }}
|
| 194 |
+
variants={FadeIn}
|
| 195 |
+
transition={{
|
| 196 |
+
duration: 0.6,
|
| 197 |
+
delay: index * 0.1,
|
| 198 |
+
}}
|
| 199 |
+
className="relative pl-14"
|
| 200 |
+
>
|
| 201 |
+
|
| 202 |
+
{/* Timeline Dot */}
|
| 203 |
+
<div className="absolute left-0 top-8">
|
| 204 |
+
|
| 205 |
+
<div className="w-8 h-8 rounded-full bg-green-500 border-4 border-[#021510] shadow-[0_0_25px_rgba(34,197,94,0.8)]" />
|
| 206 |
+
|
| 207 |
</div>
|
|
|
|
| 208 |
|
| 209 |
+
{/* Card */}
|
| 210 |
+
<motion.div
|
| 211 |
+
whileHover={{
|
| 212 |
+
scale: 1.01,
|
| 213 |
+
}}
|
| 214 |
+
transition={{
|
| 215 |
+
duration: 0.3,
|
| 216 |
+
}}
|
| 217 |
+
className="relative bg-white/5 border border-white/10 backdrop-blur-xl rounded-3xl p-8 md:p-10 shadow-2xl overflow-hidden"
|
| 218 |
+
>
|
| 219 |
+
|
| 220 |
+
{/* Gradient Overlay */}
|
| 221 |
+
<div className="absolute inset-0 bg-gradient-to-br from-green-500/5 to-transparent pointer-events-none" />
|
| 222 |
+
|
| 223 |
+
{/* Header */}
|
| 224 |
+
<div className="relative flex flex-wrap items-center justify-between gap-4 mb-10">
|
| 225 |
+
|
| 226 |
+
<div className="flex items-center gap-4 flex-wrap">
|
| 227 |
+
|
| 228 |
+
{/* Version */}
|
| 229 |
+
<div className="bg-green-500 text-black font-bold px-5 py-2 rounded-full text-sm shadow-lg">
|
| 230 |
+
{release.version}
|
| 231 |
+
</div>
|
| 232 |
+
|
| 233 |
+
{/* Type */}
|
| 234 |
+
<div className="bg-white/10 border border-white/10 text-gray-300 px-4 py-2 rounded-full text-sm">
|
| 235 |
+
{release.type}
|
| 236 |
+
</div>
|
| 237 |
+
|
| 238 |
+
{/* Status */}
|
| 239 |
+
<div className="flex items-center gap-2 text-green-400 text-sm">
|
| 240 |
+
|
| 241 |
+
<ShieldCheck size={16} />
|
| 242 |
+
|
| 243 |
+
{release.status}
|
| 244 |
+
</div>
|
| 245 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
|
| 247 |
+
{/* Date */}
|
| 248 |
+
<div className="flex items-center gap-2 text-gray-400">
|
| 249 |
+
|
| 250 |
+
<CalendarDays size={18} />
|
| 251 |
+
|
| 252 |
+
<span>{release.date}</span>
|
| 253 |
+
</div>
|
| 254 |
+
</div>
|
| 255 |
+
|
| 256 |
+
{/* Sections */}
|
| 257 |
+
<div className="relative">
|
| 258 |
+
|
| 259 |
+
<Section
|
| 260 |
+
title="New Features"
|
| 261 |
+
glow="bg-green-500/20"
|
| 262 |
+
icon={
|
| 263 |
+
<Rocket className="text-green-400" />
|
| 264 |
+
}
|
| 265 |
+
items={release.features}
|
| 266 |
+
/>
|
| 267 |
+
|
| 268 |
+
<Section
|
| 269 |
+
title="Enhancements"
|
| 270 |
+
glow="bg-blue-500/20"
|
| 271 |
+
icon={
|
| 272 |
+
<Sparkles className="text-blue-400" />
|
| 273 |
+
}
|
| 274 |
+
items={release.enhancements}
|
| 275 |
+
/>
|
| 276 |
+
|
| 277 |
+
<Section
|
| 278 |
+
title="Bug Fixes"
|
| 279 |
+
glow="bg-red-500/20"
|
| 280 |
+
icon={
|
| 281 |
+
<Bug className="text-red-400" />
|
| 282 |
+
}
|
| 283 |
+
items={release.fixes}
|
| 284 |
+
/>
|
| 285 |
+
|
| 286 |
+
</div>
|
| 287 |
+
</motion.div>
|
| 288 |
+
</motion.div>
|
| 289 |
+
))}
|
| 290 |
+
</div>
|
| 291 |
</div>
|
| 292 |
+
</section>
|
| 293 |
+
</div>
|
| 294 |
+
);
|
| 295 |
+
};
|
| 296 |
+
|
| 297 |
+
export default Changelog;
|
Frontend/src/pages/LandingPage.jsx
CHANGED
|
@@ -1,9 +1,9 @@
|
|
| 1 |
-
import React, { useRef, useEffect, useState } from 'react';
|
| 2 |
|
| 3 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 4 |
import { useNavigate } from 'react-router-dom';
|
| 5 |
import {
|
| 6 |
-
Menu, X, Check, Activity,
|
| 7 |
MapPin, AlertCircle, Folder, Zap, Bot, ArrowRight,
|
| 8 |
Clock, CheckCircle,
|
| 9 |
Star, Twitter, Linkedin, Github, Globe, MessageSquare,
|
|
@@ -14,6 +14,12 @@ import {
|
|
| 14 |
import useAuthStore from '../store/authStore';
|
| 15 |
import TeamSection from '../components/landing/TeamSection';
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
// ---- Count-up animation component ----
|
| 18 |
function AnimatedStat({ target, suffix = '', prefix = '', label, isWord = false }) {
|
| 19 |
const [display, setDisplay] = useState(isWord ? target : '0');
|
|
@@ -124,9 +130,9 @@ function DemoModal({ onClose }) {
|
|
| 124 |
<div className="flex gap-3 w-full md:w-auto">
|
| 125 |
<button
|
| 126 |
onClick={() => { onClose(); window.location.href = '/admin-signup'; }}
|
| 127 |
-
className=
|
| 128 |
>
|
| 129 |
-
Start Free <ArrowRight className=
|
| 130 |
</button>
|
| 131 |
</div>
|
| 132 |
</div>
|
|
@@ -145,7 +151,7 @@ export default function LandingPage() {
|
|
| 145 |
const [activeStep, setActiveStep] = useState(0);
|
| 146 |
const [isRedirecting, setIsRedirecting] = useState(false);
|
| 147 |
|
| 148 |
-
const steps = [
|
| 149 |
{
|
| 150 |
num: '01',
|
| 151 |
title: 'Messy User Input',
|
|
@@ -208,7 +214,7 @@ export default function LandingPage() {
|
|
| 208 |
</div>
|
| 209 |
)
|
| 210 |
}
|
| 211 |
-
];
|
| 212 |
|
| 213 |
useEffect(() => {
|
| 214 |
if (!loading && user && profile) {
|
|
@@ -225,7 +231,7 @@ export default function LandingPage() {
|
|
| 225 |
period: '/mo',
|
| 226 |
desc: 'Perfect for small teams exploring AI helpdesk.',
|
| 227 |
cta: 'Get Started Free',
|
| 228 |
-
ctaStyle:
|
| 229 |
features: ['Up to 50 tickets/mo', 'Basic AI Categorization', 'Email Support', '1 Team Member', 'Public API Access'],
|
| 230 |
popular: false,
|
| 231 |
},
|
|
@@ -235,7 +241,7 @@ export default function LandingPage() {
|
|
| 235 |
period: '/mo',
|
| 236 |
desc: 'For growing IT teams needing full automation.',
|
| 237 |
cta: 'Start Free Trial',
|
| 238 |
-
ctaStyle:
|
| 239 |
features: ['Up to 500 tickets/mo', 'Advanced AI Parsing', 'Priority Detection Engine', 'Duplicate Detection', '5 Team Members', 'Priority Email Support'],
|
| 240 |
popular: true,
|
| 241 |
},
|
|
@@ -245,7 +251,7 @@ export default function LandingPage() {
|
|
| 245 |
period: '',
|
| 246 |
desc: 'For large organizations with complex IT landscapes.',
|
| 247 |
cta: 'Contact Sales',
|
| 248 |
-
ctaStyle:
|
| 249 |
features: ['Unlimited tickets', 'Custom AI Fine-Tuning', 'SSO & Audit Logs', 'Dedicated SLA Manager', 'Unlimited Members', 'VAPT & Compliance Reports'],
|
| 250 |
popular: false,
|
| 251 |
},
|
|
@@ -273,79 +279,163 @@ export default function LandingPage() {
|
|
| 273 |
};
|
| 274 |
|
| 275 |
return (
|
| 276 |
-
<div className="min-h-screen bg-white font-sans text-slate-800">
|
| 277 |
{showDemo && <DemoModal onClose={() => setShowDemo(false)} />}
|
| 278 |
|
| 279 |
{/* ==================== NAV ==================== */}
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
|
| 350 |
{/* ==================== HERO ==================== */}
|
| 351 |
<section className="relative pt-12 md:pt-20 pb-20 md:pb-32 overflow-hidden">
|
|
@@ -369,15 +459,15 @@ export default function LandingPage() {
|
|
| 369 |
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-6">
|
| 370 |
<button
|
| 371 |
onClick={() => navigate('/admin-signup')}
|
| 372 |
-
className=
|
| 373 |
>
|
| 374 |
-
Get Started Free <ArrowRight className=
|
| 375 |
</button>
|
| 376 |
<button
|
| 377 |
onClick={() => setShowDemo(true)}
|
| 378 |
-
className=
|
| 379 |
>
|
| 380 |
-
<Play className=
|
| 381 |
</button>
|
| 382 |
</div>
|
| 383 |
|
|
@@ -414,7 +504,7 @@ export default function LandingPage() {
|
|
| 414 |
<div className="mb-4">
|
| 415 |
<h3 className="text-sm font-bold text-gray-800 mb-1">Subject: Wifi down again in Lab 3??</h3>
|
| 416 |
<p className="text-sm text-gray-600 leading-relaxed">
|
| 417 |
-
Hey support, the wifi in <span className="bg-yellow-
|
| 418 |
Can't connect at all. Class starts in 20 mins, need this fixed ASAP!<br /><br />
|
| 419 |
Thanks,<br />Sarah
|
| 420 |
</p>
|
|
@@ -645,7 +735,7 @@ export default function LandingPage() {
|
|
| 645 |
<div className="inline-flex items-center gap-2 px-3 py-1 bg-emerald-500/10 text-emerald-400 rounded-full text-[10px] md:text-xs font-bold uppercase tracking-widest border border-emerald-500/20 mb-4 md:mb-6">
|
| 646 |
The Journey
|
| 647 |
</div>
|
| 648 |
-
<h2 className="text-3xl sm:text-4xl md:text-6xl font-
|
| 649 |
From Chaos <br />
|
| 650 |
to <span className="text-emerald-500">Clarity.</span>
|
| 651 |
</h2>
|
|
@@ -656,28 +746,25 @@ export default function LandingPage() {
|
|
| 656 |
key={idx}
|
| 657 |
onMouseEnter={() => setActiveStep(idx)}
|
| 658 |
onClick={() => setActiveStep(idx)}
|
| 659 |
-
className={`group cursor-pointer p-6 rounded-3xl transition-all duration-
|
| 660 |
? 'bg-white/10 border-white/20 shadow-2xl shadow-black/20'
|
| 661 |
: 'bg-transparent border-transparent hover:bg-white/5 opacity-40 hover:opacity-100'
|
| 662 |
}`}
|
| 663 |
>
|
| 664 |
<div className="flex items-start gap-4 md:gap-6">
|
| 665 |
-
<div className={`shrink-0 w-10 md:w-12 h-10 md:h-12 rounded-xl md:rounded-2xl flex items-center justify-center font-
|
| 666 |
{step.num}
|
| 667 |
</div>
|
| 668 |
<div>
|
| 669 |
-
<h3 className={`text-xl font-
|
| 670 |
{step.title}
|
| 671 |
</h3>
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
{step.desc}
|
| 679 |
-
</motion.p>
|
| 680 |
-
)}
|
| 681 |
</div>
|
| 682 |
</div>
|
| 683 |
</div>
|
|
@@ -688,14 +775,14 @@ export default function LandingPage() {
|
|
| 688 |
{/* Right: Visual Display */}
|
| 689 |
<div className="w-full md:w-1/2 h-[350px] md:h-[500px] relative">
|
| 690 |
<div className="absolute inset-0 bg-gradient-to-br from-emerald-500/10 to-transparent rounded-[32px] md:rounded-[40px] border border-white/5 backdrop-blur-3xl overflow-hidden p-6 md:p-12 flex items-center justify-center">
|
| 691 |
-
<AnimatePresence mode="
|
| 692 |
<motion.div
|
| 693 |
key={activeStep}
|
| 694 |
-
initial={{ opacity: 0, scale: 0.
|
| 695 |
animate={{ opacity: 1, scale: 1, y: 0 }}
|
| 696 |
-
exit={{ opacity: 0, scale: 1.
|
| 697 |
-
transition={{
|
| 698 |
-
className="w-full h-full flex flex-col items-center justify-center"
|
| 699 |
>
|
| 700 |
<div className="mb-6 inline-flex items-center gap-2 px-4 py-1.5 bg-white/5 text-white/50 rounded-full text-[10px] font-black uppercase tracking-[0.2em]">
|
| 701 |
<div className={`w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse`} />
|
|
@@ -759,7 +846,7 @@ export default function LandingPage() {
|
|
| 759 |
<button
|
| 760 |
onClick={() => handlePricingClick(name)}
|
| 761 |
disabled={isRedirecting && name === 'Growth'}
|
| 762 |
-
className={`w-full flex items-center justify-center gap-2 py-3 rounded-xl font-semibold
|
| 763 |
>
|
| 764 |
{isRedirecting && name === 'Growth' ? (
|
| 765 |
<>
|
|
@@ -802,15 +889,15 @@ export default function LandingPage() {
|
|
| 802 |
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
| 803 |
<button
|
| 804 |
onClick={() => navigate('/admin-signup')}
|
| 805 |
-
className=
|
| 806 |
>
|
| 807 |
Get Started Free
|
| 808 |
</button>
|
| 809 |
<button
|
| 810 |
onClick={() => setShowDemo(true)}
|
| 811 |
-
className=
|
| 812 |
>
|
| 813 |
-
<Play className=
|
| 814 |
</button>
|
| 815 |
</div>
|
| 816 |
<div className="mt-8">
|
|
@@ -862,17 +949,17 @@ export default function LandingPage() {
|
|
| 862 |
{
|
| 863 |
heading: 'Resources',
|
| 864 |
links: [
|
| 865 |
-
{ label: 'Documentation', href: '
|
| 866 |
-
{ label: 'API Reference', href: '
|
| 867 |
{ label: 'Changelog', href: '/changelog' },
|
| 868 |
-
{ label: 'Status Page', href: '
|
| 869 |
]
|
| 870 |
},
|
| 871 |
{
|
| 872 |
heading: 'Company',
|
| 873 |
links: [
|
| 874 |
-
{ label: 'About Us', href: '/about' },
|
| 875 |
-
{ label: 'Careers', href: '
|
| 876 |
{ label: 'Privacy Policy', href: '/privacy' },
|
| 877 |
{ label: 'Terms of Service', href: '/terms' },
|
| 878 |
]
|
|
@@ -883,7 +970,7 @@ export default function LandingPage() {
|
|
| 883 |
{ label: 'Security Overview', href: '/security' },
|
| 884 |
{ label: 'Privacy Policy', href: '/privacy' },
|
| 885 |
{ label: 'Terms of Service', href: '/terms' },
|
| 886 |
-
{ label: 'Cookie Policy', href: '
|
| 887 |
]
|
| 888 |
},
|
| 889 |
].map(({ heading, links }) => (
|
|
|
|
| 1 |
+
import React, { useRef, useEffect, useState, useMemo } from 'react';
|
| 2 |
|
| 3 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 4 |
import { useNavigate } from 'react-router-dom';
|
| 5 |
import {
|
| 6 |
+
Menu, X, Check, Activity, Moon, Sun,
|
| 7 |
MapPin, AlertCircle, Folder, Zap, Bot, ArrowRight,
|
| 8 |
Clock, CheckCircle,
|
| 9 |
Star, Twitter, Linkedin, Github, Globe, MessageSquare,
|
|
|
|
| 14 |
import useAuthStore from '../store/authStore';
|
| 15 |
import TeamSection from '../components/landing/TeamSection';
|
| 16 |
|
| 17 |
+
const CTA_TRANSITION = 'transition-all duration-200 ease-out transform-gpu will-change-transform';
|
| 18 |
+
const CTA_SCALE = 'hover:scale-105 active:scale-[0.98]';
|
| 19 |
+
const CTA_PRIMARY_GLOW = 'shadow-lg shadow-emerald-900/20 hover:shadow-emerald-900/35';
|
| 20 |
+
const CTA_ICON_SHIFT = 'transition-transform duration-200 group-hover:translate-x-1';
|
| 21 |
+
const CTA_PLAY_SHIFT = 'transition-transform duration-200 group-hover:translate-x-0.5';
|
| 22 |
+
|
| 23 |
// ---- Count-up animation component ----
|
| 24 |
function AnimatedStat({ target, suffix = '', prefix = '', label, isWord = false }) {
|
| 25 |
const [display, setDisplay] = useState(isWord ? target : '0');
|
|
|
|
| 130 |
<div className="flex gap-3 w-full md:w-auto">
|
| 131 |
<button
|
| 132 |
onClick={() => { onClose(); window.location.href = '/admin-signup'; }}
|
| 133 |
+
className={`group flex-1 md:px-8 bg-emerald-600 hover:bg-emerald-500 text-white py-3 rounded-xl font-black italic uppercase tracking-wider ${CTA_TRANSITION} ${CTA_SCALE} ${CTA_PRIMARY_GLOW} flex items-center justify-center gap-2`}
|
| 134 |
>
|
| 135 |
+
Start Free <ArrowRight className={`w-4 h-4 ml-1 ${CTA_ICON_SHIFT}`} />
|
| 136 |
</button>
|
| 137 |
</div>
|
| 138 |
</div>
|
|
|
|
| 151 |
const [activeStep, setActiveStep] = useState(0);
|
| 152 |
const [isRedirecting, setIsRedirecting] = useState(false);
|
| 153 |
|
| 154 |
+
const steps = useMemo(() => [
|
| 155 |
{
|
| 156 |
num: '01',
|
| 157 |
title: 'Messy User Input',
|
|
|
|
| 214 |
</div>
|
| 215 |
)
|
| 216 |
}
|
| 217 |
+
], []);
|
| 218 |
|
| 219 |
useEffect(() => {
|
| 220 |
if (!loading && user && profile) {
|
|
|
|
| 231 |
period: '/mo',
|
| 232 |
desc: 'Perfect for small teams exploring AI helpdesk.',
|
| 233 |
cta: 'Get Started Free',
|
| 234 |
+
ctaStyle: `border border-gray-200 text-gray-700 hover:border-emerald-900 hover:text-emerald-800 group ${CTA_TRANSITION} ${CTA_SCALE}`,
|
| 235 |
features: ['Up to 50 tickets/mo', 'Basic AI Categorization', 'Email Support', '1 Team Member', 'Public API Access'],
|
| 236 |
popular: false,
|
| 237 |
},
|
|
|
|
| 241 |
period: '/mo',
|
| 242 |
desc: 'For growing IT teams needing full automation.',
|
| 243 |
cta: 'Start Free Trial',
|
| 244 |
+
ctaStyle: `bg-emerald-900 text-white hover:bg-emerald-800 group ${CTA_TRANSITION} ${CTA_SCALE} ${CTA_PRIMARY_GLOW}`,
|
| 245 |
features: ['Up to 500 tickets/mo', 'Advanced AI Parsing', 'Priority Detection Engine', 'Duplicate Detection', '5 Team Members', 'Priority Email Support'],
|
| 246 |
popular: true,
|
| 247 |
},
|
|
|
|
| 251 |
period: '',
|
| 252 |
desc: 'For large organizations with complex IT landscapes.',
|
| 253 |
cta: 'Contact Sales',
|
| 254 |
+
ctaStyle: `border border-gray-200 text-gray-700 hover:border-emerald-900 hover:text-emerald-800 group ${CTA_TRANSITION} ${CTA_SCALE}`,
|
| 255 |
features: ['Unlimited tickets', 'Custom AI Fine-Tuning', 'SSO & Audit Logs', 'Dedicated SLA Manager', 'Unlimited Members', 'VAPT & Compliance Reports'],
|
| 256 |
popular: false,
|
| 257 |
},
|
|
|
|
| 279 |
};
|
| 280 |
|
| 281 |
return (
|
| 282 |
+
<div className="min-h-screen bg-white dark:bg-gray-900 font-sans text-slate-800 dark:text-white">
|
| 283 |
{showDemo && <DemoModal onClose={() => setShowDemo(false)} />}
|
| 284 |
|
| 285 |
{/* ==================== NAV ==================== */}
|
| 286 |
+
<nav className="sticky top-0 z-50 bg-white/90 backdrop-blur-md border-b border-gray-100 shadow-sm">
|
| 287 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
| 288 |
+
<div className="flex items-center justify-between h-20">
|
| 289 |
+
|
| 290 |
+
{/* Logo */}
|
| 291 |
+
<div
|
| 292 |
+
className="flex items-center gap-3 cursor-pointer"
|
| 293 |
+
onClick={() => navigate('/')}
|
| 294 |
+
>
|
| 295 |
+
<img src="/favicon.png" alt="logo" className="w-10 h-10" />
|
| 296 |
+
<span className="font-black italic text-emerald-900 text-2xl">
|
| 297 |
+
HELPDESK.AI
|
| 298 |
+
</span>
|
| 299 |
+
</div>
|
| 300 |
+
|
| 301 |
+
{/* Desktop Nav */}
|
| 302 |
+
<div className="hidden lg:flex items-center gap-10">
|
| 303 |
+
|
| 304 |
+
<button
|
| 305 |
+
className="p-2 rounded-lg hover:bg-gray-100 transition"
|
| 306 |
+
>
|
| 307 |
+
<Moon className="w-6 h-6 text-gray-600" />
|
| 308 |
+
</button>
|
| 309 |
+
|
| 310 |
+
<a
|
| 311 |
+
href="#features"
|
| 312 |
+
className="font-semibold text-gray-600 hover:text-emerald-700 transition"
|
| 313 |
+
>
|
| 314 |
+
Features
|
| 315 |
+
</a>
|
| 316 |
+
|
| 317 |
+
<a
|
| 318 |
+
href="#how-it-works"
|
| 319 |
+
className="font-semibold text-gray-600 hover:text-emerald-700 transition"
|
| 320 |
+
>
|
| 321 |
+
How It Works
|
| 322 |
+
</a>
|
| 323 |
+
|
| 324 |
+
<a
|
| 325 |
+
href="#pricing"
|
| 326 |
+
className="font-semibold text-gray-600 hover:text-emerald-700 transition"
|
| 327 |
+
>
|
| 328 |
+
Pricing
|
| 329 |
+
</a>
|
| 330 |
+
</div>
|
| 331 |
+
|
| 332 |
+
{/* Desktop Actions */}
|
| 333 |
+
<div className="hidden lg:flex items-center gap-4">
|
| 334 |
+
|
| 335 |
+
<button
|
| 336 |
+
onClick={() => navigate('/login')}
|
| 337 |
+
className="font-semibold text-gray-700 hover:text-emerald-700"
|
| 338 |
+
>
|
| 339 |
+
Sign In
|
| 340 |
+
</button>
|
| 341 |
+
|
| 342 |
+
<button
|
| 343 |
+
onClick={() => setShowDemo(true)}
|
| 344 |
+
className="flex items-center gap-2 px-6 py-3 border border-emerald-200 rounded-xl text-emerald-700 font-semibold hover:bg-emerald-50 transition"
|
| 345 |
+
>
|
| 346 |
+
<Play size={16} fill="currentColor" />
|
| 347 |
+
Watch Demo
|
| 348 |
+
</button>
|
| 349 |
+
|
| 350 |
+
<button
|
| 351 |
+
onClick={() => navigate('/admin-signup')}
|
| 352 |
+
className="px-6 py-3 bg-emerald-900 hover:bg-emerald-800 text-white rounded-xl font-bold shadow-lg"
|
| 353 |
+
>
|
| 354 |
+
Get Started Free
|
| 355 |
+
</button>
|
| 356 |
+
|
| 357 |
+
</div>
|
| 358 |
+
|
| 359 |
+
{/* Mobile Buttons */}
|
| 360 |
+
<div className="flex lg:hidden items-center gap-2">
|
| 361 |
+
|
| 362 |
+
<button className="p-2">
|
| 363 |
+
<Moon className="w-5 h-5 text-gray-600" />
|
| 364 |
+
</button>
|
| 365 |
+
|
| 366 |
+
<button
|
| 367 |
+
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
| 368 |
+
className="p-2 border border-gray-200 rounded-lg"
|
| 369 |
+
>
|
| 370 |
+
{isMenuOpen ? (
|
| 371 |
+
<X className="w-6 h-6" />
|
| 372 |
+
) : (
|
| 373 |
+
<Menu className="w-6 h-6" />
|
| 374 |
+
)}
|
| 375 |
+
</button>
|
| 376 |
+
|
| 377 |
+
</div>
|
| 378 |
+
|
| 379 |
+
</div>
|
| 380 |
+
</div>
|
| 381 |
+
|
| 382 |
+
{/* Mobile Menu */}
|
| 383 |
+
{isMenuOpen && (
|
| 384 |
+
<div className="lg:hidden absolute top-full left-0 w-full border-t border-gray-100 bg-white shadow-xl dark:bg-gray-900 dark:border-gray-800">
|
| 385 |
+
<div className="flex flex-col px-4 py-5 gap-4">
|
| 386 |
+
|
| 387 |
+
<a
|
| 388 |
+
href="#features"
|
| 389 |
+
onClick={() => setIsMenuOpen(false)}
|
| 390 |
+
className="font-medium text-gray-700"
|
| 391 |
+
>
|
| 392 |
+
Features
|
| 393 |
+
</a>
|
| 394 |
+
|
| 395 |
+
<a
|
| 396 |
+
href="#how-it-works"
|
| 397 |
+
onClick={() => setIsMenuOpen(false)}
|
| 398 |
+
className="font-medium text-gray-700"
|
| 399 |
+
>
|
| 400 |
+
How It Works
|
| 401 |
+
</a>
|
| 402 |
+
|
| 403 |
+
<a
|
| 404 |
+
href="#pricing"
|
| 405 |
+
onClick={() => setIsMenuOpen(false)}
|
| 406 |
+
className="font-medium text-gray-700"
|
| 407 |
+
>
|
| 408 |
+
Pricing
|
| 409 |
+
</a>
|
| 410 |
+
|
| 411 |
+
<hr />
|
| 412 |
+
|
| 413 |
+
<button
|
| 414 |
+
onClick={() => navigate('/login')}
|
| 415 |
+
className="text-left font-medium"
|
| 416 |
+
>
|
| 417 |
+
Sign In
|
| 418 |
+
</button>
|
| 419 |
+
|
| 420 |
+
<button
|
| 421 |
+
onClick={() => setShowDemo(true)}
|
| 422 |
+
className="flex items-center gap-2 font-medium"
|
| 423 |
+
>
|
| 424 |
+
<Play size={16} />
|
| 425 |
+
Watch Demo
|
| 426 |
+
</button>
|
| 427 |
+
|
| 428 |
+
<button
|
| 429 |
+
onClick={() => navigate('/admin-signup')}
|
| 430 |
+
className="bg-emerald-900 text-white py-3 rounded-xl font-semibold"
|
| 431 |
+
>
|
| 432 |
+
Get Started Free
|
| 433 |
+
</button>
|
| 434 |
+
|
| 435 |
+
</div>
|
| 436 |
+
</div>
|
| 437 |
+
)}
|
| 438 |
+
</nav>
|
| 439 |
|
| 440 |
{/* ==================== HERO ==================== */}
|
| 441 |
<section className="relative pt-12 md:pt-20 pb-20 md:pb-32 overflow-hidden">
|
|
|
|
| 459 |
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-6">
|
| 460 |
<button
|
| 461 |
onClick={() => navigate('/admin-signup')}
|
| 462 |
+
className={`group w-full sm:w-auto px-8 py-4 bg-emerald-900 text-white rounded-xl font-bold ${CTA_PRIMARY_GLOW} hover:bg-emerald-800 ${CTA_TRANSITION} ${CTA_SCALE} flex items-center justify-center gap-2 text-base`}
|
| 463 |
>
|
| 464 |
+
Get Started Free <ArrowRight className={`w-5 h-5 ${CTA_ICON_SHIFT}`} />
|
| 465 |
</button>
|
| 466 |
<button
|
| 467 |
onClick={() => setShowDemo(true)}
|
| 468 |
+
className={`group w-full sm:w-auto px-8 py-4 bg-white text-gray-700 border border-gray-200 rounded-xl font-semibold hover:border-emerald-500 hover:text-emerald-700 ${CTA_TRANSITION} ${CTA_SCALE} flex items-center justify-center gap-2 text-base`}
|
| 469 |
>
|
| 470 |
+
<Play className={`w-4 h-4 fill-gray-500 ${CTA_PLAY_SHIFT}`} /> Watch a Demo
|
| 471 |
</button>
|
| 472 |
</div>
|
| 473 |
|
|
|
|
| 504 |
<div className="mb-4">
|
| 505 |
<h3 className="text-sm font-bold text-gray-800 mb-1">Subject: Wifi down again in Lab 3??</h3>
|
| 506 |
<p className="text-sm text-gray-600 leading-relaxed">
|
| 507 |
+
Hey support, the wifi in <span className="bg-yellow-200 dark:bg-yellow-500/30 dark:text-yellow-200 text-yellow-900 px-1 rounded font-medium">downstairs lab 3</span> is acting up again.
|
| 508 |
Can't connect at all. Class starts in 20 mins, need this fixed ASAP!<br /><br />
|
| 509 |
Thanks,<br />Sarah
|
| 510 |
</p>
|
|
|
|
| 735 |
<div className="inline-flex items-center gap-2 px-3 py-1 bg-emerald-500/10 text-emerald-400 rounded-full text-[10px] md:text-xs font-bold uppercase tracking-widest border border-emerald-500/20 mb-4 md:mb-6">
|
| 736 |
The Journey
|
| 737 |
</div>
|
| 738 |
+
<h2 className="text-3xl sm:text-4xl md:text-6xl font-extrabold text-white tracking-tight leading-[0.95] mb-8 md:mb-12">
|
| 739 |
From Chaos <br />
|
| 740 |
to <span className="text-emerald-500">Clarity.</span>
|
| 741 |
</h2>
|
|
|
|
| 746 |
key={idx}
|
| 747 |
onMouseEnter={() => setActiveStep(idx)}
|
| 748 |
onClick={() => setActiveStep(idx)}
|
| 749 |
+
className={`group cursor-pointer p-6 rounded-3xl transition-all duration-300 border ${activeStep === idx
|
| 750 |
? 'bg-white/10 border-white/20 shadow-2xl shadow-black/20'
|
| 751 |
: 'bg-transparent border-transparent hover:bg-white/5 opacity-40 hover:opacity-100'
|
| 752 |
}`}
|
| 753 |
>
|
| 754 |
<div className="flex items-start gap-4 md:gap-6">
|
| 755 |
+
<div className={`shrink-0 w-10 md:w-12 h-10 md:h-12 rounded-xl md:rounded-2xl flex items-center justify-center font-bold text-lg md:text-xl transition-all duration-300 ${activeStep === idx ? 'bg-emerald-500 text-white shadow-lg shadow-emerald-500/20 scale-105' : 'bg-white/10 text-white/40'}`}>
|
| 756 |
{step.num}
|
| 757 |
</div>
|
| 758 |
<div>
|
| 759 |
+
<h3 className={`text-xl font-semibold tracking-tight transition-colors duration-300 ${activeStep === idx ? 'text-white' : 'text-white/60'}`}>
|
| 760 |
{step.title}
|
| 761 |
</h3>
|
| 762 |
+
<p
|
| 763 |
+
aria-hidden={activeStep !== idx}
|
| 764 |
+
className={`text-white/60 text-sm mt-3 leading-relaxed max-w-sm overflow-hidden transition-all duration-300 ${activeStep === idx ? 'opacity-100 max-h-24' : 'opacity-0 max-h-0'}`}
|
| 765 |
+
>
|
| 766 |
+
{step.desc}
|
| 767 |
+
</p>
|
|
|
|
|
|
|
|
|
|
| 768 |
</div>
|
| 769 |
</div>
|
| 770 |
</div>
|
|
|
|
| 775 |
{/* Right: Visual Display */}
|
| 776 |
<div className="w-full md:w-1/2 h-[350px] md:h-[500px] relative">
|
| 777 |
<div className="absolute inset-0 bg-gradient-to-br from-emerald-500/10 to-transparent rounded-[32px] md:rounded-[40px] border border-white/5 backdrop-blur-3xl overflow-hidden p-6 md:p-12 flex items-center justify-center">
|
| 778 |
+
<AnimatePresence mode="sync">
|
| 779 |
<motion.div
|
| 780 |
key={activeStep}
|
| 781 |
+
initial={{ opacity: 0, scale: 0.98, y: 10 }}
|
| 782 |
animate={{ opacity: 1, scale: 1, y: 0 }}
|
| 783 |
+
exit={{ opacity: 0, scale: 1.01, y: -8 }}
|
| 784 |
+
transition={{ duration: 0.28, ease: 'easeOut' }}
|
| 785 |
+
className="w-full h-full flex flex-col items-center justify-center will-change-transform"
|
| 786 |
>
|
| 787 |
<div className="mb-6 inline-flex items-center gap-2 px-4 py-1.5 bg-white/5 text-white/50 rounded-full text-[10px] font-black uppercase tracking-[0.2em]">
|
| 788 |
<div className={`w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse`} />
|
|
|
|
| 846 |
<button
|
| 847 |
onClick={() => handlePricingClick(name)}
|
| 848 |
disabled={isRedirecting && name === 'Growth'}
|
| 849 |
+
className={`w-full flex items-center justify-center gap-2 py-3 rounded-xl font-semibold mb-8 text-sm ${ctaStyle} ${isRedirecting && name === 'Growth' ? 'opacity-80 cursor-not-allowed' : ''}`}
|
| 850 |
>
|
| 851 |
{isRedirecting && name === 'Growth' ? (
|
| 852 |
<>
|
|
|
|
| 889 |
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
| 890 |
<button
|
| 891 |
onClick={() => navigate('/admin-signup')}
|
| 892 |
+
className={`group w-full sm:w-auto px-8 py-4 bg-white text-emerald-900 font-bold rounded-xl hover:bg-green-50 ${CTA_TRANSITION} ${CTA_SCALE} shadow-xl`}
|
| 893 |
>
|
| 894 |
Get Started Free
|
| 895 |
</button>
|
| 896 |
<button
|
| 897 |
onClick={() => setShowDemo(true)}
|
| 898 |
+
className={`group w-full sm:w-auto px-8 py-4 border border-white/30 text-white font-semibold rounded-xl hover:bg-white/10 ${CTA_TRANSITION} ${CTA_SCALE} flex items-center justify-center gap-2`}
|
| 899 |
>
|
| 900 |
+
<Play className={`w-4 h-4 fill-white ${CTA_PLAY_SHIFT}`} /> Watch Demo
|
| 901 |
</button>
|
| 902 |
</div>
|
| 903 |
<div className="mt-8">
|
|
|
|
| 949 |
{
|
| 950 |
heading: 'Resources',
|
| 951 |
links: [
|
| 952 |
+
{ label: 'Documentation', href: '#' },
|
| 953 |
+
{ label: 'API Reference', href: '#' },
|
| 954 |
{ label: 'Changelog', href: '/changelog' },
|
| 955 |
+
{ label: 'Status Page', href: '#' },
|
| 956 |
]
|
| 957 |
},
|
| 958 |
{
|
| 959 |
heading: 'Company',
|
| 960 |
links: [
|
| 961 |
+
{ label: 'About Us', href: '/about-us' },
|
| 962 |
+
{ label: 'Careers', href: '#' },
|
| 963 |
{ label: 'Privacy Policy', href: '/privacy' },
|
| 964 |
{ label: 'Terms of Service', href: '/terms' },
|
| 965 |
]
|
|
|
|
| 970 |
{ label: 'Security Overview', href: '/security' },
|
| 971 |
{ label: 'Privacy Policy', href: '/privacy' },
|
| 972 |
{ label: 'Terms of Service', href: '/terms' },
|
| 973 |
+
{ label: 'Cookie Policy', href: '#' },
|
| 974 |
]
|
| 975 |
},
|
| 976 |
].map(({ heading, links }) => (
|
Frontend/src/pages/Login.jsx
CHANGED
|
@@ -7,15 +7,17 @@ import { Eye, EyeOff, BrainCircuit, ArrowRight, Loader2, ArrowLeft } from "lucid
|
|
| 7 |
|
| 8 |
function Login() {
|
| 9 |
const [email, setEmail] = useState("");
|
|
|
|
| 10 |
const [password, setPassword] = useState("");
|
| 11 |
const [showPassword, setShowPassword] = useState(false);
|
| 12 |
const [error, setError] = useState("");
|
|
|
|
| 13 |
|
| 14 |
const [isMagicLink, setIsMagicLink] = useState(false);
|
| 15 |
const [magicLinkSent, setMagicLinkSent] = useState(false);
|
| 16 |
|
| 17 |
const navigate = useNavigate();
|
| 18 |
-
const { login, signInWithMagicLink,
|
| 19 |
|
| 20 |
// Auto-redirect if already logged in
|
| 21 |
useEffect(() => {
|
|
@@ -41,6 +43,7 @@ function Login() {
|
|
| 41 |
}
|
| 42 |
|
| 43 |
setError("");
|
|
|
|
| 44 |
|
| 45 |
try {
|
| 46 |
const { profile } = await login(email, password);
|
|
@@ -55,7 +58,7 @@ function Login() {
|
|
| 55 |
|
| 56 |
if (profile.status === "rejected") {
|
| 57 |
navigate("/not-approved");
|
| 58 |
-
return;
|
| 59 |
}
|
| 60 |
|
| 61 |
if (profile.role === "master_admin" && profile.status === "active") {
|
|
@@ -74,6 +77,8 @@ function Login() {
|
|
| 74 |
errMsg = "Network Error: Failed to fetch. This usually happens if your browser's ad-blocker (like Brave Shields, uBlock Origin, etc.) is blocking Supabase requests. Please try disabling your ad-blocker for this site and refresh!";
|
| 75 |
}
|
| 76 |
setError(errMsg);
|
|
|
|
|
|
|
| 77 |
}
|
| 78 |
};
|
| 79 |
|
|
@@ -85,6 +90,7 @@ function Login() {
|
|
| 85 |
}
|
| 86 |
|
| 87 |
setError("");
|
|
|
|
| 88 |
try {
|
| 89 |
await signInWithMagicLink(email);
|
| 90 |
setMagicLinkSent(true);
|
|
@@ -95,6 +101,16 @@ function Login() {
|
|
| 95 |
errMsg = "Network Error: Failed to fetch. This usually happens if your browser's ad-blocker (like Brave Shields, uBlock Origin, etc.) is blocking Supabase requests. Please try disabling your ad-blocker for this site and refresh!";
|
| 96 |
}
|
| 97 |
setError(errMsg);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
}
|
| 99 |
};
|
| 100 |
|
|
@@ -107,10 +123,11 @@ function Login() {
|
|
| 107 |
<div
|
| 108 |
className="hidden lg:flex w-1/2 items-center justify-center p-12 relative overflow-hidden"
|
| 109 |
style={{
|
| 110 |
-
background:
|
|
|
|
|
|
|
| 111 |
}}
|
| 112 |
>
|
| 113 |
-
{/* Radial glow */}
|
| 114 |
<div
|
| 115 |
className="absolute top-0 left-0 w-[600px] h-[600px] rounded-full pointer-events-none"
|
| 116 |
style={{
|
|
@@ -119,7 +136,6 @@ function Login() {
|
|
| 119 |
/>
|
| 120 |
|
| 121 |
<div className="relative z-10 max-w-lg">
|
| 122 |
-
{/* Logo / Icon */}
|
| 123 |
<div
|
| 124 |
className="p-3 rounded-2xl w-fit mb-8"
|
| 125 |
style={{ background: 'rgba(34,160,69,0.08)', border: '1px solid #d1fae5' }}
|
|
@@ -127,13 +143,12 @@ function Login() {
|
|
| 127 |
<BrainCircuit className="w-10 h-10" style={{ color: '#16a34a' }} />
|
| 128 |
</div>
|
| 129 |
|
| 130 |
-
{/* Headline */}
|
| 131 |
<h1
|
| 132 |
style={{
|
| 133 |
fontFamily: "'Syne', sans-serif",
|
| 134 |
fontSize: '48px',
|
| 135 |
fontWeight: 800,
|
| 136 |
-
color: '#0f1f12',
|
| 137 |
letterSpacing: '-0.03em',
|
| 138 |
lineHeight: 1.1,
|
| 139 |
marginBottom: '24px',
|
|
@@ -143,12 +158,10 @@ function Login() {
|
|
| 143 |
<span style={{ color: '#16a34a' }}>IT Support</span>
|
| 144 |
</h1>
|
| 145 |
|
| 146 |
-
{
|
| 147 |
-
<p style={{ color: '#374151', fontSize: '16px', lineHeight: 1.7, marginBottom: '32px' }}>
|
| 148 |
Join thousands of IT teams using HelpDesk.ai to categorize, route, and resolve tickets instantly.
|
| 149 |
</p>
|
| 150 |
|
| 151 |
-
{/* System Status Badge */}
|
| 152 |
<div
|
| 153 |
style={{
|
| 154 |
background: '#ffffff',
|
|
@@ -180,9 +193,8 @@ function Login() {
|
|
| 180 |
{/* ── Right Panel ── */}
|
| 181 |
<div
|
| 182 |
className="flex w-full lg:w-1/2 items-center justify-center p-6 relative"
|
| 183 |
-
style={{ background: '#ffffff', borderLeft: '1px solid #f0fdf4' }}
|
| 184 |
>
|
| 185 |
-
{/* Back Button */}
|
| 186 |
<Link
|
| 187 |
to="/"
|
| 188 |
className="absolute top-8 left-8 flex items-center gap-2 transition-all group"
|
|
@@ -197,14 +209,13 @@ function Login() {
|
|
| 197 |
</Link>
|
| 198 |
|
| 199 |
<div className="w-full max-w-md mt-8 lg:mt-0" style={{ padding: '32px' }}>
|
| 200 |
-
{/* Header */}
|
| 201 |
<div className="text-center" style={{ marginBottom: '40px' }}>
|
| 202 |
<h2
|
| 203 |
style={{
|
| 204 |
fontFamily: "'Syne', sans-serif",
|
| 205 |
fontSize: '28px',
|
| 206 |
fontWeight: 800,
|
| 207 |
-
color: '#0f1f12',
|
| 208 |
letterSpacing: '-0.02em',
|
| 209 |
marginBottom: '8px',
|
| 210 |
}}
|
|
@@ -214,8 +225,6 @@ function Login() {
|
|
| 214 |
<p style={{ color: '#6b7280', fontSize: '14px' }}>Please sign in to continue</p>
|
| 215 |
</div>
|
| 216 |
|
| 217 |
-
{/* Role Toggle Removed */}
|
| 218 |
-
|
| 219 |
{error && (
|
| 220 |
<div className="mb-6 flex items-start gap-3" style={{ background: '#fef2f2', border: '1px solid #fee2e2', borderRadius: '12px', padding: '14px 16px' }}>
|
| 221 |
<div className="rounded-full p-1 mt-0.5" style={{ background: '#fee2e2' }}>
|
|
@@ -242,7 +251,6 @@ function Login() {
|
|
| 242 |
</div>
|
| 243 |
) : (
|
| 244 |
<form onSubmit={currentSubmitHandler} className="space-y-5">
|
| 245 |
-
{/* Email Field */}
|
| 246 |
<div>
|
| 247 |
<label
|
| 248 |
className="block mb-2"
|
|
@@ -271,7 +279,6 @@ function Login() {
|
|
| 271 |
/>
|
| 272 |
</div>
|
| 273 |
|
| 274 |
-
{/* Password Field */}
|
| 275 |
{!isMagicLink && (
|
| 276 |
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }}>
|
| 277 |
<div className="flex justify-between items-center mb-2">
|
|
@@ -323,10 +330,9 @@ function Login() {
|
|
| 323 |
</motion.div>
|
| 324 |
)}
|
| 325 |
|
| 326 |
-
{/* Sign In Button */}
|
| 327 |
<button
|
| 328 |
type="submit"
|
| 329 |
-
disabled={
|
| 330 |
className="w-full flex items-center justify-center gap-2 active:scale-[0.98] disabled:opacity-70 disabled:cursor-not-allowed"
|
| 331 |
style={{
|
| 332 |
background: 'linear-gradient(135deg, #16a34a, #22c55e)',
|
|
@@ -343,8 +349,47 @@ function Login() {
|
|
| 343 |
onMouseEnter={(e) => { e.currentTarget.style.transform = 'translateY(-1px)'; e.currentTarget.style.boxShadow = '0 6px 24px rgba(34,160,69,0.35)'; }}
|
| 344 |
onMouseLeave={(e) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 4px 20px rgba(34,160,69,0.3)'; }}
|
| 345 |
>
|
| 346 |
-
{
|
| 347 |
-
{!
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
</button>
|
| 349 |
|
| 350 |
{/* Divider */}
|
|
@@ -354,7 +399,6 @@ function Login() {
|
|
| 354 |
<div className="flex-grow" style={{ borderTop: '1px solid #e5e7eb' }}></div>
|
| 355 |
</div>
|
| 356 |
|
| 357 |
-
{/* Magic Link Toggle */}
|
| 358 |
<button
|
| 359 |
type="button"
|
| 360 |
onClick={() => { setIsMagicLink(!isMagicLink); setError(""); }}
|
|
@@ -376,7 +420,6 @@ function Login() {
|
|
| 376 |
{isMagicLink ? "Sign in with Password" : "Sign in with Magic Link"}
|
| 377 |
</button>
|
| 378 |
|
| 379 |
-
{/* Create Account */}
|
| 380 |
<p className="text-center" style={{ fontSize: '14px', color: '#6b7280', marginTop: '32px' }}>
|
| 381 |
Don't have an account?{" "}
|
| 382 |
<Link to="/signup" className="hover:underline transition-all" style={{ color: '#16a34a', fontWeight: 600 }}>
|
|
@@ -391,4 +434,4 @@ function Login() {
|
|
| 391 |
);
|
| 392 |
}
|
| 393 |
|
| 394 |
-
export default Login;
|
|
|
|
| 7 |
|
| 8 |
function Login() {
|
| 9 |
const [email, setEmail] = useState("");
|
| 10 |
+
const isDark = document.documentElement.classList.contains('dark');
|
| 11 |
const [password, setPassword] = useState("");
|
| 12 |
const [showPassword, setShowPassword] = useState(false);
|
| 13 |
const [error, setError] = useState("");
|
| 14 |
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
| 15 |
|
| 16 |
const [isMagicLink, setIsMagicLink] = useState(false);
|
| 17 |
const [magicLinkSent, setMagicLinkSent] = useState(false);
|
| 18 |
|
| 19 |
const navigate = useNavigate();
|
| 20 |
+
const { login, signInWithMagicLink, user, profile } = useAuthStore();
|
| 21 |
|
| 22 |
// Auto-redirect if already logged in
|
| 23 |
useEffect(() => {
|
|
|
|
| 43 |
}
|
| 44 |
|
| 45 |
setError("");
|
| 46 |
+
setIsSubmitting(true);
|
| 47 |
|
| 48 |
try {
|
| 49 |
const { profile } = await login(email, password);
|
|
|
|
| 58 |
|
| 59 |
if (profile.status === "rejected") {
|
| 60 |
navigate("/not-approved");
|
| 61 |
+
return;
|
| 62 |
}
|
| 63 |
|
| 64 |
if (profile.role === "master_admin" && profile.status === "active") {
|
|
|
|
| 77 |
errMsg = "Network Error: Failed to fetch. This usually happens if your browser's ad-blocker (like Brave Shields, uBlock Origin, etc.) is blocking Supabase requests. Please try disabling your ad-blocker for this site and refresh!";
|
| 78 |
}
|
| 79 |
setError(errMsg);
|
| 80 |
+
} finally {
|
| 81 |
+
setIsSubmitting(false);
|
| 82 |
}
|
| 83 |
};
|
| 84 |
|
|
|
|
| 90 |
}
|
| 91 |
|
| 92 |
setError("");
|
| 93 |
+
setIsSubmitting(true);
|
| 94 |
try {
|
| 95 |
await signInWithMagicLink(email);
|
| 96 |
setMagicLinkSent(true);
|
|
|
|
| 101 |
errMsg = "Network Error: Failed to fetch. This usually happens if your browser's ad-blocker (like Brave Shields, uBlock Origin, etc.) is blocking Supabase requests. Please try disabling your ad-blocker for this site and refresh!";
|
| 102 |
}
|
| 103 |
setError(errMsg);
|
| 104 |
+
} finally {
|
| 105 |
+
setIsSubmitting(false);
|
| 106 |
+
}
|
| 107 |
+
};
|
| 108 |
+
|
| 109 |
+
const handleGoogleLogin = async () => {
|
| 110 |
+
try {
|
| 111 |
+
await loginWithGoogle();
|
| 112 |
+
} catch (err) {
|
| 113 |
+
console.error("Google login failed", err.message);
|
| 114 |
}
|
| 115 |
};
|
| 116 |
|
|
|
|
| 123 |
<div
|
| 124 |
className="hidden lg:flex w-1/2 items-center justify-center p-12 relative overflow-hidden"
|
| 125 |
style={{
|
| 126 |
+
background: document.documentElement.classList.contains('dark')
|
| 127 |
+
? 'linear-gradient(160deg, #0f2a1a 0%, #1a3a25 60%, #0d2b1a 100%)'
|
| 128 |
+
: 'linear-gradient(160deg, #f0fdf4 0%, #dcfce7 60%, #bbf7d0 100%)',
|
| 129 |
}}
|
| 130 |
>
|
|
|
|
| 131 |
<div
|
| 132 |
className="absolute top-0 left-0 w-[600px] h-[600px] rounded-full pointer-events-none"
|
| 133 |
style={{
|
|
|
|
| 136 |
/>
|
| 137 |
|
| 138 |
<div className="relative z-10 max-w-lg">
|
|
|
|
| 139 |
<div
|
| 140 |
className="p-3 rounded-2xl w-fit mb-8"
|
| 141 |
style={{ background: 'rgba(34,160,69,0.08)', border: '1px solid #d1fae5' }}
|
|
|
|
| 143 |
<BrainCircuit className="w-10 h-10" style={{ color: '#16a34a' }} />
|
| 144 |
</div>
|
| 145 |
|
|
|
|
| 146 |
<h1
|
| 147 |
style={{
|
| 148 |
fontFamily: "'Syne', sans-serif",
|
| 149 |
fontSize: '48px',
|
| 150 |
fontWeight: 800,
|
| 151 |
+
color: isDark ? '#ffffff' : '#0f1f12',
|
| 152 |
letterSpacing: '-0.03em',
|
| 153 |
lineHeight: 1.1,
|
| 154 |
marginBottom: '24px',
|
|
|
|
| 158 |
<span style={{ color: '#16a34a' }}>IT Support</span>
|
| 159 |
</h1>
|
| 160 |
|
| 161 |
+
<p style={{ color: isDark ? '#d1fae5' : '#374151', fontSize: '16px', lineHeight: 1.7, marginBottom: '32px' }}>
|
|
|
|
| 162 |
Join thousands of IT teams using HelpDesk.ai to categorize, route, and resolve tickets instantly.
|
| 163 |
</p>
|
| 164 |
|
|
|
|
| 165 |
<div
|
| 166 |
style={{
|
| 167 |
background: '#ffffff',
|
|
|
|
| 193 |
{/* ── Right Panel ── */}
|
| 194 |
<div
|
| 195 |
className="flex w-full lg:w-1/2 items-center justify-center p-6 relative"
|
| 196 |
+
style={{ background: document.documentElement.classList.contains('dark') ? '#111827' : '#ffffff', borderLeft: '1px solid #f0fdf4' }}
|
| 197 |
>
|
|
|
|
| 198 |
<Link
|
| 199 |
to="/"
|
| 200 |
className="absolute top-8 left-8 flex items-center gap-2 transition-all group"
|
|
|
|
| 209 |
</Link>
|
| 210 |
|
| 211 |
<div className="w-full max-w-md mt-8 lg:mt-0" style={{ padding: '32px' }}>
|
|
|
|
| 212 |
<div className="text-center" style={{ marginBottom: '40px' }}>
|
| 213 |
<h2
|
| 214 |
style={{
|
| 215 |
fontFamily: "'Syne', sans-serif",
|
| 216 |
fontSize: '28px',
|
| 217 |
fontWeight: 800,
|
| 218 |
+
color: isDark ? '#ffffff' : '#0f1f12',
|
| 219 |
letterSpacing: '-0.02em',
|
| 220 |
marginBottom: '8px',
|
| 221 |
}}
|
|
|
|
| 225 |
<p style={{ color: '#6b7280', fontSize: '14px' }}>Please sign in to continue</p>
|
| 226 |
</div>
|
| 227 |
|
|
|
|
|
|
|
| 228 |
{error && (
|
| 229 |
<div className="mb-6 flex items-start gap-3" style={{ background: '#fef2f2', border: '1px solid #fee2e2', borderRadius: '12px', padding: '14px 16px' }}>
|
| 230 |
<div className="rounded-full p-1 mt-0.5" style={{ background: '#fee2e2' }}>
|
|
|
|
| 251 |
</div>
|
| 252 |
) : (
|
| 253 |
<form onSubmit={currentSubmitHandler} className="space-y-5">
|
|
|
|
| 254 |
<div>
|
| 255 |
<label
|
| 256 |
className="block mb-2"
|
|
|
|
| 279 |
/>
|
| 280 |
</div>
|
| 281 |
|
|
|
|
| 282 |
{!isMagicLink && (
|
| 283 |
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }}>
|
| 284 |
<div className="flex justify-between items-center mb-2">
|
|
|
|
| 330 |
</motion.div>
|
| 331 |
)}
|
| 332 |
|
|
|
|
| 333 |
<button
|
| 334 |
type="submit"
|
| 335 |
+
disabled={isSubmitting}
|
| 336 |
className="w-full flex items-center justify-center gap-2 active:scale-[0.98] disabled:opacity-70 disabled:cursor-not-allowed"
|
| 337 |
style={{
|
| 338 |
background: 'linear-gradient(135deg, #16a34a, #22c55e)',
|
|
|
|
| 349 |
onMouseEnter={(e) => { e.currentTarget.style.transform = 'translateY(-1px)'; e.currentTarget.style.boxShadow = '0 6px 24px rgba(34,160,69,0.35)'; }}
|
| 350 |
onMouseLeave={(e) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 4px 20px rgba(34,160,69,0.3)'; }}
|
| 351 |
>
|
| 352 |
+
{isSubmitting && <Loader2 className="w-5 h-5 animate-spin" />}
|
| 353 |
+
{!isSubmitting && (isMagicLink ? "Send Magic Link" : "Sign In")}
|
| 354 |
+
</button>
|
| 355 |
+
|
| 356 |
+
{/* Google Button */}
|
| 357 |
+
<button
|
| 358 |
+
type="button"
|
| 359 |
+
disabled={loading}
|
| 360 |
+
onClick={handleGoogleLogin}
|
| 361 |
+
className="w-full flex items-center justify-center gap-3 transition-all active:scale-[0.98] disabled:opacity-70 disabled:cursor-not-allowed"
|
| 362 |
+
style={{
|
| 363 |
+
background: isDark ? '#1f2937' : '#ffffff',
|
| 364 |
+
border: isDark ? '1.5px solid #374151' : '1.5px solid #e5e7eb',
|
| 365 |
+
color: isDark ? '#f9fafb' : '#374151',
|
| 366 |
+
borderRadius: '12px',
|
| 367 |
+
padding: '13px',
|
| 368 |
+
fontWeight: 500,
|
| 369 |
+
fontSize: '15px',
|
| 370 |
+
cursor: 'pointer',
|
| 371 |
+
transition: 'all 0.2s',
|
| 372 |
+
}}
|
| 373 |
+
onMouseEnter={(e) => {
|
| 374 |
+
if (!loading) {
|
| 375 |
+
e.currentTarget.style.background = isDark ? '#374151' : '#f9fafb';
|
| 376 |
+
e.currentTarget.style.borderColor = isDark ? '#4b5563' : '#d1d5db';
|
| 377 |
+
}
|
| 378 |
+
}}
|
| 379 |
+
onMouseLeave={(e) => {
|
| 380 |
+
if (!loading) {
|
| 381 |
+
e.currentTarget.style.background = isDark ? '#1f2937' : '#ffffff';
|
| 382 |
+
e.currentTarget.style.borderColor = isDark ? '#374151' : '#e5e7eb';
|
| 383 |
+
}
|
| 384 |
+
}}
|
| 385 |
+
>
|
| 386 |
+
<svg className="w-5 h-5 shrink-0" viewBox="0 0 24 24" fill="currentColor">
|
| 387 |
+
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
|
| 388 |
+
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
| 389 |
+
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.06H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.94l2.85-2.22.81-.63z" fill="#FBBC05"/>
|
| 390 |
+
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.06l3.66 2.84c.87-2.6 3.3-4.52 6.16-4.52z" fill="#EA4335"/>
|
| 391 |
+
</svg>
|
| 392 |
+
<span>Continue with Google</span>
|
| 393 |
</button>
|
| 394 |
|
| 395 |
{/* Divider */}
|
|
|
|
| 399 |
<div className="flex-grow" style={{ borderTop: '1px solid #e5e7eb' }}></div>
|
| 400 |
</div>
|
| 401 |
|
|
|
|
| 402 |
<button
|
| 403 |
type="button"
|
| 404 |
onClick={() => { setIsMagicLink(!isMagicLink); setError(""); }}
|
|
|
|
| 420 |
{isMagicLink ? "Sign in with Password" : "Sign in with Magic Link"}
|
| 421 |
</button>
|
| 422 |
|
|
|
|
| 423 |
<p className="text-center" style={{ fontSize: '14px', color: '#6b7280', marginTop: '32px' }}>
|
| 424 |
Don't have an account?{" "}
|
| 425 |
<Link to="/signup" className="hover:underline transition-all" style={{ color: '#16a34a', fontWeight: 600 }}>
|
|
|
|
| 434 |
);
|
| 435 |
}
|
| 436 |
|
| 437 |
+
export default Login;
|
Frontend/src/pages/Signup.jsx
CHANGED
|
@@ -3,6 +3,7 @@ import { useNavigate, Link } from "react-router-dom";
|
|
| 3 |
import useAuthStore from "../store/authStore";
|
| 4 |
import { supabase } from "../lib/supabaseClient";
|
| 5 |
import { Eye, EyeOff, BrainCircuit, ArrowRight, Loader2, CheckCircle2, ChevronDown, Search, Building2, ArrowLeft } from "lucide-react";
|
|
|
|
| 6 |
|
| 7 |
function Signup() {
|
| 8 |
const [email, setEmail] = useState("");
|
|
@@ -27,6 +28,10 @@ function Signup() {
|
|
| 27 |
const dropdownRef = useRef(null);
|
| 28 |
const navigate = useNavigate();
|
| 29 |
const { signup, user, profile } = useAuthStore();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
// Fetch and subscribe to companies
|
| 32 |
useEffect(() => {
|
|
@@ -103,15 +108,6 @@ function Signup() {
|
|
| 103 |
e.preventDefault();
|
| 104 |
setError("");
|
| 105 |
|
| 106 |
-
// Password complexity validator — mirrors Supabase's policy
|
| 107 |
-
const validatePassword = (pw) => {
|
| 108 |
-
if (pw.length < 8) return 'Password must be at least 8 characters long.';
|
| 109 |
-
if (!/[a-z]/.test(pw)) return 'Password must contain at least one lowercase letter (a-z).';
|
| 110 |
-
if (!/[A-Z]/.test(pw)) return 'Password must contain at least one uppercase letter (A-Z).';
|
| 111 |
-
if (!/[0-9]/.test(pw)) return 'Password must contain at least one number (0-9).';
|
| 112 |
-
return null;
|
| 113 |
-
};
|
| 114 |
-
|
| 115 |
if (!email || !password || !confirmPassword || !fullName) {
|
| 116 |
setError("All fields are required.");
|
| 117 |
return;
|
|
@@ -122,14 +118,13 @@ function Signup() {
|
|
| 122 |
return;
|
| 123 |
}
|
| 124 |
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
setError(pwError);
|
| 128 |
return;
|
| 129 |
}
|
| 130 |
|
| 131 |
-
if (
|
| 132 |
-
setError(
|
| 133 |
return;
|
| 134 |
}
|
| 135 |
|
|
@@ -325,28 +320,16 @@ function Signup() {
|
|
| 325 |
<div className="relative">
|
| 326 |
<label className="block mb-2" style={labelStyle}>Password</label>
|
| 327 |
<div className="relative">
|
| 328 |
-
<input type={showPassword ? "text" : "password"} placeholder="Min
|
| 329 |
value={password} onChange={(e) => { setPassword(e.target.value); setError(""); }} />
|
| 330 |
<button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-3 top-1/2 -translate-y-1/2" style={{ color: '#9ca3af', background: 'none', border: 'none', cursor: 'pointer' }}>
|
| 331 |
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
| 332 |
</button>
|
| 333 |
</div>
|
| 334 |
-
{/* Live password requirement checklist */}
|
| 335 |
{password && (
|
| 336 |
-
<
|
| 337 |
-
{
|
| 338 |
-
|
| 339 |
-
{ label: 'Uppercase (A-Z)', ok: /[A-Z]/.test(password) },
|
| 340 |
-
{ label: 'Lowercase (a-z)', ok: /[a-z]/.test(password) },
|
| 341 |
-
{ label: 'Number (0-9)', ok: /[0-9]/.test(password) },
|
| 342 |
-
].map(({ label, ok }) => (
|
| 343 |
-
<span key={label} className={`text-[10px] font-semibold flex items-center gap-1 transition-colors ${
|
| 344 |
-
ok ? 'text-emerald-600' : 'text-red-400'
|
| 345 |
-
}`}>
|
| 346 |
-
<span>{ok ? '✓' : '○'}</span> {label}
|
| 347 |
-
</span>
|
| 348 |
-
))}
|
| 349 |
-
</div>
|
| 350 |
)}
|
| 351 |
</div>
|
| 352 |
<div className="relative">
|
|
@@ -358,6 +341,11 @@ function Signup() {
|
|
| 358 |
{showConfirmPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
| 359 |
</button>
|
| 360 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
</div>
|
| 362 |
</div>
|
| 363 |
|
|
|
|
| 3 |
import useAuthStore from "../store/authStore";
|
| 4 |
import { supabase } from "../lib/supabaseClient";
|
| 5 |
import { Eye, EyeOff, BrainCircuit, ArrowRight, Loader2, CheckCircle2, ChevronDown, Search, Building2, ArrowLeft } from "lucide-react";
|
| 6 |
+
import { getPasswordValidation, getPasswordValidationMessage } from "../utils/passwordValidation";
|
| 7 |
|
| 8 |
function Signup() {
|
| 9 |
const [email, setEmail] = useState("");
|
|
|
|
| 28 |
const dropdownRef = useRef(null);
|
| 29 |
const navigate = useNavigate();
|
| 30 |
const { signup, user, profile } = useAuthStore();
|
| 31 |
+
const passwordRules = { minLength: 6 };
|
| 32 |
+
const passwordChecks = getPasswordValidation(password, passwordRules);
|
| 33 |
+
const passwordWarning = getPasswordValidationMessage(passwordChecks, passwordRules);
|
| 34 |
+
const confirmPasswordWarning = confirmPassword && password !== confirmPassword ? "Passwords do not match." : "";
|
| 35 |
|
| 36 |
// Fetch and subscribe to companies
|
| 37 |
useEffect(() => {
|
|
|
|
| 108 |
e.preventDefault();
|
| 109 |
setError("");
|
| 110 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
if (!email || !password || !confirmPassword || !fullName) {
|
| 112 |
setError("All fields are required.");
|
| 113 |
return;
|
|
|
|
| 118 |
return;
|
| 119 |
}
|
| 120 |
|
| 121 |
+
if (passwordWarning) {
|
| 122 |
+
setError(passwordWarning);
|
|
|
|
| 123 |
return;
|
| 124 |
}
|
| 125 |
|
| 126 |
+
if (confirmPasswordWarning) {
|
| 127 |
+
setError(confirmPasswordWarning);
|
| 128 |
return;
|
| 129 |
}
|
| 130 |
|
|
|
|
| 320 |
<div className="relative">
|
| 321 |
<label className="block mb-2" style={labelStyle}>Password</label>
|
| 322 |
<div className="relative">
|
| 323 |
+
<input type={showPassword ? "text" : "password"} placeholder="Min 6 chars" style={{ ...inputStyle, paddingRight: '44px' }} onFocus={inputFocus} onBlur={inputBlur}
|
| 324 |
value={password} onChange={(e) => { setPassword(e.target.value); setError(""); }} />
|
| 325 |
<button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-3 top-1/2 -translate-y-1/2" style={{ color: '#9ca3af', background: 'none', border: 'none', cursor: 'pointer' }}>
|
| 326 |
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
| 327 |
</button>
|
| 328 |
</div>
|
|
|
|
| 329 |
{password && (
|
| 330 |
+
<p aria-live="polite" className={`mt-2 text-[11px] font-semibold ${passwordWarning ? "text-red-600" : "text-emerald-700"}`}>
|
| 331 |
+
{passwordWarning || "Password looks good."}
|
| 332 |
+
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
)}
|
| 334 |
</div>
|
| 335 |
<div className="relative">
|
|
|
|
| 341 |
{showConfirmPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
| 342 |
</button>
|
| 343 |
</div>
|
| 344 |
+
{confirmPasswordWarning && (
|
| 345 |
+
<p aria-live="polite" className="mt-2 text-[11px] font-semibold text-red-600">
|
| 346 |
+
{confirmPasswordWarning}
|
| 347 |
+
</p>
|
| 348 |
+
)}
|
| 349 |
</div>
|
| 350 |
</div>
|
| 351 |
|
Frontend/src/services/aiAssistant.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
| 1 |
-
import {
|
| 2 |
-
import { API_CONFIG } from "../config";
|
| 3 |
|
| 4 |
// ============================================================
|
| 5 |
// MULTI-API FAILOVER CONFIGURATION
|
|
@@ -8,45 +7,30 @@ import { API_CONFIG } from "../config";
|
|
| 8 |
// ============================================================
|
| 9 |
|
| 10 |
const buildConfigList = () => {
|
| 11 |
-
const env = import.meta.env;
|
| 12 |
const configs = [];
|
| 13 |
|
| 14 |
// Priority 1: Native Gemini — try modern flash models
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
});
|
| 27 |
|
| 28 |
-
// Priority
|
| 29 |
-
const
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
].filter(Boolean);
|
| 33 |
-
const openrouterModels = (env.VITE_AI_OPENROUTER_MODELS || 'meta-llama/llama-3.2-3b-instruct:free,microsoft/phi-3-mini-128k-instruct:free,mistralai/mistral-7b-instruct:free,google/gemma-2-9b-it:free').split(',');
|
| 34 |
-
|
| 35 |
-
openrouterKeys.forEach((key, idx) => {
|
| 36 |
-
const primaryModel = openrouterModels[idx % openrouterModels.length].trim();
|
| 37 |
-
const secondaryModel = openrouterModels[(idx + 1) % openrouterModels.length].trim();
|
| 38 |
-
configs.push({ provider: 'openrouter', key, model: primaryModel });
|
| 39 |
-
configs.push({ provider: 'openrouter', key, model: secondaryModel });
|
| 40 |
-
});
|
| 41 |
-
|
| 42 |
-
// Priority 3: Groq — use stable models
|
| 43 |
-
const groqKeys = [
|
| 44 |
-
env.VITE_GROQ_API_KEY_1, env.VITE_GROQ_API_KEY_2, env.VITE_GROQ_API_KEY_3
|
| 45 |
-
].filter(Boolean);
|
| 46 |
-
const groqModels = (env.VITE_AI_GROQ_MODELS || 'llama-3.1-8b-instant,mixtral-8x7b-32768,gemma2-9b-it').split(',');
|
| 47 |
-
|
| 48 |
-
groqKeys.forEach((key, idx) => {
|
| 49 |
-
configs.push({ provider: 'groq', key, model: groqModels[idx % groqModels.length].trim() });
|
| 50 |
});
|
| 51 |
|
| 52 |
return configs;
|
|
@@ -57,11 +41,8 @@ const buildConfigList = () => {
|
|
| 57 |
// PROVIDER HANDLERS
|
| 58 |
// ============================================================
|
| 59 |
|
| 60 |
-
const
|
| 61 |
-
const
|
| 62 |
-
const model = genAI.getGenerativeModel({ model: config.model });
|
| 63 |
-
|
| 64 |
-
let formattedHistory = history.map(msg => {
|
| 65 |
const parts = [{ text: msg.text || "" }];
|
| 66 |
if (msg.image) {
|
| 67 |
const [mime, data] = msg.image.split(';base64,');
|
|
@@ -70,72 +51,110 @@ const callGemini = async (config, promptText, history, image) => {
|
|
| 70 |
return { role: msg.role === 'bot' ? 'model' : 'user', parts };
|
| 71 |
});
|
| 72 |
|
| 73 |
-
// Gemini requires history to start with 'user' role
|
| 74 |
-
const firstUserIdx = formattedHistory.findIndex(h => h.role === 'user');
|
| 75 |
-
if (firstUserIdx > 0) formattedHistory = formattedHistory.slice(firstUserIdx);
|
| 76 |
-
else if (firstUserIdx === -1) formattedHistory = [];
|
| 77 |
-
|
| 78 |
-
const chat = model.startChat({ history: formattedHistory, generationConfig: { maxOutputTokens: 2048 } });
|
| 79 |
-
|
| 80 |
const messageParts = [{ text: promptText }];
|
| 81 |
if (image) {
|
| 82 |
const [mime, data] = image.split(';base64,');
|
| 83 |
messageParts.push({ inlineData: { mimeType: mime.split(':')[1] || 'image/png', data } });
|
| 84 |
}
|
| 85 |
|
| 86 |
-
|
| 87 |
-
|
|
|
|
| 88 |
};
|
| 89 |
|
| 90 |
-
const
|
| 91 |
-
const messages = history.map(msg =>
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
const userContent = image
|
| 97 |
? [{ type: "text", text: promptText }, { type: "image_url", image_url: { url: image } }]
|
| 98 |
: promptText;
|
| 99 |
|
| 100 |
messages.push({ role: "user", content: userContent });
|
|
|
|
|
|
|
| 101 |
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
-
if (
|
| 109 |
-
const
|
| 110 |
-
|
| 111 |
-
throw
|
| 112 |
}
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
};
|
| 116 |
|
| 117 |
// Core failover runner — shared by both exported functions
|
| 118 |
const runWithFailover = async (promptText, history, image) => {
|
| 119 |
const configList = buildConfigList();
|
| 120 |
-
if (configList.length === 0) throw new Error("No AI
|
| 121 |
|
| 122 |
for (let i = 0; i < configList.length; i++) {
|
| 123 |
const config = configList[i];
|
| 124 |
console.log(`[AI Failover] Trying ${i + 1}/${configList.length}: ${config.provider} (${config.model})`);
|
| 125 |
|
| 126 |
try {
|
| 127 |
-
|
| 128 |
-
return await callGemini(config, promptText, history, image);
|
| 129 |
-
} else if (config.provider === 'openrouter') {
|
| 130 |
-
return await callOpenAICompat(config, promptText, history, image,
|
| 131 |
-
'https://openrouter.ai/api/v1',
|
| 132 |
-
{ 'HTTP-Referer': API_CONFIG.FRONTEND_URL, 'X-Title': 'AI Helpdesk' }
|
| 133 |
-
);
|
| 134 |
-
} else if (config.provider === 'groq') {
|
| 135 |
-
return await callOpenAICompat(config, promptText, history, null, // Groq = text only
|
| 136 |
-
'https://api.groq.com/openai/v1'
|
| 137 |
-
);
|
| 138 |
-
}
|
| 139 |
} catch (error) {
|
| 140 |
const isRateLimit = error.status === 429
|
| 141 |
|| error.message?.includes('429')
|
|
@@ -159,6 +178,12 @@ const localFallbackSummary = (issueText) => {
|
|
| 159 |
return { summary, image_description: '' };
|
| 160 |
};
|
| 161 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
|
| 163 |
// ============================================================
|
| 164 |
// EXPORT 1: askAI — Used by the chat troubleshooting assistant
|
|
@@ -231,11 +256,15 @@ User Issue: "${issueText}"${imageNote}${imageInstruction}`;
|
|
| 231 |
subcategory: parsed.subcategory,
|
| 232 |
priority: parsed.priority,
|
| 233 |
assigned_team: parsed.assigned_team,
|
| 234 |
-
confidence: parsed.confidence || 0.9
|
|
|
|
| 235 |
};
|
| 236 |
} catch (err) {
|
| 237 |
// All providers failed — use smart local fallback so ticket flow never breaks
|
| 238 |
console.warn('[analyzeTicketWithAI] All providers exhausted, using local fallback:', err.message);
|
| 239 |
-
return
|
|
|
|
|
|
|
|
|
|
| 240 |
}
|
| 241 |
};
|
|
|
|
| 1 |
+
import { supabase } from "../lib/supabaseClient";
|
|
|
|
| 2 |
|
| 3 |
// ============================================================
|
| 4 |
// MULTI-API FAILOVER CONFIGURATION
|
|
|
|
| 7 |
// ============================================================
|
| 8 |
|
| 9 |
const buildConfigList = () => {
|
|
|
|
| 10 |
const configs = [];
|
| 11 |
|
| 12 |
// Priority 1: Native Gemini — try modern flash models
|
| 13 |
+
configs.push(
|
| 14 |
+
{ provider: 'gemini', model: 'gemini-2.5-flash' },
|
| 15 |
+
{ provider: 'gemini', model: 'gemini-2.5-flash-lite' },
|
| 16 |
+
{ provider: 'gemini', model: 'gemini-2.0-flash' }
|
| 17 |
+
);
|
| 18 |
+
|
| 19 |
+
// Priority 2: OpenRouter — updated model slugs (verified working as of 2025)
|
| 20 |
+
const openrouterModels = [
|
| 21 |
+
'meta-llama/llama-3.2-3b-instruct:free',
|
| 22 |
+
'microsoft/phi-3-mini-128k-instruct:free',
|
| 23 |
+
'mistralai/mistral-7b-instruct:free',
|
| 24 |
+
'google/gemma-2-9b-it:free',
|
| 25 |
+
];
|
| 26 |
+
openrouterModels.forEach((model) => {
|
| 27 |
+
configs.push({ provider: 'openrouter', model });
|
| 28 |
});
|
| 29 |
|
| 30 |
+
// Priority 3: Groq — use stable, currently-available models
|
| 31 |
+
const groqModels = ['llama-3.1-8b-instant', 'mixtral-8x7b-32768', 'gemma2-9b-it'];
|
| 32 |
+
groqModels.forEach((model) => {
|
| 33 |
+
configs.push({ provider: 'groq', model });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
});
|
| 35 |
|
| 36 |
return configs;
|
|
|
|
| 41 |
// PROVIDER HANDLERS
|
| 42 |
// ============================================================
|
| 43 |
|
| 44 |
+
const buildGeminiMessages = (promptText, history, image) => {
|
| 45 |
+
const formattedHistory = history.map(msg => {
|
|
|
|
|
|
|
|
|
|
| 46 |
const parts = [{ text: msg.text || "" }];
|
| 47 |
if (msg.image) {
|
| 48 |
const [mime, data] = msg.image.split(';base64,');
|
|
|
|
| 51 |
return { role: msg.role === 'bot' ? 'model' : 'user', parts };
|
| 52 |
});
|
| 53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
const messageParts = [{ text: promptText }];
|
| 55 |
if (image) {
|
| 56 |
const [mime, data] = image.split(';base64,');
|
| 57 |
messageParts.push({ inlineData: { mimeType: mime.split(':')[1] || 'image/png', data } });
|
| 58 |
}
|
| 59 |
|
| 60 |
+
return formattedHistory.length > 0
|
| 61 |
+
? [...formattedHistory, { role: 'user', parts: messageParts }]
|
| 62 |
+
: [{ role: 'user', parts: messageParts }];
|
| 63 |
};
|
| 64 |
|
| 65 |
+
const buildOpenAICompatMessages = (promptText, history, image) => {
|
| 66 |
+
const messages = history.map(msg => {
|
| 67 |
+
const content = msg.image
|
| 68 |
+
? [
|
| 69 |
+
{ type: 'text', text: msg.text || '' },
|
| 70 |
+
{ type: 'image_url', image_url: { url: msg.image } }
|
| 71 |
+
]
|
| 72 |
+
: msg.text || '';
|
| 73 |
+
|
| 74 |
+
return {
|
| 75 |
+
role: msg.role === 'bot' ? 'assistant' : 'user',
|
| 76 |
+
content,
|
| 77 |
+
};
|
| 78 |
+
});
|
| 79 |
|
| 80 |
const userContent = image
|
| 81 |
? [{ type: "text", text: promptText }, { type: "image_url", image_url: { url: image } }]
|
| 82 |
: promptText;
|
| 83 |
|
| 84 |
messages.push({ role: "user", content: userContent });
|
| 85 |
+
return messages;
|
| 86 |
+
};
|
| 87 |
|
| 88 |
+
const extractResponseText = (data) => {
|
| 89 |
+
if (typeof data === 'string') return data;
|
| 90 |
+
if (!data || typeof data !== 'object') return '';
|
| 91 |
+
|
| 92 |
+
const openAiContent = data.choices?.[0]?.message?.content;
|
| 93 |
+
if (typeof openAiContent === 'string') return openAiContent;
|
| 94 |
+
if (Array.isArray(openAiContent)) {
|
| 95 |
+
return openAiContent
|
| 96 |
+
.map(part => part?.text || part?.content || '')
|
| 97 |
+
.filter(Boolean)
|
| 98 |
+
.join('');
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
const geminiParts = data.candidates?.[0]?.content?.parts;
|
| 102 |
+
if (Array.isArray(geminiParts)) {
|
| 103 |
+
return geminiParts
|
| 104 |
+
.map(part => part?.text || '')
|
| 105 |
+
.filter(Boolean)
|
| 106 |
+
.join('');
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
if (typeof data.candidates?.[0]?.content === 'string') {
|
| 110 |
+
return data.candidates[0].content;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
if (typeof data.text === 'string') return data.text;
|
| 114 |
+
|
| 115 |
+
return '';
|
| 116 |
+
};
|
| 117 |
+
|
| 118 |
+
const callProxy = async (config, promptText, history, image) => {
|
| 119 |
+
const body = config.provider === 'gemini'
|
| 120 |
+
? {
|
| 121 |
+
provider: config.provider,
|
| 122 |
+
model: config.model,
|
| 123 |
+
messages: buildGeminiMessages(promptText, history, image),
|
| 124 |
+
}
|
| 125 |
+
: {
|
| 126 |
+
provider: config.provider,
|
| 127 |
+
model: config.model,
|
| 128 |
+
messages: buildOpenAICompatMessages(promptText, history, config.provider === 'groq' ? null : image),
|
| 129 |
+
};
|
| 130 |
+
|
| 131 |
+
const { data, error } = await supabase.functions.invoke('ai-proxy', { body });
|
| 132 |
|
| 133 |
+
if (error) {
|
| 134 |
+
const invokeError = new Error(error.message || 'AI proxy request failed');
|
| 135 |
+
invokeError.status = error.status || error?.context?.status;
|
| 136 |
+
throw invokeError;
|
| 137 |
}
|
| 138 |
+
|
| 139 |
+
const responseText = extractResponseText(data);
|
| 140 |
+
if (!responseText) {
|
| 141 |
+
throw new Error(`No response received from ${config.provider}`);
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
return responseText;
|
| 145 |
};
|
| 146 |
|
| 147 |
// Core failover runner — shared by both exported functions
|
| 148 |
const runWithFailover = async (promptText, history, image) => {
|
| 149 |
const configList = buildConfigList();
|
| 150 |
+
if (configList.length === 0) throw new Error("No AI providers configured");
|
| 151 |
|
| 152 |
for (let i = 0; i < configList.length; i++) {
|
| 153 |
const config = configList[i];
|
| 154 |
console.log(`[AI Failover] Trying ${i + 1}/${configList.length}: ${config.provider} (${config.model})`);
|
| 155 |
|
| 156 |
try {
|
| 157 |
+
return await callProxy(config, promptText, history, image);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
} catch (error) {
|
| 159 |
const isRateLimit = error.status === 429
|
| 160 |
|| error.message?.includes('429')
|
|
|
|
| 178 |
return { summary, image_description: '' };
|
| 179 |
};
|
| 180 |
|
| 181 |
+
const getSlaBreachAt = (priority = 'Medium') => {
|
| 182 |
+
const hoursMap = { Critical: 2, High: 8, Medium: 24, Low: 72 };
|
| 183 |
+
const slaHours = hoursMap[priority] || 24;
|
| 184 |
+
return new Date(Date.now() + slaHours * 60 * 60 * 1000).toISOString();
|
| 185 |
+
};
|
| 186 |
+
|
| 187 |
|
| 188 |
// ============================================================
|
| 189 |
// EXPORT 1: askAI — Used by the chat troubleshooting assistant
|
|
|
|
| 256 |
subcategory: parsed.subcategory,
|
| 257 |
priority: parsed.priority,
|
| 258 |
assigned_team: parsed.assigned_team,
|
| 259 |
+
confidence: parsed.confidence || 0.9,
|
| 260 |
+
sla_breach_at: getSlaBreachAt(parsed.priority)
|
| 261 |
};
|
| 262 |
} catch (err) {
|
| 263 |
// All providers failed — use smart local fallback so ticket flow never breaks
|
| 264 |
console.warn('[analyzeTicketWithAI] All providers exhausted, using local fallback:', err.message);
|
| 265 |
+
return {
|
| 266 |
+
...localFallbackSummary(issueText),
|
| 267 |
+
sla_breach_at: getSlaBreachAt()
|
| 268 |
+
};
|
| 269 |
}
|
| 270 |
};
|
Frontend/src/services/api.js
CHANGED
|
@@ -2,11 +2,17 @@ import axios from 'axios';
|
|
| 2 |
import { MOCK_TICKETS } from './mockData';
|
| 3 |
import { API_CONFIG } from '../config';
|
| 4 |
|
| 5 |
-
const USE_MOCK =
|
| 6 |
const API_BASE_URL = API_CONFIG.BACKEND_URL;
|
| 7 |
|
| 8 |
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
// Safe helper to get data from storage or default
|
| 11 |
const getStorage = (key, defaultData) => {
|
| 12 |
try {
|
|
@@ -32,48 +38,81 @@ const setStorage = (key, data) => {
|
|
| 32 |
}
|
| 33 |
};
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
export const api = {
|
| 36 |
// Login and Signup have been fully migrated to Supabase via authStore.js
|
| 37 |
// Ensure that no component tries to use api.login or api.signup anymore.
|
| 38 |
|
| 39 |
-
|
| 40 |
getTickets: async () => {
|
| 41 |
if (USE_MOCK) {
|
| 42 |
await delay(500);
|
| 43 |
return getStorage('tickets', MOCK_TICKETS);
|
| 44 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
},
|
| 46 |
|
| 47 |
createTicket: async (ticketData) => {
|
| 48 |
if (USE_MOCK) {
|
| 49 |
await delay(800);
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
};
|
| 64 |
-
tickets.unshift(newTicket); // Add to beginning
|
| 65 |
-
setStorage('tickets', tickets);
|
| 66 |
-
return { data: newTicket };
|
| 67 |
}
|
| 68 |
},
|
| 69 |
|
| 70 |
predictTicket: async (issueText, imageBase64 = "") => {
|
| 71 |
try {
|
|
|
|
| 72 |
// ALWAYS call the real backend for prediction if possible
|
| 73 |
const response = await axios.post(`${API_BASE_URL}/ai/analyze_ticket`, {
|
| 74 |
text: issueText,
|
| 75 |
image_base64: imageBase64,
|
| 76 |
-
image_text: ""
|
|
|
|
| 77 |
});
|
| 78 |
|
| 79 |
const result = response.data;
|
|
@@ -95,7 +134,10 @@ export const api = {
|
|
| 95 |
reasoning: result.reasoning,
|
| 96 |
decision_factors: result.decision_factors,
|
| 97 |
image_description: result.image_description,
|
| 98 |
-
ocr_text: result.ocr_text
|
|
|
|
|
|
|
|
|
|
| 99 |
}
|
| 100 |
};
|
| 101 |
} catch (error) {
|
|
@@ -112,7 +154,10 @@ export const api = {
|
|
| 112 |
routing_confidence: 0.5,
|
| 113 |
duplicate_probability: 0.0,
|
| 114 |
summary: issueText.substring(0, 50) + "...",
|
| 115 |
-
entities: []
|
|
|
|
|
|
|
|
|
|
| 116 |
}
|
| 117 |
};
|
| 118 |
}
|
|
|
|
| 2 |
import { MOCK_TICKETS } from './mockData';
|
| 3 |
import { API_CONFIG } from '../config';
|
| 4 |
|
| 5 |
+
const USE_MOCK = API_CONFIG.USE_MOCK;
|
| 6 |
const API_BASE_URL = API_CONFIG.BACKEND_URL;
|
| 7 |
|
| 8 |
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
| 9 |
|
| 10 |
+
const getSlaBreachAt = (priority = 'Low') => {
|
| 11 |
+
const hoursMap = { Critical: 2, High: 8, Medium: 24, Low: 72 };
|
| 12 |
+
const slaHours = hoursMap[priority] || 72;
|
| 13 |
+
return new Date(Date.now() + slaHours * 60 * 60 * 1000).toISOString();
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
// Safe helper to get data from storage or default
|
| 17 |
const getStorage = (key, defaultData) => {
|
| 18 |
try {
|
|
|
|
| 38 |
}
|
| 39 |
};
|
| 40 |
|
| 41 |
+
// Shared mock logic for createTicket
|
| 42 |
+
const createTicketMock = (ticketData) => {
|
| 43 |
+
const tickets = getStorage('tickets', MOCK_TICKETS);
|
| 44 |
+
const newTicket = {
|
| 45 |
+
ticket_id: "TCKT-" + Math.floor(Math.random() * 10000),
|
| 46 |
+
status: 'Open',
|
| 47 |
+
createdAt: new Date().toISOString(),
|
| 48 |
+
...ticketData,
|
| 49 |
+
messages: [
|
| 50 |
+
{
|
| 51 |
+
sender: 'user',
|
| 52 |
+
message: ticketData.description || ticketData.summary || '',
|
| 53 |
+
timestamp: new Date().toISOString()
|
| 54 |
+
}
|
| 55 |
+
]
|
| 56 |
+
};
|
| 57 |
+
tickets.unshift(newTicket); // Add to beginning
|
| 58 |
+
setStorage('tickets', tickets);
|
| 59 |
+
return { data: newTicket };
|
| 60 |
+
};
|
| 61 |
+
|
| 62 |
export const api = {
|
| 63 |
// Login and Signup have been fully migrated to Supabase via authStore.js
|
| 64 |
// Ensure that no component tries to use api.login or api.signup anymore.
|
| 65 |
|
|
|
|
| 66 |
getTickets: async () => {
|
| 67 |
if (USE_MOCK) {
|
| 68 |
await delay(500);
|
| 69 |
return getStorage('tickets', MOCK_TICKETS);
|
| 70 |
}
|
| 71 |
+
try {
|
| 72 |
+
const response = await axios.get(`${API_BASE_URL}/tickets`);
|
| 73 |
+
const data = response?.data;
|
| 74 |
+
|
| 75 |
+
// Normalize to the mock shape: an array of tickets
|
| 76 |
+
if (Array.isArray(data)) return data;
|
| 77 |
+
if (data && Array.isArray(data.data)) return data.data;
|
| 78 |
+
if (data && Array.isArray(data.tickets)) return data.tickets;
|
| 79 |
+
|
| 80 |
+
return data;
|
| 81 |
+
} catch (error) {
|
| 82 |
+
console.error("Backend unavailable, falling back to mock:", error);
|
| 83 |
+
await delay(500);
|
| 84 |
+
return getStorage('tickets', MOCK_TICKETS);
|
| 85 |
+
}
|
| 86 |
},
|
| 87 |
|
| 88 |
createTicket: async (ticketData) => {
|
| 89 |
if (USE_MOCK) {
|
| 90 |
await delay(800);
|
| 91 |
+
return createTicketMock(ticketData);
|
| 92 |
+
}
|
| 93 |
+
try {
|
| 94 |
+
const response = await axios.post(`${API_BASE_URL}/tickets/save`, ticketData);
|
| 95 |
+
const created = response?.data;
|
| 96 |
+
|
| 97 |
+
// Normalize to mock shape: { data: <createdTicket> }
|
| 98 |
+
if (created && created.data) return created;
|
| 99 |
+
return { data: created };
|
| 100 |
+
} catch (error) {
|
| 101 |
+
console.error("Backend unavailable, falling back to mock:", error);
|
| 102 |
+
await delay(800);
|
| 103 |
+
return createTicketMock(ticketData);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
}
|
| 105 |
},
|
| 106 |
|
| 107 |
predictTicket: async (issueText, imageBase64 = "") => {
|
| 108 |
try {
|
| 109 |
+
const currentUser = JSON.parse(sessionStorage.getItem("currentUser") || "{}");
|
| 110 |
// ALWAYS call the real backend for prediction if possible
|
| 111 |
const response = await axios.post(`${API_BASE_URL}/ai/analyze_ticket`, {
|
| 112 |
text: issueText,
|
| 113 |
image_base64: imageBase64,
|
| 114 |
+
image_text: "",
|
| 115 |
+
company_id: currentUser.company_id || currentUser.companyId || null
|
| 116 |
});
|
| 117 |
|
| 118 |
const result = response.data;
|
|
|
|
| 134 |
reasoning: result.reasoning,
|
| 135 |
decision_factors: result.decision_factors,
|
| 136 |
image_description: result.image_description,
|
| 137 |
+
ocr_text: result.ocr_text,
|
| 138 |
+
is_potential_duplicate: result.is_potential_duplicate || false,
|
| 139 |
+
parent_ticket_id: result.parent_ticket_id || result.duplicate_ticket?.duplicate_ticket_id || null,
|
| 140 |
+
sla_breach_at: result.sla_breach_at || getSlaBreachAt(result.priority)
|
| 141 |
}
|
| 142 |
};
|
| 143 |
} catch (error) {
|
|
|
|
| 154 |
routing_confidence: 0.5,
|
| 155 |
duplicate_probability: 0.0,
|
| 156 |
summary: issueText.substring(0, 50) + "...",
|
| 157 |
+
entities: [],
|
| 158 |
+
is_potential_duplicate: false,
|
| 159 |
+
parent_ticket_id: null,
|
| 160 |
+
sla_breach_at: getSlaBreachAt("Medium")
|
| 161 |
}
|
| 162 |
};
|
| 163 |
}
|
Frontend/src/store/authStore.js
CHANGED
|
@@ -1,8 +1,63 @@
|
|
| 1 |
import { create } from 'zustand';
|
| 2 |
import { persist } from 'zustand/middleware';
|
| 3 |
import { supabase } from '../lib/supabaseClient';
|
|
|
|
| 4 |
import useTicketStore from './ticketStore';
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
const useAuthStore = create(
|
| 7 |
persist(
|
| 8 |
(set, get) => ({
|
|
@@ -10,6 +65,7 @@ const useAuthStore = create(
|
|
| 10 |
user: null,
|
| 11 |
profile: null,
|
| 12 |
loading: false,
|
|
|
|
| 13 |
|
| 14 |
// --- SUPABASE AUTH METHODS ---
|
| 15 |
|
|
@@ -18,38 +74,25 @@ const useAuthStore = create(
|
|
| 18 |
if (!user) return null;
|
| 19 |
|
| 20 |
const metadata = user.user_metadata || {};
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
// 1. Resolve FROM METADATA or PERSISTED state
|
| 24 |
-
// Priority 1: If we have a persisted session for THIS user and it's active, keep it
|
| 25 |
-
// to prevent temporary lobbies during refresh/tab switching.
|
| 26 |
-
if (currentProfile && currentProfile.id === user.id && currentProfile.status === 'active') {
|
| 27 |
-
console.log("Active profile retained from state.");
|
| 28 |
-
// Background fetch to ensure session is still valid/synced
|
| 29 |
-
get()._syncProfile(user.id);
|
| 30 |
-
return currentProfile;
|
| 31 |
-
}
|
| 32 |
|
| 33 |
-
//
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
const instantProfile = {
|
| 37 |
id: user.id,
|
| 38 |
email: user.email,
|
| 39 |
-
full_name:
|
| 40 |
-
role:
|
| 41 |
-
status:
|
| 42 |
company: metadata.company || ''
|
| 43 |
};
|
| 44 |
|
| 45 |
-
|
| 46 |
-
// This prevents flashes of 'pending_email_verification' when returning from magic links
|
| 47 |
-
const dbProfile = await get()._syncProfile(user.id);
|
| 48 |
-
if (dbProfile) {
|
| 49 |
-
return dbProfile;
|
| 50 |
-
}
|
| 51 |
-
|
| 52 |
-
console.log("Falling back to instant profile resolved from metadata:", instantProfile.role);
|
| 53 |
set({ profile: instantProfile });
|
| 54 |
return instantProfile;
|
| 55 |
},
|
|
@@ -79,31 +122,49 @@ const useAuthStore = create(
|
|
| 79 |
},
|
| 80 |
|
| 81 |
getCurrentUser: async () => {
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
set({ user: null, profile: null });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
}
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
set({ user: null, profile: null });
|
| 97 |
-
return null;
|
| 98 |
-
} finally {
|
| 99 |
-
set({ loading: false });
|
| 100 |
-
}
|
| 101 |
},
|
| 102 |
|
| 103 |
login: async (email, password) => {
|
| 104 |
set({ loading: true });
|
| 105 |
console.log("Attempting login for:", email);
|
| 106 |
try {
|
|
|
|
|
|
|
| 107 |
const { data, error } = await supabase.auth.signInWithPassword({
|
| 108 |
email,
|
| 109 |
password,
|
|
@@ -134,6 +195,26 @@ const useAuthStore = create(
|
|
| 134 |
}
|
| 135 |
},
|
| 136 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
signInWithMagicLink: async (email) => {
|
| 138 |
set({ loading: true });
|
| 139 |
console.log("Attempting magic link / OTP login for:", email);
|
|
@@ -192,6 +273,14 @@ const useAuthStore = create(
|
|
| 192 |
console.log("Starting signup for:", email);
|
| 193 |
|
| 194 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
// 1. Auth Signup with Metadata
|
| 196 |
console.log("Step 1: Auth.signUp...");
|
| 197 |
const { data, error } = await supabase.auth.signUp({
|
|
@@ -232,6 +321,15 @@ const useAuthStore = create(
|
|
| 232 |
logout: async () => {
|
| 233 |
set({ loading: true });
|
| 234 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
const { error } = await supabase.auth.signOut();
|
| 236 |
if (error) throw error;
|
| 237 |
set({ user: null, profile: null });
|
|
@@ -279,25 +377,31 @@ const useAuthStore = create(
|
|
| 279 |
|
| 280 |
supabase.auth.onAuthStateChange(async (event, session) => {
|
| 281 |
console.log("Auth state change:", event);
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
set({ user: null, profile: null });
|
|
|
|
|
|
|
| 287 |
}
|
| 288 |
-
set({ loading: false });
|
| 289 |
});
|
| 290 |
}
|
| 291 |
}),
|
| 292 |
{
|
| 293 |
name: 'auth-storage',
|
| 294 |
partialize: (state) => ({
|
| 295 |
-
//
|
| 296 |
-
|
| 297 |
-
profile: state.profile
|
| 298 |
}),
|
| 299 |
}
|
| 300 |
)
|
| 301 |
);
|
| 302 |
|
| 303 |
export default useAuthStore;
|
|
|
|
|
|
| 1 |
import { create } from 'zustand';
|
| 2 |
import { persist } from 'zustand/middleware';
|
| 3 |
import { supabase } from '../lib/supabaseClient';
|
| 4 |
+
import { API_CONFIG } from '../config';
|
| 5 |
import useTicketStore from './ticketStore';
|
| 6 |
|
| 7 |
+
const BACKEND_URL = API_CONFIG.BACKEND_URL;
|
| 8 |
+
|
| 9 |
+
const verifyServerCookieSession = async () => {
|
| 10 |
+
try {
|
| 11 |
+
const controller = new AbortController();
|
| 12 |
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
| 13 |
+
const res = await fetch(`${BACKEND_URL}/auth/me`, {
|
| 14 |
+
method: 'GET',
|
| 15 |
+
credentials: 'include',
|
| 16 |
+
headers: { Accept: 'application/json' },
|
| 17 |
+
signal: controller.signal,
|
| 18 |
+
});
|
| 19 |
+
clearTimeout(timeout);
|
| 20 |
+
if (!res.ok) return null;
|
| 21 |
+
const body = await res.json();
|
| 22 |
+
return body?.user || null;
|
| 23 |
+
} catch (e) {
|
| 24 |
+
console.warn('Server cookie session check failed:', e?.message || e);
|
| 25 |
+
return null;
|
| 26 |
+
}
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
const mirrorBackendAuth = async (path, payload) => {
|
| 30 |
+
try {
|
| 31 |
+
const controller = new AbortController();
|
| 32 |
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
| 33 |
+
await fetch(`${BACKEND_URL}${path}`, {
|
| 34 |
+
method: 'POST',
|
| 35 |
+
credentials: 'include',
|
| 36 |
+
headers: { 'Content-Type': 'application/json' },
|
| 37 |
+
body: JSON.stringify(payload),
|
| 38 |
+
signal: controller.signal,
|
| 39 |
+
});
|
| 40 |
+
clearTimeout(timeout);
|
| 41 |
+
} catch (e) {
|
| 42 |
+
console.warn(`Backend auth ${path} failed:`, e?.message || e);
|
| 43 |
+
}
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
let currentUserPromise = null;
|
| 47 |
+
|
| 48 |
+
const getProfileCache = (profile) => {
|
| 49 |
+
if (!profile?.id) return null;
|
| 50 |
+
|
| 51 |
+
return {
|
| 52 |
+
id: profile.id,
|
| 53 |
+
email: profile.email,
|
| 54 |
+
full_name: profile.full_name,
|
| 55 |
+
company: profile.company,
|
| 56 |
+
company_id: profile.company_id,
|
| 57 |
+
profile_picture: profile.profile_picture,
|
| 58 |
+
};
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
const useAuthStore = create(
|
| 62 |
persist(
|
| 63 |
(set, get) => ({
|
|
|
|
| 65 |
user: null,
|
| 66 |
profile: null,
|
| 67 |
loading: false,
|
| 68 |
+
isCheckingSession: true,
|
| 69 |
|
| 70 |
// --- SUPABASE AUTH METHODS ---
|
| 71 |
|
|
|
|
| 74 |
if (!user) return null;
|
| 75 |
|
| 76 |
const metadata = user.user_metadata || {};
|
| 77 |
+
set({ profile: null });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
+
// Always resolve authorization fields from the database. Local storage and
|
| 80 |
+
// user_metadata are client-controlled surfaces and must not grant roles.
|
| 81 |
+
const dbProfile = await get()._syncProfile(user.id);
|
| 82 |
+
if (dbProfile) {
|
| 83 |
+
return dbProfile;
|
| 84 |
+
}
|
| 85 |
|
| 86 |
const instantProfile = {
|
| 87 |
id: user.id,
|
| 88 |
email: user.email,
|
| 89 |
+
full_name: metadata.full_name || 'User',
|
| 90 |
+
role: 'user',
|
| 91 |
+
status: 'pending_email_verification',
|
| 92 |
company: metadata.company || ''
|
| 93 |
};
|
| 94 |
|
| 95 |
+
console.log("Falling back to non-authoritative profile:", instantProfile.role);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
set({ profile: instantProfile });
|
| 97 |
return instantProfile;
|
| 98 |
},
|
|
|
|
| 122 |
},
|
| 123 |
|
| 124 |
getCurrentUser: async () => {
|
| 125 |
+
if (currentUserPromise) {
|
| 126 |
+
return currentUserPromise;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
currentUserPromise = (async () => {
|
| 130 |
+
try {
|
| 131 |
+
set({ isCheckingSession: true });
|
| 132 |
+
const cookieUser = await verifyServerCookieSession();
|
| 133 |
+
if (cookieUser) {
|
| 134 |
+
set({ user: cookieUser });
|
| 135 |
+
await get().getProfile(cookieUser);
|
| 136 |
+
return cookieUser;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
const { data: { user }, error } = await supabase.auth.getUser();
|
| 140 |
+
if (error) throw error;
|
| 141 |
|
| 142 |
+
if (user) {
|
| 143 |
+
set({ user });
|
| 144 |
+
await get().getProfile(user);
|
| 145 |
+
} else {
|
| 146 |
+
set({ user: null, profile: null });
|
| 147 |
+
}
|
| 148 |
+
return user;
|
| 149 |
+
// eslint-disable-next-line no-unused-vars
|
| 150 |
+
} catch (error) {
|
| 151 |
set({ user: null, profile: null });
|
| 152 |
+
return null;
|
| 153 |
+
} finally {
|
| 154 |
+
set({ loading: false, isCheckingSession: false });
|
| 155 |
+
currentUserPromise = null;
|
| 156 |
}
|
| 157 |
+
})();
|
| 158 |
+
|
| 159 |
+
return currentUserPromise;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
},
|
| 161 |
|
| 162 |
login: async (email, password) => {
|
| 163 |
set({ loading: true });
|
| 164 |
console.log("Attempting login for:", email);
|
| 165 |
try {
|
| 166 |
+
await mirrorBackendAuth('/auth/login', { email, password });
|
| 167 |
+
|
| 168 |
const { data, error } = await supabase.auth.signInWithPassword({
|
| 169 |
email,
|
| 170 |
password,
|
|
|
|
| 195 |
}
|
| 196 |
},
|
| 197 |
|
| 198 |
+
loginWithGoogle: async () => {
|
| 199 |
+
const { error } =
|
| 200 |
+
await supabase.auth.signInWithOAuth({
|
| 201 |
+
provider: 'google',
|
| 202 |
+
options: {
|
| 203 |
+
redirectTo:
|
| 204 |
+
`${window.location.origin}/auth/callback`
|
| 205 |
+
}
|
| 206 |
+
});
|
| 207 |
+
|
| 208 |
+
if (error) {
|
| 209 |
+
console.error(
|
| 210 |
+
"Google OAuth error:",
|
| 211 |
+
error.message
|
| 212 |
+
);
|
| 213 |
+
|
| 214 |
+
throw error;
|
| 215 |
+
}
|
| 216 |
+
},
|
| 217 |
+
|
| 218 |
signInWithMagicLink: async (email) => {
|
| 219 |
set({ loading: true });
|
| 220 |
console.log("Attempting magic link / OTP login for:", email);
|
|
|
|
| 273 |
console.log("Starting signup for:", email);
|
| 274 |
|
| 275 |
try {
|
| 276 |
+
await mirrorBackendAuth('/auth/signup', {
|
| 277 |
+
email,
|
| 278 |
+
password,
|
| 279 |
+
full_name: fullName,
|
| 280 |
+
role,
|
| 281 |
+
company,
|
| 282 |
+
});
|
| 283 |
+
|
| 284 |
// 1. Auth Signup with Metadata
|
| 285 |
console.log("Step 1: Auth.signUp...");
|
| 286 |
const { data, error } = await supabase.auth.signUp({
|
|
|
|
| 321 |
logout: async () => {
|
| 322 |
set({ loading: true });
|
| 323 |
try {
|
| 324 |
+
try {
|
| 325 |
+
await fetch(`${BACKEND_URL}/auth/logout`, {
|
| 326 |
+
method: 'POST',
|
| 327 |
+
credentials: 'include',
|
| 328 |
+
});
|
| 329 |
+
} catch (e) {
|
| 330 |
+
console.warn('Backend cookie logout failed:', e?.message || e);
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
const { error } = await supabase.auth.signOut();
|
| 334 |
if (error) throw error;
|
| 335 |
set({ user: null, profile: null });
|
|
|
|
| 377 |
|
| 378 |
supabase.auth.onAuthStateChange(async (event, session) => {
|
| 379 |
console.log("Auth state change:", event);
|
| 380 |
+
try {
|
| 381 |
+
if (session?.user) {
|
| 382 |
+
set({ user: session.user, loading: true, isCheckingSession: true });
|
| 383 |
+
await get().getProfile(session.user);
|
| 384 |
+
} else {
|
| 385 |
+
set({ user: null, profile: null });
|
| 386 |
+
}
|
| 387 |
+
} catch (e) {
|
| 388 |
+
console.warn("Auth state change error:", e?.message || e);
|
| 389 |
set({ user: null, profile: null });
|
| 390 |
+
} finally {
|
| 391 |
+
set({ loading: false, isCheckingSession: false });
|
| 392 |
}
|
|
|
|
| 393 |
});
|
| 394 |
}
|
| 395 |
}),
|
| 396 |
{
|
| 397 |
name: 'auth-storage',
|
| 398 |
partialize: (state) => ({
|
| 399 |
+
// Cache display-only profile fields. Role/status must come from the DB.
|
| 400 |
+
profile: getProfileCache(state.profile)
|
|
|
|
| 401 |
}),
|
| 402 |
}
|
| 403 |
)
|
| 404 |
);
|
| 405 |
|
| 406 |
export default useAuthStore;
|
| 407 |
+
|
Frontend/src/store/ticketStore.js
CHANGED
|
@@ -3,14 +3,19 @@ import { persist } from 'zustand/middleware';
|
|
| 3 |
|
| 4 |
const useTicketStore = create(
|
| 5 |
persist(
|
| 6 |
-
(set) => ({
|
| 7 |
aiTicket: null,
|
| 8 |
activeTicket: null,
|
| 9 |
autoResolvedTickets: [], // For analytics
|
| 10 |
tickets: [], // Global queue for admins
|
| 11 |
notifications: [], // User notifications
|
|
|
|
|
|
|
| 12 |
setAITicket: (data) => set({ aiTicket: data }),
|
| 13 |
setActiveTicket: (ticket) => set({ activeTicket: ticket }),
|
|
|
|
|
|
|
|
|
|
| 14 |
addAutoResolvedTicket: (record) => set((state) => ({
|
| 15 |
autoResolvedTickets: [...state.autoResolvedTickets, record]
|
| 16 |
})),
|
|
@@ -30,27 +35,68 @@ const useTicketStore = create(
|
|
| 30 |
tickets: [...state.tickets, ticket]
|
| 31 |
};
|
| 32 |
}),
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
const updatedTickets = state.tickets.map(t => t.ticket_id === ticketId ? { ...t, ...updates } : t);
|
| 37 |
-
const shouldUpdateActive = state.activeTicket?.ticket_id === ticketId;
|
| 38 |
-
|
| 39 |
-
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
return {
|
| 43 |
tickets: updatedTickets,
|
| 44 |
activeTicket: shouldUpdateActive ? { ...state.activeTicket, ...updates } : state.activeTicket
|
| 45 |
};
|
| 46 |
}),
|
| 47 |
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
appendMessage: (ticketId, message) => set((state) => {
|
| 55 |
const updatedTickets = state.tickets.map(t =>
|
| 56 |
t.ticket_id === ticketId
|
|
|
|
| 3 |
|
| 4 |
const useTicketStore = create(
|
| 5 |
persist(
|
| 6 |
+
(set, get) => ({
|
| 7 |
aiTicket: null,
|
| 8 |
activeTicket: null,
|
| 9 |
autoResolvedTickets: [], // For analytics
|
| 10 |
tickets: [], // Global queue for admins
|
| 11 |
notifications: [], // User notifications
|
| 12 |
+
wsConnected: false, // WebSocket connection status
|
| 13 |
+
|
| 14 |
setAITicket: (data) => set({ aiTicket: data }),
|
| 15 |
setActiveTicket: (ticket) => set({ activeTicket: ticket }),
|
| 16 |
+
|
| 17 |
+
setWsConnected: (connected) => set({ wsConnected: connected }),
|
| 18 |
+
|
| 19 |
addAutoResolvedTicket: (record) => set((state) => ({
|
| 20 |
autoResolvedTickets: [...state.autoResolvedTickets, record]
|
| 21 |
})),
|
|
|
|
| 35 |
tickets: [...state.tickets, ticket]
|
| 36 |
};
|
| 37 |
}),
|
| 38 |
+
upsertTicket: (ticket) => set((state) => {
|
| 39 |
+
const ticketId = ticket?.id ?? ticket?.ticket_id;
|
| 40 |
+
if (!ticketId) return state;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
+
const exists = state.tickets.some(t => (t.id ?? t.ticket_id) === ticketId);
|
| 43 |
+
const tickets = exists
|
| 44 |
+
? state.tickets.map(t => (t.id ?? t.ticket_id) === ticketId ? { ...t, ...ticket } : t)
|
| 45 |
+
: [ticket, ...state.tickets];
|
| 46 |
+
const shouldUpdateActive = (state.activeTicket?.id ?? state.activeTicket?.ticket_id) === ticketId;
|
| 47 |
|
| 48 |
+
return {
|
| 49 |
+
tickets,
|
| 50 |
+
activeTicket: shouldUpdateActive ? { ...state.activeTicket, ...ticket } : state.activeTicket
|
| 51 |
+
};
|
| 52 |
+
}),
|
| 53 |
+
removeTicket: (ticketId) => set((state) => ({
|
| 54 |
+
tickets: state.tickets.filter(t => (t.id ?? t.ticket_id) !== ticketId),
|
| 55 |
+
activeTicket: (state.activeTicket?.id ?? state.activeTicket?.ticket_id) === ticketId
|
| 56 |
+
? null
|
| 57 |
+
: state.activeTicket
|
| 58 |
+
})),
|
| 59 |
+
updateTicket: (ticketId, updates) => set((state) => {
|
| 60 |
+
const existingTicket = state.tickets.find(t => (t.id ?? t.ticket_id) === ticketId);
|
| 61 |
+
if (!existingTicket) return state;
|
| 62 |
+
const updatedTickets = state.tickets.map(t => (t.id ?? t.ticket_id) === ticketId ? { ...t, ...updates } : t);
|
| 63 |
+
const shouldUpdateActive = (state.activeTicket?.id ?? state.activeTicket?.ticket_id) === ticketId;
|
| 64 |
return {
|
| 65 |
tickets: updatedTickets,
|
| 66 |
activeTicket: shouldUpdateActive ? { ...state.activeTicket, ...updates } : state.activeTicket
|
| 67 |
};
|
| 68 |
}),
|
| 69 |
|
| 70 |
+
/**
|
| 71 |
+
* Route an incoming WebSocket message to the correct store action.
|
| 72 |
+
*
|
| 73 |
+
* Call this from the component that owns the WebSocket connection
|
| 74 |
+
* (e.g. AdminDashboard) whenever a message arrives.
|
| 75 |
+
*/
|
| 76 |
+
handleWsMessage: (msg) => {
|
| 77 |
+
if (!msg || !msg.type) return;
|
| 78 |
+
|
| 79 |
+
const { type, event, ticket, ticket_id } = msg;
|
| 80 |
+
|
| 81 |
+
switch (type) {
|
| 82 |
+
case "ticket_update": {
|
| 83 |
+
if (!ticket) break;
|
| 84 |
+
if (event === "created") {
|
| 85 |
+
// Avoid duplicates — use upsert
|
| 86 |
+
get().upsertTicket(ticket);
|
| 87 |
+
} else if (event === "updated") {
|
| 88 |
+
get().upsertTicket(ticket);
|
| 89 |
+
} else if (event === "deleted") {
|
| 90 |
+
get().removeTicket(ticket_id);
|
| 91 |
+
}
|
| 92 |
+
break;
|
| 93 |
+
}
|
| 94 |
+
default:
|
| 95 |
+
// Ignore unknown message types (e.g. heartbeat)
|
| 96 |
+
break;
|
| 97 |
+
}
|
| 98 |
+
},
|
| 99 |
+
|
| 100 |
appendMessage: (ticketId, message) => set((state) => {
|
| 101 |
const updatedTickets = state.tickets.map(t =>
|
| 102 |
t.ticket_id === ticketId
|
Frontend/src/user/components/QuickActions.jsx
CHANGED
|
@@ -7,6 +7,7 @@ const actions = [
|
|
| 7 |
title: "Network Issues",
|
| 8 |
description: "Connectivity problems, VPN access, and slow internet.",
|
| 9 |
category: "Network",
|
|
|
|
| 10 |
icon: Network,
|
| 11 |
iconBg: '#EDFAF3',
|
| 12 |
iconColor: '#16a34a',
|
|
@@ -15,6 +16,7 @@ const actions = [
|
|
| 15 |
title: "Software Problems",
|
| 16 |
description: "Application crashes, license issues, and installations.",
|
| 17 |
category: "Software",
|
|
|
|
| 18 |
icon: Laptop,
|
| 19 |
iconBg: '#EEF2FF',
|
| 20 |
iconColor: '#4f46e5',
|
|
@@ -23,6 +25,7 @@ const actions = [
|
|
| 23 |
title: "Access Requests",
|
| 24 |
description: "Permission changes, new account setup, and MFA.",
|
| 25 |
category: "Access",
|
|
|
|
| 26 |
icon: ShieldCheck,
|
| 27 |
iconBg: '#F5F0FF',
|
| 28 |
iconColor: '#7c3aed',
|
|
@@ -33,8 +36,8 @@ const QuickActions = () => {
|
|
| 33 |
const navigate = useNavigate();
|
| 34 |
const [hoveredIdx, setHoveredIdx] = useState(null);
|
| 35 |
|
| 36 |
-
const handleActionClick = (
|
| 37 |
-
navigate('/create-ticket', { state: { prefilledCategory: category } });
|
| 38 |
};
|
| 39 |
|
| 40 |
return (
|
|
@@ -42,7 +45,7 @@ const QuickActions = () => {
|
|
| 42 |
{actions.map((action, index) => (
|
| 43 |
<div
|
| 44 |
key={index}
|
| 45 |
-
onClick={() => handleActionClick(action
|
| 46 |
onMouseEnter={() => setHoveredIdx(index)}
|
| 47 |
onMouseLeave={() => setHoveredIdx(null)}
|
| 48 |
style={{
|
|
|
|
| 7 |
title: "Network Issues",
|
| 8 |
description: "Connectivity problems, VPN access, and slow internet.",
|
| 9 |
category: "Network",
|
| 10 |
+
templateId: "vpn-connectivity",
|
| 11 |
icon: Network,
|
| 12 |
iconBg: '#EDFAF3',
|
| 13 |
iconColor: '#16a34a',
|
|
|
|
| 16 |
title: "Software Problems",
|
| 17 |
description: "Application crashes, license issues, and installations.",
|
| 18 |
category: "Software",
|
| 19 |
+
templateId: "software-installation",
|
| 20 |
icon: Laptop,
|
| 21 |
iconBg: '#EEF2FF',
|
| 22 |
iconColor: '#4f46e5',
|
|
|
|
| 25 |
title: "Access Requests",
|
| 26 |
description: "Permission changes, new account setup, and MFA.",
|
| 27 |
category: "Access",
|
| 28 |
+
templateId: "password-reset",
|
| 29 |
icon: ShieldCheck,
|
| 30 |
iconBg: '#F5F0FF',
|
| 31 |
iconColor: '#7c3aed',
|
|
|
|
| 36 |
const navigate = useNavigate();
|
| 37 |
const [hoveredIdx, setHoveredIdx] = useState(null);
|
| 38 |
|
| 39 |
+
const handleActionClick = (action) => {
|
| 40 |
+
navigate('/create-ticket', { state: { templateId: action.templateId, prefilledCategory: action.category } });
|
| 41 |
};
|
| 42 |
|
| 43 |
return (
|
|
|
|
| 45 |
{actions.map((action, index) => (
|
| 46 |
<div
|
| 47 |
key={index}
|
| 48 |
+
onClick={() => handleActionClick(action)}
|
| 49 |
onMouseEnter={() => setHoveredIdx(index)}
|
| 50 |
onMouseLeave={() => setHoveredIdx(null)}
|
| 51 |
style={{
|
Frontend/src/user/components/RecentTickets.jsx
CHANGED
|
@@ -150,6 +150,11 @@ const RecentTickets = () => {
|
|
| 150 |
<p style={{ fontSize: '14px', fontWeight: 500, color: '#111827', margin: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '320px' }}>
|
| 151 |
{ticket.summary || ticket.subject || ticket.description || "No description provided"}
|
| 152 |
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
</td>
|
| 154 |
<td style={{ padding: '16px 28px' }}>
|
| 155 |
{getStatusBadge(ticket.status)}
|
|
|
|
| 150 |
<p style={{ fontSize: '14px', fontWeight: 500, color: '#111827', margin: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '320px' }}>
|
| 151 |
{ticket.summary || ticket.subject || ticket.description || "No description provided"}
|
| 152 |
</p>
|
| 153 |
+
{ticket?.metadata?.translation?.translated && (
|
| 154 |
+
<p style={{ fontSize: '11px', color: '#0369a1', margin: '4px 0 0' }}>
|
| 155 |
+
Translated from {ticket.metadata.translation.source_language_name || ticket.metadata.translation.source_language || 'Unknown'}
|
| 156 |
+
</p>
|
| 157 |
+
)}
|
| 158 |
</td>
|
| 159 |
<td style={{ padding: '16px 28px' }}>
|
| 160 |
{getStatusBadge(ticket.status)}
|
Frontend/src/user/components/TopNav.jsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
-
import React, { useState } from 'react';
|
| 2 |
-
import { Bell, Box, CheckCircle2, MessageSquare, Menu, X, LogOut, User as UserIcon,
|
| 3 |
import { Link, useNavigate } from 'react-router-dom';
|
| 4 |
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar";
|
| 5 |
import { Button } from "../../components/ui/button";
|
|
@@ -8,15 +8,32 @@ import { Popover, PopoverContent, PopoverTrigger } from "../../components/ui/pop
|
|
| 8 |
import useTicketStore from "../../store/ticketStore";
|
| 9 |
// removed useNotificationStore
|
| 10 |
import NotificationPopover from "./NotificationPopover";
|
| 11 |
-
|
| 12 |
import useAuthStore from "../../store/authStore";
|
| 13 |
|
| 14 |
const TopNav = () => {
|
| 15 |
const navigate = useNavigate();
|
| 16 |
-
|
| 17 |
-
|
| 18 |
const { profile, logout } = useAuthStore();
|
| 19 |
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
const initials = profile?.full_name ? profile.full_name[0].toUpperCase() : (profile?.email ? profile.email[0].toUpperCase() : 'U');
|
| 22 |
|
|
@@ -25,29 +42,35 @@ const TopNav = () => {
|
|
| 25 |
navigate('/login');
|
| 26 |
};
|
| 27 |
|
| 28 |
-
|
| 29 |
-
|
| 30 |
return (
|
| 31 |
-
<header className="w-full bg-white border-b border-gray-200 sticky top-0 z-50">
|
| 32 |
<div className="max-w-[1100px] mx-auto px-4 md:px-6 h-16 flex items-center justify-between">
|
| 33 |
{/* Left: Logo */}
|
| 34 |
<div className="flex items-center gap-3 cursor-pointer" onClick={() => navigate('/dashboard')}>
|
| 35 |
<div className="flex items-center justify-center overflow-hidden">
|
| 36 |
<img src="/favicon.png" alt="HELPDESK.AI Logo" className="w-7 h-7 object-contain" />
|
| 37 |
</div>
|
| 38 |
-
<h1 className="text-xl font-black tracking-tighter text-gray-900 italic">HELPDESK.AI</h1>
|
| 39 |
</div>
|
| 40 |
|
| 41 |
{/* Center: Navigation Links */}
|
| 42 |
<nav className="hidden md:flex items-center gap-8">
|
| 43 |
-
<Link className="text-sm font-semibold text-gray-900 hover:text-emerald-600 transition-colors" to="/dashboard">Dashboard</Link>
|
| 44 |
-
<Link className="text-sm font-semibold text-gray-500 hover:text-gray-900 transition-colors" to="/my-tickets">My Tickets</Link>
|
| 45 |
-
<Link className="text-sm font-semibold text-gray-500 hover:text-gray-900 transition-colors" to="/help">Help</Link>
|
| 46 |
-
<Link className="text-sm font-semibold text-gray-500 hover:text-gray-900 transition-colors" to="/docs">Documentation</Link>
|
| 47 |
</nav>
|
| 48 |
|
| 49 |
{/* Right: Profile */}
|
| 50 |
<div className="flex items-center gap-3">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
<NotificationPopover />
|
| 52 |
<div className="hidden md:block">
|
| 53 |
<Avatar
|
|
@@ -60,7 +83,7 @@ const TopNav = () => {
|
|
| 60 |
</div>
|
| 61 |
<button
|
| 62 |
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
| 63 |
-
className="md:hidden p-2 text-gray-600 focus:outline-none"
|
| 64 |
>
|
| 65 |
{isMenuOpen ? <X size={24} /> : <Menu size={24} />}
|
| 66 |
</button>
|
|
@@ -69,54 +92,35 @@ const TopNav = () => {
|
|
| 69 |
|
| 70 |
{/* Mobile Menu Overlay */}
|
| 71 |
{isMenuOpen && (
|
| 72 |
-
<div className="md:hidden bg-white border-t border-gray-100 absolute w-full shadow-2xl z-50 animate-in fade-in slide-in-from-top-2 duration-200">
|
| 73 |
<div className="px-6 py-8 space-y-6">
|
| 74 |
-
<div className="flex items-center gap-4 border-b border-gray-50 pb-6">
|
| 75 |
<Avatar className="size-12 border border-gray-100">
|
| 76 |
<AvatarImage src={profile?.profile_picture} />
|
| 77 |
<AvatarFallback className="bg-emerald-50 text-emerald-700 font-black">{initials}</AvatarFallback>
|
| 78 |
</Avatar>
|
| 79 |
<div>
|
| 80 |
-
<p className="font-bold text-gray-900">{profile?.full_name}</p>
|
| 81 |
<p className="text-xs text-gray-400 font-medium">{profile?.email}</p>
|
| 82 |
</div>
|
| 83 |
</div>
|
| 84 |
|
| 85 |
<div className="space-y-4">
|
| 86 |
-
<Link
|
| 87 |
-
to="/dashboard"
|
| 88 |
-
onClick={() => setIsMenuOpen(false)}
|
| 89 |
-
className="flex items-center gap-3 text-lg font-bold text-gray-700 hover:text-emerald-700 transition-colors"
|
| 90 |
-
>
|
| 91 |
<Box size={20} className="text-gray-400" /> Dashboard
|
| 92 |
</Link>
|
| 93 |
-
<Link
|
| 94 |
-
to="/my-tickets"
|
| 95 |
-
onClick={() => setIsMenuOpen(false)}
|
| 96 |
-
className="flex items-center gap-3 text-lg font-bold text-gray-700 hover:text-emerald-700 transition-colors"
|
| 97 |
-
>
|
| 98 |
<MessageSquare size={20} className="text-gray-400" /> My Tickets
|
| 99 |
</Link>
|
| 100 |
-
<Link
|
| 101 |
-
to="/profile"
|
| 102 |
-
onClick={() => setIsMenuOpen(false)}
|
| 103 |
-
className="flex items-center gap-3 text-lg font-bold text-gray-700 hover:text-emerald-700 transition-colors"
|
| 104 |
-
>
|
| 105 |
<UserIcon size={20} className="text-gray-400" /> My Profile
|
| 106 |
</Link>
|
| 107 |
-
<Link
|
| 108 |
-
to="/docs"
|
| 109 |
-
onClick={() => setIsMenuOpen(false)}
|
| 110 |
-
className="flex items-center gap-3 text-lg font-bold text-gray-700 hover:text-emerald-700 transition-colors"
|
| 111 |
-
>
|
| 112 |
-
<BookOpen size={20} className="text-gray-400" /> Documentation
|
| 113 |
-
</Link>
|
| 114 |
</div>
|
| 115 |
|
| 116 |
-
<div className="pt-6 border-t border-gray-50">
|
| 117 |
<button
|
| 118 |
onClick={handleLogout}
|
| 119 |
-
className="w-full py-4 bg-gray-50 rounded-2xl flex items-center justify-center gap-2 text-red-600 font-bold active:scale-95 transition-all"
|
| 120 |
>
|
| 121 |
<LogOut size={18} /> Sign Out
|
| 122 |
</button>
|
|
@@ -128,4 +132,4 @@ const TopNav = () => {
|
|
| 128 |
);
|
| 129 |
};
|
| 130 |
|
| 131 |
-
export default TopNav;
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { Bell, Box, CheckCircle2, MessageSquare, Menu, X, LogOut, User as UserIcon, Moon, Sun } from 'lucide-react';
|
| 3 |
import { Link, useNavigate } from 'react-router-dom';
|
| 4 |
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar";
|
| 5 |
import { Button } from "../../components/ui/button";
|
|
|
|
| 8 |
import useTicketStore from "../../store/ticketStore";
|
| 9 |
// removed useNotificationStore
|
| 10 |
import NotificationPopover from "./NotificationPopover";
|
|
|
|
| 11 |
import useAuthStore from "../../store/authStore";
|
| 12 |
|
| 13 |
const TopNav = () => {
|
| 14 |
const navigate = useNavigate();
|
|
|
|
|
|
|
| 15 |
const { profile, logout } = useAuthStore();
|
| 16 |
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
| 17 |
+
const [isDark, setIsDark] = useState(false);
|
| 18 |
+
|
| 19 |
+
useEffect(() => {
|
| 20 |
+
const saved = localStorage.getItem('theme');
|
| 21 |
+
if (saved === 'dark') {
|
| 22 |
+
document.documentElement.classList.add('dark');
|
| 23 |
+
setIsDark(true);
|
| 24 |
+
}
|
| 25 |
+
}, []);
|
| 26 |
+
|
| 27 |
+
const toggleDark = () => {
|
| 28 |
+
if (isDark) {
|
| 29 |
+
document.documentElement.classList.remove('dark');
|
| 30 |
+
localStorage.setItem('theme', 'light');
|
| 31 |
+
} else {
|
| 32 |
+
document.documentElement.classList.add('dark');
|
| 33 |
+
localStorage.setItem('theme', 'dark');
|
| 34 |
+
}
|
| 35 |
+
setIsDark(!isDark);
|
| 36 |
+
};
|
| 37 |
|
| 38 |
const initials = profile?.full_name ? profile.full_name[0].toUpperCase() : (profile?.email ? profile.email[0].toUpperCase() : 'U');
|
| 39 |
|
|
|
|
| 42 |
navigate('/login');
|
| 43 |
};
|
| 44 |
|
|
|
|
|
|
|
| 45 |
return (
|
| 46 |
+
<header className="w-full bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-50">
|
| 47 |
<div className="max-w-[1100px] mx-auto px-4 md:px-6 h-16 flex items-center justify-between">
|
| 48 |
{/* Left: Logo */}
|
| 49 |
<div className="flex items-center gap-3 cursor-pointer" onClick={() => navigate('/dashboard')}>
|
| 50 |
<div className="flex items-center justify-center overflow-hidden">
|
| 51 |
<img src="/favicon.png" alt="HELPDESK.AI Logo" className="w-7 h-7 object-contain" />
|
| 52 |
</div>
|
| 53 |
+
<h1 className="text-xl font-black tracking-tighter text-gray-900 dark:text-white italic">HELPDESK.AI</h1>
|
| 54 |
</div>
|
| 55 |
|
| 56 |
{/* Center: Navigation Links */}
|
| 57 |
<nav className="hidden md:flex items-center gap-8">
|
| 58 |
+
<Link className="text-sm font-semibold text-gray-900 dark:text-gray-100 hover:text-emerald-600 transition-colors" to="/dashboard">Dashboard</Link>
|
| 59 |
+
<Link className="text-sm font-semibold text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors" to="/my-tickets">My Tickets</Link>
|
| 60 |
+
<Link className="text-sm font-semibold text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors" to="/help">Help</Link>
|
|
|
|
| 61 |
</nav>
|
| 62 |
|
| 63 |
{/* Right: Profile */}
|
| 64 |
<div className="flex items-center gap-3">
|
| 65 |
+
{/* Dark Mode Toggle */}
|
| 66 |
+
<button
|
| 67 |
+
onClick={toggleDark}
|
| 68 |
+
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
| 69 |
+
aria-label="Toggle dark mode"
|
| 70 |
+
>
|
| 71 |
+
{isDark ? <Sun size={20} className="text-yellow-400" /> : <Moon size={20} className="text-gray-600" />}
|
| 72 |
+
</button>
|
| 73 |
+
|
| 74 |
<NotificationPopover />
|
| 75 |
<div className="hidden md:block">
|
| 76 |
<Avatar
|
|
|
|
| 83 |
</div>
|
| 84 |
<button
|
| 85 |
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
| 86 |
+
className="md:hidden p-2 text-gray-600 dark:text-gray-300 focus:outline-none"
|
| 87 |
>
|
| 88 |
{isMenuOpen ? <X size={24} /> : <Menu size={24} />}
|
| 89 |
</button>
|
|
|
|
| 92 |
|
| 93 |
{/* Mobile Menu Overlay */}
|
| 94 |
{isMenuOpen && (
|
| 95 |
+
<div className="md:hidden bg-white dark:bg-gray-900 border-t border-gray-100 dark:border-gray-700 absolute w-full shadow-2xl z-50 animate-in fade-in slide-in-from-top-2 duration-200">
|
| 96 |
<div className="px-6 py-8 space-y-6">
|
| 97 |
+
<div className="flex items-center gap-4 border-b border-gray-50 dark:border-gray-700 pb-6">
|
| 98 |
<Avatar className="size-12 border border-gray-100">
|
| 99 |
<AvatarImage src={profile?.profile_picture} />
|
| 100 |
<AvatarFallback className="bg-emerald-50 text-emerald-700 font-black">{initials}</AvatarFallback>
|
| 101 |
</Avatar>
|
| 102 |
<div>
|
| 103 |
+
<p className="font-bold text-gray-900 dark:text-white">{profile?.full_name}</p>
|
| 104 |
<p className="text-xs text-gray-400 font-medium">{profile?.email}</p>
|
| 105 |
</div>
|
| 106 |
</div>
|
| 107 |
|
| 108 |
<div className="space-y-4">
|
| 109 |
+
<Link to="/dashboard" onClick={() => setIsMenuOpen(false)} className="flex items-center gap-3 text-lg font-bold text-gray-700 dark:text-gray-200 hover:text-emerald-700 transition-colors">
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
<Box size={20} className="text-gray-400" /> Dashboard
|
| 111 |
</Link>
|
| 112 |
+
<Link to="/my-tickets" onClick={() => setIsMenuOpen(false)} className="flex items-center gap-3 text-lg font-bold text-gray-700 dark:text-gray-200 hover:text-emerald-700 transition-colors">
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
<MessageSquare size={20} className="text-gray-400" /> My Tickets
|
| 114 |
</Link>
|
| 115 |
+
<Link to="/profile" onClick={() => setIsMenuOpen(false)} className="flex items-center gap-3 text-lg font-bold text-gray-700 dark:text-gray-200 hover:text-emerald-700 transition-colors">
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
<UserIcon size={20} className="text-gray-400" /> My Profile
|
| 117 |
</Link>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
</div>
|
| 119 |
|
| 120 |
+
<div className="pt-6 border-t border-gray-50 dark:border-gray-700">
|
| 121 |
<button
|
| 122 |
onClick={handleLogout}
|
| 123 |
+
className="w-full py-4 bg-gray-50 dark:bg-gray-800 rounded-2xl flex items-center justify-center gap-2 text-red-600 font-bold active:scale-95 transition-all"
|
| 124 |
>
|
| 125 |
<LogOut size={18} /> Sign Out
|
| 126 |
</button>
|
|
|
|
| 132 |
);
|
| 133 |
};
|
| 134 |
|
| 135 |
+
export default TopNav;
|
Frontend/src/user/pages/AIProcessing.jsx
CHANGED
|
@@ -24,7 +24,7 @@ const steps = [
|
|
| 24 |
const AIProcessing = () => {
|
| 25 |
const navigate = useNavigate();
|
| 26 |
const location = useLocation();
|
| 27 |
-
const { text, image_text, image_base64 } = location.state || {};
|
| 28 |
const setAITicket = useTicketStore((state) => state.setAITicket);
|
| 29 |
const { settings } = useAdminStore();
|
| 30 |
const { user, profile } = useAuthStore();
|
|
@@ -112,9 +112,15 @@ const AIProcessing = () => {
|
|
| 112 |
profile?.company ||
|
| 113 |
user?.user_metadata?.company ||
|
| 114 |
"System",
|
|
|
|
| 115 |
image_url: uploadedImageUrl,
|
| 116 |
confidence_threshold: settings.aiConfidenceThreshold,
|
| 117 |
-
duplicate_sensitivity: settings.duplicateSensitivity
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
};
|
| 119 |
|
| 120 |
const response = await fetch(
|
|
@@ -270,10 +276,10 @@ const AIProcessing = () => {
|
|
| 270 |
const aiTicketObject = {
|
| 271 |
...finalTicket,
|
| 272 |
status: 'analyzing',
|
| 273 |
-
originalIssue: text,
|
|
|
|
| 274 |
capturedFileBase64: image_base64,
|
| 275 |
-
ocrText: image_text
|
| 276 |
-
image_url: uploadedImageUrl || finalTicket.image_url
|
| 277 |
};
|
| 278 |
|
| 279 |
setAITicket(aiTicketObject);
|
|
@@ -343,10 +349,10 @@ const AIProcessing = () => {
|
|
| 343 |
|
| 344 |
ocr_text: image_text || "",
|
| 345 |
highlights: [],
|
| 346 |
-
originalIssue: text,
|
|
|
|
| 347 |
capturedFileBase64: image_base64,
|
| 348 |
-
ocrText: image_text
|
| 349 |
-
image_url: uploadedImageUrl
|
| 350 |
};
|
| 351 |
|
| 352 |
setAITicket(fallbackTicket);
|
|
|
|
| 24 |
const AIProcessing = () => {
|
| 25 |
const navigate = useNavigate();
|
| 26 |
const location = useLocation();
|
| 27 |
+
const { text, image_text, image_base64, template_id, template_used, user_modified, ticket_title, original_text, original_language } = location.state || {};
|
| 28 |
const setAITicket = useTicketStore((state) => state.setAITicket);
|
| 29 |
const { settings } = useAdminStore();
|
| 30 |
const { user, profile } = useAuthStore();
|
|
|
|
| 112 |
profile?.company ||
|
| 113 |
user?.user_metadata?.company ||
|
| 114 |
"System",
|
| 115 |
+
company_id: profile?.company_id || null,
|
| 116 |
image_url: uploadedImageUrl,
|
| 117 |
confidence_threshold: settings.aiConfidenceThreshold,
|
| 118 |
+
duplicate_sensitivity: settings.duplicateSensitivity,
|
| 119 |
+
// Smart Template metadata (backend can use for improved routing)
|
| 120 |
+
template_id: template_id || null,
|
| 121 |
+
template_used: template_used || false,
|
| 122 |
+
user_modified: user_modified || false,
|
| 123 |
+
ticket_title: ticket_title || null,
|
| 124 |
};
|
| 125 |
|
| 126 |
const response = await fetch(
|
|
|
|
| 276 |
const aiTicketObject = {
|
| 277 |
...finalTicket,
|
| 278 |
status: 'analyzing',
|
| 279 |
+
originalIssue: original_text || text,
|
| 280 |
+
originalLanguage: original_language || 'en',
|
| 281 |
capturedFileBase64: image_base64,
|
| 282 |
+
ocrText: image_text
|
|
|
|
| 283 |
};
|
| 284 |
|
| 285 |
setAITicket(aiTicketObject);
|
|
|
|
| 349 |
|
| 350 |
ocr_text: image_text || "",
|
| 351 |
highlights: [],
|
| 352 |
+
originalIssue: original_text || text,
|
| 353 |
+
originalLanguage: original_language || 'en',
|
| 354 |
capturedFileBase64: image_base64,
|
| 355 |
+
ocrText: image_text
|
|
|
|
| 356 |
};
|
| 357 |
|
| 358 |
setAITicket(fallbackTicket);
|
Frontend/src/user/pages/AutoResolveChat.jsx
CHANGED
|
@@ -19,6 +19,7 @@ const AutoResolveChat = () => {
|
|
| 19 |
const { aiTicket } = useTicketStore();
|
| 20 |
const navigate = useNavigate();
|
| 21 |
const [messages, setMessages] = useState([]);
|
|
|
|
| 22 |
const [isThinking, setIsThinking] = useState(false);
|
| 23 |
const [isFinal, setIsFinal] = useState(false);
|
| 24 |
const [inputText, setInputText] = useState('');
|
|
@@ -77,7 +78,7 @@ const AutoResolveChat = () => {
|
|
| 77 |
}
|
| 78 |
|
| 79 |
if (newSteps.length >= 2) {
|
| 80 |
-
|
| 81 |
} else {
|
| 82 |
const sentences = response
|
| 83 |
.replace(/\*\*/g, '')
|
|
@@ -87,7 +88,12 @@ const AutoResolveChat = () => {
|
|
| 87 |
.slice(0, 4);
|
| 88 |
|
| 89 |
if (sentences.length >= 2) {
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
} else {
|
| 92 |
throw new Error("Could not parse steps from AI response.");
|
| 93 |
}
|
|
@@ -197,6 +203,12 @@ const AutoResolveChat = () => {
|
|
| 197 |
else recognition.start();
|
| 198 |
};
|
| 199 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
if (!aiTicket) return null;
|
| 201 |
|
| 202 |
return (
|
|
@@ -249,6 +261,48 @@ const AutoResolveChat = () => {
|
|
| 249 |
</div>
|
| 250 |
</div>
|
| 251 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
{/* Chat Messages */}
|
| 253 |
<div
|
| 254 |
ref={scrollRef}
|
|
|
|
| 19 |
const { aiTicket } = useTicketStore();
|
| 20 |
const navigate = useNavigate();
|
| 21 |
const [messages, setMessages] = useState([]);
|
| 22 |
+
const [steps, setSteps] = useState([]);
|
| 23 |
const [isThinking, setIsThinking] = useState(false);
|
| 24 |
const [isFinal, setIsFinal] = useState(false);
|
| 25 |
const [inputText, setInputText] = useState('');
|
|
|
|
| 78 |
}
|
| 79 |
|
| 80 |
if (newSteps.length >= 2) {
|
| 81 |
+
setSteps(newSteps);
|
| 82 |
} else {
|
| 83 |
const sentences = response
|
| 84 |
.replace(/\*\*/g, '')
|
|
|
|
| 88 |
.slice(0, 4);
|
| 89 |
|
| 90 |
if (sentences.length >= 2) {
|
| 91 |
+
const sentenceSteps = sentences.map((s, i) => ({
|
| 92 |
+
id: i + 1,
|
| 93 |
+
task: s,
|
| 94 |
+
completed: false
|
| 95 |
+
}));
|
| 96 |
+
setSteps(sentenceSteps);
|
| 97 |
} else {
|
| 98 |
throw new Error("Could not parse steps from AI response.");
|
| 99 |
}
|
|
|
|
| 203 |
else recognition.start();
|
| 204 |
};
|
| 205 |
|
| 206 |
+
const toggleStep = (stepId) => {
|
| 207 |
+
setSteps(prev => prev.map(step =>
|
| 208 |
+
step.id === stepId ? { ...step, completed: !step.completed } : step
|
| 209 |
+
));
|
| 210 |
+
};
|
| 211 |
+
|
| 212 |
if (!aiTicket) return null;
|
| 213 |
|
| 214 |
return (
|
|
|
|
| 261 |
</div>
|
| 262 |
</div>
|
| 263 |
|
| 264 |
+
{/* Troubleshooting Steps */}
|
| 265 |
+
{steps.length > 0 && (
|
| 266 |
+
<div className="px-10 py-6 border-b border-white/40 bg-emerald-50/30">
|
| 267 |
+
<div className="flex items-center gap-2 mb-3">
|
| 268 |
+
<ListChecks size={16} className="text-emerald-600" />
|
| 269 |
+
<h3 className="text-[10px] font-black text-emerald-700 uppercase tracking-[0.2em]">
|
| 270 |
+
Troubleshooting Plan
|
| 271 |
+
</h3>
|
| 272 |
+
</div>
|
| 273 |
+
<div className="space-y-2">
|
| 274 |
+
{steps.map((step) => (
|
| 275 |
+
<button
|
| 276 |
+
key={step.id}
|
| 277 |
+
onClick={() => toggleStep(step.id)}
|
| 278 |
+
className={`w-full text-left flex items-center gap-3 p-3 rounded-xl border transition-all duration-300 ${
|
| 279 |
+
step.completed
|
| 280 |
+
? 'bg-emerald-100 border-emerald-200 opacity-60'
|
| 281 |
+
: 'bg-white border-slate-100 hover:border-emerald-200 hover:bg-emerald-50/50'
|
| 282 |
+
}`}
|
| 283 |
+
>
|
| 284 |
+
<div className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 transition-all duration-300 ${
|
| 285 |
+
step.completed
|
| 286 |
+
? 'bg-emerald-500 text-white'
|
| 287 |
+
: 'bg-slate-100 text-slate-400'
|
| 288 |
+
}`}>
|
| 289 |
+
{step.completed ? (
|
| 290 |
+
<CheckCircle2 size={14} />
|
| 291 |
+
) : (
|
| 292 |
+
<span className="text-[10px] font-black">{step.id}</span>
|
| 293 |
+
)}
|
| 294 |
+
</div>
|
| 295 |
+
<span className={`text-[13px] font-bold leading-snug transition-all duration-300 ${
|
| 296 |
+
step.completed ? 'text-slate-400 line-through' : 'text-slate-700'
|
| 297 |
+
}`}>
|
| 298 |
+
{step.task}
|
| 299 |
+
</span>
|
| 300 |
+
</button>
|
| 301 |
+
))}
|
| 302 |
+
</div>
|
| 303 |
+
</div>
|
| 304 |
+
)}
|
| 305 |
+
|
| 306 |
{/* Chat Messages */}
|
| 307 |
<div
|
| 308 |
ref={scrollRef}
|
Frontend/src/user/pages/CreateTicket.jsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import React, { useState, useRef, useEffect } from 'react';
|
| 2 |
-
import { useNavigate } from 'react-router-dom';
|
| 3 |
import {
|
| 4 |
Upload,
|
| 5 |
X,
|
|
@@ -21,11 +21,14 @@ import { motion, AnimatePresence } from 'framer-motion';
|
|
| 21 |
import { Button } from "../../components/ui/button";
|
| 22 |
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "../../components/ui/card";
|
| 23 |
import { Textarea } from "../../components/ui/textarea";
|
| 24 |
-
import Tesseract from 'tesseract.js';
|
| 25 |
import { translateText, SUPPORTED_LANGUAGES } from '../../services/translationService';
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
const CreateTicket = () => {
|
| 28 |
const [issue, setIssue] = useState('');
|
|
|
|
| 29 |
const [file, setFile] = useState(null);
|
| 30 |
const [imagePreview, setImagePreview] = useState(null);
|
| 31 |
const [isLoading, setIsLoading] = useState(false);
|
|
@@ -33,8 +36,10 @@ const CreateTicket = () => {
|
|
| 33 |
const [extractedOCR, setExtractedOCR] = useState('');
|
| 34 |
const [isOcrLoading, setIsOcrLoading] = useState(false);
|
| 35 |
const [isListening, setIsListening] = useState(false);
|
|
|
|
| 36 |
const fileInputRef = useRef(null);
|
| 37 |
const navigate = useNavigate();
|
|
|
|
| 38 |
const MAX_CHARS = 1000;
|
| 39 |
const supportsSpeech = 'SpeechRecognition' in window || 'webkitSpeechRecognition' in window;
|
| 40 |
const [selectedLanguage, setSelectedLanguage] = useState('en');
|
|
@@ -42,6 +47,13 @@ const CreateTicket = () => {
|
|
| 42 |
const [isLangOpen, setIsLangOpen] = useState(false);
|
| 43 |
const langRef = useRef(null);
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
// Voice UI states
|
| 46 |
const [showVoiceModal, setShowVoiceModal] = useState(false);
|
| 47 |
const [voiceTranscript, setVoiceTranscript] = useState('');
|
|
@@ -58,12 +70,25 @@ const CreateTicket = () => {
|
|
| 58 |
|
| 59 |
useEffect(() => {
|
| 60 |
return () => {
|
|
|
|
| 61 |
if (recognitionRef.current) recognitionRef.current.stop();
|
| 62 |
if (audioContextRef.current) audioContextRef.current.close();
|
| 63 |
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
|
| 64 |
};
|
| 65 |
}, []);
|
| 66 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
// Close language dropdown on outside click
|
| 68 |
useEffect(() => {
|
| 69 |
const handleClickOutside = (event) => {
|
|
@@ -85,6 +110,7 @@ const CreateTicket = () => {
|
|
| 85 |
const processOCR = async (imageFile) => {
|
| 86 |
setIsOcrLoading(true);
|
| 87 |
try {
|
|
|
|
| 88 |
const { data: { text } } = await Tesseract.recognize(imageFile, 'eng');
|
| 89 |
setExtractedOCR(text.trim());
|
| 90 |
} catch (err) {
|
|
@@ -165,13 +191,22 @@ const CreateTicket = () => {
|
|
| 165 |
console.error("Speech Recognition Error:", event.error);
|
| 166 |
if (event.error !== 'no-speech') {
|
| 167 |
setError(`Microphone error: ${event.error}`);
|
|
|
|
| 168 |
}
|
| 169 |
};
|
| 170 |
|
| 171 |
recognition.onend = () => {
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
}
|
| 176 |
};
|
| 177 |
|
|
@@ -179,6 +214,7 @@ const CreateTicket = () => {
|
|
| 179 |
recognition.start();
|
| 180 |
|
| 181 |
setIsListening(true);
|
|
|
|
| 182 |
setShowVoiceModal(true);
|
| 183 |
setVoiceTranscript('');
|
| 184 |
setInterimVoice('');
|
|
@@ -192,6 +228,7 @@ const CreateTicket = () => {
|
|
| 192 |
|
| 193 |
const stopListening = () => {
|
| 194 |
setIsListening(false);
|
|
|
|
| 195 |
if (recognitionRef.current) {
|
| 196 |
recognitionRef.current.stop();
|
| 197 |
recognitionRef.current = null;
|
|
@@ -263,11 +300,68 @@ const CreateTicket = () => {
|
|
| 263 |
}
|
| 264 |
};
|
| 265 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
const handleAnalyze = async (e) => {
|
| 267 |
e.preventDefault();
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
}
|
| 272 |
|
| 273 |
if (file && !isOcrLoading && !extractedOCR.trim()) {
|
|
@@ -279,15 +373,20 @@ const CreateTicket = () => {
|
|
| 279 |
setError('');
|
| 280 |
|
| 281 |
try {
|
| 282 |
-
let textToSubmit =
|
| 283 |
|
| 284 |
// Translate to English if a different language is selected
|
| 285 |
if (selectedLanguage !== 'en') {
|
| 286 |
setIsTranslating(true);
|
| 287 |
-
textToSubmit = await translateText(
|
| 288 |
setIsTranslating(false);
|
| 289 |
}
|
| 290 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
let imageBase64 = "";
|
| 292 |
let extractedOCRText = extractedOCR;
|
| 293 |
if (file) {
|
|
@@ -304,10 +403,15 @@ const CreateTicket = () => {
|
|
| 304 |
navigate('/ai-processing', {
|
| 305 |
state: {
|
| 306 |
text: textToSubmit,
|
| 307 |
-
original_text:
|
| 308 |
original_language: selectedLanguage,
|
| 309 |
image_base64: imageBase64,
|
| 310 |
-
image_text: extractedOCRText
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
}
|
| 312 |
});
|
| 313 |
|
|
@@ -346,88 +450,133 @@ const CreateTicket = () => {
|
|
| 346 |
|
| 347 |
<CardContent className="p-8 pt-2 flex-grow flex flex-col">
|
| 348 |
<form onSubmit={handleAnalyze} className="space-y-6 flex-grow flex flex-col">
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
>
|
| 367 |
-
<span className="flex items-center gap-2">
|
| 368 |
-
<Globe size={14} className="text-emerald-500" />
|
| 369 |
-
{SUPPORTED_LANGUAGES.find(l => l.code === selectedLanguage)?.label}
|
| 370 |
-
</span>
|
| 371 |
-
<motion.div
|
| 372 |
-
animate={{ rotate: isLangOpen ? 180 : 0 }}
|
| 373 |
-
className="text-gray-400 group-hover:text-emerald-500 transition-colors"
|
| 374 |
>
|
| 375 |
-
<
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
<AnimatePresence>
|
| 380 |
-
{isLangOpen && (
|
| 381 |
<motion.div
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
| 385 |
-
className="absolute z-50 top-full left-0 right-0 bg-white border border-gray-100 rounded-2xl shadow-2xl shadow-emerald-900/10 p-2 overflow-hidden"
|
| 386 |
>
|
| 387 |
-
<
|
| 388 |
-
{SUPPORTED_LANGUAGES.map(lang => (
|
| 389 |
-
<button
|
| 390 |
-
key={lang.code}
|
| 391 |
-
type="button"
|
| 392 |
-
onClick={() => {
|
| 393 |
-
setSelectedLanguage(lang.code);
|
| 394 |
-
setIsLangOpen(false);
|
| 395 |
-
}}
|
| 396 |
-
className={`w-full text-left px-4 py-2.5 rounded-xl text-sm font-bold transition-all flex items-center justify-between
|
| 397 |
-
${selectedLanguage === lang.code
|
| 398 |
-
? 'bg-emerald-50 text-emerald-700'
|
| 399 |
-
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
| 400 |
-
}`}
|
| 401 |
-
>
|
| 402 |
-
{lang.label}
|
| 403 |
-
{selectedLanguage === lang.code && <CheckCircle2 size={14} className="text-emerald-500" />}
|
| 404 |
-
</button>
|
| 405 |
-
))}
|
| 406 |
-
</div>
|
| 407 |
</motion.div>
|
| 408 |
-
|
| 409 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 410 |
</div>
|
| 411 |
-
{selectedLanguage !== 'en' && (
|
| 412 |
-
<motion.span
|
| 413 |
-
initial={{ opacity: 0, x: -10 }}
|
| 414 |
-
animate={{ opacity: 1, x: 0 }}
|
| 415 |
-
className="text-[10px] bg-emerald-500 text-white px-2.5 py-1 rounded-full font-black uppercase tracking-widest shadow-lg shadow-emerald-200"
|
| 416 |
-
>
|
| 417 |
-
Translating
|
| 418 |
-
</motion.span>
|
| 419 |
-
)}
|
| 420 |
-
</div>
|
| 421 |
-
<div className="relative flex-grow flex flex-col">
|
| 422 |
-
<Textarea
|
| 423 |
-
value={issue}
|
| 424 |
-
onChange={(e) => setIssue(e.target.value.substring(0, MAX_CHARS))}
|
| 425 |
-
placeholder="Describe your problem. Example: VPN not connecting error 789"
|
| 426 |
-
className="min-h-[160px] flex-grow rounded-2xl border-gray-100 bg-gray-50/50 focus:bg-white transition-all text-base p-4 resize-none"
|
| 427 |
-
disabled={isLoading}
|
| 428 |
-
/>
|
| 429 |
</div>
|
| 430 |
-
|
| 431 |
|
| 432 |
{/* Premium Voice Visualizer */}
|
| 433 |
{supportsSpeech && (
|
|
@@ -571,7 +720,7 @@ const CreateTicket = () => {
|
|
| 571 |
{/* Primary Submit Button */}
|
| 572 |
<Button
|
| 573 |
type="submit"
|
| 574 |
-
disabled={isLoading || isOcrLoading || isTranslating || !issue.trim()}
|
| 575 |
className="w-full h-14 bg-emerald-500 hover:bg-emerald-600 text-white font-bold text-lg rounded-2xl flex items-center justify-center gap-2 transition-all border-none shadow-emerald-200/50 shadow-lg hover:shadow-xl active:scale-[0.98] disabled:opacity-50"
|
| 576 |
>
|
| 577 |
{(isLoading || isTranslating) ? (
|
|
|
|
| 1 |
import React, { useState, useRef, useEffect } from 'react';
|
| 2 |
+
import { useNavigate, useLocation } from 'react-router-dom';
|
| 3 |
import {
|
| 4 |
Upload,
|
| 5 |
X,
|
|
|
|
| 21 |
import { Button } from "../../components/ui/button";
|
| 22 |
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "../../components/ui/card";
|
| 23 |
import { Textarea } from "../../components/ui/textarea";
|
|
|
|
| 24 |
import { translateText, SUPPORTED_LANGUAGES } from '../../services/translationService';
|
| 25 |
+
import TemplateSelector from '../components/TemplateSelector';
|
| 26 |
+
import TemplateForm from '../components/TemplateForm';
|
| 27 |
+
import TICKET_TEMPLATES, { serializeFieldsToText, getEmptyFormValues } from '../../data/ticketTemplates';
|
| 28 |
|
| 29 |
const CreateTicket = () => {
|
| 30 |
const [issue, setIssue] = useState('');
|
| 31 |
+
const [ticketTitle, setTicketTitle] = useState('');
|
| 32 |
const [file, setFile] = useState(null);
|
| 33 |
const [imagePreview, setImagePreview] = useState(null);
|
| 34 |
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
| 36 |
const [extractedOCR, setExtractedOCR] = useState('');
|
| 37 |
const [isOcrLoading, setIsOcrLoading] = useState(false);
|
| 38 |
const [isListening, setIsListening] = useState(false);
|
| 39 |
+
const isListeningRef = useRef(false);
|
| 40 |
const fileInputRef = useRef(null);
|
| 41 |
const navigate = useNavigate();
|
| 42 |
+
const location = useLocation();
|
| 43 |
const MAX_CHARS = 1000;
|
| 44 |
const supportsSpeech = 'SpeechRecognition' in window || 'webkitSpeechRecognition' in window;
|
| 45 |
const [selectedLanguage, setSelectedLanguage] = useState('en');
|
|
|
|
| 47 |
const [isLangOpen, setIsLangOpen] = useState(false);
|
| 48 |
const langRef = useRef(null);
|
| 49 |
|
| 50 |
+
// ── Smart Template state (v2: two-step highlight → activate flow) ──
|
| 51 |
+
const [highlightedTemplateId, setHighlightedTemplateId] = useState(null); // Card highlighted (preview)
|
| 52 |
+
const [activatedTemplateId, setActivatedTemplateId] = useState(null); // Template committed (form shown)
|
| 53 |
+
const [formValues, setFormValues] = useState({}); // Dynamic form field values
|
| 54 |
+
const [templateUsed, setTemplateUsed] = useState(false);
|
| 55 |
+
const [userModified, setUserModified] = useState(false);
|
| 56 |
+
|
| 57 |
// Voice UI states
|
| 58 |
const [showVoiceModal, setShowVoiceModal] = useState(false);
|
| 59 |
const [voiceTranscript, setVoiceTranscript] = useState('');
|
|
|
|
| 70 |
|
| 71 |
useEffect(() => {
|
| 72 |
return () => {
|
| 73 |
+
isListeningRef.current = false;
|
| 74 |
if (recognitionRef.current) recognitionRef.current.stop();
|
| 75 |
if (audioContextRef.current) audioContextRef.current.close();
|
| 76 |
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
|
| 77 |
};
|
| 78 |
}, []);
|
| 79 |
|
| 80 |
+
// Pre-select template if navigated from QuickActions or Dashboard with a templateId
|
| 81 |
+
// v2: Now highlights the template so user sees the preview first
|
| 82 |
+
useEffect(() => {
|
| 83 |
+
const incomingTemplateId = location.state?.templateId;
|
| 84 |
+
if (incomingTemplateId) {
|
| 85 |
+
const template = TICKET_TEMPLATES.find((t) => t.id === incomingTemplateId);
|
| 86 |
+
if (template) {
|
| 87 |
+
handleHighlightTemplate(template);
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
}, []);
|
| 91 |
+
|
| 92 |
// Close language dropdown on outside click
|
| 93 |
useEffect(() => {
|
| 94 |
const handleClickOutside = (event) => {
|
|
|
|
| 110 |
const processOCR = async (imageFile) => {
|
| 111 |
setIsOcrLoading(true);
|
| 112 |
try {
|
| 113 |
+
const { default: Tesseract } = await import('tesseract.js');
|
| 114 |
const { data: { text } } = await Tesseract.recognize(imageFile, 'eng');
|
| 115 |
setExtractedOCR(text.trim());
|
| 116 |
} catch (err) {
|
|
|
|
| 191 |
console.error("Speech Recognition Error:", event.error);
|
| 192 |
if (event.error !== 'no-speech') {
|
| 193 |
setError(`Microphone error: ${event.error}`);
|
| 194 |
+
stopListening();
|
| 195 |
}
|
| 196 |
};
|
| 197 |
|
| 198 |
recognition.onend = () => {
|
| 199 |
+
if (isListeningRef.current) {
|
| 200 |
+
try {
|
| 201 |
+
recognitionRef.current?.start();
|
| 202 |
+
} catch {
|
| 203 |
+
// start() may throw if already running — safely ignore
|
| 204 |
+
}
|
| 205 |
+
} else {
|
| 206 |
+
if (animationFrameRef.current) {
|
| 207 |
+
cancelAnimationFrame(animationFrameRef.current);
|
| 208 |
+
animationFrameRef.current = null;
|
| 209 |
+
}
|
| 210 |
}
|
| 211 |
};
|
| 212 |
|
|
|
|
| 214 |
recognition.start();
|
| 215 |
|
| 216 |
setIsListening(true);
|
| 217 |
+
isListeningRef.current = true;
|
| 218 |
setShowVoiceModal(true);
|
| 219 |
setVoiceTranscript('');
|
| 220 |
setInterimVoice('');
|
|
|
|
| 228 |
|
| 229 |
const stopListening = () => {
|
| 230 |
setIsListening(false);
|
| 231 |
+
isListeningRef.current = false;
|
| 232 |
if (recognitionRef.current) {
|
| 233 |
recognitionRef.current.stop();
|
| 234 |
recognitionRef.current = null;
|
|
|
|
| 300 |
}
|
| 301 |
};
|
| 302 |
|
| 303 |
+
// ── Smart Template handlers (v2: highlight → activate → dismiss) ──
|
| 304 |
+
|
| 305 |
+
/** Step 1: Highlight a template card (shows preview, does NOT apply) */
|
| 306 |
+
const handleHighlightTemplate = (template) => {
|
| 307 |
+
setHighlightedTemplateId(template ? template.id : null);
|
| 308 |
+
setError('');
|
| 309 |
+
};
|
| 310 |
+
|
| 311 |
+
/** Step 2: User explicitly confirms — activate template and show dynamic form */
|
| 312 |
+
const handleActivateTemplate = (template) => {
|
| 313 |
+
setActivatedTemplateId(template.id);
|
| 314 |
+
setHighlightedTemplateId(null);
|
| 315 |
+
setTicketTitle(template.title);
|
| 316 |
+
setFormValues(getEmptyFormValues(template.fields));
|
| 317 |
+
setIssue(''); // Clear manual textarea content
|
| 318 |
+
setTemplateUsed(true);
|
| 319 |
+
setUserModified(false);
|
| 320 |
+
setError('');
|
| 321 |
+
};
|
| 322 |
+
|
| 323 |
+
/** Dismiss: clear template and restore manual mode */
|
| 324 |
+
const handleDismissTemplate = () => {
|
| 325 |
+
setActivatedTemplateId(null);
|
| 326 |
+
setHighlightedTemplateId(null);
|
| 327 |
+
setTicketTitle('');
|
| 328 |
+
setIssue('');
|
| 329 |
+
setFormValues({});
|
| 330 |
+
setTemplateUsed(false);
|
| 331 |
+
setUserModified(false);
|
| 332 |
+
};
|
| 333 |
+
|
| 334 |
+
/** Handle individual field changes in the dynamic form */
|
| 335 |
+
const handleFormFieldChange = (key, value) => {
|
| 336 |
+
setFormValues((prev) => ({ ...prev, [key]: value }));
|
| 337 |
+
if (templateUsed) setUserModified(true);
|
| 338 |
+
};
|
| 339 |
+
|
| 340 |
const handleAnalyze = async (e) => {
|
| 341 |
e.preventDefault();
|
| 342 |
+
|
| 343 |
+
// ── v2: Determine description text based on active mode ──
|
| 344 |
+
const activeTemplate = TICKET_TEMPLATES.find((t) => t.id === activatedTemplateId);
|
| 345 |
+
let descriptionText = issue; // Default: manual textarea content
|
| 346 |
+
|
| 347 |
+
if (activeTemplate) {
|
| 348 |
+
// Template mode: serialize structured form fields to text
|
| 349 |
+
descriptionText = serializeFieldsToText(activeTemplate.fields, formValues);
|
| 350 |
+
|
| 351 |
+
// Validate required fields
|
| 352 |
+
const missingFields = activeTemplate.fields
|
| 353 |
+
.filter((f) => f.required && !formValues[f.key] && formValues[f.key] !== false)
|
| 354 |
+
.map((f) => f.label);
|
| 355 |
+
if (missingFields.length > 0) {
|
| 356 |
+
setError(`Please fill in required fields: ${missingFields.join(', ')}`);
|
| 357 |
+
return;
|
| 358 |
+
}
|
| 359 |
+
} else {
|
| 360 |
+
// Manual mode: validate textarea
|
| 361 |
+
if (!issue.trim()) {
|
| 362 |
+
setError('Please describe your issue first.');
|
| 363 |
+
return;
|
| 364 |
+
}
|
| 365 |
}
|
| 366 |
|
| 367 |
if (file && !isOcrLoading && !extractedOCR.trim()) {
|
|
|
|
| 373 |
setError('');
|
| 374 |
|
| 375 |
try {
|
| 376 |
+
let textToSubmit = descriptionText;
|
| 377 |
|
| 378 |
// Translate to English if a different language is selected
|
| 379 |
if (selectedLanguage !== 'en') {
|
| 380 |
setIsTranslating(true);
|
| 381 |
+
textToSubmit = await translateText(descriptionText, selectedLanguage, 'en');
|
| 382 |
setIsTranslating(false);
|
| 383 |
}
|
| 384 |
|
| 385 |
+
// If a title was provided, prepend it to the text for richer AI context
|
| 386 |
+
if (ticketTitle.trim()) {
|
| 387 |
+
textToSubmit = `${ticketTitle.trim()}\n\n${textToSubmit}`;
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
let imageBase64 = "";
|
| 391 |
let extractedOCRText = extractedOCR;
|
| 392 |
if (file) {
|
|
|
|
| 403 |
navigate('/ai-processing', {
|
| 404 |
state: {
|
| 405 |
text: textToSubmit,
|
| 406 |
+
original_text: descriptionText,
|
| 407 |
original_language: selectedLanguage,
|
| 408 |
image_base64: imageBase64,
|
| 409 |
+
image_text: extractedOCRText,
|
| 410 |
+
// Smart Template metadata
|
| 411 |
+
template_id: activatedTemplateId || null,
|
| 412 |
+
template_used: templateUsed,
|
| 413 |
+
user_modified: userModified,
|
| 414 |
+
ticket_title: ticketTitle.trim() || null,
|
| 415 |
}
|
| 416 |
});
|
| 417 |
|
|
|
|
| 450 |
|
| 451 |
<CardContent className="p-8 pt-2 flex-grow flex flex-col">
|
| 452 |
<form onSubmit={handleAnalyze} className="space-y-6 flex-grow flex flex-col">
|
| 453 |
+
|
| 454 |
+
{/* ── Smart Ticket Templates (v2: two-step selection) ── */}
|
| 455 |
+
<TemplateSelector
|
| 456 |
+
selectedTemplateId={highlightedTemplateId}
|
| 457 |
+
activatedTemplateId={activatedTemplateId}
|
| 458 |
+
onHighlightTemplate={handleHighlightTemplate}
|
| 459 |
+
onActivateTemplate={handleActivateTemplate}
|
| 460 |
+
onDismissTemplate={handleDismissTemplate}
|
| 461 |
+
hasExistingContent={!!(issue.trim() || ticketTitle.trim())}
|
| 462 |
+
/>
|
| 463 |
+
|
| 464 |
+
{/* Title Field */}
|
| 465 |
+
<div className="space-y-2">
|
| 466 |
+
<label className="text-sm font-bold text-gray-700">Title</label>
|
| 467 |
+
<input
|
| 468 |
+
type="text"
|
| 469 |
+
value={ticketTitle}
|
| 470 |
+
onChange={(e) => {
|
| 471 |
+
setTicketTitle(e.target.value);
|
| 472 |
+
if (templateUsed) setUserModified(true);
|
| 473 |
+
}}
|
| 474 |
+
placeholder="Brief summary of your issue"
|
| 475 |
+
className="w-full rounded-2xl border border-gray-100 bg-gray-50/50 focus:bg-white focus:border-emerald-300 focus:ring-2 focus:ring-emerald-100 transition-all text-base p-4 outline-none"
|
| 476 |
+
disabled={isLoading}
|
| 477 |
+
maxLength={200}
|
| 478 |
+
/>
|
| 479 |
+
</div>
|
| 480 |
+
|
| 481 |
+
{/* ── v2: Conditional rendering — TemplateForm OR manual textarea ── */}
|
| 482 |
+
{activatedTemplateId ? (
|
| 483 |
+
/* Template mode: show structured dynamic form */
|
| 484 |
+
<div className="space-y-2">
|
| 485 |
+
<label className="text-sm font-bold text-gray-700">Fill in the details</label>
|
| 486 |
+
<TemplateForm
|
| 487 |
+
fields={TICKET_TEMPLATES.find(t => t.id === activatedTemplateId)?.fields || []}
|
| 488 |
+
values={formValues}
|
| 489 |
+
onChange={handleFormFieldChange}
|
| 490 |
+
disabled={isLoading}
|
| 491 |
+
/>
|
| 492 |
</div>
|
| 493 |
+
) : (
|
| 494 |
+
/* Manual mode: original textarea with language selector */
|
| 495 |
+
<div className="space-y-2 flex-grow flex flex-col relative">
|
| 496 |
+
<div className="flex items-center justify-between">
|
| 497 |
+
<label className="text-sm font-bold text-gray-700">Describe your issue</label>
|
| 498 |
+
<span className={`text-xs font-semibold ${issue.length >= MAX_CHARS ? 'text-red-500' : 'text-gray-400'}`}>
|
| 499 |
+
{issue.length} / {MAX_CHARS}
|
| 500 |
+
</span>
|
| 501 |
+
</div>
|
| 502 |
|
| 503 |
+
{/* Premium Language Selector */}
|
| 504 |
+
<div className="flex items-center gap-2">
|
| 505 |
+
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider shrink-0">Language:</label>
|
| 506 |
+
<div className="relative flex-1" ref={langRef}>
|
| 507 |
+
<button
|
| 508 |
+
type="button"
|
| 509 |
+
onClick={() => setIsLangOpen(!isLangOpen)}
|
| 510 |
+
className="w-full bg-gray-50 border border-gray-100 rounded-xl px-4 py-2.5 text-sm font-bold text-gray-700 flex items-center justify-between hover:bg-white hover:border-emerald-200 transition-all shadow-sm group"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 511 |
>
|
| 512 |
+
<span className="flex items-center gap-2">
|
| 513 |
+
<Globe size={14} className="text-emerald-500" />
|
| 514 |
+
{SUPPORTED_LANGUAGES.find(l => l.code === selectedLanguage)?.label}
|
| 515 |
+
</span>
|
|
|
|
|
|
|
| 516 |
<motion.div
|
| 517 |
+
animate={{ rotate: isLangOpen ? 180 : 0 }}
|
| 518 |
+
className="text-gray-400 group-hover:text-emerald-500 transition-colors"
|
|
|
|
|
|
|
| 519 |
>
|
| 520 |
+
<ChevronDown size={16} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 521 |
</motion.div>
|
| 522 |
+
</button>
|
| 523 |
+
|
| 524 |
+
<AnimatePresence>
|
| 525 |
+
{isLangOpen && (
|
| 526 |
+
<motion.div
|
| 527 |
+
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
| 528 |
+
animate={{ opacity: 1, y: 5, scale: 1 }}
|
| 529 |
+
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
| 530 |
+
className="absolute z-50 top-full left-0 right-0 bg-white border border-gray-100 rounded-2xl shadow-2xl shadow-emerald-900/10 p-2 overflow-hidden"
|
| 531 |
+
>
|
| 532 |
+
<div className="max-h-[220px] overflow-y-auto custom-scrollbar space-y-1">
|
| 533 |
+
{SUPPORTED_LANGUAGES.map(lang => (
|
| 534 |
+
<button
|
| 535 |
+
key={lang.code}
|
| 536 |
+
type="button"
|
| 537 |
+
onClick={() => {
|
| 538 |
+
setSelectedLanguage(lang.code);
|
| 539 |
+
setIsLangOpen(false);
|
| 540 |
+
}}
|
| 541 |
+
className={`w-full text-left px-4 py-2.5 rounded-xl text-sm font-bold transition-all flex items-center justify-between
|
| 542 |
+
${selectedLanguage === lang.code
|
| 543 |
+
? 'bg-emerald-50 text-emerald-700'
|
| 544 |
+
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
| 545 |
+
}`}
|
| 546 |
+
>
|
| 547 |
+
{lang.label}
|
| 548 |
+
{selectedLanguage === lang.code && <CheckCircle2 size={14} className="text-emerald-500" />}
|
| 549 |
+
</button>
|
| 550 |
+
))}
|
| 551 |
+
</div>
|
| 552 |
+
</motion.div>
|
| 553 |
+
)}
|
| 554 |
+
</AnimatePresence>
|
| 555 |
+
</div>
|
| 556 |
+
{selectedLanguage !== 'en' && (
|
| 557 |
+
<motion.span
|
| 558 |
+
initial={{ opacity: 0, x: -10 }}
|
| 559 |
+
animate={{ opacity: 1, x: 0 }}
|
| 560 |
+
className="text-[10px] bg-emerald-500 text-white px-2.5 py-1 rounded-full font-black uppercase tracking-widest shadow-lg shadow-emerald-200"
|
| 561 |
+
>
|
| 562 |
+
Translating
|
| 563 |
+
</motion.span>
|
| 564 |
+
)}
|
| 565 |
+
</div>
|
| 566 |
+
<div className="relative flex-grow flex flex-col">
|
| 567 |
+
<Textarea
|
| 568 |
+
value={issue}
|
| 569 |
+
onChange={(e) => {
|
| 570 |
+
setIssue(e.target.value.substring(0, MAX_CHARS));
|
| 571 |
+
if (templateUsed) setUserModified(true);
|
| 572 |
+
}}
|
| 573 |
+
placeholder="Describe your problem. Example: VPN not connecting error 789"
|
| 574 |
+
className="min-h-[160px] flex-grow rounded-2xl border-gray-100 bg-gray-50/50 focus:bg-white transition-all text-base p-4 resize-none"
|
| 575 |
+
disabled={isLoading}
|
| 576 |
+
/>
|
| 577 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 578 |
</div>
|
| 579 |
+
)}
|
| 580 |
|
| 581 |
{/* Premium Voice Visualizer */}
|
| 582 |
{supportsSpeech && (
|
|
|
|
| 720 |
{/* Primary Submit Button */}
|
| 721 |
<Button
|
| 722 |
type="submit"
|
| 723 |
+
disabled={isLoading || isOcrLoading || isTranslating || (!activatedTemplateId && !issue.trim())}
|
| 724 |
className="w-full h-14 bg-emerald-500 hover:bg-emerald-600 text-white font-bold text-lg rounded-2xl flex items-center justify-center gap-2 transition-all border-none shadow-emerald-200/50 shadow-lg hover:shadow-xl active:scale-[0.98] disabled:opacity-50"
|
| 725 |
>
|
| 726 |
{(isLoading || isTranslating) ? (
|
Frontend/src/user/pages/Dashboard.jsx
CHANGED
|
@@ -42,9 +42,10 @@ const Dashboard = () => {
|
|
| 42 |
<RecentTickets />
|
| 43 |
</section>
|
| 44 |
|
|
|
|
| 45 |
<footer className="mt-8 text-center">
|
| 46 |
<p className="text-xs text-gray-400 font-medium tracking-tight">
|
| 47 |
-
©
|
| 48 |
</p>
|
| 49 |
</footer>
|
| 50 |
</div>
|
|
|
|
| 42 |
<RecentTickets />
|
| 43 |
</section>
|
| 44 |
|
| 45 |
+
{/* Footer Info */}
|
| 46 |
<footer className="mt-8 text-center">
|
| 47 |
<p className="text-xs text-gray-400 font-medium tracking-tight">
|
| 48 |
+
© 2026 Emerald Helpdesk. All systems operational.
|
| 49 |
</p>
|
| 50 |
</footer>
|
| 51 |
</div>
|
Frontend/src/user/pages/DuplicateDetection.jsx
CHANGED
|
@@ -3,11 +3,17 @@ import { useNavigate } from 'react-router-dom';
|
|
| 3 |
import {
|
| 4 |
FileText, Database, Zap,
|
| 5 |
CheckCircle2, AlertTriangle, ArrowRight,
|
| 6 |
-
Lightbulb, SearchX, TicketCheck, Search
|
| 7 |
} from 'lucide-react';
|
| 8 |
import useTicketStore from "../../store/ticketStore";
|
| 9 |
import { API_CONFIG } from "../../config";
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
// ─── Shimmer skeleton ─────────────────────────────────────────────────────────
|
| 13 |
const Shimmer = ({ className = '' }) => (
|
|
@@ -34,33 +40,6 @@ const DuplicateDetection = () => {
|
|
| 34 |
const [isLoading, setIsLoading] = useState(true);
|
| 35 |
const [activeStep, setActiveStep] = useState(0);
|
| 36 |
const [countdown, setCountdown] = useState(2);
|
| 37 |
-
const [totalCases, setTotalCases] = useState('10,000+');
|
| 38 |
-
|
| 39 |
-
// ─── Animated Step Pipeline (Dynamic) ───────────────────────────────────────────
|
| 40 |
-
const pipelineSteps = [
|
| 41 |
-
{ icon: FileText, label: 'Your Issue', desc: 'Captured & analysed' },
|
| 42 |
-
{ icon: Database, label: 'Case History', desc: `Scanned ${totalCases} cases` },
|
| 43 |
-
{ icon: Zap, label: 'Match Found', desc: 'Similarity calculated' },
|
| 44 |
-
];
|
| 45 |
-
|
| 46 |
-
// Fetch dynamic case count from Supabase database
|
| 47 |
-
useEffect(() => {
|
| 48 |
-
const fetchCaseCount = async () => {
|
| 49 |
-
try {
|
| 50 |
-
if (supabase) {
|
| 51 |
-
const { count, error } = await supabase
|
| 52 |
-
.from('tickets')
|
| 53 |
-
.select('id', { count: 'exact', head: true });
|
| 54 |
-
if (!error && count != null) {
|
| 55 |
-
setTotalCases(count.toLocaleString());
|
| 56 |
-
}
|
| 57 |
-
}
|
| 58 |
-
} catch (err) {
|
| 59 |
-
console.warn("[DuplicateDetection] Failed to fetch live case count fallback to default:", err);
|
| 60 |
-
}
|
| 61 |
-
};
|
| 62 |
-
fetchCaseCount();
|
| 63 |
-
}, []);
|
| 64 |
|
| 65 |
// Loading delay + step animation
|
| 66 |
useEffect(() => {
|
|
@@ -75,6 +54,7 @@ const DuplicateDetection = () => {
|
|
| 75 |
}, [isLoading, aiTicket, navigate]);
|
| 76 |
|
| 77 |
const isDuplicate = aiTicket?.duplicate_ticket?.is_duplicate === true;
|
|
|
|
| 78 |
|
| 79 |
// Auto-redirect when no duplicate
|
| 80 |
useEffect(() => {
|
|
@@ -208,8 +188,45 @@ const DuplicateDetection = () => {
|
|
| 208 |
{/* ── Match / No-match result card ─────────────────────── */}
|
| 209 |
{isDuplicate ? (
|
| 210 |
<div className="bg-white rounded-3xl border border-slate-100 shadow-xl shadow-slate-200/40 overflow-hidden">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
{/* Card header */}
|
| 212 |
-
<div className="px-8 py-6 bg-amber-50 border-b border-amber-100 flex items-start gap-4">
|
| 213 |
<div className="p-2.5 bg-amber-100 rounded-xl shrink-0">
|
| 214 |
<AlertTriangle className="w-5 h-5 text-amber-600" />
|
| 215 |
</div>
|
|
|
|
| 3 |
import {
|
| 4 |
FileText, Database, Zap,
|
| 5 |
CheckCircle2, AlertTriangle, ArrowRight,
|
| 6 |
+
Lightbulb, SearchX, TicketCheck, Search, Bell, Link2
|
| 7 |
} from 'lucide-react';
|
| 8 |
import useTicketStore from "../../store/ticketStore";
|
| 9 |
import { API_CONFIG } from "../../config";
|
| 10 |
+
|
| 11 |
+
// ─── Animated Step Pipeline ───────────────────────────────────────────────────
|
| 12 |
+
const pipelineSteps = [
|
| 13 |
+
{ icon: FileText, label: 'Your Issue', desc: 'Captured & analysed' },
|
| 14 |
+
{ icon: Database, label: 'Case History', desc: 'Scanned 10 000+ cases' },
|
| 15 |
+
{ icon: Zap, label: 'Match Found', desc: 'Similarity calculated' },
|
| 16 |
+
];
|
| 17 |
|
| 18 |
// ─── Shimmer skeleton ─────────────────────────────────────────────────────────
|
| 19 |
const Shimmer = ({ className = '' }) => (
|
|
|
|
| 40 |
const [isLoading, setIsLoading] = useState(true);
|
| 41 |
const [activeStep, setActiveStep] = useState(0);
|
| 42 |
const [countdown, setCountdown] = useState(2);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
// Loading delay + step animation
|
| 45 |
useEffect(() => {
|
|
|
|
| 54 |
}, [isLoading, aiTicket, navigate]);
|
| 55 |
|
| 56 |
const isDuplicate = aiTicket?.duplicate_ticket?.is_duplicate === true;
|
| 57 |
+
const duplicateParentTicketId = aiTicket?.parent_ticket_id || aiTicket?.duplicate_ticket?.parent_ticket_id || aiTicket?.duplicate_ticket?.duplicate_ticket_id;
|
| 58 |
|
| 59 |
// Auto-redirect when no duplicate
|
| 60 |
useEffect(() => {
|
|
|
|
| 188 |
{/* ── Match / No-match result card ─────────────────────── */}
|
| 189 |
{isDuplicate ? (
|
| 190 |
<div className="bg-white rounded-3xl border border-slate-100 shadow-xl shadow-slate-200/40 overflow-hidden">
|
| 191 |
+
<div className="px-8 pt-8">
|
| 192 |
+
<div className="relative overflow-hidden rounded-3xl border border-amber-200 bg-gradient-to-br from-amber-50 via-white to-orange-50 p-6 shadow-[0_20px_60px_-24px_rgba(217,119,6,0.45)]">
|
| 193 |
+
<div className="absolute -right-10 -top-10 h-32 w-32 rounded-full bg-amber-300/20 blur-3xl" />
|
| 194 |
+
<div className="relative flex items-start gap-4">
|
| 195 |
+
<div className="rounded-2xl bg-amber-100 p-3 shadow-sm">
|
| 196 |
+
<Bell className="h-6 w-6 text-amber-700" />
|
| 197 |
+
</div>
|
| 198 |
+
<div className="flex-1">
|
| 199 |
+
<p className="text-[11px] font-black uppercase tracking-[0.22em] text-amber-700">Potential Duplicate</p>
|
| 200 |
+
<h2 className="mt-1 text-xl font-black text-slate-900 tracking-tight">
|
| 201 |
+
A similar issue was recently reported by your teammate.
|
| 202 |
+
</h2>
|
| 203 |
+
<p className="mt-2 max-w-2xl text-sm font-medium leading-6 text-slate-600">
|
| 204 |
+
Would you like to subscribe to updates on the existing ticket instead of creating a duplicate? You can open the parent ticket now or continue anyway if this is a new incident.
|
| 205 |
+
</p>
|
| 206 |
+
<div className="mt-5 flex flex-col gap-3 sm:flex-row">
|
| 207 |
+
{duplicateParentTicketId ? (
|
| 208 |
+
<button
|
| 209 |
+
onClick={() => navigate(`/ticket/${duplicateParentTicketId}`)}
|
| 210 |
+
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-slate-900 px-5 py-3 text-sm font-black uppercase tracking-widest text-white transition-transform hover:scale-[1.01]"
|
| 211 |
+
>
|
| 212 |
+
<Link2 className="h-4 w-4" />
|
| 213 |
+
Open Existing Ticket
|
| 214 |
+
</button>
|
| 215 |
+
) : null}
|
| 216 |
+
<button
|
| 217 |
+
onClick={handleCreateTicket}
|
| 218 |
+
className="inline-flex items-center justify-center gap-2 rounded-2xl border border-amber-200 bg-white px-5 py-3 text-sm font-black uppercase tracking-widest text-amber-900 transition-transform hover:scale-[1.01]"
|
| 219 |
+
>
|
| 220 |
+
Create Anyway
|
| 221 |
+
</button>
|
| 222 |
+
</div>
|
| 223 |
+
</div>
|
| 224 |
+
</div>
|
| 225 |
+
</div>
|
| 226 |
+
</div>
|
| 227 |
+
|
| 228 |
{/* Card header */}
|
| 229 |
+
<div className="px-8 py-6 bg-amber-50 border-b border-amber-100 flex items-start gap-4 mt-8">
|
| 230 |
<div className="p-2.5 bg-amber-100 rounded-xl shrink-0">
|
| 231 |
<AlertTriangle className="w-5 h-5 text-amber-600" />
|
| 232 |
</div>
|
Frontend/src/user/pages/MyTickets.jsx
CHANGED
|
@@ -120,6 +120,14 @@ function MyTickets() {
|
|
| 120 |
return 'text-gray-600';
|
| 121 |
};
|
| 122 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
return (
|
| 124 |
<main className="flex-1 max-w-[1200px] w-full mx-auto px-6 py-10 flex flex-col gap-8">
|
| 125 |
{/* Header section */}
|
|
@@ -317,6 +325,11 @@ function MyTickets() {
|
|
| 317 |
<p className="text-sm font-semibold text-gray-900 truncate group-hover:text-emerald-700 transition-colors">
|
| 318 |
{ticket.summary || ticket.subject || ticket.description || "No subject"}
|
| 319 |
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
</td>
|
| 321 |
<td className="px-6 py-4">
|
| 322 |
<span className="text-sm font-medium text-gray-600 bg-gray-100 px-2.5 py-1 rounded-md">
|
|
|
|
| 120 |
return 'text-gray-600';
|
| 121 |
};
|
| 122 |
|
| 123 |
+
const getTranslationInfo = (ticket) => {
|
| 124 |
+
const t = ticket?.metadata?.translation;
|
| 125 |
+
if (!t?.translated) return null;
|
| 126 |
+
return {
|
| 127 |
+
sourceLanguageName: t.source_language_name || t.source_language || 'Unknown',
|
| 128 |
+
};
|
| 129 |
+
};
|
| 130 |
+
|
| 131 |
return (
|
| 132 |
<main className="flex-1 max-w-[1200px] w-full mx-auto px-6 py-10 flex flex-col gap-8">
|
| 133 |
{/* Header section */}
|
|
|
|
| 325 |
<p className="text-sm font-semibold text-gray-900 truncate group-hover:text-emerald-700 transition-colors">
|
| 326 |
{ticket.summary || ticket.subject || ticket.description || "No subject"}
|
| 327 |
</p>
|
| 328 |
+
{getTranslationInfo(ticket) && (
|
| 329 |
+
<p className="text-[10px] text-slate-500 mt-1">
|
| 330 |
+
Translated from {getTranslationInfo(ticket).sourceLanguageName}
|
| 331 |
+
</p>
|
| 332 |
+
)}
|
| 333 |
</td>
|
| 334 |
<td className="px-6 py-4">
|
| 335 |
<span className="text-sm font-medium text-gray-600 bg-gray-100 px-2.5 py-1 rounded-md">
|
Frontend/src/user/pages/TicketDetail.jsx
CHANGED
|
@@ -22,6 +22,7 @@ const TicketDetail = () => {
|
|
| 22 |
const [isReopening, setIsReopening] = useState(false);
|
| 23 |
const [showCsat, setShowCsat] = useState(false);
|
| 24 |
const [csatHasBeenDismissed, setCsatHasBeenDismissed] = useState(false);
|
|
|
|
| 25 |
|
| 26 |
useEffect(() => {
|
| 27 |
window.scrollTo(0, 0);
|
|
@@ -118,10 +119,14 @@ const TicketDetail = () => {
|
|
| 118 |
}
|
| 119 |
|
| 120 |
// Safely parse arrays and formats
|
| 121 |
-
const entities = ticket.
|
| 122 |
const solutionSteps = Array.isArray(ticket.solution_steps) ? ticket.solution_steps : [];
|
| 123 |
const isAutoResolved = ticket.auto_resolve === true;
|
| 124 |
const confidenceScore = ticket.metadata?.confidence ?? ticket.routing_confidence ?? 0.92;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
|
| 127 |
const handleReopen = async () => {
|
|
@@ -184,6 +189,27 @@ const TicketDetail = () => {
|
|
| 184 |
|
| 185 |
{/* LEFT SIDE (Main Content) */}
|
| 186 |
<div className="lg:col-span-2 flex flex-col gap-6">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
|
| 188 |
{/* Card 1: Ticket Timeline */}
|
| 189 |
<Card className="p-6 sm:p-8 rounded-2xl border border-gray-100 shadow-sm bg-white">
|
|
|
|
| 22 |
const [isReopening, setIsReopening] = useState(false);
|
| 23 |
const [showCsat, setShowCsat] = useState(false);
|
| 24 |
const [csatHasBeenDismissed, setCsatHasBeenDismissed] = useState(false);
|
| 25 |
+
const [showOriginalText, setShowOriginalText] = useState(false);
|
| 26 |
|
| 27 |
useEffect(() => {
|
| 28 |
window.scrollTo(0, 0);
|
|
|
|
| 119 |
}
|
| 120 |
|
| 121 |
// Safely parse arrays and formats
|
| 122 |
+
const entities = ticket.entities || [];
|
| 123 |
const solutionSteps = Array.isArray(ticket.solution_steps) ? ticket.solution_steps : [];
|
| 124 |
const isAutoResolved = ticket.auto_resolve === true;
|
| 125 |
const confidenceScore = ticket.metadata?.confidence ?? ticket.routing_confidence ?? 0.92;
|
| 126 |
+
const translationMeta = ticket.metadata?.translation;
|
| 127 |
+
const originalTextMeta = ticket.metadata?.original_text;
|
| 128 |
+
const isTranslated = Boolean(translationMeta?.translated && originalTextMeta?.description);
|
| 129 |
+
const sourceLanguageName = translationMeta?.source_language_name || translationMeta?.source_language || 'Unknown';
|
| 130 |
|
| 131 |
|
| 132 |
const handleReopen = async () => {
|
|
|
|
| 189 |
|
| 190 |
{/* LEFT SIDE (Main Content) */}
|
| 191 |
<div className="lg:col-span-2 flex flex-col gap-6">
|
| 192 |
+
{isTranslated && (
|
| 193 |
+
<Card className="p-4 rounded-2xl border border-sky-100 bg-sky-50/70 shadow-sm">
|
| 194 |
+
<div className="flex items-center justify-between gap-3">
|
| 195 |
+
<p className="text-sm font-semibold text-sky-900">
|
| 196 |
+
Translated from {sourceLanguageName}
|
| 197 |
+
</p>
|
| 198 |
+
<button
|
| 199 |
+
type="button"
|
| 200 |
+
onClick={() => setShowOriginalText(prev => !prev)}
|
| 201 |
+
className="text-xs font-bold text-sky-700 hover:text-sky-900"
|
| 202 |
+
>
|
| 203 |
+
{showOriginalText ? "View English" : "View Original"}
|
| 204 |
+
</button>
|
| 205 |
+
</div>
|
| 206 |
+
{showOriginalText && (
|
| 207 |
+
<p className="mt-3 text-sm text-slate-700 bg-white border border-sky-100 rounded-lg px-3 py-2">
|
| 208 |
+
{originalTextMeta?.description}
|
| 209 |
+
</p>
|
| 210 |
+
)}
|
| 211 |
+
</Card>
|
| 212 |
+
)}
|
| 213 |
|
| 214 |
{/* Card 1: Ticket Timeline */}
|
| 215 |
<Card className="p-6 sm:p-8 rounded-2xl border border-gray-100 shadow-sm bg-white">
|
Frontend/src/user/pages/TicketTracking.jsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import React, { useEffect, useState, useRef } from 'react';
|
| 2 |
import { useNavigate, useLocation } from 'react-router-dom';
|
| 3 |
import {
|
| 4 |
Activity, CheckCircle2, ShieldCheck, User,
|
|
@@ -20,14 +20,20 @@ const TicketTracking = () => {
|
|
| 20 |
const [error, setError] = useState(null);
|
| 21 |
const [createdTicket, setCreatedTicket] = useState(null);
|
| 22 |
const hasCreated = useRef(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
useEffect(() => {
|
| 24 |
if (!aiTicket) {
|
| 25 |
navigate('/create-ticket');
|
| 26 |
return;
|
| 27 |
}
|
| 28 |
|
| 29 |
-
const resolutionSteps = location.state?.resolutionSteps || [];
|
| 30 |
-
|
| 31 |
const finalizeTracking = async () => {
|
| 32 |
if (hasCreated.current) return;
|
| 33 |
hasCreated.current = true;
|
|
@@ -42,6 +48,7 @@ const TicketTracking = () => {
|
|
| 42 |
user_id: user?.id,
|
| 43 |
subject: aiTicket.summary,
|
| 44 |
description: aiTicket.originalIssue || aiTicket.summary,
|
|
|
|
| 45 |
category: aiTicket.category,
|
| 46 |
subcategory: aiTicket.subcategory,
|
| 47 |
priority: aiTicket.priority,
|
|
@@ -49,11 +56,13 @@ const TicketTracking = () => {
|
|
| 49 |
status: status,
|
| 50 |
auto_resolve: isAutoResolved,
|
| 51 |
is_duplicate: aiTicket.duplicate_ticket?.is_duplicate || false,
|
|
|
|
|
|
|
| 52 |
confidence: aiTicket.confidence,
|
| 53 |
image_url: aiTicket.image_url || null,
|
| 54 |
-
company: profile?.company ||
|
| 55 |
company_id: profile?.company_id || null,
|
| 56 |
-
sla_breach_at: aiTicket.sla_breach_at,
|
| 57 |
metadata: {
|
| 58 |
confidence: aiTicket.confidence,
|
| 59 |
entities: aiTicket.entities,
|
|
@@ -91,7 +100,7 @@ const TicketTracking = () => {
|
|
| 91 |
};
|
| 92 |
|
| 93 |
finalizeTracking();
|
| 94 |
-
}, [aiTicket, addTicket, navigate, user, profile?.company,
|
| 95 |
|
| 96 |
if (!aiTicket) return null;
|
| 97 |
|
|
|
|
| 1 |
+
import React, { useEffect, useState, useRef, useMemo } from 'react';
|
| 2 |
import { useNavigate, useLocation } from 'react-router-dom';
|
| 3 |
import {
|
| 4 |
Activity, CheckCircle2, ShieldCheck, User,
|
|
|
|
| 20 |
const [error, setError] = useState(null);
|
| 21 |
const [createdTicket, setCreatedTicket] = useState(null);
|
| 22 |
const hasCreated = useRef(false);
|
| 23 |
+
const resolutionSteps = useMemo(() => location.state?.resolutionSteps || [], [location.state?.resolutionSteps]);
|
| 24 |
+
|
| 25 |
+
const getSlaBreachAt = (priority = 'Medium') => {
|
| 26 |
+
const hoursMap = { Critical: 2, High: 8, Medium: 24, Low: 72 };
|
| 27 |
+
const slaHours = hoursMap[priority] || 24;
|
| 28 |
+
return new Date(Date.now() + slaHours * 60 * 60 * 1000).toISOString();
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
useEffect(() => {
|
| 32 |
if (!aiTicket) {
|
| 33 |
navigate('/create-ticket');
|
| 34 |
return;
|
| 35 |
}
|
| 36 |
|
|
|
|
|
|
|
| 37 |
const finalizeTracking = async () => {
|
| 38 |
if (hasCreated.current) return;
|
| 39 |
hasCreated.current = true;
|
|
|
|
| 48 |
user_id: user?.id,
|
| 49 |
subject: aiTicket.summary,
|
| 50 |
description: aiTicket.originalIssue || aiTicket.summary,
|
| 51 |
+
original_language: aiTicket.originalLanguage || 'en',
|
| 52 |
category: aiTicket.category,
|
| 53 |
subcategory: aiTicket.subcategory,
|
| 54 |
priority: aiTicket.priority,
|
|
|
|
| 56 |
status: status,
|
| 57 |
auto_resolve: isAutoResolved,
|
| 58 |
is_duplicate: aiTicket.duplicate_ticket?.is_duplicate || false,
|
| 59 |
+
is_potential_duplicate: aiTicket.is_potential_duplicate || aiTicket.duplicate_ticket?.is_potential_duplicate || false,
|
| 60 |
+
parent_ticket_id: aiTicket.parent_ticket_id || aiTicket.duplicate_ticket?.parent_ticket_id || aiTicket.duplicate_ticket?.duplicate_ticket_id || null,
|
| 61 |
confidence: aiTicket.confidence,
|
| 62 |
image_url: aiTicket.image_url || null,
|
| 63 |
+
company: profile?.company || null,
|
| 64 |
company_id: profile?.company_id || null,
|
| 65 |
+
sla_breach_at: aiTicket.sla_breach_at || getSlaBreachAt(aiTicket.priority),
|
| 66 |
metadata: {
|
| 67 |
confidence: aiTicket.confidence,
|
| 68 |
entities: aiTicket.entities,
|
|
|
|
| 100 |
};
|
| 101 |
|
| 102 |
finalizeTracking();
|
| 103 |
+
}, [aiTicket, addTicket, navigate, user, profile?.company, profile?.company_id, resolutionSteps]);
|
| 104 |
|
| 105 |
if (!aiTicket) return null;
|
| 106 |
|
Frontend/vite.config.js
CHANGED
|
@@ -15,6 +15,6 @@ export default defineConfig({
|
|
| 15 |
},
|
| 16 |
},
|
| 17 |
build: {
|
| 18 |
-
sourcemap:
|
| 19 |
}
|
| 20 |
})
|
|
|
|
| 15 |
},
|
| 16 |
},
|
| 17 |
build: {
|
| 18 |
+
sourcemap: 'hidden'
|
| 19 |
}
|
| 20 |
})
|
MobileApp/App.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
| 1 |
import 'react-native-gesture-handler';
|
| 2 |
-
import LogRocket from '@logrocket/react-native';
|
| 3 |
import React, { useEffect, useState } from 'react';
|
| 4 |
import { NavigationContainer } from '@react-navigation/native';
|
| 5 |
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
|
@@ -7,8 +6,8 @@ import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
|
| 7 |
import { StatusBar } from 'expo-status-bar';
|
| 8 |
import { supabase } from './src/lib/supabase';
|
| 9 |
import { COLORS } from './src/styles/theme';
|
| 10 |
-
import { LayoutDashboard, Ticket, User } from 'lucide-react-native';
|
| 11 |
-
import { View, ActivityIndicator } from 'react-native';
|
| 12 |
import AsyncStorage from '@react-native-async-storage/async-storage';
|
| 13 |
import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context';
|
| 14 |
|
|
@@ -34,6 +33,13 @@ import AIProcessingScreen from './src/screens/user/AIProcessingScreen';
|
|
| 34 |
import NotificationsScreen from './src/screens/user/NotificationsScreen';
|
| 35 |
import KnowledgeBaseScreen from './src/screens/user/KnowledgeBaseScreen';
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
const Stack = createNativeStackNavigator();
|
| 38 |
const Tab = createBottomTabNavigator();
|
| 39 |
|
|
@@ -72,6 +78,45 @@ const TabNavigator = () => {
|
|
| 72 |
);
|
| 73 |
};
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
// Inner app that has access to SafeAreaProvider context
|
| 76 |
const AppContent = () => {
|
| 77 |
const insets = useSafeAreaInsets();
|
|
@@ -79,36 +124,95 @@ const AppContent = () => {
|
|
| 79 |
const [loading, setLoading] = useState(true);
|
| 80 |
const [showOnboarding, setShowOnboarding] = useState(null);
|
| 81 |
const [userStatus, setUserStatus] = useState(null); // 'active', 'pending_approval', 'rejected'
|
|
|
|
| 82 |
|
| 83 |
useEffect(() => {
|
| 84 |
-
|
| 85 |
-
LogRocket.init('ky7sla/helpdeskai');
|
| 86 |
|
| 87 |
const initialize = async () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
try {
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
setSession(session);
|
| 91 |
|
|
|
|
| 92 |
if (session?.user) {
|
| 93 |
-
|
| 94 |
-
LogRocket.identify(session.user.id, {
|
| 95 |
-
email: session.user.email,
|
| 96 |
-
name: session.user.user_metadata?.full_name || 'User',
|
| 97 |
-
});
|
| 98 |
-
|
| 99 |
-
const { data } = await supabase
|
| 100 |
.from('profiles')
|
| 101 |
-
.select('status')
|
| 102 |
.eq('id', session.user.id)
|
| 103 |
.single();
|
| 104 |
-
setUserStatus(data?.status || 'active');
|
| 105 |
-
}
|
| 106 |
|
| 107 |
-
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
} catch (e) {
|
| 110 |
-
console.log('
|
| 111 |
} finally {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
setLoading(false);
|
| 113 |
}
|
| 114 |
};
|
|
@@ -118,21 +222,39 @@ const AppContent = () => {
|
|
| 118 |
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (_event, session) => {
|
| 119 |
setSession(session);
|
| 120 |
if (session?.user) {
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
} else {
|
| 135 |
setUserStatus(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
}
|
| 137 |
});
|
| 138 |
|
|
@@ -150,14 +272,56 @@ const AppContent = () => {
|
|
| 150 |
schema: 'public',
|
| 151 |
table: 'profiles',
|
| 152 |
filter: `id=eq.${session.user.id}`,
|
| 153 |
-
}, (payload) => {
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
})
|
| 156 |
.subscribe();
|
| 157 |
|
| 158 |
return () => supabase.removeChannel(channel);
|
| 159 |
}, [session?.user?.id]);
|
| 160 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
if (loading) {
|
| 162 |
return (
|
| 163 |
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#0b1120' }}>
|
|
@@ -170,6 +334,7 @@ const AppContent = () => {
|
|
| 170 |
const isActive = userStatus === 'active';
|
| 171 |
const isPending = userStatus === 'pending_approval';
|
| 172 |
const isRejected = userStatus === 'rejected';
|
|
|
|
| 173 |
|
| 174 |
return (
|
| 175 |
<NotificationProvider topInset={insets.top}>
|
|
@@ -191,13 +356,20 @@ const AppContent = () => {
|
|
| 191 |
) : (
|
| 192 |
// ─── Active user ───
|
| 193 |
<>
|
| 194 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
<Stack.Screen name="CreateTicket" component={CreateTicketScreen} options={{ animation: 'slide_from_bottom' }} />
|
| 196 |
<Stack.Screen name="AIProcessing" component={AIProcessingScreen} options={{ animation: 'slide_from_right' }} />
|
| 197 |
<Stack.Screen name="TicketTracking" component={TicketTrackingScreen} options={{ animation: 'slide_from_right' }} />
|
| 198 |
<Stack.Screen name="TicketDetail" component={TicketDetailScreen} options={{ animation: 'slide_from_right' }} />
|
| 199 |
<Stack.Screen name="Notifications" component={NotificationsScreen} options={{ animation: 'slide_from_right' }} />
|
| 200 |
<Stack.Screen name="KnowledgeBase" component={KnowledgeBaseScreen} options={{ animation: 'slide_from_right' }} />
|
|
|
|
|
|
|
|
|
|
| 201 |
</>
|
| 202 |
)}
|
| 203 |
</Stack.Navigator>
|
|
|
|
| 1 |
import 'react-native-gesture-handler';
|
|
|
|
| 2 |
import React, { useEffect, useState } from 'react';
|
| 3 |
import { NavigationContainer } from '@react-navigation/native';
|
| 4 |
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
|
|
|
| 6 |
import { StatusBar } from 'expo-status-bar';
|
| 7 |
import { supabase } from './src/lib/supabase';
|
| 8 |
import { COLORS } from './src/styles/theme';
|
| 9 |
+
import { LayoutDashboard, Ticket, User, Settings, ShieldAlert, Users } from 'lucide-react-native';
|
| 10 |
+
import { View, ActivityIndicator, Linking } from 'react-native';
|
| 11 |
import AsyncStorage from '@react-native-async-storage/async-storage';
|
| 12 |
import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context';
|
| 13 |
|
|
|
|
| 33 |
import NotificationsScreen from './src/screens/user/NotificationsScreen';
|
| 34 |
import KnowledgeBaseScreen from './src/screens/user/KnowledgeBaseScreen';
|
| 35 |
|
| 36 |
+
// Admin Screens
|
| 37 |
+
import AdminDashboardScreen from './src/screens/admin/AdminDashboardScreen';
|
| 38 |
+
import AdminTicketsScreen from './src/screens/admin/AdminTicketsScreen';
|
| 39 |
+
import AdminTicketDetailScreen from './src/screens/admin/AdminTicketDetailScreen';
|
| 40 |
+
import AdminUsersScreen from './src/screens/admin/AdminUsersScreen';
|
| 41 |
+
import AdminSettingsScreen from './src/screens/admin/AdminSettingsScreen';
|
| 42 |
+
|
| 43 |
const Stack = createNativeStackNavigator();
|
| 44 |
const Tab = createBottomTabNavigator();
|
| 45 |
|
|
|
|
| 78 |
);
|
| 79 |
};
|
| 80 |
|
| 81 |
+
const AdminTabNavigator = () => {
|
| 82 |
+
const insets = useSafeAreaInsets();
|
| 83 |
+
const tabBarHeight = 60 + insets.bottom;
|
| 84 |
+
|
| 85 |
+
return (
|
| 86 |
+
<Tab.Navigator
|
| 87 |
+
screenOptions={({ route }) => ({
|
| 88 |
+
tabBarIcon: ({ color, size }) => {
|
| 89 |
+
if (route.name === 'AdminDashboard') return <LayoutDashboard size={size} color={color} />;
|
| 90 |
+
if (route.name === 'Tickets') return <Ticket size={size} color={color} />;
|
| 91 |
+
if (route.name === 'Users') return <Users size={size} color={color} />;
|
| 92 |
+
if (route.name === 'Settings') return <Settings size={size} color={color} />;
|
| 93 |
+
if (route.name === 'Profile') return <User size={size} color={color} />;
|
| 94 |
+
},
|
| 95 |
+
tabBarActiveTintColor: COLORS.primary,
|
| 96 |
+
tabBarInactiveTintColor: COLORS.textMuted,
|
| 97 |
+
tabBarLabelStyle: { fontSize: 11, fontWeight: '700' },
|
| 98 |
+
tabBarStyle: {
|
| 99 |
+
height: tabBarHeight,
|
| 100 |
+
paddingBottom: insets.bottom + 8,
|
| 101 |
+
paddingTop: 10,
|
| 102 |
+
backgroundColor: '#ffffff',
|
| 103 |
+
borderTopWidth: 1,
|
| 104 |
+
borderTopColor: '#f0f0f0',
|
| 105 |
+
elevation: 0,
|
| 106 |
+
shadowOpacity: 0,
|
| 107 |
+
},
|
| 108 |
+
headerShown: false,
|
| 109 |
+
})}
|
| 110 |
+
>
|
| 111 |
+
<Tab.Screen name="AdminDashboard" component={AdminDashboardScreen} options={{ title: 'Dashboard' }} />
|
| 112 |
+
<Tab.Screen name="Tickets" component={AdminTicketsScreen} />
|
| 113 |
+
<Tab.Screen name="Users" component={AdminUsersScreen} />
|
| 114 |
+
<Tab.Screen name="Settings" component={AdminSettingsScreen} />
|
| 115 |
+
<Tab.Screen name="Profile" component={ProfileScreen} />
|
| 116 |
+
</Tab.Navigator>
|
| 117 |
+
);
|
| 118 |
+
};
|
| 119 |
+
|
| 120 |
// Inner app that has access to SafeAreaProvider context
|
| 121 |
const AppContent = () => {
|
| 122 |
const insets = useSafeAreaInsets();
|
|
|
|
| 124 |
const [loading, setLoading] = useState(true);
|
| 125 |
const [showOnboarding, setShowOnboarding] = useState(null);
|
| 126 |
const [userStatus, setUserStatus] = useState(null); // 'active', 'pending_approval', 'rejected'
|
| 127 |
+
const [userRole, setUserRole] = useState('user'); // 'user', 'admin', 'master_admin'
|
| 128 |
|
| 129 |
useEffect(() => {
|
| 130 |
+
let finished = false;
|
|
|
|
| 131 |
|
| 132 |
const initialize = async () => {
|
| 133 |
+
// Safety timeout wrapper: if anything hangs, force mount after 3 seconds
|
| 134 |
+
const timeoutId = setTimeout(async () => {
|
| 135 |
+
if (!finished) {
|
| 136 |
+
console.log('[AuthInit] Timeout reached (3s). Forcing mount fallback.');
|
| 137 |
+
try {
|
| 138 |
+
const onboardingDone = await AsyncStorage.getItem('@onboarding_complete');
|
| 139 |
+
setShowOnboarding(onboardingDone === null);
|
| 140 |
+
} catch (err) {
|
| 141 |
+
setShowOnboarding(false);
|
| 142 |
+
}
|
| 143 |
+
setLoading(false);
|
| 144 |
+
}
|
| 145 |
+
}, 3000);
|
| 146 |
+
|
| 147 |
try {
|
| 148 |
+
// 1. Instantly load cached status and role from AsyncStorage (stale-while-revalidate pattern)
|
| 149 |
+
const [cachedStatus, cachedRole] = await Promise.all([
|
| 150 |
+
AsyncStorage.getItem('@user_status'),
|
| 151 |
+
AsyncStorage.getItem('@user_role'),
|
| 152 |
+
]);
|
| 153 |
+
|
| 154 |
+
if (cachedStatus) setUserStatus(cachedStatus);
|
| 155 |
+
if (cachedRole) setUserRole(cachedRole);
|
| 156 |
+
|
| 157 |
+
// 2. Fetch session with a 2.5 second timeout wrapper to prevent slow refreshing from locking the app
|
| 158 |
+
const sessionPromise = supabase.auth.getSession();
|
| 159 |
+
const sessionTimeoutPromise = new Promise((_, reject) =>
|
| 160 |
+
setTimeout(() => reject(new Error('Session fetch timed out')), 2500)
|
| 161 |
+
);
|
| 162 |
+
|
| 163 |
+
const { data: { session } } = await Promise.race([sessionPromise, sessionTimeoutPromise]);
|
| 164 |
setSession(session);
|
| 165 |
|
| 166 |
+
// 3. If session is valid, validate/fetch the user profile
|
| 167 |
if (session?.user) {
|
| 168 |
+
const profilePromise = supabase
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
.from('profiles')
|
| 170 |
+
.select('status, role')
|
| 171 |
.eq('id', session.user.id)
|
| 172 |
.single();
|
|
|
|
|
|
|
| 173 |
|
| 174 |
+
const profileTimeoutPromise = new Promise((_, reject) =>
|
| 175 |
+
setTimeout(() => reject(new Error('Profile fetch timed out')), 2000)
|
| 176 |
+
);
|
| 177 |
+
|
| 178 |
+
const { data, error } = await Promise.race([profilePromise, profileTimeoutPromise]);
|
| 179 |
+
|
| 180 |
+
if (error) {
|
| 181 |
+
console.log('[AuthInit] Profile fetch error, validating session:', error.message);
|
| 182 |
+
// Verify if session is still validly active (GetUser forces refresh if expired)
|
| 183 |
+
const { data: userData, error: userError } = await supabase.auth.getUser();
|
| 184 |
+
if (userError) {
|
| 185 |
+
console.log('[AuthInit] Token validation failed. Clearing session.');
|
| 186 |
+
setSession(null);
|
| 187 |
+
await AsyncStorage.removeItem('@user_status');
|
| 188 |
+
await AsyncStorage.removeItem('@user_role');
|
| 189 |
+
} else {
|
| 190 |
+
// Valid token but profiles table is offline; retain cached or default to safe user status
|
| 191 |
+
if (!cachedStatus) setUserStatus('active');
|
| 192 |
+
if (!cachedRole) setUserRole('user');
|
| 193 |
+
}
|
| 194 |
+
} else {
|
| 195 |
+
const status = data?.status || 'active';
|
| 196 |
+
const role = data?.role || 'user';
|
| 197 |
+
setUserStatus(status);
|
| 198 |
+
setUserRole(role);
|
| 199 |
+
await Promise.all([
|
| 200 |
+
AsyncStorage.setItem('@user_status', status),
|
| 201 |
+
AsyncStorage.setItem('@user_role', role),
|
| 202 |
+
]);
|
| 203 |
+
}
|
| 204 |
+
}
|
| 205 |
} catch (e) {
|
| 206 |
+
console.log('[AuthInit] Exception caught during initialization:', e.message || e);
|
| 207 |
} finally {
|
| 208 |
+
finished = true;
|
| 209 |
+
clearTimeout(timeoutId);
|
| 210 |
+
try {
|
| 211 |
+
const onboardingDone = await AsyncStorage.getItem('@onboarding_complete');
|
| 212 |
+
setShowOnboarding(onboardingDone === null);
|
| 213 |
+
} catch (err) {
|
| 214 |
+
setShowOnboarding(false);
|
| 215 |
+
}
|
| 216 |
setLoading(false);
|
| 217 |
}
|
| 218 |
};
|
|
|
|
| 222 |
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (_event, session) => {
|
| 223 |
setSession(session);
|
| 224 |
if (session?.user) {
|
| 225 |
+
try {
|
| 226 |
+
const { data, error } = await supabase
|
| 227 |
+
.from('profiles')
|
| 228 |
+
.select('status, role')
|
| 229 |
+
.eq('id', session.user.id)
|
| 230 |
+
.single();
|
| 231 |
+
|
| 232 |
+
if (error) {
|
| 233 |
+
console.log('[AuthChange] Profile query failed:', error.message);
|
| 234 |
+
setUserStatus('active');
|
| 235 |
+
setUserRole('user');
|
| 236 |
+
} else {
|
| 237 |
+
const status = data?.status || 'active';
|
| 238 |
+
const role = data?.role || 'user';
|
| 239 |
+
setUserStatus(status);
|
| 240 |
+
setUserRole(role);
|
| 241 |
+
await Promise.all([
|
| 242 |
+
AsyncStorage.setItem('@user_status', status),
|
| 243 |
+
AsyncStorage.setItem('@user_role', role),
|
| 244 |
+
]);
|
| 245 |
+
}
|
| 246 |
+
} catch (err) {
|
| 247 |
+
console.warn('[AuthChange] Uncaught exception inside handler:', err);
|
| 248 |
+
setUserStatus('active');
|
| 249 |
+
setUserRole('user');
|
| 250 |
+
}
|
| 251 |
} else {
|
| 252 |
setUserStatus(null);
|
| 253 |
+
setUserRole('user');
|
| 254 |
+
await Promise.all([
|
| 255 |
+
AsyncStorage.removeItem('@user_status'),
|
| 256 |
+
AsyncStorage.removeItem('@user_role'),
|
| 257 |
+
]);
|
| 258 |
}
|
| 259 |
});
|
| 260 |
|
|
|
|
| 272 |
schema: 'public',
|
| 273 |
table: 'profiles',
|
| 274 |
filter: `id=eq.${session.user.id}`,
|
| 275 |
+
}, async (payload) => {
|
| 276 |
+
const status = payload.new.status;
|
| 277 |
+
const role = payload.new.role || 'user';
|
| 278 |
+
setUserStatus(status);
|
| 279 |
+
setUserRole(role);
|
| 280 |
+
await Promise.all([
|
| 281 |
+
AsyncStorage.setItem('@user_status', status),
|
| 282 |
+
AsyncStorage.setItem('@user_role', role),
|
| 283 |
+
]);
|
| 284 |
})
|
| 285 |
.subscribe();
|
| 286 |
|
| 287 |
return () => supabase.removeChannel(channel);
|
| 288 |
}, [session?.user?.id]);
|
| 289 |
|
| 290 |
+
// Deep link listener for magic link / email verification callbacks
|
| 291 |
+
useEffect(() => {
|
| 292 |
+
const handleUrl = async ({ url }) => {
|
| 293 |
+
if (!url) return;
|
| 294 |
+
const hashIndex = url.indexOf('#');
|
| 295 |
+
if (hashIndex === -1) return;
|
| 296 |
+
const hash = url.substring(hashIndex + 1);
|
| 297 |
+
const params = {};
|
| 298 |
+
hash.split('&').forEach(pair => {
|
| 299 |
+
const [key, val] = pair.split('=');
|
| 300 |
+
if (key && val) params[key] = decodeURIComponent(val);
|
| 301 |
+
});
|
| 302 |
+
if (params.access_token && params.refresh_token) {
|
| 303 |
+
try {
|
| 304 |
+
const { data, error } = await supabase.auth.setSession({
|
| 305 |
+
access_token: params.access_token,
|
| 306 |
+
refresh_token: params.refresh_token,
|
| 307 |
+
});
|
| 308 |
+
if (error) {
|
| 309 |
+
console.warn('[DeepLink] Failed to set session:', error.message);
|
| 310 |
+
} else {
|
| 311 |
+
console.log('[DeepLink] Session set successfully for:', data.user?.email);
|
| 312 |
+
}
|
| 313 |
+
} catch (e) {
|
| 314 |
+
console.warn('[DeepLink] Error parsing magic link:', e);
|
| 315 |
+
}
|
| 316 |
+
}
|
| 317 |
+
};
|
| 318 |
+
|
| 319 |
+
const subscription = Linking.addEventListener('url', handleUrl);
|
| 320 |
+
Linking.getInitialURL().then(url => url && handleUrl({ url }));
|
| 321 |
+
|
| 322 |
+
return () => subscription.remove();
|
| 323 |
+
}, []);
|
| 324 |
+
|
| 325 |
if (loading) {
|
| 326 |
return (
|
| 327 |
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#0b1120' }}>
|
|
|
|
| 334 |
const isActive = userStatus === 'active';
|
| 335 |
const isPending = userStatus === 'pending_approval';
|
| 336 |
const isRejected = userStatus === 'rejected';
|
| 337 |
+
const isAdmin = userRole === 'admin' || userRole === 'master_admin';
|
| 338 |
|
| 339 |
return (
|
| 340 |
<NotificationProvider topInset={insets.top}>
|
|
|
|
| 356 |
) : (
|
| 357 |
// ─── Active user ───
|
| 358 |
<>
|
| 359 |
+
{isAdmin ? (
|
| 360 |
+
<Stack.Screen name="MainTabs" component={AdminTabNavigator} />
|
| 361 |
+
) : (
|
| 362 |
+
<Stack.Screen name="MainTabs" component={TabNavigator} />
|
| 363 |
+
)}
|
| 364 |
<Stack.Screen name="CreateTicket" component={CreateTicketScreen} options={{ animation: 'slide_from_bottom' }} />
|
| 365 |
<Stack.Screen name="AIProcessing" component={AIProcessingScreen} options={{ animation: 'slide_from_right' }} />
|
| 366 |
<Stack.Screen name="TicketTracking" component={TicketTrackingScreen} options={{ animation: 'slide_from_right' }} />
|
| 367 |
<Stack.Screen name="TicketDetail" component={TicketDetailScreen} options={{ animation: 'slide_from_right' }} />
|
| 368 |
<Stack.Screen name="Notifications" component={NotificationsScreen} options={{ animation: 'slide_from_right' }} />
|
| 369 |
<Stack.Screen name="KnowledgeBase" component={KnowledgeBaseScreen} options={{ animation: 'slide_from_right' }} />
|
| 370 |
+
|
| 371 |
+
{/* Admin specific screens */}
|
| 372 |
+
<Stack.Screen name="AdminTicketDetail" component={AdminTicketDetailScreen} options={{ animation: 'slide_from_right' }} />
|
| 373 |
</>
|
| 374 |
)}
|
| 375 |
</Stack.Navigator>
|
MobileApp/app.json
CHANGED
|
@@ -12,13 +12,14 @@
|
|
| 12 |
"resizeMode": "contain",
|
| 13 |
"backgroundColor": "#ffffff"
|
| 14 |
},
|
|
|
|
| 15 |
"ios": {
|
| 16 |
"supportsTablet": true
|
| 17 |
},
|
| 18 |
"android": {
|
| 19 |
"package": "com.ritesh.helpdeskai",
|
| 20 |
"adaptiveIcon": {
|
| 21 |
-
"foregroundImage": "./assets/
|
| 22 |
"backgroundColor": "#0b1120"
|
| 23 |
},
|
| 24 |
"edgeToEdgeEnabled": true
|
|
@@ -27,7 +28,6 @@
|
|
| 27 |
"favicon": "./assets/favicon.png"
|
| 28 |
},
|
| 29 |
"plugins": [
|
| 30 |
-
"@logrocket/react-native",
|
| 31 |
[
|
| 32 |
"expo-build-properties",
|
| 33 |
{
|
|
@@ -41,7 +41,9 @@
|
|
| 41 |
"policy": "sdkVersion"
|
| 42 |
},
|
| 43 |
"updates": {
|
| 44 |
-
"url": "https://u.expo.dev/a5830464-58a7-4737-9cc9-e5ecad2e4acf"
|
|
|
|
|
|
|
| 45 |
},
|
| 46 |
"extra": {
|
| 47 |
"eas": {
|
|
|
|
| 12 |
"resizeMode": "contain",
|
| 13 |
"backgroundColor": "#ffffff"
|
| 14 |
},
|
| 15 |
+
"scheme": "helpdeskai",
|
| 16 |
"ios": {
|
| 17 |
"supportsTablet": true
|
| 18 |
},
|
| 19 |
"android": {
|
| 20 |
"package": "com.ritesh.helpdeskai",
|
| 21 |
"adaptiveIcon": {
|
| 22 |
+
"foregroundImage": "./assets/adaptive-icon.png",
|
| 23 |
"backgroundColor": "#0b1120"
|
| 24 |
},
|
| 25 |
"edgeToEdgeEnabled": true
|
|
|
|
| 28 |
"favicon": "./assets/favicon.png"
|
| 29 |
},
|
| 30 |
"plugins": [
|
|
|
|
| 31 |
[
|
| 32 |
"expo-build-properties",
|
| 33 |
{
|
|
|
|
| 41 |
"policy": "sdkVersion"
|
| 42 |
},
|
| 43 |
"updates": {
|
| 44 |
+
"url": "https://u.expo.dev/a5830464-58a7-4737-9cc9-e5ecad2e4acf",
|
| 45 |
+
"checkAutomatically": "ON_LOAD",
|
| 46 |
+
"fallbackToCacheTimeout": 0
|
| 47 |
},
|
| 48 |
"extra": {
|
| 49 |
"eas": {
|
MobileApp/assets/adaptive-icon.png
CHANGED
|
|
Git LFS Details
|
|
|
Git LFS Details
|
MobileApp/assets/icon.png
CHANGED
|
|
Git LFS Details
|
|
|
Git LFS Details
|
MobileApp/assets/logo_v1.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
MobileApp/assets/splash-icon.png
CHANGED
|
|
Git LFS Details
|
|
|
Git LFS Details
|
MobileApp/package-lock.json
CHANGED
|
@@ -8,7 +8,6 @@
|
|
| 8 |
"name": "mobileapp_new",
|
| 9 |
"version": "1.0.0",
|
| 10 |
"dependencies": {
|
| 11 |
-
"@logrocket/react-native": "^2.3.2",
|
| 12 |
"@react-native-async-storage/async-storage": "2.2.0",
|
| 13 |
"@react-navigation/bottom-tabs": "^7.15.10",
|
| 14 |
"@react-navigation/native": "^7.2.2",
|
|
@@ -32,6 +31,7 @@
|
|
| 32 |
"react-native-screens": "~4.16.0",
|
| 33 |
"react-native-svg": "15.12.1",
|
| 34 |
"react-native-url-polyfill": "^3.0.0",
|
|
|
|
| 35 |
"zustand": "^5.0.12"
|
| 36 |
},
|
| 37 |
"devDependencies": {
|
|
@@ -2736,23 +2736,6 @@
|
|
| 2736 |
"@jridgewell/sourcemap-codec": "^1.4.14"
|
| 2737 |
}
|
| 2738 |
},
|
| 2739 |
-
"node_modules/@logrocket/react-native": {
|
| 2740 |
-
"version": "2.3.2",
|
| 2741 |
-
"resolved": "https://registry.npmjs.org/@logrocket/react-native/-/react-native-2.3.2.tgz",
|
| 2742 |
-
"integrity": "sha512-Fi8H3HCTHj6NFcDGurxz/bXZ2A1fTCj6WVocRNmaqDSSIHL63xP01PyXqLT6xdTakO65Mlw7i37WxojLYuzgmQ==",
|
| 2743 |
-
"license": "MIT",
|
| 2744 |
-
"peerDependencies": {
|
| 2745 |
-
"expo": ">=45.0.0",
|
| 2746 |
-
"expo-build-properties": "*",
|
| 2747 |
-
"react": "*",
|
| 2748 |
-
"react-native": ">=0.60.0-rc.0 <1.0.x"
|
| 2749 |
-
},
|
| 2750 |
-
"peerDependenciesMeta": {
|
| 2751 |
-
"expo": {
|
| 2752 |
-
"optional": true
|
| 2753 |
-
}
|
| 2754 |
-
}
|
| 2755 |
-
},
|
| 2756 |
"node_modules/@react-native-async-storage/async-storage": {
|
| 2757 |
"version": "2.2.0",
|
| 2758 |
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
|
|
@@ -8495,6 +8478,32 @@
|
|
| 8495 |
"react-native": "*"
|
| 8496 |
}
|
| 8497 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8498 |
"node_modules/react-native/node_modules/@react-native/virtualized-lists": {
|
| 8499 |
"version": "0.81.5",
|
| 8500 |
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz",
|
|
|
|
| 8 |
"name": "mobileapp_new",
|
| 9 |
"version": "1.0.0",
|
| 10 |
"dependencies": {
|
|
|
|
| 11 |
"@react-native-async-storage/async-storage": "2.2.0",
|
| 12 |
"@react-navigation/bottom-tabs": "^7.15.10",
|
| 13 |
"@react-navigation/native": "^7.2.2",
|
|
|
|
| 31 |
"react-native-screens": "~4.16.0",
|
| 32 |
"react-native-svg": "15.12.1",
|
| 33 |
"react-native-url-polyfill": "^3.0.0",
|
| 34 |
+
"react-native-webview": "^13.15.0",
|
| 35 |
"zustand": "^5.0.12"
|
| 36 |
},
|
| 37 |
"devDependencies": {
|
|
|
|
| 2736 |
"@jridgewell/sourcemap-codec": "^1.4.14"
|
| 2737 |
}
|
| 2738 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2739 |
"node_modules/@react-native-async-storage/async-storage": {
|
| 2740 |
"version": "2.2.0",
|
| 2741 |
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
|
|
|
|
| 8478 |
"react-native": "*"
|
| 8479 |
}
|
| 8480 |
},
|
| 8481 |
+
"node_modules/react-native-webview": {
|
| 8482 |
+
"version": "13.16.1",
|
| 8483 |
+
"resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.16.1.tgz",
|
| 8484 |
+
"integrity": "sha512-If0eHhoEdOYDcHsX+xBFwHMbWBGK1BvGDQDQdVkwtSIXiq1uiqjkpWVP2uQ1as94J0CzvFE9PUNDuhiX0Z6ubw==",
|
| 8485 |
+
"license": "MIT",
|
| 8486 |
+
"dependencies": {
|
| 8487 |
+
"escape-string-regexp": "^4.0.0",
|
| 8488 |
+
"invariant": "2.2.4"
|
| 8489 |
+
},
|
| 8490 |
+
"peerDependencies": {
|
| 8491 |
+
"react": "*",
|
| 8492 |
+
"react-native": "*"
|
| 8493 |
+
}
|
| 8494 |
+
},
|
| 8495 |
+
"node_modules/react-native-webview/node_modules/escape-string-regexp": {
|
| 8496 |
+
"version": "4.0.0",
|
| 8497 |
+
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
| 8498 |
+
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
| 8499 |
+
"license": "MIT",
|
| 8500 |
+
"engines": {
|
| 8501 |
+
"node": ">=10"
|
| 8502 |
+
},
|
| 8503 |
+
"funding": {
|
| 8504 |
+
"url": "https://github.com/sponsors/sindresorhus"
|
| 8505 |
+
}
|
| 8506 |
+
},
|
| 8507 |
"node_modules/react-native/node_modules/@react-native/virtualized-lists": {
|
| 8508 |
"version": "0.81.5",
|
| 8509 |
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz",
|