diff --git a/Frontend/src/App.jsx b/Frontend/src/App.jsx index c0b210073..11fb2ee97 100644 --- a/Frontend/src/App.jsx +++ b/Frontend/src/App.jsx @@ -1,97 +1,144 @@ -import { - BrowserRouter, - Routes, - Route, - Navigate, - useLocation -} from "react-router-dom"; -import React, { useEffect } from "react"; -import { AnimatePresence } from "framer-motion"; -import { NotFound } from "./components/ui/not-found-2"; -import useTicketStore from "./store/ticketStore"; -import Toaster from "./components/shared/Toaster"; -import BugReportWidget from "./components/shared/BugReportWidget"; -import useRealtimeNotifications from "./hooks/useRealtimeNotifications"; - -// Auth Components -import Login from "./pages/Login"; -import ForgotPassword from "./pages/ForgotPassword"; -import ResetPassword from "./pages/ResetPassword"; -import Signup from "./pages/Signup"; -import AdminSignup from "./pages/AdminSignup"; -import AdminLobby from "./pages/AdminLobby"; -import UserLobby from "./pages/UserLobby"; -import LandingPage from "./pages/LandingPage"; -import ContactSales from "./pages/ContactSales"; - -// Legacy components -import DuplicateDetection from "./user/pages/DuplicateDetection"; -import AutoResolveChat from "./user/pages/AutoResolveChat"; -import Resolved from "./user/pages/Resolved"; -import TicketTracking from "./user/pages/TicketTracking"; -// Layouts -import UserLayout from "./user/UserLayout"; -import AdminLayout from "./admin/layout/AdminLayout"; - -// User Pages -import Dashboard from "./user/pages/Dashboard"; -import CreateTicket from "./user/pages/CreateTicket"; -import MyTickets from "./user/pages/MyTickets"; -import TicketResult from "./user/pages/TicketResult"; -import Profile from "./user/pages/Profile"; -import TicketDetail from "./user/pages/TicketDetail"; -import TicketProcessing from "./user/pages/AIProcessing"; // Renamed generic import just in case, but keeping AIProcessing -import AIProcessing from "./user/pages/AIProcessing"; -import AIUnderstanding from "./user/pages/AIUnderstanding"; -import Notifications from "./user/pages/Notifications"; -import Help from "./user/pages/Help"; -import DocsPortal from "./docs/pages/DocsPortal"; - -// New Showcase Pages -import ApiReference from "./pages/ApiReference"; -import Changelog from "./pages/Changelog"; -import StatusPage from "./pages/StatusPage"; -import AboutUs from "./pages/AboutUs"; -import Careers from "./pages/Careers"; -import CookiePolicy from "./pages/legal/CookiePolicy"; - -// NEW Admin Pages (Refactored) -import AdminDashboard from "./admin/pages/AdminDashboard"; -import AdminTickets from "./admin/pages/AdminTickets"; -import AdminTicketDetail from "./admin/pages/AdminTicketDetail"; -import AdminUsers from "./admin/pages/AdminUsers"; -import AdminAnalytics from "./admin/pages/AdminAnalytics"; -import AdminProfile from "./admin/pages/AdminProfile"; -import AdminSettings from "./admin/pages/AdminSettings"; -import MasterBugReports from "./master-admin/pages/MasterBugReports"; - -// Feature Pages -import AutoCategorizationFeature from "./pages/features/AutoCategorizationFeature"; -import PriorityDetectionFeature from "./pages/features/PriorityDetectionFeature"; -import SmartResolutionFeature from "./pages/features/SmartResolutionFeature"; - -// Legal Pages -import TermsOfService from "./pages/legal/TermsOfService"; -import PrivacyPolicy from "./pages/legal/PrivacyPolicy"; -import Security from "./pages/legal/Security"; -import AdminProtectedRoute from "./components/shared/AdminProtectedRoute"; -import MasterAdminProtectedRoute from "./components/shared/MasterAdminProtectedRoute"; -import ProtectedRoute from "./components/shared/ProtectedRoute"; -import useAuthStore from "./store/authStore"; -import NotApproved from "./pages/NotApproved"; - -// Master Admin Components -import MasterAdminLogin from "./pages/MasterAdminLogin"; -import MasterAdminLayout from "./master-admin/layout/MasterAdminLayout"; -import MasterAdminDashboard from "./master-admin/pages/MasterAdminDashboard"; -import PendingAdminRequests from "./master-admin/pages/PendingAdminRequests"; -import AllCompanies from "./master-admin/pages/AllCompanies"; -import AllAdmins from "./master-admin/pages/AllAdmins"; - +/** + * App.jsx — Unified React Router configuration for HELPDESK.AI + * Consolidates all routes and fix syntax/duplication issues. + */ + +import React, { lazy, Suspense, useEffect, useState } from 'react'; +import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom'; +import { PageSkeleton, MinimalSkeleton } from './components/ui/page-skeleton'; +import { NotFound } from './components/ui/not-found-2'; +import Toaster from './components/shared/Toaster'; +import BugReportWidget from './components/shared/BugReportWidget'; +import useAuthStore from './store/authStore'; +import useKeyboardShortcuts from './hooks/useKeyboardShortcuts'; +import ShortcutsHelp from './components/shared/ShortcutsHelp'; +import BackToTop from './components/shared/BackToTop'; +import ScrollToTopButton from './components/ScrollToTopButton'; + +// --------------------------------------------------------------------------- +// Eagerly-loaded auth pages +// --------------------------------------------------------------------------- +import Login from './pages/Login'; +import ForgotPassword from './pages/ForgotPassword'; +import ResetPassword from './pages/ResetPassword'; +import Signup from './pages/Signup'; +import AdminSignup from './pages/AdminSignup'; +import LandingPage from './pages/LandingPage'; +import NotApproved from './pages/NotApproved'; +import AuthCallback from './pages/AuthCallback'; + +// Route guards +import AdminProtectedRoute from './components/shared/AdminProtectedRoute'; +import MasterAdminProtectedRoute from './components/shared/MasterAdminProtectedRoute'; +import ProtectedRoute from './components/shared/ProtectedRoute'; + +// --------------------------------------------------------------------------- +// Lazily-loaded layouts +// --------------------------------------------------------------------------- +const UserLayout = lazy(() => import('./user/UserLayout')); +const AdminLayout = lazy(() => import('./admin/layout/AdminLayout')); +const MasterAdminLayout = lazy(() => import('./master-admin/layout/MasterAdminLayout')); + +// --------------------------------------------------------------------------- +// Lazily-loaded pages +// --------------------------------------------------------------------------- +const AdminLobby = lazy(() => import('./pages/AdminLobby')); +const UserLobby = lazy(() => import('./pages/UserLobby')); +const MasterAdminLogin = lazy(() => import('./pages/MasterAdminLogin')); + +const Dashboard = lazy(() => import('./user/pages/Dashboard')); +const CreateTicket = lazy(() => import('./user/pages/CreateTicket')); +const MyTickets = lazy(() => import('./user/pages/MyTickets')); +const TicketResult = lazy(() => import('./user/pages/TicketResult')); +const Profile = lazy(() => import('./user/pages/Profile')); +const TicketDetail = lazy(() => import('./user/pages/TicketDetail')); +const AIProcessing = lazy(() => import('./user/pages/AIProcessing')); +const AIUnderstanding = lazy(() => import('./user/pages/AIUnderstanding')); +const Notifications = lazy(() => import('./user/pages/Notifications')); +const Help = lazy(() => import('./user/pages/Help')); +const DuplicateDetection = lazy(() => import('./user/pages/DuplicateDetection')); +const AutoResolveChat = lazy(() => import('./user/pages/AutoResolveChat')); +const Resolved = lazy(() => import('./user/pages/Resolved')); +const TicketTracking = lazy(() => import('./user/pages/TicketTracking')); + +const AdminDashboard = lazy(() => import('./admin/pages/AdminDashboard')); +const AdminTickets = lazy(() => import('./admin/pages/AdminTickets')); +const AdminTicketDetail = lazy(() => import('./admin/pages/AdminTicketDetail')); +const AdminUsers = lazy(() => import('./admin/pages/AdminUsers')); +const AdminAnalytics = lazy(() => import('./admin/pages/AdminAnalytics')); +const AdminProfile = lazy(() => import('./admin/pages/AdminProfile')); +const AdminSettings = lazy(() => import('./admin/pages/AdminSettings')); +const AdminScorecard = lazy(() => import('./admin/components/AgentScorecard')); +const SLAPage = lazy(() => import('./admin/pages/SLAPage')); + +const MasterAdminDashboard = lazy(() => import('./master-admin/pages/MasterAdminDashboard')); +const AllAdmins = lazy(() => import('./master-admin/pages/AllAdmins')); +const AllCompanies = lazy(() => import('./master-admin/pages/AllCompanies')); +const PendingAdminRequests = lazy(() => import('./master-admin/pages/PendingAdminRequests')); +const MasterBugReports = lazy(() => import('./master-admin/pages/MasterBugReports')); + +const ContactSales = lazy(() => import('./pages/ContactSales')); +const ApiReference = lazy(() => import('./pages/ApiReference')); +const Changelog = lazy(() => import('./pages/Changelog')); +const StatusPage = lazy(() => import('./pages/StatusPage')); +const AboutUs = lazy(() => import('./pages/AboutUs')); +const Careers = lazy(() => import('./pages/Careers')); +const DocsPortal = lazy(() => import('./docs/pages/DocsPortal')); + +const AutoCategorizationFeature = lazy(() => import('./pages/features/AutoCategorizationFeature')); +const PriorityDetectionFeature = lazy(() => import('./pages/features/PriorityDetectionFeature')); +const SmartResolutionFeature = lazy(() => import('./pages/features/SmartResolutionFeature')); + +const TermsOfService = lazy(() => import('./pages/legal/TermsOfService')); +const PrivacyPolicy = lazy(() => import('./pages/legal/PrivacyPolicy')); +const Security = lazy(() => import('./pages/legal/Security')); +const CookiePolicy = lazy(() => import('./pages/legal/CookiePolicy')); + +// Tenant Core Workspace Layout Shells +const UserLayout = lazy(() => import('./user/UserLayout')); +const AdminLayout = lazy(() => import('./admin/layout/AdminLayout')); +const MasterAdminLayout = lazy(() => import('./master-admin/layout/MasterAdminLayout')); + +// User Telemetry Target Node Matrix +const Dashboard = lazy(() => import('./user/pages/Dashboard')); +const CreateTicket = lazy(() => import('./user/pages/CreateTicket')); +const MyTickets = lazy(() => import('./user/pages/MyTickets')); +const TicketResult = lazy(() => import('./user/pages/TicketResult')); +const Profile = lazy(() => import('./user/pages/Profile')); +const TicketDetail = lazy(() => import('./user/pages/TicketDetail')); +const AIProcessing = lazy(() => import('./user/pages/AIProcessing')); +const AIUnderstanding = lazy(() => import('./user/pages/AIUnderstanding')); +const Notifications = lazy(() => import('./user/pages/Notifications')); +const Help = lazy(() => import('./user/pages/Help')); +const DuplicateDetection = lazy(() => import('./user/pages/DuplicateDetection')); +const AutoResolveChat = lazy(() => import('./user/pages/AutoResolveChat')); +const Resolved = lazy(() => import('./user/pages/Resolved')); +const TicketTracking = lazy(() => import('./user/pages/TicketTracking')); + +// Operational Support Admin Nodes +const AdminDashboard = lazy(() => import('./admin/pages/AdminDashboard')); +const AdminTickets = lazy(() => import('./admin/pages/AdminTickets')); +const AdminTicketDetail = lazy(() => import('./admin/pages/AdminTicketDetail')); +const AdminUsers = lazy(() => import('./admin/pages/AdminUsers')); +const AdminAnalytics = lazy(() => import('./admin/pages/AdminAnalytics')); +const AdminProfile = lazy(() => import('./admin/pages/AdminProfile')); +const AdminSettings = lazy(() => import('./admin/pages/AdminSettings')); +const AdminScorecard = lazy(() => import('./admin/pages/AdminScorecard')); +const SLAPage = lazy(() => import('./admin/pages/SLAPage')); + +// Master Root Governance Matrix +const MasterAdminLogin = lazy(() => import('./pages/MasterAdminLogin')); +const MasterAdminDashboard = lazy(() => import('./master-admin/pages/MasterAdminDashboard')); +const PendingAdminRequests = lazy(() => import('./master-admin/pages/PendingAdminRequests')); +const AllCompanies = lazy(() => import('./master-admin/pages/AllCompanies')); +const AllAdmins = lazy(() => import('./master-admin/pages/AllAdmins')); +const MasterBugReports = lazy(() => import('./master-admin/pages/MasterBugReports')); + +// Dynamic Inline Fallbacks +const NotFoundPage = lazy(() => import('./components/ui/not-found-2').then((module) => ({ default: module.NotFound }))); function TitleUpdater() { const location = useLocation(); - useEffect(() => { const path = location.pathname; let title = 'HELPDESK.AI'; @@ -106,10 +153,12 @@ function TitleUpdater() { else if (path.startsWith('/admin/settings')) title = 'Settings | Admin'; // Master Admin Routes else if (path.startsWith('/master-admin/dashboard')) title = 'Master Dashboard'; - else if (path.startsWith('/master-admin/admin-requests')) title = 'Pending Requests | Master Admin'; + else if (path.startsWith('/master-admin/admin-requests')) + title = 'Pending Requests | Master Admin'; else if (path.startsWith('/master-admin/companies')) title = 'Companies | Master Admin'; else if (path.startsWith('/master-admin/all-admins')) title = 'All Admins | Master Admin'; - else if (path.startsWith('/master-admin/bug-reports')) title = 'System Bug Radar | Master Admin'; + else if (path.startsWith('/master-admin/bug-reports')) + title = 'System Bug Radar | Master Admin'; // User Routes else if (path.startsWith('/ticket/')) title = 'Ticket Detail'; else if (path.startsWith('/ai-understanding')) title = 'AI Understanding'; @@ -128,84 +177,146 @@ function TitleUpdater() { else if (path === '/cookie-policy') title = 'Cookie Policy'; // Public / Lobby Routes else if (path === '/login') title = 'Login'; - else if (path === '/signup') title = 'Create Account'; - else if (path === '/admin-signup') title = 'Admin Signup'; - else if (path === '/user-lobby') title = 'User Lobby'; - else if (path === '/admin-lobby') title = 'Admin Lobby'; - else if (path === '/') title = 'Welcome'; - - document.title = title === 'HELPDESK.AI' ? title : `${title} | HELPDESK.AI`; + else if (path === '/signup') title = 'Sign Up'; + document.title = `${title} | HELPDESK.AI`; }, [location]); - return null; } -// Scrolls to top on every route change function ScrollToTop() { const { pathname } = useLocation(); useEffect(() => { - window.scrollTo({ top: 0, behavior: 'instant' }); + window.scrollTo(0, 0); }, [pathname]); return null; } -function AppLayout() { - const { user, profile } = useAuthStore(); +class ErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { hasError: false }; + } + static getDerivedStateFromError() { return { hasError: true }; } + componentDidCatch(error, info) { console.error(error, info); } + render() { + if (this.state.hasError) return ( +
+
+ Something went wrong. Please refresh the page. +
+
+ ); + return this.props.children; + } +} - // Initialize Global Realtime Notifications Listener +function AppContent() { + const { profile } = useAuthStore(); + const { pathname } = useLocation(); + const [showShortcuts, setShowShortcuts] = useState(false); useRealtimeNotifications(); - useEffect(() => { - if (!user) return; - const handleFocus = () => { - useTicketStore.persist.rehydrate(); - }; - - window.addEventListener('focus', handleFocus); - return () => window.removeEventListener('focus', handleFocus); - }, [user]); + const isAdminRoute = pathname.startsWith('/admin'); + const { shortcuts } = useKeyboardShortcuts( + {}, + { + enabled: !isAdminRoute, + role: profile?.role, + onShortcutsHelp: () => setShowShortcuts(true), + } + ); - // ProtectedRoute handles the redirect to /login if user is not present - // but we still need to handle role-based navigation here return ( <> + setShowShortcuts(false)} shortcuts={shortcuts} /> - } /> - } /> - } /> - - {/* --- User Portal --- */} - : - (profile?.role === 'admin' || profile?.role === 'super_admin') ? : - profile?.status === 'pending_approval' ? : - profile?.status === 'rejected' ? : - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + + {/* ── Public / Auth routes ─────────────────────────────────────── */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Lobby Routes */} + }>} /> + }>} /> + + {/* Marketing / Resources */} + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + + {/* Features */} + }>} /> + }>} /> + }>} /> + + {/* Legal */} + }>} /> + }>} /> + }>} /> + }>} /> + + {/* Protected User Routes */} + }> + }>}> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Support legacy paths */} + } /> - {/* --- Admin Portal (Protected) --- */} + {/* Protected Admin Routes */} }> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + }>}> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + {/* Master Admin Routes */} + } /> + }> + }>}> + } /> + } /> + } /> + } /> + } /> + } /> @@ -215,92 +326,66 @@ function AppLayout() { ); } - -function App() { +export default function App() { const { initialize } = useAuthStore(); - - useEffect(() => { - initialize(); + useEffect(() => { + initialize().catch(err => console.error('Auth init failed:', err)); }, [initialize]); + // Handle docs subdomain if applicable const isDocsSubdomain = window.location.hostname.startsWith('docs.'); if (isDocsSubdomain) { return ( + + + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + + + + ); + } + + return ( + - + - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> ); - } return ( - - - - - {/* Public */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - {/* Feature Pages */} - } /> - } /> - } /> - - {/* Legal Pages */} - } /> - } /> - } /> - - {/* Master Admin Portal */} - } /> - - }> - }> - } /> - } /> - } /> - } /> - } /> - - - - {/* Protected */} - }> - } /> - - + + }> + + + + + + + ); } - -export default App; - diff --git a/Frontend/src/admin/components/ShortcutsHelpModal.jsx b/Frontend/src/admin/components/ShortcutsHelpModal.jsx new file mode 100644 index 000000000..bc4553da9 --- /dev/null +++ b/Frontend/src/admin/components/ShortcutsHelpModal.jsx @@ -0,0 +1,93 @@ +import React, { useEffect } from 'react'; +import { X, Keyboard } from 'lucide-react'; +import { Card, CardHeader, CardTitle, CardContent } from '../../components/ui/card'; + +const shortcuts = [ + { keys: 'G + D', label: 'Go to Dashboard' }, + { keys: 'G + T', label: 'Go to Tickets' }, + { keys: 'G + S', label: 'Go to Settings' }, + { keys: 'G + P', label: 'Go to Profile' }, + { keys: 'G + H', label: 'Go to Help' }, + { keys: 'G + U', label: 'Go to Users (admin)' }, + { keys: 'G + A', label: 'Go to Analytics (admin)' }, + { keys: 'Ctrl + F', label: 'Focus search bar' }, + { keys: '?', label: 'Toggle this help' }, + { keys: 'Esc', label: 'Close this help' }, +]; + +const ShortcutsHelpModal = ({ isOpen, onClose, isAdmin = false }) => { + useEffect(() => { + if (!isOpen) return; + const handler = (e) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + const visibleShortcuts = shortcuts.filter((s) => { + if (!isAdmin && (s.label.includes('(admin)'))) return false; + return true; + }); + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ + +
+ + Keyboard Shortcuts +
+ +
+ + +
+ {visibleShortcuts.map((shortcut) => ( +
+ {shortcut.label} + + {shortcut.keys.split(' + ').map((part, i) => ( + + {i > 0 && +} + + {part} + + + ))} + +
+ ))} +
+ +

+ Press ? anytime to toggle this help +

+
+
+
+
+ ); +}; + +export default ShortcutsHelpModal; diff --git a/Frontend/src/admin/layout/AdminLayout.jsx b/Frontend/src/admin/layout/AdminLayout.jsx index cfebcdb35..97d441867 100644 --- a/Frontend/src/admin/layout/AdminLayout.jsx +++ b/Frontend/src/admin/layout/AdminLayout.jsx @@ -3,63 +3,73 @@ import { Outlet } from 'react-router-dom'; import AdminSidebar from '../components/AdminSidebar'; import AdminHeader from '../components/AdminHeader'; import NotificationToast from '../../user/components/NotificationToast'; +import useKeyboardShortcuts from '../../hooks/useKeyboardShortcuts'; +import ShortcutsHelpModal from '../../admin/components/ShortcutsHelpModal'; /** * AdminLayout Component * Master framework for the administrative zone. * Enforces a fixed-sidebar architecture with a centered, high-density content terminal. + * Integrates global keyboard shortcuts (Issue #1172). */ const AdminLayout = () => { - const [isMobileNavOpen, setIsMobileNavOpen] = useState(false); - const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); + const [isMobileNavOpen, setIsMobileNavOpen] = useState(false); + const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); + const { showHelp, setShowHelp } = useKeyboardShortcuts({}, { role: 'admin' }); - return ( -
- {/* Master Navigation Column (Responsive) */} -
- setIsSidebarCollapsed(!isSidebarCollapsed)} /> -
+ return ( +
+ {/* Master Navigation Column (Responsive) */} +
+ setIsSidebarCollapsed(!isSidebarCollapsed)} + /> +
- {/* Viewport Execution Layer */} -
- {/* Global Command Header */} - setIsMobileNavOpen(!isMobileNavOpen)} - isSidebarCollapsed={isSidebarCollapsed} - onToggleSidebar={() => setIsSidebarCollapsed(!isSidebarCollapsed)} - /> + {/* Viewport Execution Layer */} +
+ {/* Global Command Header */} + setIsMobileNavOpen(!isMobileNavOpen)} + isSidebarCollapsed={isSidebarCollapsed} + onToggleSidebar={() => setIsSidebarCollapsed(!isSidebarCollapsed)} + /> - {/* Operational Workspace */} -
- {/* Centered Payload Container */} -
- -
-
-
+ {/* Operational Workspace */} +
+ {/* Centered Payload Container */} +
+ +
+
+
- {/* Real-time System Notifications */} - + {/* Real-time System Notifications */} + - {/* Mobile Nav Overlay (Emergency protocols) */} - {isMobileNavOpen && ( -
setIsMobileNavOpen(false)} - > -
e.stopPropagation()} - > - setIsMobileNavOpen(false)} /> -
-
- )} + {/* Keyboard Shortcuts Help Modal */} + setShowHelp(false)} isAdmin={true} /> + + {/* Mobile Nav Overlay (Emergency protocols) */} + {isMobileNavOpen && ( +
setIsMobileNavOpen(false)} + > +
e.stopPropagation()} + > + setIsMobileNavOpen(false)} /> +
- ); + )} +
+ ); }; export default AdminLayout; diff --git a/Frontend/src/components/shared/ShortcutsHelp.jsx b/Frontend/src/components/shared/ShortcutsHelp.jsx new file mode 100644 index 000000000..718e6530f --- /dev/null +++ b/Frontend/src/components/shared/ShortcutsHelp.jsx @@ -0,0 +1,181 @@ +/** + * Shortcuts Help Modal + * Displays available keyboard shortcuts in a styled overlay. + */ + +import React, { useState, useEffect } from 'react'; +import { formatShortcut, getShortcutDescription } from '../../hooks/useKeyboardShortcuts'; + +const ShortcutsHelp = ({ isOpen, onClose, shortcuts = {} }) => { + const [selectedCategory, setSelectedCategory] = useState('navigation'); + + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = 'unset'; + } + return () => { + document.body.style.overflow = 'unset'; + }; + }, [isOpen]); + + useEffect(() => { + const handleEscape = (e) => { + if (e.key === 'Escape' && isOpen) { + onClose(); + } + }; + window.addEventListener('keydown', handleEscape); + return () => window.removeEventListener('keydown', handleEscape); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + // Categorize shortcuts + const categories = { + navigation: { + title: 'Navigation', + icon: ( + + + + ), + shortcuts: ['g,d', 'g,t', 'g,n', 'g,p', 'g,h', 'g,u', 'g,s', 'g,a'], + }, + actions: { + title: 'Quick Actions', + icon: ( + + + + ), + shortcuts: ['ctrl+f', 'ctrl+k', 'ctrl+/', '?', 'escape'], + }, + }; + + // Filter shortcuts based on what's available + const getAvailableShortcuts = (categoryShortcuts) => { + return categoryShortcuts.filter(s => s in shortcuts); + }; + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+
+
+
+ + + +
+
+

Keyboard Shortcuts

+

Navigate faster with keyboard shortcuts

+
+
+ +
+
+ + {/* Content */} +
+ {/* Category Tabs */} +
+ {Object.entries(categories).map(([key, category]) => { + const available = getAvailableShortcuts(category.shortcuts); + if (available.length === 0) return null; + + return ( + + ); + })} +
+ + {/* Shortcuts List */} +
+ {categories[selectedCategory]?.shortcuts.map(shortcut => { + if (!(shortcut in shortcuts)) return null; + + const description = getShortcutDescription(shortcut); + const formatted = formatShortcut(shortcut); + + return ( +
+ {description} +
+ {formatted.split('').map((char, index) => ( + + {char} + + ))} +
+
+ ); + })} +
+ + {/* Tips */} +
+

💡 Tips

+
    +
  • • Press G then wait for a second, then press the next key
  • +
  • • Shortcuts don't work when typing in input fields
  • +
  • • Press Esc to close any modal
  • +
+
+
+ + {/* Footer */} +
+
+

+ Press Ctrl + / to toggle this help +

+ +
+
+
+
+ ); +}; + +export default ShortcutsHelp; diff --git a/Frontend/src/hooks/keyboard_shortcuts_config.js b/Frontend/src/hooks/keyboard_shortcuts_config.js new file mode 100644 index 000000000..808d0d348 --- /dev/null +++ b/Frontend/src/hooks/keyboard_shortcuts_config.js @@ -0,0 +1,105 @@ +const USER_NAVIGATION_SHORTCUTS = { + 'g,d': '/user/dashboard', + 'g,t': '/user/my-tickets', + 'g,n': '/user/create-ticket', + 'g,p': '/user/profile', + 'g,h': '/user/help', +}; + +const ADMIN_NAVIGATION_SHORTCUTS = { + 'g,d': '/admin/dashboard', + 'g,t': '/admin/tickets', + 'g,u': '/admin/users', + 'g,s': '/admin/settings', + 'g,p': '/admin/profile', + 'g,a': '/admin/analytics', +}; + +const QUICK_ACTION_SHORTCUTS = { + 'ctrl+f': 'search', + 'ctrl+k': 'search', + 'ctrl+/': 'shortcuts-help', + '?': 'shortcuts-help', + escape: 'close-modal', +}; + +export const SHORTCUTS_LEGEND = [ + { combo: 'G + D', description: 'Go to Dashboard' }, + { combo: 'G + T', description: 'Go to Tickets' }, + { combo: 'Ctrl + F', description: 'Focus Search' }, + { combo: 'Ctrl + /', description: 'Show Keyboard Shortcuts' }, +]; + +export const SHORTCUT_GROUPS = [ + { + group: 'Navigation', + shortcuts: [ + { keys: ['G', 'D'], description: 'Go to Dashboard' }, + { keys: ['G', 'T'], description: 'Go to Tickets' }, + { keys: ['G', 'U'], description: 'Go to Users (admin)' }, + { keys: ['G', 'S'], description: 'Go to Settings' }, + { keys: ['G', 'P'], description: 'Go to Profile' }, + { keys: ['G', 'A'], description: 'Go to Analytics (admin)' }, + ], + }, + { + group: 'Quick actions', + shortcuts: [ + { keys: ['Ctrl', 'F'], description: 'Focus search' }, + { keys: ['Ctrl', 'K'], description: 'Focus search' }, + { keys: ['Ctrl', '/'], description: 'Show keyboard shortcuts' }, + { keys: ['?'], description: 'Show keyboard shortcuts' }, + { keys: ['Esc'], description: 'Close modal' }, + ], + }, +]; + +export const SHORTCUT_LIST = SHORTCUT_GROUPS; + +export const normalizeRole = (role) => { + if (role === 'admin' || role === true || role?.isAdmin) return 'admin'; + return 'user'; +}; + +export const createRoleAwareShortcuts = (role = 'user') => ({ + ...(normalizeRole(role) === 'admin' ? ADMIN_NAVIGATION_SHORTCUTS : USER_NAVIGATION_SHORTCUTS), + ...QUICK_ACTION_SHORTCUTS, +}); + +export const normalizeShortcutKey = (key = '') => { + const normalized = String(key).toLowerCase(); + if (normalized === 'esc') return 'escape'; + return normalized; +}; + +export const getShortcutAction = (shortcuts, keys) => { + const combo = keys.map(normalizeShortcutKey).join('+').replace('g+', 'g,'); + return shortcuts[combo] || null; +}; + +export const isShortcutTypingTarget = (target) => { + if (!target) return false; + + const tagName = target.tagName?.toUpperCase(); + return ( + tagName === 'INPUT' || + tagName === 'TEXTAREA' || + tagName === 'SELECT' || + target.isContentEditable === true + ); +}; + +export const getShortcutDescription = (shortcut) => { + const normalizedShortcut = String(shortcut).toLowerCase(); + const match = SHORTCUT_GROUPS + .flatMap((group) => group.shortcuts) + .find( + (entry) => + entry.keys + .map(normalizeShortcutKey) + .join('+') + .replace('g+', 'g,') === normalizedShortcut + ); + + return match?.description || shortcut; +}; diff --git a/Frontend/src/hooks/keyboard_shortcuts_hook.test.jsx b/Frontend/src/hooks/keyboard_shortcuts_hook.test.jsx new file mode 100644 index 000000000..f0098ba22 --- /dev/null +++ b/Frontend/src/hooks/keyboard_shortcuts_hook.test.jsx @@ -0,0 +1,18 @@ +import { fireEvent, renderHook } from '@testing-library/react'; +import useKeyboardShortcuts from './useKeyboardShortcuts'; +import { mockedNavigate } from '../../jest.setup'; + +describe('useKeyboardShortcuts', () => { + beforeEach(() => { + mockedNavigate.mockClear(); + }); + + test('keeps G navigation sequence active when callers pass inline empty custom shortcuts', () => { + renderHook(() => useKeyboardShortcuts({}, { role: 'admin' })); + + fireEvent.keyDown(window, { key: 'g' }); + fireEvent.keyDown(window, { key: 'd' }); + + expect(mockedNavigate).toHaveBeenCalledWith('/admin/dashboard'); + }); +}); diff --git a/Frontend/src/hooks/useKeyboardShortcuts.js b/Frontend/src/hooks/useKeyboardShortcuts.js new file mode 100644 index 000000000..f4a6b2627 --- /dev/null +++ b/Frontend/src/hooks/useKeyboardShortcuts.js @@ -0,0 +1,241 @@ +/** + * Keyboard Shortcuts Hook + * Provides global keyboard shortcuts for rapid navigation. + */ + +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + createRoleAwareShortcuts, + getShortcutDescription, + getShortcutAction, + isShortcutTypingTarget, + SHORTCUTS_LEGEND, + SHORTCUT_GROUPS, + SHORTCUT_LIST, +} from './keyboard_shortcuts_config'; + +const SEQUENCE_TIMEOUT_MS = 1000; +const SEARCH_SELECTOR = [ + '[data-shortcut-search]', + 'input[type="search"]', + 'input[placeholder*="Search" i]', + 'input[aria-label*="Search" i]', +].join(','); + +const focusSearchField = () => { + const searchInput = document.querySelector(SEARCH_SELECTOR); + if (searchInput && typeof searchInput.focus === 'function') { + searchInput.focus(); + return true; + } + return false; +}; + +const closeOpenModal = () => { + const closeButton = document.querySelector( + '[data-modal-close], [aria-label="Close shortcuts help"], [aria-label="Close keyboard shortcuts"], [aria-label="Close shortcuts legend"]' + ); + + if (closeButton && typeof closeButton.click === 'function') { + closeButton.click(); + return true; + } + + return false; +}; + +/** + * Hook to register keyboard shortcuts. + * + * @param {Object} customShortcuts - Additional shortcuts to merge with defaults. + * @param {Object} options - Configuration options. + * @param {boolean|string|Object} options.role - Current role; admin receives admin routes. + * @param {boolean} options.enabled - Whether shortcuts are enabled. + * @param {Function} options.onSearch - Callback for search shortcut. + * @param {Function} options.onShortcutsHelp - Callback for shortcuts help. + */ +export const useKeyboardShortcuts = (customShortcuts = {}, options = {}) => { + const normalizedOptions = customShortcuts?.isAdmin + ? { ...options, role: 'admin' } + : options; + const normalizedCustomShortcuts = customShortcuts?.isAdmin ? {} : customShortcuts; + + const { + enabled = true, + role = 'user', + onSearch = null, + onShortcutsHelp = null, + } = normalizedOptions; + + const navigate = useNavigate(); + const [pendingKey, setPendingKey] = useState(null); + const [showHelp, setShowHelp] = useState(false); + const timeoutRef = useRef(null); + const pendingKeyRef = useRef(null); + const customShortcutsKey = JSON.stringify(normalizedCustomShortcuts || {}); + + const shortcuts = useMemo( + () => ({ + ...createRoleAwareShortcuts(role), + ...(normalizedCustomShortcuts || {}), + }), + [role, customShortcutsKey] + ); + + const clearPendingKey = () => { + pendingKeyRef.current = null; + setPendingKey(null); + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + + useEffect(() => { + if (!enabled) return undefined; + + const handleAction = (action) => { + if (!action) return; + + if (action === 'search') { + if (onSearch) { + onSearch(); + } else { + focusSearchField(); + } + return; + } + + if (action === 'shortcuts-help') { + if (onShortcutsHelp) { + onShortcutsHelp(); + } else { + setShowHelp(true); + } + return; + } + + if (action === 'close-modal') { + setShowHelp(false); + closeOpenModal(); + return; + } + + navigate(action); + }; + + const handleKeyDown = (event) => { + if (isShortcutTypingTarget(event.target)) return; + + const key = event.key.toLowerCase(); + const isCtrl = event.ctrlKey || event.metaKey; + const isAlt = event.altKey; + const isShift = event.shiftKey; + + if (key === 'escape') { + event.preventDefault(); + handleAction(shortcuts.escape); + clearPendingKey(); + return; + } + + if (isCtrl && key === 'f') { + event.preventDefault(); + handleAction(getShortcutAction(shortcuts, ['ctrl', 'f'])); + clearPendingKey(); + return; + } + + if (isCtrl && key === 'k') { + event.preventDefault(); + handleAction(getShortcutAction(shortcuts, ['ctrl', 'k'])); + clearPendingKey(); + return; + } + + if (isCtrl && key === '/') { + event.preventDefault(); + handleAction(getShortcutAction(shortcuts, ['ctrl', '/'])); + clearPendingKey(); + return; + } + + if (!isCtrl && !isAlt && key === '?') { + event.preventDefault(); + handleAction(getShortcutAction(shortcuts, ['?'])); + clearPendingKey(); + return; + } + + if (pendingKeyRef.current === 'g') { + const action = getShortcutAction(shortcuts, ['g', key]); + if (action) { + event.preventDefault(); + handleAction(action); + } + clearPendingKey(); + return; + } + + if (!isCtrl && !isAlt && !isShift && key === 'g') { + event.preventDefault(); + pendingKeyRef.current = 'g'; + setPendingKey('g'); + timeoutRef.current = setTimeout(clearPendingKey, SEQUENCE_TIMEOUT_MS); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + clearPendingKey(); + }; + }, [enabled, navigate, onSearch, onShortcutsHelp, shortcuts]); + + return { + shortcuts, + pendingKey, + showHelp, + setShowHelp, + }; +}; + +/** + * Get shortcut display string. + * @param {string} shortcut - Shortcut key combination. + * @returns {string} - Formatted string for display. + */ +export const formatShortcut = (shortcut) => { + const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; + + return shortcut + .split('+') + .map((key) => { + switch (key.toLowerCase()) { + case 'ctrl': + return isMac ? '⌘' : 'Ctrl'; + case 'shift': + return isMac ? '⇧' : 'Shift'; + case 'alt': + return isMac ? '⌥' : 'Alt'; + case 'g': + return 'G'; + default: + return key.toUpperCase(); + } + }) + .join(isMac ? '' : '+'); +}; + +export { + getShortcutDescription, + SHORTCUTS_LEGEND, + SHORTCUT_GROUPS, + SHORTCUT_LIST, + createRoleAwareShortcuts, + getShortcutAction, + isShortcutTypingTarget, +}; + +export default useKeyboardShortcuts; diff --git a/Frontend/tests/keyboard_shortcuts.test.js b/Frontend/tests/keyboard_shortcuts.test.js new file mode 100644 index 000000000..ce02fcaf8 --- /dev/null +++ b/Frontend/tests/keyboard_shortcuts.test.js @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + createRoleAwareShortcuts, + getShortcutAction, + getShortcutDescription, + isShortcutTypingTarget, + SHORTCUTS_LEGEND, +} from '../src/hooks/keyboard_shortcuts_config.js'; + +test('maps admin dashboard and tickets shortcuts to admin routes', () => { + const shortcuts = createRoleAwareShortcuts('admin'); + + assert.equal(getShortcutAction(shortcuts, ['g', 'd']), '/admin/dashboard'); + assert.equal(getShortcutAction(shortcuts, ['g', 't']), '/admin/tickets'); +}); + +test('maps user dashboard and tickets shortcuts to user routes', () => { + const shortcuts = createRoleAwareShortcuts('user'); + + assert.equal(getShortcutAction(shortcuts, ['g', 'd']), '/user/dashboard'); + assert.equal(getShortcutAction(shortcuts, ['g', 't']), '/user/my-tickets'); +}); + +test('keeps browser-safe quick actions available', () => { + const shortcuts = createRoleAwareShortcuts('admin'); + + assert.equal(getShortcutAction(shortcuts, ['ctrl', 'f']), 'search'); + assert.equal(getShortcutAction(shortcuts, ['ctrl', 'k']), 'search'); + assert.equal(getShortcutAction(shortcuts, ['ctrl', '/']), 'shortcuts-help'); + assert.equal(getShortcutAction(shortcuts, ['?']), 'shortcuts-help'); +}); + +test('does not run global shortcuts while users are typing', () => { + assert.equal(isShortcutTypingTarget({ tagName: 'INPUT' }), true); + assert.equal(isShortcutTypingTarget({ tagName: 'TEXTAREA' }), true); + assert.equal(isShortcutTypingTarget({ tagName: 'DIV', isContentEditable: true }), true); + assert.equal(isShortcutTypingTarget({ tagName: 'BUTTON' }), false); +}); + +test('legend documents required dashboard, tickets, search, and help shortcuts', () => { + const combos = SHORTCUTS_LEGEND.map(({ combo }) => combo); + + assert.deepEqual(combos, ['G + D', 'G + T', 'Ctrl + F', 'Ctrl + /']); +}); + +test('normalizes role objects and boolean admin flags', () => { + assert.equal(createRoleAwareShortcuts({ isAdmin: true })['g,u'], '/admin/users'); + assert.equal(createRoleAwareShortcuts(true)['g,s'], '/admin/settings'); + assert.equal(createRoleAwareShortcuts({ isAdmin: false })['g,n'], '/user/create-ticket'); +}); + +test('describes normalized escape shortcut consistently', () => { + assert.equal(getShortcutDescription('escape'), 'Close modal'); +});