diff --git a/src/App.tsx b/src/App.tsx index 3334a6a..97fa625 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,9 @@ import React, { lazy, Suspense } from 'react' import { Routes, Route, Navigate } from 'react-router-dom' import { useAuthStore } from '@/store/authStore' import { AppLayout } from '@/components/Layout' +import { AdminLayout } from '@/components/AdminLayout' import { PublicLayout } from '@/components/PublicLayout' +import { ErrorBoundary } from '@/components/ErrorBoundary' import { Spinner } from '@/components/ui' import './App.css' @@ -22,6 +24,16 @@ const AnalyticsPage = lazy(() => import('@/pages/AnalyticsPage').then(m => ({ de const PublicChatPage = lazy(() => import('@/pages/PublicChatPage').then(m => ({ default: m.PublicChatPage }))) const InboxPage = lazy(() => import('@/pages/InboxPage').then(m => ({ default: m.InboxPage }))) const LeadsPage = lazy(() => import('@/pages/LeadsPage').then(m => ({ default: m.LeadsPage }))) +const AppointmentsPage = lazy(() => import('@/pages/AppointmentsPage').then(m => ({ default: m.AppointmentsPage }))) +const CampaignsPage = lazy(() => import('@/pages/CampaignsPage').then(m => ({ default: m.CampaignsPage }))) +const PublicBookingPage = lazy(() => import('@/pages/PublicBookingPage').then(m => ({ default: m.PublicBookingPage }))) + +// Admin pages +const AdminDashboardPage = lazy(() => import('@/pages/admin/AdminDashboardPage').then(m => ({ default: m.AdminDashboardPage }))) +const AdminUsersPage = lazy(() => import('@/pages/admin/AdminUsersPage').then(m => ({ default: m.AdminUsersPage }))) +const AdminChatbotsPage = lazy(() => import('@/pages/admin/AdminChatbotsPage').then(m => ({ default: m.AdminChatbotsPage }))) +const AdminConversationsPage = lazy(() => import('@/pages/admin/AdminConversationsPage').then(m => ({ default: m.AdminConversationsPage }))) +const AdminSystemPage = lazy(() => import('@/pages/admin/AdminSystemPage').then(m => ({ default: m.AdminSystemPage }))) const PageLoader = () => (
@@ -35,6 +47,13 @@ const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => return {children} } +const AdminRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { isAuthenticated, user } = useAuthStore() + if (!isAuthenticated) return + if (!user?.is_admin) return + return {children} +} + const PublicOnlyRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => { const { isAuthenticated } = useAuthStore() if (isAuthenticated) return @@ -50,38 +69,52 @@ const SmartPublicRoute: React.FC<{ children: React.ReactNode }> = ({ children }) } export const App: React.FC = () => ( - }> - - {/* Public - Landing has its own nav */} - } /> + + }> + + {/* Public - Landing has its own nav */} + } /> - {/* Public pages - wrapped in SmartPublicRoute for proper nav */} - } /> - } /> - } /> + {/* Public pages - wrapped in SmartPublicRoute for proper nav */} + } /> + } /> + } /> - {/* Public chat - no auth, no layout */} - } /> + {/* Public chat - no auth, no layout */} + } /> - {/* Auth */} - } /> - } /> - } /> - } /> + {/* Public booking - no auth, no layout */} + } /> - {/* Protected */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + {/* Auth */} + } /> + } /> + } /> + } /> - {/* Fallback */} - } /> - - -) \ No newline at end of file + {/* Protected */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Admin */} + } /> + } /> + } /> + } /> + } /> + + {/* Fallback */} + } /> + + + +) diff --git a/src/components/AdminLayout.tsx b/src/components/AdminLayout.tsx new file mode 100644 index 0000000..0920670 --- /dev/null +++ b/src/components/AdminLayout.tsx @@ -0,0 +1,139 @@ +import React, { useState } from 'react' +import { Link, useLocation, useNavigate } from 'react-router-dom' +import { cn } from '@/lib/utils' +import { useAuthStore } from '@/store/authStore' +import { authAPI } from '@/services/api' +import { + LayoutDashboard, Users, Bot, MessageSquare, Activity, + LogOut, Menu, Shield, X, ChevronRight, +} from 'lucide-react' + +const NAV_ITEMS = [ + { label: 'Overview', href: '/admin', icon: LayoutDashboard, exact: true }, + { label: 'Users', href: '/admin/users', icon: Users }, + { label: 'Chatbots', href: '/admin/chatbots', icon: Bot }, + { label: 'Conversations', href: '/admin/conversations', icon: MessageSquare }, + { label: 'System Health', href: '/admin/system', icon: Activity }, +] + +export const AdminLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { user, logout } = useAuthStore() + const location = useLocation() + const navigate = useNavigate() + const [sidebarOpen, setSidebarOpen] = useState(false) + + const handleLogout = async () => { + try { await authAPI.logout() } catch { /* ignore */ } + logout() + navigate('/login') + } + + const isActive = (item: typeof NAV_ITEMS[0]) => + item.exact ? location.pathname === item.href : location.pathname.startsWith(item.href) + + return ( +
+ {/* Mobile backdrop */} + {sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} + + {/* Sidebar */} + + + {/* Main */} +
+ {/* Mobile header */} +
+ +
+ + Admin Panel +
+
+ +
+ {children} +
+
+
+ ) +} diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..493da31 --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,45 @@ +import React from 'react' + +interface Props { + children: React.ReactNode +} + +interface State { + hasError: boolean + error?: Error +} + +export class ErrorBoundary extends React.Component { + state: State = { hasError: false } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error } + } + + componentDidCatch(error: Error, info: React.ErrorInfo) { + console.error('[ErrorBoundary]', error, info) + } + + render() { + if (this.state.hasError) { + return ( +
+
+ ⚠️ +
+

Something went wrong

+

+ {this.state.error?.message ?? 'An unexpected error occurred.'} +

+ +
+ ) + } + return this.props.children + } +} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 537fb7b..07357b4 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -6,13 +6,16 @@ import { authAPI } from '@/services/api' import { getPlanColor } from '@/lib/utils' import { LayoutDashboard, ShoppingBag, Settings, - LogOut, Menu, Sparkles, BarChart3, Mail, Users + LogOut, Menu, Sparkles, BarChart3, Mail, Users, + Shield, X, CalendarDays, Megaphone, } from 'lucide-react' const NAV_ITEMS = [ { label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard }, { label: 'Inbox', href: '/inbox', icon: Mail }, { label: 'Leads', href: '/leads', icon: Users }, + { label: 'Appointments', href: '/appointments', icon: CalendarDays }, + { label: 'Campaigns', href: '/campaigns', icon: Megaphone }, { label: 'Analytics', href: '/analytics', icon: BarChart3 }, { label: 'Marketplace', href: '/marketplace', icon: ShoppingBag }, { label: 'Settings', href: '/settings', icon: Settings }, @@ -25,101 +28,131 @@ export const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children }) const [sidebarOpen, setSidebarOpen] = useState(false) const handleLogout = async () => { - try { await authAPI.logout() } catch { /* intentionally ignored */ } + try { await authAPI.logout() } catch { /* ignore */ } logout() navigate('/login') } - return ( -
- {/* Mobile backdrop */} - {sidebarOpen && ( -
setSidebarOpen(false)} /> - )} + const initial = user?.email?.charAt(0).toUpperCase() || '?' - {/* Sidebar */} - + + {/* Main */} +
+ {/* Mobile header */} +
+ +
+
+ +
+ Contexta +
+
+ +
+ {children} +
+
) -} \ No newline at end of file +} diff --git a/src/components/Skeletons.tsx b/src/components/Skeletons.tsx new file mode 100644 index 0000000..702d402 --- /dev/null +++ b/src/components/Skeletons.tsx @@ -0,0 +1,65 @@ +import React from 'react' +import { cn } from '@/lib/utils' + +const Pulse: React.FC<{ className?: string }> = ({ className }) => ( +
+) + +export const SkeletonCard: React.FC<{ className?: string }> = ({ className }) => ( +
+
+ +
+ + +
+
+ + +
+ + +
+
+) + +export const SkeletonTable: React.FC<{ rows?: number }> = ({ rows = 5 }) => ( +
+ + {Array.from({ length: rows }).map((_, i) => ( +
+ + + + + +
+ ))} +
+) + +export const SkeletonList: React.FC<{ rows?: number }> = ({ rows = 6 }) => ( +
+ {Array.from({ length: rows }).map((_, i) => ( +
+ +
+ + +
+ +
+ ))} +
+) + +export const SkeletonStatCard: React.FC = () => ( +
+
+ + +
+ + +
+) diff --git a/src/components/ui.tsx b/src/components/ui.tsx index 67e243a..a410a9c 100644 --- a/src/components/ui.tsx +++ b/src/components/ui.tsx @@ -1,5 +1,6 @@ -import React from 'react' +import React, { useEffect, useRef } from 'react' import { cn } from '@/lib/utils' +import { X } from 'lucide-react' // ─── Button ──────────────────────────────────────────────────────────────────── interface ButtonProps extends React.ButtonHTMLAttributes { @@ -11,24 +12,48 @@ interface ButtonProps extends React.ButtonHTMLAttributes { export const Button: React.FC = ({ variant = 'primary', size = 'md', loading, disabled, children, className, ...props }) => { - const base = 'inline-flex items-center justify-center gap-2 font-medium rounded-lg transition-all focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed active:scale-95' + const base = cn( + 'inline-flex items-center justify-center gap-2 font-medium rounded-xl', + 'transition-all duration-200', + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2', + 'disabled:opacity-50 disabled:cursor-not-allowed', + 'active:scale-[0.97] select-none', + ) const variants = { - primary: 'bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500', - secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-400', - outline: 'border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 focus:ring-primary-500', - ghost: 'text-gray-600 hover:bg-gray-100 focus:ring-gray-400', - danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500', + primary: cn( + 'bg-gradient-to-b from-primary-500 to-primary-600 text-white', + 'hover:from-primary-600 hover:to-primary-700', + 'focus-visible:ring-primary-500', + 'shadow-sm shadow-primary-200/60 hover:shadow-md hover:shadow-primary-300/40', + ), + secondary: 'bg-gray-100 text-gray-800 hover:bg-gray-200 focus-visible:ring-gray-400', + outline: cn( + 'border border-gray-200 bg-white text-gray-700', + 'hover:bg-gray-50 hover:border-gray-300', + 'focus-visible:ring-primary-500 shadow-sm', + ), + ghost: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 focus-visible:ring-gray-400', + danger: cn( + 'bg-gradient-to-b from-red-500 to-red-600 text-white', + 'hover:from-red-600 hover:to-red-700', + 'focus-visible:ring-red-500 shadow-sm', + ), + } + const sizes = { + sm: 'px-3 py-1.5 text-xs gap-1.5', + md: 'px-4 py-2 text-sm', + lg: 'px-6 py-2.5 text-base', } - const sizes = { sm: 'px-3 py-1.5 text-sm', md: 'px-4 py-2 text-sm', lg: 'px-6 py-3 text-base' } return (
) } @@ -80,18 +120,27 @@ export const Textarea: React.FC = ({ label, error, hint, classNam const inputId = id || label?.toLowerCase().replace(/\s+/g, '-') return (
- {label && } + {label && ( + + )}